life-towers/CLAUDE.md

21 KiB
Raw Blame History

Life Towers — agent notes

This file captures the load-bearing things to know about this codebase. Read it before making non-trivial changes.

What this is

A personal-productivity TODO app where each user has multiple pages, each page holds several towers (vertical task columns), and each tower has blocks (atomic tasks). Pending blocks live in a tasks accordion at the top of the tower; completed ones fall into the tower as colored squares (the "falling animation" is the defining visual). Date-range slider filters which done blocks are visible.

This is a port/modernisation of a legacy Angular 7 app. Design parity with the legacy is a hard requirement — the user cares deeply about the original aesthetic. The legacy source lives at _legacy_reference/ (gitignored) and is the source of truth for any visual question. A detailed style guide is at docs/DESIGN.md.

Repo layout

backend/                 # FastAPI + SQLite (WAL)
  src/life_towers/
    main.py              # ASGI app, lifespan, static mount + SPA fallback
    api.py               # Routes (/api/v1/{health,register,data})
    auth.py              # Bearer UUIDv4 → user_id
    db.py                # sqlite3 factory + migration runner
    limits.py            # slowapi limiter + payload-size middleware
    models.py            # pydantic v2 schemas
    logging.py           # structlog JSON
    migrations/          # 001_initial.sql consolidated schema
  tests/test_api.py      # pytest + httpx AsyncClient
  pyproject.toml         # uv-managed
frontend/                # Angular 21+ standalone, signals, zoneless
  src/app/
    components/
      pages/             # Top-level: page selector + Settings button
      page/              # Tower row + slider + trash zone + confirm-delete
      tower/             # White tower card: tasks accordion + add-block + falling stack + name input
      block/             # Colored square (1/6 tower width)
      tasks/             # Pending-blocks accordion with tickbox
      welcome/           # Zero-state intro modal + "Try an example"
      modal/             # Generic backdrop shell + sub-modals (block-edit carousel, settings, tower-settings)
      shared/            # select-add, toggle, double-slider, color-picker, icon
    services/
      api.service.ts     # HttpClient wrapper, exact API contract
      store.service.ts   # signal-based store, debounced PUT, retry, loadExample
      modal-state.service.ts  # global open-modal counter (drag locking)
    models/index.ts      # Page / Tower / Block / HslColor TS interfaces
    utils/{color,hash}.ts
  library/               # Legacy SCSS dropped in verbatim — DO NOT REFACTOR
  public/assets/         # SVG icons (arrow, pen, plus-sign, trash, x-sign)
  src/assets/fonts/      # Self-hosted woff2 (Open Sans Condensed, Raleway, …)
  e2e/                   # Playwright (smoke + visuals)
docs/
  api-spec.md            # Single source of truth for the HTTP API contract
  DESIGN.md              # Legacy visual spec (verbatim SCSS quotes)
_legacy_reference/       # Original Angular 7 source — read-only, gitignored
Dockerfile               # Multi-stage: node:22-alpine build → python:3.13-slim runtime
docker-compose.yml       # Production single-container
docker-compose.dev.yml   # Ephemeral volume for Playwright runs
.forgejo/workflows/      # CI + deploy

