291 lines
21 KiB
Markdown
291 lines
21 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```scss
|
||
// _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`):
|
||
```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 (only `pages.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 `requestAnimationFrame` → `requestAnimationFrame` 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".
|
||
|
||
### Block-edit carousel (modal/block-edit.component)
|
||
|
||
- 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
|
||
|
||
### Carousel card date format
|
||
|
||
```ts
|
||
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/`
|