axis

case study

A craft tool for time.

Axis is a personal scheduler built in a one-week sprint — and a product-management case study written as it happened. Every non-trivial call got a decision entry the moment it was made: context, options, choice, tradeoffs, reversibility. No retrospective polish.

What follows is the log itself, typeset. 57 decisions, 2026-04-17 through 2026-08-20.

D1·2026-04-17

Project is a personal tool + PM case study

Context. The project could be framed as a personal productivity tool, a sellable product, or a portfolio piece. The framing drives what "done" means.

Options considered.

  • A. Personal tool only — done when Beau uses it daily.
  • B. Productised — done when paying users exist.
  • C. Portfolio / open-source — done when a GitHub README demonstrates sophistication.
  • D. Personal tool that doubles as a PM case study artifact.

Chosen: D. Beau wants both daily utility and evidence of PM/PO direction he can show prospective employers/clients.

Tradeoffs. Documentation overhead (decision log, competitive analysis, metrics) costs ~20-30% of project time. Accepted because the written artifact is itself a deliverable. The code does not have to be exceptional; the thinking does.

Reversibility. Cheap. Can narrow to just-a-tool or broaden to product at any time.

D2·2026-04-17

Scheduling philosophy: hybrid, configurable, self-experimenting

Context. Competitors cluster into three philosophies: aggressive (Motion), defensive (Reclaim), intentional (Sunsama). Which is right?

Options considered.

  • A. Aggressive — auto-place every task, reshuffle on change.
  • B. Defensive — protect focus blocks, let meetings flow around them.
  • C. Intentional — suggest placements, user approves each one.
  • D. Hybrid with configurable aggressiveness → measure which setting converges as Beau uses the tool.

Chosen: D. Beau's gut was that the right answer probably varies by task type and by person. Rather than pre-committing, Axis exposes aggressiveness as a parameter and the metrics package tracks whether it converges.

Tradeoffs. More UI surface, more decisions for the user, more implementation complexity in the scheduler. Motion's whole thesis is "don't make users think" — Axis takes the opposite bet. Named in the case study as an explicit, testable hypothesis.

Reversibility. Moderate. Removing configurability is cheap; adding it later requires UI + data-model rework.

D3·2026-04-17

Calendar: Google Calendar only, two-way sync, writeback to dedicated calendar

Context. The scheduler needs to know what time is busy, and users may want their scheduled blocks visible in their normal calendar.

Options considered.

  • A. Google only.
  • B. Google + Outlook.
  • C. Google + Outlook + Apple (via CalDAV).
  • D. No external calendar — internal only.
  • E. (Sub-decision) Read-only vs two-way writeback.

Chosen: A + two-way writeback to a dedicated "Axis" calendar created programmatically.

Why dedicated calendar, not primary? A separate calendar lets Axis bulk-delete / regenerate blocks on reshuffle without ever touching real meetings. Motion and Reclaim both do this. Visibility can be toggled as a single setting.

Tradeoffs. Users without Google Calendar are excluded from v1. Accepted — Beau uses Google, and cross-provider support is a v2 decision if Axis is ever shared.

Open question for v1 implementation. If the user manually drags an Axis-placed block inside Google Calendar, does that lock the placement (respect) or does the next reshuffle overwrite it? Defer decision until the scheduler loop is built.

Reversibility. Moderate. Adding Outlook later requires a second OAuth flow + provider-abstraction layer.

D4·2026-04-17

Task complexity cut: v1 = fixed/flex + chunking; v2 = deps + recurring; v3 = energy tags

Context. Task models can grow infinitely. What does v1 actually need?

Options considered. Dependencies, recurring tasks, subtask hierarchy, fixed-vs-flexible timing, effort-vs-duration chunking, energy/context tags.

Chosen for v1: fixed-vs-flexible timing + chunking only.

  • v2: dependencies + recurring.
  • v3 / delighter: energy tags + context matching.

Why. Fixed/flex + chunking is the minimum for auto-scheduling to feel useful. Dependencies add a topological sort to the scheduler and break the naive greedy algorithm; recurring tasks need their own recurrence engine — both add ~60% of algorithmic complexity for edge cases Beau can't verify he needs yet. Real usage data will tell us which v2 feature actually mattered.

Tradeoffs. Power users who expect dependency chains will feel v1 is shallow. Accepted — Axis has one user for now, and he doesn't need them.

Reversibility. Cheap to add v2 features; the data model will be extended rather than rebuilt.

D5·2026-04-17

Data model: Project → Task; link-only attachments; user-defined flat tags; fixed metadata schema

Context. Task model needs to balance expressiveness vs scope.

Decisions.

  • Projects are first-class parent entities. Every task belongs to a project. Minimum fields: name, description, colour (for calendar grouping), status (active/archived).
  • Attachments: URLs/links only for v1. No file uploads. Rich text description covers most needs.
  • Categories: user-defined flat tags (not fixed list, not hierarchical). Most expressive option that doesn't add hierarchy complexity.
  • Custom metadata fields: fixed schema for v1. User-defined fields (Notion-style) deferred to v2.

Why Project as first-class entity. Tiny cost now (one table + FK), huge retrofit cost later. Unlocks project views, cross-project scheduling balance, rollup metrics, and team-mode optionality — all without building them yet.

Tradeoffs. Flat tags mean no "Work > Client X > Sprint 4" hierarchy. Accepted — hierarchical tagging is a rabbit hole, and a well-named flat tag system is usually enough in practice.

Reversibility. Cheap for tags; moderate for custom fields.

D6·2026-04-17

Stack: Next.js 16.2.3 LTS + Supabase + Drizzle + shadcn/ui + googleapis (dropped Payload)

Context. Beau has a Payload + Supabase + Vercel template. Should Axis reuse it?

Options considered.

  • A. Reuse Payload template (full CMS).
  • B. Drop Payload, use Next.js + Supabase directly.
  • C. Experimental stack (Convex, SvelteKit, tRPC).

Chosen: B.

Why dropped Payload. Payload's ~80% value is its admin UI for non-technical content editors. Axis has one user (the developer) who will edit fields through code. Payload therefore adds abstraction cost with no offsetting benefit. This is the kind of "don't adopt frameworks for features you won't use" call worth making visible in the case study.

Final stack.

  • Next.js 16.2.3 LTS (App Router, Turbopack stable default, React Compiler stable)
  • Supabase (Postgres + Auth + Row-Level Security)
  • Drizzle ORM (typesafe schema + migrations)
  • shadcn/ui + Tailwind
  • TanStack Query (heavy re-fetch on drag-drop and reshuffle)
  • googleapis npm package for Calendar
  • Vercel hosting

Pitfall noted for case study. @supabase/auth-helpers-nextjs is deprecated but still heavily indexed on Google. Axis will use the current @supabase/ssr (createServerClient / createBrowserClient) pattern throughout. Documenting pitfalls avoided is part of the case study value.

Reversibility. Adding Payload later would be a significant retrofit; sticking without it is a one-way door that we're deliberately choosing.

D7·2026-04-17

Platform: Web PWA (installable) with push notifications

Context. Where will the tool actually be used?

Options. Web-only desktop · Web PWA · Web + native mobile · Web responsive no PWA.

Chosen: Web PWA.

Why. The "check my plan on phone in the morning" use case is real. PWA gets ~90% of native feel for ~5% of the effort. Web Push for reminders is genuinely useful.

Known constraint. iOS Safari only honours Web Push when the PWA is installed to home screen. Flagging this in the case study as a known limitation rather than a surprise-bug.

Tradeoffs. No App Store presence. Accepted.

Reversibility. Moderate. Native apps (React Native / Expo) are a v3 move if ever wanted.

D8·2026-04-17

Auth: multi-tenant Supabase Auth via Google OAuth; RLS from day 1

Context. One user today — but architecture should not make team mode painful if we ever want it.

Options. Single-user hardcoded · Multi-tenant with RLS (one signup) · Multi-tenant with public signup.

