feat: add daily home and vitals dashboard prototypes
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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の認可を完了できませんでした。',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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ページ目から')
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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(
|
||||||
'更新した調査',
|
'更新した調査',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 |
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { VitalsPage } from '#/features/vitals/vitals-page'
|
||||||
|
export const Route = createFileRoute('/vitals')({ component: VitalsPage })
|
||||||
Reference in New Issue
Block a user