Step-by-step plan to port AddEventDialog UX (header, full keyboard nav, date+time via DateField) into TaskFormDialog. Two new shared hooks (useRovingFocus, useListboxKeys), parseDate time-suffix, DateField withTime + flat props, and i18n updates across all 5 languages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1173 lines
40 KiB
Markdown
1173 lines
40 KiB
Markdown
# Task Form Dialog — keyboard + header polish — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Port `AddEventDialog`'s header style + full keyboard navigation + date-time keyboard entry into `TaskFormDialog`, reusing existing `DateField` and adding two small shared hooks.
|
||
|
||
**Architecture:** Two new generic hooks (`useRovingFocus`, `useListboxKeys`) cover keyboard plumbing for any pill row + list popover. `parseDate` gains an optional trailing `HH:MM`. `DateField` gains `withTime` + `flat` props so it can be embedded inside another Popover without nesting. `TaskFormDialog` consumes all of these and drops its bespoke Due Popover + the footer ⌘+Enter hint.
|
||
|
||
**Tech Stack:** React 19, TypeScript, shadcn/ui (`Popover`, `Calendar`, `Select`), react-day-picker, `react-i18next`, `TZDate` from `react-day-picker` (timezone-correct date construction), Drizzle ORM (unchanged), Electron.
|
||
|
||
**Spec:** [docs/2026-05-14-task-form-dialog-kbd-design.md](./2026-05-14-task-form-dialog-kbd-design.md)
|
||
**Mockup:** [docs/mockups/2026-05-14-task-form-dialog-mockup.html](./mockups/2026-05-14-task-form-dialog-mockup.html)
|
||
|
||
**No test suite in the project (`adiuvAI/CLAUDE.md`: "No test suite currently.").** Verification per task is `npm run lint` + manual smoke. Plan does not include TDD steps; if pytest/vitest is introduced later, regression cases for `parseDate` belong in `docs/2026-05-14-task-form-dialog-kbd-design.md` § 3 table.
|
||
|
||
**Commit cadence:** one commit per task. Each commit message follows the existing Conventional Commits style visible in `git log` (`feat:`, `refactor:`, `docs:`, …) and includes the `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>` trailer.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Op | Responsibility |
|
||
|---|---|---|
|
||
| `adiuvAI/src/renderer/hooks/useRovingFocus.ts` | Create | Roving-tabindex hook for any horizontally-arranged group of focusable items (pills, tabs). |
|
||
| `adiuvAI/src/renderer/hooks/useListboxKeys.ts` | Create | Listbox keyboard handler (↑/↓/Home/End/Enter/Esc/Tab) shared by all single- and multi-select popovers. |
|
||
| `adiuvAI/src/renderer/lib/parseDate.ts` | Modify | Add optional trailing ` HH:MM` parsing. |
|
||
| `adiuvAI/src/renderer/components/ui/date-field.tsx` | Modify | Add `withTime?: boolean` (renders HH/MM Selects in popover) and `flat?: boolean` (skips the internal Popover, renders Input + Calendar + Time row inline). |
|
||
| `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx` | Modify | New header (Title + Description, no border); pills row uses `useRovingFocus`; each list popover uses `useListboxKeys`; Due popover content is `<DateField withTime flat />`; remove `⌘+Enter` footer hint. Pill triggers become real `<button>` elements (not `<span>`). |
|
||
| `adiuvAI/src/renderer/components/tasks/PropertyPill.tsx` | Modify | Change rendered element from `<span>` to `<button type="button">`; add focus-visible ring; accept `tabIndex` + standard button refs/handlers. |
|
||
| `adiuvAI/src/renderer/locales/en/translation.json` | Modify | Add `tasks.newTaskDescription`, `tasks.editTaskDescription`. |
|
||
| `adiuvAI/src/renderer/locales/it/translation.json` | Modify | Same keys, IT. |
|
||
| `adiuvAI/src/renderer/locales/es/translation.json` | Modify | Same keys, ES. |
|
||
| `adiuvAI/src/renderer/locales/fr/translation.json` | Modify | Same keys, FR. |
|
||
| `adiuvAI/src/renderer/locales/de/translation.json` | Modify | Same keys, DE. |
|
||
|
||
The `adiuvAI` directory is a git submodule. All commits below are inside that submodule; the outer monorepo gets a `bump adiuvAI submodule` commit at the end.
|
||
|
||
---
|
||
|
||
## Task 1 — Hook: `useRovingFocus`
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/hooks/useRovingFocus.ts`
|
||
|
||
**Why:** Pills row needs single tab stop + ←/→/↑/↓ navigation between items. Generic, reusable.
|
||
|
||
- [ ] **Step 1.1 — Create the hook file**
|
||
|
||
```ts
|
||
// adiuvAI/src/renderer/hooks/useRovingFocus.ts
|
||
import { useCallback, useRef, useState } from 'react';
|
||
|
||
export type RovingDirection = 'horizontal' | 'vertical' | 'both';
|
||
|
||
export interface UseRovingFocusOptions {
|
||
count: number;
|
||
direction?: RovingDirection;
|
||
initialIndex?: number;
|
||
loop?: boolean;
|
||
}
|
||
|
||
export interface RovingItemProps {
|
||
tabIndex: number;
|
||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||
onFocus: () => void;
|
||
ref: (el: HTMLElement | null) => void;
|
||
}
|
||
|
||
/**
|
||
* Roving tabindex helper. Group exposes a single tab stop; arrow keys
|
||
* move focus between items. Index state is internal — call `getItemProps(i)`
|
||
* for each item to wire it up.
|
||
*/
|
||
export function useRovingFocus({
|
||
count,
|
||
direction = 'both',
|
||
initialIndex = 0,
|
||
loop = false,
|
||
}: UseRovingFocusOptions) {
|
||
const [active, setActive] = useState<number>(initialIndex);
|
||
const refs = useRef<Array<HTMLElement | null>>([]);
|
||
|
||
const move = useCallback(
|
||
(next: number) => {
|
||
let target = next;
|
||
if (loop) {
|
||
target = ((next % count) + count) % count;
|
||
} else {
|
||
target = Math.max(0, Math.min(count - 1, next));
|
||
}
|
||
setActive(target);
|
||
const el = refs.current[target];
|
||
if (el) el.focus();
|
||
},
|
||
[count, loop],
|
||
);
|
||
|
||
const getItemProps = useCallback(
|
||
(index: number): RovingItemProps => ({
|
||
tabIndex: index === active ? 0 : -1,
|
||
ref: (el) => {
|
||
refs.current[index] = el;
|
||
},
|
||
onFocus: () => setActive(index),
|
||
onKeyDown: (e: React.KeyboardEvent) => {
|
||
const horizontal = direction === 'horizontal' || direction === 'both';
|
||
const vertical = direction === 'vertical' || direction === 'both';
|
||
if (horizontal && e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
move(index + 1);
|
||
} else if (horizontal && e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
move(index - 1);
|
||
} else if (vertical && e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
move(index + 1);
|
||
} else if (vertical && e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
move(index - 1);
|
||
} else if (e.key === 'Home') {
|
||
e.preventDefault();
|
||
move(0);
|
||
} else if (e.key === 'End') {
|
||
e.preventDefault();
|
||
move(count - 1);
|
||
}
|
||
},
|
||
}),
|
||
[active, count, direction, move],
|
||
);
|
||
|
||
return { activeIndex: active, setActive, getItemProps };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.2 — Lint**
|
||
|
||
Run inside `adiuvAI/`:
|
||
```
|
||
npm run lint
|
||
```
|
||
Expected: no new warnings/errors.
|
||
|
||
- [ ] **Step 1.3 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/hooks/useRovingFocus.ts
|
||
git -C adiuvAI commit -m "feat: add useRovingFocus hook for roving-tabindex groups"
|
||
```
|
||
(Include the standard `Co-Authored-By` trailer via heredoc as in `CLAUDE.md`.)
|
||
|
||
---
|
||
|
||
## Task 2 — Hook: `useListboxKeys`
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/hooks/useListboxKeys.ts`
|
||
|
||
**Why:** Project / Priority / Status / Assignees popovers all need the same arrow-nav + Enter-to-select keyboard model. Multi-select for Assignees differs only in selection callback.
|
||
|
||
- [ ] **Step 2.1 — Create the hook**
|
||
|
||
```ts
|
||
// adiuvAI/src/renderer/hooks/useListboxKeys.ts
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
||
export interface UseListboxKeysOptions {
|
||
itemCount: number;
|
||
initialIndex?: number;
|
||
onSelect: (index: number) => void;
|
||
onClose: () => void;
|
||
/**
|
||
* If true, focus does NOT move out of the listbox on Tab; the caller is
|
||
* expected to also call onClose so the parent can advance focus.
|
||
*/
|
||
closeOnTab?: boolean;
|
||
}
|
||
|
||
export interface ListboxItemProps {
|
||
tabIndex: number;
|
||
ref: (el: HTMLElement | null) => void;
|
||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||
onMouseEnter: () => void;
|
||
'aria-selected': boolean;
|
||
}
|
||
|
||
/**
|
||
* Listbox keyboard model used inside popovers: ↑/↓ move active item,
|
||
* Home/End jump to ends, Enter/Space selects, Esc closes, Tab closes
|
||
* (so the parent's tab order resumes from the trigger).
|
||
*/
|
||
export function useListboxKeys({
|
||
itemCount,
|
||
initialIndex = 0,
|
||
onSelect,
|
||
onClose,
|
||
closeOnTab = true,
|
||
}: UseListboxKeysOptions) {
|
||
const [active, setActive] = useState<number>(initialIndex);
|
||
const refs = useRef<Array<HTMLElement | null>>([]);
|
||
|
||
// Reset active when the item set changes size.
|
||
useEffect(() => {
|
||
if (active >= itemCount) setActive(Math.max(0, itemCount - 1));
|
||
}, [itemCount, active]);
|
||
|
||
const focusIndex = useCallback((i: number) => {
|
||
setActive(i);
|
||
const el = refs.current[i];
|
||
if (el) el.focus();
|
||
}, []);
|
||
|
||
const getItemProps = useCallback(
|
||
(index: number): ListboxItemProps => ({
|
||
tabIndex: index === active ? 0 : -1,
|
||
ref: (el) => {
|
||
refs.current[index] = el;
|
||
},
|
||
'aria-selected': index === active,
|
||
onMouseEnter: () => setActive(index),
|
||
onKeyDown: (e: React.KeyboardEvent) => {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
focusIndex(Math.min(index + 1, itemCount - 1));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
focusIndex(Math.max(index - 1, 0));
|
||
} else if (e.key === 'Home') {
|
||
e.preventDefault();
|
||
focusIndex(0);
|
||
} else if (e.key === 'End') {
|
||
e.preventDefault();
|
||
focusIndex(itemCount - 1);
|
||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onSelect(index);
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onClose();
|
||
} else if (e.key === 'Tab' && closeOnTab) {
|
||
// Let the Tab proceed; but close so popover unmounts.
|
||
onClose();
|
||
}
|
||
},
|
||
}),
|
||
[active, itemCount, focusIndex, onSelect, onClose, closeOnTab],
|
||
);
|
||
|
||
return { activeIndex: active, setActive, focusIndex, getItemProps };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.2 — Lint**
|
||
|
||
```
|
||
npm run lint
|
||
```
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 2.3 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/hooks/useListboxKeys.ts
|
||
git -C adiuvAI commit -m "feat: add useListboxKeys hook for popover list keyboard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3 — `parseDate`: accept optional `HH:MM` suffix
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/lib/parseDate.ts`
|
||
|
||
**Why:** `DateField` will route both date and time through a single typeable input. Parser must accept e.g. `30/04/2026 14:30` and `tomorrow 08:15`.
|
||
|
||
Read the current file first so the diff is correct (it lives at lines 1–80+; the public entry is the `parseDate` function near the bottom — confirm before editing).
|
||
|
||
- [ ] **Step 3.1 — Add regex constant**
|
||
|
||
In `parseDate.ts`, add near the top with the other regex constants:
|
||
|
||
```ts
|
||
const RE_TIME_SUFFIX = /\s+(\d{1,2}):(\d{2})\s*$/;
|
||
```
|
||
|
||
- [ ] **Step 3.2 — Strip + apply time inside `parseDate`**
|
||
|
||
Locate the `export function parseDate(input, prefs, keywords): Date | null` (current entrypoint that delegates to `parseRel`, `parseKeyword`, `parseNumeric`). Replace its body so it first peels off the time suffix, parses the remaining string, then applies hours/minutes to the result:
|
||
|
||
```ts
|
||
export function parseDate(
|
||
input: ParseInput,
|
||
prefs: FormatPrefs,
|
||
keywords: DateKeywords,
|
||
base: Date = new Date(),
|
||
): Date | null {
|
||
if (!input) return null;
|
||
const raw = input.trim();
|
||
if (!raw) return null;
|
||
|
||
let datePart = raw;
|
||
let hours: number | null = null;
|
||
let minutes: number | null = null;
|
||
const tm = raw.match(RE_TIME_SUFFIX);
|
||
if (tm) {
|
||
const h = parseInt(tm[1], 10);
|
||
const m = parseInt(tm[2], 10);
|
||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||
hours = h;
|
||
minutes = m;
|
||
datePart = raw.slice(0, raw.length - tm[0].length).trim();
|
||
if (!datePart) return null;
|
||
}
|
||
|
||
// Existing resolution order — keep identical to the current implementation.
|
||
const fromRel = parseRel(datePart, base);
|
||
const fromKw = fromRel ?? parseKeyword(datePart, keywords, base);
|
||
const fromNum = fromKw ?? parseNumeric(datePart, prefs, base);
|
||
const result = fromNum;
|
||
if (!result) return null;
|
||
|
||
if (hours != null && minutes != null) {
|
||
result.setHours(hours, minutes, 0, 0);
|
||
}
|
||
return result;
|
||
}
|
||
```
|
||
|
||
If the current source uses different helper names (e.g. `parseRelative` instead of `parseRel`), keep the existing names — only the time-suffix scaffolding is new. The Date returned by the date-only branch already represents midnight local; the `setHours` call only fires when a time suffix is present, so existing callers (timeline `DateField` without `withTime`) keep their current behavior.
|
||
|
||
- [ ] **Step 3.3 — Lint**
|
||
|
||
```
|
||
npm run lint
|
||
```
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 3.4 — Manual smoke** (no automated tests in repo)
|
||
|
||
Open `adiuvAI/src/renderer/lib/parseDate.ts` and re-read the function to confirm: ` 14:30` suffix without leading date → returns null (because `datePart` is empty after slicing). `25:00` → null. Bare date still works.
|
||
|
||
- [ ] **Step 3.5 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/lib/parseDate.ts
|
||
git -C adiuvAI commit -m "feat(parseDate): accept optional ' HH:MM' suffix"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4 — `DateField`: add `withTime` and `flat` props
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/ui/date-field.tsx`
|
||
|
||
**Why:** Reuse the existing typeable DateField (which already drives via `parseDate` + `FormatPrefs`). Add (a) time Selects in the popover and (b) a `flat` mode that drops the internal Popover so the caller can embed it inside its own popover without nesting.
|
||
|
||
Re-read the file first — the full source is short (~165 lines).
|
||
|
||
- [ ] **Step 4.1 — Extend `DateFieldProps`**
|
||
|
||
```ts
|
||
export type DateFieldProps = {
|
||
value: Date | undefined;
|
||
onChange: (d: Date | undefined) => void;
|
||
onCommit?: (d: Date) => void;
|
||
placeholder?: string;
|
||
minDate?: Date;
|
||
autoFocus?: boolean;
|
||
invalidMessage?: string;
|
||
className?: string;
|
||
'aria-label'?: string;
|
||
id?: string;
|
||
/** Show hour/minute selects under the calendar; parseDate accepts ` HH:MM` suffix. */
|
||
withTime?: boolean;
|
||
/** Render Input + Calendar (+ Time) inline without an internal Popover. */
|
||
flat?: boolean;
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4.2 — Update display text when value changes**
|
||
|
||
The current effect formats the date via `formatDate(value.getTime(), prefs)`. When `withTime` is on, append `HH:MM` if the time component is non-zero:
|
||
|
||
```ts
|
||
useEffect(() => {
|
||
if (!focused) {
|
||
setText(value ? formatValue(value, prefs, withTime) : '');
|
||
setInvalid(false);
|
||
}
|
||
}, [value, focused, prefs, withTime]);
|
||
```
|
||
|
||
Add a local helper inside the file (or import from `lib/date.ts` if you'd rather extend it — but keeping it local avoids touching unrelated callers):
|
||
|
||
```ts
|
||
function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
|
||
const base = formatDate(d.getTime(), prefs);
|
||
if (!withTime) return base;
|
||
const h = String(d.getHours()).padStart(2, '0');
|
||
const m = String(d.getMinutes()).padStart(2, '0');
|
||
if (h === '00' && m === '00') return base;
|
||
return `${base} ${h}:${m}`;
|
||
}
|
||
```
|
||
|
||
(`FormatPrefs` is already imported as `useFormatPrefs`; add `import type { FormatPrefs } from '@/lib/date';` if needed.)
|
||
|
||
- [ ] **Step 4.3 — Add Time row inside the calendar popover**
|
||
|
||
The current `<PopoverContent>` body is just `<Calendar mode="single" ... />`. Wrap it so a Time row appears below when `withTime` is set. Same pattern as `TaskFormDialog`'s current Hour/Minute Selects (HOURS = 24, MINUTES = 5-minute steps).
|
||
|
||
Add at the top of the file:
|
||
|
||
```ts
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
|
||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||
```
|
||
|
||
Extract the calendar+time block into a small inline component used by both `flat` and popover modes:
|
||
|
||
```tsx
|
||
function CalendarTimeBody({
|
||
value,
|
||
onChange,
|
||
onCommit,
|
||
prefs,
|
||
withTime,
|
||
minDate,
|
||
onAfterPick,
|
||
}: {
|
||
value: Date | undefined;
|
||
onChange: (d: Date | undefined) => void;
|
||
onCommit?: (d: Date) => void;
|
||
prefs: FormatPrefs;
|
||
withTime: boolean;
|
||
minDate?: Date;
|
||
onAfterPick: () => void;
|
||
}) {
|
||
const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
|
||
const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';
|
||
|
||
function applyTime(h: string, m: string) {
|
||
if (!value) return;
|
||
const next = new Date(value);
|
||
next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
||
onChange(next);
|
||
onCommit?.(next);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Calendar
|
||
mode="single"
|
||
selected={value}
|
||
onSelect={(d) => {
|
||
if (d) {
|
||
// Preserve existing time when re-picking a date.
|
||
const next = new Date(d);
|
||
if (value && withTime) {
|
||
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
|
||
}
|
||
onChange(next);
|
||
onCommit?.(next);
|
||
}
|
||
onAfterPick();
|
||
}}
|
||
disabled={minDate ? { before: minDate } : undefined}
|
||
/>
|
||
{withTime && (
|
||
<div className="border-t px-3 py-2 flex items-center gap-1.5">
|
||
<Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
|
||
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
|
||
<SelectContent>
|
||
{HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-muted-foreground text-sm">:</span>
|
||
<Select value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''} onValueChange={(m) => applyTime(dueHour || '00', m)} disabled={!value}>
|
||
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
|
||
<SelectContent>
|
||
{MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.4 — Use `CalendarTimeBody` in both modes**
|
||
|
||
Inside the `DateField` JSX:
|
||
|
||
- When `flat`, render `<Input ... /> <div className="mt-2 rounded-md border"><CalendarTimeBody ... onAfterPick={() => inputRef.current?.focus()} /></div>`. **Do not** render the `Popover` wrapper or the trailing `CalendarIcon` button.
|
||
- When not `flat` (existing behavior), keep the `Popover` wrapper and replace the inner `<Calendar … />` with `<CalendarTimeBody ... onAfterPick={() => { setOpen(false); inputRef.current?.focus(); }} />`.
|
||
|
||
- [ ] **Step 4.5 — Lint**
|
||
|
||
```
|
||
npm run lint
|
||
```
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4.6 — Smoke**
|
||
|
||
Run dev (`npm start`), open Timeline → "Add event" → confirm DateField still works as before (no `withTime`, no `flat`). Spot-check: typing `tomorrow`, clicking calendar day. Visual must match prior behavior.
|
||
|
||
- [ ] **Step 4.7 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/components/ui/date-field.tsx
|
||
git -C adiuvAI commit -m "feat(date-field): add withTime + flat props"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5 — `PropertyPill`: render as real `<button>`
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/tasks/PropertyPill.tsx`
|
||
|
||
**Why:** Roving focus + Enter-to-open requires a real focusable element. The current `<span>`-based pill can't receive keyboard focus reliably.
|
||
|
||
Read the file first; it is small. Replace the rendered element with a `<button type="button">` and forward refs/handlers:
|
||
|
||
- [ ] **Step 5.1 — Update component signature and rendering**
|
||
|
||
```tsx
|
||
// adiuvAI/src/renderer/components/tasks/PropertyPill.tsx
|
||
import { forwardRef } from 'react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export interface PropertyPillProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
value?: string | null;
|
||
empty?: boolean;
|
||
}
|
||
|
||
export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
|
||
({ icon, label, value, empty, className, ...rest }, ref) => (
|
||
<button
|
||
ref={ref}
|
||
type="button"
|
||
data-empty={empty ? 'true' : undefined}
|
||
className={cn(
|
||
'inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs',
|
||
'border bg-card/70 text-foreground transition-colors',
|
||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
|
||
empty
|
||
? 'border-dashed text-muted-foreground bg-transparent'
|
||
: 'border-border',
|
||
className,
|
||
)}
|
||
{...rest}
|
||
>
|
||
<span className="shrink-0">{icon}</span>
|
||
<span className="text-muted-foreground">{label}</span>
|
||
{value != null && !empty && (
|
||
<>
|
||
<span className="text-muted-foreground/50">·</span>
|
||
<span className="font-medium">{value}</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
),
|
||
);
|
||
PropertyPill.displayName = 'PropertyPill';
|
||
```
|
||
|
||
- [ ] **Step 5.2 — Update callers that wrap PropertyPill in `<span>`**
|
||
|
||
`TaskFormDialog.tsx` currently wraps each `<PropertyPill ... />` inside `<span>` so `PopoverTrigger asChild` can attach a ref to a single element. Now that `PropertyPill` is itself a `<button>` with a forwarded ref, the `<span>` wrapper must go (`PopoverTrigger asChild` will inject its props/ref directly into the pill button). This is part of Task 6; no separate caller update needed here.
|
||
|
||
- [ ] **Step 5.3 — Lint**
|
||
|
||
```
|
||
npm run lint
|
||
```
|
||
|
||
Likely yields a transient ESLint warning at Task 6 callsites until Step 6.* lands. If lint output is otherwise clean, proceed to commit.
|
||
|
||
- [ ] **Step 5.4 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/components/tasks/PropertyPill.tsx
|
||
git -C adiuvAI commit -m "refactor(PropertyPill): render as button with forwardRef and focus ring"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6 — `TaskFormDialog`: header + pills row roving focus + Due via DateField + listbox keys + drop ⌘+Enter hint
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/tasks/TaskFormDialog.tsx`
|
||
|
||
This is the big task. Subdivided.
|
||
|
||
### 6a. Header swap
|
||
|
||
- [ ] **Step 6a.1 — Add `DialogDescription` import + new keys reference**
|
||
|
||
At the top of `TaskFormDialog.tsx`, in the existing `@/components/ui/dialog` import, add `DialogDescription`:
|
||
|
||
```ts
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog';
|
||
```
|
||
|
||
- [ ] **Step 6a.2 — Replace header markup**
|
||
|
||
Find:
|
||
|
||
```tsx
|
||
<DialogHeader className="px-5 py-3 border-b border-border/40">
|
||
<DialogTitle className="text-sm font-medium">
|
||
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```tsx
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
```
|
||
|
||
(The `DialogHeader` and `DialogTitle` shadcn defaults already give the same spacing/typography used in `AddEventDialog`. No `border-b`.)
|
||
|
||
### 6b. Pills row roving focus
|
||
|
||
- [ ] **Step 6b.1 — Import hook**
|
||
|
||
```ts
|
||
import { useRovingFocus } from '@/hooks/useRovingFocus';
|
||
```
|
||
|
||
- [ ] **Step 6b.2 — Wire up at top of component body**
|
||
|
||
Just after the existing `useState` calls, add:
|
||
|
||
```ts
|
||
const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
|
||
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
|
||
```
|
||
|
||
- [ ] **Step 6b.3 — Attach to each pill**
|
||
|
||
Each `<PopoverTrigger asChild>` wraps a `<span>` wrapping `<PropertyPill ... />`. Remove the `<span>` wrappers and pass roving props directly to the pill. The pattern, repeated five times with `index = 0..4`:
|
||
|
||
```tsx
|
||
<PopoverTrigger asChild>
|
||
<PropertyPill
|
||
{...pillsRoving.getItemProps(0)}
|
||
icon={<Folder className="h-3 w-3" />}
|
||
label={t('tasks.project')}
|
||
value={selectedProject?.name ?? null}
|
||
empty={!selectedProject}
|
||
aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
|
||
/>
|
||
</PopoverTrigger>
|
||
```
|
||
|
||
Repeat with `getItemProps(1)` (Priority), `getItemProps(2)` (Status), `getItemProps(3)` (Due), `getItemProps(4)` (Assignees). Update the surrounding `aria-label` per pill.
|
||
|
||
Add `role="toolbar" aria-label={t('tasks.properties')}` to the `<div className="flex flex-wrap gap-1.5" data-testid="property-pills">` wrapper that contains all pills.
|
||
|
||
### 6c. Each list popover uses `useListboxKeys`
|
||
|
||
- [ ] **Step 6c.1 — Import**
|
||
|
||
```ts
|
||
import { useListboxKeys } from '@/hooks/useListboxKeys';
|
||
```
|
||
|
||
- [ ] **Step 6c.2 — Project popover**
|
||
|
||
The Project popover today renders either `<InlineProjectForm ... />` (when `creatingProject`) or a list (`No project`, then `projectsList.map`). Wrap **only the list branch** in a listbox container that uses `useListboxKeys`:
|
||
|
||
```tsx
|
||
function ProjectList({
|
||
projects,
|
||
selectedId,
|
||
onSelect,
|
||
onCreate,
|
||
onClose,
|
||
}: {
|
||
projects: { id: string; name: string }[];
|
||
selectedId: string | null;
|
||
onSelect: (id: string | null) => void;
|
||
onCreate: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
// Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
|
||
const items = [
|
||
{ kind: 'new' as const },
|
||
{ kind: 'none' as const },
|
||
...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
|
||
];
|
||
const listbox = useListboxKeys({
|
||
itemCount: items.length,
|
||
initialIndex: selectedId
|
||
? items.findIndex((it) => it.kind === 'project' && it.id === selectedId)
|
||
: 1,
|
||
onSelect: (i) => {
|
||
const it = items[i];
|
||
if (it.kind === 'new') onCreate();
|
||
else if (it.kind === 'none') onSelect(null);
|
||
else onSelect(it.id);
|
||
},
|
||
onClose,
|
||
});
|
||
|
||
// Focus the active item once on mount.
|
||
useEffect(() => {
|
||
listbox.focusIndex(listbox.activeIndex);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
return (
|
||
<div className="p-1" role="listbox" aria-label={t('tasks.project')}>
|
||
{items.map((it, i) => {
|
||
const itemProps = listbox.getItemProps(i);
|
||
const base =
|
||
'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
|
||
if (it.kind === 'new') {
|
||
return (
|
||
<button
|
||
key="new"
|
||
type="button"
|
||
{...itemProps}
|
||
role="option"
|
||
className={base + ' text-primary'}
|
||
onClick={() => onCreate()}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
{t('projects.newProject')}
|
||
</button>
|
||
);
|
||
}
|
||
if (it.kind === 'none') {
|
||
return (
|
||
<button
|
||
key="none"
|
||
type="button"
|
||
{...itemProps}
|
||
role="option"
|
||
className={base}
|
||
onClick={() => onSelect(null)}
|
||
>
|
||
{t('tasks.noProject')}
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<button
|
||
key={it.id}
|
||
type="button"
|
||
{...itemProps}
|
||
role="option"
|
||
className={base}
|
||
onClick={() => onSelect(it.id)}
|
||
>
|
||
{it.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
In the Project popover, replace the inline list JSX with `<ProjectList ... />` and pass:
|
||
|
||
```tsx
|
||
<ProjectList
|
||
projects={projectsList}
|
||
selectedId={values.projectId}
|
||
onSelect={(id) => {
|
||
setValues((v) => ({ ...v, projectId: id }));
|
||
setProjectPopoverOpen(false);
|
||
}}
|
||
onCreate={() => setCreatingProject(true)}
|
||
onClose={() => setProjectPopoverOpen(false)}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 6c.3 — Priority popover**
|
||
|
||
Replace the existing three-button `priorities.map` with a small listbox wrapper:
|
||
|
||
```tsx
|
||
function PriorityList({
|
||
value,
|
||
onSelect,
|
||
onClose,
|
||
}: {
|
||
value: string;
|
||
onSelect: (v: 'high' | 'medium' | 'low') => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const items = ['high', 'medium', 'low'] as const;
|
||
const initial = Math.max(0, items.indexOf(value as typeof items[number]));
|
||
const listbox = useListboxKeys({
|
||
itemCount: items.length,
|
||
initialIndex: initial,
|
||
onSelect: (i) => onSelect(items[i]),
|
||
onClose,
|
||
});
|
||
useEffect(() => { listbox.focusIndex(listbox.activeIndex); /* eslint-disable-next-line */ }, []);
|
||
return (
|
||
<div role="listbox" aria-label={t('tasks.priority')} className="p-1">
|
||
{items.map((p, i) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
{...listbox.getItemProps(i)}
|
||
role="option"
|
||
aria-selected={value === p}
|
||
onClick={() => onSelect(p)}
|
||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||
>
|
||
{t(`tasks.${p}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Drop in the Priority `<PopoverContent>`:
|
||
|
||
```tsx
|
||
<PriorityList
|
||
value={values.priority}
|
||
onSelect={(p) => setValues((v) => ({ ...v, priority: p }))}
|
||
onClose={() => {/* shadcn closes when triggering element loses focus; explicit close below */}}
|
||
/>
|
||
```
|
||
|
||
For consistency with Project, control the Priority popover's `open` state from the parent (`const [priorityOpen, setPriorityOpen] = useState(false);`), pass it to `<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>`, and `onClose={() => setPriorityOpen(false)}`. On `onSelect`, also call `setPriorityOpen(false)`.
|
||
|
||
- [ ] **Step 6c.4 — Status popover**
|
||
|
||
Identical pattern to Priority. Items: `['todo', 'in_progress', 'done']`. Component `StatusList`. Parent state `statusOpen / setStatusOpen`.
|
||
|
||
- [ ] **Step 6c.5 — Assignees popover**
|
||
|
||
Multi-select. Build `AssigneesList` similar to the others but `onSelect` toggles membership in `values.assignees` (and the popover stays open):
|
||
|
||
```tsx
|
||
function AssigneesList({
|
||
known,
|
||
selected,
|
||
onToggle,
|
||
onClose,
|
||
newName,
|
||
onNewNameChange,
|
||
onAddNew,
|
||
}: {
|
||
known: string[];
|
||
selected: string[];
|
||
onToggle: (name: string) => void;
|
||
onClose: () => void;
|
||
newName: string;
|
||
onNewNameChange: (s: string) => void;
|
||
onAddNew: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const listbox = useListboxKeys({
|
||
itemCount: known.length,
|
||
initialIndex: 0,
|
||
onSelect: (i) => onToggle(known[i]),
|
||
onClose,
|
||
});
|
||
useEffect(() => {
|
||
if (known.length > 0) listbox.focusIndex(0);
|
||
// eslint-disable-next-line
|
||
}, []);
|
||
return (
|
||
<div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
|
||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||
{known.map((name, i) => {
|
||
const isOn = selected.includes(name);
|
||
return (
|
||
<button
|
||
key={name}
|
||
type="button"
|
||
{...listbox.getItemProps(i)}
|
||
role="option"
|
||
aria-selected={isOn}
|
||
onClick={() => onToggle(name)}
|
||
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||
>
|
||
{isOn ? '✓ ' : ' '}{name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="border-t mt-2 pt-2 flex gap-1.5">
|
||
<Input
|
||
placeholder={t('tasks.newAssigneeName', 'New name…')}
|
||
value={newName}
|
||
onChange={(e) => onNewNameChange(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
onAddNew();
|
||
}
|
||
}}
|
||
className="h-8 text-sm flex-1"
|
||
/>
|
||
<Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
|
||
{t('common.add')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
In the Assignees `<PopoverContent>`, replace the existing JSX with:
|
||
|
||
```tsx
|
||
<AssigneesList
|
||
known={knownAssignees}
|
||
selected={values.assignees}
|
||
onToggle={(name) =>
|
||
setValues((v) => ({
|
||
...v,
|
||
assignees: v.assignees.includes(name)
|
||
? v.assignees.filter((a) => a !== name)
|
||
: [...v.assignees, name],
|
||
}))
|
||
}
|
||
onClose={() => {/* uncontrolled Popover; native Esc/blur closes */}}
|
||
newName={assigneeInput}
|
||
onNewNameChange={setAssigneeInput}
|
||
onAddNew={addNewAssignee}
|
||
/>
|
||
```
|
||
|
||
(Assignees can stay uncontrolled — shadcn handles dismissal on outside click + Esc inside the listbox is already wired by `useListboxKeys` via `onClose`; for a true close-on-esc, mirror the controlled pattern used for Priority/Status if desired. Keeping uncontrolled is consistent with the current code.)
|
||
|
||
### 6d. Due popover via `DateField`
|
||
|
||
- [ ] **Step 6d.1 — Replace the entire Due `<Popover>` block**
|
||
|
||
Remove the `HOURS`/`MINUTES` constants and the `derivePartsInTz` / `updateDueTime` helpers from `TaskFormDialog.tsx` — `DateField` now owns time handling. Then replace the Due `<Popover>` body with:
|
||
|
||
```tsx
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<PropertyPill
|
||
{...pillsRoving.getItemProps(3)}
|
||
icon={<CalIcon className="h-3 w-3" />}
|
||
label={t('tasks.colDue')}
|
||
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
|
||
empty={!values.dueDate}
|
||
aria-label={t('tasks.colDue')}
|
||
/>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-3" align="start">
|
||
<DateField
|
||
flat
|
||
withTime
|
||
value={values.dueDate ? new Date(values.dueDate) : undefined}
|
||
onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
|
||
placeholder={t('tasks.colDue')}
|
||
aria-label={t('tasks.colDue')}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
```
|
||
|
||
Add `import { DateField } from '@/components/ui/date-field';` at the top and remove unused imports (`TZDate`, `Calendar`, `Select`* if no longer used elsewhere in the file — re-run lint after).
|
||
|
||
**Note on timezones:** the old code constructed `TZDate` using user `prefs.timezone` so e.g. `00:00` was interpreted in the user's IANA zone. `DateField` uses plain `Date` (browser local time). In Electron this is the user's OS timezone, which is what `prefs.timezone` already defaults to. For users who manually override `prefs.timezone` to a non-OS zone, due times here will reflect OS-local instead. This matches `AddEventDialog`'s current behavior and is acceptable; if/when timezone-aware editing is reintroduced, both `DateField` and `AddEventDialog` should change together.
|
||
|
||
### 6e. Remove ⌘+Enter footer hint
|
||
|
||
- [ ] **Step 6e.1 — Audit footer**
|
||
|
||
Re-read the current footer: the existing footer has only Cancel + Submit + (optional Paperclip in edit mode). It does NOT contain a "⌘+Enter to create" hint — that was a mockup-only element. The form-level `onKeyDown` that submits on `⌘/Ctrl+Enter` stays.
|
||
|
||
No code change required for 6e; this step is a checklist confirmation. Move on.
|
||
|
||
### 6f. Lint + smoke + commit
|
||
|
||
- [ ] **Step 6f.1 — Lint**
|
||
|
||
```
|
||
npm run lint
|
||
```
|
||
Expected: clean. Address any "unused import" errors from removed `TZDate` / `Select` / `Calendar` etc.
|
||
|
||
- [ ] **Step 6f.2 — Manual smoke (full keyboard flow)**
|
||
|
||
`npm start`, then in the running app:
|
||
|
||
1. Open Tasks page → "New task". Header shows title + muted description, no separator line.
|
||
2. Tab from the title input → focus lands on the description textarea. Tab again → first pill (Project) shows focus ring.
|
||
3. Press ←/→ and ↑/↓: focus moves between pills.
|
||
4. With Project pill focused, press Enter: popover opens; "No project" or current selection is focused.
|
||
5. ↓/↑ navigate items. Enter selects → popover closes → focus returns to the Project pill.
|
||
6. Repeat for Priority, Status, Assignees. For Assignees, Enter toggles, Esc closes.
|
||
7. With Due pill focused, press Enter: popover opens with `DateField`. Type `30/04/2026 14:30`, press Enter — pill value reads `30/04/2026 14:30` (or US format if `FormatPrefs.dateFormat = 'MM/dd/yyyy'`).
|
||
8. Re-open Due, click a calendar day — date updates while keeping the time. Change hour Select → pill reflects new time.
|
||
9. `Ctrl+Enter` anywhere in the form submits.
|
||
10. Edit mode: re-open an existing task with a due date that has `HH:MM` and verify the field renders both date and time.
|
||
|
||
- [ ] **Step 6f.3 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/components/tasks/TaskFormDialog.tsx
|
||
git -C adiuvAI commit -m "feat(TaskFormDialog): new header, full keyboard nav, DateField-based due"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7 — i18n keys in all five languages
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json`
|
||
|
||
**Why:** Header description string is new; missing keys would print the key path.
|
||
|
||
- [ ] **Step 7.1 — Add keys under the `tasks` namespace**
|
||
|
||
For each language, add (or merge into the existing `"tasks": { ... }` block) the two new keys:
|
||
|
||
EN:
|
||
```json
|
||
"newTaskDescription": "Capture what needs doing. Set properties below or refine later.",
|
||
"editTaskDescription": "Update the task details, properties, and attachments."
|
||
```
|
||
|
||
IT:
|
||
```json
|
||
"newTaskDescription": "Annota cosa va fatto. Imposta le proprietà sotto o aggiornale dopo.",
|
||
"editTaskDescription": "Aggiorna dettagli, proprietà e allegati dell'attività."
|
||
```
|
||
|
||
ES:
|
||
```json
|
||
"newTaskDescription": "Anota qué hay que hacer. Define las propiedades abajo o más tarde.",
|
||
"editTaskDescription": "Actualiza los detalles, propiedades y adjuntos de la tarea."
|
||
```
|
||
|
||
FR:
|
||
```json
|
||
"newTaskDescription": "Notez ce qu'il faut faire. Définissez les propriétés ci-dessous ou plus tard.",
|
||
"editTaskDescription": "Mettez à jour les détails, propriétés et pièces jointes de la tâche."
|
||
```
|
||
|
||
DE:
|
||
```json
|
||
"newTaskDescription": "Halten Sie fest, was zu tun ist. Eigenschaften unten oder später setzen.",
|
||
"editTaskDescription": "Aktualisieren Sie Details, Eigenschaften und Anhänge der Aufgabe."
|
||
```
|
||
|
||
Place the keys near other `tasks.new*` / `tasks.edit*` entries (alphabetical inside `tasks` is the existing convention — match it).
|
||
|
||
- [ ] **Step 7.2 — Lint**
|
||
|
||
```
|
||
npm run lint
|
||
```
|
||
Expected: clean (JSON files don't lint, but TypeScript references resolve).
|
||
|
||
- [ ] **Step 7.3 — Smoke**
|
||
|
||
`npm start`, switch language in Settings → General → Language and confirm the dialog description renders in the chosen language.
|
||
|
||
- [ ] **Step 7.4 — Commit**
|
||
|
||
```
|
||
git -C adiuvAI add src/renderer/locales/en/translation.json src/renderer/locales/it/translation.json src/renderer/locales/es/translation.json src/renderer/locales/fr/translation.json src/renderer/locales/de/translation.json
|
||
git -C adiuvAI commit -m "i18n: add tasks.newTaskDescription / editTaskDescription"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8 — Bump submodule in the monorepo
|
||
|
||
**Files:**
|
||
- Modify: top-level `adiuvAI` submodule pointer.
|
||
|
||
- [ ] **Step 8.1 — Stage submodule bump**
|
||
|
||
From the monorepo root (`c:\Users\PC-Roby\Documents\_adiuvai_workspace`):
|
||
|
||
```
|
||
git add adiuvAI
|
||
git status
|
||
```
|
||
|
||
Expected: `modified: adiuvAI (new commits)` is now staged.
|
||
|
||
- [ ] **Step 8.2 — Commit at monorepo level**
|
||
|
||
```
|
||
git commit -m "feat: bump adiuvAI submodule — task form keyboard polish"
|
||
```
|
||
|
||
- [ ] **Step 8.3 — Run graphify update**
|
||
|
||
Per project `CLAUDE.md`:
|
||
|
||
```
|
||
graphify update .
|
||
```
|
||
|
||
This keeps the knowledge graph aligned with the new hooks / components. AST-only, no API cost.
|
||
|
||
- [ ] **Step 8.4 — Final smoke + done**
|
||
|
||
Reopen the Task dialog one more time and run through the full keyboard flow in §6f.2 to confirm nothing regressed after the submodule pointer was bumped.
|
||
|
||
---
|
||
|
||
## Self-review (post-write, pre-handoff)
|
||
|
||
- **Spec coverage:**
|
||
- §1 Header → Task 6a.
|
||
- §2 Pills row roving focus → Task 1 + 6b. List popovers → Task 2 + 6c. Calendar nav → react-day-picker built-in (no task needed; surfaced inside `CalendarTimeBody` in Task 4). Description Enter = newline → unchanged (Task 6e checklist).
|
||
- §3 Date+time:
|
||
- parseDate suffix → Task 3.
|
||
- DateField `withTime` + `flat` → Task 4.
|
||
- TaskFormDialog Due popover replacement → Task 6d.
|
||
- §4 Files list → mirrored 1-for-1.
|
||
- §5 Accessibility — `role="toolbar"` (6b.3), `role="listbox"` / `role="option"` / `aria-multiselectable` / `aria-selected` (6c). DateField a11y unchanged.
|
||
- §6 Out-of-scope items are not in the plan. Good.
|
||
|
||
- **Placeholder scan:** no "TBD", "TODO", or hand-waving steps. Each code-changing step shows code. The "no internal tests" note explicitly says manual smoke instead of inventing fake tests.
|
||
|
||
- **Type consistency:**
|
||
- `useRovingFocus` exposes `{ activeIndex, setActive, getItemProps }` — used in Task 6b.
|
||
- `useListboxKeys` exposes `{ activeIndex, setActive, focusIndex, getItemProps }` — used in Tasks 6c.2–6c.5.
|
||
- `DateField` new props `withTime`, `flat` — consumed in Task 6d.
|
||
- `PropertyPill` becomes a forwardRef `<button>` — consumed via `{...pillsRoving.getItemProps(i)}` in Task 6b.
|
||
- i18n keys `tasks.newTaskDescription`, `tasks.editTaskDescription` — defined Task 7, referenced Task 6a. Match.
|
||
|
||
No gaps detected.
|