From 0ee5d181a574cbbd9e1033b2ad1a7124df3cfbdf Mon Sep 17 00:00:00 2001 From: yuta Date: Mon, 28 Sep 2026 18:03:06 +0900 Subject: [PATCH] feat: add communication support and journal prototypes --- .hallmark/log.json | 8 + README.md | 5 + docs/execution-support.md | 34 ++ e2e/integrations/support.test.ts | 147 ++++++++ src/components/workspace-navigation.tsx | 20 +- src/features/journal/journal-page.tsx | 349 +++++++++++++++++++ src/features/journal/journal.css | 129 +++++++ src/features/support/conversation-detail.tsx | 283 +++++++++++++++ src/features/support/mock-conversations.ts | 186 ++++++++++ src/features/support/support-page.tsx | 200 +++++++++++ src/features/support/support.css | 89 +++++ src/routeTree.gen.ts | 42 +++ src/routes/journal.tsx | 4 + src/routes/support.tsx | 3 + 14 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 docs/execution-support.md create mode 100644 e2e/integrations/support.test.ts create mode 100644 src/features/journal/journal-page.tsx create mode 100644 src/features/journal/journal.css create mode 100644 src/features/support/conversation-detail.tsx create mode 100644 src/features/support/mock-conversations.ts create mode 100644 src/features/support/support-page.tsx create mode 100644 src/features/support/support.css create mode 100644 src/routes/journal.tsx create mode 100644 src/routes/support.tsx diff --git a/.hallmark/log.json b/.hallmark/log.json index e9b61be..8652055 100644 --- a/.hallmark/log.json +++ b/.hallmark/log.json @@ -1,4 +1,12 @@ [ + { + "date": "2026-09-28", + "macrostructure": "communication workbench and append-only journal", + "theme": "shadcn base-nova neutral", + "scope": "support", + "enrichment": "none", + "brief": "Compact execution support integrated with common sidebar, editable reply review and journal corrections" + }, { "date": "2026-09-28", "macrostructure": "sidebar-09 Inbox", diff --git a/README.md b/README.md index c3bd888..a2dfaad 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ Articles are fictional samples; search and article selection work locally. On mobile, the article list and reader switch views. The shared sidebar’s bottom avatar opens connected-account management from either workspace. +The shared sidebar also contains `/support` (communication support) and `/journal` +(notes and health records). These use fictional conversations and local session +state to preview reply review, scheduling, deferral, recording, and corrections. +See [execution support](docs/execution-support.md) for scope and limitations. + 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 diff --git a/docs/execution-support.md b/docs/execution-support.md new file mode 100644 index 0000000..8230008 --- /dev/null +++ b/docs/execution-support.md @@ -0,0 +1,34 @@ +# Execution support prototype + +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. +No messages, calendar events, medical records, or Rensheng pages are written. + +## Reference and scope + +The functional reference is the user's life brief +`briefs/2026-09-25-executive-support-design.md`, together with the public +[Beeper Copilot concept](https://beeper-copilot-lp.vercel.app/). +The prototype adopts the execution flows, not private facts from the life repo +or the reference site's deployment and privacy claims. + +- Communication: unified conversations, a maximum of three initial actions, + deferral, waiting, dismissal, and a full list; source messages, summaries, + editable reply styles, explicit review, and simulated sending. +- Scheduling: choosing a candidate and reviewing a simulated calendar entry. +- Context: person notes with links back to the sample conversation and a local + record of the simulated outcome. No permanent trait or goal is inferred. +- Journal: free notes, resumption points, user-entered medication and weight + records; occurrence and recording times, append-only corrections, and a + selectable summary preview for a consultation. + +The intended memory boundary remains Rensheng: execution events can eventually +be compiled into durable context after source comparison and user correction. +This prototype neither implements that compiler nor creates a second personal +context database. Missing records remain unknown; there are no medication, +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. diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts new file mode 100644 index 0000000..cc65dd2 --- /dev/null +++ b/e2e/integrations/support.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from '../fixtures' + +const reviewSubject = '来週のレビュー、日程を決める' +const coffeeSubject = '久しぶりに、お茶でも' + +test('reviews an edited reply and keeps the simulated result in history', async ({ + page, + a11y, +}) => { + await page.goto('/support') + await page.waitForLoadState('networkidle') + const rows = page.locator('.support-row') + await expect(rows).toHaveCount(3) + await rows.filter({ hasText: reviewSubject }).click() + await page.getByRole('button', { name: '丁寧に', exact: true }).click() + const reply = page.getByRole('textbox', { name: '返信', exact: true }) + await expect(reply).toHaveValue(/資料をご共有/) + const edited = '資料を確認しました。初回の導線について当日お話しします。' + await reply.fill(edited) + const slot = '10月5日(月)14:00–14:30' + await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot) + await page.getByRole('button', { name: '返信に入れる' }).click() + await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`) + await page.getByRole('button', { name: '送信内容を確認' }).click() + const dialog = page.getByRole('dialog', { name: '送信内容の確認' }) + await expect(dialog).toContainText(edited) + await expect(dialog).toContainText(slot) + await expect(dialog).toContainText('外部には送信されません') + expect((await a11y().analyze()).violations).toEqual([]) + await dialog.getByRole('button', { name: '戻る', exact: true }).click() + await expect(reply).toHaveValue(new RegExp(edited)) + await page.getByRole('button', { name: '送信内容を確認' }).click() + await dialog.getByRole('button', { name: '送信を試す' }).click() + await expect(dialog).toHaveCount(0) + await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0) + await page.getByRole('button', { name: '履歴', exact: true }).click() + await rows.filter({ hasText: reviewSubject }).click() + await expect(page.locator('.support-outcome')).toContainText(edited) + await expect(page.locator('.support-outcome')).toContainText(slot) +}) + +test('defers a conversation and restores it to the current queue', async ({ + page, +}) => { + await page.goto('/support') + await page.waitForLoadState('networkidle') + const rows = page.locator('.support-row') + await rows.filter({ hasText: coffeeSubject }).click() + await page + .getByRole('textbox', { name: '返信', exact: true }) + .fill('土曜の予定を確認してから返事するね。') + await page.getByRole('button', { name: '今は置いておく' }).click() + await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0) + await page.getByRole('button', { name: 'あとで', exact: true }).click() + await rows.filter({ hasText: coffeeSubject }).click() + await expect( + page.getByRole('textbox', { name: '返信', exact: true }), + ).toHaveValue('土曜の予定を確認してから返事するね。') + await page.getByRole('button', { name: '今に戻す' }).click() + const back = page.getByRole('button', { name: '連絡一覧に戻る' }) + if (await back.isVisible()) await back.click() + await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible() + await expect( + page.getByRole('button', { name: '今', exact: true }), + ).toHaveAttribute('aria-pressed', 'true') +}) + +test('keeps corrections as new records and summarizes only selected current facts', async ({ + page, + a11y, +}) => { + await page.goto('/journal') + await page.waitForLoadState('networkidle') + await expect( + page.getByRole('button', { name: '記録する', exact: true }), + ).toBeDisabled() + await page + .getByRole('textbox', { name: '記録内容' }) + .fill('次はレビュー資料の導線を確認する') + await page.getByRole('button', { name: '記録する', exact: true }).click() + await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText( + '発生日時: 不明', + ) + await page.getByRole('button', { name: '体重', exact: true }).click() + await page.getByRole('spinbutton', { name: '体重', exact: true }).fill('65.2') + await page.getByRole('textbox', { name: '記録元' }).fill('自宅の体重計') + await page.getByRole('button', { name: '記録する', exact: true }).click() + await page + .getByRole('article', { name: '体重の記録' }) + .getByRole('button', { name: '訂正する' }) + .click() + const correction = page.getByRole('dialog', { name: '記録を訂正' }) + await correction + .getByRole('spinbutton', { name: '体重', exact: true }) + .fill('65.8') + await correction.getByRole('button', { name: '訂正を追加' }).click() + const weights = page.getByRole('article', { name: '体重の記録' }) + await expect(weights).toHaveCount(2) + await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前') + await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正') + await page + .getByRole('button', { name: '記録をまとめる', exact: true }) + .click() + const review = page.getByRole('dialog', { name: '記録をまとめる' }) + const summary = review.getByRole('textbox', { name: '記録のまとめ' }) + await expect(summary).toHaveValue(/65.8 kg/) + await expect(summary).not.toHaveValue(/65.2 kg/) + await expect(summary).toHaveValue(/記録元: 自宅の体重計/) + await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/) + await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck() + await expect(summary).not.toHaveValue(/次はレビュー資料/) + await expect(summary).toHaveValue(/記録のない期間の服薬・体重は不明/) + expect((await a11y().analyze()).violations).toEqual([]) +}) + +test('fits communication and journal interactions in narrow screens', async ({ + page, + a11y, +}) => { + for (const width of [320, 375, 768]) { + await page.setViewportSize({ width, height: 850 }) + await page.goto('/support') + await page.waitForLoadState('networkidle') + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width) + await page.locator('.support-row').first().click() + await expect( + page.getByRole('textbox', { name: '返信', exact: true }), + ).toBeVisible() + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width) + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) + const back = page.getByRole('button', { name: '連絡一覧に戻る' }) + if (await back.isVisible()) await back.click() + await page.getByRole('link', { name: '記録', exact: true }).click() + await page.getByRole('button', { name: '体重', exact: true }).click() + await expect( + page.getByRole('spinbutton', { name: '体重', exact: true }), + ).toBeVisible() + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width) + if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) + } +}) diff --git a/src/components/workspace-navigation.tsx b/src/components/workspace-navigation.tsx index 74f8cbd..8de4310 100644 --- a/src/components/workspace-navigation.tsx +++ b/src/components/workspace-navigation.tsx @@ -1,5 +1,11 @@ import { Link, useLocation } from '@tanstack/react-router' -import { Inbox, Layers3, Search } from 'lucide-react' +import { + Inbox, + Layers3, + MessagesSquare, + NotebookPen, + Search, +} from 'lucide-react' import { type ReactNode, useState } from 'react' import { Avatar, AvatarFallback } from '#/components/ui/avatar' import { @@ -30,6 +36,18 @@ export function WorkspaceNavigation({ const [managingAccounts, setManagingAccounts] = useState(false) const pathname = useLocation({ select: (location) => location.pathname }) const navigation = [ + { + title: '連絡', + to: '/support', + icon: MessagesSquare, + active: pathname === '/support', + }, + { + title: '記録', + to: '/journal', + icon: NotebookPen, + active: pathname === '/journal', + }, { title: 'リーダー', to: '/inbox', diff --git a/src/features/journal/journal-page.tsx b/src/features/journal/journal-page.tsx new file mode 100644 index 0000000..6a78930 --- /dev/null +++ b/src/features/journal/journal-page.tsx @@ -0,0 +1,349 @@ +import { useId, useState } from 'react' +import { Dialog } from '#/components/dialog' +import { Button } from '#/components/ui/button' +import { Checkbox } from '#/components/ui/checkbox' +import { Input } from '#/components/ui/input' +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 './journal.css' + +type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重' +type Entry = { + id: string + kind: EntryKind + text: string + source: string + occurredAt: string + recordedAt: string + correctionOf?: string +} +type Draft = Pick +const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' } +const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重'] + +function formatTime(value: string | undefined) { + if (!value) return '不明' + return new Date(value).toLocaleString('ja-JP', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function JournalPage() { + const [entries, setEntries] = useState([]) + const [draft, setDraft] = useState(emptyDraft) + const [correcting, setCorrecting] = useState(null) + const [reviewing, setReviewing] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const superseded = new Set(entries.map((entry) => entry.correctionOf)) + const current = entries.filter((entry) => !superseded.has(entry.id)) + + function append(value: Draft, correctionOf?: string) { + setEntries((previous) => [ + { + ...value, + text: value.text.trim(), + source: value.source.trim(), + id: crypto.randomUUID(), + recordedAt: new Date().toISOString(), + correctionOf, + }, + ...previous, + ]) + } + + return ( + + + + +
+

記録

+ +
+
+
+ { + append(draft) + setDraft(emptyDraft) + }} + submitLabel="記録する" + /> +
+ {entries.length === 0 && ( +

まだ記録はありません

+ )} + {entries.map((entry) => ( +
+
+ {entry.kind} + {superseded.has(entry.id) && ( + + 訂正前 + + )} + {entry.correctionOf && ( + + 訂正 + + )} + {!superseded.has(entry.id) && ( + + )} +
+

+ {entry.text} + {entry.kind === '体重' ? ' kg' : ''} +

+

+ 発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '} + {formatTime(entry.recordedAt)} +

+ {(entry.kind === '体重' || entry.source) && ( +

+ 記録元: {entry.source || '不明'} +

+ )} +
+ ))} +
+
+
+
+
+ {correcting && ( + setCorrecting(null)} + onSave={(value) => { + append(value, correcting.id) + setCorrecting(null) + }} + /> + )} + {reviewing && ( + setReviewing(false)} + > +

+ 含める記録を選んで確認できます。外部には送信されません。 +

+
+ {current.map((entry) => ( + + ))} +
+