Files
workspace/docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md
Roberto aba0f38816 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>
2026-05-13 15:53:07 +02:00

14 KiB
Raw Blame History

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 510 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

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.

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.

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.