Tech stack quickrefs

  • Frontend: Angular 21+, standalone components (no NgModule), signal() / computed() / effect() / linkedSignal(), zoneless change detection (provideZonelessChangeDetection), @if/@for control flow, OnPush everywhere, esbuild builder (@angular/build:application), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap)
  • Backend: FastAPI, pydantic v2, slowapi (rate limiting), structlog, sqlite3 with WAL + foreign_keys ON every connection, uv-managed deps
  • Runtime topology: single Docker container — FastAPI process serves both /api/v1/* JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with --proxy-headers --forwarded-allow-ips=*
  • Storage: SQLite at /data/life-towers.db on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside BEGIN IMMEDIATE)

Build / dev / test / deploy

# Full local stack (ephemeral data)
docker compose -f docker-compose.dev.yml up --build -d        # http://localhost:8000
docker compose -f docker-compose.dev.yml down -v              # teardown

# Frontend dev (with backend proxied via proxy.conf.json)
cd frontend && npm start                                       # ng serve on :4200, /api → :8000
cd frontend && npm run build                                   # production bundle to dist/frontend/browser/
cd frontend && npm test                                        # vitest (--run already in the script)
cd frontend && npm run test:e2e                                # Playwright

# Backend dev
cd backend && uv sync
cd backend && uv run pytest -v
cd backend && uv run uvicorn life_towers.main:app --reload     # :8000

# E2E in the sandbox: host-to-container port forwarding is broken here.
# Run Playwright INSIDE a container on the docker network instead:
docker run --rm \
  --network life-towers_default \
  -v "$(pwd)/frontend:/work" -w /work \
  -e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
  mcr.microsoft.com/playwright:v1.60.0-noble \
  npx playwright test

Design system — the legacy is the source of truth

docs/DESIGN.md is the comprehensive spec. Key tokens:

// _legacy_reference/frontend/src/library/common-variables.scss
$accent-color: #a2666f;          // rose
$text-color:   #5d576b;          // muted purple-grey (also iOS theme-color)
$light-color:  #ffffff;
$background-gradient:        linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%);  // 50% alpha — modal backdrop
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%);      // body background
$shadow:        0 0 1.5px 1.5px rgba(0,0,0,0.1), 0 0 3px 2px rgba(0,0,0,0.05);
$shadow-border: 0 0 0 0.75px rgba(0,0,0,0.1);                                      // hairline
$normal-font: 'Open Sans Condensed', sans-serif;
$title-font:  'Raleway', serif;
$mobile-width: 520px;

Spacing tokens (library/main.scss):

:root {
  --large-padding: 30px;   // 20px on mobile
  --medium-padding: 15px;
  --small-padding: 10px;   // 7.5px on mobile
  --border-radius: 5px;    // 3px on mobile
}

Font sizes (library/text.scss): --larger/large/medium/small-font-size = 22/18/16/11 desktop, 20/16/14/10 mobile.

Animation timings:

  • $long-animation-time: 200ms — opacity, hover transforms, modal entry
  • $short-animation-time: 100ms — tighter transitions (red trash-highlight overlay)
  • Falling animation: transform 1.5s cubic-bezier(0.5, 0, 1, 0) (gravity ease-in)
  • Modal opacity entry/exit: 300ms

Architectural conventions to follow

Components

  • Every component is standalone, OnPush, with signal()-based local state. No NgModule.
  • Templates use @if / @for / @switch — never *ngIf/*ngFor.
  • Inline templates + inline styles in @Component({ template: \...`, styles: `...` })is the norm. Larger components use templateUrl + styleUrl (onlypages.component, page.component`).
  • SCSS inside styles: template literal — // comments are fine inside SCSS but watch for backticks; // display: contents`` inside a template literal closes it early. Use /* */ if you need to mention CSS strings in comments.
  • Library files (library/*.scss) are dropped from the legacy verbatim. Don't refactor them. Components @import '../../../library/main' (or similar relative depth).

State

  • StoreService is the single source of truth (signal-based).
  • Mutations update the signal immediately (optimistic) and call scheduleSave() which debounces 750ms and PUTs the full tree.
  • Failure mode: exponential backoff up to 5 attempts (1s, 2s, 4s, 8s, 16s).
  • LocalStorage cache key: life-towers.cache.v4. Token: life-towers.token.v4.
  • init() flow: stored token? Use it. No token? Mint via uuidV4() → register → GET data. On 401: re-register the SAME token (idempotent server-side), never silently mint a fresh one (would orphan data).

Reactivity caveats with zoneless

  • Plain field mutations in event handlers ((click)="x = true") still trigger CD because Angular's event manager marks the view dirty — even with zoneless. But any async mutation outside an Angular event (setTimeout, raw addEventListener, MutationObserver) will not trigger CD; use signals there.
  • effect() running on signal() reads triggers re-runs; wrap writes in untracked() to avoid loops.
  • For input-driven derived state that the user can also override (e.g. tasks.expanded seeded from initiallyOpen but also clickable), use either linkedSignal or effect() + a flag (the codebase uses the latter for tasks.component).

Sync model

  • Tree-replace: PUT /api/v1/data sends the entire user hierarchy atomically. Backend wraps in BEGIN IMMEDIATE, deletes existing pages (cascading to towers + blocks via FK), inserts new rows. position columns track ordering.
  • No granular endpoints for individual entity CRUD. Keep it tree-replace.
  • Spec is in docs/api-spec.md. Backend pydantic models match it. Frontend models/index.ts matches it. Field names are snake_case on both sides.

Backend

  • All endpoints are inside APIRouter(prefix="/api/v1"). Spec drives behavior — if you change a limit, update both spec and code.
  • Migrations: package data under src/life_towers/migrations/, loaded via importlib.resources.files("life_towers").joinpath("migrations"). The runner tracks applied state in a schema_migrations(filename TEXT PRIMARY KEY, applied_at INTEGER) table.
  • All sqlite connections must do PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000. The get_connection() factory does this.
  • Errors → JSON {"error": code, "detail": str} via a single HTTPException handler in main.py. Stack traces never leak (logged server-side).
  • Rate limits via slowapi: /register 30/hour/IP, GET /data 60/min/token, PUT /data 30/min/token.

Visual + interaction details that bit me

Falling animation (tower.component)

The .block-container has transform: scaleY(-1) so blocks visually fall from the TOP into the bottom of the tower. Each block default-positions at translateY(500%) via a * rule; the inline [style.transform]="b._transform" binding overrides per-block.

When exactly one new done block is added, the reconcile() method:

  1. Sets the new block's _transform: 'translateY(500%)' and _opacity: '0' (off-screen)
  2. Calls requestAnimationFramerequestAnimationFrame to let the browser paint the initial state
  3. Sets _anim: 'descend', _transform: 'translateY(0)', _opacity: '1' — the CSS transition fires

Critical: grewByOne detection is position-independent (set-difference). When a tickbox flips a pending block to done, the new entry inserts at its original tower.blocks index, not appended. Use the new ID, not styled[length-1].

The date range slider asymmetry: blocks below range.from are removed from visibleBlocks entirely (instant shuffle, no gap), blocks above range.to get _anim: 'ascend' and stay in the list flying up. prevDoneIds tracks the full allDone array — not the filtered styled list — so range expansions don't mis-fire as "new block".

  • Lives at position: fixed; z-index: 10001 to escape the modal dialog wrapper and cover the viewport
  • Two placeholder cards flank the real cards so the active card can fully center via scroll-snap-align: center
  • .mask overlay on non-active cards has three tiers: active opacity 0, near-active 0.55, default 1. Card opacity also tiered (1 / 0.85 / 0.6) mimicking the legacy 1.33*(1-t/2) curve
  • Backdrop click (anywhere not a non-placeholder card) closes the modal
  • Delete on an existing card does not close the modal — it stays open and the card re-renders out of the list
  • Auto-save on tag/toggle change; description deferred to blur

Select-add (shared/select-add)

  • Has a .top chip and a .bottom slide-down panel. :has(.bottom.open) .top, .background { border-radius: var(--border-radius) var(--border-radius) 0 0 } squares the bottom corners when open so the chip and panel read as one card
  • Shadow seam between chip + panel solved with clip-path: inset(...): .background.active clips bottom (inset(-6px -6px 0 -6px)), .bottom.open clips top (inset(0 -6px -6px -6px)). 6px > the total $shadow spread (5px) so neither edge bleeds across the seam
  • Closing animation requires a two-transition setup: default state has transition: ... visibility 0s $long-animation-time (visibility delays on close), .open overrides with visibility 0s 0s (instant on open)

Modal shell (modal/modal.component)

  • :host { display: contents } — critical. Without this, the lt-modal host element takes a flex slot in pages.component's inner-spacing layout, pushing the Settings button up when the modal mounts
  • section.modal is position: fixed; z-index: 10000 with transition: opacity 300ms. The component flips active = true in ngAfterViewInit via setTimeout(0) so the opacity 0 → 1 transition runs
  • ModalStateService.open() / .close() are called in AfterViewInit/OnDestroy. page.component reads modalState.anyOpen and binds it to every tower's [cdkDragDisabled] so users can't drag towers behind an open modal
formatDate(ts: number): string {
  return new Date(ts * 1000).toLocaleString(undefined, {
    year: 'numeric', month: 'short', day: 'numeric',
    hour: '2-digit', minute: '2-digit',
  });  // "May 28, 2026, 14:32"
}

Double-slider relative-time labels

page.component.ts formats with Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }). Buckets: <45s second, <45min minute, <22h hour, <26d day, <320d month, else year. Labels are deduped by string (multiple distinct timestamps that round to the same "5 hr ago" appear once).

When values.length grows (new block added), the slider snaps the higher of oneValue/otherValue to MAX - 1 so the newest entry is always visible; the lower thumb (the user's left edge) stays put.

Color picker (shared/color-picker)

  • Row of 12 preset color swatches + a rainbow hue slider + a big preview swatch
  • Saturation and lightness are FIXED at 0.7 / 0.55. Only hue varies
  • Preset hues [0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355] — skips the green/yellow zone (60°180°) which muddies with the rose accent palette

Tickbox (tasks/tasks.component)

  • glyph is hidden at rest (opacity: 0) and only revealed on interaction: 0.85 (hover/focus-visible), 1 (active). It fades via the opacity transition
  • The tickbox is a <button>, so the global animated-underline bar from forms.scss (button:after { content:''; height: 2px; width: 0→100% on hover; background-color: $text-color }) applies to it. all: unset strips the button's own styling but does NOT reach the pseudo-element — so .tickbox::after MUST re-assert width: 100%; height: 100%; background: none, otherwise on hover a dark $text-color bar paints across the top AND the box collapses to 2px (pinned at top:0), which centers the glyph near the top
  • font: bold 18px/1 $normal-font is re-asserted on ::after because all: unset drops the font to serif (Times New Roman)
  • transform: translateY(1px) nudges the to optical centre (it sits a touch high in its em-box); :active must re-state the translateY or the glyph jumps when pressed
  • .all-task is overflow: hidden (NOT a scroller) and animates to #all.scrollHeight; tall lists scroll in the outer .container (overflow-y: auto; max-height: 30vh). Making .all-task itself overflow-y: auto pops a scrollbar the moment the tickbox scale(1.05)s on hover (transforms widen the scrollable-overflow box)

Mobile responsive

  • $mobile-width: 520px is the single breakpoint
  • Tower row: on mobile, width: calc(66vw - var(--medium-padding)) !important per tower with overflow-x: auto + scroll-snap-type: x mandatory. About 1.5 columns visible by default
  • Carousel cards: width: 85vw, placeholders 7.5vw, carousel padding: 0 7.5vw → snap-center lines up perfectly
  • Modal cards (settings/tower-settings/welcome/confirm-delete): width: 88vw; padding: var(--medium-padding) on mobile. Confirm-buttons stack vertically with full width
  • Block hover effect (gravitate) gated behind @media (hover: hover) and (pointer: fine) so touch devices don't get stuck scale-up
  • Viewport meta blocks pinch-zoom: <meta name="viewport" content="..., maximum-scale=1.0, user-scalable=no" />
  • scrollbar-gutter: stable + overflow-y: scroll on html so modal opens don't shift content sideways

Tower drag-drop

  • CDK drag-drop on .towers cdkDropList[orientation=horizontal], each tower is cdkDrag
  • Trash zone: <img class="trash"> is OUTSIDE .towers (anchored to page.component :host at bottom: 8px; left: 50%). The legacy structure — moving it inside .towers breaks because it becomes a flex item
  • Trash-highlight: pointerenter on trash → direct DOM document.querySelector('.cdk-drag-preview').classList.add('trash-highlight'). Matches legacy approach
  • Drop over trash → opens a confirm modal (no immediate delete)

Welcome modal + example data

  • Shows when !store.loading() && store.pages().length === 0. Auto-dismisses when pages().length > 0
  • "Try an example" calls store.loadExample() which creates a "Hobbies" page with three towers (Reading, Side projects, Exercise) at varied created_at ages so the slider has interesting labels

Frontend sharp edges

  • crypto.randomUUID() requires a secure context (HTTPS or localhost). On a plain-HTTP origin behind nginx, it throws and init() rejects, leaving loading = true forever. Always fall back to crypto.getRandomValues (uuidV4() helper in store.service.ts)
  • Angular 17+ deprecated fileReplacements for env-per-build. Don't use environment.ts — use relative API paths everywhere + proxy.conf.json for ng serve
  • Angular 19+ application builder copies public/, NOT src/assets/. SVG icons must live at frontend/public/assets/ to be served at /assets/foo.svg. Fonts referenced via url() in styles.scss go through the CSS asset pipeline and emit to /media/
  • Backticks inside styles: template literal close the string early — break TS parsing
  • Async iframe-like spawning of agents in this sandbox (host port forwarding) doesn't work — run e2e via Playwright Docker image on the same life-towers_default network

Backend sharp edges

  • The 256 KiB payload cap is enforced by middleware reading Content-Length. Chunked encoding bypasses the check. Defense-in-depth would also stream request.stream()
  • 10 MiB per-user quota is checked against the request body, NOT the existing user's stored total. With the 256 KiB request cap, the 10 MiB check is effectively unreachable. Documented spec gap
  • The migrations directory was moved to src/life_towers/migrations/ (inside the package) to ship as importlib.resources data — don't recreate backend/migrations/

Visual e2e

  • frontend/e2e/visuals.spec.ts is the source-of-truth for "what should this look like." It captures ~15 screenshots into frontend/visuals/ (gitignored)
  • Add a screenshot every time we land a visual change. Don't merge if it broke the visuals run
  • Mobile screenshots use a separate test that spawns its own browser.newContext({ viewport: { width: 390, height: 844 } }) — that's the iPhone 14 Pro viewport

Conventions to enforce on changes

  • Never use the legacy frontend-legacy/ or backend-legacy/ paths — those folders are GONE. _legacy_reference/ is the reference, gitignored
  • Never refactor library/*.scss — those files are dropped verbatim from the legacy and downstream components depend on their exports
  • Never change the API contract in docs/api-spec.md without updating both pydantic models, api.py, frontend models/index.ts, and the backend tests in tests/test_api.py
  • Never put modal-related elements as direct flex children of a layout container without :host { display: contents } on the modal — they'll add invisible flex slots
  • Always run npm run build after a frontend change to catch TS/template errors that don't surface in the IDE
  • Always run the visuals test after a visual change. Pull the screenshots, look at them, compare to the legacy

Where to look next

  • For the API contract: docs/api-spec.md
  • For visual design questions: docs/DESIGN.md + _legacy_reference/frontend/src/library/
  • For the sync flow: frontend/src/app/services/store.service.ts (the init(), flush(), scheduleSave() chain)
  • For the falling animation: frontend/src/app/components/tower/tower.component.ts:reconcile()
  • For drag-drop + trash: frontend/src/app/components/page/page.component.{ts,html,scss}
  • For deploy: Dockerfile, docker-compose.yml, .forgejo/workflows/