Skip to content

fyi-tempo-domain

fyi-tempo-domain

fyiread

Use this when: you're working on Tempo's task / Todoist domain

Problem it solves — Tempo has conventions you can't guess — the inverted Todoist priority, the DTO layer, the ci:check gate, the Windows/Herd footguns. This is the reference that loads them before you touch task or Todoist code.

FYI: Tempo domain conventions

Front-loaded context so changes land right the first time. Verify file paths against the current code before relying on them.

Todoist priority is inverted

Todoist api/v1 priority integer: 4 = very urgent (client "p1") … 1 = natural/default (client "p4"). The labels and the integers run opposite ways.

  • Base URL is https://api.todoist.com/api/v1; list endpoints return a { "results": [...], "next_cursor": ... } envelope (unwrap results).
  • The canonical stored value is the raw API integer in App\Data\Task::$priority.
  • Convert via Task::priorityLabelFor() / Task::priorityValueFor() and Task::NATURAL_PRIORITY. Never hardcode the int↔p-code mapping inline.

Labels are an enum, stored bare (no "@")

Todoist's API returns and stores label names without the leading "@" (the "@" is only a UI affordance). So the canonical form everywhere is bare.

  • Use App\Enums\Label for every label comparison and write — never a string literal like '@someday'. The old @-prefixed literals silently failed to match real Todoist data (which is bare); the enum exists so that class of bug can't recur.
  • Compare with Label::Someday->existsIn($task->labels) or Label::Someday->is($raw). normalize() slugifies (lowercases, strips "@", spaces/underscores/junk → hyphens) via Str::slug(..., dictionary: ['@' => '']) — so "@Low Energy" matches "low-energy"; casing/spacing/"@" can't mismatch.
  • ->value is the canonical slug (write it back to Todoist); valueWithPrefix() / Label::withPrefix($raw) add the "@" for display only (Helpers UI), not the dashboard cards.
  • Classification::fromLlm() and HelperSkill (mutator) store the canonical slug — those labels are app-created. Task::fromTodoist() keeps Todoist's labels verbatim — never canonicalise read labels, or the write-back paths (Someday promote, Task Helper) would silently rename the user's own labels. Matching is always the enum's job, never a raw string compare.

Key objects are spatie/laravel-data DTOs

Domain objects live in app/Data/, not array<string,mixed>: Task, TaskCollection (extends DataCollection, fixed to Task), TaskDue, TaskDuration, Classification, Digest, GoogleTask.

  • TaskClient (getTasks/getTask/getAllTasks) returns TaskCollection/Task; createTask/updateTask take/return raw Todoist payload arrays.
  • DTOs map via fromTodoist() / fromLlm() factories; derived values use #[Computed] (e.g. Task::$priorityLabel).
  • Names are abstract (Task, not TodoistTask) — provider names belong only on implementations/fakes (HttpTodoistClient, FakeTodoistClient).
  • Validation lives in App\Http\Requests FormRequest classes, never inline $request->validate() in a controller. Type-hint the request, use $request->validated().
  • Iterate a DataCollection with iterator_to_array($collection)collect() calls its toArray() and turns each DTO into an array.

Integration secrets live in the DB, not .env

Every external secret — Todoist token + webhook secret, Anthropic API key, Notion token, Google client id/secret/redirect — is stored encrypted in the credentials table and read through App\Services\CredentialStore, keyed by App\Enums\CredentialName. Only APP_KEY and the DB connection stay in .env.

  • Resolution is DB-only: CredentialStore::getOrFail() throws MissingCredentialException when absent — no silent .env fallback. Clients take a Closure(): string resolved per call, so construction never hits the store. The Anthropic key is pushed into config('ai.providers.anthropic.key') in AppServiceProvider::boot() (guarded by Schema::hasTable).
  • Add a new integration's secret as a CredentialName case + manage it in the settings Credentials UI — never introduce a new .env var. CredentialName::configKey() must point at a real config path (there's a test asserting each exists).
  • php artisan credentials:import seeds the store from config on cutover.
  • Never put a real secret in a seeder or factoryCredentialFactory uses fake()->sha256(); tests seed dummy values like 'test-token'.

The dashboard runs off a server-built plan

The morning TaskOrganiserJob calls DailyPlanner, which decides the digest, sections (quick/important/everything/overdue + week peek) and order, persisting daily_plan_tasks rows. DashboardController is thin: read today's plan → fetch fresh Todoist data → render. No plan ⇒ empty state. Categorisation logic belongs in DailyPlanner, not the controller or the Vue.

Quality gate

composer ci:check must pass before committing: Pint, ESLint, Prettier, vue-tsc, PHPStan level 7, coverage ≥ 80%, mutation ≥ 90%. Never lower those thresholds without explicit permission. When a mutation survives, add tests — don't simplify production code.

Windows / Herd tooling

  • Run PHP/artisan/composer/pest via PowerShell, not the Bash tool (php isn't on Bash's PATH).
  • PowerShell parses | in args — use the stop-parsing token for filters: php artisan test --% --compact --filter "A|B".
  • Run vendor/bin/pint --dirty --format agent before committing PHP.
  • spatie Data::toArray() needs the app container — DTO tests that call it must live in tests/Feature, not tests/Unit.