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 <noreply@anthropic.com>
This commit is contained in:
301
docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md
Normal file
301
docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md
Normal file
@@ -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 `<DateField>` 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<StagedEvent[]>([]);
|
||||
const [mode, setMode] = useState<Mode>({ kind: 'add' });
|
||||
// Form fields:
|
||||
const [title, setTitle] = useState('');
|
||||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||
const [focusedRowId, setFocusedRowId] = useState<string | null>(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)
|
||||
|
||||
**`<ProjectPickerRow>`** — shadcn `Command` inside `Popover`. Typeable filter. Disabled when `staged.length > 0` (visual: muted, tooltip "Project locked after first event"). Hidden when `defaultProjectId` set.
|
||||
|
||||
**`<StagedList>`** — `<ul role="listbox" aria-label="Staged events">`. Empty state: muted hint `t('timeline.emptyStagedHint')`. Each row `<li role="option" tabIndex={-1}>` 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.
|
||||
|
||||
**`<EventForm>`** — wraps:
|
||||
- `ToggleGroup` for type (existing pattern)
|
||||
- `Input` for title (autoFocus when `mode.kind === 'add'`)
|
||||
- `<DateField>` for `date`
|
||||
- `<DateField>` 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 `<DateField>`.
|
||||
- `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx` — swap popover+Calendar for `<DateField>`.
|
||||
- `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.
|
||||
Reference in New Issue
Block a user