Skip to content

Inertia (server + client)

Success/failure/empty surfaces (toasts, inline errors, empty states) follow the observability contract in docs/agents/ui-patterns.md — every mutation must report its outcome there.

  • Controllers return pages, not JSON: Inertia::render('People/Index', [...]). After a successful store/update/destroy, redirect back (PRG) — return to_route('people.index'), never re-render the page inline (a refresh would re-POST).

  • Generate URLs from Wayfinder named routes — import the action/route (import { update } from '@/actions/…' or @/routes/*) and call it; never hardcode /people/1. Use <Link :href="…"> for internal navigation; reserve <a> for external links.

  • Drive forms with useForm; submit through a Wayfinder action and read server validation from form.errors — the server is the source of truth, don't duplicate the rules client-side:

    ts
    const form = useForm({ name: '' });
    form.submit(store());        // Wayfinder action → POST
    // template: <InputError :message="form.errors.name" />
  • Shared data (in HandleInertiaRequests::share) stays minimal: lazily-evaluated closures, only(...) the fields the client needs — never whole models.

  • Pages are thin + typed (defineProps<{...}>()), composed from components/ and layouts/. Pass App\Data DTOs, not loose arrays, so the page props carry a stable typed shape.

  • Force forceFormData only when a file is actually attached (forceFormData: files.length > 0), never unconditionally. Inertia auto-switches to FormData when the payload contains a File, so forcing multipart on a text-only submit is needless and can drop plain fields server-side (it dropped message in the in-process pest-browser server — docs/learnings/2026-06-11-inertia-forceformdata-text-only-multipart.md).

Consume Tempo's domain layer, not raw Todoist

The frontend renders Tempo's DTO/label layer, never raw Todoist fields. The DTO (app/Data/*) does the mapping once, server-side; the page consumes the mapped value. Reaching past it to a raw Todoist field re-implements (and gets wrong) a translation the DTO already owns.

The canonical trap is priority (bug #2): Todoist's raw priority int is inverted1 = natural/default … 4 = most urgent — whereas Tempo's client-facing codes run p1 (most urgent) … p4. So the raw int read straight into the UI is upside-down.

vue
<!-- ✗ raw Todoist field — inverted, unmapped -->
<span>{{ task.priority }}</span>

<!-- ✓ Tempo's mapped value off the App\Data\Task DTO -->
<span>{{ task.priorityLabel }}</span>

Same rule for the rest of the task shape: use priorityLabel, displayLabels (raw labels minus the duration estimate), estimate() / the duration band (rendered as "X min", never a chip), and TaskDue / deadline — never the raw label array, raw duration, or raw date strings. If a mapped value you need is missing, add it to the DTO — don't map in the template.