feat: add daily home and vitals dashboard prototypes

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