Appearance
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 (unwrapresults). - The canonical stored value is the raw API integer in
App\Data\Task::$priority. - Convert via
Task::priorityLabelFor()/Task::priorityValueFor()andTask::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\Labelfor 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)orLabel::Someday->is($raw).normalize()slugifies (lowercases, strips "@", spaces/underscores/junk → hyphens) viaStr::slug(..., dictionary: ['@' => ''])— so "@Low Energy" matches "low-energy"; casing/spacing/"@" can't mismatch. ->valueis 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()andHelperSkill(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) returnsTaskCollection/Task;createTask/updateTasktake/return raw Todoist payload arrays.- DTOs map via
fromTodoist()/fromLlm()factories; derived values use#[Computed](e.g.Task::$priorityLabel). - Names are abstract (
Task, notTodoistTask) — provider names belong only on implementations/fakes (HttpTodoistClient,FakeTodoistClient). - Validation lives in
App\Http\RequestsFormRequest classes, never inline$request->validate()in a controller. Type-hint the request, use$request->validated(). - Iterate a
DataCollectionwithiterator_to_array($collection)—collect()calls itstoArray()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()throwsMissingCredentialExceptionwhen absent — no silent.envfallback. Clients take aClosure(): stringresolved per call, so construction never hits the store. The Anthropic key is pushed intoconfig('ai.providers.anthropic.key')inAppServiceProvider::boot()(guarded bySchema::hasTable). - Add a new integration's secret as a
CredentialNamecase + manage it in the settings Credentials UI — never introduce a new.envvar.CredentialName::configKey()must point at a real config path (there's a test asserting each exists). php artisan credentials:importseeds the store from config on cutover.- Never put a real secret in a seeder or factory —
CredentialFactoryusesfake()->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 (
phpisn'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 agentbefore committing PHP. - spatie
Data::toArray()needs the app container — DTO tests that call it must live intests/Feature, nottests/Unit.