diff --git a/.hallmark/log.json b/.hallmark/log.json index 8652055..21f42d5 100644 --- a/.hallmark/log.json +++ b/.hallmark/log.json @@ -1,4 +1,12 @@ [ + { + "date": "2026-09-28", + "macrostructure": "daily home with natural-language composer and flexible plan", + "theme": "shadcn base-nova neutral", + "scope": "home", + "enrichment": "none", + "brief": "Greeting and large composer above time-free routines, tasks, contact actions and context; shared session state" + }, { "date": "2026-09-28", "macrostructure": "communication workbench and append-only journal", diff --git a/README.md b/README.md index a2dfaad..b4f9d1f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ bound to a connection account, so platforms and multiple accounts work side by s - Saved chat history with deck, account and citation restoration across devices and backend restarts -Both `/` and `/deck` open the deck workspace. A shared sidebar also opens +`/` opens the daily home with a greeting, natural-language action proposals, +flexible tasks/routines, messages, notes, and reading. `/deck` opens Twitter Lite. +The shared sidebar also opens the article reader at `/inbox`. This UI-only prototype shows articles inferred from past reading, searches, and research, with no manual interest settings. Articles are fictional samples; search and article selection work locally. @@ -35,6 +37,9 @@ The shared sidebar also contains `/support` (communication support) and `/journa state to preview reply review, scheduling, deferral, recording, and corrections. See [execution support](docs/execution-support.md) for scope and limitations. +`/vitals` is a separate [health/environment reference preview](docs/vitals-preview.md) +with sample scores, body systems, environmental charts, and simulated controls. + The former SNS reader, search, list, user-profile, and conversation routes have been removed. Original-post links on post cards open their source site; SNS reply threads are not rendered @@ -225,7 +230,7 @@ data is left untouched. See [the deck model and persistence contract](docs/resea A WebMCP-enabled browser exposes `list_connections`, `list_decks`, `get_deck`, `set_deck`, `save_deck`, `select_deck`, `delete_deck`, `get_column_posts`, and -`load_more_column` on both deck routes. Discover connection IDs first. +`load_more_column` on `/deck`. Discover connection IDs first. `set_deck` creates a temporary view when `deckId` is omitted. `save_deck` persists it. Replacing or deleting a saved deck requires its `expectedRevision`. diff --git a/docs/execution-support.md b/docs/execution-support.md index 8230008..11a3af7 100644 --- a/docs/execution-support.md +++ b/docs/execution-support.md @@ -2,7 +2,8 @@ The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts -and in-memory state. Reloading or leaving the route resets the prototype state. +and in-memory state. Reloading resets the prototype state. Tasks, communication status, outcomes, and +journal entries are shared between routes for the current browser session. No messages, calendar events, medical records, or Rensheng pages are written. ## Reference and scope @@ -32,3 +33,32 @@ dose, weight-target, or treatment recommendations. The UI uses existing shadcn Base UI components and neutral theme tokens. Shared account management continues to use the existing connection system; the new communication and journal prototypes do not consume those accounts. + +## Daily home + +`/` presents a greeting, a large input, and a flexible plan grouped as +「まずこれ」「いつものこと」「余裕があれば」. There are no fixed time slots. +Tasks can be reordered, completed, deferred, restored, or followed as routine +steps. Communications can be reviewed directly in a home dialog; changes also +appear in `/support`. Journal entries and corrections appear on the home, and +reader samples open for reading. Twitter Lite remains a separate work surface. + +The input currently uses a deterministic Japanese mock interpreter, not an LLM. +It proposes typed actions for task creation/status/reordering, routine steps, +contact reply/review or deferral, notes/weight/medication records, and navigation. +Confirming a proposal dispatches the corresponding operation. Unknown or +ambiguous input does not change state. Message sending still goes through the +existing simulated review UI; this prototype performs no external send. + +Examples: 「買い物を追加」「朝の支度をあとで」「青木さんに返信」 +「メモ:次は資料の2ページ目から」「体重は65.2kg」「リーダーを開く」. + +The interaction reference is [Tiimo visual planning](https://www.tiimoapp.com/product/visual-planning), +reviewed on 2026-09-28: combined routines and tasks, flexible reordering, small +steps, and natural-language planning. Unlike its timed scheduling options, this +prototype deliberately keeps the day free of precise times as requested. + +The home greeting includes a short prose brief derived from current task and +communication state. Completed or deferred items leave the brief; the next +contact opens directly from the text. A current resumption note takes precedence +over the optional-task suggestion. This is local templated copy, not an LLM report. diff --git a/docs/research-decks.md b/docs/research-decks.md index cec0a9a..3755789 100644 --- a/docs/research-decks.md +++ b/docs/research-decks.md @@ -2,7 +2,7 @@ ## Workspace -Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui +`/deck` renders the research workspace; `/` is the integrated daily home. A collapsible shadcn/ui sidebar based on `sidebar-09` shares its navigation rail with the article reader at `/inbox`. Within Twitter Lite, it switches between research chat and deck management. Deck management switches decks and adds columns. The avatar at the bottom of the diff --git a/docs/vitals-preview.md b/docs/vitals-preview.md new file mode 100644 index 0000000..24b0aa2 --- /dev/null +++ b/docs/vitals-preview.md @@ -0,0 +1,18 @@ +# Vitals reference preview + +`/vitals` recreates the Body and Environment screenshots attached to +[Alan Shiflett’s post](https://x.com/AlanShiflett/status/2103524300158366183). +The shared sidebar opens it as バイタル. Body categories and history, environment +metric details, chart period/series selection, and simulated climate controls +are interactive. Other screenshot navigation entries open a preview explanation. + +All scores, classifications, dates, readings, and connection indicators are +reference/demo values, not the user's health or device data. No clinical +assessment, data sync, or device command is performed. Obscured reference text +remains a visual placeholder, not inferred medical content. + +`public/vitals/body-reference.jpg` is the post's second public image, retained +as the anatomy illustration source and clipped in CSS. Source media: +https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg +The rest of the interface is HTML/CSS with SVG data charts. Reference-specific +colors are scoped to `--vital-*` tokens in `vitals.css`. diff --git a/e2e/integrations/access.test.ts b/e2e/integrations/access.test.ts index d7d54e6..c5d0ef8 100644 --- a/e2e/integrations/access.test.ts +++ b/e2e/integrations/access.test.ts @@ -41,7 +41,7 @@ test('server functions also reject foreign identity and cross-site calls', async const serverRequest = page.waitForRequest((request) => request.url().includes('/_serverFn/'), ) - await page.goto('/') + await page.goto('/deck') const url = (await serverRequest).url() expect( ( diff --git a/e2e/integrations/connections.test.ts b/e2e/integrations/connections.test.ts index 9a9504a..876fffa 100644 --- a/e2e/integrations/connections.test.ts +++ b/e2e/integrations/connections.test.ts @@ -3,7 +3,7 @@ import { expect, test } from '../fixtures' test('opens account management after hydration without inheriting real OAuth configuration', async ({ page, }) => { - await page.goto('/') + await page.goto('/deck') await expect( page.getByRole('button', { name: '名前を変更', includeHidden: true }), ).toBeEnabled() @@ -51,7 +51,7 @@ test('rejects an unbound OAuth callback without contacting an instance', async ( page, }) => { await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code') - await expect(page).toHaveURL(/\/\?mastodon=failed$/) + await expect(page).toHaveURL(/\/deck\?mastodon=failed$/) await expect(page.getByRole('alert')).toContainText( 'Mastodonの認可を完了できませんでした。', ) diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index 56e8f39..8060cc4 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -40,7 +40,7 @@ async function addColumn( } test.beforeEach(async ({ page }) => { - await page.goto('/') + await page.goto('/deck') await expect( page.getByRole('button', { name: 'デッキとして保存', exact: true }), ).toBeEnabled() @@ -301,7 +301,7 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn }) try { const device = await other.newPage() - await device.goto('/') + await device.goto('/deck') await page.getByRole('button', { name: '名前を変更' }).click() await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前') await device.getByRole('button', { name: '名前を変更' }).click() diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts new file mode 100644 index 0000000..e7c185d --- /dev/null +++ b/e2e/integrations/home.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from '../fixtures' + +test('previews a natural-language memo and shares the executed record with journal', async ({ + page, +}) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + const memo = '明日のレビューは導線から確認する' + await page + .getByRole('textbox', { name: 'ホームへの入力' }) + .fill(`メモ: ${memo}`) + await page.getByRole('button', { name: '候補を表示' }).click() + const execute = page.getByRole('button', { name: '実行する', exact: true }) + await expect(execute).toBeVisible() + await execute.click() + await page.getByRole('link', { name: '記録', exact: true }).first().click() + await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText( + memo, + ) + await page.getByRole('link', { name: 'ホーム', exact: true }).first().click() + await expect( + page.getByRole('textbox', { name: 'ホームへの入力' }), + ).toBeVisible() +}) + +test('keeps the home usable on narrow screens', async ({ + page, + a11y, +}, testInfo) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + for (const width of [320, 375, 768]) { + await page.setViewportSize({ width, height: 850 }) + await expect( + page.getByRole('textbox', { name: 'ホームへの入力' }), + ).toBeVisible() + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width) + await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) }) + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) + } +}) + +test('adds tasks only after confirmation and can defer and restore them', async ({ + page, +}) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + const input = page.getByRole('textbox', { name: 'ホームへの入力' }) + const complete = page.getByRole('button', { + name: '買い物を完了', + exact: true, + }) + await input.fill('買い物を追加') + await page.getByRole('button', { name: '候補を表示' }).click() + await expect(complete).toHaveCount(0) + await page.getByRole('button', { name: '実行する', exact: true }).click() + await expect(complete).toBeVisible() + await input.fill('買い物をあとで') + await page.getByRole('button', { name: '候補を表示' }).click() + await expect(complete).toBeVisible() + await page.getByRole('button', { name: '実行する', exact: true }).click() + await expect(complete).toHaveCount(0) + await page.locator('.home-rest summary').click() + await page.getByRole('button', { name: '買い物を戻す', exact: true }).click() + await complete.click() + await expect(complete).toHaveCount(0) + await page.locator('.home-rest summary').click() + await expect( + page.getByRole('button', { name: '買い物を戻す', exact: true }), + ).toBeVisible() +}) + +test('resolves communication within home and carries the status to the shared queue', async ({ + page, + a11y, +}) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: '青木 遥との会話を開く' }).click() + const dialog = page.getByRole('dialog', { name: '青木 遥との連絡' }) + await expect( + dialog.getByRole('textbox', { name: '返信', exact: true }), + ).toBeVisible() + expect((await a11y().analyze()).violations).toEqual([]) + await dialog.getByRole('button', { name: '今は置いておく' }).click() + await expect(dialog).toHaveCount(0) + await expect( + page.getByRole('button', { name: '青木 遥との会話を開く' }), + ).toHaveCount(0) + await page.getByRole('link', { name: '連絡', exact: true }).first().click() + const rows = page.locator('.support-row') + await expect( + rows.filter({ hasText: '来週のレビュー、日程を決める' }), + ).toHaveCount(0) + await page.getByRole('button', { name: 'あとで', exact: true }).click() + await rows.filter({ hasText: '来週のレビュー、日程を決める' }).click() + await page.getByRole('button', { name: '今に戻す' }).click() + const back = page.getByRole('button', { name: '連絡一覧に戻る' }) + if (await back.isVisible()) await back.click() + await page.getByRole('link', { name: 'ホーム', exact: true }).first().click() + await expect( + page.getByRole('button', { name: '青木 遥との会話を開く' }), + ).toBeVisible() +}) + +test('updates the prose brief as priorities change and opens the referenced conversation', async ({ + page, +}) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + const brief = page.getByRole('region', { name: '今日のブリーフ' }) + await expect(brief).toContainText('レビュー資料を確認する') + await expect(brief).toContainText('4件') + await page + .getByRole('button', { name: 'レビュー資料を確認するを完了', exact: true }) + .click() + await expect(brief).not.toContainText('レビュー資料を確認する') + await brief.getByRole('button', { name: '青木 遥さんへの返信' }).click() + const dialog = page.getByRole('dialog', { + name: '青木 遥との連絡', + exact: true, + }) + await dialog.getByRole('button', { name: '今は置いておく' }).click() + await expect(brief).toContainText('3件') + await expect(brief).not.toContainText('青木 遥') + await page + .getByRole('textbox', { name: 'ホームへの入力' }) + .fill('再開地点:資料の2ページ目から') + await page.getByRole('button', { name: '候補を表示' }).click() + await page.getByRole('button', { name: '実行する', exact: true }).click() + await expect(brief).toContainText('資料の2ページ目から') +}) diff --git a/e2e/integrations/research.test.ts b/e2e/integrations/research.test.ts index 3fee9e3..b4d6f3f 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -10,7 +10,7 @@ import { test('pushes the current research snapshot on each SSE connection', async ({ page, }) => { - await page.goto('/') + await page.goto('/deck') const snapshots = await page.evaluate(async () => { const connect = () => new Promise((resolve, reject) => { @@ -41,7 +41,7 @@ test('pushes the current research snapshot on each SSE connection', async ({ test('restores the conversation and growing temporary deck from reconnect snapshots', async ({ page, }) => { - await page.goto('/') + await page.goto('/deck') await showDeckManagement(page) await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() const accounts = page.getByRole('combobox', { @@ -215,7 +215,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col a11y, }) => { await page.emulateMedia({ reducedMotion: 'reduce' }) - await page.goto('/') + await page.goto('/deck') await showDeckManagement(page) await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() const accounts = page.getByRole('combobox', { @@ -322,7 +322,7 @@ test('explains unconfigured Codex and prevents starting research without affecti page, a11y, }) => { - await page.goto('/') + await page.goto('/deck') const heading = page.getByRole('heading', { level: 1 }) await expect(heading).toBeVisible() const originalTitle = await heading.textContent() diff --git a/e2e/integrations/vitals.test.ts b/e2e/integrations/vitals.test.ts new file mode 100644 index 0000000..af5a622 --- /dev/null +++ b/e2e/integrations/vitals.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from '../fixtures' + +test('filters body systems and opens lab history', async ({ page, a11y }) => { + await page.goto('/vitals') + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('heading', { name: 'Body', exact: true }), + ).toBeVisible() + const systems = page.locator('.vital-system-card') + await expect(systems).toHaveCount(8) + await page + .locator('.vital-body-page > .vital-tabs') + .getByRole('button', { name: 'Cardiovascular', exact: true }) + .click() + await expect(systems).toHaveCount(1) + await systems.first().click() + const dialog = page.getByRole('dialog', { name: 'Cardiovascular' }) + await expect(dialog).toContainText('68') + await dialog + .getByRole('button', { name: '閉じる', exact: true }) + .last() + .click() + await page.getByRole('button', { name: 'Lab history', exact: true }).click() + await expect(page.locator('.vital-history')).toContainText('Jul 28') + await expect(page.locator('.vital-history')).toContainText('93') + await page.getByRole('button', { name: 'Systems', exact: true }).click() + await page + .locator('.vital-body-page > .vital-tabs') + .getByRole('button', { name: 'Overview', exact: true }) + .click() + await expect(systems).toHaveCount(8) + expect((await a11y().analyze()).violations).toEqual([]) +}) + +test('explores environment charts and simulated climate controls', async ({ + page, + a11y, +}) => { + await page.goto('/vitals') + await page.waitForLoadState('networkidle') + const menu = page.getByRole('button', { name: 'Healthメニューを開く' }) + if (await menu.isVisible()) await menu.click() + await page + .getByRole('complementary', { name: 'Health navigation' }) + .getByRole('button', { name: 'Environment', exact: true }) + .click() + await expect( + page.getByRole('heading', { name: 'Environment', exact: true }), + ).toBeVisible() + await page.getByRole('button', { name: '7D', exact: true }).click() + await page + .getByRole('group', { name: 'Trend metric' }) + .getByRole('button', { name: 'CO₂', exact: true }) + .click() + await expect( + page.getByRole('img', { name: '7D CO₂ simulated environmental readings' }), + ).toBeVisible() + await page + .locator('.vital-metric') + .filter({ hasText: 'Room temperature' }) + .click() + const dialog = page.getByRole('dialog', { name: 'Room temperature' }) + await expect(dialog).toContainText('64.0') + await dialog.getByRole('button', { name: 'Back to overview' }).click() + await page.getByRole('button', { name: 'Sync now', exact: true }).click() + await expect( + page.getByRole('status').filter({ hasText: 'Sample readings refreshed' }), + ).toBeVisible() + expect((await a11y().analyze()).violations).toEqual([]) + await page + .getByRole('button', { name: 'Climate control', exact: true }) + .click() + await page.getByRole('button', { name: 'Cool', exact: true }).click() + await expect( + page.getByRole('button', { name: 'Cool', exact: true }), + ).toHaveAttribute('aria-pressed', 'true') + const temperature = page.getByRole('slider', { name: 'Target temperature' }) + await temperature.focus() + await temperature.press('ArrowRight') + await expect(temperature).toHaveValue('69') + await page.getByRole('button', { name: 'Air purifier · On' }).click() + await expect( + page.getByRole('button', { name: 'Air purifier · Off' }), + ).toHaveAttribute('aria-pressed', 'false') +}) + +test('keeps both dashboards within the viewport', async ({ + page, +}, testInfo) => { + await page.goto('/vitals') + await page.waitForLoadState('networkidle') + for (const width of [375, 768, 1440]) { + await page.setViewportSize({ width, height: 1000 }) + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width) + await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) }) + const menu = page.getByRole('button', { name: 'Healthメニューを開く' }) + if (await menu.isVisible()) await menu.click() + const navigation = page.getByRole('complementary', { + name: 'Health navigation', + }) + await navigation + .getByRole('button', { name: 'Environment', exact: true }) + .click() + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width) + await page.screenshot({ + path: testInfo.outputPath(`environment-${width}.png`), + }) + if (await menu.isVisible()) await menu.click() + await navigation.getByRole('button', { name: 'Body', exact: true }).click() + } +}) diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index bc0e654..5ea17e7 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -64,7 +64,7 @@ let pageErrors: string[] test.beforeEach(async ({ page }) => { pageErrors = [] page.on('pageerror', (error) => pageErrors.push(error.message)) - await page.goto('/') + await page.goto('/deck') await expect .poll(() => toolNames(page)) .toEqual([ @@ -141,7 +141,7 @@ test('creates temporary research, edits it, persists explicitly and reopens it o }) try { const device = await other.newPage() - await device.goto('/') + await device.goto('/deck') await expect(device.getByRole('heading', { level: 1 })).toHaveText( '更新した調査', ) diff --git a/e2e/integrations/workspace.test.ts b/e2e/integrations/workspace.test.ts index 1fbbe9a..168f898 100644 --- a/e2e/integrations/workspace.test.ts +++ b/e2e/integrations/workspace.test.ts @@ -5,7 +5,7 @@ test('keeps the sidebar, composer and deck reachable at compact widths', async ( page, a11y, }, testInfo) => { - await page.goto('/') + await page.goto('/deck') for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }) await showResearch(page) diff --git a/public/vitals/body-reference.jpg b/public/vitals/body-reference.jpg new file mode 100644 index 0000000..5303ddc Binary files /dev/null and b/public/vitals/body-reference.jpg differ diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index e8baee4..d15170a 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -54,7 +54,7 @@ function Workspace({ <> location.pathname }) const navigation = [ + { title: 'ホーム', to: '/', icon: House, active: pathname === '/' }, + { + title: 'バイタル', + to: '/vitals', + icon: Activity, + active: pathname === '/vitals', + }, { title: '連絡', to: '/support', @@ -58,7 +67,7 @@ export function WorkspaceNavigation({ title: 'Twitter Lite', to: '/deck', icon: Search, - active: pathname === '/' || pathname === '/deck', + active: pathname === '/deck', }, ] as const diff --git a/src/features/home/daily-brief.tsx b/src/features/home/daily-brief.tsx new file mode 100644 index 0000000..59a5cc7 --- /dev/null +++ b/src/features/home/daily-brief.tsx @@ -0,0 +1,64 @@ +import { conversations } from '#/features/support/mock-conversations' +import { useWorkspaceState } from '#/features/workspace/workspace-state' + +export function DailyBrief({ + onOpenContact, +}: { + onOpenContact: (id: string) => void +}) { + const { tasks, statuses, entries } = useWorkspaceState() + const focus = tasks.find( + (task) => task.section === 'focus' && task.status === 'todo', + ) + const optional = tasks.find( + (task) => task.section === 'optional' && task.status === 'todo', + ) + const contacts = conversations.filter( + (person) => (statuses[person.id] ?? 'now') === 'now', + ) + const firstContact = contacts[0] + const resume = entries.find( + (entry) => + entry.kind === '再開地点' && + !entries.some((other) => other.correctionOf === entry.id), + ) + + return ( +
+

+ {focus + ? `今日は「${focus.title}」から始められそうです。` + : 'いま優先して取りかかるタスクはありません。'} + {firstContact ? ( + <> + {' '} + + {contacts.length > 1 + ? `など、確認できる連絡が${contacts.length}件あります。` + : 'が残っています。'} + + ) : ( + ' いま返信に取りかかる連絡もありません。' + )} +

+ {resume ? ( +

+ 再開メモには「 + {resume.text.length > 100 + ? `${resume.text.slice(0, 100)}…` + : resume.text} + 」と残っています。 +

+ ) : optional ? ( +

+ 「{optional.title}」は、余裕があるときに。 +

+ ) : null} +
+ ) +} diff --git a/src/features/home/home-page.tsx b/src/features/home/home-page.tsx new file mode 100644 index 0000000..4353142 --- /dev/null +++ b/src/features/home/home-page.tsx @@ -0,0 +1,597 @@ +import { Link, useNavigate } from '@tanstack/react-router' +import { + ArrowDown, + ArrowUp, + ArrowUpRight, + Check, + ChevronRight, + Circle, + Coffee, + MessagesSquare, + Plus, + RotateCcw, + X, +} from 'lucide-react' +import { useRef, useState } from 'react' +import { Dialog } from '#/components/dialog' +import { Button } from '#/components/ui/button' +import { Checkbox } from '#/components/ui/checkbox' +import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar' +import { Textarea } from '#/components/ui/textarea' +import { TooltipProvider } from '#/components/ui/tooltip' +import { WorkspaceNavigation } from '#/components/workspace-navigation' +import { articles } from '#/features/inbox/mock-articles' +import { ConversationDetail } from '#/features/support/conversation-detail' +import { conversations } from '#/features/support/mock-conversations' +import { useWorkspaceState } from '#/features/workspace/workspace-state' +import { DailyBrief } from './daily-brief' +import { type HomeAction, interpretAction } from './interpret-action' +import './home.css' + +export function HomePage() { + const { + tasks, + setTasks, + statuses, + setStatuses, + outcomes, + setOutcomes, + entries, + setEntries, + } = useWorkspaceState() + const navigate = useNavigate() + const input = useRef(null) + const [prompt, setPrompt] = useState('') + const [proposal, setProposal] = useState<{ + action: HomeAction + label: string + } | null>(null) + const [notice, setNotice] = useState('') + const [contactId, setContactId] = useState() + const [articleId, setArticleId] = useState() + const contact = conversations.find((item) => item.id === contactId) + const article = articles.find((item) => item.id === articleId) + const waiting = conversations.filter( + (item) => (statuses[item.id] ?? 'now') === 'now', + ) + const currentEntries = entries.filter( + (entry) => !entries.some((other) => other.correctionOf === entry.id), + ) + const rest = tasks.filter((task) => task.status !== 'todo') + + function suggest(text: string) { + setPrompt(text) + setProposal(null) + input.current?.focus() + } + function interpret() { + const result = interpretAction(prompt, tasks, conversations) + if ('error' in result) { + setNotice(result.error) + setProposal(null) + } else { + setProposal(result) + setNotice('') + } + } + function apply(action: HomeAction) { + switch (action.type) { + case 'add-task': + setTasks((current) => [ + ...current, + { + id: crypto.randomUUID(), + title: action.title, + section: action.section, + status: 'todo', + }, + ]) + break + case 'task-status': + setTasks((current) => + current.map((task) => + task.id === action.id ? { ...task, status: action.status } : task, + ), + ) + break + case 'task-move': + moveTask(action.id, action.direction) + break + case 'routine-step': + setTasks((current) => + current.map((task) => + task.id === action.id + ? { + ...task, + completedSteps: action.done + ? Array.from( + new Set([...(task.completedSteps ?? []), action.step]), + ) + : (task.completedSteps ?? []).filter( + (step) => step !== action.step, + ), + } + : task, + ), + ) + break + case 'contact': + setContactId(action.id) + break + case 'contact-status': + setStatuses((current) => ({ ...current, [action.id]: action.status })) + break + case 'journal': + setEntries((current) => [ + { + id: crypto.randomUUID(), + kind: action.kind, + text: action.text, + source: '', + occurredAt: '', + recordedAt: new Date().toISOString(), + }, + ...current, + ]) + break + case 'navigate': + void navigate({ to: action.to }) + break + } + setNotice(proposal?.label ?? '反映しました') + setProposal(null) + setPrompt('') + } + function moveTask(id: string, direction: -1 | 1) { + setTasks((current) => { + const index = current.findIndex((task) => task.id === id) + const siblings = current + .map((task, i) => ({ task, i })) + .filter( + ({ task }) => + task.section === current[index]?.section && task.status === 'todo', + ) + const position = siblings.findIndex(({ task }) => task.id === id) + const target = siblings[position + direction]?.i + if (target === undefined) return current + const sourceTask = current[index] + const targetTask = current[target] + if (!sourceTask || !targetTask) return current + const updated = [...current] + updated[index] = targetTask + updated[target] = sourceTask + return updated + }) + } + return ( + + + + +
+
+
+

今日

+

おはようございます、Yutaさん

+ +
+
{ + event.preventDefault() + interpret() + }} + > +