Chosen: Multi-tenant with RLS, one signup (Beau's).

Why. Google OAuth is the auth flow anyway — it's how Axis obtains the Calendar API access token. Using it for sign-in too adds zero new infrastructure. RLS is ~2 days extra work now vs weeks of retrofit pain later if teams are ever added. Also an organizations table (unused in v1) is added now to preserve team-mode optionality cheaply — classic "free to add early, expensive to add late."

Tradeoffs. More auth ceremony than strictly needed for a one-user tool. Accepted because the alternative narrows future options.

Reversibility. Cheap to stay; expensive to add later.

D9·2026-04-17

Product name: Axis

Context. Naming affects domain, brand, repo, and case-study narrative. Worth deliberate thought, not placeholder.

Options considered (highlights).

  • Dial — directly names the configurability bet.
  • Kairos — Greek for "opportune moment"; philosophical anchor.
  • Cadence — rhythm of sustainable work.
  • Settle, Ember, Tempo, Compass, Hence, Slate, Reclock.

Chosen: Axis.

Why. Short, clean, distinctive. The metaphor — the point your week pivots around — is quietly strong for a scheduler. No major collision in the productivity space. Good domain / handle optionality.

Tradeoffs. Less directly "about" configurability than Dial would have been. Slightly less philosophical than Kairos. Accepted in exchange for a cleaner, more ownable brand.

Reversibility. Cheap now (nothing shipped), expensive once the domain, users, and case study reference the name.

D10·2026-04-17

Timeline: ~1 week full-time MVP; scope unchanged; case study written in parallel

Context. Beau is available to work on Axis full-time, so the 2-3 week evenings/weekends framing doesn't fit. Scope (full v1 as specced in D1–D9) stays the same. Question is: how to pace it, and how to write the case study.

Options considered for pace.

  • A. Weekend MVP — ruthless cut (drop drag-drop / reshuffle / PWA). Rejected: scope is not the lever; we already cut hard in D4.
  • B. ~1 week full-time — full v1 as specced. Realistic at full-time capacity.
  • C. Stretched multi-month build-in-public. Rejected: case study benefits from momentum; dragging it out loses narrative tension.

Options considered for case-study cadence.

  • i. Retrospective (write up after v1 ships).
  • ii. Live weekly posts as you go.
  • iii. Parallel living draft — decision log + docs are the case study, shaped into polished posts at milestones.

Chosen: B + iii. Roughly one week of full-time work for v1, case study maintained in parallel so Axis is presentable at any point.

Why. Retrospective write-ups are the most common PM-case-study failure mode — memory of tradeoffs degrades within days. Living draft keeps the raw material fresh. "Presentable at any point" is an explicit success criterion: if a recruiter asked today, the README.md + docs/ already answer the "what are you working on?" question credibly.

Working-day buckets (indicative, not a contract; revised post-eng-review).

  • Day 1 — PRD + roadmap + Next.js 16 scaffold + Drizzle + Supabase SSR + auth skeleton + test infra + CLAUDE.md.
  • Days 2–3 — data model + RLS + Google OAuth + Calendar API (read sync + writes to user's primary calendar per D12).
  • Day 4 — GCal push webhook + channel-renewal cron + writeback outbox + outbox-retry worker.
  • Day 5 — scheduler: placer + reshuffler + generateReason helper + fast-check property tests + debounce sweep.
  • Day 6 — calendar view UI + drag-drop + user_adjusted detection + metrics instrumentation + Sentry + /api/health.
  • Day 7 — PWA (Android + desktop) + Web Push (VAPID) + Friday journal + /story route + polish + go-live.

Baseline week. Runs before v1 go-live (days -7 to 0, captured in a notebook or the existing task system). NOT a Day-7 app mode. Rationale for this correction in eng-review-proposed feature E2: building a baseline-mode feature for a single-user pre-registration is rigor theater — a notebook does the same job for zero engineering cost. The case study narrates the baseline week in prose, citing numbers captured by hand.

Tradeoffs. Full-time pace compresses time available to reflect on each decision. Mitigated by the discipline of writing each decision into the log at the moment it's made.

Reversibility. Cheap to slow down or speed up; expensive to reconstruct decision rationale retrospectively once the pace picks up.

D11·2026-04-20

Design direction: craft-tool, not productivity-SaaS

Context. Axis has UI surface: a week calendar, an inbox, an aggressiveness slider, a Friday journal, a public /story page for recruiters. The visual system shapes how both daily users and case-study readers perceive the product. A /design-consultation session with browse-based competitive research surfaced a categorical observation.

Options considered.

  • A. Pastel SaaS (Motion, Reclaim, Notion Calendar lane) — purple/peach/cream, rounded cards, sales-y hero with abundant feature screenshots.
  • B. Thoughtful lifestyle brand (Sunsama lane) — warm neutrals, soft shadows, calm-focused copy.
  • C. Craft tool (Linear, Raycast, Figma lane) — dark canvas, precise geometry, minimal decoration, one signal color, typography does the work.
  • D. Editorial-first (like a publication) — serif-heavy throughout, wide measure, grid-breaking.

Chosen: C — craft tool, with a deliberate editorial moment on `/story` only.

Why. The memorable-thing forcing question produced: "This looks like a tool made by someone who knows what they have to do, but needs to have a plan on when to do them." Every productivity tool markets itself as lifestyle because they're pitching the outcome (calm, reclaimed time, AI-does-it-for-you) to users who feel lost. Axis's user already knows what they have to do — they just need when. That rules out aspirational marketing-first aesthetics and points directly at control-surface aesthetics: Linear / Raycast / Figma / Obsidian.

No scheduler currently sits in that lane. Motion/Sunsama/Reclaim all converge on pastel-SaaS. This is an empty category position — the kind of small-but-real differentiation that reads as authentic to the users who'd find Axis most useful.

Signal-color bet. Electric lime #C4F542 chosen deliberately because no competitor owns it. Motion=purple, Sunsama=peach, Reclaim=orange, Linear=mint, Raycast=red. Axis=lime. Used sparingly: today-marker, slider thumb, user-adjusted blocks, primary CTA, /story pull-quote accent.

Typography. Geist (body/UI) + Geist Mono (data, scheduler reasons, timestamps) + Fraunces (variable serif, /story only + marketing hero). Avoids Inter/Space Grotesk convergence trap.

Editorial twist on `/story`. The case-study page is a different kind of reading — narrative, not control. Serif-on-dark (Fraunces) signals "this page is for reading, not for using." Unified navbar + color palette keeps the app and the essay visibly connected.

Tradeoffs. The system is precise, not cozy. Users who want Motion-style "I don't want to think about this" will not feel welcomed. Light-mode-first users exist; they aren't the ICP. A second accent color will be tempting and must be refused — every additional accent dilutes the signal. Serif on a dark technical product is unusual and could feel affected if the rest of the page isn't disciplined.

Reversibility. Cheap for individual tokens (color, fonts) — variables are centralized in DESIGN.md and app/globals.css. Moderate for aesthetic direction — changing from craft-tool to lifestyle mid-build would re-set visual expectations across every page. Design consultation produced a live preview at .design-preview/index.html and authoritative DESIGN.md at repo root. CLAUDE.md added with design-system enforcement for future agents.

D12·2026-04-20

Writeback target: user's primary calendar (supersedes D3)

Context. D3 chose to create a dedicated "Axis" calendar in the user's Google account and route all scheduler writes there. During Day 2 implementation, the dedicated-calendar model surfaced two real issues: (1) calendar.app.created OAuth scope complexity, and (2) a UX problem — users don't naturally see the Axis calendar alongside their meetings unless they explicitly toggle it on in Google Calendar's sidebar.

The deeper question re-posed. D3's blast-radius argument was "a scheduler bug shouldn't wipe real meetings." Separating into a dedicated calendar prevents calendar-level collateral damage. But the same protection — and stronger — is available at the event level via extendedProperties.private.axis_origin = "true" (already decided in the eng review for feedback-loop prevention). Every Axis write carries the tag; every Axis read/update/delete scopes to events carrying the tag. A scheduler bug deleting "random" events can only ever delete events it has previously marked as its own. Net blast radius: zero user-created data, even when writing to primary.

Options reconsidered.

  • A. Keep D3 (dedicated calendar, calendar.app.created scope).
  • B. Write to primary, rely on axis_origin tag for safety.
  • C. Hybrid — primary by default, let users opt into a dedicated calendar in /settings.

Chosen: B, with the column kept flexible for future C.

user_profiles.axis_calendar_id stays as a text column, defaulting to "primary" on sign-in. Future /settings UI can let users override to any calendar they own with a single string update — no schema change needed. This preserves C as a v1.x feature without paying its cost now.

Why the pivot.

  1. UX wins. Primary-calendar writes appear naturally where the user already looks. Zero discovery step, zero toggle.
  2. Narrower OAuth scope. Reverts to calendar.events.owned + calendar.readonly (removing calendar.app.created). Better security posture — we never request permission to create calendars we don't own.
  3. Stronger protection, not weaker. Event-level tagging is strictly more precise than calendar-level separation. A misconfigured delete against a dedicated calendar could still delete ALL its events (including ones the user manually added there); an axis_origin-scoped delete only touches our writes regardless of location.
  4. Competitive-landscape reality check. Motion and Reclaim create dedicated calendars primarily to differentiate their visual brand on the user's timeline (blocks in their brand color). Axis's craft-tool aesthetic per D11 doesn't need that visual real-estate — we'd rather blend in.

Tradeoffs.

  • Axis-authored blocks sit alongside user meetings on the primary calendar. Some users may want visual separation (different colors, hidden toggle). Addressed post-v1 via Google Calendar's native event-color API (colorId) — each Axis block can be colored with a single identifying color without needing a separate calendar.
  • The D3 "single visibility toggle" UX is lost. Users who want to hide all Axis blocks have to uncheck nothing — there's no single switch. Mitigation deferred to /settings ("disable writeback" mode that stops new writes; existing blocks can be bulk-deleted via a one-button operation).

Implementation.

  • lib/gcal/create-axis-calendar.ts → renamed lib/gcal/scheduler-calendar.ts; function ensureAxisCalendarensureSchedulerCalendar. New implementation records "primary" to user_profiles + gcal_sync_state; no GCal API call during sign-in.
  • AXIS_ORIGIN_TAG = "axis_origin" exported as the canonical extendedProperty key. Used by every event write on Day 3 onward.
  • GCAL_SCOPES in lib/gcal/tokens.ts reverted to [calendar.events.owned, calendar.readonly].
  • /auth/callback calls ensureSchedulerCalendar instead of the old create-calendar path.
  • Case-study note: this is the second "simpler than originally planned" pivot in the build week (first was D10's baseline-mode-as-notebook). Pattern worth naming — "default to the simpler implementation that achieves the same invariant."

Reversibility. Cheap. If we ever want dedicated-calendar back, C is a single-setting-plus-calendar-creation flow in /settings. No schema changes. No stored-data invalidation.

D13·2026-04-20

Calendar sync window is dynamic, bound to scheduler's furthest deadline

Context. Day 3 implements pullCalendarEvents — Google Calendar read sync. The API requires timeMin / timeMax on the first call; after that it returns a syncToken bound to those bounds and used for incremental deltas. Pass the token back with different bounds → Google errors. So the window isn't just a bandwidth knob — it's the scheduler's visibility horizon: meetings outside the window don't exist as far as the placer is concerned. A task deadline beyond timeMax would get placed without knowing about meetings in that period.

Options considered for the forward window.

  • A. Fixed 30 days — matches the ~30-day syncToken TTL; cheapest full resync. Rejected: any quarterly-horizon task would be placed blind.
  • B. Fixed 90 days — 3-month planning horizon, still cheap. Good default but encodes an assumption that may not hold for all task deadlines.
  • C. Fixed 365 days — correctness-first but expensive resync and many recurring-event instances (singleEvents=true expands a 5x/week meeting into ~260 rows over a year).
  • D. Dynamic: max(90 days, latest_task_deadline + 14d buffer). Caller (the scheduler) supplies the latest deadline; sync code picks the wider of the default and the deadline-driven bound.

Chosen: D.

Rationale: the static form (B or C) either leaves correctness gaps or pays for headroom nobody uses. The dynamic form self-corrects: in the steady state (no long-horizon tasks) it behaves identically to B; when a long-deadline task appears, it transparently widens. One extra line of code in computeWindow, no ongoing cost.

Token-binding implication. Since Google's syncToken is bound to the (timeMin, timeMax) pair it was issued against, widening the window invalidates the token. We persist time_min / time_max on gcal_sync_state (migration 0004_sync_window_bounds.sql) so each incremental call can check: does the stored window cover the requested window? If yes, use the token. If no, null it and full-resync with the new bounds.

Backward window. Fixed 7 days. Only purpose is catching recent user edits on Axis-origin events (the user_adjusted flag logic on Day 4 / E3). Cheap and has no correctness implications further back.

Full-resync trigger taxonomy. Three distinct causes of a full resync, each handled explicitly in code:

  1. No token stored — first sync for the user.
  2. Token stored but caller requested window wider than stored bounds.
  3. 410 GONE / fullSyncRequired reason from Google — token aged out.

All three converge on the same runFull path. The state transition is "null the token, fetch with current requested window, persist the new token and bounds."

Classification decision. Sync returns events split three ways: Axis-origin (carry extendedProperties.private.axis_origin = "true"), external (user- or other-app-created), and cancelled. Split at the sync boundary rather than at call sites because every caller will need the same split, and doing it centrally makes it a single unit to test.

Test strategy deviation from CLAUDE.md. CLAUDE.md specifies MSW for GCal mocks. For these unit tests I've mocked getCalendarClient directly at the SDK boundary instead. Reasoning: these tests target sync logic (window/token handling, classification, 410 retry) rather than transport. Stubbing the SDK avoids fighting googleapis' OAuth refresh flow during tests while keeping the tests deterministic. MSW is still the right tool when we test the push webhook end-to-end on Day 4, because that path exercises our real getAuthenticatedClient flow. Recording this deviation here so it doesn't look like an oversight.

Tradeoffs.

  • ✅ Correctness-first: no task deadline gets placed blind.
  • ✅ Schema change is minimal (two nullable columns).
  • ⚠️ Widening the window invalidates the token, costing one extra full resync per widening event. Acceptable — widening is rare and the resync still completes in one or two pages.
  • ⚠️ Narrowing doesn't happen (we only ever widen). Over months, the window may grow to cover one-off long-deadline tasks that have since been deleted. Accept this leak; a future cleanup job could shrink the window when no in-scope task requires it.

Reversibility. Cheap. If we revert to a fixed window, computeWindow becomes a constant and the stored bounds columns are unused (not a problem — nullable). The schema migration is additive.

D14·2026-04-21

Outbox pattern + exponential backoff for calendar writebacks

Context. Day 4 implements the plumbing the Day-5 scheduler will ride on. The scheduler writes placements to scheduled_blocks in one Postgres transaction, then needs to reflect those to Google Calendar. The question: what happens when GCal is slow, rate-limits us, or is down?

Options considered.

  • A. Synchronous writes inside the scheduler transaction. Reject: couples scheduler latency to Google's uptime; a 5xx during a reshuffle rolls back user-visible placements.
  • B. Fire-and-forget async (queue in memory, flush on timer). Reject: crash/deploy loses pending writes; silent divergence between DB and calendar.
  • C. Writeback outbox (T-OB in CEO review): every intent inserted into writeback_outbox inside the scheduler's own transaction; separate worker drains. Atomic with placement; recoverable; observable.

Chosen: C. Table already scoped in v1 schema (scheduled_blocks.gcal_event_id intentionally nullable to allow the worker to fill it post-insert).

Retry policy. Exponential backoff 2/4/8/15 min, clamped at 15; MAX_ATTEMPTS=5, then status='dead_letter'. Clamp deliberate — without it attempt 5 pushes ~32min out, which at a 60s cron cadence causes queue starvation on busy users. 15 min absorbs Google's documented rate-limit windows without sitting idle past a transient outage.

Schema refinement (migration 0005). Split next_attempt_at out from enqueued_at rather than overloading the latter. Cost: one column. Benefit: enqueued_at retains its plain-English meaning (the moment the scheduler enqueued the intent), which is the more useful axis for observability / case-study reporting. Overloading would have saved ~10 minutes today and invited at least one "why is this row enqueued_at in the future" confusion later.

Worker structure. Ports-and-adapters split (outbox-worker.ts + outbox-supabase-repo.ts + outbox-gcal-dispatcher.ts). Retry and dead-letter logic sits in the pure core with a plain-object test; Supabase and googleapis only appear in the thin adapters. Mirrored in webhook-handler for the same reason.

Tradeoffs. Two extra files per feature. Real payoff: (a) tests run without spinning up MSW for googleapis, (b) the core logic is the part that's hardest to get right and easiest to regression-test.

Reversibility. Cheap. If the ports-adapter split turns out to be over-engineered for v1, collapsing back into one file is mechanical. The next_attempt_at column is trivially keepable in any future refactor.

D15·2026-04-21

Push-channel rotation is destructive-idempotent

Context. registerWatch(userId) is called from three paths (first sign-in, hourly renewal cron, manual repair scripts). Each call opens a Google push channel; channels live ~7 days. Question: on re-register, do we stop the prior channel or let it expire naturally?

Options considered.

  • A. Let old channels expire on their own. Cheapest.
  • B. Stop the prior channel before opening a new one.

Chosen: B. Two reasons:

  1. Brief overlap windows where Google fires webhooks on both the old and new channel. The webhook handler ignores unknown channel_ids (acks 200) but the double-fire is wasted work and muddies logs.
  2. Google's per-user channel table is finite; long-lived test/dev accounts accumulate dead channels and hit ceilings.

Note: The initial register call in /auth/callback is best-effort — dev's http://localhost:3001 can't register a watch (Google requires HTTPS). Renewal cron is the backstop. Documented as a precondition check in registerWatch so the error is self-explanatory rather than a cryptic Google 400.

Reversibility. Cheap. If rotation cost becomes noticeable, switching to let-expire is one line.

D16·2026-04-21

Scheduler: greedy first-fit with aggressiveness-driven buffer

Context. Day 5 core decision. Motion and Reclaim ship schedulers with heuristics that tune themselves. v1 Axis needs one that's explainable (E1), testable, and lets D2's aggressiveness dial express itself as a concrete, measurable behaviour.

Options considered.

  • A. Constraint-solver (e.g. Timefold, OR-Tools). Generates provably-optimal schedules.
  • B. Custom ILP / tabu-search hybrid (Motion-like).
  • C. Greedy first-fit ordered by deadline, with an aggressiveness-driven slot buffer.

Chosen: C. v1 explicitly values explanation over optimality (E1 placement_reason). Option A/B's output is a black box from the user's perspective — "solver said so" is not an acceptable reason string. Greedy first-fit produces a reason by construction: which slot was picked and why (first-of-day, fits-snug, deadline-pressure).

Aggressiveness contract (v1).

  • buffer_minutes = round((1 - aggressiveness) * 15) applied as leading + trailing padding on every placement's slot footprint.
  • 1.0 → back-to-back placements (Motion behaviour).
  • 0.0 → 15-min padding on both sides (Sunsama-esque breathing room).
  • Default 0.30 → 11-min buffer.
  • Placement duration is not padded — only the slot footprint. The user's accounting of hours worked stays exact; the buffer only affects which slots can accept the task.

Why this definition of aggressiveness. Other mappings were considered (tighter deadlines, reshuffle eagerness, multi-task-per-slot chunking). Buffer-width won because it's (a) the cleanest scalar → observable-behaviour mapping, (b) user-visible (whitespace in the calendar), (c) trivially testable, and (d) the P3 metric (CV of aggressiveness) has a direct physical interpretation: "how consistently I give myself room to breathe."

Invariants locked by fast-check (reshuffler.test.ts).

  • No two placements overlap.
  • Every placement ends by its deadline.
  • Placement duration === estimatedMinutes exactly.
  • Reshuffle is idempotent when inputs haven't changed.
  • user_adjusted placements preserved byte-for-byte.

Tradeoffs. First-fit is not optimal. A 4h task that could fit snugly on Friday morning might get placed into a 6h Monday slot, leaving Friday orphaned. Accepted: (a) Axis is explicitly not a solver, (b) user_adjusted lets the user fix any bad placement in one drag, and (c) the reason string makes non-optimal placements legible rather than surprising. Upgrade path to B/C preserved by the port-adapter split (scheduler-repo and placer are independent).

Reversibility. Moderate. Swapping the placer for a solver is mechanical — the reshuffler feeds it the same freeSlots list and expects the same ProposedPlacement[]. Changing the aggressiveness contract is cheap in code but expensive in observability (P3's semantics would need to be redefined).

D17·2026-04-21

Debounce via reshuffle_pending_at + 1-min sweep

Context. Scheduler triggers come from several paths: UI task create/edit, drag in the calendar view, GCal push webhooks on external changes. If each fires a reshuffle synchronously, a user adding three tasks in ten seconds triggers three reshuffles with mostly-identical inputs.

Options considered.

  • A. Synchronous reshuffle on every trigger. Simple, wasteful.
  • B. In-process debounce (setTimeout). Breaks across serverless invocations.
  • C. user_profiles.reshuffle_pending_at timestamp + a sweep cron that fires a reshuffle for any user whose pending time has elapsed.

Chosen: C. Each trigger stamps reshuffle_pending_at = now + debounce_ms (defaults to 5s). A cron at 1/min cadence picks up anyone whose stamp is in the past, runs scheduleUser once, clears the flag. Multiple triggers within 5s coalesce into one pass for free; state lives in Postgres so it survives serverless cold starts.

Tradeoffs. Latency floor is ~60s — a task edit takes up to a minute to appear on the calendar. Acceptable for v1 (solo user, Beau's usage, case-study context). If latency matters later, promote the cron to 1/15s or add a separate "force-schedule-now" path for high-intent edits.

Reversibility. Cheap. Flipping to synchronous is one line of orchestration; to a proper debounce worker (Supabase queues, pgmq) is a bounded refactor.

D18·2026-04-22

Crons run from GitHub Actions, not Vercel

Context. First prod deploy to axis-plum.vercel.app failed: Vercel Hobby caps cron jobs at 2 total, running at most daily. v1 needs three schedules (sweep + outbox + watch-renew) at sub-daily cadence to hit the UX promise D17 baked in.

Options considered.

  • A. Upgrade to Vercel Pro ($20/month). Restores 1-minute cron. Overkill for a solo case-study product that probably runs for a few months.
  • B. Downgrade to Hobby-compatible (2 crons, daily). Tasks take up to 24h to appear on the calendar — destroys the whole point of a scheduler that feels responsive.
  • C. Move the schedule triggers out of Vercel. Keep the API routes; have something external POST to them on a cadence. GitHub Actions is the natural fit because the repo already lives there.

Chosen: C. .github/workflows/cron.yml fires the three routes on a 5-minute schedule (Actions minimum). The sweep + outbox run as sequential steps in one job so the outbox always sees fresh enqueues from the same tick — worst-case edit-to-calendar latency stays ~10 minutes instead of the 1–2 minutes Vercel Pro would give us.

Why this is actually fine.

  • Cadence: 5 min vs 1 min. For a human-scale scheduler, this is imperceptible.
  • Reliability: GitHub's scheduled workflows can drift under platform load, but so can Vercel Cron. Neither is "real-time" — the debounce pattern (D17) absorbs drift either way.
  • Cost: $0 on a private repo. ~150 min/month against the 2000-min free quota.
  • Failure surface: moves from the Vercel dashboard to the Actions tab. One extra URL to know about, not a real operational cost.
  • Case-study artifact: shipping on Hobby by decoupling the automation layer is exactly the kind of real-world tradeoff a PM case study benefits from documenting.

What stays the same.

  • CRON_SECRET still protects every endpoint.
  • The routes themselves don't know or care who calls them — the port/adapter split in outbox-worker.ts and schedule-user.ts means the trigger is a pure implementation detail.
  • vercel.json crons array removed; the routes stay.

Upgrade path. If Axis ever gets real users and wants sub-minute latency, switch to Vercel Pro — one-line vercel.json restore + delete the Actions workflow.

Tradeoffs. One external dependency added (GitHub Actions). CRON_SECRET now needs to exist in two places (Vercel env + GitHub Secrets). Documented in CLAUDE.md.

Reversibility. Very cheap. Restore vercel.json crons and delete .github/workflows/cron.yml to roll back to Pro.

Superseded by D19 (same day) — moved triggers to Cloudflare Workers Cron to recover sub-minute cadence at $0. D18 stands as the record of the GHA stop-gap and the reasoning that exposed Cloudflare as the better fit.

D19·2026-04-22

Cron triggers move to Cloudflare Workers (supersedes D18)

Context. D18 accepted GitHub Actions' 5-minute minimum cadence as the cost of staying on Vercel Hobby. Within hours of writing D18, the Cloudflare option surfaced: Cloudflare DNS was already on the roadmap for the domain cutover, and Workers Cron Triggers fire at 1-minute granularity on the free tier. That collapses the only meaningful tradeoff D18 was making.

Options reconsidered.

  • A. Stay on GHA. Works, but accepts ~10 min worst-case edit-to-calendar latency forever unless we pay Vercel Pro.
  • B. Move to Cloudflare Workers Cron. 1-min cadence, $0, same port/adapter shape on the Vercel side (routes don't change). Adds Cloudflare as a dependency, but it was already being adopted for DNS — net new vendor count: zero.
  • C. Pay Vercel Pro. Still $20/month for one feature, still overkill for a solo case-study product.

Chosen: B. A ~30-line Worker in cloudflare/ runs * * * * * (sweep + outbox sequential) and 0 * * * * (watch-renew). event.cron branches the handler. ctx.waitUntil() keeps the invocation alive across the awaited fetches.

Why this is strictly better than D18.

  • Cadence: 5 min → 1 min. Worst-case edit-to-calendar latency drops from ~10 min to ~2 min — matches what Vercel Pro would have given us.
  • Cost: still $0. Workers Free tier is 100k requests/day; we burn ~5,800/day at full cadence (~6%).
  • Reliability: Cloudflare's distributed scheduler drifts less than GHA's centralized scheduler (which queues scheduled workflows behind on-demand jobs at peak hours).
  • Cold-start: ~30–60s GHA VM spin-up → sub-millisecond Worker invocation. Means the cadence is also closer to honest — a 1-min cron actually fires within seconds of the minute boundary.
  • Vendor surface: Cloudflare was already coming in for DNS. Net new dependencies: zero.

What stays the same.

  • CRON_SECRET still protects every endpoint, same Bearer-token shape.
  • The Vercel API routes don't change. Port/adapter split holds — the trigger remains a pure implementation detail.
  • D18's invariant "sweep before outbox in the same tick" is preserved by chaining two await fetch() calls inside one scheduled() invocation.

What changes operationally.

  • CRON_SECRET lives in three places during the migration window (Vercel env + GitHub Secrets + Cloudflare Worker secret). Once verified, the GHA workflow is deleted and we're back to two.
  • Failure surface: GitHub Actions tab → Cloudflare dashboard (workers.cloudflare.comaxis-cron → Logs).
  • Setup runbook: docs/cloudflare-cron-setup.md.

Case-study framing. D18 → D19 within hours is itself the case-study artifact: "first ship was a Hobby-tier compromise; reconsidered when an adjacent infra decision (DNS) opened a strictly-better path." Documenting the reversal — not just the final state — shows the reasoning, which is the actual product of a PM/PO portfolio.

Tradeoffs. Cloudflare account is now load-bearing for the product, not just DNS. If Cloudflare has an incident, the cron stops — same failure mode GHA had. Mitigations: (1) routes are idempotent, so a missed tick recovers on the next, (2) D17's debounce pattern absorbs gaps, (3) workflow_dispatch on the GHA workflow stays available as a manual fallback for the first month before deletion.

Reversibility. Cheap. Re-enable the GHA workflow (or restore vercel.json crons if on Pro) and delete the Cloudflare Worker. The Vercel routes don't move either way.

Update — same day, post-domain-cutover. GHA fallback removed earlier than the planned 1-month window. Two reasons converged: (1) the APP_URL GHA variable still pointed at axis-plum.vercel.app after the cutover to axis.beauleach.com, so the manual-dispatch fallback would have hit a stale URL anyway; (2) Cloudflare cron verified clean against the new domain across consecutive ticks within the same session. Keeping a broken fallback is worse than no fallback — future-me would reach for workflow_dispatch during an incident, hit the old URL, and waste minutes debugging the wrong layer. .github/workflows/cron.yml deleted; GHA secret/var cleanup is a housekeeping task (inert if left in place).

D20·2026-04-22

Timezone: Google seeds, user overrides win

Context. The user_profiles.timezone column was hardcoded to "UTC" by the 0002 signup trigger and never repaired. The calendar view's formatting is correct (Intl.DateTimeFormat with a timeZone prop), so the UTC default flowed straight through to the render path — a Brisbane-based user saw every event in UTC. The 0002 trigger's own comment acknowledged this was a placeholder: "'UTC' default; user updates via /settings or first-run detect-and-save on the client". D20 closes that placeholder.

Options considered.

  • A. Google as source of truth (auto-sync every sign-in). Call calendars.get('primary') on every OAuth callback and overwrite the stored timezone. Pro: self-healing if the user updates Google. Con: zero override mechanism — a user who wants Axis in a different zone than their Google account can't have it.
  • B. Browser auto-detect only. Intl.DateTimeFormat().resolvedOptions().timeZone client-side on first load. Pro: reflects where the user is, not where their Google account claims. Con: a Google-Calendar-first app whose view disagrees with its source is a surprising UX, and the detection drifts silently if the user travels mid-session.
  • C. Hybrid: Google seeds on first connect, manual override in `/settings` wins. Seed only when timezone = "UTC" (i.e., unchanged from signup default). Override via a picker that never gets clobbered.

Chosen: C.

Why. Axis's value prop is scheduling around Google Calendar. Defaulting to Google's zone is the least-surprising starting state. But the whole product is "one dial the user controls" — overriding a zone is the same shape of user agency as overriding aggressiveness. The guard update ... where timezone = 'UTC' on both the seed path and Drizzle update is what enforces "override wins": once the column holds anything other than the signup placeholder, no future OAuth callback will touch it.

Mechanism.

  • lib/gcal/timezone.ts::seedTimezoneFromGoogle(userId) — reads the profile, bails if already non-UTC, otherwise fetches calendars.get('primary').timeZone and updates with a timezone = 'UTC' predicate for race safety.
  • /auth/callback/route.ts — calls seedTimezoneFromGoogle best-effort after ensureSchedulerCalendar. Failure is a console.warn, not a redirect — user still lands signed in, can pick manually.
  • /settings/page.tsx — promoted timezone out of the "Work window" read-only block into its own section with TimezoneSelect.
  • TimezoneSelect — native <select> populated from Intl.supportedValuesOf('timeZone'), commit-on-change to updateTimezone server action. Server action re-validates against the same IANA list before write, so a hand-crafted POST can't poison the column.

Backfill. No migration needed. Existing users (N=1, me) land in one of two paths: (a) re-auth → auto-seed fills from Google, or (b) open /settings → pick manually. Either is a one-touch fix. Not worth a SQL backfill for a single row.

Tradeoffs.

  • ✅ Zero-friction happy path for new users.
  • ✅ Override mechanism maps cleanly onto the existing "one dial the user controls" mental model.
  • ⚠️ If a user updates Google's calendar timezone after first sign-in (e.g., permanent move), Axis won't pick it up — the override guard treats any non-UTC value as "user intent". Acceptable; they update it in /settings the same trip. If this turns into a real papercut, add a /settings button "re-sync from Google" that clears the guard.
  • ⚠️ Intl.supportedValuesOf('timeZone') dumps the full IANA list (~400 entries) into a native <select>. Native selects handle that size fine on desktop; mobile scroll is long but usable. Not worth a custom combobox for v1.

Reversibility. Cheap. Delete seedTimezoneFromGoogle + revert the callback edit → behavior collapses back to the prior "UTC placeholder" state. The picker and server action stay useful independently; they're the follow-up the 0002 trigger always anticipated.

D21·2026-04-25

Event-level privacy defaults: visibility: "private" + transparency: "opaque"

Context. D12 routes Axis writes to the user's primary Google Calendar, protected from accidentally touching user-created events by extendedProperties.private.axis_origin = "true" filtering. D12's residual concern was content visibility — if a user shares their primary calendar with a manager, spouse, or team, Axis-authored event titles ("Deep work: interview prep") leak in plain text to anyone with reader access. D12 acknowledged the gap and pointed to event-level controls as the v1.x lever; D21 closes it.

Options considered.

  • A. Do nothing. Rely on users to set their primary calendar to fully private, or to share at freeBusyReader only. Rejected: makes a workplace-default sharing posture (manager has reader) leak task titles.
  • B. Move writes back to a dedicated calendar (revert D12). Rejected: D12's reasoning still holds — event-level visibility is strictly more precise than calendar-level separation.
  • C. Set `visibility: "private"` + `transparency: "opaque"` on every Axis write. private hides title/description/location from anyone with reader access; they see a "Busy" block with no details, identical to any meeting kept title-private. opaque keeps the slot in freebusy so other tools (Calendly, "find a time") still respect it.

Chosen: C.

Why.

  1. Closes the only real D12 cost without reverting D12. Shared calendars stay shareable; details stay private. Net experience for a teammate viewing the calendar is "Beau is heads-down here," which reads professional, not cluttered.
  2. `visibility` and `transparency` are orthogonal to `axis_origin` and to each other. The presentation layer is decoupled from identity (extendedProperties) and from conflict semantics (transparency). Future work — colour (T17), per-event icons, etc. — composes cleanly.
  3. Idempotent on every write. Both events.insert and events.patch re-assert the policy. A user who toggled visibility in the Google UI sees Axis re-privatize on the next reshuffle, which matches Axis's "the dial is the source of truth" model.

Implementation.

  • lib/gcal/events.ts::insertAxisEvent — adds visibility: "private" + transparency: "opaque" to the events.insert requestBody.
  • lib/gcal/events.ts::updateAxisEvent — adds the same fields to the events.patch requestBody, alongside the existing extendedProperties re-stamp.
  • lib/gcal/events.test.ts — both insert and patch tests assert the two fields on every body.

Edge case (calendar delegates). Google "delegates" (users with the "Make changes and manage sharing" role on the calendar) can still see private events. This is almost never a real exposure — delegates are typically admin assistants, set up deliberately. Documented here so the limitation is explicit.

Backfill. No script needed. Existing Axis events get upgraded the next time the scheduler patches them (which happens regularly during reshuffle). If immediate backfill is desired, a one-shot loop over scheduled_blocks where axis_origin='true' calling updateAxisEvent with no changes (placement_reason carries through) would re-write every event with the new defaults.

Tradeoffs.

  • ✅ Closes the D12 sharing-leak concern without architectural change.
  • ✅ Composable with future event-level features (colour, etc.).
  • ⚠️ Calendar delegates retain visibility. Not a v1 issue; document and revisit if a real user reports it.
  • ⚠️ A user who deliberately makes one Axis event public via the Google UI will be re-privatized on the next reshuffle. Considered a feature, not a bug — Axis events are scheduler-owned, not user-edited primitives. Manual override would be a /settings-level toggle (deferred).

Reversibility. Trivial. Two two-line removals from events.ts + the test assertions revert the policy. Existing private events stay private until manually toggled.

D22·2026-06-22

Calendar week navigation: URL state + prefetched soft-nav, not client state

Context. /calendar shipped with the visible window hard-locked to the current week — computeWeekWindow(new Date(), tz). T12 (calendar week-nav controls) was the documented gap. The implementation choice — how the user moves between weeks — is the real decision; the UI affordance is downstream of it.

Options considered.

  • A. URL searchParam (`?weekOf=YYYY-MM-DD`) + bare `<Link>`. Server is the source of truth; each click is a soft-nav with one Supabase round-trip. Browser back/forward works, URLs shareable. Cost: ~150–400ms of "dead" click feel without prefetch.
  • B. URL searchParam + `<Link prefetch>` + `useTransition`. Same architecture as A. prefetch warms the adjacent week's RSC payload on hover/viewport; useTransition exposes a pending flag so the trigger can dim while in-flight. Sequential flicking (the 95% case) feels near-instant; cold non-sequential jumps fall back to A's latency with a visual cue rather than a frozen UI.
  • C. Load ±N weeks upfront, filter client-side, sync URL via `router.replace`. Snappiest local switch. Cost: ~9× more rows on initial page load and a "how wide a window" decision that's wrong for someone outside the median — narrow window forces server round-trips anyway, wide window blows up the initial query.
  • D. Pure client state. Snappy local switch but kills browser back/forward and shareable URLs.

Chosen: B.

Why.

  1. Matches the rest of the app's architecture. Every other page (/tasks, /projects, /login) consumes searchParams: Promise<{...}> as server-component config. C would have made /calendar the only client-state surface — a one-off pattern future-me would have to keep re-learning.
  2. `prefetch` + `useTransition` covers the latency. For the common access pattern (sequential prev/next flicking), the next week's RSC payload is on the wire before the click. useTransition's pending flag drives a data-pending attribute on the nav group, so the dim cue is data-attribute styled — no extra JS hops, no React state in the grid itself.
  3. Shareable URLs are a case-study feature, not just a generic web nicety. "Here's what my week of 2026-04-22 looked like" lands in a screenshot or a /story link as a stable URL. C/D would have broken that.
  4. Dataset is unbounded in principle. Blocks accrue forever; C would have required deciding how wide a preload window to ship. B punts that decision to the next-week boundary, where it's free.

Implementation.

  • app/calendar/week-window.ts — added parseWeekOf, formatYmdInTz, shiftWeekAnchor. Parameter rename now → anchor reflects the broader meaning (the date you want a week around, not necessarily now).
  • app/calendar/week-nav.tsx — new client island. Three <Link prefetch> controls intercepted via e.preventDefault() + startTransition(() => router.push(href)). Keeping the underlying href preserves middle-click / right-click → "Open in new tab" for the user, and reads cleanly for screen readers.
  • app/calendar/page.tsx — accepts searchParams: Promise<{ weekOf?: string }>; falls back to now when missing or malformed. Subtitle now shows the week range (Jun 22 — Jun 28) rather than just the Monday — small thing, but the closed range reads as a unit rather than a starting point.
  • app/calendar/week-window.test.ts — pins the DST invariants explicitly (spring-forward 2026-03-08 in NY, fall-back 2026-11-01 in NY), the year-boundary overflow, and the round-trip identity parseWeekOf ∘ formatYmdInTz = id for an Australia/Brisbane Monday.

DST. The non-obvious correctness point is in shiftWeekAnchor: it goes via formatYmdInTz → localMomentToUtc, not getTime() ± 7 × 86_400_000. A week containing a DST transition is exactly 7 calendar days in the user's timezone but only 6 days 23h (or 7 days 1h) in UTC ms — UTC-ms arithmetic would push the anchor onto the wrong calendar day in ~2 weeks per year. The test suite pins both directions.

Tradeoffs.

  • ✅ Browser back/forward + shareable URLs without writing a popstate listener.
  • ✅ "Today" button hides when already on this week — no redundant no-op control.
  • ✅ Server-component data fetching stays untouched; the new client surface is one ~50-line file.
  • ⚠️ A cold non-sequential jump (e.g. typing ?weekOf=2025-01-06 directly) still pays the Supabase round-trip. Acceptable — that path isn't on the hot interaction surface.
  • ⚠️ Prefetch budget. Three <Link prefetch> per render. Default Next 16 prefetch is partial-payload-on-viewport, so the cost is bounded; if calendar prefetches start showing up as noise in network panels, we drop prefetch from the rarely-used Today control first.

Reversibility. Cheap. Delete week-nav.tsx, revert page.tsx's header + searchParams, drop the three helpers from week-window.ts. The grid component (week-grid.tsx) is untouched, so reverting doesn't risk the calendar render itself.

D23·2026-06-22

User-adjusted pins: drop when stale, expose an unpin action

Context. E3 shipped user_adjusted=true as a sticky flag — the reshuffler preserves flagged blocks byte-for-byte so a drag is never overwritten. The flag had no expiry and no UI escape hatch. Concrete failure surfaced this week: a task was dragged, its deadline passed, the deadline was later edited forward, and the block stayed welded to the past slot. markReshufflePending fired from updateTask, the sweep ran, the reshuffler saw the pin and exited. The only recovery was a manual scheduled_blocks row edit.

Options considered.

  • A. Manual unpin only. Add a button in the task detail sheet that flips user_adjusted=false. Minimal behaviour change; user is always in control. Doesn't self-heal — a user who doesn't know the button exists is still stuck.
  • B. Auto-drop stale pins only. Teach the reshuffler that pins whose slot has fully elapsed (existing.end <= now) carry no planning information. Self-heals the deadline-edit case without UI. Doesn't help the "I dragged it and immediately regret it" case for a future block.
  • C. Both A and B. Two complementary surfaces — automatic for the case that recurs by itself, manual for the rare deliberate undo.
  • D. Pin TTL (e.g. expire pins 24h after the block ends). More principled in theory, more knobs in practice. The "fully elapsed" rule already captures the only case where preserving a pin has zero upside.

Chosen: C.

Why.

  1. The auto-drop rule has no failure mode. A pin only ever asserts "place this task at this time." Once the time has fully passed, the assertion is empty — keeping the row only constrains the reshuffler's free slots wrongly (it doesn't, because the slot is outside the planning window, but the row blocks the task from being re-placed). Dropping it is monotonically better than preserving it.
  2. The manual button is cheap, but it removes a class of "where do I edit the DB" support calls. Future-Beau (or any v2 user) shouldn't need to know what scheduled_blocks.user_adjusted is to undo a regretted drag. The cost is one server action + one button in a sheet that already exists.
  3. The two changes are independent. The reshuffler self-heal handles the recurring case without UI; the button handles the deliberate-undo case the self-heal doesn't catch. Either change alone leaves a real gap.

Implementation.

  • lib/scheduler/reshuffler.ts — added the existing.end <= input.now short-circuit in the user-adjusted preservation loop. Block is not added to retainedBlockIds, so it falls into removedBlockIds at the diff step; the task is still in liveTaskIds so the flexible/fixed pass re-places it.
  • lib/scheduler/reshuffler.test.ts — added a stale-pin unit test alongside the existing "preserves byte-for-byte" test (which keeps a future pin and continues to pass as a regression guard).
  • app/calendar/actions.ts — new unpinBlock(blockId) server action. Verifies ownership, flips the flag, stamps placement_reason = "unpinned; pending reshuffle", calls markReshufflePending(user.id, 0) (zero debounce — explicit unpin is an intent signal), and emits a block_unpinned metric.
  • components/task-detail-sheet.tsxStatusRow gains an "Unpin · re-schedule" button when block.user_adjusted=true. Two-stage button state ("Unpinning…" → "Unpinned · reshuffle queued") so the user sees that the action landed before the next sweep tick.

Tradeoffs.

  • ✅ Self-healing for the deadline-edit case. No knob needed; the rule is "an elapsed pin is empty information."
  • ✅ Explicit unpin is the only way to make a future-pinned block move without dragging it again.
  • ⚠️ Slight churn on stale-pin reshuffle: the old block is deleted and a new one inserted (rather than the existing row updated), because the flexible-pass blockId lookup at reshuffler.ts:152 only reuses non-userAdjusted existing placements. Acceptable — the GCal event is re-created and the case is rare. Could be tightened by extending the lookup to "reusable iff not userAdjusted OR end <= now," but the extra complexity isn't earning anything here.
  • ⚠️ The zero-debounce unpin still waits for the next sweep tick (≤ 1 min). Could call scheduleUser inline for instant feedback, but that puts a GCal round-trip on a server action and breaks the "all reshuffles go through the sweep" invariant the rest of the codebase relies on.

Reversibility. Cheap. Revert the three files; no migration, no data shape change. The metric event type is additive and self-documenting.

D24·2026-06-22

Reshuffler must not double-place tasks that already have a user-adjusted pin

Context. Same-day follow-on to D23. Dogfooding surfaced a task ("Deploy to Vercel + prod verification", id c9fa5a73…) with four scheduled_blocks rows for a single 90-minute non-chunked task. Audit across all active tasks found five tasks with 11 overflow blocks total. Every previous drag → reshuffle cycle had been silently bleeding an extra auto-placed block.

Root cause. The reshuffler partitions tasks into fixedTasks / flexibleTasks from liveTaskIds (every active task) and runs the placer over each one. The user-adjusted preservation loop earlier in the function adds the pinned block to the placements output, but the partition step never excludes the task whose pin was just preserved. Compounding this, the existing-block reuse lookup at line 152 of reshuffler.ts explicitly excludes userAdjusted placements (!p.userAdjusted) — so the placer can't even reuse the pinned block's id; it always emits blockId: null, which apply-diff inserts as a brand-new row. The pinned block survives; the new auto-placed block ships alongside it. Next drag pins the new one too. Repeat.

Options considered.

  • A. Skip pinned tasks in the placer pass (chosen). Build pinnedTaskIds from blocks that were retained in the preservation loop. Fixed and flexible passes continue for any task in that set. The pin IS the placement.
  • B. Allow placer to reuse a user_adjusted block's id. Drop the !p.userAdjusted clause at line 152. Conceptually wrong — that's the lookup for the "placer carries over the prior auto-placement" case. A pinned block isn't a prior auto-placement; treating it as one would mean reshuffles silently move pinned blocks, breaking D23/E3's "pins are sticky" contract.
  • C. Defer to chunking. Treat the pin as one chunk and place the remaining minutes. v1 doesn't support chunking; would design a v2 feature into a v1 bugfix.

Chosen: A.

Implementation.

  • lib/scheduler/reshuffler.ts — introduced pinnedTaskIds: Set<string> populated alongside retainedBlockIds in the preservation loop. Both the fixed-task loop and the flexible-task loop now short-circuit when pinnedTaskIds.has(task.id).
  • lib/scheduler/reshuffler.test.ts — new unit test "does not double-place a task that already has a user_adjusted pin (D24)". Confirms exactly one placement per task and that removedBlockIds stays empty (the pin is retained, no new block is created).
  • scripts/audit-duplicate-blocks.ts — read-only diagnostic that lists every active task whose scheduled_blocks row count exceeds ceil(estimate / chunk_minutes). Useful for support and for verifying the fix in production.
  • scripts/inspect-task-blocks.ts — read-only diagnostic for a single task id; prints every block row plus the user's reshuffle_pending_at stamp.

Cleanup of accumulated rows. The fix stops the bleeding but doesn't remove the rows that already exist. A separate one-shot cleanup script enqueues outbox deletes for the duplicate GCal events and removes the surplus scheduled_blocks rows, keeping the most-recently-updated block per task (which represents current intent — either the latest drag or the latest reshuffle touch). Run once after this commit ships.

Tradeoffs.

  • ✅ Stops the per-drag bleed for every non-chunked task.
  • ✅ Single-place semantics for non-chunked tasks is now an explicit invariant — could be promoted to a property-based fast-check in a later commit.
  • ⚠️ Chunked tasks (chunk_minutes set) are not yet exercised by the reshuffler; when chunking lands, the pinnedTaskIds guard will need to be refined to "skip iff all chunks are pinned, else place the remaining unpinned chunks."
  • ⚠️ The fix doesn't help users whose calendars have already accumulated dups. Cleanup is a separate operation.

Reversibility. Trivial — remove the two continue guards and the pinnedTaskIds set. No schema change.

D25·2026-07-08

Microsoft 365 calendar integration: second identity provider, multi-calendar busy reads, single writeback target

Context. v1 is Google-only by construction: the Google OAuth sign-in is the calendar grant (D8), and lib/gcal/* is shaped around Google concepts (syncTokens, watch channels, extendedProperties). The real-world need driving this entry: work calendars live on Microsoft 365. A scheduler that can't see work meetings places blocks blind for exactly the hours that are most contested.

Decisions (made 2026-07-08, product owner call):

  1. Microsoft becomes a sign-in provider, not just a connectable calendar. Azure AD is enabled as a second Supabase auth provider. The second calendar on an existing account connects via supabase.auth.linkIdentity() (manual identity linking), which doubles as the token grant — same pattern as the existing Google callback capture.
  2. Both providers connectable simultaneously; busy/free is read from every connected calendar. The placer's input becomes the union of busy intervals across providers.
  3. Exactly one writeback target. user_profiles.writeback_provider ('google' | 'microsoft', default = first-connected). Axis blocks are written to one calendar only; the other is read-only input.
  4. Work/school (M365) accounts only — Azure app registration on the organizations endpoint. Personal outlook.com deferred until a need exists.

Options considered for identity.

  • A. Connect-only: keep Google as sole sign-in; Microsoft is a settings-page OAuth connect. Smaller blast radius, but a Microsoft-first user can never exist, and Supabase linkIdentity gives us the linked-identity plumbing anyway.
  • B. Dual sign-in + linked identities (chosen). Either provider signs in; linking merges them into one user. Costs: provisioning chain (/auth/callback) must branch per provider; account-linking semantics must be explicit (link is user-initiated from a signed-in session — no silent email-match merging).

Architecture.

  • Extract a CalendarProvider port from the current gcal module: pullDelta, insertEvent / patchEvent / deleteEvent (origin-tag-guarded), subscribe / renewSubscription. lib/gcal becomes the Google adapter; new lib/mscal implements Microsoft Graph.
  • Graph concept mapping: syncToken → /me/calendarView/delta deltatokens; 410 GONE → Graph's resyncRequired; watch channels → Graph subscriptions (max lifetime ~3 days for Outlook resources — the existing hourly watch-renew cron absorbs renewal); extendedProperties.private.axis_originsingleValueExtendedProperties (filterable server-side, preserving the D12 origin-scoping invariant); visibility: private + transparency: opaquesensitivity: "private" + showAs: "busy".
  • Timezone pitfall recorded up front: Graph defaults to Windows timezone names. All Graph calls send Prefer: outlook.timezone="UTC" and treat returned dateTime + timeZone pairs explicitly; never trust bare dateTimes.
  • Schema: gcal_credentialscalendar_credentials (+ provider), gcal_sync_statecalendar_sync_state (+ provider, unique per user+provider), scheduled_blocks.gcal_event_id → keep column, add external_provider; writeback_outbox payload gains provider. Renames done as additive migrations with views or column aliases considered NOT worth it for a single-user dataset — plain rename migrations are fine.
  • Scheduler core (lib/scheduler/*) is already provider-agnostic (busy intervals in, placements out) — untouched except for the busy-union assembly in schedule-user.ts.

Sequencing. Lands immediately after the Phase 0 correctness fixes from the 2026-07-08 review (docs/v1-finish-plan.md) — specifically because Phase 0 rewrites the outbox dispatcher's insert idempotency, and the Microsoft dispatcher must be built on the fixed semantics, not refactored after.

Tradeoffs.

  • ✅ Solves the real "work meetings are invisible" gap; scheduling quality on weekdays goes from theoretical to honest.
  • ✅ Port extraction pays down the implicit Google coupling; a third provider (CalDAV, Apple) becomes an adapter, not a rewrite.
  • ⚠️ Two webhook trust boundaries, two token lifecycles, two subscription-renewal paths — operational surface roughly doubles.
  • ⚠️ Graph subscriptions' short TTL makes the renewal cron load-bearing for Microsoft in a way watch-renewal isn't for Google (Google channels last ~a week+). A missed renewal degrades to sweep-driven polling; acceptable.
  • ⚠️ M365 tenant admins can block third-party app consent; the app registration needs admin consent in the target tenant. This is an ops prerequisite, not code.

Reversibility. Moderate. The port extraction and schema generalization are one-way (but harmless if Microsoft is later dropped — Google remains an adapter). The Azure provider can be disabled in Supabase without data loss; linked identities persist.

D26·2026-07-08

Outbox inserts made idempotent: deterministic event ids + dispatch-time block snapshot

Context. 2026-07-08 code review found two critical defects in the insert path of the D14 outbox: (1) a retry after a partial failure (Google write succeeded, DB bind of the event id didn't) called events.insert again and minted a duplicate calendar event, permanently orphaning the first; (2) a reshuffle that moved a block between enqueue and dispatch updated scheduled_blocks but not the queued payload, so the event was created at the stale time and never reconciled (the diff compares against the DB, which already matched).

Chosen fix.

  • The dispatcher derives a client-supplied Google event id from scheduled_block_id (UUID minus dashes = valid base32hex). Same block → same id; a retry's 409 means "already created" and just returns the id for binding. (deterministicEventId in lib/gcal/events.ts.)
  • The worker re-reads the block's current start_at/end_at at dispatch time (OutboxRepo.loadBlockSnapshot) instead of trusting enqueue-time payload values. A missing block row (deleted before dispatch) now skips the insert entirely — previously that path created an orphan event nothing could ever delete.

Alternatives considered. Refreshing the queued payload on every re-place (write amplification, racy) — rejected in favor of one read at the single point of truth.

Tradeoffs. ✅ Retries converge instead of duplicating; ✅ inserted events always match the DB; ⚠️ a 409 against a cancelled event id (delete-then-reinsert of the same block id) would need an events.update resurrect path — flagged in code, not expected to fire since blocks get new ids on re-placement.

Reversibility. Trivial — stop passing eventId and drop the snapshot read.

D27·2026-07-08

Sweep claims users with compare-and-swap on reshuffle_pending_at

Context. Same review: the Cloudflare cron fires the sweep every minute regardless of whether the previous tick finished. scheduleUser cleared reshuffle_pending_at only at the END of a pass, so a pass slower than the cadence let the next tick pick up the same user concurrently → two schedulers double-placing blocks (the D24 failure class via a different trigger). The end-of-pass clear also silently wiped any NEW debounce a user edit stamped mid-pass.

Chosen fix. claimReshuffle(userId, now) — conditional UPDATE ... SET reshuffle_pending_at = NULL WHERE id = $1 AND reshuffle_pending_at <= now RETURNING id — the same CAS pattern the outbox worker already uses for markInFlight. The sweep claims before scheduling and skips claim-losers; a failed pass calls restoreReshuffle so the next tick retries. scheduleUser itself no longer touches the flag.

Tradeoffs. ✅ At most one concurrent pass per user; ✅ mid-pass edits keep their debounce; ⚠️ claim-then-crash before restore loses one reshuffle until the next edit stamps again — acceptable vs. duplicates.

Reversibility. Trivial.

D28·2026-07-11

Status-pill text moves off --text-dim: contrast beats hierarchy at 11px

Context. The 2026-07-08 design review measured the unplaced (tasks/project detail) and archived (projects list) pills at 2.83:1 — --text-dim (#5A5B5F) on --bg-canvas fails WCAG AA for text. --text-dim was chosen originally to make de-emphasized states read as "background noise," but 11px mono text is exactly where contrast floors matter most.

Options considered.

  • A. Keep --text-dim, accept the failure as deliberate de-emphasis. Reject: DESIGN.md's own contrast section promises AA.
  • B. Lighten the --text-dim token globally. Reject: dim is correct for separators/disabled where it isn't carrying text.
  • C. Move these pills' text+dot to --text-muted (4.6:1, AA ✓); keep the dashed border + transparent bg so the states still read as "lesser."

Chosen: C. The dashed border and empty fill already carry the de-emphasis; the text doesn't have to fail contrast to look secondary. DESIGN.md status-pill spec updated in the same commit.

Reversibility. Trivial — two CSS values.

D29·2026-07-13

External API: bearer-auth machine endpoints for projects, tasks, schedule trigger

Context. Agents (Claude Code) need to create projects and tasks and trigger a scheduling pass without a browser session. All CRUD lived in server actions (session-cookie auth, FormData) — no external surface existed beyond the cron endpoints.

Options considered.

  • A. Direct Supabase writes with the service-role key. Works but bypasses the action-layer rules (project ownership checks, palette rotation, reshuffle stamping) and scatters app logic into callers.
  • B. Thin authed REST routes reusing the existing helpers (chosen). GET/POST /api/projects, POST /api/tasks (batch-capable), POST /api/schedule (synchronous pass).
  • C. Refactor server actions into a shared core consumed by both. Cleanest long-term, biggest diff, touches working code for no behaviour change.

Chosen: B. Routes duplicate the small insert paths but reuse every non-trivial helper: ensureDefaultProject, palette rotation, markReshufflePending, emitMetric, and the D27 claim CAS for the synchronous scheduler run.

Auth. Bearer API_SECRET (new Vercel env var, timing-safe compare). Reusing CRON_SECRET was considered and rejected: that secret also lives in the Cloudflare Worker, and reuse would silently upgrade the Worker's credential from "trigger idempotent crons" to "read/write all tasks and projects". Separate secrets keep least privilege and rotate independently. User identity: routes resolve the sole user_profiles row; >1 user requires explicit userId (400 otherwise). Middleware: the three routes join PUBLIC_PREFIXES in lib/supabase/middleware.ts — like the cron routes they carry their own auth and must not be 302'd to /login.

Tradeoffs. ✅ Sync /api/schedule returns placement counts immediately and can't double-run against the sweep (restore-then-claim); ✅ cron and API secrets stay decoupled; ⚠️ one more secret to provision (Vercel only — no Cloudflare copy); ⚠️ insert field lists exist in both the action and the route — a schema change must touch both.

Reversibility. Trivial — delete app/api/{projects,tasks,schedule}/route.ts + lib/api/; no schema change.

D30·2026-07-13

Per-account API keys, HMAC-derived, zero provisioning (supersedes D28's shared API_SECRET)

Context. D28 shipped with one shared API_SECRET bearer that resolved "the sole user" — wrong shape once two accounts exist (e.g. beau@beauleach.com and bl@nbh.co), and it required manually provisioning a secret in Vercel. Requirement: every account gets its own key, and nothing gets copy/pasted into infrastructure.

Options considered.

  • A. Random keys stored hashed in a table. Standard SaaS shape: per-key revocation, but needs a migration, a generation action, and a lookup on every request.
  • B. HMAC-derived stateless keys (chosen). key = axis_<userId>_<HMAC-SHA256(signingSecret, userId)>. The key embeds the account it authenticates; verification recomputes the MAC (timing-safe) — no storage, no lookup beyond an existence check.
  • C. Supabase JWTs as API keys. Session tokens expire; minting long-lived JWTs fights the auth system.

Chosen: B, with the signing secret itself derived: HMAC(SUPABASE_SERVICE_ROLE_KEY, "axis-api-key-v1"). Domain separation means the raw service-role key never signs anything directly and cannot be recovered from keys. Because the admin client already requires that env var, there is ZERO new provisioning — the Settings page shows the signed-in account's key, computed on render.

Tradeoffs. ✅ Distinct key per account by construction; ✅ nothing to provision, store, or sync; ✅ deleted accounts fail the existence check; ⚠️ no per-key revocation — rotating SUPABASE_SERVICE_ROLE_KEY rotates every key (documented in docs/api.md; move to option A if that ever bites); ⚠️ key validity is coupled to service-role key rotation.

Reversibility. Easy — swap lib/api/keys.ts + authenticateApi for a keys table; route signatures unchanged.

D31·2026-07-13

API discovery via public OpenAPI spec at /api/openapi.json

Context. The external API's docs (docs/api.md) live in a private repo, so a remote agent holding only an API key and a base URL cannot discover the endpoints.

Options considered. A) Serve docs/api.md as text behind auth — cheapest, but prose, not machine-consumable. B) OpenAPI 3.1 spec, hand-written, served publicly (chosen). C) llms.txt — newer convention, weaker tool support than OpenAPI.

Chosen: B. Spec is a typed object in lib/api/openapi.ts, served at GET /api/openapi.json (force-static, public — it describes shapes any caller could probe and contains no secrets; the endpoints it documents all require keys). Hand-written because four endpoints doesn't justify a codegen pipeline; the drift risk is pinned by lib/api/openapi.test.ts, which asserts every documented path maps to a real route file exporting the documented methods.

Tradeoffs. ✅ Standard, tool-supported discovery (agents, Postman, codegen clients all read OpenAPI); ✅ zero runtime cost (static); ⚠️ request/response schemas are asserted by hand, not derived from code — the test catches missing/renamed routes but not field-level drift.

Reversibility. Trivial — delete the route + lib file.

D32·2026-07-13

External calendar events render read-only in /calendar

Context. The week view drew only Axis scheduled blocks; external meetings were invisible even though the scheduler places around them. Blocks floating in an apparently-empty week make placements look arbitrary, and once D25 (Microsoft busy-union) lands, no single external calendar shows the full picture — Axis's own view becomes the only surface that can. User requirement: events pulled from the calendar are strictly view-only — no drag, no edit; only Axis blocks move.

Options considered. A) Reuse pullCalendarEvents from the page — rejected: it persists the scheduler's syncToken and window bounds on every call (a page render must never move the sync cursor), and its now-anchored window can't serve past-week navigation. B) Persist external events to a DB table and read from there — rejected for now: a new table + sync lifecycle for a render-only concern. C) Stateless events.list over exactly the visible week (chosen) — lib/gcal/external-events.ts, no sync-state reads or writes, filtered with the same isAxisOriginEvent check so Axis blocks aren't drawn twice.

Chosen: C. Read-only enforced structurally: EventPill is a server component with no pointer handlers and no server action — there is no code path that can mutate an external event. Meeting treatment per DESIGN.md (--block-meeting fill, dim left border), rendered beneath block pills. Fetch failure degrades to blocks-only rendering, mirroring the scheduler's posture.

Tradeoffs. ✅ Zero interference with sync-token machinery; ✅ correct for any week navigated; ⚠️ one Google API round-trip per page render (fine solo; cache if it ever hurts); ⚠️ all-day events skipped (no all-day lane yet); ⚠️ Microsoft events not shown until D25's pullMicrosoftEvents lands — the read path can union providers later.

Reversibility. Trivial — delete lib/gcal/external-events.ts, event-pill.tsx, and the events prop.

D33·2026-07-13

Calendar fills the viewport; block click opens the shared TaskDetailSheet

Context. Two friction points from dogfooding: (1) the week grid was a fixed 40px-per-hour card floating in a page that scrolled as a whole — on any decent monitor most of the window was empty canvas while the grid stayed small; (2) a block on the calendar couldn't be inspected or edited — the only path to a task's details was switching to /tasks and finding it in the list.

Options considered.

  • Panel: A) build a calendar-specific popover/panel; B) reuse the existing URL-driven TaskDetailSheet from /tasks (chosen). The sheet is already ?task=<id>-driven and every task action (updateTask, deleteTask, unpinBlock) already revalidates /calendar, so B is wiring, not building.
  • Full height: A) keep px positioning and only make the card taller (grid still fixed-density, dead space remains on tall windows); B) reposition pills/gridlines/today-line as percentages of the column height so the grid stretches with the window (chosen), with a 40px/hour minHeight floor below which the card scrolls internally under a sticky day-header row.

Chosen. Percent-based geometry + shared sheet. Click vs drag disambiguation reuses the existing 4px drag threshold in block-pill.tsx — a non-drag pointerup now pushes ?task=<taskId> (preserving weekOf). Drag math derives px-per-minute from the column's rendered height at pointerdown instead of a constant, since row height is now viewport-dependent.

Tradeoffs. ✅ One detail-editing surface for the whole app — no drift between list and calendar editing; ✅ grid density adapts to the window with a readability floor; ⚠️ calendar page now fetches full task rows + active projects for the sheet (was titles only) — three cheap indexed queries, fine solo; ⚠️ external EventPills remain read-only (D31) — clicking a meeting does nothing, which is correct but may read as inert.

Reversibility. Cheap — the sheet render and taskId plumbing are a few lines; percent geometry reverts to px by swapping the units back in pillGeometry.

D34·2026-07-13

Inbox is a holding pen; API grows update + unschedule

Context. Unscheduling didn't exist: STATUS_ACTIVE included inbox, so every task auto-scheduled from birth and the only way off the calendar was done or archived — and archived tasks have no UI surface. A real case hit this (25 API-created tasks needing to come off the calendar for fixes) and required archiving them via direct DB writes. Separately, the external API had no update path at all: no task list, no field edits, no project rename.

Options considered. For unschedule: A) a new paused status — rejected, a fourth state when an existing one (inbox) already reads as "not on the calendar yet"; B) repurpose inbox as the unscheduled holding pen (chosen). For scheduling intent: creation now defaults to scheduled (UI action and API alike) so the create→auto-place behaviour is unchanged; API callers can pass status: "inbox" to park a task.

Chosen: B. STATUS_ACTIVE = {"scheduled"} in schedule-user.ts — the sweep's diff removes blocks for anything else, so unscheduling is just a status write plus the next pass. New routes: GET /api/tasks (filters: projectId, status — also fixes "which tasks are unplaced/parked" being invisible to agents), PATCH /api/tasks/{id} (title, description, estimatedMinutes, deadline, projectId, status ∈ inbox|scheduled|done — done mirrors completeTask incl. completed_at + metric), PATCH /api/projects/{id} (name, description, color; not status — archive cascades stay in-app). Parsers in lib/api/parse.ts with tests; OpenAPI spec bumped to 1.1.0 with the path-drift test extended to {id}[id] mapping.

Data migration. Existing inbox tasks were auto-scheduling under the old rule; a one-off UPDATE tasks SET status='scheduled' WHERE status='inbox' (run directly against prod, all accounts) preserves their behaviour. No migration file — a delayed re-run would wrongly promote deliberately-parked inbox tasks under the new semantics.

Tradeoffs. ✅ Unschedule is now first-class and reversible, no more archive hack; ✅ scheduled status finally means something (it was written nowhere before); ⚠️ the UI has no schedule/unschedule affordance yet — inbox tasks are only reachable via API until a surface lands; ⚠️ UI-created tasks skip inbox entirely, so the in-app "inbox" concept is currently API-populated only.

Reversibility. Cheap — put inbox back in STATUS_ACTIVE, revert the two creation defaults; the new routes are additive.

D35·2026-07-13

/calendar shows events from all Google calendars the user has selected, not just one

Context. The week view's external events (D32) came from a single calendar — axis_calendar_id ?? "primary". Calendars shared with the user (team calendars, a partner's calendar) were invisible even though the calendar.readonly scope already grants read access to them. User requirement: see items from shared calendars in Axis.

Options considered. A) An Axis-side calendar picker in /settings persisting chosen calendar ids — rejected: new table/column, new UI, and it duplicates a choice Google already stores. B) Fetch every calendar in calendarList unconditionally — rejected: subscribed holiday/birthday calendars and deliberately-hidden calendars would flood the grid. C) Fetch calendars where calendarList's selected flag is true (chosen) — the flag mirrors the sidebar checkboxes in Google Calendar itself, so "what Axis shows" is controlled from the surface the user already curates, with zero Axis UI.

Chosen: C. listExternalEvents now takes (userId, window), lists selected calendars via calendarList.list, fetches each in parallel with Promise.allSettled (one unshared/revoked calendar can't blank the rest), and dedupes by event id — an invited event carries the same id on every attendee's calendar, so a meeting on both primary and a shared calendar would otherwise render twice. Falls back to primary if the selected set is somehow empty. Display-only, same as D32.

Tradeoffs. ✅ Shared calendars appear with no new settings surface; ✅ still stateless — no sync-token or DB involvement; ⚠️ N+1 Google round-trips per page render (one per selected calendar, parallelised — fine solo); ⚠️ the scheduler's busy-time reads still watch only the sync calendar, so a shared-calendar meeting is visible but not yet avoided when placing blocks — resolved same day by D36; ⚠️ no per-calendar colour/label on pills yet.

Reversibility. Trivial — restore the calendarId parameter and the single events.list.

D36·2026-07-13

Shared-calendar events count as scheduler busy time via a stateless third busy leg

Context. D35 made shared calendars visible in /calendar, but the scheduler's Google busy leg still came only from pullCalendarEvents, which is syncToken-bound to the single sync calendar in gcal_sync_state. A meeting on a shared calendar could have an Axis block placed on top of it. User requirement: shared calendars should count.

Options considered. A) Extend the syncToken machinery to N calendars — one gcal_sync_state row and one push-watch channel per selected calendar, resync lifecycle each. Correct and incremental, but a large lift (schema change, watch-renew fan-out, per-calendar 410 handling) for busy data that gets re-read every pass anyway. B) Stateless windowed read across selected calendars, unioned into externalBusy (chosen) — reuses D35's listExternalEvents over the same deadline-extended window (computeWindow) the sync pull uses.

Chosen: B. Third leg loadSharedCalendarBusy alongside the D25 Google/Microsoft legs, same fail-open posture (failure logs and returns []). The leg overlaps the sync-calendar pull when the sync calendar is selected in Google's sidebar — deliberately tolerated: busy is a union, duplicate intervals cannot change which slots are free, and excluding the sync calendar would mean plumbing its id through for zero behavioural gain.

Tradeoffs. ✅ Shared meetings are now avoided when placing blocks, no schema or watch changes; ✅ which calendars count is the same Google-sidebar selected set that drives display — see/avoid stay consistent; ⚠️ no push notifications from shared calendars — a new shared-calendar event won't trigger a reshuffle, it's only respected on the next pass (task edit, sweep, work-hours change); ⚠️ one extra events.list per selected calendar per pass over the full scheduler window (90d+), vs. the sync leg's incremental token reads.

Reversibility. Trivial — delete the leg from the Promise.all.

D37·2026-07-14

Timezone convention: wall times resolve against profile timezone; display formatting pins locale + zone

Context. A /qa pass on the perceived-slowness report found two timezone defects with one root: Vercel runs TZ=UTC and the code let the platform default leak. (1) datetime-local wall times were parsed server-side with new Date(), so an AEST 5pm deadline stored as 5pm UTC — displayed as 3am the next day, and quick-added tasks (default "today 17:00" computed from the server's calendar day) were born overdue. (2) Intl.DateTimeFormat(undefined) rendered differently on the server (en-US, UTC) and in the browser (en-AU, local), throwing React #418 hydration errors on every hard load of /tasks and showing UTC times in the task sheet and Friday journal.

Decision. Two-part convention, now load-bearing:

  • Inputs: any zone-less wall time from a datetime-local control is resolved to UTC on the server via wallTimeToIso (lib/tasks/wall-time.ts, reusing localMomentToUtc) against user_profiles.timezone. Already-zoned ISO strings pass through untouched.
  • Display: every user-facing timestamp formatter pins en-AU + explicit timeZone from user_profiles.timezone, threaded down from the page loader. Intl.DateTimeFormat(undefined) without a timeZone is now a known footgun in this codebase — SSR and client must produce byte-identical text.

Alternatives. Client-side ISO conversion before submit (browser knows its own zone natively) — rejected to keep one convention server-side where profile timezone already drives the scheduler, and to fix all three write paths (createTask, updateTask deadline + fixed_at) in one file. Client-only rendering of timestamps (mount gate) — rejected: flicker and a hole in SSR for no gain.

Tradeoffs. ✅ Deterministic hydration; deadlines mean what the user typed. ⚠️ Locale hardcoded to en-AU (single-user product; revisit if that changes). ⚠️ Pre-fix deadline rows remain shifted +10h (T20). ⚠️ If the browser and profile timezones diverge, the profile wins — consistent with the scheduler.

Reversibility. Trivial per call site; the helper is 20 lines with unit tests incl. DST (lib/tasks/wall-time.test.ts).

D38·2026-07-14

Vercel functions pinned to syd1: compute sits next to the database

Context. Content loaded slowly even after streaming shells shipped. x-vercel-id: syd1::iad1 on production responses showed requests entering Vercel's Sydney edge and executing in the default US-East (iad1) region, while the Supabase project lives in Sydney (74ms REST TTFB from Brisbane). Every DB query — and pages ran five of them serially — paid a ~220ms DC↔Sydney roundtrip: ~1.1s of pure latency per page render, ~2s inside a task-complete action + its revalidation re-render.

Decision. "regions": ["syd1"] in vercel.json, plus parallelising independent queries (pages and actions) so the remaining hops overlap. Serial auth.getUser() calls (middleware + page + navbar) were considered for a getClaims() migration but dropped: co-located, each call costs single-digit milliseconds — not worth touching the auth path.

Tradeoffs. ✅ Each DB hop ~220ms → ~2ms; the region change alone dwarfs every other optimisation shipped this week. ⚠️ Single-region functions: US visitors now pay the reverse latency — irrelevant for a single-user product in Brisbane. ⚠️ Google Calendar API calls now originate from Sydney; no observed difference.

Reversibility. Delete one line.

D39·2026-07-14

Task detail on /tasks: inline split-view panel, not an overlay sheet

Context. The task detail sheet rendered as a fixed overlay with a scrim that blocked the list behind it — reviewing several tasks meant open → read → close → find the next row → open again. Beau asked for the Asana model: detail as a section within the page, so clicking another row swaps the panel content without a close/reopen cycle.

Decision. TaskDetailPanel (née TaskDetailSheet) grows a variant prop. /tasks uses "inline": the panel renders as a sticky in-page column (440px, top: 32px, own scroll) inside a two-column grid, the page container widens 1080→1400px, and the open row highlights via data-selected. /calendar keeps "overlay" — a week grid can't give up a 440px column. Below lg the inline variant goes full-screen fixed (mobile). Layout switching is pure CSS :has(#task-detail) — no client wrapper, no layout state, and the streamed page shell (D-entry re: streaming) stays sync. The inner form is keyed by task id so swapping tasks remounts and re-initialises field state; without the key, the previous task's local edits would leak into the next task's form (the overlay never hit this because the scrim made task-to-task swaps impossible).

Alternatives. Client wrapper reading useSearchParams for layout — rejected: :has() does it with zero hydration surface, and the codebase already uses has-[] variants. Condensing list columns when the panel is open (full Asana behaviour) — deferred: widening the container preserves all columns with no column-hiding machinery; revisit if the split feels cramped on smaller screens.

Tradeoffs. ✅ Task-to-task browsing in one click; list stays interactive under the open panel. ⚠️ Unsaved panel edits are silently dropped when switching tasks (same loss as closing the old sheet, but now one click easier to trigger). ⚠️ At viewports between lg and ~1300px the list's title column gets tight.

Reversibility. Trivial — pass variant="overlay" (or omit it) on /tasks and delete the grid wrapper classes.

D40·2026-07-14

Detail panel: Asana anatomy with per-field autosave

Context. The panel (D39) kept the old sheet's form-like innards: boxed inputs in a 2-column grid, explicit Save, complete only from the list row. Beau asked for a rework modelled on Asana's task details; reference screenshots of his Asana account (split view, task swap, date picker, focus view) captured 2026-07-14.

Decision. Adopt Asana's anatomy in the Axis register: an action bar on top (✓ Mark complete left — completing no longer requires the list row; delete + close right), editable title, label-left/value-right field rows with borderless-until-hover values, description with placeholder, comments stream + composer (D42), meta footer. Every field autosaves on blur/change through updateTask's existing partial-FormData contract; the Save button is gone. A ref of last-committed values suppresses no-op writes. Completing from the panel closes it (one-offs leave the list; recurring tasks re-appear with the advanced deadline). Both variants (inline and calendar overlay) share the body.

Alternatives. Keep explicit Save with the new layout — offered, Beau chose autosave. Debounced-while-typing saves — rejected: blur/change is calmer, and datetime-locals fire noisy intermediate changes.

Tradeoffs. ✅ Task-to-task browsing never loses edits (each blur persisted). ✅ Fewer clicks per edit. ⚠️ Each blur fires a server action + revalidation — acceptable post-D38 (syd1). ⚠️ No undo; a mis-edit needs manual correction.

Reversibility. The commit helpers are thin; restoring a Save button is a small diff.

D41·2026-07-14

Recurring tasks: complete-to-advance, not clone-on-schedule

Context. Beau needs recurring tasks (weekly reviews, daily standups). Two common models: (a) occurrences spawn on a schedule regardless of state (cron-style), (b) the next occurrence appears only when the current one is completed (Asana/Todoist).

Decision. Model (b), stored as a single tasks.recurrence text column (daily | weekdays | weekly | fortnightly | monthly, null = one-off). completeTask on a recurring task advances deadline (and fixed_at) to the next occurrence via lib/tasks/recurrence.ts and leaves status scheduled; the task never proliferates rows. Advancement is wall-clock-preserving in the profile timezone (a 5pm deadline stays 5pm across DST, D37 convention) and loops past now so an overdue daily task lands tomorrow, not four days ago. Monthly clamps to the target month's length (Jan 31 → Feb 28). Completion metrics still emit task_completed (with recurring: true), so history lives in metric_events, not task rows. Any user-adjusted pin is demoted to user_adjusted = false on advance so the sweep re-places the new occurrence through the normal outbox path (GCal event moves instead of orphaning). A recurring task with no deadline and no fixed_at completes like a one-off — there's nothing to advance.

Alternatives. Cron-spawned occurrence rows — rejected: needs a template/instance split, a spawner cron, and dedup; heavy for v1. RRULE strings — rejected: five presets cover the real cases; a text enum is queryable and obvious.

Tradeoffs. ✅ One column, one helper, one branch in completeTask. ⚠️ No per-occurrence completion history on the task itself (metrics carry it). ⚠️ No "every N days" custom interval; add a preset when a real need appears.

Reversibility. Drop the branch and the column; tasks degrade to one-offs.

D42·2026-07-14

Task comments: plain-text progress log per task

Context. Beau wants to record what got done on a task without completing it — a progress log. Maps to Asana's comment stream in the detail panel.

Decision. task_comments table (id, user_id, task_id, body text, created_at) with owner-only RLS, cascade delete with the task. addTaskComment / deleteTaskComment go through the user's JWT client (RLS owns tenancy; no scheduler state touched, so no admin client and no reshuffle stamp). Insert verifies the task is visible to the user first — the comments RLS policy alone can't stop attaching a comment to someone else's task id. Comments are fetched per page alongside scheduled_blocks (same in task_id pattern) and render as a stream + composer at the bottom of the detail panel. Plain text only.

Alternatives. Rich text / markdown bodies — rejected for v1, plain text covers a progress log. Editing comments — skipped; delete + rewrite is fine solo.

Reversibility. Drop the table and two actions.

D43·2026-07-14

Recurring completion: duplicate-and-schedule, superseding D41's advance-in-place

Context. D41 advanced the same task row's deadline on completion — no task row ever recorded that an occurrence was completed (only a metric_events entry). Beau requires a verifiable completion record per occurrence.

Decision. Completing a recurring task now (1) inserts a fresh task row for the next occurrence — same title/description/estimate/chunk/timing/tags/project/recurrence, deadline and fixed_at advanced via nextOccurrence — then (2) marks the current row done with completed_at, exactly like a one-off. Every occurrence therefore ends life as a real done row, with its comment log attached; the new occurrence starts with a clean log. Insert happens before the done-update so a failed insert aborts the completion rather than silently ending the chain (a failed done-update after insert leaves a visible duplicate, which is recoverable; a vanished chain is not). The D41 pin-demotion is gone — the completed row's blocks are cleaned up by the sweep like any done task, and the new row has no pins. task_completed metrics carry next_task_id to link the chain.

Tradeoffs. ✅ Auditable per-occurrence history in the tasks table itself. ✅ Comments scope naturally to one occurrence. ⚠️ Row growth — one row per completed occurrence (fine at personal scale). ⚠️ The occurrence chain is only linked via metrics, not a task column; add a spawned_from FK if the UI ever needs to walk it.

Reversibility. Restore D41's update-in-place branch; both rest on the same nextOccurrence helper.

D44·2026-07-24

Weekends: per-account toggle governing both placement and calendar view

Context. Beau wanted a work-window setting to hide/show weekends, assuming it was env-driven. It wasn't — weekends were never gated anywhere. The scheduler's workdayWindows placed on all 7 days and the week grid always rendered 7 columns.

Decision. Added user_profiles.show_weekends boolean not null default true (migration 0009). One toggle drives two consumers: workdayWindows/computeFreeSlots skip Sat/Sun (weekday resolved in the user's TZ) when false, and the week grid renders Mon–Fri (5 columns) instead of Mon–Sun. Settings exposes it in the Work window section; a change re-stamps reshuffle_pending_at the same way work hours and aggressiveness do, so existing weekend blocks migrate off without another edit. Default true preserves current behaviour.

Options considered. (A) View-only toggle — rejected: the scheduler would keep placing invisible weekend blocks. (B) Per-day workday mask (arbitrary subset of days) — rejected as speculative; nobody asked for "work Sundays but not Wednesdays". (C) This: one boolean, both consumers.

Tradeoffs. ✅ Weekend work can never be scheduled onto a hidden day. ✅ Minimal surface — one column, one enum-free boolean. ⚠️ All-or-nothing (Sat and Sun move together); a per-day mask is the upgrade path if ever needed. ⚠️ The scheduler resolves weekday via Intl per candidate day — negligible cost at a personal scale, one DateTimeFormat call per day walked.

Reversibility. Cheap. Drop the column and the default-true params restore 7-day behaviour; the grid's dayCount collapses back to a constant 7.

D45·2026-07-24

Reschedule a placed block: start now / set time / find next slot

Context. Once a block was placed, the detail panel offered no way to change its time — the only reschedule affordance (Unpin · re-schedule) appeared solely for already-pinned (user_adjusted) blocks. Real case: a meeting overruns, Beau starts the task halfway through its placed slot and wants to reflect the actual start, or bump it to the next free gap.

Decision. One reschedule affordance with three actions, surfaced in the detail panel (Scheduled row, all blocks) and via right-click on the calendar block pill:

  • Start nowadjustBlock(block.id, now) — pins the block to start now, duration preserved.
  • Set timeadjustBlock(block.id, chosen) — pins to a chosen datetime-local.
  • Find next slotrescheduleBlock(block.id) — hands the block back to the sweep.

adjustBlock (the existing drag path) already covers the first two; "Start now"/"Set time" pin the block (user_adjusted) exactly like a drag rather than flipping the task to fixed timing. The old unpinBlock (D23) was generalised into rescheduleBlock: it drops the !user_adjusted early-return so a plain placed block can also be swept, clears the pin, and stamps reshuffle_pending_at with a 0ms debounce. The reshuffler places from now forward deadline-first, so an overrun slot lands in the best free gap.

Options considered. (A) Keep unpin pin-only and tell users to drag — rejected: no way to sweep a placed block, and drag can't express "start now". (B) Force "find next slot" to the soonest gap regardless of deadline — rejected: needs new placer logic and bumps other tasks; deadline-first reuse is free. (C) Calendar context menu duplicating the datetime-local picker — rejected: "Set time…" on the pill opens the panel where the picker already lives; drag remains the calendar's primary time gesture.

Tradeoffs. ✅ Every block is reschedulable from both surfaces with existing actions. ✅ GCal follows via the same outbox path as drag/sweep. ⚠️ "Find next slot" may not be the immediately-next gap if an earlier-deadline task outranks it — intended. ⚠️ Right-click menu is desktop-first; touch reschedule goes through the panel.

Reversibility. Cheap. The UI is additive; reverting restores the pin-only unpin button. rescheduleBlock is a strict superset of unpinBlock, so no data-shape change.

D46·2026-07-27

Block starts clamp to a 5-minute grid

Context. Free slots inherit the ragged edges of external calendar events: a meeting ending at 15:33 produces a slot starting at 15:33, and the placer began the next task right there (plus the aggressiveness buffer, giving starts like 15:44). Times that never land on a readable mark make the day hard to scan and time reporting messy. "Start now" already rounded to 5 minutes ad hoc (D45), so the grid existed in one path and nowhere else.

Decision. A block's start may only sit on a 5-minute mark. One helper (lib/scheduler/snap.ts) exports snapToGrid (nearest) and snapUpToGrid (next mark at or after), applied at all four write paths that set a start:

  • placersnapUpToGrid(slot.start + buffer), then re-check end + buffer <= slot.end since snapping can push the tail past the slot.
  • reshuffler, fixed passsnapToGrid(task.fixedAt).
  • `adjustBlock`snapToGrid(newStart). Single server-side entry point for drag, "start now", and "set time", so no client can route around it.
  • `updateTask`'s `fixed_at` write — snapped, so the stored fixed time matches the block it produces. Deadlines are not start times and stay as entered.

The grid is anchored to the UTC epoch. Every IANA offset is a whole multiple of 5 minutes (including the :30 and :45 zones), so a UTC mark is a local mark everywhere — no per-timezone grid needed. Two fast-check invariants cover it: all placements land on the grid under ragged busy intervals and random aggressiveness, and snapped placements still never overlap busy.

Options considered. (A) Snap only in the placer — rejected: drag and "set time" would still write ragged starts. (B) Round free-slot boundaries in computeFreeSlots instead — rejected: rounding a slot start down would place a task over the tail of a real meeting; rounding busy intervals outward silently discards up to 5 minutes of genuinely free time on both sides of every event. Snapping at placement keeps the free-slot model honest. (C) step={300} on the datetime-local inputs alone — kept as a UI nicety, but insufficient: client-side validation is bypassable and doesn't touch auto-placement.

Tradeoffs. ✅ Every surface reads on clean marks. ✅ Single choke point, four call sites, no new dependency. ⚠️ A task can now be declined from a slot that fits by less than 5 minutes — correct, it genuinely does not fit on the grid. ⚠️ Inbound GCal edits (the webhook mirror) are deliberately not snapped: Google is the source of truth for a user's own drag there, and re-snapping would desync the block from the event it mirrors.

Reversibility. Trivial. Delete snap.ts and the four call sites; existing rows keep whatever start they were last written with.

D47·2026-07-27

Why-tooltip hangs left on the right-most day columns

Context. The calendar's week grid showed a ~36px horizontal scrollbar. The document didn't overflow — the scroll was inside .week-grid (overflow-auto, which the grid needs for vertical scrolling and for the deliberate min-w-[760px] responsive scroll below ~800px). Cause: the block pill's why-tooltip is absolute left-full ml-2 w-64 — 264px hanging off the pill's right edge, wider than a day column (~216px at 1280px viewport). It is rendered at all times (opacity-0, not display:none), so it contributes to the scroll container's overflow even when invisible. A pill in either of the last two columns pushes the scrollable area past the grid. Measured live: pills in the second-to-last column, scrollWidth 1176 vs clientWidth 1140.

Decision. Flip the tooltip to right-full mr-2 (with the slide-in transform mirrored) for the last two day columns, via a flipTooltip prop threaded WeekGrid → DayColumn → BlockPillPositioned → BlockPill. Verified against the live DOM: overflow 36px → 0, tooltip right edge lands left of the pill and inside the grid on both axes.

Two columns, not one: the tooltip needs 264px of clearance, so the second-to-last column spills too — and in the observed failure it was the second-to-last column doing it while the last column was empty. A one-column threshold would not have fixed the reported bug.

Options considered. (A) overflow-x: clip/hidden on .week-grid — rejected: kills the intentional horizontal scroll below ~800px (min-w-[760px], documented in the component) and clips last-column tooltips instead of relocating them. (B) Render the tooltip only on hover — rejected: it would then create the scrollbar on hover, flashing the layout. (C) Measure available space per column at runtime and flip dynamically — rejected: needs client measurement in a server component for a fixed-width tooltip against columns that only vary between ~96px and ~230px.

Tradeoffs. ✅ No phantom scrollbar; tooltip stays fully readable everywhere. ✅ Server-rendered, no measurement, no client JS added. ⚠️ Deviates from DESIGN.md's "why-tooltip slides in 10px right" for the last two columns — the slide mirrors to match its side. ⚠️ The 2-column threshold is a constant, correct for any column ≥132px; below ~800px the grid scrolls horizontally by design so the spill is moot. Marked with a ponytail: comment.

Reversibility. Trivial. Drop the prop and the conditional class.

D48·2026-07-28

Completed tasks keep their calendar slot; /tasks grows a Completed section

Context. Completing a task destroyed the record of having done it. completeTask sets status='done'; the next sweep's loadActiveTasks returns only status='scheduled' rows, so the task is absent from the reshuffler's liveTaskIds, its scheduled_blocks row comes back in removedBlockIds, and scheduleUser deletes the row and enqueues a Google Calendar delete. Meanwhile /tasks queries status in ('inbox','scheduled'). The row survived in Postgres with completed_at already populated — but nothing read it. Net: no surface for "what did I actually do", on either side of the sync.

Decision. Retain the blocks and surface the history, in four parts:

  1. Scheduler. excludeCompletedPlacements(placements, doneTaskIds) filters completed tasks' blocks out of currentPlacements before reshuffle() sees them, so they can never enter removedBlockIds. loadActiveTasks already selected status for every row, so it returns the done-id set alongside the placeable tasks — no extra query. Only 'done' is excluded; 'inbox' and 'archived' blocks are still culled (D34's unschedule path).
  2. Calendar. A fourth block state: --block-meeting fill with an --accent-dim left rule and a leading . Completed wins over user-adjusted. The pill keeps click-to-open, drops drag and the D45 reschedule menu.
  3. Movement lock. adjustBlock and rescheduleBlock select tasks!inner(status) on the row they already load and throw on 'done'. The client-side suppression is cosmetic; this is the enforcement.
  4. List. A closed Completed <details> at the bottom of /tasks — separate query, completed_at desc, capped at 50, honouring the project filter. uncompleteTask returns a row to 'scheduled', clears completed_at, and stamps a reshuffle, which hands the block back to the sweep.

Options considered. (A) A completed boolean on scheduled_blocks and a reshuffle() branch that retains flagged blocks — rejected: adds a column that duplicates tasks.status, and puts a history concern inside the pure scheduler, whose tests would all need the new field. Filtering at the adapter boundary keeps reshuffle() ignorant of completion. (B) Treat completed slots as busy so the time stays reserved — rejected: finishing a 2h block in 40 minutes should give the remaining 80 back, not forfeit them. (C) Retitle the GCal event ✓ Task title — rejected for now: one extra outbox write per completion to restate what the Axis grid already shows. (D) A separate /tasks/done route — rejected: a new page shell and loader for what one collapsed section does, and it separates "what I did" from "what's left" on the surface where you compare them.

Tradeoffs. ✅ History survives in Axis and Google, with no migration and no new column. ✅ One bounded query for the list; the calendar derives completion from task rows it already fetches for the detail panel. ✅ The lock has a server-side guard, not just a client one. ⚠️ Completed blocks leave the busy union, so finishing early frees the tail — meaning a completed pill and a newly placed pill can visually overlap in that window. Accepted as the lesser evil versus forfeiting real time. ⚠️ uncompleteTask does not unwind D43's recurrence spawn: the next occurrence is a row the user may already have edited, and deleting it silently is worse than leaving one they can see and remove. ⚠️ The 50-row cap is fixed; older completions are reached by browsing back through calendar weeks. Marked with a ponytail: comment. ⚠️ Two DESIGN.md amendments were needed (a fourth block state; /tasks sections are no longer purely time-derived) — both approved before implementation.

Reversibility. Easy but lossy in one direction: dropping excludeCompletedPlacements makes the next sweep delete every retained block and its calendar event. The section, the pill state, and uncompleteTask all delete cleanly.

D49·2026-07-28

Unplaced tasks report why: capacity vs deadline, and the reason reaches the API

Context. A 120-minute task with a Thursday deadline came back unplaced from POST /api/schedule while Thursday visibly had a 135-minute gap. Diagnosis took far longer than the bug warranted, for two compounding reasons. First, placeTask skips every slot shorter than the task's footprint, then evaluates the first slot that is long enough — which, by construction, is often after the deadline — and returns "no slot ending before deadline". A pure capacity failure was therefore reported as a timing failure, sending the reader to hunt deadlines, timezones, and all-day-event handling instead of gap length. Second, scheduleUser reduced reshuffle()'s unplaced: [{taskId, reason}] to .length and ScheduleResult exposed only that integer, so the reason the placer had already computed was unreachable from outside the app. "4 unplaced" is not actionable.

(The originating incident was a third thing, not a code defect: scheduled_blocks had drifted from Google Calendar after blocks were dragged directly in Google, so the scheduler was planning around phantom busy time. That is a sync concern, tracked separately — but it was invisible precisely because of the two defects above.)

Decision. Two changes, both narrow:

  1. Distinguish the two failure modes. placeTask tracks whether it passed over a slot that began before the deadline purely for being too short (either the footprint check or the post-snap tail check). If so, the deadline branch returns "no free slot long enough before deadline"; otherwise it keeps "no slot ending before deadline". The whole-horizon miss keeps "no free slot long enough". Three reasons, each naming the actual constraint.
  2. Surface the reasons. ScheduleUserResult gains unplacedTasks: [{taskId, title, reason}], populated from the existing loadTaskTitles call by adding the unplaced ids to the set it already fetches — no extra query. The route spreads the result, so the field flows to the API without touching the handler. unplaced stays an integer for backwards compatibility.

Options considered. (A) Return a structured reason object ({kind, shortestRequiredMinutes, longestAvailableMinutes}) — rejected for now: richer, but every consumer and test would need updating for a string the user reads once. Marked as the upgrade path if reasons start being parsed rather than read. (B) Replace unplaced: integer with the array outright — rejected: a breaking change to a published schema for no gain, since the count is what callers assert on. (C) Log the reason server-side only — rejected: the API is how this account is driven, and a reason visible only in Vercel logs is a reason nobody reads. (D) Leave the string alone and fix only the plumbing — rejected: the misleading string was the more expensive of the two defects.

Tradeoffs. ✅ An unplaced task now names its own constraint, in the response, with the task title attached. ✅ No new queries, no schema migration, no breaking change. ✅ Both branches are covered by tests, including a control asserting the deadline reason still fires when no pre-deadline slot existed. ⚠️ The reasons are prose, not machine-readable; parsing them would be a mistake and option (A) exists for that day. ⚠️ The flag is per-task and resets each placeTask call — correct, but it means the reason describes only that task's search, not the day's overall pressure. ⚠️ Still no reason surfaced for tasks that fail before the placer runs (malformed fixed tasks are silently skipped in reshuffle) — out of scope here.

Reversibility. Trivial. Both changes are additive and independently revertable; deleting unplacedTasks from the result type and the schema restores the prior contract exactly.

D50·2026-07-28

user_adjusted stops the scheduler moving a block, not the webhook noticing it moved

Context. detectUserAdjustments opened with if (block.userAdjusted) continue;, documented as "once user_adjusted, the block is pinned and we don't re-evaluate it on every webhook". The consequence was not the intended cheap skip: a block's first drag in Google Calendar was recorded, and every drag after it was discarded. The row stayed pinned to the first dragged position while Google showed the newest one. Because reshuffle() treats a user_adjusted block as preserved busy time, the scheduler then planned around a slot the user had already vacated — phantom busy that silently rejected new placements elsewhere in the day. Observed 2026-07-28: a 120-minute task reported unplaced against a day whose real largest gap was 135 minutes, because two blocks were being reserved at stale positions.

The flag was carrying two meanings that only look alike. "The scheduler must not move this" is a scheduling instruction. "We have already seen where the user put it" is a sync fact. They diverge the moment the user drags a second time.

Decision. Drop the guard; re-evaluate every Axis-origin event against its row on every webhook. user_adjusted keeps its scheduling meaning (D23/E3: the reshuffler preserves pinned blocks) and loses its sync meaning entirely. applyAdjustment already sets the flag unconditionally, so re-flagging an adjusted block is a no-op write.

Why this cannot feed back on itself. The comparison is against the row we hold, and scheduleUser writes scheduled_blocks before enqueueing the outbox write. By the time Axis's own calendar write lands and Google pushes a notification, event and row already agree, so no adjustment is produced. This is the same ordering that makes the axis_origin tag sufficient elsewhere; the tag prevents our events being read as external busy, and the write-then-enqueue order prevents our writes being read as user drags.

Options considered. (A) Keep the guard but clear user_adjusted whenever the scheduler successfully re-places a block — rejected: the flag would then flicker with scheduling activity, and a user drag between the clear and the next webhook is lost. (B) Compare against a separate last_seen_gcal_start/end pair so the pin and the sync cursor are distinct columns — rejected as premature: it is the correct shape if drag detection ever needs to distinguish "user moved it" from "we moved it", but today the row IS the last-known state and a migration buys nothing. Named here as the upgrade path. (C) Re-evaluate only blocks whose event was in the incremental delta — rejected: pullCalendarEvents already returns only changed events, so the loop is over the delta regardless; the guard was never a performance measure.

Tradeoffs. ✅ Every drag now propagates, not just the first. ✅ No migration, no new column, one line removed. ✅ The feedback-loop safety is a property of existing write ordering rather than a new mechanism. ⚠️ A block dragged repeatedly now writes once per drag instead of once ever — correct, and bounded by how fast a human can drag. ⚠️ This fixes detection only. If the Google watch channel has lapsed no webhook arrives at all and the handler never runs; channel health is separate and unverified as of this entry. ⚠️ Drags of a block whose task is done are still applied; D48 keeps those blocks out of the scheduler, not out of the webhook.

Reversibility. Trivial — restoring the single continue line restores the prior behaviour exactly.

D51·2026-07-29

Drag detection must not run while Axis's own write is still queued

Context. Blocks were being flagged user_adjusted (and shown as "user-adj" pills) with no user interaction at all. D50's safety argument — "scheduleUser writes the row before enqueueing the outbox write, so by the time our own write lands, event and row already agree" — assumed writeback is synchronous. It is not: the outbox drains on a cron up to ~60s later (minutes on retries or when a batch exceeds 10 rows). In that window the GCal event still shows the old time while the row shows the new one, and any webhook pull that catches the divergence records a phantom drag. Worse, applyAdjustment also rewrites the row back to the event's stale times, undoing the reshuffle's placement. And because a reshuffle triggered by editing task X is free to move blocks of tasks Y and Z, the falsely pinned blocks were ones the user never touched. A second hole compounded it: D26 gave outbox inserts a dispatch-time snapshot of the block's current times, but updates still dispatched the payload frozen at enqueue time — so two reshuffles inside one outbox tick made Axis itself write stale times to Google, then flag its own write as a user drag.

Decision. Two changes, same defect. (1) handleWebhook loads the set of block ids with a pending or in_flight writeback_outbox row and drops any detected adjustment for those blocks — a queued write means event/row divergence is expected mid-flight state, not a drag. A reshuffle always moves the row and enqueues the update in the same pass, so "a pending outbox row exists" is exactly the race window. (2) Outbox update dispatch now re-reads the block's current start_at/end_at via loadBlockSnapshot at dispatch time, mirroring D26's insert treatment, so stale times never reach Google in the first place; an update whose block row is gone is skipped (the queued delete owns the event's removal).

Options considered. (A) Compare Google's event.updated timestamp against the row's updated_at and only flag when the event changed later — rejected: two clocks (Google's and Postgres's) with independent skew deciding a boolean is exactly the kind of correctness that fails silently; the outbox already knows whether a write is ours. (B) Suppress detection for N seconds after any row update — rejected: a timer is a guess at the queue's drain latency, and retries blow past any constant. (C) The pending-write check alone without the dispatch-time snapshot — rejected: it masks the flag but still lets a stale write land on the calendar, showing the user a time the scheduler has already abandoned until the next update catches up.

Tradeoffs. ✅ A drag made while no write is queued for that block — the overwhelmingly common case — is detected exactly as before. ✅ One extra indexed select per webhook, no schema change. ⚠️ A drag made inside the queue window loses, once: the queued dispatch re-reads the row and patches the event back to the row's times, so the drag is undone on the calendar and never recorded (the post-dispatch webhook sees event and row agreeing). The user sees the block snap back and their next drag sticks, because by then no write is queued. Making the dispatch lose instead requires an etag-conditional patch (If-Match on events.patch) — filed as T21. An earlier draft of this entry claimed the suppressed drag "gets picked up once the queue drains"; that was wrong, for exactly this reason. ⚠️ A dead-lettered update leaves event and row divergent with the delta already consumed; a later full resync misreads that divergence as a drag — T22. ⚠️ Blocks already falsely pinned stay pinned; the flag cannot be distinguished from a real drag retroactively, so cleanup is a manual reset per block.

Adversarial-review hardening (same day). Two review rounds (adversarial pass, then a red-team pass over the fixes) closed residual windows in the original design: (1) the pending-write set is sampled twice — before the calendar pull and after the blocks load — and suppression uses the union, because rows race the slow pull in both directions: dispatches landing mid-pull leave the set (and our own write landing is what triggers most webhooks), while a reshuffle enqueueing mid-pull enters it; (2) the suppression query is age-bounded to 60 minutes so a row stranded in_flight by a worker crash (nothing reaps those) cannot suppress drag detection on its block forever; (3) scheduleUser and adjustBlock enqueue the outbox update before moving the row — supabase-js cannot make the pair transactional, and the old order left a gap where a webhook saw the moved row with no suppressing outbox row (adjustBlock's gap even spanned an extra round trip, so an in-app drag could be silently reverted); (4) on a handler failure after the pull, the route nulls the stored syncToken — the pull advances the token before adjustments apply and Google never resends, so a mid-loop failure otherwise consumes drags/deletes forever; a forced full resync re-derives them. Each ordering was chosen so the racy side over-suppresses (heals on the next delta) rather than under-suppresses (the phantom pin).

Reversibility. All changes are additive and revert independently — drop the filter in handleWebhook, or restore the payload pass-through in dispatch().

D52·2026-07-29

The now-line ticks on the client; everything else in the grid stays server-rendered

Context. The calendar's today indicator was rendered by the server component from new Date() at request time. It was correct at first paint and then never moved — a tab open for twenty minutes showed the marker twenty minutes behind the wall clock (reported 2026-07-29, 09:17 real vs ~08:55 shown). No client code re-rendered it; the only path back to the truth was a full reload.

Decision. Extract TodayIndicator into a client island (app/calendar/today-indicator.tsx) that reads the clock through useSyncExternalStore: server snapshot from the server-passed instant (SSR HTML and hydration agree), client snapshot the real clock bucketed to 30-second ticks, subscription a 30-second interval. Mount re-reads the snapshot, so a stale SSR position snaps to true time immediately, then tracks. The rest of the grid — pills, gridlines, layout — stays server-rendered; only the 2px line hydrates.

Options considered. (A) setState in a mount effect plus setInterval — rejected: the React Compiler lint (react-hooks/set-state-in-effect) correctly flags the synchronous set, and a clock IS an external store; subscribing to it is the primitive's intended use. (B) Periodic router.refresh() so the whole server component re-renders — rejected: re-fetches the page's data every tick to move a 2px line, and pauses at the whim of fetch latency. (C) CSS animation advancing the line between server renders — rejected: clever, unreadable, and drifts anyway after tab sleep.

Tradeoffs. ✅ Marker accurate within a minute indefinitely (30s bucket floor plus up to 30s of interval phase), including after laptop sleep/bfcache restore (snapshot re-read on wake ticks). ✅ Grid stays a server component. ⚠️ localMinutesFromMidnight is now duplicated in the island — exporting it from the server module would drag that module graph into the client bundle; extract to a shared lib if a third copy appears. ⚠️ The column highlight for "today" is still server-computed: a tab left open across midnight keeps the old column highlighted (and the marker in it) until any navigation. Accepted — the drift complaint was intra-day; midnight staleness heals on first interaction.

Reversibility. Delete the island and restore the inline server-rendered function — the markup is unchanged.

D53·2026-07-29

Hour marks are positioned from the band's top edge, not from the hour it starts in

Context. Reported the same morning D52 shipped: at 10:18 a block labelled "10:30 · 1h" appeared to start at the 10:00 gridline, and the freshly-fixed now-line sat above the "10:00" label. The now-line fix was real but had exposed a second, older bug by making the first one stop moving.

Blocks, external events, and the now-line all position themselves as (localMinutes - dayStartMinutes) / visibleMinutes — measured from the top edge of the visible band. The hour gutter did not. It built labels by whole hours from startH and placed label i at i * 60 / visibleMinutes. Both halves silently assume workhours begin exactly on the hour. Workhours are a plain <input type="time"> with no step, and this profile runs 08:30–17:30, so every label and gridline was drawn 30 minutes — the start's minute component — below its true line. The "08:00" label was painted at the 08:30 top edge, and a block at 10:30 (120 minutes into the band) landed precisely on the mislabelled "10:00" mark (also drawn at 120 minutes). The blocks were never wrong; the ruler beside them was.

Decision. Extract hourMarks(dayStartMinutes, dayEndMinutes) into app/calendar/hour-marks.ts, returning {label, topPct} pairs computed as (h * 60 - dayStartMinutes) / visibleMinutes. Marks start at the first whole hour at or after the top edge (Math.ceil(dayStartMinutes / 60)) — a band starting at 08:30 has no 08:00 line to draw — and run while h * 60 <= dayEndMinutes. The gutter and the per-day gridlines both consume the same array, so the ruler and the things measured against it can no longer disagree.

Options considered. (A) Keep the index-based loop and subtract startM at the call sites — rejected: same latent assumption, two places to forget it, and it still emits an 08:00 label for an 08:30 band. (B) Snap workhours to whole hours in /settings — rejected: fixes the display by removing a legitimate setting, and existing half-hour profiles would silently shift. (C) Draw a mark at the band's top edge labelled with the actual start time (08:30) plus whole hours after — rejected for v1: it reads as a denser, less scannable ruler; the top edge is already unambiguous.

Tradeoffs. ✅ One pure function, unit-testable without rendering; the two consumers can't drift apart. ✅ Bands starting on the hour are unchanged (Math.ceil is a no-op, first mark at 0%). ⚠️ A band starting mid-hour now has a gap between its top edge and the first label — correct, but it makes the half-hour start visible where it previously hid behind a wrong label. ⚠️ Only whole hours are marked; a 30-minute band would render no marks at all (returns [] rather than dividing by zero).

Reversibility. Self-contained module plus two call sites.

D54·2026-07-30

Unschedule is a status change on the Status pill, not a date change

Context. A task with a deadline that isn't getting done reappears in Today every morning. The only ways to quiet it were pushing the deadline (a lie about when it's due) or clearing it (losing the date). D34 already built the mechanism — status = 'inbox' is the off-calendar holding pen, the scheduler only places 'scheduled' (lib/scheduler/schedule-user.ts), and the API exposes it as PATCH {status: "inbox"} — but nothing in the UI could reach it.

Decision. The Status column pill becomes a button with a one-item menu: Unschedule on a scheduled task, Schedule on an unscheduled one. Unschedule sets status = 'inbox' and touches nothing else; the deadline is deliberately preserved. Two consequences follow from that one write: markReshufflePending means the next sweep culls the task's blocks and enqueues the Google Calendar deletes, and bucketTasksByTime now sends any 'inbox' task to Unscheduled regardless of its deadline or lingering blocks. The pill reads unscheduled and beats overdue — a parked task shouldn't keep shouting — while the Deadline cell stays red, so the date is still honest.

Shipped as one shared client component (components/task-status-menu.tsx) replacing the duplicated TaskStatusPill in /tasks and /projects/[id], and a five-line status branch in the existing updateTask action rather than a new action.

Options considered. (A) A separate "unschedule" icon button in the row — rejected: another glyph in a row that already carries four controls, and it can't show the current state. (B) Click the pill to toggle straight to unscheduled, no menu — rejected: the pill sits beside a delete button in a dense row, and a misclick silently strips a placement. (C) A new unscheduleTask server action — rejected: updateTask already patches arbitrary task fields through the admin client and stamps the reshuffle; status is one more field. (D) Delete the scheduled_blocks rows inline instead of waiting for the sweep — rejected: the sweep owns block lifecycle and the outbox ordering rules from D51; a second writer to that table is exactly the feedback-loop risk those rules exist to prevent. Cost is up to a minute of calendar lag, consistent with every other mutation.

Tradeoffs. ✅ The date survives, so an unscheduled task keeps its real due date for when it comes back. ✅ Reuses D34's existing unschedule path end to end — no new scheduler behaviour, no migration. ✅ One pill component instead of two divergent copies. ⚠️ The calendar block lingers until the next sweep tick. ⚠️ An unscheduled task with a past deadline no longer shows an overdue pill anywhere; the red deadline cell is the only overdue signal in that state. ⚠️ Nothing prompts you to revisit the Unscheduled section — parking a task is genuinely out of sight, which is the point, but it is not a snooze.

Reversibility. One component, one action branch, one line in the bucketing function.

D55·2026-07-31

Full-detail task creation reuses the detail panel as a draft, with ?task=new

Context. The inline add row on /tasks (one row per section) carries title, estimate, deadline and a project picker — but the picker only renders when projects.length >= 2, so on a single-Inbox profile there is no way to set a project at creation time, and even with it visible the row can't reach description, timing type, fixed_at, chunk size, recurrence or tags. Every task therefore started thin and had to be opened and edited straight afterwards.

Decision. Add a second, deliberate creation path: a New task button that opens the existing task detail panel in a draft variant at ?task=new. Same field set, same layout, same shell — but every field is local state and a single Create task button performs the one write. Nothing exists in the database until that press; Cancel, close and Escape discard the draft entirely. On success the panel swaps to the created task's real panel (?task=<id>), so autosave takes over exactly where the draft left off.

Three supporting changes: createTask now accepts the full optional field set (description, chunkMinutes, timingType, fixedAt, recurrence, tagsJson) and returns the new id instead of void; the panel shell and the tags chip input were extracted (PanelShell, TagsEditor) so the draft and live variants share them rather than forking; TaskDetailPanel takes an optional defaultProjectId so the draft opens pre-scoped to whatever project filter is active.

Options considered. (A) Thread a draft mode through PanelInner itself — rejected: that component is built around autosave (commitIfChanged, a last-committed ref, save-state indicator) plus comments, blocks, complete and delete, none of which mean anything before the row exists; every one would need a null branch. (B) Create the row immediately on button press and let the existing panel autosave into it — rejected: it violates the explicit requirement that nothing is created until saved, and an abandoned draft leaves a real task the sweep would schedule. (C) Widen the inline add row to hold all fields — rejected: it's the fast path; the value of ⏎-and-repeat is that it doesn't ask for nine fields. (D) A separate /tasks/new route — rejected: loses the list beside the form, and the panel already renders as an in-page column.

Tradeoffs. ✅ Nothing is written until Create; abandoning costs nothing. ✅ One creation action for both paths, so the inline row inherits the wider field support for free if it ever wants it. ✅ Panel shell and tags input now exist once instead of twice. ⚠️ The draft form duplicates the field markup of the live panel (same components, separate JSX) — the two can drift; a field added to one should be added to the other. ⚠️ ?task=new is a reserved id value: a task whose id is literally new would be shadowed (uuids, so not reachable). ⚠️ The button only appears on /tasks; /calendar and project pages still create through their existing affordances.

Reversibility. Remove the button, the openId === "new" branch and DraftPanel; createTask's extra fields are all optional and backward compatible.

D56·2026-08-19

Defer ("Later") is a not-before floor, separate from the deadline and from Unschedule

Context. The scheduler is purely deadline-driven greedy first-fit (D16): a flexible task lands in the earliest free slot that still meets its deadline, full stop. A task due next week gets placed today if there's room, crowding out work you actually mean to do now. Unschedule (D54) isn't a fit for this — it parks a task in status = 'inbox', and D54 says outright "it is not a snooze": nothing brings the task back, so using it here would mean remembering to manually re-schedule every deferred task.

Decision. Add tasks.deferred_until (nullable timestamp), independent of deadline. The placer (lib/scheduler/placer.ts) clips each candidate free slot to max(slot.start, deferredUntil) before evaluating footprint/placement — a slot entirely before the floor is skipped outright, one straddling it is entered late. No status change and no explicit "become eligible again" step: once deferred_until passes, the next sweep places the task like any other, because the check is a live comparison against now, not a one-time flag. Sections stay keyed on deadline (lib/tasks/sections.ts untouched) — a task due next week still shows under "This week", it just doesn't occupy calendar time until eligible; the Status pill (components/task-status-menu.tsx) shows a later <date> state in the meantime.

Three ways to set it: quick presets on the Status pill menu (Tomorrow / Next week / Weekend — plain wall-time floors), a persistent "Start" field in the task detail panel (components/task-detail-panel.tsx, mirrors the Deadline field exactly), and "Next available slot", which sets the floor to the end of the task's current scheduled block via the new deferTaskToNextSlot action (app/tasks/actions.ts).

That third preset exists because of a real bug this investigation surfaced: the reshuffler (lib/scheduler/reshuffler.ts) wipes and re-places every flexible task fresh each pass, deterministically, deadline-ordered. The existing "Find next slot" button (rescheduleBlock, app/calendar/actions.ts) just clears user_adjusted and waits for the next sweep — if nothing else about the schedule changed, the deterministic algorithm puts the task right back in the identical slot it just left. Fixed at the source: rescheduleBlock now also floors the task's deferred_until at the block's current end, so both it and the new "Next available slot" preset are guaranteed to move forward, not just churn.

Options considered. (A) Overload status = 'inbox' with an implicit resume date stored elsewhere — rejected: conflates with Unschedule's explicit "stay parked" contract from D54 and needs the same holding-pen bucket to mean two different things. (B) A new 'deferred' status with a cron to flip it back to 'scheduled' — rejected: an explicit state machine and a background flip for something a live now comparison already answers for free. (C) Leave "Find next slot" as-is and only fix it for the new "Next available slot" preset — rejected: identical root cause, identical fix; leaving the old button broken while shipping a new one that works right next to it is confusing for no savings.

Tradeoffs. ✅ The deadline and its placement priority are untouched — deferring changes when a task may start, not how urgently it's treated once eligible. ✅ Resuming is automatic; no manual re-schedule step, unlike Unschedule. ✅ One mechanism (a per-task floor the placer already has to check) serves fixed-date presets, custom dates, and "move forward" all at once. ✅ Fixes a genuine standing bug in "Find next slot" as a side effect. ⚠️ A past deferred_until is never cleared back to null — harmless (it's a permanent no-op once passed) but the column can carry stale values indefinitely. ⚠️ "Weekend" against showWeekends = false just means the real next slot is Monday — correct, if slightly surprising.

Reversibility. One nullable column, one clipping step in the placer's slot loop, one extra floor-set in rescheduleBlock. Dropping the column and the clipping step fully reverts; the Status pill and detail panel changes are additive UI.

D57·2026-08-20

External API accepts both snake_case and camelCase field names

Context. Dogfooding the external API (D28) with a curl script surfaced a silent bug: POST /api/tasks only reads projectId/estimatedMinutes (lib/api/parse.ts), so a caller sending project_id/estimated_minutes — the natural shape for a shell script — gets a 200, a task in the Inbox, and a 60-minute default, with no signal anything was dropped. PATCH /api/tasks/:id has the mirror problem: snake_case-only bodies hit "no updatable fields supplied" (400), since parseTaskPatch never recognizes them either.

Decision. lib/api/parse.ts gains an aliased(rec, camelKey, snakeKey) helper that reads either casing, camelCase winning if both are present. Applied to the two ambiguous fields on both parseTaskInput (create) and parseTaskPatch (update): projectId/project_id and estimatedMinutes/estimated_minutes, plus deferredUntil/deferred_until on the patch. The OpenAPI schema (lib/api/openapi.ts) keeps documenting camelCase as canonical — the alias is accepted leniency for callers, not a second official contract.

Options considered. (A) Honour project_id/estimated_minutes on create only — rejected: leaves the PATCH 400 in place, so a snake_case caller still has to know to switch casing to update a task. (B) Reject unknown/ignored fields with 400 instead of silently dropping them — this would have surfaced the bug immediately, but it's a stricter contract change (any client sending an extra/misspelled field starts failing) than the bug report actually calls for; parked as a follow-up if a second silent-drop bug shows up. (C) Standardise on one casing and break the other — rejected: no way to do this without a breaking change for whichever casing loses, and the whole point is external callers use either.

Tradeoffs. ✅ Fixes both the silent-drop-on-create and the false-400-on-patch with one small helper touching two call sites. ✅ No API contract break — existing camelCase callers see zero change. ⚠️ A field typo (e.g. project_di) still fails silently on create, same as before — option (B) above is the fix if that becomes a real problem.

Reversibility. One helper function, four call sites reading through it instead of the raw camelCase key. Reverting is deleting the helper and the four aliased(...) calls.