From aba0f38816fb39a89e879fd4bc19c8717799f0f8 Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 13 May 2026 15:53:07 +0200 Subject: [PATCH] docs: add timeline batch-add design spec Stage-then-commit batch flow for AddEventDialog. One project per batch, typed date entry with smart parse, full keyboard operation. Extracts DateField + parseDate as shared primitives, migrates EditEventDialog and TaskFormDialog. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-13-timeline-batch-add-design.md | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md diff --git a/docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md b/docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md new file mode 100644 index 0000000..393bb83 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md @@ -0,0 +1,301 @@ +# Timeline Batch Add — Design + +**Date:** 2026-05-13 +**Status:** Draft, awaiting user review +**Scope:** `adiuvAI/` submodule only + +## Problem + +Adding timeline events today goes through `AddEventDialog.tsx` one event at a time. The dialog already supports a sequential "add then add another" loop, but: + +- Each event commits immediately on Enter (no client-side staging). +- The project picker appears at the bottom, after type/title/date. +- Date entry requires opening a calendar popover — keyboard-hostile. +- A user planning a project (kickoff + milestones + activities) clicks through the dialog 5–10 times to seed a project's timeline. + +## Goal + +Single dialog session lets the user pick a project, stage multiple timeline events of mixed types, review, and commit the batch. Fully operable without a mouse. + +## Non-goals + +- New backend endpoint. Re-use `trpc.timelineEvents.create` per event. +- Reordering staged events. Order is "as added". +- Bulk import (CSV/paste). Out of scope. +- Cross-project batch. One batch = one project. + +## Architecture + +Refactor `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` in place. Same callsites (`routes/timeline.tsx`, `components/projects/ProjectDetail.tsx`), same props (`open`, `onOpenChange`, `defaultProjectId?`, `onRecordHistory?`). + +Two new shared primitives extracted from this work: + +- `adiuvAI/src/renderer/lib/parseDate.ts` — pure date parser, locale-aware. +- `adiuvAI/src/renderer/components/ui/date-field.tsx` — controlled date input with typed entry + popover fallback. + +Existing `EditEventDialog.tsx` and `TaskFormDialog.tsx` migrate to `` as part of this work. + +## State model + +```ts +type StagedEvent = { + id: string; // nanoid, local-only key + title: string; + type: 'milestone' | 'checkpoint' | 'activity'; + date: Date; + endDate?: Date; // activity only +}; + +type Mode = { kind: 'add' } | { kind: 'edit'; id: string }; + +// In AddEventDialog +const [projectId, setProjectId] = useState(defaultProjectId ?? ''); +const [staged, setStaged] = useState([]); +const [mode, setMode] = useState({ kind: 'add' }); +// Form fields: +const [title, setTitle] = useState(''); +const [type, setType] = useState('milestone'); +const [date, setDate] = useState(); +const [endDate, setEndDate] = useState(); +const [focusedRowId, setFocusedRowId] = useState(null); +``` + +## Layout + +``` +┌─── Add timeline events ─────────────────┐ +│ │ +│ Project [ Search project… ▾ ] │ ← hidden when defaultProjectId set +│ │ locked when staged.length > 0 +│ ┌─ Staged list (scrollable, max ~6) ─┐ │ +│ │ ✓ Kickoff milestone 15/03 ✕│ │ +│ │ ✓ Phase 1 checkpoint 22/03 ✕│ │ +│ └───────────────────────────────────┘ │ +│ ───────────────────────────────────── │ +│ ( Milestone | Checkpoint | Activity ) │ +│ [ Event title… ] │ +│ [ Date ] [End date ] │ ← end shown only for activity +│ │ +│ [Cancel] [Add ↵] [Save N] │ +└─────────────────────────────────────────┘ +``` + +States: +1. **Fresh open** — empty staged list with hint text, form ready, focus on project picker (or title if `defaultProjectId`). +2. **N staged, form ready** — staged list visible, form empty, focus on title. +3. **Row focused** — form dimmed (`opacity-50 pointer-events-none`), staged row has focus ring. +4. **Editing row** — form populated from row, "Add ↵" button reads "Update ↵", row in list highlighted. + +## Components + +### Shared primitives (new) + +**`lib/parseDate.ts`** — pure functions, no React. + +```ts +export function parseDate( + input: string, + prefs: FormatPrefs, + baseDate?: Date, +): Date | null; + +export function parseDateRange( + input: string, + prefs: FormatPrefs, + baseDate?: Date, +): { from: Date; to?: Date } | null; +``` + +Accepts: +- Keywords: `today`, `tomorrow`, `yesterday` (i18n-aware via current `i18n.language`) +- Relative: `+Nd`, `+Nw`, `+Nm`, `-Nd` +- Weekday names in current UI language (next occurrence): `mon`/`monday`, `lun`/`lunedì`, etc. +- Partial date: `DD/MM` or `MM/DD` (per `prefs.dateFormat`) → current year, year-rollover if past +- Full date: `DD/MM/YYYY`, `MM/DD/YYYY`, `YYYY-MM-DD` (per prefs) + +Returns `null` on unparseable. No date library — small regex + native `Date`. + +**`components/ui/date-field.tsx`** — controlled input. + +```ts +type DateFieldProps = { + value: Date | undefined; + onChange: (d: Date | undefined) => void; + placeholder?: string; + minDate?: Date; + autoFocus?: boolean; + invalidMessage?: string; + className?: string; + 'aria-label'?: string; + id?: string; + onCommit?: (d: Date) => void; // fired on Enter after valid parse +}; +``` + +Internal: text input + calendar icon button → Popover wrapping shadcn `Calendar`. Reads `useFormatPrefs()` internally. + +Behavior: +- Display formatted value (via `formatDate(prefs)`) when input not focused and value valid. +- Show raw user text while focused. +- Parse on blur and on Enter — if valid, call `onChange(date)`; if invalid, set `aria-invalid="true"` and red ring. +- Alt+↓ opens popover. Calendar selection commits via `onChange` and closes popover. +- `Enter` inside input: `e.preventDefault()`, parse, call `onChange(parsed)`, then call optional `onCommit?: (d: Date) => void` prop synchronously with the parsed value. Parent uses `onCommit` to stage without relying on `useState` flush. If invalid, no `onCommit` call, no propagation. + +### Internal to AddEventDialog (not exported) + +**``** — shadcn `Command` inside `Popover`. Typeable filter. Disabled when `staged.length > 0` (visual: muted, tooltip "Project locked after first event"). Hidden when `defaultProjectId` set. + +**``** — `
    `. Empty state: muted hint `t('timeline.emptyStagedHint')`. Each row `
  • ` with: +- Type badge (color from existing palette — chart-1/2/3 mapping by type) +- Title (truncate) +- Date(s), formatted per prefs +- ✕ icon button (hover-visible only) for mouse users; aria-label `t('timeline.removeRow')` + +Roving tabindex managed by `focusedRowId`. List itself has `tabIndex={0}` when no row focused, so Tab reaches it. + +**``** — wraps: +- `ToggleGroup` for type (existing pattern) +- `Input` for title (autoFocus when `mode.kind === 'add'`) +- `` for `date` +- `` for `endDate`, mounted only when `type === 'activity'`, `minDate` = `date` + +## Keyboard map + +| Context | Key | Action | +|----------------------|----------------|--------| +| Project picker open | type | filter list | +| | ↑/↓ | nav results | +| | Enter | select, focus title | +| | Esc | close picker | +| Form, any field | Tab/Shift+Tab | cycle: project → type → title → date → endDate → footer buttons | +| | Enter (valid) | stage event (add) or update row (edit), focus title | +| | Ctrl+Enter | save batch (if N ≥ 1) | +| | Esc | close dialog (confirm if staged > 0) | +| Title field | ↑ (caret at 0) | focus last staged row | +| Type toggle | ←/→ | cycle types | +| Date field | Alt+↓ | open calendar popover | +| | Enter | parse + commit + advance focus | +| Staged row | ↑/↓ | move focus | +| | Enter | load row → form, mode=edit | +| | Del/Backspace | remove row, focus next or form | +| | Esc | focus form title | +| Footer Save button | Enter/Space | save batch | + +## Data flow + +``` +type+title+date entered, Enter pressed + → validateForm() + → if mode.add: setStaged([...staged, newEvent]); resetForm(); focusTitle() + → if mode.edit: setStaged(staged.map(e => e.id===mode.id ? newEvent : e)); setMode({kind:'add'}); resetForm(); focusTitle() + +Save N pressed (or Ctrl+Enter) + → for each staged event: + results = await Promise.allSettled( + staged.map(e => createEvent.mutateAsync({ + title: e.title, + date: e.date.getTime(), + endDate: e.endDate?.getTime(), + type: e.type, + projectId: defaultProjectId || projectId || undefined, + })) + ) + → for each fulfilled: onRecordHistory?.({kind:'create', id, payload:...}) + → utils.timelineEvents.list.invalidate() // once, not per event + → if all fulfilled: notify success, close + → if partial: keep rejected rows in staged, mark with error tooltip, notify warning + → if all rejected: notify error, no rows removed +``` + +## Error handling + +Per-field (inline, no toast): +- Empty title → submit disabled, Enter no-op. +- Unparseable date → red ring on `DateField`, `aria-invalid="true"`, submit disabled. +- Activity `endDate < date` → red ring on end field, message `timeline.endBeforeStart`, submit disabled. +- No project selected (picker shown) → submit disabled, picker gets focus ring. + +Batch failure (per data flow above): +- All success → toast + close. +- Partial → keep failed rows with error tooltip, toast warns count. +- All fail → toast error, dialog stays open. + +Edge cases: +- Dialog closed mid-batch: fire-and-forget mutations continue server-side; UI suppresses their toasts after close (track via local `closedRef`). +- Project deleted between selection and submit → falls into partial-fail path. Acceptable. +- `defaultProjectId` for deleted project → already handled by existing callsite contracts. + +## i18n keys (added to all 5 locales) + +``` +timeline.endBeforeStart "End must be after start" +timeline.dateInvalid "Unrecognized date" +timeline.batchCreated_one "1 event created" +timeline.batchCreated_other "{{count}} events created" +timeline.batchPartial "{{ok}} created, {{failed}} failed" +timeline.batchFailed "Could not create events" +timeline.staged_one "1 event staged" +timeline.staged_other "{{count}} events staged" +timeline.emptyStagedHint "Type a title, set a date, press Enter" +timeline.editRow "Edit" +timeline.removeRow "Remove" +timeline.projectLocked "Project locked after first event" +timeline.confirmCloseStaged "Discard {{count}} staged events?" +timeline.saveAll "Save {{count}}" +timeline.update "Update" +common.add existing — re-use +common.cancel existing — re-use +``` + +Date parser keywords (per locale): +``` +date.keyword.today +date.keyword.tomorrow +date.keyword.yesterday +date.keyword.weekdays array, mon..sun in locale (short + long) +``` + +## File touch list + +New: +- `adiuvAI/src/renderer/lib/parseDate.ts` +- `adiuvAI/src/renderer/components/ui/date-field.tsx` + +Modified: +- `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` — full rewrite to staged-batch model. +- `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` — swap popover+Calendar for ``. +- `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx` — swap popover+Calendar for ``. +- `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` — add i18n keys above. + +Untouched: +- Backend (`api/`): no schema, no router changes. +- tRPC contracts: re-use `timelineEvents.create`. +- DB schema: no migration. + +## Testing + +Repo has no automated test suite (per `adiuvAI/.claude/CLAUDE.md`). Manual verification before merge: + +- [ ] Open from `/timeline`: project picker visible, locks after first staged. +- [ ] Open from `ProjectDetail`: project picker hidden, preset used. +- [ ] Parse — type and verify: `today`, `tomorrow`, `+3d`, `+1w`, `mon`, `15/03`, `15/03/26`, `2026-03-15`. Repeat with `dateFormat` switched in Settings. +- [ ] Switch UI language to IT, type `oggi`, `domani`, `lun` — parse works. +- [ ] Stage 3 mixed-type events, Save → all created, history records 3 entries, toast plural correct. +- [ ] Stage 2, kill network mid-save → failed row stays with error tooltip, toast warns count. +- [ ] Pure keyboard run: open dialog, Tab to project, type+Enter, type title, Tab, type date, Enter (stage), repeat ×3, Ctrl+Enter (save). Mouse never touched. +- [ ] ↑ from title moves to last row, Enter loads to form for edit, Esc returns to form. +- [ ] Del on focused row removes it, focus advances. +- [ ] Esc with staged > 0 shows confirm; cancel keeps dialog, OK closes. +- [ ] `EditEventDialog` opens, `DateField` shows existing date formatted, edit and save works. +- [ ] `TaskFormDialog` create + edit flow works with `DateField`. +- [ ] Reduced-motion preference respected (no popover spring if user has it). + +## Open questions + +None known at design time. Resolved during brainstorming: +- Batch model: stage then commit all. +- Project scope: one project per batch, locked after first event. +- Date entry: typed input with smart parse, calendar popover as fallback. +- Range entry: two fields (start → Tab → end). +- Row edit: arrow nav, Enter edit, Del remove. +- Components: `DateField` + `parseDate` extracted as shared primitives, migrate `EditEventDialog` + `TaskFormDialog` in this work.