diff --git a/.oxlintrc.json b/.oxlintrc.json index b90dc13..d601c85 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -19,9 +19,7 @@ "react-perf", "jsx-a11y" ], - "jsPlugins": [ - "@shadcn/lint" - ], + "jsPlugins": ["@shadcn/lint"], "rules": { "no-console": "error", "no-unused-vars": [ @@ -50,9 +48,7 @@ "shadcn/no-arbitrary-values": [ "error", { - "allow": [ - "layout" - ] + "allow": ["layout"] } ], "shadcn/no-inline-styles": "error", @@ -60,114 +56,59 @@ "shadcn/no-restyle": [ "error", { - "allow": [ - "layout" - ], + "allow": ["layout"], "contracts": [ { "pattern": "^Sidebar$", - "allow": [ - "layout", - "border-r", - "border-r-0", - "border-border", - "transition-none" - ] + "allow": ["layout", "border-r", "border-r-0", "border-border", "transition-none"] }, { "pattern": "^SidebarHeader$", - "allow": [ - "layout", - "py-4", - "gap-4", - "border-b", - "p-5" - ] + "allow": ["layout", "py-4", "gap-4", "border-b", "p-5"] }, { "pattern": "^SidebarGroup$", - "allow": [ - "layout", - "px-3" - ] + "allow": ["layout", "px-3"] }, { "pattern": "^SidebarFooter$", - "allow": [ - "layout", - "px-3", - "pb-4" - ] + "allow": ["layout", "px-3", "pb-4"] }, { "pattern": "^SidebarMenuButton$", - "allow": [ - "layout", - "p-0" - ] + "allow": ["layout", "p-0"] }, { "pattern": "^Label$", - "allow": [ - "layout", - "gap-2", - "text-sm", - "text-xs", - "text-muted-foreground" - ] + "allow": ["layout", "gap-2", "text-sm", "text-xs", "text-muted-foreground"] }, { "pattern": "^MessageScrollerContent$", - "allow": [ - "layout", - "gap-5", - "p-4" - ] + "allow": ["layout", "gap-5", "p-4"] }, { "pattern": "^(Marker|Collapsible)$", - "allow": [ - "layout", - "text-research-label", - "text-muted-foreground" - ] + "allow": ["layout", "text-research-label", "text-muted-foreground"] }, { "pattern": "^SidebarProvider$", - "allow": [ - "layout", - "bg-vital-bg", - "text-vital-text", - "font-arial" - ] + "allow": ["layout", "bg-vital-bg", "text-vital-text", "font-arial"] }, { "pattern": "^Button$", - "allow": [ - "layout", - "px-2" - ] + "allow": ["layout", "px-2"] }, { "pattern": "^Input$", - "allow": [ - "layout", - "pl-9" - ] + "allow": ["layout", "pl-9"] }, { "pattern": "^SidebarContent$", - "allow": [ - "layout", - "gap-0" - ] + "allow": ["layout", "gap-0"] }, { "pattern": "^Badge$", - "allow": [ - "layout", - "gap-1" - ] + "allow": ["layout", "gap-1"] } ] } @@ -177,17 +118,13 @@ }, "overrides": [ { - "files": [ - "scripts/**" - ], + "files": ["scripts/**"], "rules": { "no-console": "off" } }, { - "files": [ - "src/components/ui/**" - ], + "files": ["src/components/ui/**"], "rules": { "shadcn/no-arbitrary-values": "off", "shadcn/no-restyle": "off", @@ -195,13 +132,8 @@ } }, { - "files": [ - "src/**/*.{test,spec}.{ts,tsx}", - "tests/**/*.{ts,tsx}" - ], - "plugins": [ - "vitest" - ], + "files": ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"], + "plugins": ["vitest"], "rules": { "typescript/unbound-method": "error", "vitest/expect-expect": "error", @@ -211,9 +143,7 @@ } }, { - "files": [ - "e2e/**/*.{ts,tsx}" - ], + "files": ["e2e/**/*.{ts,tsx}"], "rules": { "react/rules-of-hooks": "off" } diff --git a/README.md b/README.md index 9bbc976..8698e81 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ SQLite records across Home, Messages, and MCP. User edits and decisions survive agent updates and reloads. Routines and Journal remain session-only prototypes; Beeper history synchronization and actual message sending remain unconnected. +Home's greeting follows the device's local time: morning from 05:00, afternoon +from 12:00, and evening from 18:00 until 05:00. It refreshes while the page is +open and when returning to the app. + ## Workspaces and current status | Workspace | Route | Current implementation | diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json index 381ec1c..fca86b6 100644 --- a/drizzle/meta/0006_snapshot.json +++ b/drizzle/meta/0006_snapshot.json @@ -151,12 +151,8 @@ "name": "auth_sessions_owner_id_workspace_owner_id_fk", "tableFrom": "auth_sessions", "tableTo": "workspace_owner", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -239,12 +235,8 @@ "name": "connection_credentials_connection_id_connections_id_fk", "tableFrom": "connection_credentials", "tableTo": "connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -252,12 +244,8 @@ "name": "connection_credentials_app_id_oauth_apps_id_fk", "tableFrom": "connection_credentials", "tableTo": "oauth_apps", - "columnsFrom": [ - "app_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["app_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -336,19 +324,12 @@ "indexes": { "connections_account": { "name": "connections_account", - "columns": [ - "platform", - "origin", - "account_id" - ], + "columns": ["platform", "origin", "account_id"], "isUnique": true }, "connections_relay_profile": { "name": "connections_relay_profile", - "columns": [ - "origin", - "relay_profile" - ], + "columns": ["origin", "relay_profile"], "isUnique": true } }, @@ -406,10 +387,7 @@ "indexes": { "deck_columns_position": { "name": "deck_columns_position", - "columns": [ - "deck_id", - "position" - ], + "columns": ["deck_id", "position"], "isUnique": true } }, @@ -418,12 +396,8 @@ "name": "deck_columns_deck_id_decks_id_fk", "tableFrom": "deck_columns", "tableTo": "decks", - "columnsFrom": [ - "deck_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -431,22 +405,15 @@ "name": "deck_columns_connection_id_connections_id_fk", "tableFrom": "deck_columns", "tableTo": "connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": { "deck_columns_deck_id_id_pk": { - "columns": [ - "deck_id", - "id" - ], + "columns": ["deck_id", "id"], "name": "deck_columns_deck_id_id_pk" } }, @@ -603,11 +570,7 @@ "indexes": { "oauth_apps_configuration": { "name": "oauth_apps_configuration", - "columns": [ - "origin", - "redirect_uri", - "scopes" - ], + "columns": ["origin", "redirect_uri", "scopes"], "isUnique": true } }, @@ -675,12 +638,8 @@ "name": "oauth_attempts_app_id_oauth_apps_id_fk", "tableFrom": "oauth_attempts", "tableTo": "oauth_apps", - "columnsFrom": [ - "app_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["app_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -688,12 +647,8 @@ "name": "oauth_attempts_connection_id_connections_id_fk", "tableFrom": "oauth_attempts", "tableTo": "connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -815,4 +770,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json index 839ac05..5b16cf2 100644 --- a/drizzle/meta/0007_snapshot.json +++ b/drizzle/meta/0007_snapshot.json @@ -151,12 +151,8 @@ "name": "auth_sessions_owner_id_workspace_owner_id_fk", "tableFrom": "auth_sessions", "tableTo": "workspace_owner", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -251,10 +247,7 @@ "foreignKeys": {}, "compositePrimaryKeys": { "beeper_chat_checkpoints_target_id_chat_id_pk": { - "columns": [ - "target_id", - "chat_id" - ], + "columns": ["target_id", "chat_id"], "name": "beeper_chat_checkpoints_target_id_chat_id_pk" } }, @@ -297,11 +290,7 @@ "foreignKeys": {}, "compositePrimaryKeys": { "beeper_seen_messages_target_id_chat_id_message_id_pk": { - "columns": [ - "target_id", - "chat_id", - "message_id" - ], + "columns": ["target_id", "chat_id", "message_id"], "name": "beeper_seen_messages_target_id_chat_id_message_id_pk" } }, @@ -346,12 +335,8 @@ "name": "connection_credentials_connection_id_connections_id_fk", "tableFrom": "connection_credentials", "tableTo": "connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -359,12 +344,8 @@ "name": "connection_credentials_app_id_oauth_apps_id_fk", "tableFrom": "connection_credentials", "tableTo": "oauth_apps", - "columnsFrom": [ - "app_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["app_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -443,19 +424,12 @@ "indexes": { "connections_account": { "name": "connections_account", - "columns": [ - "platform", - "origin", - "account_id" - ], + "columns": ["platform", "origin", "account_id"], "isUnique": true }, "connections_relay_profile": { "name": "connections_relay_profile", - "columns": [ - "origin", - "relay_profile" - ], + "columns": ["origin", "relay_profile"], "isUnique": true } }, @@ -513,10 +487,7 @@ "indexes": { "deck_columns_position": { "name": "deck_columns_position", - "columns": [ - "deck_id", - "position" - ], + "columns": ["deck_id", "position"], "isUnique": true } }, @@ -525,12 +496,8 @@ "name": "deck_columns_deck_id_decks_id_fk", "tableFrom": "deck_columns", "tableTo": "decks", - "columnsFrom": [ - "deck_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -538,22 +505,15 @@ "name": "deck_columns_connection_id_connections_id_fk", "tableFrom": "deck_columns", "tableTo": "connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": { "deck_columns_deck_id_id_pk": { - "columns": [ - "deck_id", - "id" - ], + "columns": ["deck_id", "id"], "name": "deck_columns_deck_id_id_pk" } }, @@ -732,17 +692,12 @@ "indexes": { "mcp_event_outbox_identity": { "name": "mcp_event_outbox_identity", - "columns": [ - "subscription_id", - "event_id" - ], + "columns": ["subscription_id", "event_id"], "isUnique": true }, "mcp_event_outbox_due": { "name": "mcp_event_outbox_due", - "columns": [ - "next_attempt_at" - ], + "columns": ["next_attempt_at"], "isUnique": false } }, @@ -751,12 +706,8 @@ "name": "mcp_event_outbox_subscription_id_mcp_event_subscriptions_id_fk", "tableFrom": "mcp_event_outbox", "tableTo": "mcp_event_subscriptions", - "columnsFrom": [ - "subscription_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -915,11 +866,7 @@ "indexes": { "oauth_apps_configuration": { "name": "oauth_apps_configuration", - "columns": [ - "origin", - "redirect_uri", - "scopes" - ], + "columns": ["origin", "redirect_uri", "scopes"], "isUnique": true } }, @@ -987,12 +934,8 @@ "name": "oauth_attempts_app_id_oauth_apps_id_fk", "tableFrom": "oauth_attempts", "tableTo": "oauth_apps", - "columnsFrom": [ - "app_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["app_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1000,12 +943,8 @@ "name": "oauth_attempts_connection_id_connections_id_fk", "tableFrom": "oauth_attempts", "tableTo": "connections", - "columnsFrom": [ - "connection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1127,4 +1066,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3ea47f9..234d40b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -59,4 +59,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/e2e/integrations/inbox.test.ts b/e2e/integrations/inbox.test.ts index 9a47f21..2eb25e4 100644 --- a/e2e/integrations/inbox.test.ts +++ b/e2e/integrations/inbox.test.ts @@ -39,6 +39,7 @@ test("recovers from empty search and shares navigation with Research", async ({ await expect(readerPage.editInterestsButton).toHaveCount(0); await workspacePage.navigateTo("Research"); await expect(page).toHaveURL(/\/deck$/); + await expect(workspacePage.researchReady).toBeEnabled(); await workspacePage.navigateTo("Reader"); await expect(page).toHaveURL(/\/inbox$/); await expect(readerPage.rows).toHaveCount(9); diff --git a/e2e/models/HomePage.ts b/e2e/models/HomePage.ts index 5e8815f..d27ed4c 100644 --- a/e2e/models/HomePage.ts +++ b/e2e/models/HomePage.ts @@ -32,7 +32,7 @@ export class HomePage { return this.page.getByRole("button", { name: `Record weight Recorded ยท ${weight} kg` }); } greeting(name: string) { - return this.page.getByRole("heading", { name: `Good morning, ${name}` }); + return this.page.getByRole("heading", { level: 1 }).filter({ hasText: `, ${name}` }); } async goTo(search = "") { await this.page.goto(`/${search}`); diff --git a/knip.ts b/knip.ts index 32d4166..e882ce1 100644 --- a/knip.ts +++ b/knip.ts @@ -1,6 +1,12 @@ import type { KnipConfig } from "knip"; export default { - entry: ["playwright.config.ts", "e2e/**/*.test.ts", "e2e/mock-relay.mjs"], + entry: [ + "playwright.config.ts", + "e2e/**/*.{test,spec}.ts", + "e2e/mock-relay.mjs", + "public/sw.js", + "scripts/events-worker.ts", + ], playwright: false, } satisfies KnipConfig; diff --git a/src/components/workspace-navigation.tsx b/src/components/workspace-navigation.tsx index b9f3f81..cb8cdf6 100644 --- a/src/components/workspace-navigation.tsx +++ b/src/components/workspace-navigation.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation, useRouteContext } from "@tanstack/react-router"; +import { Link, useHydrated, useLocation, useRouteContext } from "@tanstack/react-router"; import { Activity, House, Inbox, Layers3, MessagesSquare, NotebookPen, Search } from "lucide-react"; import type { ReactNode } from "react"; import { Avatar, AvatarFallback } from "#/components/ui/avatar"; @@ -29,6 +29,7 @@ export function WorkspaceNavigation({ hasTemporaryDecks, }: WorkspaceNavigationProps) { const { auth } = useRouteContext({ from: "__root__" }); + const hydrated = useHydrated(); const [search, updateSearch] = useUrlState(); const managingAccounts = search.accounts; const setManagingAccounts = (open: boolean) => updateSearch({ accounts: open || undefined }); @@ -116,7 +117,9 @@ export function WorkspaceNavigation({ + } tooltip={{ children: "Manage connected accounts", hidden: false }} className="size-10 justify-center p-0" onClick={() => setManagingAccounts(true)} diff --git a/src/features/activities/model.ts b/src/features/activities/model.ts index feb76d5..321952f 100644 --- a/src/features/activities/model.ts +++ b/src/features/activities/model.ts @@ -9,7 +9,7 @@ const required = (max: number) => .max(max) .refine((value) => value.trim().length > 0); const section = z.enum(["focus", "optional"]); -export const activityState = z.enum([ +const activityState = z.enum([ "available", "inProgress", "waiting", @@ -48,7 +48,7 @@ const base = { recommendation: z.strictObject({ section, reason: z.string().max(2000).optional() }).optional(), sources: z.array(z.discriminatedUnion("kind", [messageSource, lifeSource])).max(20), }; -export const activityProposal = z +const activityProposal = z .discriminatedUnion("kind", [ z.strictObject({ ...base, kind: z.literal("task") }), z.strictObject({ @@ -89,7 +89,7 @@ export const publishActivitiesInput = z new Set(value.activities.map((activity) => activity.id)).size === value.activities.length, "Activity IDs must be unique.", ); -export const activityOverrides = z.strictObject({ +const activityOverrides = z.strictObject({ title: required(300).optional(), nextAction: required(2000).optional(), detail: z.string().max(8000).optional(), diff --git a/src/features/home/greeting.test.tsx b/src/features/home/greeting.test.tsx new file mode 100644 index 0000000..ee41102 --- /dev/null +++ b/src/features/home/greeting.test.tsx @@ -0,0 +1,43 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Greeting } from "./greeting"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +it.each([ + [0, "Good evening"], + [4, "Good evening"], + [5, "Good morning"], + [11, "Good morning"], + [12, "Good afternoon"], + [17, "Good afternoon"], + [18, "Good evening"], + [23, "Good evening"], +])("greets using the device's local hour %i", (hour, greeting) => { + vi.setSystemTime(new Date(2026, 9, 8, hour)); + render(); + expect(screen.getByText(`${greeting}, Yuta`)).toBeVisible(); +}); + +it("updates when an open page crosses noon", async () => { + vi.setSystemTime(new Date(2026, 9, 8, 11, 59, 50)); + render(); + expect(screen.getByText("Good morning, Yuta")).toBeVisible(); + await act(() => vi.advanceTimersByTime(30_000)); + expect(screen.getByText("Good afternoon, Yuta")).toBeVisible(); +}); + +it("refreshes after returning to a suspended page", () => { + vi.setSystemTime(new Date(2026, 9, 8, 10)); + render(); + vi.setSystemTime(new Date(2026, 9, 8, 20)); + fireEvent(document, new Event("visibilitychange")); + expect(screen.getByText("Good evening, Yuta")).toBeVisible(); +}); + +it("does not use the server's timezone in the initial HTML", () => { + vi.setSystemTime(new Date(2026, 9, 8, 10)); + expect(renderToString()).toContain("Hello"); +}); diff --git a/src/features/home/greeting.tsx b/src/features/home/greeting.tsx new file mode 100644 index 0000000..99d41cb --- /dev/null +++ b/src/features/home/greeting.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; + +function currentGreeting() { + const hour = new Date().getHours(); + if (hour >= 5 && hour < 12) return "Good morning"; + if (hour >= 12 && hour < 18) return "Good afternoon"; + return "Good evening"; +} + +export function Greeting({ name }: { name: string | undefined }) { + // The server cannot know the device's timezone. Keep hydration consistent. + const [greeting, setGreeting] = useState("Hello"); + useEffect(() => { + const update = () => setGreeting(currentGreeting()); + update(); + const interval = window.setInterval(update, 30_000); + document.addEventListener("visibilitychange", update); + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", update); + }; + }, []); + return ( + <> + {greeting}, {name} + + ); +} diff --git a/src/features/home/home-page.tsx b/src/features/home/home-page.tsx index 6896586..0265cc1 100644 --- a/src/features/home/home-page.tsx +++ b/src/features/home/home-page.tsx @@ -13,6 +13,7 @@ import { useActivities } from "#/features/activities/activity-state"; import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state"; import { DailyBrief } from "./daily-brief"; import { ExecutionWorkbench } from "./execution-workbench"; +import { Greeting } from "./greeting"; import { type HomeAction, interpretAction } from "./interpret-action"; export function HomePage() { @@ -191,7 +192,7 @@ export function HomePage() {

Today

- Good morning, {auth.owner?.name} +

diff --git a/src/features/mcp-events/events.server.ts b/src/features/mcp-events/events.server.ts index 1df1497..f7f14be 100644 --- a/src/features/mcp-events/events.server.ts +++ b/src/features/mcp-events/events.server.ts @@ -13,7 +13,7 @@ import { type WebhookSender, } from "./webhook.server"; -export const BEEPER_MESSAGE_EVENT = "beeper.message.received"; +const BEEPER_MESSAGE_EVENT = "beeper.message.received"; // Explicit single-owner prototype trust boundary: callers can reach the private workspace MCP tunnel. // This is a storage namespace, not an authenticated user identity. const OWNER = "private-workspace"; diff --git a/src/features/mcp-events/webhook.server.ts b/src/features/mcp-events/webhook.server.ts index bff07f0..d67e300 100644 --- a/src/features/mcp-events/webhook.server.ts +++ b/src/features/mcp-events/webhook.server.ts @@ -83,7 +83,7 @@ export function isPublicAddress(address: string) { } return false; } -export type WebhookResponse = { status: number; body: string }; +type WebhookResponse = { status: number; body: string }; export type WebhookSender = ( url: string, body: string,