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
+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
}