Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .claude/rules/ui-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,30 @@ container (the schedule grid scrolls; only modified wheels change its range),
and keep the callbacks in the dependency array identity-stable (`useCallback`
with functional `setState`) so the listener is not torn down and rebuilt on
every render. See `src/components/schedule/schedule-grid.tsx`.

## Selection state: keep the id, look up the object

When a view owns a document and a side panel edits one element of it, store
only the element's **id** in state and derive the element from the document:

```ts
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = useMemo(
() => (selectedId ? (doc?.items.find((i) => i.id === selectedId) ?? null) : null),
[doc, selectedId],
);
```

Holding a copy of the element (`useState<Item | null>`) forks the truth. Every
edit path that does not go through the panel — a drag on the grid, a keyboard
nudge, an undo, a reload after an external file edit — updates the document
and leaves the copy behind; the panel's next patch then spreads that stale copy
back over the document and silently reverts the earlier edit. The schedule tab
shipped exactly that bug: resize a bar on the grid, then change its kind in the
panel, and the old dates came back (T-0101).

The same rule applies inside the panel: render fields straight from the `item`
prop rather than seeding a local draft from it. A draft re-seeded by
`useEffect` is the same stale copy one level down. If a field ever does need a
draft (an in-progress text edit that must not round-trip), scope it to that
field and reconcile it explicitly — do not draft the whole element.
37 changes: 18 additions & 19 deletions src/components/schedule/item-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
Expand Down Expand Up @@ -31,6 +30,12 @@ import type { Task } from "@/types";
* `Details` is the element's body — the indented continuation lines under it
* in the file. A note shows it on hover in the grid; every other kind shows it
* in its tooltip, where it reads as a remark about the element.
*
* The panel holds **no draft state**: every field renders straight from `item`,
* and each edit is a patch on that same object. A local copy re-seeded from the
* prop looks equivalent but is not — a drag on the grid changes the document
* without going through this panel, and the copy would keep serving the dates
* from before the drag until the next edit wrote them back over it.
*/

