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",
|
||||
"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
|
||||
and backend restarts
|
||||
|
||||
Both `/` and `/deck` open the deck workspace. A shared sidebar also opens
|
||||
`/` opens the daily home with a greeting, natural-language action proposals,
|
||||
flexible tasks/routines, messages, notes, and reading. `/deck` opens Twitter Lite.
|
||||
The shared sidebar also opens
|
||||
the article reader at `/inbox`. This UI-only prototype shows articles inferred
|
||||
from past reading, searches, and research, with no manual interest settings.
|
||||
Articles are fictional samples; search and article selection work locally.
|
||||
@@ -35,6 +37,9 @@ The shared sidebar also contains `/support` (communication support) and `/journa
|
||||
state to preview reply review, scheduling, deferral, recording, and corrections.
|
||||
See [execution support](docs/execution-support.md) for scope and limitations.
|
||||
|
||||
`/vitals` is a separate [health/environment reference preview](docs/vitals-preview.md)
|
||||
with sample scores, body systems, environmental charts, and simulated controls.
|
||||
|
||||
The former SNS reader, search,
|
||||
list, user-profile, and conversation routes have been removed. Original-post
|
||||
links on post cards open their source site; SNS reply threads are not rendered
|
||||
@@ -225,7 +230,7 @@ data is left untouched. See [the deck model and persistence contract](docs/resea
|
||||
|
||||
A WebMCP-enabled browser exposes `list_connections`, `list_decks`, `get_deck`,
|
||||
`set_deck`, `save_deck`, `select_deck`, `delete_deck`, `get_column_posts`, and
|
||||
`load_more_column` on both deck routes. Discover connection IDs first.
|
||||
`load_more_column` on `/deck`. Discover connection IDs first.
|
||||
`set_deck` creates a temporary view when `deckId` is omitted. `save_deck`
|
||||
persists it. Replacing or deleting a saved deck requires its `expectedRevision`.
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
|
||||
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts
|
||||
and in-memory state. Reloading or leaving the route resets the prototype state.
|
||||
and in-memory state. Reloading resets the prototype state. Tasks, communication status, outcomes, and
|
||||
journal entries are shared between routes for the current browser session.
|
||||
No messages, calendar events, medical records, or Rensheng pages are written.
|
||||
|
||||
## Reference and scope
|
||||
@@ -32,3 +33,32 @@ dose, weight-target, or treatment recommendations.
|
||||
The UI uses existing shadcn Base UI components and neutral theme tokens.
|
||||
Shared account management continues to use the existing connection system;
|
||||
the new communication and journal prototypes do not consume those accounts.
|
||||
|
||||
## Daily home
|
||||
|
||||
`/` presents a greeting, a large input, and a flexible plan grouped as
|
||||
「まずこれ」「いつものこと」「余裕があれば」. There are no fixed time slots.
|
||||
Tasks can be reordered, completed, deferred, restored, or followed as routine
|
||||
steps. Communications can be reviewed directly in a home dialog; changes also
|
||||
appear in `/support`. Journal entries and corrections appear on the home, and
|
||||
reader samples open for reading. Twitter Lite remains a separate work surface.
|
||||
|
||||
The input currently uses a deterministic Japanese mock interpreter, not an LLM.
|
||||
It proposes typed actions for task creation/status/reordering, routine steps,
|
||||
contact reply/review or deferral, notes/weight/medication records, and navigation.
|
||||
Confirming a proposal dispatches the corresponding operation. Unknown or
|
||||
ambiguous input does not change state. Message sending still goes through the
|
||||
existing simulated review UI; this prototype performs no external send.
|
||||
|
||||
Examples: 「買い物を追加」「朝の支度をあとで」「青木さんに返信」
|
||||
「メモ:次は資料の2ページ目から」「体重は65.2kg」「リーダーを開く」.
|
||||
|
||||
The interaction reference is [Tiimo visual planning](https://www.tiimoapp.com/product/visual-planning),
|
||||
reviewed on 2026-09-28: combined routines and tasks, flexible reordering, small
|
||||
steps, and natural-language planning. Unlike its timed scheduling options, this
|
||||
prototype deliberately keeps the day free of precise times as requested.
|
||||
|
||||
The home greeting includes a short prose brief derived from current task and
|
||||
communication state. Completed or deferred items leave the brief; the next
|
||||
contact opens directly from the text. A current resumption note takes precedence
|
||||
over the optional-task suggestion. This is local templated copy, not an LLM report.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Workspace
|
||||
|
||||
Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui
|
||||
`/deck` renders the research workspace; `/` is the integrated daily home. A collapsible shadcn/ui
|
||||
sidebar based on `sidebar-09` shares its navigation rail with the article reader
|
||||
at `/inbox`. Within Twitter Lite, it switches between research chat and deck management.
|
||||
Deck management switches decks and adds columns. The avatar at the bottom of the
|
||||
|
||||
@@ -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) =>
|
||||
request.url().includes('/_serverFn/'),
|
||||
)
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
const url = (await serverRequest).url()
|
||||
expect(
|
||||
(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from '../fixtures'
|
||||
test('opens account management after hydration without inheriting real OAuth configuration', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
await expect(
|
||||
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
|
||||
).toBeEnabled()
|
||||
@@ -51,7 +51,7 @@ test('rejects an unbound OAuth callback without contacting an instance', async (
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code')
|
||||
await expect(page).toHaveURL(/\/\?mastodon=failed$/)
|
||||
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/)
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Mastodonの認可を完了できませんでした。',
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ async function addColumn(
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
|
||||
).toBeEnabled()
|
||||
@@ -301,7 +301,7 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
|
||||
})
|
||||
try {
|
||||
const device = await other.newPage()
|
||||
await device.goto('/')
|
||||
await device.goto('/deck')
|
||||
await page.getByRole('button', { name: '名前を変更' }).click()
|
||||
await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前')
|
||||
await device.getByRole('button', { name: '名前を変更' }).click()
|
||||
|
||||
@@ -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 ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
const snapshots = await page.evaluate(async () => {
|
||||
const connect = () =>
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
await showDeckManagement(page)
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
const accounts = page.getByRole('combobox', {
|
||||
@@ -215,7 +215,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
|
||||
a11y,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
await showDeckManagement(page)
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
const accounts = page.getByRole('combobox', {
|
||||
@@ -322,7 +322,7 @@ test('explains unconfigured Codex and prevents starting research without affecti
|
||||
page,
|
||||
a11y,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
const originalTitle = await heading.textContent()
|
||||
|
||||
@@ -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 }) => {
|
||||
pageErrors = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
await expect
|
||||
.poll(() => toolNames(page))
|
||||
.toEqual([
|
||||
@@ -141,7 +141,7 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
|
||||
})
|
||||
try {
|
||||
const device = await other.newPage()
|
||||
await device.goto('/')
|
||||
await device.goto('/deck')
|
||||
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
|
||||
'更新した調査',
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ test('keeps the sidebar, composer and deck reachable at compact widths', async (
|
||||
page,
|
||||
a11y,
|
||||
}, testInfo) => {
|
||||
await page.goto('/')
|
||||
await page.goto('/deck')
|
||||
for (const width of [320, 375, 414, 768]) {
|
||||
await page.setViewportSize({ width, height: 850 })
|
||||
await showResearch(page)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -54,7 +54,7 @@ function Workspace({
|
||||
<>
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
|
||||
className="overflow-hidden transition-none *:data-[sidebar=sidebar]:flex-row"
|
||||
>
|
||||
<WorkspaceNavigation
|
||||
hasTemporaryDecks={hasTemporaryDecks}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import {
|
||||
Activity,
|
||||
House,
|
||||
Inbox,
|
||||
Layers3,
|
||||
MessagesSquare,
|
||||
@@ -36,6 +38,13 @@ export function WorkspaceNavigation({
|
||||
const [managingAccounts, setManagingAccounts] = useState(false)
|
||||
const pathname = useLocation({ select: (location) => location.pathname })
|
||||
const navigation = [
|
||||
{ title: 'ホーム', to: '/', icon: House, active: pathname === '/' },
|
||||
{
|
||||
title: 'バイタル',
|
||||
to: '/vitals',
|
||||
icon: Activity,
|
||||
active: pathname === '/vitals',
|
||||
},
|
||||
{
|
||||
title: '連絡',
|
||||
to: '/support',
|
||||
@@ -58,7 +67,7 @@ export function WorkspaceNavigation({
|
||||
title: 'Twitter Lite',
|
||||
to: '/deck',
|
||||
icon: Search,
|
||||
active: pathname === '/' || pathname === '/deck',
|
||||
active: pathname === '/deck',
|
||||
},
|
||||
] as const
|
||||
|
||||
|
||||
@@ -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 { TooltipProvider } from '#/components/ui/tooltip'
|
||||
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||
import {
|
||||
type JournalEntry as Entry,
|
||||
type EntryKind,
|
||||
useWorkspaceState,
|
||||
} from '#/features/workspace/workspace-state'
|
||||
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'>
|
||||
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
|
||||
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
|
||||
@@ -35,7 +30,7 @@ function formatTime(value: string | undefined) {
|
||||
}
|
||||
|
||||
export function JournalPage() {
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const { entries, setEntries } = useWorkspaceState()
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft)
|
||||
const [correcting, setCorrecting] = useState<Entry | null>(null)
|
||||
const [reviewing, setReviewing] = useState(false)
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from '#/components/ui/sidebar'
|
||||
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||
import {
|
||||
type Status,
|
||||
useWorkspaceState,
|
||||
} from '#/features/workspace/workspace-state'
|
||||
import { ConversationDetail } from './conversation-detail'
|
||||
import { conversations } from './mock-conversations'
|
||||
import './support.css'
|
||||
|
||||
type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
|
||||
const views = [
|
||||
{ id: 'now', label: '今' },
|
||||
{ id: 'later', label: 'あとで' },
|
||||
@@ -26,10 +29,9 @@ const views = [
|
||||
export function SupportPage() {
|
||||
const [view, setView] = useState<string>('now')
|
||||
const [query, setQuery] = useState('')
|
||||
const [statuses, setStatuses] = useState<Record<string, Status>>({})
|
||||
const { statuses, setStatuses, outcomes, setOutcomes } = useWorkspaceState()
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [reading, setReading] = useState(false)
|
||||
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
|
||||
const [notice, setNotice] = useState('')
|
||||
const matches = conversations.filter((item) => {
|
||||
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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as VitalsRouteImport } from './routes/vitals'
|
||||
import { Route as SupportRouteImport } from './routes/support'
|
||||
import { Route as JournalRouteImport } from './routes/journal'
|
||||
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 ApiResearchEventsRouteImport } from './routes/api/research/events'
|
||||
|
||||
const VitalsRoute = VitalsRouteImport.update({
|
||||
id: '/vitals',
|
||||
path: '/vitals',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SupportRoute = SupportRouteImport.update({
|
||||
id: '/support',
|
||||
path: '/support',
|
||||
@@ -59,6 +65,7 @@ export interface FileRoutesByFullPath {
|
||||
'/inbox': typeof InboxRoute
|
||||
'/journal': typeof JournalRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/vitals': typeof VitalsRoute
|
||||
'/api/research/events': typeof ApiResearchEventsRoute
|
||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
@@ -68,6 +75,7 @@ export interface FileRoutesByTo {
|
||||
'/inbox': typeof InboxRoute
|
||||
'/journal': typeof JournalRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/vitals': typeof VitalsRoute
|
||||
'/api/research/events': typeof ApiResearchEventsRoute
|
||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
@@ -78,6 +86,7 @@ export interface FileRoutesById {
|
||||
'/inbox': typeof InboxRoute
|
||||
'/journal': typeof JournalRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/vitals': typeof VitalsRoute
|
||||
'/api/research/events': typeof ApiResearchEventsRoute
|
||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
@@ -89,6 +98,7 @@ export interface FileRouteTypes {
|
||||
| '/inbox'
|
||||
| '/journal'
|
||||
| '/support'
|
||||
| '/vitals'
|
||||
| '/api/research/events'
|
||||
| '/oauth/mastodon/callback'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -98,6 +108,7 @@ export interface FileRouteTypes {
|
||||
| '/inbox'
|
||||
| '/journal'
|
||||
| '/support'
|
||||
| '/vitals'
|
||||
| '/api/research/events'
|
||||
| '/oauth/mastodon/callback'
|
||||
id:
|
||||
@@ -107,6 +118,7 @@ export interface FileRouteTypes {
|
||||
| '/inbox'
|
||||
| '/journal'
|
||||
| '/support'
|
||||
| '/vitals'
|
||||
| '/api/research/events'
|
||||
| '/oauth/mastodon/callback'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -117,12 +129,20 @@ export interface RootRouteChildren {
|
||||
InboxRoute: typeof InboxRoute
|
||||
JournalRoute: typeof JournalRoute
|
||||
SupportRoute: typeof SupportRoute
|
||||
VitalsRoute: typeof VitalsRoute
|
||||
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
|
||||
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/vitals': {
|
||||
id: '/vitals'
|
||||
path: '/vitals'
|
||||
fullPath: '/vitals'
|
||||
preLoaderRoute: typeof VitalsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/support': {
|
||||
id: '/support'
|
||||
path: '/support'
|
||||
@@ -181,6 +201,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
InboxRoute: InboxRoute,
|
||||
JournalRoute: JournalRoute,
|
||||
SupportRoute: SupportRoute,
|
||||
VitalsRoute: VitalsRoute,
|
||||
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
||||
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from '@tanstack/react-router'
|
||||
import { WorkspaceStateProvider } from '#/features/workspace/workspace-state'
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
type RouterContext = { queryClient: QueryClient }
|
||||
@@ -33,7 +34,11 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
})
|
||||
|
||||
function RootContent() {
|
||||
return <Outlet />
|
||||
return (
|
||||
<WorkspaceStateProvider>
|
||||
<Outlet />
|
||||
</WorkspaceStateProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { DeckPage } from '#/features/decks/deck-page'
|
||||
|
||||
export const Route = createFileRoute('/')({ component: DeckPage })
|
||||
import { HomePage } from '#/features/home/home-page'
|
||||
export const Route = createFileRoute('/')({ component: HomePage })
|
||||
|
||||
@@ -29,7 +29,7 @@ export const Route = createFileRoute('/oauth/mastodon/callback')({
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: `/?mastodon=${outcome}`,
|
||||
location: `/deck?mastodon=${outcome}`,
|
||||
'cache-control': 'no-store',
|
||||
'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