feat: add daily home and vitals dashboard prototypes
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user