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>
40 KiB
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 Mockup: docs/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
// 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
// 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:
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:
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
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:
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):
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:
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:
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
CalendarTimeBodyin 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 thePopoverwrapper or the trailingCalendarIconbutton. -
When not
flat(existing behavior), keep thePopoverwrapper 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
// 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
DialogDescriptionimport + new keys reference
At the top of TaskFormDialog.tsx, in the existing @/components/ui/dialog import, add DialogDescription:
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
- Step 6a.2 — Replace header markup
Find:
<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:
<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
import { useRovingFocus } from '@/hooks/useRovingFocus';
- Step 6b.2 — Wire up at top of component body
Just after the existing useState calls, add:
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:
<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
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:
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:
<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:
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>:
<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):
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:
<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:
<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:
- Open Tasks page → "New task". Header shows title + muted description, no separator line.
- Tab from the title input → focus lands on the description textarea. Tab again → first pill (Project) shows focus ring.
- Press ←/→ and ↑/↓: focus moves between pills.
- With Project pill focused, press Enter: popover opens; "No project" or current selection is focused.
- ↓/↑ navigate items. Enter selects → popover closes → focus returns to the Project pill.
- Repeat for Priority, Status, Assignees. For Assignees, Enter toggles, Esc closes.
- With Due pill focused, press Enter: popover opens with
DateField. Type30/04/2026 14:30, press Enter — pill value reads30/04/2026 14:30(or US format ifFormatPrefs.dateFormat = 'MM/dd/yyyy'). - Re-open Due, click a calendar day — date updates while keeping the time. Change hour Select → pill reflects new time.
Ctrl+Enteranywhere in the form submits.- Edit mode: re-open an existing task with a due date that has
HH:MMand 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
tasksnamespace
For each language, add (or merge into the existing "tasks": { ... } block) the two new keys:
EN:
"newTaskDescription": "Capture what needs doing. Set properties below or refine later.",
"editTaskDescription": "Update the task details, properties, and attachments."
IT:
"newTaskDescription": "Annota cosa va fatto. Imposta le proprietà sotto o aggiornale dopo.",
"editTaskDescription": "Aggiorna dettagli, proprietà e allegati dell'attività."
ES:
"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:
"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:
"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
adiuvAIsubmodule 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
CalendarTimeBodyin 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:
useRovingFocusexposes{ activeIndex, setActive, getItemProps }— used in Task 6b.useListboxKeysexposes{ activeIndex, setActive, focusIndex, getItemProps }— used in Tasks 6c.2–6c.5.DateFieldnew propswithTime,flat— consumed in Task 6d.PropertyPillbecomes 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.