feat: redesign daily execution and messaging interfaces
This commit is contained in:
@@ -5,7 +5,11 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
|
||||
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 contacts = conversations.filter(
|
||||
(person) =>
|
||||
(statuses[person.id] ?? "now") === "now" &&
|
||||
(person.replyRequired || statuses[person.id] === "now"),
|
||||
);
|
||||
const firstContact = contacts[0];
|
||||
const resume = entries.find(
|
||||
(entry) =>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/* Flexible day flow: quiet activity blocks, with detail where the action happens. */
|
||||
.execution-day {
|
||||
min-width: 0;
|
||||
}
|
||||
.execution-flow-group {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.execution-flow-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.execution-flow-heading h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.execution-flow-heading > span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
.execution-flow-items {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
.execution-flow-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
.execution-flow-card[data-kind="focus"] {
|
||||
background: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
|
||||
}
|
||||
.execution-flow-card[data-kind="routine"] {
|
||||
background: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
|
||||
}
|
||||
.execution-flow-card[data-kind="reply"] {
|
||||
background: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
|
||||
}
|
||||
.execution-flow-card[data-kind="weight"] {
|
||||
background: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
|
||||
}
|
||||
.execution-flow-card[data-kind="optional"] {
|
||||
background: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
|
||||
}
|
||||
.execution-flow-card[data-expanded="true"] {
|
||||
border-color: color-mix(in oklch, var(--foreground) 28%, var(--border));
|
||||
}
|
||||
.execution-flow-card[data-status="done"] {
|
||||
background: var(--background);
|
||||
}
|
||||
.execution-flow-card[data-status="done"] .execution-flow-title {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-flow-card[data-status="done"] .execution-complete,
|
||||
.execution-flow-card[data-status="done"] .execution-flow-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.execution-flow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.execution-complete,
|
||||
.execution-flow-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
height: 28px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-complete {
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.execution-complete:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.execution-complete:hover:not(:disabled) {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.execution-complete svg,
|
||||
.execution-flow-icon svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.execution-flow-open {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.execution-flow-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.execution-flow-description {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.execution-expand-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-flow-card[data-expanded="true"] > .execution-flow-row > .execution-expand-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.execution-inline-detail {
|
||||
padding: 6px 22px 24px 55px;
|
||||
}
|
||||
.execution-inline-detail h2 {
|
||||
display: none;
|
||||
}
|
||||
.execution-detail-symbol,
|
||||
.execution-eyebrow {
|
||||
display: none;
|
||||
}
|
||||
.execution-description {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted-foreground);
|
||||
max-width: 480px;
|
||||
}
|
||||
.execution-routine-progress {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
.execution-step-list {
|
||||
display: grid;
|
||||
}
|
||||
.execution-step-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.execution-step-list label > .execution-step-text {
|
||||
flex: 1;
|
||||
}
|
||||
.execution-step-list label[data-checked="true"] > .execution-step-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-step-number {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.execution-next-step {
|
||||
padding: 14px 0;
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.execution-next-step > span {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-next-step p {
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.execution-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.execution-resume-note {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 25px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.execution-resume-note > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.execution-resume-note textarea {
|
||||
min-height: 74px;
|
||||
background: var(--background);
|
||||
font-size: 12px;
|
||||
}
|
||||
.execution-note-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.execution-note-footer > span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
.execution-order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 20px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-order > span {
|
||||
font-size: 11px;
|
||||
margin-right: auto;
|
||||
}
|
||||
.execution-weight-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.execution-weight-form > label {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.execution-weight-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 180px;
|
||||
}
|
||||
.execution-weight-input input {
|
||||
background: var(--background);
|
||||
font-size: 20px;
|
||||
height: 42px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.execution-weight-input > span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
.execution-weight-form > p {
|
||||
width: 100%;
|
||||
color: var(--destructive);
|
||||
font-size: 12px;
|
||||
}
|
||||
.execution-source-note {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.execution-flow-empty {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
padding: 16px 0;
|
||||
}
|
||||
.execution-rest {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 20px;
|
||||
}
|
||||
.execution-rest summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-rest summary > span {
|
||||
margin-left: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.execution-rest-items {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.execution-rest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.execution-rest-row > svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.execution-rest-row > span {
|
||||
flex: 1;
|
||||
}
|
||||
.execution-rest-row small {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.execution-flow-open:focus-visible,
|
||||
.execution-complete:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.execution-flow-row {
|
||||
padding: 15px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.execution-flow-heading > span {
|
||||
font-size: 10px;
|
||||
}
|
||||
.execution-inline-detail {
|
||||
padding: 4px 18px 20px;
|
||||
}
|
||||
.execution-flow-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
.execution-flow-description {
|
||||
font-size: 11px;
|
||||
}
|
||||
.execution-note-footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
ListChecks,
|
||||
MessageCircle,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Scale,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "#/components/ui/button";
|
||||
import { Checkbox } from "#/components/ui/checkbox";
|
||||
import { Input } from "#/components/ui/input";
|
||||
import { Textarea } from "#/components/ui/textarea";
|
||||
import { conversations } from "#/features/support/mock-conversations";
|
||||
import {
|
||||
type JournalEntry,
|
||||
type Task,
|
||||
useWorkspaceState,
|
||||
} from "#/features/workspace/workspace-state";
|
||||
|
||||
type ExecutionWorkbenchProps = {
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
onOpenContact: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ExecutionWorkbench({
|
||||
selectedId,
|
||||
onSelect,
|
||||
onOpenContact,
|
||||
}: ExecutionWorkbenchProps) {
|
||||
const { tasks, setTasks, statuses, entries } = useWorkspaceState();
|
||||
const currentEntries = entries.filter(
|
||||
(entry) => !entries.some((other) => other.correctionOf === entry.id),
|
||||
);
|
||||
const weightEntry = currentEntries.find((entry) => entry.kind === "体重");
|
||||
const replies = conversations.filter(
|
||||
(conversation) => conversation.replyRequired || statuses[conversation.id] === "now",
|
||||
);
|
||||
function toggle(id: string) {
|
||||
onSelect(selectedId === id ? "" : id);
|
||||
}
|
||||
function finish(id: string) {
|
||||
setTasks((current) =>
|
||||
current.map((task) =>
|
||||
task.id === id ? { ...task, status: task.status === "todo" ? "done" : "todo" } : task,
|
||||
),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="execution-day" aria-label="Today’s plan">
|
||||
{(
|
||||
[
|
||||
{ id: "focus", label: "Start here", subtitle: "Just the next thing" },
|
||||
{ id: "routine", label: "Routines", subtitle: "Find your rhythm" },
|
||||
{ id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" },
|
||||
] as const
|
||||
).map((section) => (
|
||||
<section className="execution-flow-group" key={section.id}>
|
||||
<header className="execution-flow-heading">
|
||||
<h2>{section.label}</h2>
|
||||
</header>
|
||||
<div className="execution-flow-items">
|
||||
{tasks
|
||||
.filter((task) => task.section === section.id)
|
||||
.map((task) => {
|
||||
const id = `task:${task.id}`;
|
||||
const expanded = selectedId === id;
|
||||
const steps = task.steps ?? [];
|
||||
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
||||
return (
|
||||
<div
|
||||
className="execution-flow-card"
|
||||
key={task.id}
|
||||
data-expanded={expanded}
|
||||
data-kind={task.section}
|
||||
data-status={task.status}
|
||||
>
|
||||
<div className="execution-flow-row">
|
||||
<button
|
||||
type="button"
|
||||
className="execution-complete"
|
||||
aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`}
|
||||
aria-pressed={task.status === "done"}
|
||||
disabled={
|
||||
task.status === "todo" && steps.length > 0 && checked < steps.length
|
||||
}
|
||||
onClick={() => finish(task.id)}
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<Check />
|
||||
) : task.status === "later" ? (
|
||||
<Pause />
|
||||
) : (
|
||||
<Circle />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="execution-flow-open"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`detail-${task.id}`}
|
||||
onClick={() => toggle(id)}
|
||||
>
|
||||
<span className="execution-flow-title">{task.title}</span>
|
||||
<span className="execution-flow-description">
|
||||
{task.status === "done"
|
||||
? "Completed"
|
||||
: task.status === "later"
|
||||
? "Set aside"
|
||||
: steps.length
|
||||
? `${checked} of ${steps.length} steps · ${steps.find((step) => !task.completedSteps?.includes(step)) ?? "Ready to finish"}`
|
||||
: task.id === "review"
|
||||
? "Start with the first screen"
|
||||
: "At your own pace"}
|
||||
</span>
|
||||
</button>
|
||||
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="execution-inline-detail" id={`detail-${task.id}`}>
|
||||
<TaskDetail task={task} onAction={() => onSelect("")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{section.id === "focus" &&
|
||||
replies.slice(0, 2).map((conversation) => (
|
||||
<div
|
||||
className="execution-flow-card"
|
||||
key={conversation.id}
|
||||
data-kind="reply"
|
||||
data-status={
|
||||
statuses[conversation.id] === "sent" ||
|
||||
statuses[conversation.id] === "dismissed"
|
||||
? "done"
|
||||
: statuses[conversation.id]
|
||||
}
|
||||
>
|
||||
<div className="execution-flow-row">
|
||||
<span className="execution-flow-icon">
|
||||
{statuses[conversation.id] === "sent" ||
|
||||
statuses[conversation.id] === "dismissed" ? (
|
||||
<Check />
|
||||
) : (
|
||||
<MessageCircle />
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="execution-flow-open"
|
||||
aria-label={`Open conversation with ${conversation.name}`}
|
||||
onClick={() => onOpenContact(conversation.id)}
|
||||
>
|
||||
<span className="execution-flow-title">Reply to {conversation.name}</span>
|
||||
<span className="execution-flow-description">
|
||||
{statuses[conversation.id] === "sent"
|
||||
? "Reply recorded · Preview only"
|
||||
: statuses[conversation.id] === "dismissed"
|
||||
? "No reply needed"
|
||||
: statuses[conversation.id] === "later"
|
||||
? "Set aside"
|
||||
: statuses[conversation.id] === "waiting"
|
||||
? "Waiting for their reply"
|
||||
: conversation.subject}
|
||||
</span>
|
||||
</button>
|
||||
<ArrowUpRight className="execution-expand-icon" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{section.id === "routine" && (
|
||||
<div
|
||||
className="execution-flow-card"
|
||||
data-expanded={selectedId === "weight"}
|
||||
data-kind="weight"
|
||||
data-status={weightEntry ? "done" : "todo"}
|
||||
>
|
||||
<div className="execution-flow-row">
|
||||
<span className="execution-flow-icon">{weightEntry ? <Check /> : <Scale />}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="execution-flow-open"
|
||||
aria-expanded={selectedId === "weight"}
|
||||
aria-controls="detail-weight"
|
||||
onClick={() => toggle("weight")}
|
||||
>
|
||||
<span className="execution-flow-title">Record weight</span>
|
||||
<span className="execution-flow-description">
|
||||
{weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"}
|
||||
</span>
|
||||
</button>
|
||||
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
|
||||
</div>
|
||||
{selectedId === "weight" && (
|
||||
<div className="execution-inline-detail" id="detail-weight">
|
||||
<WeightDetail
|
||||
key={weightEntry?.id ?? "new"}
|
||||
entry={weightEntry}
|
||||
onAction={() => onSelect("")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!tasks.some((task) => task.section === section.id && task.status === "todo") &&
|
||||
(section.id !== "focus" || !replies.length) &&
|
||||
section.id !== "routine" && (
|
||||
<p className="execution-flow-empty">A little breathing room.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||
const { setTasks, entries, setEntries } = useWorkspaceState();
|
||||
const source = `task:${task.id}`;
|
||||
const savedNote = entries.find((entry) => entry.kind === "再開地点" && entry.source === source);
|
||||
const [note, setNote] = useState(savedNote?.text ?? "");
|
||||
const [notice, setNotice] = useState("");
|
||||
const steps = task.steps ?? [];
|
||||
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
||||
function setStatus(status: Task["status"]) {
|
||||
setTasks((current) =>
|
||||
current.map((item) => (item.id === task.id ? { ...item, status } : item)),
|
||||
);
|
||||
onAction();
|
||||
}
|
||||
function move(direction: -1 | 1) {
|
||||
setTasks((current) => {
|
||||
const position = current.findIndex((item) => item.id === task.id);
|
||||
const siblings = current
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(({ item }) => item.section === task.section && item.status === task.status);
|
||||
const sibling = siblings.findIndex(({ item }) => item.id === task.id);
|
||||
const target = siblings[sibling + direction];
|
||||
const origin = current[position];
|
||||
if (!target || !origin) return current;
|
||||
const updated = [...current];
|
||||
updated[position] = target.item;
|
||||
updated[target.index] = origin;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="execution-detail-symbol">
|
||||
<ListChecks />
|
||||
</div>
|
||||
<p className="execution-eyebrow">
|
||||
{task.section === "routine"
|
||||
? "Your routine"
|
||||
: task.section === "optional"
|
||||
? "If there’s room"
|
||||
: "One next step"}
|
||||
</p>
|
||||
<h2>{task.title}</h2>
|
||||
{steps.length > 0 ? (
|
||||
<>
|
||||
<div className="execution-routine-progress">
|
||||
<span>
|
||||
{checked} of {steps.length} steps
|
||||
</span>
|
||||
<span>
|
||||
{task.status === "done"
|
||||
? "Completed"
|
||||
: checked === steps.length
|
||||
? "Ready to finish"
|
||||
: "At your pace"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="execution-step-list">
|
||||
{steps.map((step, index) => (
|
||||
<label
|
||||
key={step}
|
||||
htmlFor={`execution-step-${task.id}-${index}`}
|
||||
data-checked={task.completedSteps?.includes(step) ?? false}
|
||||
>
|
||||
<Checkbox
|
||||
id={`execution-step-${task.id}-${index}`}
|
||||
checked={task.completedSteps?.includes(step) ?? false}
|
||||
disabled={task.status === "done"}
|
||||
onCheckedChange={(value) =>
|
||||
setTasks((current) =>
|
||||
current.map((item) =>
|
||||
item.id === task.id
|
||||
? {
|
||||
...item,
|
||||
completedSteps: value
|
||||
? Array.from(new Set([...(item.completedSteps ?? []), step]))
|
||||
: (item.completedSteps ?? []).filter(
|
||||
(completed) => completed !== step,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="execution-step-text">{step}</span>
|
||||
<span className="execution-step-number">{String(index + 1).padStart(2, "0")}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="execution-description">
|
||||
{task.id === "review"
|
||||
? "Look through the first-time experience. Leave a short note on anything that feels unclear."
|
||||
: task.section === "optional"
|
||||
? "There’s room to decide later. Start whenever it feels right."
|
||||
: "Choose a small first step. You can leave a note if you need to pause."}
|
||||
</p>
|
||||
{task.id === "review" && (
|
||||
<div className="execution-next-step">
|
||||
<span>Start here</span>
|
||||
<p>Open the materials and look at the first screen.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="execution-detail-actions">
|
||||
{task.status !== "done" && (
|
||||
<Button
|
||||
onClick={() => setStatus("done")}
|
||||
disabled={steps.length > 0 && checked < steps.length}
|
||||
>
|
||||
<Check /> {steps.length > 0 ? "Finish routine" : "Mark complete"}
|
||||
</Button>
|
||||
)}
|
||||
{task.status === "todo" && (
|
||||
<Button variant="outline" onClick={() => setStatus("later")}>
|
||||
<Pause /> Later
|
||||
</Button>
|
||||
)}
|
||||
{task.status !== "todo" && (
|
||||
<Button variant="outline" onClick={() => setStatus("todo")}>
|
||||
<RotateCcw /> Bring back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<section className="execution-resume-note" aria-label="Resume note">
|
||||
<label htmlFor={`resume-${task.id}`}>A place to pick up</label>
|
||||
<Textarea
|
||||
id={`resume-${task.id}`}
|
||||
placeholder="Where did you leave off?"
|
||||
value={note}
|
||||
onChange={(event) => {
|
||||
setNote(event.target.value);
|
||||
setNotice("");
|
||||
}}
|
||||
/>
|
||||
<div className="execution-note-footer">
|
||||
<span role="status">{notice || "Leave a sentence for your future self."}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!note.trim()}
|
||||
onClick={() => {
|
||||
setEntries((current) => [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
kind: "再開地点",
|
||||
text: note.trim(),
|
||||
source,
|
||||
occurredAt: "",
|
||||
recordedAt: new Date().toISOString(),
|
||||
...(savedNote ? { correctionOf: savedNote.id } : {}),
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
setNotice("Saved");
|
||||
}}
|
||||
>
|
||||
Save note
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="execution-order">
|
||||
<span>Order in your list</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Move ${task.title} up`}
|
||||
onClick={() => move(-1)}
|
||||
>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Move ${task.title} down`}
|
||||
onClick={() => move(1)}
|
||||
>
|
||||
<ArrowDown />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: () => void }) {
|
||||
const { setEntries } = useWorkspaceState();
|
||||
const [weight, setWeight] = useState(entry?.text ?? "");
|
||||
const [error, setError] = useState("");
|
||||
return (
|
||||
<>
|
||||
<div className="execution-detail-symbol">
|
||||
<Scale />
|
||||
</div>
|
||||
<p className="execution-eyebrow">A quick check-in</p>
|
||||
<h2>Record your weight</h2>
|
||||
<p className="execution-description">Just a measurement. No target to hit.</p>
|
||||
<form
|
||||
className="execution-weight-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const value = Number(weight);
|
||||
if (!weight.trim() || !Number.isFinite(value) || value <= 0 || value > 1000) {
|
||||
setError("Enter a weight greater than 0 and no more than 1,000 kg.");
|
||||
return;
|
||||
}
|
||||
setEntries((current) => [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
kind: "体重",
|
||||
text: String(value),
|
||||
source: "Home check-in",
|
||||
occurredAt: new Date().toISOString(),
|
||||
recordedAt: new Date().toISOString(),
|
||||
...(entry ? { correctionOf: entry.id } : {}),
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
setError("");
|
||||
onAction();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="execution-weight">Weight</label>
|
||||
<div className="execution-weight-input">
|
||||
<Input
|
||||
id="execution-weight"
|
||||
inputMode="decimal"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="1000"
|
||||
step="any"
|
||||
value={weight}
|
||||
required
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? "execution-weight-error" : undefined}
|
||||
onChange={(event) => {
|
||||
setWeight(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
<span>kg</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p id="execution-weight-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit">
|
||||
<Check /> {entry ? "Save correction" : "Save measurement"}
|
||||
</Button>
|
||||
</form>
|
||||
{entry && (
|
||||
<div className="execution-next-step">
|
||||
<span>Recorded</span>
|
||||
<p>{entry.text} kg</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="execution-source-note">Your measurement also appears in Journal.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,8 @@
|
||||
import { Link, useNavigate, useRouteContext } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Coffee,
|
||||
MessagesSquare,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ArrowUp, ArrowUpRight, Plus, 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";
|
||||
@@ -26,11 +13,12 @@ import { ConversationDetail } from "#/features/support/conversation-detail";
|
||||
import { conversations } from "#/features/support/mock-conversations";
|
||||
import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state";
|
||||
import { DailyBrief } from "./daily-brief";
|
||||
import { ExecutionWorkbench } from "./execution-workbench";
|
||||
import { type HomeAction, interpretAction } from "./interpret-action";
|
||||
|
||||
export function HomePage() {
|
||||
const { auth } = useRouteContext({ from: "__root__" });
|
||||
const { tasks, setTasks, statuses, setStatuses, outcomes, setOutcomes, entries, setEntries } =
|
||||
const { tasks, setTasks, setStatuses, outcomes, setOutcomes, entries, setEntries } =
|
||||
useWorkspaceState();
|
||||
const navigate = useNavigate();
|
||||
const input = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -49,11 +37,9 @@ export function HomePage() {
|
||||
updateSearch({ article: id, conversation: undefined });
|
||||
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);
|
||||
@@ -230,215 +216,12 @@ export function HomePage() {
|
||||
{notice}
|
||||
</p>
|
||||
<div className="home-columns">
|
||||
<section aria-label="Today’s plan">
|
||||
{(
|
||||
[
|
||||
{ id: "focus", label: "Start here" },
|
||||
{ id: "routine", label: "Routines" },
|
||||
{ id: "optional", label: "If there’s room" },
|
||||
] 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={`Add to ${section.label}`}
|
||||
onClick={() =>
|
||||
suggest(
|
||||
section.id === "routine"
|
||||
? "Add routine:"
|
||||
: section.id === "optional"
|
||||
? "Optional: "
|
||||
: "Add:",
|
||||
)
|
||||
}
|
||||
>
|
||||
<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={`Complete ${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>One step at a time</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={`Move ${task.title} up`}
|
||||
onClick={() => moveTask(task.id, -1)}
|
||||
>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Move ${task.title} down`}
|
||||
onClick={() => moveTask(task.id, 1)}
|
||||
>
|
||||
<ArrowDown />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Defer ${task.title}`}
|
||||
onClick={() =>
|
||||
setTasks((current) =>
|
||||
current.map((item) =>
|
||||
item.id === task.id ? { ...item, status: "later" } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Later
|
||||
</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">Reply to {item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.subject}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open conversation with ${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">It’s okay to leave some room.</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<details className="home-rest">
|
||||
<summary>
|
||||
Later & done <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={`Restore ${task.title}`}
|
||||
onClick={() =>
|
||||
setTasks((current) =>
|
||||
current.map((item) =>
|
||||
item.id === task.id ? { ...item, status: "todo" } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
<ExecutionWorkbench
|
||||
selectedId={search.activity}
|
||||
onSelect={(id) => updateSearch({ activity: id || undefined })}
|
||||
onOpenContact={setContactId}
|
||||
/>
|
||||
<aside aria-label="Today’s context">
|
||||
<section className="home-section">
|
||||
<header className="home-section-heading">
|
||||
<h2>Messages</h2>
|
||||
<Link to="/support" aria-label="View all messages">
|
||||
<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"
|
||||
? "Waiting"
|
||||
: statuses[item.id] === "later"
|
||||
? "Later"
|
||||
: item.channels.join(" / ")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
<section className="home-section">
|
||||
<header className="home-section-heading">
|
||||
<h2>Journal</h2>
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
.home-content {
|
||||
max-width: 1140px;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
padding: 64px 48px 80px;
|
||||
padding: 40px 48px 80px;
|
||||
}
|
||||
.home-greeting {
|
||||
margin-bottom: 32px;
|
||||
@@ -88,8 +88,8 @@
|
||||
}
|
||||
.home-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.65fr) minmax(240px, 1fr);
|
||||
gap: 64px;
|
||||
grid-template-columns: minmax(0, 2.5fr) minmax(190px, 1fr);
|
||||
gap: 40px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.home-section {
|
||||
|
||||
Reference in New Issue
Block a user