fix: update local greetings and stabilize account navigation

This commit is contained in:
2026-10-08 01:10:49 +09:00
parent 67afc7bf15
commit c66b427a23
15 changed files with 164 additions and 254 deletions
+21 -91
View File
@@ -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"
}
+4
View File
@@ -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 |
+20 -65
View File
@@ -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": {}
}
}
}
+26 -87
View File
@@ -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": {}
}
}
}
+1 -1
View File
@@ -59,4 +59,4 @@
"breakpoints": true
}
]
}
}
+1
View File
@@ -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);
+1 -1
View File
@@ -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}`);
+7 -1
View File
@@ -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;
+5 -2
View File
@@ -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({
<SidebarMenu className="mt-2">
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Manage connected accounts"
render={
<button type="button" aria-label="Manage connected accounts" disabled={!hydrated} />
}
tooltip={{ children: "Manage connected accounts", hidden: false }}
className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)}
+3 -3
View File
@@ -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(),
+43
View File
@@ -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(<Greeting name="Yuta" />);
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(<Greeting name="Yuta" />);
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(<Greeting name="Yuta" />);
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(<Greeting name="Yuta" />)).toContain("Hello");
});
+28
View File
@@ -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}
</>
);
}
+2 -1
View File
@@ -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() {
<header className="mb-px-32">
<p className="text-sm text-muted-foreground">Today</p>
<h1 className="mt-px-12 text-home-greeting font-book tracking-home leading-normal max-md:text-ui-25">
Good morning, {auth.owner?.name}
<Greeting name={auth.owner?.name} />
</h1>
<DailyBrief onOpenContact={openContact} />
</header>
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,