interface Props {
Expand All @@ -54,31 +59,25 @@ function collapseLines(value: string): string {
}

export function ItemEditor({ item, tasks, onChange, onDelete, onClose }: Props) {
const [draft, setDraft] = useState(item);

// Re-seed when the grid selects a different element (the panel is reused).
useEffect(() => setDraft(item), [item]);

const commit = (patch: Partial<ScheduleItem>) => {
const next = { ...draft, ...patch };
const next = { ...item, ...patch };
// A range element cannot end before it starts; pushing the far edge along is
// less surprising than rejecting the edit the user just made.
if (isRangeKind(next.kind) && next.end < next.start) {
if (patch.start) next.end = next.start;
else next.start = next.end;
}
if (!isRangeKind(next.kind)) next.end = next.start;
setDraft(next);
onChange(next);
};

return (
// Width comes from the sidebar column, not from here — see schedule-view.
<div className="shrink-0 space-y-3 border-b p-3 text-xs">
<div className="flex items-center justify-between">
<span className="truncate font-mono text-[11px] text-muted-foreground">{draft.id}</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">{item.id}</span>
<Select
value={draft.kind}
value={item.kind}
onValueChange={(v) => commit({ kind: v as ScheduleItem["kind"] })}
>
{/* `max-w` rather than a bare width: the column is resizable now, and
Expand All @@ -96,24 +95,24 @@ export function ItemEditor({ item, tasks, onChange, onDelete, onClose }: Props)
</div>

<Input
value={draft.title}
value={item.title}
placeholder="Title"
className="h-8 text-xs"
onChange={(e) => commit({ title: collapseLines(e.target.value) })}
/>

<Textarea
value={draft.body ?? ""}
placeholder={draft.kind === "note" ? "Note text (shown on hover)" : "Details"}
rows={draft.kind === "note" ? 4 : 2}
value={item.body ?? ""}
placeholder={item.kind === "note" ? "Note text (shown on hover)" : "Details"}
rows={item.kind === "note" ? 4 : 2}
className="resize-none text-xs"
onChange={(e) => commit({ body: e.target.value })}
/>

<div className="space-y-1.5">
<DatePicker value={draft.start} onChange={(v) => v && commit({ start: v })} />
{isRangeKind(draft.kind) && (
<DatePicker value={draft.end} onChange={(v) => v && commit({ end: v })} />
<DatePicker value={item.start} onChange={(v) => v && commit({ start: v })} />
{isRangeKind(item.kind) && (
<DatePicker value={item.end} onChange={(v) => v && commit({ end: v })} />
)}
</div>

Expand All @@ -127,14 +126,14 @@ export function ItemEditor({ item, tasks, onChange, onDelete, onClose }: Props)
style={{ background: COLOR_HEX[color as Color] }}
className={cn(
"size-5 rounded",
draft.color === color && "ring-2 ring-foreground ring-offset-1 ring-offset-background",
item.color === color && "ring-2 ring-foreground ring-offset-1 ring-offset-background",
)}
/>
))}
</div>

<Select
value={draft.task ?? NONE}
value={item.task ?? NONE}
onValueChange={(v) => commit({ task: v === NONE ? undefined : v })}
>
<SelectTrigger className="h-7 text-xs">
Expand Down
52 changes: 31 additions & 21 deletions src/components/schedule/schedule-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export function ScheduleView({ configVersion }: Props) {
const [doc, setDoc] = useState<ScheduleDocModel | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [aiRun, setAiRun] = useState<ScheduleEditRun | null>(null);
const [selected, setSelected] = useState<ScheduleItem | null>(null);
// Only the *id* of the selection is state; the element itself is looked up in
// the document below. Keeping a copy here would fork the truth: a drag on the
// grid edits `doc` without passing through the side panel, and the panel's
// next edit would write the pre-drag copy back over it (T-0101).
const [selectedId, setSelectedId] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [window_, setWindow] = useState<{ start: string; end: string } | null>(null);
const [creating, setCreating] = useState(false);
Expand Down Expand Up @@ -176,7 +180,7 @@ export function ScheduleView({ configVersion }: Props) {
void loadDoc(path);
// Each note carries its own range; drop the window so the new note's is used.
setWindow(null);
setSelected(null);
setSelectedId(null);
}, [path, loadDoc]);

// External edits (Obsidian, the AI agent) arrive as events rather than
Expand Down Expand Up @@ -249,7 +253,7 @@ export function ScheduleView({ configVersion }: Props) {
if (!previous || !doc || aiRunning) return;
undoStack.current = undoStack.current.slice(0, -1);
redoStack.current = [...redoStack.current, doc];
setSelected(null);
setSelectedId(null);
apply(previous);
}, [doc, aiRunning, apply]);

Expand All @@ -258,7 +262,7 @@ export function ScheduleView({ configVersion }: Props) {
if (!next || !doc || aiRunning) return;
redoStack.current = redoStack.current.slice(0, -1);
undoStack.current = [...undoStack.current, doc];
setSelected(null);
setSelectedId(null);
apply(next);
}, [doc, aiRunning, apply]);

Expand All @@ -270,6 +274,19 @@ export function ScheduleView({ configVersion }: Props) {
[doc, mutate],
);

/**
* The selected element, read out of the document rather than remembered.
*
* This is what keeps the side panel honest against every edit that does not
* originate in it — a drag, an arrow-key nudge, an undo, a reload after an
* Obsidian or agent edit. It also closes the panel on its own when the
* element goes away, since a deleted id simply finds nothing.
*/
const selected = useMemo(
() => (selectedId ? (doc?.items.find((i) => i.id === selectedId) ?? null) : null),
[doc, selectedId],
);

const projectTasks = useMemo(
() => tasks.filter((t) => !t.archived && (!project || t.project === project)),
[tasks, project],
Expand Down Expand Up @@ -320,15 +337,15 @@ export function ScheduleView({ configVersion }: Props) {
return;
}
if (e.key === "Escape") {
setSelected(null);
setSelectedId(null);
return;
}
if (!selected) return;

if (e.key === "Delete") {
e.preventDefault();
if (doc) mutate({ ...doc, items: doc.items.filter((i) => i.id !== selected.id) });
setSelected(null);
setSelectedId(null);
return;
}
const step = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
Expand All @@ -340,17 +357,13 @@ export function ScheduleView({ configVersion }: Props) {
if (!isRangeKind(selected.kind)) return;
const end = shiftDate(selected.end, step);
if (end < selected.start) return;
const next = { ...selected, end };
setSelected(next);
patchItem(next.id, () => next);
patchItem(selected.id, () => ({ ...selected, end }));
} else {
const next = {
patchItem(selected.id, () => ({
...selected,
start: shiftDate(selected.start, step),
end: shiftDate(selected.end, step),
};
setSelected(next);
patchItem(next.id, () => next);
}));
}
};
window.addEventListener("keydown", onKeyDown);
Expand Down Expand Up @@ -594,7 +607,7 @@ export function ScheduleView({ configVersion }: Props) {
return next;
})
}
onSelectItem={(item) => setSelected(item)}
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
onToggleNonWorking={(date) => toggleNonWorking(date)}
onCreateItem={(kind, from, to) => createItem(kind, from, to)}
onMoveTaskDue={(taskId, date) => {
Expand Down Expand Up @@ -638,15 +651,12 @@ export function ScheduleView({ configVersion }: Props) {
<ItemEditor
item={selected}
tasks={projectTasks}
onChange={(next) => {
setSelected(next);
patchItem(next.id, () => next);
}}
onChange={(next) => patchItem(next.id, () => next)}
onDelete={() => {
mutate({ ...doc, items: doc.items.filter((i) => i.id !== selected.id) });
setSelected(null);
setSelectedId(null);
}}
onClose={() => setSelected(null)}
onClose={() => setSelectedId(null)}
/>
)}
{aiRun && (
Expand Down Expand Up @@ -704,7 +714,7 @@ export function ScheduleView({ configVersion }: Props) {
...(isRangeKind(kind) ? { color: "blue" as const } : {}),
};
mutate({ ...doc, items: [...doc.items, item] });
setSelected(item);
setSelectedId(item.id);
}
}

Expand Down
Loading