feat: add daily home and vitals dashboard prototypes

This commit is contained in:
2026-09-28 18:30:59 +09:00
parent 0ee5d181a5
commit e870bdfcbb
32 changed files with 4028 additions and 37 deletions
+8
View File
@@ -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", "date": "2026-09-28",
"macrostructure": "communication workbench and append-only journal", "macrostructure": "communication workbench and append-only journal",
+7 -2
View File
@@ -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 - Saved chat history with deck, account and citation restoration across devices
and backend restarts 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 the article reader at `/inbox`. This UI-only prototype shows articles inferred
from past reading, searches, and research, with no manual interest settings. from past reading, searches, and research, with no manual interest settings.
Articles are fictional samples; search and article selection work locally. 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. state to preview reply review, scheduling, deferral, recording, and corrections.
See [execution support](docs/execution-support.md) for scope and limitations. 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, The former SNS reader, search,
list, user-profile, and conversation routes have been removed. Original-post 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 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`, A WebMCP-enabled browser exposes `list_connections`, `list_decks`, `get_deck`,
`set_deck`, `save_deck`, `select_deck`, `delete_deck`, `get_column_posts`, and `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` `set_deck` creates a temporary view when `deckId` is omitted. `save_deck`
persists it. Replacing or deleting a saved deck requires its `expectedRevision`. persists it. Replacing or deleting a saved deck requires its `expectedRevision`.
+31 -1
View File
@@ -2,7 +2,8 @@
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts リーダー 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. No messages, calendar events, medical records, or Rensheng pages are written.
## Reference and scope ## 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. The UI uses existing shadcn Base UI components and neutral theme tokens.
Shared account management continues to use the existing connection system; Shared account management continues to use the existing connection system;
the new communication and journal prototypes do not consume those accounts. 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.
+1 -1
View File
@@ -2,7 +2,7 @@
## Workspace ## 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 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. 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 Deck management switches decks and adds columns. The avatar at the bottom of the
+18
View File
@@ -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`.
+1 -1
View File
@@ -41,7 +41,7 @@ test('server functions also reject foreign identity and cross-site calls', async
const serverRequest = page.waitForRequest((request) => const serverRequest = page.waitForRequest((request) =>
request.url().includes('/_serverFn/'), request.url().includes('/_serverFn/'),
) )
await page.goto('/') await page.goto('/deck')
const url = (await serverRequest).url() const url = (await serverRequest).url()
expect( expect(
( (
+2 -2
View File
@@ -3,7 +3,7 @@ import { expect, test } from '../fixtures'
test('opens account management after hydration without inheriting real OAuth configuration', async ({ test('opens account management after hydration without inheriting real OAuth configuration', async ({
page, page,
}) => { }) => {
await page.goto('/') await page.goto('/deck')
await expect( await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }), page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled() ).toBeEnabled()
@@ -51,7 +51,7 @@ test('rejects an unbound OAuth callback without contacting an instance', async (
page, page,
}) => { }) => {
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code') 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( await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。', 'Mastodonの認可を完了できませんでした。',
) )
+2 -2
View File
@@ -40,7 +40,7 @@ async function addColumn(
} }
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/') await page.goto('/deck')
await expect( await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }), page.getByRole('button', { name: 'デッキとして保存', exact: true }),
).toBeEnabled() ).toBeEnabled()
@@ -301,7 +301,7 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
}) })
try { try {
const device = await other.newPage() const device = await other.newPage()
await device.goto('/') await device.goto('/deck')
await page.getByRole('button', { name: '名前を変更' }).click() await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前') await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前')
await device.getByRole('button', { name: '名前を変更' }).click() await device.getByRole('button', { name: '名前を変更' }).click()
+134
View File
@@ -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ページ目から')
})
+4 -4
View File
@@ -10,7 +10,7 @@ import {
test('pushes the current research snapshot on each SSE connection', async ({ test('pushes the current research snapshot on each SSE connection', async ({
page, page,
}) => { }) => {
await page.goto('/') await page.goto('/deck')
const snapshots = await page.evaluate(async () => { const snapshots = await page.evaluate(async () => {
const connect = () => const connect = () =>
new Promise<unknown>((resolve, reject) => { new Promise<unknown>((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 ({ test('restores the conversation and growing temporary deck from reconnect snapshots', async ({
page, page,
}) => { }) => {
await page.goto('/') await page.goto('/deck')
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', { const accounts = page.getByRole('combobox', {
@@ -215,7 +215,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
a11y, a11y,
}) => { }) => {
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/') await page.goto('/deck')
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', { const accounts = page.getByRole('combobox', {
@@ -322,7 +322,7 @@ test('explains unconfigured Codex and prevents starting research without affecti
page, page,
a11y, a11y,
}) => { }) => {
await page.goto('/') await page.goto('/deck')
const heading = page.getByRole('heading', { level: 1 }) const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible() await expect(heading).toBeVisible()
const originalTitle = await heading.textContent() const originalTitle = await heading.textContent()
+115
View File
@@ -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()
}
})
+2 -2
View File
@@ -64,7 +64,7 @@ let pageErrors: string[]
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
pageErrors = [] pageErrors = []
page.on('pageerror', (error) => pageErrors.push(error.message)) page.on('pageerror', (error) => pageErrors.push(error.message))
await page.goto('/') await page.goto('/deck')
await expect await expect
.poll(() => toolNames(page)) .poll(() => toolNames(page))
.toEqual([ .toEqual([
@@ -141,7 +141,7 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
}) })
try { try {
const device = await other.newPage() const device = await other.newPage()
await device.goto('/') await device.goto('/deck')
await expect(device.getByRole('heading', { level: 1 })).toHaveText( await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査', '更新した調査',
) )
+1 -1
View File
@@ -5,7 +5,7 @@ test('keeps the sidebar, composer and deck reachable at compact widths', async (
page, page,
a11y, a11y,
}, testInfo) => { }, testInfo) => {
await page.goto('/') await page.goto('/deck')
for (const width of [320, 375, 414, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 })
await showResearch(page) await showResearch(page)
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+1 -1
View File
@@ -54,7 +54,7 @@ function Workspace({
<> <>
<Sidebar <Sidebar
collapsible="icon" collapsible="icon"
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row" className="overflow-hidden transition-none *:data-[sidebar=sidebar]:flex-row"
> >
<WorkspaceNavigation <WorkspaceNavigation
hasTemporaryDecks={hasTemporaryDecks} hasTemporaryDecks={hasTemporaryDecks}
+10 -1
View File
@@ -1,5 +1,7 @@
import { Link, useLocation } from '@tanstack/react-router' import { Link, useLocation } from '@tanstack/react-router'
import { import {
Activity,
House,
Inbox, Inbox,
Layers3, Layers3,
MessagesSquare, MessagesSquare,
@@ -36,6 +38,13 @@ export function WorkspaceNavigation({
const [managingAccounts, setManagingAccounts] = useState(false) const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname }) const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [ const navigation = [
{ title: 'ホーム', to: '/', icon: House, active: pathname === '/' },
{
title: 'バイタル',
to: '/vitals',
icon: Activity,
active: pathname === '/vitals',
},
{ {
title: '連絡', title: '連絡',
to: '/support', to: '/support',
@@ -58,7 +67,7 @@ export function WorkspaceNavigation({
title: 'Twitter Lite', title: 'Twitter Lite',
to: '/deck', to: '/deck',
icon: Search, icon: Search,
active: pathname === '/' || pathname === '/deck', active: pathname === '/deck',
}, },
] as const ] as const
+64
View File
@@ -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 (
<section className="home-brief" aria-label="今日のブリーフ">
<p>
{focus
? `今日は「${focus.title}」から始められそうです。`
: 'いま優先して取りかかるタスクはありません。'}
{firstContact ? (
<>
{' '}
<button
type="button"
onClick={() => onOpenContact(firstContact.id)}
>
{firstContact.name}さんへの返信
</button>
{contacts.length > 1
? `など、確認できる連絡が${contacts.length}件あります。`
: 'が残っています。'}
</>
) : (
' いま返信に取りかかる連絡もありません。'
)}
</p>
{resume ? (
<p className="home-brief-secondary">
再開メモには「
{resume.text.length > 100
? `${resume.text.slice(0, 100)}…`
: resume.text}
」と残っています。
</p>
) : optional ? (
<p className="home-brief-secondary">
「{optional.title}」は、余裕があるときに。
</p>
) : null}
</section>
)
}
+597
View File
@@ -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<HTMLTextAreaElement>(null)
const [prompt, setPrompt] = useState('')
const [proposal, setProposal] = useState<{
action: HomeAction
label: string
} | null>(null)
const [notice, setNotice] = useState('')
const [contactId, setContactId] = useState<string>()
const [articleId, setArticleId] = useState<string>()
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 (
<TooltipProvider delay={500}>
<SidebarProvider className="home-workspace">
<WorkspaceNavigation />
<SidebarInset className="home-main">
<div className="home-scroll">
<div className="home-content">
<header className="home-greeting">
<p className="text-sm text-muted-foreground">今日</p>
<h1>おはようございます、Yutaさん</h1>
<DailyBrief onOpenContact={setContactId} />
</header>
<form
className="home-composer"
onSubmit={(event) => {
event.preventDefault()
interpret()
}}
>
<Textarea
ref={input}
aria-label="ホームへの入力"
placeholder="頭にあることを、そのまま。"
value={prompt}
onChange={(event) => {
setPrompt(event.target.value)
setProposal(null)
setNotice('')
}}
onKeyDown={(event) => {
if (
(event.metaKey || event.ctrlKey) &&
event.key === 'Enter' &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
interpret()
}
}}
/>
<div className="home-composer-footer">
<span className="text-xs text-muted-foreground">
やること、返信、メモ。
</span>
<Button
type="submit"
size="icon"
aria-label="候補を表示"
disabled={!prompt.trim()}
>
<ArrowUp />
</Button>
</div>
</form>
<div className="home-suggestions">
{[
'買い物を追加',
'青木さんに返信',
'メモ:次は資料の2ページ目から',
].map((text) => (
<button
type="button"
key={text}
onClick={() => suggest(text)}
>
{text}
<ArrowUpRight className="size-3" />
</button>
))}
</div>
{proposal && (
<div className="home-proposal">
<p>{proposal.label}</p>
<Button onClick={() => apply(proposal.action)}>
実行する
</Button>
<Button
variant="ghost"
size="icon"
aria-label="候補を取り消す"
onClick={() => setProposal(null)}
>
<X />
</Button>
</div>
)}
<p className="home-status" role="status">
{notice}
</p>
<div className="home-columns">
<section className="home-plan" aria-label="今日の予定">
{(
[
{ id: 'focus', label: 'まずこれ' },
{ id: 'routine', label: 'いつものこと' },
{ id: 'optional', label: '余裕があれば' },
] as const
).map((section) => (
<section key={section.id} className="home-section">
<header className="home-section-heading">
<h2>{section.label}</h2>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${section.label}に追加`}
onClick={() =>
suggest(
section.id === 'routine'
? 'ルーティンに追加:'
: section.id === 'optional'
? '余裕があれば '
: '追加:',
)
}
>
<Plus />
</Button>
</header>
{tasks
.filter(
(task) =>
task.section === section.id &&
task.status === 'todo',
)
.map((task) => (
<div className="home-task" key={task.id}>
<button
type="button"
className="home-check"
aria-label={`${task.title}を完了`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'done' }
: item,
),
)
}
>
<Circle className="size-5" />
</button>
<div className="home-task-body">
<p>{task.title}</p>
{task.steps && (
<details>
<summary>小さく進める</summary>
<div className="home-steps">
{task.steps.map((step) => (
<label
key={step}
htmlFor={`step-${task.id}-${step}`}
className="flex items-center gap-3"
>
<Checkbox
id={`step-${task.id}-${step}`}
checked={
task.completedSteps?.includes(
step,
) ?? false
}
onCheckedChange={(checked) =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? {
...item,
completedSteps: checked
? [
...(item.completedSteps ??
[]),
step,
]
: (
item.completedSteps ??
[]
).filter(
(value) =>
value !== step,
),
}
: item,
),
)
}
/>
<span>{step}</span>
</label>
))}
</div>
</details>
)}
</div>
<div className="home-task-actions">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${task.title}を上へ`}
onClick={() => moveTask(task.id, -1)}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${task.title}を下へ`}
onClick={() => moveTask(task.id, 1)}
>
<ArrowDown />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={`${task.title}をあとで`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'later' }
: item,
),
)
}
>
あとで
</Button>
</div>
</div>
))}
{section.id === 'focus' &&
waiting.slice(0, 2).map((item) => (
<div className="home-task" key={item.id}>
<MessagesSquare className="size-5 text-muted-foreground mt-1" />
<button
type="button"
className="home-task-body text-left"
onClick={() => setContactId(item.id)}
>
<span className="block">
{item.name}さんに返信
</span>
<span className="text-xs text-muted-foreground">
{item.subject}
</span>
</button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${item.name}との会話を開く`}
onClick={() => setContactId(item.id)}
>
<ChevronRight />
</Button>
</div>
))}
{!tasks.some(
(task) =>
task.section === section.id && task.status === 'todo',
) &&
(section.id !== 'focus' || !waiting.length) && (
<p className="home-empty">
ここは空けておいても大丈夫。
</p>
)}
</section>
))}
{rest.length > 0 && (
<details className="home-rest">
<summary>
あとで・終えたこと <span>{rest.length}</span>
</summary>
{rest.map((task) => (
<div className="home-task" key={task.id}>
{task.status === 'done' ? (
<Check className="size-4" />
) : (
<Coffee className="size-4" />
)}
<span className="flex-1 text-sm">{task.title}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${task.title}を戻す`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'todo' }
: item,
),
)
}
>
<RotateCcw />
</Button>
</div>
))}
</details>
)}
</section>
<aside className="home-context" aria-label="今日のつながり">
<section className="home-section">
<header className="home-section-heading">
<h2>連絡</h2>
<Link to="/support" aria-label="連絡をすべて見る">
<ArrowUpRight className="size-4" />
</Link>
</header>
{conversations
.filter(
(item) =>
!['sent', 'dismissed'].includes(
statuses[item.id] ?? 'now',
),
)
.map((item) => (
<button
className="home-context-row"
type="button"
key={item.id}
onClick={() => setContactId(item.id)}
>
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">
{statuses[item.id] === 'waiting'
? '相手待ち'
: statuses[item.id] === 'later'
? 'あとで'
: item.channels.join(' / ')}
</span>
</button>
))}
</section>
<section className="home-section">
<header className="home-section-heading">
<h2>記録</h2>
<Link to="/journal" aria-label="記録を開く">
<ArrowUpRight className="size-4" />
</Link>
</header>
{currentEntries.slice(0, 2).map((entry) => (
<p className="home-record" key={entry.id}>
<span className="text-xs text-muted-foreground">
{entry.kind}
</span>
<span>
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
</span>
</p>
))}
{!currentEntries.length && (
<button
type="button"
className="home-context-row"
onClick={() => suggest('メモ:')}
>
<span>ひとこと残す</span>
<Plus className="size-4" />
</button>
)}
</section>
<section className="home-section">
<header className="home-section-heading">
<h2>読みもの</h2>
<Link to="/inbox" aria-label="リーダーを開く">
<ArrowUpRight className="size-4" />
</Link>
</header>
{articles.slice(0, 2).map((item) => (
<button
type="button"
className="home-reading"
key={item.id}
onClick={() => setArticleId(item.id)}
>
<span className="text-xs text-muted-foreground">
{item.source}
</span>
<span>{item.title}</span>
</button>
))}
</section>
<Link to="/deck" className="home-deck-link">
Twitter Liteで調べる
<ArrowUpRight className="size-4" />
</Link>
</aside>
</div>
</div>
</div>
</SidebarInset>
{contact && (
<Dialog
title={`${contact.name}との連絡`}
className="sm:max-w-3xl"
onClose={() => setContactId(undefined)}
>
<ConversationDetail
conversation={contact}
onResolve={(status, note) => {
setStatuses((current) => ({ ...current, [contact.id]: status }))
if (note)
setOutcomes((current) => ({ ...current, [contact.id]: note }))
setContactId(undefined)
}}
/>
{outcomes[contact.id] && (
<p className="text-sm whitespace-pre-wrap">
{outcomes[contact.id]}
</p>
)}
</Dialog>
)}
{article && (
<Dialog
title={article.title}
className="sm:max-w-3xl"
onClose={() => setArticleId(undefined)}
>
<p className="text-xs text-muted-foreground">{article.source}</p>
{article.paragraphs.map((text) => (
<p className="text-sm leading-8" key={text}>
{text}
</p>
))}
</Dialog>
)}
</SidebarProvider>
</TooltipProvider>
)
}
+254
View File
@@ -0,0 +1,254 @@
/* Hallmark · daily workbench · neutral / quiet · P5 H5 E4 S5 R5 V4 */
.home-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.home-main {
min-width: 0;
overflow: hidden;
}
.home-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.home-content {
max-width: 1140px;
margin: auto;
padding: 64px 48px 80px;
}
.home-greeting {
margin-bottom: 32px;
}
.home-greeting h1 {
font-size: clamp(24px, 3vw, 34px);
font-weight: 550;
letter-spacing: -0.035em;
line-height: 1.5;
margin-top: 12px;
}
.home-composer {
border: 1px solid var(--border);
border-radius: 20px;
background: var(--muted);
padding: 16px;
}
.home-composer textarea {
min-height: 130px;
resize: vertical;
border: 0;
box-shadow: none;
background: transparent;
padding: 8px;
font-size: 16px;
line-height: 1.8;
}
.home-composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
}
.home-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: 16px;
}
.home-suggestions button {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted-foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.home-proposal {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
}
.home-proposal p {
flex: 1;
font-size: 14px;
min-width: 140px;
}
.home-status {
min-height: 36px;
padding-top: 12px;
color: var(--muted-foreground);
font-size: 12px;
}
.home-columns {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(240px, 1fr);
gap: 64px;
margin-top: 24px;
}
.home-section {
margin-bottom: 36px;
}
.home-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.home-section-heading h2 {
font-size: 14px;
font-weight: 600;
}
.home-section-heading a {
color: var(--muted-foreground);
padding: 6px;
}
.home-task {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 0;
border-bottom: 1px solid var(--border);
}
.home-check {
padding-top: 2px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-task-body {
flex: 1;
min-width: 0;
font-size: 14px;
line-height: 1.8;
}
.home-task-body summary {
margin-top: 6px;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-steps {
display: grid;
gap: 12px;
padding-top: 14px;
font-size: 13px;
}
.home-task-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 115px;
}
.home-rest summary {
font-size: 13px;
color: var(--muted-foreground);
cursor: pointer;
}
.home-rest summary span {
margin-left: 8px;
}
.home-empty {
font-size: 12px;
color: var(--muted-foreground);
padding: 16px 0;
}
.home-context-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.home-record,
.home-reading {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
text-align: left;
font-size: 13px;
line-height: 1.8;
overflow-wrap: anywhere;
}
.home-reading {
width: 100%;
cursor: pointer;
}
.home-deck-link {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--muted-foreground);
}
@media (max-width: 1000px) {
.home-content {
padding: 40px 28px;
}
.home-columns {
gap: 32px;
}
.home-task-actions {
max-width: 70px;
}
}
@media (max-width: 767px) {
.home-content {
padding: 32px 20px;
}
.home-columns {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.home-greeting h1 {
font-size: 25px;
}
.home-composer {
padding: 10px;
}
.home-composer textarea {
min-height: 150px;
}
.home-task {
gap: 10px;
}
.home-task-actions {
max-width: 60px;
}
.home-suggestions {
gap: 12px;
}
}
.home-brief {
margin-top: 20px;
max-width: 760px;
font-size: 15px;
line-height: 1.95;
overflow-wrap: anywhere;
}
.home-brief button {
text-decoration: underline;
text-decoration-color: var(--border);
text-underline-offset: 4px;
cursor: pointer;
}
.home-brief button:hover {
text-decoration-color: var(--foreground);
}
.home-brief-secondary {
margin-top: 8px;
color: var(--muted-foreground);
font-size: 13px;
}
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { interpretAction } from './interpret-action'
const tasks = [
{ id: 'stretch', title: 'ストレッチ' },
{ id: 'shop', title: '買い物' },
]
const contacts = [
{ id: 'aoki', name: '青木 遥' },
{ id: 'mori', name: '森 直人' },
]
describe('home natural-language proposals', () => {
it.each([
['買い物を上に移動', -1],
['買い物を下に動かして', 1],
])('moves the named task by one position: %s', (input, direction) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'task-move', id: 'shop', direction },
})
})
it('does not reorder a partial task match', () => {
expect(interpretAction('買いを上に移動', tasks, contacts)).toHaveProperty(
'error',
)
})
it.each([
['朝の支度の水を飲むを完了', true],
['朝の支度の水を飲むを未完了にして', false],
])('targets a named routine step: %s', (input, done) => {
expect(
interpretAction(
input,
[{ id: 'morning', title: '朝の支度', steps: ['水を飲む', '着替える'] }],
contacts,
),
).toMatchObject({
action: { type: 'routine-step', id: 'morning', step: '水を飲む', done },
})
})
it('rejects unknown and ambiguous routine steps', () => {
const routines = [
{ id: 'morning', title: '朝の支度', steps: ['水を飲む', '水を飲む'] },
]
expect(
interpretAction('朝の支度の水を飲むを完了', routines, contacts),
).toHaveProperty('error')
expect(
interpretAction('朝の支度の着替えるを完了', routines, contacts),
).toHaveProperty('error')
})
it.each([
['買い物を追加', '買い物', 'focus'],
['追加:買い物', '買い物', 'focus'],
['ルーティンに追加:夜のストレッチ', '夜のストレッチ', 'routine'],
['夜のストレッチをルーティンに追加', '夜のストレッチ', 'routine'],
['余裕があれば本を読む', '本を読む', 'optional'],
])('proposes %s without mutating existing tasks', (input, title, section) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'add-task', title, section },
})
expect(tasks).toHaveLength(2)
})
it.each([
['ストレッチを完了', 'done'],
['ストレッチは終わった', 'done'],
['ストレッチをあとで', 'later'],
['ストレッチを保留', 'later'],
['ストレッチを未完了にして', 'todo'],
])('matches the whole task title for %s', (input, status) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'task-status', id: 'stretch', status },
})
})
it('rejects partial and duplicate task names', () => {
expect(interpretAction('買いを完了', tasks, contacts)).toHaveProperty(
'error',
)
expect(
interpretAction(
'買い物を完了',
[...tasks, { id: 'duplicate', title: '買い物' }],
contacts,
),
).toHaveProperty('error')
})
it('opens the uniquely named contact without sending', () => {
expect(interpretAction('青木さんに返信', tasks, contacts)).toMatchObject({
action: { type: 'contact', id: 'aoki' },
})
expect(interpretAction('青木遥に返信', tasks, contacts)).toMatchObject({
action: { type: 'contact', id: 'aoki' },
})
})
it('rejects ambiguous surnames', () => {
expect(
interpretAction('青木さんに返信', tasks, [
...contacts,
{ id: 'other', name: '青木 太郎' },
]),
).toHaveProperty('error')
})
it.each([
['森さんを相手待ちにして', 'waiting'],
['森さんを保留', 'later'],
])('changes only the named contact for %s', (input, status) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'contact-status', id: 'mori', status },
})
})
it.each([
['メモ:散歩でアイデアを思いついた', 'メモ', '散歩でアイデアを思いついた'],
['再開地点:レビューの続き', '再開地点', 'レビューの続き'],
['体重は65.2kg', '体重', '65.2'],
['薬を飲んだ', '服薬', '服薬した'],
])('records explicit user statements: %s', (input, kind, text) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'journal', kind, text },
})
})
it.each([
'ストレッチを完了にしないで',
'買い物を追加しない',
'青木さんに送信',
'青木さんに返信を送って',
'薬を何錠飲めばいい',
'体重を記録',
'適当に片付けて',
'toString',
'買い物を完了、ストレッチを追加',
'買い物を完了して、ストレッチを追加',
])('leaves unsupported, negative, and compound requests unchanged: %s', (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty('error')
})
it.each([
['連絡を開いて', '/support'],
['記録', '/journal'],
['リーダーを開く', '/inbox'],
['Twitter Liteに移動', '/deck'],
])('recognizes navigation: %s', (input, to) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'navigate', to },
})
})
})
+225
View File
@@ -0,0 +1,225 @@
export type HomeAction =
| {
type: 'add-task'
title: string
section: 'focus' | 'routine' | 'optional'
}
| { type: 'task-status'; id: string; status: 'todo' | 'done' | 'later' }
| { type: 'task-move'; id: string; direction: -1 | 1 }
| { type: 'routine-step'; id: string; step: string; done: boolean }
| { type: 'contact'; id: string }
| { type: 'contact-status'; id: string; status: 'later' | 'waiting' }
| {
type: 'journal'
kind: 'メモ' | '再開地点' | '服薬' | '体重'
text: string
}
| { type: 'navigate'; to: '/support' | '/journal' | '/inbox' | '/deck' }
type Interpretation = { action: HomeAction; label: string } | { error: string }
const unsupported = {
error:
'まだ読み取れません。「買い物を追加」「青木さんに返信」「メモ:気づいたこと」のように、ひとつずつ入力してください。',
}
/** Local, deterministic prototype. Returns a proposal; never performs an action. */
export function interpretAction(
input: string,
tasks: Array<{ id: string; title: string; steps?: string[] }>,
contacts: Array<{ id: string; name: string }>,
): Interpretation {
const text = input
.trim()
.replace(/[。!!]$/, '')
.trim()
if (!text) return { error: 'やりたいことを入力してください。' }
if (
/しない|しなく|せず|しないで|やめ|取り消|取消|ではなく|じゃなく|不要|送信|送って|送る/.test(
text,
)
) {
return {
error: 'この内容は自動で変更できません。対象の画面で確認してください。',
}
}
const navigation: Record<
string,
'/support' | '/journal' | '/inbox' | '/deck'
> = {
連絡: '/support',
記録: '/journal',
リーダー: '/inbox',
'Twitter Lite': '/deck',
デッキ: '/deck',
}
const destination = text.replace(/(?:を開いて|を開く|に移動)$/, '')
const route = Object.hasOwn(navigation, destination)
? navigation[destination]
: undefined
if (route)
return {
action: { type: 'navigate', to: route },
label: `${destination}を開く`,
}
const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s)
if (note?.[2]) {
const kind = note[1] === 'メモ' ? 'メモ' : '再開地点'
const body = note[2].trim()
if (!body) return unsupported
return {
action: { type: 'journal', kind, text: body },
label: `${kind}を記録する`,
}
}
const weight = text.match(
/^体重(?:は|を記録[::]?|[::]|\s)*\s*(\d{2,3}(?:\.\d{1,2})?)\s*(?:kg|キロ)(?:を記録|だった|です)?$/i,
)
if (weight?.[1])
return {
action: { type: 'journal', kind: '体重', text: weight[1] },
label: `体重 ${weight[1]} kg を記録する`,
}
if (
/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(
text,
)
) {
return {
action: { type: 'journal', kind: '服薬', text: '服薬した' },
label: '服薬したことを記録する',
}
}
const contact = text.match(
/^(.+?)(?:さん)?(?:に返信(?:したい|する)?|の返信(?:を開いて|を開く)?|を相手待ち(?:にして|にする)?|を保留(?:にして|にする)?)$/,
)
if (contact?.[1]) {
const name = contact[1].trim()
const matches = contacts.filter(
(item) =>
item.name === name ||
item.name.replace(/\s/g, '') === name ||
item.name.split(/\s+/)[0] === name,
)
const matchingTasks = tasks.filter((task) => task.title === name)
const matchingTask = matchingTasks[0]
if (
matches.length === 0 &&
matchingTasks.length === 1 &&
matchingTask &&
/保留/.test(text)
) {
return {
action: {
type: 'task-status',
id: matchingTask.id,
status: 'later',
},
label: `${name}をあとでにする`,
}
}
if (matches.length > 0 && matchingTasks.length > 0 && /保留/.test(text)) {
return {
error:
'タスクと連絡先の両方に当てはまります。対象の画面で確認してください。',
}
}
const person = matches[0]
if (matches.length !== 1 || !person)
return { error: '連絡先をひとりに絞れません。氏名を入力してください。' }
if (/相手待ち|保留/.test(text)) {
const status = text.includes('相手待ち') ? 'waiting' : 'later'
return {
action: { type: 'contact-status', id: person.id, status },
label: `${person.name}との連絡を${status === 'waiting' ? '相手待ち' : '保留'}にする`,
}
}
return {
action: { type: 'contact', id: person.id },
label: `${person.name}への返信を確認する`,
}
}
const move = text.match(/^(.+?)を(上|下)(?:に移動|に動かして|へ|にして)$/)
if (move?.[1]) {
const title = move[1].trim()
const matches = tasks.filter((task) => task.title === title)
const task = matches[0]
if (matches.length !== 1 || !task)
return {
error:
'タスクをひとつに絞れません。一覧の名前をそのまま指定してください。',
}
return {
action: {
type: 'task-move',
id: task.id,
direction: move[2] === '上' ? -1 : 1,
},
label: `${task.title}をひとつ${move[2]}に移動する`,
}
}
const change = text.match(
/^(.+?)(?:を|は)(完了(?:にして|にする)?|終わった|あとで(?:にして|にする)?|後で(?:にして|にする)?|保留(?:にして|にする)?|未完了(?:にして|にする)?|戻して)$/,
)
if (change?.[1] && change[2]) {
const title = change[1].trim()
const matches = tasks.filter((task) => task.title === title)
const steps = tasks.flatMap((task) =>
(task.steps ?? [])
.filter((step) => `${task.title}の${step}` === title)
.map((step) => ({ id: task.id, step })),
)
if (matches.length + steps.length !== 1)
return {
error:
'タスクをひとつに絞れません。一覧の名前をそのまま指定してください。',
}
const status = /未完了|戻して/.test(change[2])
? 'todo'
: /完了|終わった/.test(change[2])
? 'done'
: 'later'
const step = steps[0]
if (steps.length === 1 && step) {
if (status === 'later')
return { error: 'ルーティンの手順は完了・未完了に変更できます。' }
return {
action: {
type: 'routine-step',
id: step.id,
step: step.step,
done: status === 'done',
},
label: `${title}を${status === 'done' ? '完了' : '未完了'}にする`,
}
}
const task = matches[0]
if (!task) return unsupported
return {
action: { type: 'task-status', id: task.id, status },
label: `${title}を${status === 'done' ? '完了' : status === 'later' ? 'あとで' : '未完了'}にする`,
}
}
const routine =
text.match(/^(.+?)をルーティンに追加(?:して)?$/) ??
text.match(/^ルーティンに追加[::]\s*(.+)$/)
const optional = text.match(/^余裕があれば[、\s]*(.+)$/)
const add =
text.match(/^(.+?)を(?:タスクに)?追加(?:して)?$/) ??
text.match(/^追加[::]\s*(.+)$/)
const title = (routine?.[1] ?? optional?.[1] ?? add?.[1])?.trim()
if (title && !/[。、\n]|(?:してから|それから|と一緒に)/.test(title)) {
const section = routine ? 'routine' : optional ? 'optional' : 'focus'
return {
action: { type: 'add-task', title, section },
label: `「${title}」を${section === 'routine' ? 'ルーティン' : section === 'optional' ? '余裕があれば' : '今日のタスク'}に追加する`,
}
}
return unsupported
}
+6 -11
View File
@@ -7,18 +7,13 @@ import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
import { Textarea } from '#/components/ui/textarea' import { Textarea } from '#/components/ui/textarea'
import { TooltipProvider } from '#/components/ui/tooltip' import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation' import { WorkspaceNavigation } from '#/components/workspace-navigation'
import {
type JournalEntry as Entry,
type EntryKind,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import './journal.css' import './journal.css'
type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
type Entry = {
id: string
kind: EntryKind
text: string
source: string
occurredAt: string
recordedAt: string
correctionOf?: string
}
type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'> type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'>
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' } const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重'] const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
@@ -35,7 +30,7 @@ function formatTime(value: string | undefined) {
} }
export function JournalPage() { export function JournalPage() {
const [entries, setEntries] = useState<Entry[]>([]) const { entries, setEntries } = useWorkspaceState()
const [draft, setDraft] = useState<Draft>(emptyDraft) const [draft, setDraft] = useState<Draft>(emptyDraft)
const [correcting, setCorrecting] = useState<Entry | null>(null) const [correcting, setCorrecting] = useState<Entry | null>(null)
const [reviewing, setReviewing] = useState(false) const [reviewing, setReviewing] = useState(false)
+5 -3
View File
@@ -11,11 +11,14 @@ import {
} from '#/components/ui/sidebar' } from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip' import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation' import { WorkspaceNavigation } from '#/components/workspace-navigation'
import {
type Status,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import { ConversationDetail } from './conversation-detail' import { ConversationDetail } from './conversation-detail'
import { conversations } from './mock-conversations' import { conversations } from './mock-conversations'
import './support.css' import './support.css'
type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
const views = [ const views = [
{ id: 'now', label: '今' }, { id: 'now', label: '今' },
{ id: 'later', label: 'あとで' }, { id: 'later', label: 'あとで' },
@@ -26,10 +29,9 @@ const views = [
export function SupportPage() { export function SupportPage() {
const [view, setView] = useState<string>('now') const [view, setView] = useState<string>('now')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [statuses, setStatuses] = useState<Record<string, Status>>({}) const { statuses, setStatuses, outcomes, setOutcomes } = useWorkspaceState()
const [selectedId, setSelectedId] = useState<string>() const [selectedId, setSelectedId] = useState<string>()
const [reading, setReading] = useState(false) const [reading, setReading] = useState(false)
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
const [notice, setNotice] = useState('') const [notice, setNotice] = useState('')
const matches = conversations.filter((item) => { const matches = conversations.filter((item) => {
const status = statuses[item.id] ?? 'now' const status = statuses[item.id] ?? 'now'
+673
View File
@@ -0,0 +1,673 @@
import {
Check,
ChevronRight,
Cloud,
Droplets,
Grid2X2,
Radio,
RefreshCw,
Thermometer,
TriangleAlert,
Wifi,
Wind,
} from 'lucide-react'
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
type Tone = 'green' | 'blue' | 'purple' | 'yellow'
type Metric = {
name: string
value: string
unit: string
tone: Tone
status?: string
min: string
max: string
}
const metrics: Metric[] = [
{
name: 'Room temperature',
value: '64.0',
unit: '°F',
tone: 'purple',
status: 'Watch',
min: '58.1',
max: '73.6',
},
{
name: 'Humidity',
value: '64',
unit: '%',
tone: 'yellow',
status: 'Watch',
min: '47',
max: '71',
},
{
name: 'CO₂',
value: '676',
unit: 'ppm',
tone: 'blue',
status: 'Good',
min: '437',
max: '1,639',
},
{
name: 'PM1.0',
value: '0.0',
unit: 'µg/m³',
tone: 'purple',
min: '0.0',
max: '4.0',
},
{
name: 'PM2.5',
value: '0.8',
unit: 'µg/m³',
tone: 'green',
status: 'Good',
min: '0.0',
max: '3.2',
},
{
name: 'PM10',
value: '1.0',
unit: 'µg/m³',
tone: 'purple',
min: '0.0',
max: '5.9',
},
{
name: 'TVOC',
value: '73',
unit: 'ppb',
tone: 'green',
status: 'Good',
min: '30',
max: '1,252',
},
{
name: 'NOx index',
value: '1',
unit: '',
tone: 'green',
status: 'Good',
min: '1',
max: '1',
},
{
name: 'Particle count',
value: '217',
unit: 'particles/dL',
tone: 'purple',
min: '8',
max: '817',
},
]
const scores = [
{
name: 'Air quality score',
value: '93',
unit: '',
status: 'Optimal',
change: '−3',
suffix: '',
progress: 0.93,
},
{
name: 'CO₂',
value: '676',
unit: 'ppm',
status: 'Good',
change: '+42',
suffix: 'ppm',
progress: 0.91,
},
{
name: 'PM2.5',
value: '0.8',
unit: 'µg/m³',
status: 'Good',
change: '+0.4',
suffix: 'µg/m³',
progress: 0.96,
},
{
name: 'Comfort index',
value: '80',
unit: '',
status: 'Good',
change: '+2',
suffix: '',
progress: 0.8,
},
]
const series = [
{
name: 'PM2.5',
unit: 'µg/m³',
tone: 'green',
values: [
2, 1, 0, 0, 0, 1, 0, 6, 1, 1, 0, 0, 5, 21, 14, 9, 0, 0, 1, 2, 0, 2, 5, 62,
40, 21, 11, 4, 20, 54,
],
},
{
name: 'CO₂',
unit: 'ppm',
tone: 'blue',
values: [
38, 34, 38, 41, 39, 29, 67, 56, 50, 53, 56, 53, 54, 34, 34, 32, 40, 42,
29, 59, 95, 58, 50, 48, 49, 50, 50, 56, 39, 48,
],
},
{
name: 'Room temperature',
unit: '°F',
tone: 'purple',
values: [
55, 60, 66, 69, 69, 64, 62, 43, 36, 35, 33, 27, 29, 43, 57, 65, 71, 75,
72, 69, 66, 45, 39, 34, 30, 27, 29, 33, 36, 39,
],
},
{
name: 'Humidity',
unit: '%',
tone: 'yellow',
values: [
4, 4, 3, 3, 3, 3, 4, 4, 5, 5, 4, 4, 4, 5, 4, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 4, 4,
],
},
] as const
function points(values: readonly number[], width = 640, height = 190) {
return values
.map(
(value, index) =>
`${(index * width) / (values.length - 1)},${height - (value * height) / 100}`,
)
.join(' ')
}
function Sparkline({ index, tone = 'green' }: { index: number; tone?: Tone }) {
const values =
index === 7
? Array<number>(30).fill(20)
: (series[index % series.length]?.values ?? series[0].values)
return (
<svg
className="vital-sparkline"
viewBox="0 0 300 40"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
points={points(values, 300, 36)}
fill="none"
stroke={`var(--vital-${tone})`}
strokeWidth="1.8"
vectorEffect="non-scaling-stroke"
/>
</svg>
)
}
function Gauge({ value }: { value: number }) {
return (
<svg className="vital-gauge" viewBox="0 0 110 65" aria-hidden="true">
<path
d="M 5 58 A 50 50 0 0 1 105 58"
fill="none"
stroke="var(--vital-border)"
strokeWidth="5"
strokeLinecap="round"
/>
<path
d="M 5 58 A 50 50 0 0 1 105 58"
fill="none"
stroke="var(--vital-green)"
strokeWidth="5"
strokeLinecap="round"
pathLength="100"
strokeDasharray={`${value * 100} 100`}
/>
</svg>
)
}
function Trends() {
const [range, setRange] = useState('48H')
const [metric, setMetric] = useState('Overview')
const visible = series.filter(
(item) => metric === 'Overview' || item.name === metric,
)
const labels =
range === '7D'
? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
: range === '24H'
? ['12 AM', '4 AM', '8 AM', '12 PM', '4 PM', '8 PM']
: [
'Mon 7 PM',
'Tue 3 AM',
'Tue 11 AM',
'Tue 7 PM',
'Wed 3 AM',
'Wed 11 AM',
]
return (
<section
className="vital-trends vital-card"
aria-label="Environmental trends"
>
<div className="vital-card-header">
<h2>Trends</h2>
<fieldset className="vital-segmented" aria-label="Trend period">
{['24H', '48H', '7D'].map((item) => (
<button
type="button"
key={item}
aria-pressed={range === item}
onClick={() => setRange(item)}
>
{item}
</button>
))}
</fieldset>
</div>
<fieldset
className="vital-chart-tabs vital-segmented"
aria-label="Trend metric"
>
{['Overview', ...series.map((item) => item.name)].map((item) => (
<button
type="button"
key={item}
aria-pressed={metric === item}
onClick={() => setMetric(item)}
>
{item}
</button>
))}
</fieldset>
<div className="vital-chart-legend">
{visible.map((item) => (
<span key={item.name} data-tone={item.tone}>
<i />
{item.name} ({item.unit})
</span>
))}
</div>
<svg
className="vital-trend-chart"
viewBox="0 0 740 260"
role="img"
aria-label={`${range} ${metric} simulated environmental readings`}
>
{[0, 1, 2, 3].map((index) => (
<g key={index}>
<line
x1="56"
x2="696"
y1={30 + index * 62}
y2={30 + index * 62}
stroke="var(--vital-border)"
/>
<text
x="46"
y={35 + index * 62}
textAnchor="end"
fill="var(--vital-muted)"
fontSize="12"
>
{['1,500', '1,000', '500', '0'][index]}
</text>
</g>
))}
<text x="6" y="14" fill="var(--vital-muted)" fontSize="12">
ppm / %
</text>
<text x="712" y="14" fill="var(--vital-purple)" fontSize="12">
°F
</text>
<g transform="translate(56,26)">
{visible.map((item) => {
const values =
range === '24H'
? item.values.slice(15)
: range === '7D'
? [...item.values.slice(10), ...item.values.slice(0, 10)]
: item.values
return (
<polyline
key={item.name}
points={points(values)}
fill="none"
stroke={`var(--vital-${item.tone})`}
strokeWidth="2.3"
strokeLinejoin="round"
/>
)
})}
</g>
{labels.map((label, index) => (
<text
key={label}
x={75 + (index * 610) / (labels.length - 1)}
y="242"
fill="var(--vital-muted)"
fontSize="12"
textAnchor="middle"
>
{label}
</text>
))}
</svg>
</section>
)
}
function AirMonitor() {
const [synced, setSynced] = useState(false)
return (
<section className="vital-monitor vital-card">
<div className="vital-card-header">
<h2>Air monitor</h2>
<button
className="vital-outline-button"
type="button"
onClick={() => setSynced(true)}
>
<RefreshCw size={15} />
{synced ? 'Synced' : 'Sync now'}
</button>
</div>
<div className="vital-device">
<Radio className="vital-device-icon" size={66} strokeWidth={1} />
<div>
<div className="vital-device-heading">
<strong>Room</strong>
<span data-tone="green">● Connected</span>
</div>
<p>Air monitor</p>
<p role="status">
{synced ? 'Sample readings refreshed' : 'Updated just now'}
</p>
<div className="vital-device-badges">
{['CO₂', 'PM2.5', 'Temp', 'Humidity', 'VOC', 'NOx'].map((item) => (
<span key={item}>{item}</span>
))}
</div>
</div>
</div>
<p className="vital-monitor-footnote">
{synced
? '71 sample readings synced. No device connected.'
: '71 sample readings · simulated device'}
</p>
</section>
)
}
function Insights() {
return (
<aside className="vital-env-insights">
<section className="vital-insights vital-card">
<div className="vital-card-header">
<h2>Insights</h2>
<span>today</span>
</div>
<div className="vital-insight">
<TriangleAlert data-tone="yellow" size={20} />
<div
className="vital-obscured"
role="img"
aria-label="Text obscured in reference image"
>
<i />
<i />
<i />
<i />
</div>
</div>
<div className="vital-insight">
<Check data-tone="green" size={20} />
<div>
<h3>Particulates stayed low</h3>
<p>
The sample PM2.5 reading is 0.8 µg/m³. View its recent pattern in
Trends.
</p>
</div>
</div>
<div className="vital-insight">
<Check data-tone="green" size={20} />
<div>
<h3>Humidity through the day</h3>
<p>
The room’s current sample reading is 64%. Select Humidity to
explore the chart.
</p>
</div>
</div>
<div className="vital-insight">
<Check data-tone="green" size={20} />
<div>
<h3>Room temperature</h3>
<p>
The current sample temperature is 64.0°F. Recent readings are
shown alongside the other room metrics.
</p>
</div>
</div>
</section>
<section className="vital-insights vital-card">
<h2>Recommended actions</h2>
<div className="vital-insight">
<Grid2X2 size={20} />
<div
className="vital-obscured"
role="img"
aria-label="Text obscured in reference image"
>
<i />
<i />
</div>
</div>
<div className="vital-insight">
<Droplets data-tone="yellow" size={20} />
<div>
<h3>Review room humidity</h3>
<p>Explore the climate controls with simulated settings.</p>
</div>
</div>
</section>
</aside>
)
}
function ClimateControl() {
const [temperature, setTemperature] = useState(68)
const [mode, setMode] = useState('Auto')
const [purifier, setPurifier] = useState(true)
return (
<section className="vital-climate vital-card">
<h2>Room climate</h2>
<p>Simulated controls. Changes stay on this screen.</p>
<label htmlFor="vital-temperature">
Target temperature <strong>{temperature}°F</strong>
</label>
<input
id="vital-temperature"
type="range"
min="60"
max="80"
value={temperature}
onChange={(event) => setTemperature(Number(event.target.value))}
/>
<fieldset className="vital-segmented" aria-label="Climate mode">
{['Auto', 'Cool', 'Heat', 'Off'].map((item) => (
<button
type="button"
key={item}
aria-pressed={item === mode}
onClick={() => setMode(item)}
>
{item}
</button>
))}
</fieldset>
<button
className="vital-outline-button"
type="button"
aria-pressed={purifier}
onClick={() => setPurifier(!purifier)}
>
<Wind size={18} />
Air purifier · {purifier ? 'On' : 'Off'}
</button>
</section>
)
}
export function EnvironmentPanel() {
const [tab, setTab] = useState('Overview')
const [detail, setDetail] = useState<{
name: string
value: string
unit: string
} | null>(null)
return (
<>
<fieldset
className="vital-env-tabs vital-segmented"
aria-label="Environment view"
>
{['Overview', 'Climate control'].map((item) => (
<button
type="button"
key={item}
aria-pressed={tab === item}
onClick={() => setTab(item)}
>
{item}
</button>
))}
</fieldset>
{tab === 'Climate control' ? (
<ClimateControl />
) : (
<div className="vital-env-layout">
<div className="vital-env-main">
<div className="vital-env-scores">
{scores.map((score, index) => (
<button
type="button"
className="vital-env-score vital-card"
key={score.name}
onClick={() => setDetail(score)}
>
<span className="vital-score-heading">
{score.name}
<ChevronRight size={16} />
</span>
<div className="vital-score-reading">
<Gauge value={score.progress} />
<div>
<strong>{score.value}</strong>
<small>{score.unit}</small>
<p data-tone="green">{score.status}</p>
</div>
</div>
<p className="vital-delta">
<span data-tone={index === 3 ? 'green' : 'yellow'}>
{score.change}
</span>{' '}
{score.suffix} vs yesterday
</p>
<Sparkline index={index} />
</button>
))}
</div>
<div className="vital-env-metrics">
{metrics.map((metric, index) => {
const MetricIcon =
index === 0
? Thermometer
: index === 1
? Droplets
: index === 2
? Cloud
: Grid2X2
return (
<button
type="button"
className="vital-metric vital-card"
key={metric.name}
onClick={() => setDetail(metric)}
>
<MetricIcon size={17} data-tone={metric.tone} />
<div className="vital-metric-content">
<span>{metric.name}</span>
<div className="vital-metric-reading">
<strong>{metric.value}</strong>
<small>{metric.unit}</small>
{metric.status && (
<em
data-tone={
metric.status === 'Watch' ? 'yellow' : 'green'
}
>
{metric.status}
</em>
)}
</div>
</div>
<div className="vital-metric-spark">
<small>{metric.min}</small>
<Sparkline index={index} tone={metric.tone} />
<small>{metric.max}</small>
</div>
</button>
)
})}
<div className="vital-metric vital-card vital-room">
<Wifi size={17} data-tone="green" />
<div>
<span>Room</span>
<strong>Connected</strong>
<p>
<span data-tone="green">●</span> Updated just now
</p>
</div>
</div>
</div>
<div className="vital-env-bottom">
<Trends />
<AirMonitor />
</div>
</div>
<Insights />
</div>
)}
{detail && (
<Dialog title={detail.name} onClose={() => setDetail(null)}>
<p className="vital-detail-value">
{detail.value} <small>{detail.unit}</small>
</p>
<p>
This is a sample reading for the dashboard preview. No health or
environmental device is connected.
</p>
<button
className="vital-outline-button"
type="button"
onClick={() => setDetail(null)}
>
Back to overview
</button>
</Dialog>
)}
</>
)
}
+445
View File
@@ -0,0 +1,445 @@
import {
Activity,
Bell,
ChartNoAxesCombined,
ChevronDown,
ChevronRight,
CircleHelp,
ClipboardList,
Heart,
House,
Leaf,
Lightbulb,
Menu,
Plus,
Shield,
Stethoscope,
UserRound,
Utensils,
Waves,
Wind,
X,
} from 'lucide-react'
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { SidebarProvider } from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { EnvironmentPanel } from './environment-panel'
import './vitals.css'
const systems = [
{ name: 'Cardiovascular', score: 68, count: 19, tone: 'red', icon: Heart },
{ name: 'Liver', score: 95, count: 11, tone: 'yellow', icon: Shield },
{ name: 'Immune', score: 95, count: 19, tone: 'purple', icon: Shield },
{ name: 'Metabolic', score: 92, count: 6, tone: 'green', icon: Waves },
{ name: 'Endocrine', score: 94, count: 25, tone: 'yellow', icon: Waves },
{ name: 'Blood', score: 100, count: 17, tone: 'red', icon: Activity },
{ name: 'Kidney', score: 98, count: 33, tone: 'purple', icon: Waves },
{ name: 'Nutrients', score: 97, count: 17, tone: 'green', icon: Leaf },
] as const
const nav = [
{ name: 'Home', icon: House },
{ name: 'Body', icon: UserRound },
{ name: 'Vitals', icon: Activity },
{ name: 'Nutrition', icon: Utensils },
{ name: 'Care', icon: Stethoscope },
{ name: 'Environment', icon: Wind },
{ name: 'Lights', icon: Lightbulb },
{ name: 'Records', icon: ClipboardList },
{ name: 'Analysis', icon: ChartNoAxesCombined },
{ name: 'Profile', icon: UserRound },
]
export function VitalsPage() {
const [section, setSection] = useState('Body')
const [mobileMenu, setMobileMenu] = useState(false)
const [detail, setDetail] = useState<string>()
const [tab, setTab] = useState('Overview')
const [history, setHistory] = useState(false)
return (
<TooltipProvider>
<SidebarProvider className="vital-workspace">
<WorkspaceNavigation />
<div className="vital-app">
<aside
className={`vital-navigation ${mobileMenu ? 'is-open' : ''}`}
aria-label="Health navigation"
>
<div className="vital-brand">
<Activity />
<span>
1F2T <span>Health</span>
</span>
<button
className="vital-mobile-close"
type="button"
aria-label="メニューを閉じる"
onClick={() => setMobileMenu(false)}
>
<X />
</button>
</div>
<nav>
{nav.map((item) => (
<button
key={item.name}
type="button"
className={section === item.name ? 'is-active' : ''}
onClick={() => {
if (item.name === 'Body' || item.name === 'Environment') {
setSection(item.name)
setMobileMenu(false)
} else setDetail(item.name)
}}
>
<item.icon />
<span>{item.name}</span>
</button>
))}
</nav>
<div className="vital-navigation-bottom">
<button
type="button"
onClick={() => setDetail('Log with Claude')}
>
<Plus />
Log with Claude
</button>
<button type="button" onClick={() => setDetail('Help')}>
<CircleHelp />
Help
</button>
<p>Reference preview</p>
</div>
</aside>
<div className="vital-surface">
<header className="vital-topbar">
<button
className="vital-mobile-toggle"
type="button"
aria-label="Healthメニューを開く"
onClick={() => setMobileMenu(true)}
>
<Menu />
</button>
<h1>{section}</h1>
<div className="vital-topbar-right">
<span className="vital-pulse">
<Heart />
86 bpm
</span>
<button
type="button"
aria-label="通知を開く"
onClick={() => setDetail('Notifications')}
>
<Bell />
</button>
<button
type="button"
className="vital-profile"
onClick={() => setDetail('Profile')}
>
<span className="vital-avatar">Y</span>Yuta
<ChevronDown />
</button>
</div>
</header>
<main className="vital-scroll">
{section === 'Environment' ? (
<EnvironmentPanel />
) : (
<div className="vital-body-page">
<div className="vital-tabs">
{[
'Overview',
'Cardiovascular',
'Metabolic',
'Liver',
'Kidney',
'Endocrine',
'Immune',
'Blood',
'Nutrients',
].map((name) => (
<button
type="button"
key={name}
className={tab === name ? 'is-active' : ''}
onClick={() => setTab(name)}
>
{name}
</button>
))}
</div>
<div className="vital-body-columns">
<div className="vital-card vital-body-main">
<div className="vital-lab-row">
<section className="vital-lab-score">
<header>
<h2>Lab score</h2>
<span>Latest draw · Jul 28</span>
</header>
<div className="vital-lab-value">
<div className="vital-ring">
<svg viewBox="0 0 160 160" aria-hidden="true">
<circle cx="80" cy="80" r="67" />
<circle
cx="80"
cy="80"
r="67"
pathLength="100"
strokeDasharray="92 100"
/>
</svg>
<span>
92<small>Lab score</small>
</span>
</div>
<div className="vital-lab-description">
<h3>
Optimal <span>▾ 1</span>
</h3>
<p>
7 of 8 systems optimal · 21 of 161 biomarkers
out of range
</p>
<div className="vital-lab-change">
<div>
<span>Change ⓘ</span>
<strong>
−1 <small>pt</small>
</strong>
<p>Since last draw</p>
</div>
<svg
viewBox="0 0 190 65"
aria-label="Lab score trend"
role="img"
>
<path
d="M2 12 L24 12 Q34 8 39 29 T65 57 Q76 66 85 20 T105 19 L132 17 Q145 17 153 49 T174 48 L188 51"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</div>
</div>
</div>
</section>
<section className="vital-recent">
<h2>Recent results</h2>
{[1, 2, 3].map((index) => (
<button
key={index}
type="button"
onClick={() => setDetail('Recent result')}
>
<span className="vital-warning">△</span>
<span
className="vital-obscured"
role="img"
aria-label="Reference text obscured"
>
<i />
<i />
</span>
<ChevronRight />
</button>
))}
</section>
</div>
<section className="vital-systems">
<header>
<h2>{tab === 'Overview' ? 'Body systems' : tab}</h2>
<div className="vital-tabs">
<button
type="button"
className={!history ? 'is-active' : ''}
onClick={() => setHistory(false)}
>
Systems
</button>
<button
type="button"
className={history ? 'is-active' : ''}
onClick={() => setHistory(true)}
>
Lab history
</button>
</div>
</header>
{history ? (
<div className="vital-history">
<h3>Lab history</h3>
{[
['Jul 28', '92'],
['May 16', '93'],
['Mar 04', '89'],
].map(([date, score]) => (
<div key={date}>
<span>{date}</span>
<span>
{score}
<small> Lab score</small>
</span>
</div>
))}
</div>
) : (
<div className="vital-anatomy-grid">
<div className="vital-system-list">
{systems
.slice(0, 4)
.filter(
(item) =>
tab === 'Overview' || tab === item.name,
)
.map((item) => (
<SystemCard
key={item.name}
item={item}
onSelect={() => setDetail(item.name)}
/>
))}
</div>
<div
className="vital-anatomy"
role="img"
aria-label="人体と各系統の位置を示す参照図"
>
<img src="/vitals/body-reference.jpg" alt="" />
</div>
<div className="vital-system-list">
{systems
.slice(4)
.filter(
(item) =>
tab === 'Overview' || tab === item.name,
)
.map((item) => (
<SystemCard
key={item.name}
item={item}
onSelect={() => setDetail(item.name)}
/>
))}
</div>
</div>
)}
</section>
</div>
<aside className="vital-card vital-insights">
<h2>Insights</h2>
<div className="vital-insight-feature">
<span className="vital-red">△</span>
<div
className="vital-obscured"
role="img"
aria-label="Reference text obscured"
>
<i />
<i />
<i />
</div>
<button type="button" onClick={() => setHistory(true)}>
View lab history
</button>
</div>
<h3>Summary</h3>
<div className="vital-summary">
<span>
<b className="vital-dot vital-red" />4 need attention
</span>
<span>
<b className="vital-dot vital-yellow" />3 to watch
</span>
<span>
<b className="vital-dot vital-green" />4 going well
</span>
</div>
<h3>More insights</h3>
{[0, 1, 2].map((index) => (
<div className="vital-insight-placeholder" key={index}>
<b
className={`vital-dot ${index === 2 ? 'vital-yellow' : 'vital-red'}`}
/>
<div
className="vital-obscured"
role="img"
aria-label="Reference text obscured"
>
<i />
<i />
<i />
</div>
</div>
))}
<button
className="vital-text-button"
type="button"
onClick={() => setDetail('Insights')}
>
Show 6 more
</button>
<p className="vital-updated">Reference data · Sep 25</p>
</aside>
</div>
</div>
)}
</main>
</div>
</div>
{detail && (
<Dialog
title={detail}
className="sm:max-w-xl"
onClose={() => setDetail(undefined)}
>
<p className="text-sm leading-relaxed text-muted-foreground">
画像をもとにした表示サンプルです。実際の測定・診断・機器の操作には接続していません。
</p>
{systems.find((item) => item.name === detail) && (
<p className="text-3xl">
{systems.find((item) => item.name === detail)?.score}
<span className="ml-2 text-sm text-muted-foreground">
score
</span>
</p>
)}
<Button variant="outline" onClick={() => setDetail(undefined)}>
閉じる
</Button>
</Dialog>
)}
</SidebarProvider>
</TooltipProvider>
)
}
function SystemCard({
item,
onSelect,
}: {
item: (typeof systems)[number]
onSelect: () => void
}) {
return (
<button type="button" className="vital-system-card" onClick={onSelect}>
<span className={`vital-system-icon vital-${item.tone}`}>
<item.icon />
</span>
<span className="vital-system-label">
{item.name}
<small>{item.count} biomarkers</small>
</span>
<span className="vital-system-score">
{item.score}
<small className={item.score < 70 ? 'vital-yellow' : 'vital-green'}>
● {item.score < 70 ? 'Watch' : 'Optimal'}
</small>
</span>
</button>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import {
createContext,
type Dispatch,
type ReactNode,
type SetStateAction,
useContext,
useState,
} from 'react'
export type Task = {
id: string
title: string
section: 'focus' | 'routine' | 'optional'
status: 'todo' | 'done' | 'later'
steps?: string[]
completedSteps?: string[]
}
export type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
export type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
export type JournalEntry = {
id: string
kind: EntryKind
text: string
source: string
occurredAt: string
recordedAt: string
correctionOf?: string
}
type WorkspaceState = {
tasks: Task[]
setTasks: Dispatch<SetStateAction<Task[]>>
statuses: Record<string, Status>
setStatuses: Dispatch<SetStateAction<Record<string, Status>>>
outcomes: Record<string, string>
setOutcomes: Dispatch<SetStateAction<Record<string, string>>>
entries: JournalEntry[]
setEntries: Dispatch<SetStateAction<JournalEntry[]>>
}
const WorkspaceContext = createContext<WorkspaceState | null>(null)
export function WorkspaceStateProvider({ children }: { children: ReactNode }) {
const [tasks, setTasks] = useState<Task[]>([
{
id: 'review',
title: 'レビュー資料を確認する',
section: 'focus',
status: 'todo',
},
{
id: 'morning',
title: '朝の支度',
section: 'routine',
status: 'todo',
steps: ['水を飲む', '身支度をする', '朝食をとる'],
},
{ id: 'walk', title: '散歩する', section: 'optional', status: 'todo' },
])
const [statuses, setStatuses] = useState<Record<string, Status>>({})
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
const [entries, setEntries] = useState<JournalEntry[]>([])
return (
<WorkspaceContext.Provider
value={{
tasks,
setTasks,
statuses,
setStatuses,
outcomes,
setOutcomes,
entries,
setEntries,
}}
>
{children}
</WorkspaceContext.Provider>
)
}
export function useWorkspaceState() {
const state = useContext(WorkspaceContext)
if (!state) throw new Error('WorkspaceStateProvider is required')
return state
}
+21
View File
@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as VitalsRouteImport } from './routes/vitals'
import { Route as SupportRouteImport } from './routes/support' import { Route as SupportRouteImport } from './routes/support'
import { Route as JournalRouteImport } from './routes/journal' import { Route as JournalRouteImport } from './routes/journal'
import { Route as InboxRouteImport } from './routes/inbox' import { Route as InboxRouteImport } from './routes/inbox'
@@ -17,6 +18,11 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback' import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events' import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
const VitalsRoute = VitalsRouteImport.update({
id: '/vitals',
path: '/vitals',
getParentRoute: () => rootRouteImport,
} as any)
const SupportRoute = SupportRouteImport.update({ const SupportRoute = SupportRouteImport.update({
id: '/support', id: '/support',
path: '/support', path: '/support',
@@ -59,6 +65,7 @@ export interface FileRoutesByFullPath {
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/journal': typeof JournalRoute '/journal': typeof JournalRoute
'/support': typeof SupportRoute '/support': typeof SupportRoute
'/vitals': typeof VitalsRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -68,6 +75,7 @@ export interface FileRoutesByTo {
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/journal': typeof JournalRoute '/journal': typeof JournalRoute
'/support': typeof SupportRoute '/support': typeof SupportRoute
'/vitals': typeof VitalsRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -78,6 +86,7 @@ export interface FileRoutesById {
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/journal': typeof JournalRoute '/journal': typeof JournalRoute
'/support': typeof SupportRoute '/support': typeof SupportRoute
'/vitals': typeof VitalsRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -89,6 +98,7 @@ export interface FileRouteTypes {
| '/inbox' | '/inbox'
| '/journal' | '/journal'
| '/support' | '/support'
| '/vitals'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -98,6 +108,7 @@ export interface FileRouteTypes {
| '/inbox' | '/inbox'
| '/journal' | '/journal'
| '/support' | '/support'
| '/vitals'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
id: id:
@@ -107,6 +118,7 @@ export interface FileRouteTypes {
| '/inbox' | '/inbox'
| '/journal' | '/journal'
| '/support' | '/support'
| '/vitals'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -117,12 +129,20 @@ export interface RootRouteChildren {
InboxRoute: typeof InboxRoute InboxRoute: typeof InboxRoute
JournalRoute: typeof JournalRoute JournalRoute: typeof JournalRoute
SupportRoute: typeof SupportRoute SupportRoute: typeof SupportRoute
VitalsRoute: typeof VitalsRoute
ApiResearchEventsRoute: typeof ApiResearchEventsRoute ApiResearchEventsRoute: typeof ApiResearchEventsRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/vitals': {
id: '/vitals'
path: '/vitals'
fullPath: '/vitals'
preLoaderRoute: typeof VitalsRouteImport
parentRoute: typeof rootRouteImport
}
'/support': { '/support': {
id: '/support' id: '/support'
path: '/support' path: '/support'
@@ -181,6 +201,7 @@ const rootRouteChildren: RootRouteChildren = {
InboxRoute: InboxRoute, InboxRoute: InboxRoute,
JournalRoute: JournalRoute, JournalRoute: JournalRoute,
SupportRoute: SupportRoute, SupportRoute: SupportRoute,
VitalsRoute: VitalsRoute,
ApiResearchEventsRoute: ApiResearchEventsRoute, ApiResearchEventsRoute: ApiResearchEventsRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute, OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
} }
+6 -1
View File
@@ -5,6 +5,7 @@ import {
Outlet, Outlet,
Scripts, Scripts,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { WorkspaceStateProvider } from '#/features/workspace/workspace-state'
import appCss from '../styles.css?url' import appCss from '../styles.css?url'
type RouterContext = { queryClient: QueryClient } type RouterContext = { queryClient: QueryClient }
@@ -33,7 +34,11 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}) })
function RootContent() { function RootContent() {
return <Outlet /> return (
<WorkspaceStateProvider>
<Outlet />
</WorkspaceStateProvider>
)
} }
function RootDocument({ children }: { children: React.ReactNode }) { function RootDocument({ children }: { children: React.ReactNode }) {
+2 -3
View File
@@ -1,4 +1,3 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { DeckPage } from '#/features/decks/deck-page' import { HomePage } from '#/features/home/home-page'
export const Route = createFileRoute('/')({ component: HomePage })
export const Route = createFileRoute('/')({ component: DeckPage })
+1 -1
View File
@@ -29,7 +29,7 @@ export const Route = createFileRoute('/oauth/mastodon/callback')({
return new Response(null, { return new Response(null, {
status: 303, status: 303,
headers: { headers: {
location: `/?mastodon=${outcome}`, location: `/deck?mastodon=${outcome}`,
'cache-control': 'no-store', 'cache-control': 'no-store',
'referrer-policy': 'no-referrer', 'referrer-policy': 'no-referrer',
}, },
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { VitalsPage } from '#/features/vitals/vitals-page'
export const Route = createFileRoute('/vitals')({ component: VitalsPage })