Compare commits

..

39 commits

Author SHA1 Message Date
bf03648bef Fix navigation-no-fall e2e on CI: generate UUIDs in Node
All checks were successful
Docker / build-and-push (push) Successful in 37s
CI / Backend tests (push) Successful in 25s
CI / Frontend lint (push) Successful in 23s
CI / Frontend unit tests (push) Successful in 20s
CI / Frontend build (push) Successful in 37s
CI / Playwright e2e (push) Successful in 1m6s
The init script called crypto.randomUUID() inside the page, but the CI
origin (http://life-towers:8000) is plain HTTP — not a secure context —
so the call throws, the token/cache never get seeded, and the app boots
empty: waitForSelector('lt-block') times out on every attempt.

Generate the token and seeded tree in Node instead (same pattern as
tasks-overflow.spec.ts) and pass them into addInitScript, which now only
writes localStorage and installs the fall detectors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:32:33 +01:00
66da22d0c4 Fix visual bugs
Some checks failed
CI / Frontend lint (push) Successful in 21s
CI / Backend tests (push) Successful in 23s
CI / Frontend unit tests (push) Successful in 21s
CI / Frontend build (push) Successful in 20s
Docker / build-and-push (push) Successful in 51s
CI / Playwright e2e (push) Failing after 2m0s
2026-06-10 21:51:25 +01:00
29de505fe2 Improve UX & fix bugs
All checks were successful
CI / Backend tests (push) Successful in 27s
CI / Frontend lint (push) Successful in 32s
Docker / build-and-push (push) Successful in 40s
CI / Frontend unit tests (push) Successful in 25s
CI / Frontend build (push) Successful in 25s
CI / Playwright e2e (push) Successful in 1m27s
2026-06-09 08:12:48 +01:00
9a2c8a9483 Merge pull request 'Adopt shared ci-actions for publishing' (#2) from ci/shared-actions into master
All checks were successful
Docker / build-and-push (push) Successful in 42s
CI / Backend tests (push) Successful in 20s
CI / Frontend lint (push) Successful in 19s
CI / Frontend unit tests (push) Successful in 18s
CI / Frontend build (push) Successful in 21s
CI / Playwright e2e (push) Successful in 1m2s
Reviewed-on: https://home.schmelczer.dev/git/git/andras/life-towers/pulls/2
2026-06-06 15:18:17 +01:00
30a33a69bb Adopt shared ci-actions for publishing
All checks were successful
CI / Backend tests (pull_request) Successful in 17s
CI / Frontend lint (pull_request) Successful in 19s
CI / Frontend unit tests (pull_request) Successful in 17s
CI / Frontend build (pull_request) Successful in 20s
CI / Playwright e2e (pull_request) Successful in 53s
Replace the inline publish step(s) with the canonical shell-only composite
actions in andras/ci-actions (deploy-pages / docker-publish / forgejo-release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:58:37 +01:00
921eeb624e rename steps
All checks were successful
CI / Frontend lint (push) Successful in 26s
CI / Backend tests (push) Successful in 26s
CI / Frontend unit tests (push) Successful in 21s
Docker / build-and-push (push) Successful in 50s
CI / Frontend build (push) Successful in 24s
CI / Playwright e2e (push) Successful in 45s
2026-06-05 21:11:36 +01:00
948b49bb49 Fix scrollbars and enter to submit
All checks were successful
CI / Frontend lint (push) Successful in 24s
CI / Frontend unit tests (push) Successful in 24s
CI / Backend tests (push) Successful in 25s
CI / Frontend build (push) Successful in 24s
Docker / build-and-push (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 51s
2026-06-05 11:37:54 +01:00
688bc0cfe9 Add SSE updates 2026-06-04 22:12:10 +01:00
d1732128e2 Review
All checks were successful
CI / Backend tests (push) Successful in 21s
CI / Frontend lint (push) Successful in 34s
CI / Frontend build (push) Successful in 24s
CI / Frontend unit tests (push) Successful in 46s
Docker / build-and-push (push) Successful in 1m17s
CI / Playwright e2e (push) Successful in 1m5s
2026-06-03 20:39:32 +01:00
bf81b8d3df Review feedback 2026-06-03 20:05:16 +01:00
e00300de6c Fix x sign
All checks were successful
CI / Backend tests (push) Successful in 34s
CI / Frontend lint (push) Successful in 30s
CI / Frontend unit tests (push) Successful in 34s
CI / Frontend build (push) Successful in 34s
Docker / build-and-push (push) Successful in 1m40s
CI / Playwright e2e (push) Successful in 1m10s
2026-05-31 22:35:31 +01:00
a48aad974a Support subpath
All checks were successful
CI / Backend tests (push) Successful in 30s
CI / Frontend lint (push) Successful in 33s
CI / Frontend build (push) Successful in 25s
CI / Frontend unit tests (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 1m43s
Docker / build-and-push (push) Successful in 2m30s
2026-05-31 20:27:41 +01:00
006ae81c3b Fix publish
All checks were successful
CI / Backend tests (push) Successful in 19s
CI / Frontend build (push) Successful in 22s
CI / Frontend lint (push) Successful in 47s
CI / Frontend unit tests (push) Successful in 46s
CI / Playwright e2e (push) Successful in 59s
Docker / build-and-push (push) Successful in 1m53s
2026-05-31 18:31:22 +01:00
bec981f601 ?
Some checks failed
CI / Backend tests (pull_request) Successful in 39s
CI / Frontend lint (pull_request) Successful in 30s
CI / Frontend unit tests (pull_request) Successful in 52s
CI / Frontend build (pull_request) Successful in 29s
CI / Playwright e2e (pull_request) Successful in 1m42s
CI / Backend tests (push) Successful in 18s
CI / Frontend unit tests (push) Successful in 33s
CI / Frontend build (push) Successful in 24s
CI / Frontend lint (push) Successful in 51s
CI / Playwright e2e (push) Successful in 1m4s
Docker / build-and-push (push) Failing after 3m52s
2026-05-31 15:04:42 +01:00
e94ea62ee1 Fix again
Some checks failed
CI / Backend tests (pull_request) Successful in 22s
CI / Frontend unit tests (pull_request) Successful in 1m11s
CI / Frontend lint (pull_request) Successful in 1m12s
CI / Frontend build (pull_request) Successful in 1m3s
CI / Playwright e2e (pull_request) Failing after 57s
2026-05-31 14:30:25 +01:00
18f6d3a173 Install docker
Some checks failed
CI / Backend tests (pull_request) Successful in 23s
CI / Frontend build (pull_request) Successful in 32s
CI / Frontend unit tests (pull_request) Successful in 1m5s
CI / Frontend lint (pull_request) Successful in 1m11s
CI / Playwright e2e (pull_request) Failing after 1m0s
2026-05-31 13:50:07 +01:00
b7f460bb68 Fix
Some checks failed
CI / Backend tests (pull_request) Successful in 24s
CI / Frontend unit tests (pull_request) Successful in 36s
CI / Frontend lint (pull_request) Successful in 37s
CI / Frontend build (pull_request) Successful in 28s
CI / Playwright e2e (pull_request) Failing after 10s
2026-05-31 12:51:55 +01:00
dc10b3323a Fix
Some checks failed
CI / Frontend unit tests (pull_request) Successful in 38s
CI / Frontend lint (pull_request) Successful in 50s
CI / Backend tests (pull_request) Failing after 53s
CI / Frontend build (pull_request) Successful in 35s
CI / Playwright e2e (pull_request) Has been skipped
2026-05-31 11:52:33 +01:00
f6696a6553 Different runner
Some checks failed
CI / Frontend unit tests (pull_request) Successful in 37s
CI / Backend tests (pull_request) Failing after 36s
CI / Frontend lint (pull_request) Successful in 43s
CI / Frontend build (pull_request) Successful in 27s
CI / Playwright e2e (pull_request) Has been skipped
2026-05-31 11:07:54 +01:00
40e2c478fb test(e2e): update smoke and visual specs
Some checks failed
CI / Backend tests (pull_request) Has been cancelled
CI / Frontend lint (pull_request) Has been cancelled
CI / Frontend unit tests (pull_request) Has been cancelled
CI / Frontend build (pull_request) Has been cancelled
CI / Playwright e2e (pull_request) Has been cancelled
2026-05-31 10:52:26 +01:00
4fd9e6f6bc feat(tasks): add keep-tasks-open toggle 2026-05-31 10:49:26 +01:00
c2c2598eab frontend(tower): rework falling/range reconcile and block rendering, add tests 2026-05-31 10:49:26 +01:00
8390ece334 frontend(page): update page layout, slider and page selector 2026-05-31 10:49:26 +01:00
108cfb9a19 frontend(welcome): revamp the zero-state intro modal 2026-05-31 10:49:26 +01:00
bcca7f4f2e frontend(block-edit): rework the block edit carousel and add tests 2026-05-31 10:49:26 +01:00
e3dcf75eb5 frontend(modals): consolidate settings modals and drop page-settings 2026-05-31 10:49:26 +01:00
9b8bb96001 frontend(shared): polish color-picker, double-slider and select-add widgets 2026-05-31 10:49:26 +01:00
85d565ba7b frontend(store): rework store/sync and API client, add service tests 2026-05-31 10:49:26 +01:00
d50aa53a73 frontend(styles): extract shared form styles into the SCSS library 2026-05-31 10:49:26 +01:00
af4216f383 frontend: move app shell into standalone root and wire analytics service 2026-05-31 10:49:26 +01:00
ce5f8995f7 branding: add logo/favicon/icon set and OG image, remove legacy SVGs and fonts 2026-05-31 10:49:26 +01:00
d9724a462d backend: tidy modules, consolidate schema migrations, expand API tests 2026-05-31 10:49:26 +01:00
4156d1d469 chore(backend): update Python dependencies 2026-05-31 10:49:26 +01:00
757cae5dcf build(frontend): add Vitest setup and update Angular/TypeScript config 2026-05-31 10:49:26 +01:00
5a364ce638 ci: replace Forgejo deploy workflow with container build pipeline 2026-05-31 10:49:26 +01:00
afce46ccf8 docs: add CLAUDE.md agent guide, drop legacy design/API specs, refresh READMEs 2026-05-31 10:49:26 +01:00
f74ee43cb4 snapshot 2026-05-28 21:24:47 +01:00
3ad2766f82 Merge with store 2026-05-28 08:42:34 +01:00
706fe745d3
Merge with local folder 2022-09-16 21:45:27 +02:00
215 changed files with 23634 additions and 10833 deletions

35
.dockerignore Normal file
View file

@ -0,0 +1,35 @@
# Version control
.git
.forgejo
# Data volume (never bake in runtime data)
data/
# Test artefacts (Playwright)
**/test-results/
**/playwright-report/
**/visuals/
# Build artifacts and caches
**/node_modules/
**/dist/
**/.angular/
**/__pycache__/
**/.pytest_cache/
**/.venv/
# Docs (the API spec is the runtime contract — but the image doesn't need it)
docs/
*.md
README.md
# Secrets — never bake into images
.env
.env.*
*.pem
*.key
*.crt
secrets/
# OS artefacts
.DS_Store

View file

@ -0,0 +1,25 @@
name: Docker
on:
push:
branches: [master]
tags: ["v*"]
workflow_dispatch:
jobs:
build-and-push:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and publish image
uses: http://forgejo:3000/andras/ci-actions/docker-publish@main
with:
context: .
# The published image is deployed at https://schmelczer.dev/towers/,
# so the SPA is built with that sub-path baked into <base href> and
# the service-worker manifest. Change this if the deploy path changes.
build-args: |
BASE_HREF=/towers/
token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}

190
.forgejo/workflows/test.yml Normal file
View file

@ -0,0 +1,190 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
backend:
name: Backend tests
runs-on: docker
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Set up Python
run: uv python install 3.13
- name: Sync dependencies
run: uv sync
- name: Run pytest
run: uv run pytest -v
frontend-lint:
name: Frontend lint
runs-on: docker
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
frontend-test:
name: Frontend unit tests
runs-on: docker
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run Vitest
run: npm test
frontend-build:
name: Frontend build
runs-on: docker
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build production bundle
run: npm run build
e2e:
name: Playwright e2e
runs-on: docker
needs: [backend, frontend-build]
steps:
- uses: actions/checkout@v4
- name: Ensure Docker CLI
run: |
set -eux
if ! command -v docker >/dev/null 2>&1; then
DOCKER_VERSION=27.5.1
curl -fsSL "https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_VERSION}.tgz" \
| tar -xz -C /usr/local/bin --strip-components=1 docker/docker
fi
if ! docker compose version >/dev/null 2>&1; then
COMPOSE_VERSION=v2.32.4
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
fi
docker --version
docker compose version
- name: Start stack
run: docker compose -p life-towers -f docker-compose.dev.yml up --build -d
- name: Wait for /api/v1/health
run: |
set -e
cid=$(docker compose -p life-towers -f docker-compose.dev.yml ps -q life-towers)
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting)
if [ "$status" = healthy ]; then
echo "stack healthy after ${i} attempts"
exit 0
fi
sleep 2
done
echo "stack failed to become healthy" >&2
docker compose -p life-towers -f docker-compose.dev.yml logs >&2
exit 1
- name: Run Playwright
run: |
set -eux
# Sibling-container (Docker-out-of-Docker) setup: the host daemon can't
# see this job's filesystem, so `-v "$(pwd)/frontend:/work"` would mount
# an empty dir. Instead create the container, copy the source IN, run,
# then copy artifacts back OUT — all via the CLI, which reads/writes the
# job container's filesystem.
cid=$(docker create \
--network life-towers_default \
-e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
-e CI=true \
mcr.microsoft.com/playwright:v1.60.0-noble \
sh -c 'cd /work && npm ci && npx playwright test')
# /work does not exist yet, so cp creates it from frontend's contents.
docker cp ./frontend "$cid":/work
# Run, but always copy artifacts back even when tests fail.
set +e
docker start -a "$cid"
code=$?
set -e
docker cp "$cid":/work/playwright-report ./frontend/ || true
docker cp "$cid":/work/visuals ./frontend/ || true
docker rm -f "$cid" >/dev/null 2>&1 || true
exit "$code"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: frontend/playwright-report
if-no-files-found: ignore
retention-days: 14
- name: Upload visual screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-visuals
path: frontend/visuals
if-no-files-found: ignore
retention-days: 14
- name: Dump container logs on failure
if: failure()
run: docker compose -p life-towers -f docker-compose.dev.yml logs
- name: Tear down stack
if: always()
run: docker compose -p life-towers -f docker-compose.dev.yml down -v

47
.gitignore vendored
View file

@ -1,46 +1 @@
# See http://help.github.com/ignore-files/ for more about ignoring files. **/__pycache__
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events.json
speed-measure-plugin.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

View file

@ -1,8 +0,0 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true
}

319
CLAUDE.md Normal file
View file

@ -0,0 +1,319 @@
# 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=*`. **Deployed under a sub-path** (`https://schmelczer.dev/towers/`) — see "Sub-path deployment" below
- **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`)
## Sub-path deployment
The app is deployed under a path: `https://schmelczer.dev/towers/`. The mechanism, and the one rule that keeps it working:
- **SPA**: built with `ng build --base-href /towers/` (wired via the `BASE_HREF` Docker build arg; default `/`, set to `/towers/` in `.forgejo/workflows/docker.yml`). This stamps `<base href="/towers/">` into `index.html` and prefixes every URL in the service-worker manifest (`ngsw.json`). Asset and script `src`s stay relative, so they resolve against `<base>`.
- **API calls are relative** (`api/v1/...`, no leading slash — see `api.service.ts`) so they resolve against `<base>`: `/towers/api/v1/...` in prod, `/api/v1/...` at the root for dev/e2e. **Never reintroduce a leading slash** — it pins calls to the origin root and breaks the sub-path deploy.
- **Backend is path-agnostic** — it still serves the API at `/api/v1/*` and the SPA at the container root. **nginx strips the prefix** before proxying, so the backend never sees `/towers`. Because of that strip, the backend can't infer its public URL, so `LIFE_TOWERS_PUBLIC_URL` (set in `docker-compose.yml`) is what makes the server-rendered canonical / OG / Twitter tags correct (`main.py:_absolute_meta_urls`).
- **Required nginx** (the trailing slash on `proxy_pass` is what strips `/towers/`):
```nginx
location = /towers { return 308 /towers/; } # add the trailing slash
location /towers/ {
proxy_pass http://127.0.0.1:8000/; # trailing slash strips the prefix
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
- **SSE (`/api/v1/events`) through nginx**: the endpoint sends `X-Accel-Buffering: no`, which nginx honors to disable response buffering for that stream — so the block above works without a dedicated location. The 20s server-side keepalive comment stays under nginx's default 60s `proxy_read_timeout`, so idle streams don't get culled. If you ever lower `proxy_read_timeout` below ~20s, add `proxy_buffering off;` + a larger read timeout on a `location /towers/api/v1/events`.
- **Dev/e2e stay at the root**: `docker-compose.dev.yml` builds with the default `BASE_HREF=/`, and Playwright hits `http://life-towers:8000/`. The relative API paths work identically there, so e2e exercises the same code.
## 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.
### Multi-client sync (revision + CAS + SSE)
Multiple clients can share one token (same user on phone + laptop). Three pieces keep them in step — see `backend/src/life_towers/events.py`, `api.py`, and `frontend/src/app/{services,utils}`:
- **Per-user `revision`** (`users.revision`, migration `002_revision.sql`): a monotonic counter bumped inside the same `BEGIN IMMEDIATE` as every PUT. `GET /data` returns it; `PUT /data` returns the new one (`{ "revision": N }`, status 200 — **not** 204 anymore).
- **Compare-and-swap**: the client sends its base revision as the `If-Match` header on PUT. If the stored revision moved underneath it, the server rolls back and returns **409** with `{ "error": "conflict", "revision": <current> }`. Absent `If-Match` ⇒ unguarded write (keeps older cached clients working across a deploy). On 409 the store resolves **server-wins**: it refetches the current server tree and adopts it, discarding this device's un-pushed edit (`store.service.adoptServerData`). The CAS still prevents a stale write from clobbering the other device's data; there is deliberately **no client-side merge** — a conflicting in-flight edit on the losing device is dropped, which is acceptable for a single user across a few devices. (An earlier `utils/sync-merge.ts` 3-way merge was removed in favour of this simpler policy.)
- **SSE push** (`GET /api/v1/events`, `text/event-stream`): an in-process asyncio pub/sub (`events.py`, single worker — see Dockerfile) pushes the new revision to every live connection right after a PUT commits. The client consumes it with **fetch()+ReadableStream**, not `EventSource`, so the Bearer token rides in a header instead of the URL (`api.service.openEventStream` + `utils/sse.ts` parser). On a newer-revision event the store refetches **only when clean**; if it has pending edits it lets the next PUT's CAS resolve it (server-wins). The stream auto-reconnects with backoff; `switchToken`/`ngOnDestroy` tear it down.
- **Gotcha**: Starlette's `BaseHTTPMiddleware` is fine with streaming responses, but **httpx's in-memory `ASGITransport` is not** — it can't open a long-lived stream with concurrent requests, so SSE wire behaviour is verified against real uvicorn, not in pytest. Pytest covers the pub/sub bus + that a PUT publishes onto it; `tests/test_api.py` has the pattern.
### 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. `GET /events` (SSE) is intentionally **un**-limited — it's one long-lived connection per client.
## 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.
`reconcile()` renders done blocks at their **resting** position, then decides what should fall via the pure, unit-tested `decideFalls()` helper. Two guarantees:
- **No fall on page load.** A `hasRenderedStack` flag gates the very first render of a tower's stack: it never animates (blocks just appear at rest). Only the deliberate "Try an example" showcase (`animateInitialStack`) falls its whole initial stack. Do NOT key "first render" off effect-run ordering vs `ngAfterViewInit` measurement — that was non-deterministic and caused intermittent load-falls.
- **Always fall on add / tick.** After the first render, any done-block id that's genuinely new this round (set-difference vs `prevDoneIds`) and is currently resting & visible falls.
The fall itself is played **imperatively via the Web Animations API** (`playFall`): the block is already rendered at rest, and WAAPI animates it in from `translateY(500%)`/`opacity:0` on the next frame (`fill: 'backwards'` so it never flashes at rest first). This is deterministic — the old approach snapped the block to `500%` then flipped it back with a `.descend` CSS transition across a double-`requestAnimationFrame`, which raced zoneless change detection and would intermittently drop the fall (block just appears) or misfire on load. **WAAPI is the right tool for block animations here precisely because it's immune to CD timing.**
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. The `.descend` CSS class survives **only** for the reverse case — an already-rendered block re-entering range as the slider widens glides back down (a real in-DOM transform change, so CSS transitions reliably; no WAAPI needed). `prevDoneIds` tracks the full `allDone` id set — not the filtered styled list — so range reshuffles keep the same ids and never 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/`

76
Dockerfile Normal file
View file

@ -0,0 +1,76 @@
# Stage 1: SPA build
FROM node:22-alpine AS spa-build
WORKDIR /build
# Sub-path the SPA is served under, e.g. "/towers/" for https://schmelczer.dev/towers/.
# Defaults to "/" so local/dev/e2e builds (served at the container root) work
# unchanged. The production image (see .forgejo/workflows/docker.yml) overrides
# this. The trailing slash matters — it becomes <base href> and the service
# worker (ngsw.json) URL prefix.
ARG BASE_HREF=/
# Toggle the built-in PWA service worker. Defaults to "enabled" so prod images
# ship the full offline-capable PWA unchanged. The dev / e2e compose overrides
# this to "disabled" (see docker-compose.dev.yml): it swaps Angular's real
# ngsw-worker.js for a self-unregistering safety worker and drops ngsw.json, so
# a `--build` rebuild is never shadowed by a stale, client-cached app shell on
# the fixed localhost origin. (Docker layer caching already busts the SPA build
# on FE source changes; this handles the browser-side service-worker cache.)
ARG SERVICE_WORKER=enabled
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# Angular's application builder outputs to dist/frontend/browser/
RUN npm run build -- --base-href="$BASE_HREF" \
&& if [ "$SERVICE_WORKER" = "disabled" ]; then \
echo "Neutralising service worker for dev/e2e image"; \
rm -f dist/frontend/browser/ngsw.json; \
cp safety-worker.js dist/frontend/browser/ngsw-worker.js; \
fi
# Stage 2: runtime
FROM python:3.13-slim
# Runtime essentials:
# curl — used by HEALTHCHECK
# sqlite3 — used for `docker compose exec ... sqlite3` backups
# uv — Python dependency installer
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates sqlite3 \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Create non-root user with a real shell so `docker exec ... sh` works for ops.
RUN useradd -r -u 1000 -m -s /bin/sh appuser
WORKDIR /app
# Install backend deps WITHOUT building the project (source not copied yet).
# The package itself is resolvable via PYTHONPATH=/app/src below; this avoids
# busting the dep-layer cache on every source change.
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
# Backend source (migrations now ship as package data under src/life_towers/)
COPY backend/src/ ./src/
# SPA static files
COPY --from=spa-build /build/dist/frontend/browser/ /app/static/
# Persistent data directory + recursive ownership for everything appuser
# might read or write at runtime.
RUN mkdir -p /data && chown -R appuser:appuser /data /app
USER appuser
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
LIFE_TOWERS_STATIC_DIR=/app/static \
LIFE_TOWERS_FORWARDED_ALLOW_IPS=* \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/src \
PATH=/app/.venv/bin:$PATH
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
CMD ["sh", "-c", "uvicorn life_towers.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${LIFE_TOWERS_FORWARDED_ALLOW_IPS}"]

View file

@ -1,27 +1,98 @@
# Frontend <p align="center">
<img src="frontend/public/logo.svg" alt="Life Towers" width="420">
</p>
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.3.8. A personal productivity tool for organising tasks into visual "towers" of blocks, grouped on pages. Each user is identified by a client-generated token — no accounts, no passwords.
## Development server ## Architecture
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. The application runs as a single Docker container. A FastAPI process serves both the Angular SPA (as static files) and the JSON API on port 8000. Data is stored in a SQLite database file persisted via a volume mount at `/data`. There is no separate database server required.
## Code scaffolding The container is designed to run behind a reverse proxy (nginx, Caddy, Traefik). It honors `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` so rate limiting and logging see the real client IP, and links built with `request.url` produce the correct scheme.
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. ## Quick start
## Build ```bash
# Local build (uses docker-compose.dev.yml which builds from source and
# wipes data on `down -v`):
docker compose -f docker-compose.dev.yml up --build -d
```
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. Then visit http://localhost:8000.
## Running unit tests For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). ```bash
export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest
docker compose pull
docker compose up -d
```
## Running end-to-end tests ## Environment variables
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). | Variable | Default | Description |
|---|---|---|
| `LIFE_TOWERS_IMAGE` | `life-towers:local` | The image `docker-compose.yml` will run. Point at your registry tag for production deploys. |
| `LIFE_TOWERS_PORT` | `8000` | Host port mapped to the container. |
| `LIFE_TOWERS_PULL_POLICY` | `missing` | `pull_policy` passed to compose. Set `always` to force-pull on `up`. |
| `LIFE_TOWERS_ALLOWED_ORIGIN` | _(empty)_ | If set, restricts CORS to this origin. Leave empty for same-origin mode (the typical setup behind nginx). |
| `LIFE_TOWERS_PUBLIC_URL` | _(derived from request)_ | Absolute public URL used for canonical and Open Graph image tags. Set this when serving behind a path prefix or proxy that rewrites the visible URL. |
| `LIFE_TOWERS_FORWARDED_ALLOW_IPS` | `*` | (Optional, advanced.) Override uvicorn's `--forwarded-allow-ips` if you want to narrow the set of trusted proxies. |
## Further help ## Data persistence
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). SQLite data is stored at `/data/life-towers.db` inside the container, persisted via the `life-towers-data` Docker named volume. Back up with:
```bash
docker compose exec life-towers sqlite3 /data/life-towers.db .dump > backup.sql
```
To switch to a host bind mount for easier file-level backups, see the commented line in `docker-compose.yml`.
## Behind nginx
Sample reverse-proxy snippet:
```nginx
server {
listen 443 ssl http2;
server_name towers.example.com;
# SSL config omitted
client_max_body_size 4m; # backend enforces 2 MiB; give a small margin
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
## Development without Docker
- Backend: `cd backend && uv run uvicorn life_towers.main:app --reload`
- Frontend: `cd frontend && npm start` — runs `ng serve` on :4200 with `/api/*` proxied to the backend on :8000 (see `frontend/proxy.conf.json`).
## End-to-end tests
A `docker-compose.dev.yml` builds a single-container stack with an ephemeral volume — ideal for Playwright runs:
```bash
docker compose -f docker-compose.dev.yml up --build -d
cd frontend && npm run test:e2e # http://localhost:8000
docker compose -f docker-compose.dev.yml down -v
```
`PLAYWRIGHT_BASE_URL` overrides the target (e.g. `http://life-towers:8000` when Playwright itself runs in a container on the same docker network).
## Deployment
Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`.
The Docker workflow (`.forgejo/workflows/docker.yml`) builds and pushes images tagged `latest` and `sha-<commit>` on pushes to `master` or manual dispatch. It publishes to `vars.REGISTRY` (default `ghcr.io`) under the repository name, using `secrets.GITHUB_TOKEN` for registry auth.

View file

@ -1,86 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": [],
"es5BrowserSupport": true
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "20mb",
"maximumError": "50mb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "frontend:build"
},
"configurations": {
"production": {
"browserTarget": "frontend:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "frontend:build"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"],
"exclude": ["**/node_modules/**"]
}
}
}
}
},
"defaultProject": "frontend"
}

33
backend/pyproject.toml Normal file
View file

@ -0,0 +1,33 @@
[project]
name = "life-towers"
version = "0.1.0"
description = "Life Towers FastAPI backend"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111.0",
"uvicorn[standard]>=0.29.0",
"slowapi>=0.1.9",
"structlog>=24.1.0",
"pydantic>=2.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/life_towers"]
[tool.hatch.build.targets.wheel.force-include]
"src/life_towers/migrations" = "life_towers/migrations"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
]

View file

@ -0,0 +1 @@
# Life Towers FastAPI backend

View file

@ -0,0 +1,371 @@
from __future__ import annotations
import asyncio
import json
import sqlite3
import time
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from . import events
from .auth import get_current_user
from .db import db_connection
from .limits import limiter
from .logging import token_log_id
from .models import (
BlockOut,
DataIn,
DataOut,
HealthResponse,
HslColor,
PageOut,
PutDataResponse,
RegisterRequest,
RegisterResponse,
TowerOut,
)
router = APIRouter(prefix="/api/v1")
logger = structlog.get_logger(__name__)
# How long the SSE loop waits for a new revision before emitting a keepalive
# comment. Keeps the connection warm through proxies and bounds disconnect
# detection latency.
SSE_KEEPALIVE_SECONDS = 20
def _read_revision(conn: sqlite3.Connection, user_id: str) -> int:
row = conn.execute(
"SELECT revision FROM users WHERE id = ?", (user_id,)
).fetchone()
return int(row["revision"]) if row is not None else 0
def _parse_if_match(value: str | None) -> int | None:
"""Parse the client's CAS base revision from the If-Match header.
Tolerates an ETag-style quoted form (``"5"``). Returns None when the header
is absent or unparseable, in which case the write proceeds unguarded
(last-writer-wins) this keeps older cached clients working across a deploy.
"""
if value is None:
return None
cleaned = value.strip().strip('"')
try:
return int(cleaned)
except ValueError:
return None
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(status="ok")
@router.post("/register", response_model=RegisterResponse)
@limiter.limit("30/hour", key_func=lambda request: request.client.host if request.client else "unknown")
async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
token = body.token
now = int(time.time())
with db_connection() as conn:
existing = conn.execute(
"SELECT id FROM users WHERE id = ?", (token,)
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO users (id, created_at, last_seen_at) VALUES (?, ?, ?)",
(token, now, now),
)
else:
conn.execute(
"UPDATE users SET last_seen_at = ? WHERE id = ?",
(now, token),
)
conn.commit()
logger.info("user_registered", user_id=token_log_id(token), new=existing is None)
return RegisterResponse(user_id=token)
@router.get("/data", response_model=DataOut)
@limiter.limit("60/minute")
async def get_data(
request: Request,
user_id: Annotated[str, Depends(get_current_user)],
) -> DataOut:
with db_connection() as conn:
revision = _read_revision(conn, user_id)
pages_rows = conn.execute(
"""
SELECT id, name, hide_create_tower_button, keep_tasks_open, default_date_from, default_date_to
FROM pages
WHERE user_id = ?
ORDER BY position
""",
(user_id,),
).fetchall()
pages_out: list[PageOut] = []
for page_row in pages_rows:
page_id = page_row["id"]
tower_rows = conn.execute(
"""
SELECT id, name, base_color_h, base_color_s, base_color_l
FROM towers
WHERE page_id = ?
ORDER BY position
""",
(page_id,),
).fetchall()
towers_out: list[TowerOut] = []
for tower_row in tower_rows:
tower_id = tower_row["id"]
block_rows = conn.execute(
"""
SELECT id, tag, description, is_done, difficulty, created_at
FROM blocks
WHERE tower_id = ?
ORDER BY position
""",
(tower_id,),
).fetchall()
blocks_out = [
BlockOut(
id=b["id"],
tag=b["tag"],
description=b["description"],
is_done=bool(b["is_done"]),
difficulty=b["difficulty"],
created_at=b["created_at"],
)
for b in block_rows
]
towers_out.append(
TowerOut(
id=tower_row["id"],
name=tower_row["name"],
base_color=HslColor(
h=tower_row["base_color_h"],
s=tower_row["base_color_s"],
l=tower_row["base_color_l"],
),
blocks=blocks_out,
)
)
pages_out.append(
PageOut(
id=page_row["id"],
name=page_row["name"],
hide_create_tower_button=bool(
page_row["hide_create_tower_button"]
),
keep_tasks_open=bool(page_row["keep_tasks_open"]),
default_date_from=page_row["default_date_from"],
default_date_to=page_row["default_date_to"],
towers=towers_out,
)
)
return DataOut(pages=pages_out, revision=revision)
@router.put("/data", response_model=PutDataResponse)
@limiter.limit("30/minute")
async def put_data(
request: Request,
body: DataIn,
user_id: Annotated[str, Depends(get_current_user)],
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
) -> PutDataResponse:
# Tree-replace semantics mean the request size IS the user's total storage,
# so the 2 MiB request cap (enforced by payload_size_middleware) is the only
# quota we need.
now = int(time.time())
base_revision = _parse_if_match(if_match)
with db_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
current_revision = _read_revision(conn, user_id)
# Compare-and-swap: reject if another client advanced the revision
# under us. The client refetches, merges, and retries with the
# fresh base. An absent If-Match opts out of the guard.
if base_revision is not None and base_revision != current_revision:
conn.rollback()
raise HTTPException(
status_code=409,
detail={
"error": "conflict",
"detail": "Revision is stale; refetch and retry",
"revision": current_revision,
},
)
new_revision = current_revision + 1
# Delete existing data for this user (cascades to towers + blocks)
conn.execute("DELETE FROM pages WHERE user_id = ?", (user_id,))
for page_pos, page in enumerate(body.pages):
conn.execute(
"""
INSERT INTO pages
(id, user_id, position, name, hide_create_tower_button,
keep_tasks_open, default_date_from, default_date_to,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
page.id,
user_id,
page_pos,
page.name,
1 if page.hide_create_tower_button else 0,
1 if page.keep_tasks_open else 0,
page.default_date_from,
page.default_date_to,
now,
now,
),
)
for tower_pos, tower in enumerate(page.towers):
conn.execute(
"""
INSERT INTO towers
(id, page_id, user_id, position, name,
base_color_h, base_color_s, base_color_l,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
tower.id,
page.id,
user_id,
tower_pos,
tower.name,
tower.base_color.h,
tower.base_color.s,
tower.base_color.l,
now,
now,
),
)
for block_pos, block in enumerate(tower.blocks):
created_at = block.created_at if block.created_at is not None else now
conn.execute(
"""
INSERT INTO blocks
(id, tower_id, user_id, position, tag,
description, is_done, difficulty,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
block.id,
tower.id,
user_id,
block_pos,
block.tag,
block.description,
1 if block.is_done else 0,
block.difficulty,
created_at,
now,
),
)
# Bump the revision and refresh last_seen_at in the same transaction.
conn.execute(
"UPDATE users SET last_seen_at = ?, revision = ? WHERE id = ?",
(now, new_revision, user_id),
)
conn.commit()
except HTTPException:
# Already rolled back (e.g. the CAS 409); just propagate.
raise
except sqlite3.IntegrityError as exc:
conn.rollback()
raise HTTPException(
status_code=409,
detail="Submitted IDs conflict with existing data",
) from exc
except Exception:
conn.rollback()
raise
# Notify other live clients for this user to refetch. Done after commit so
# subscribers never observe a revision that isn't durably stored yet.
events.publish(user_id, new_revision)
logger.info(
"data_replaced",
user_id=token_log_id(user_id),
pages=len(body.pages),
revision=new_revision,
)
return PutDataResponse(revision=new_revision)
def _format_revision_event(revision: int) -> str:
return f"event: revision\ndata: {json.dumps({'revision': revision})}\n\n"
@router.get("/events")
async def events_stream(
request: Request,
user_id: Annotated[str, Depends(get_current_user)],
) -> StreamingResponse:
"""Server-Sent Events stream that notifies a client to refetch.
Authenticated with the same Bearer token as the rest of the API clients
consume it via fetch()+ReadableStream (EventSource cannot set headers), so
the token never leaks into a URL. Each event carries the current revision;
the client refetches GET /data when it sees a revision newer than its own.
"""
queue = events.subscribe(user_id)
async def event_generator():
try:
# Emit the current revision immediately so a (re)connecting client
# can catch up on anything it missed while disconnected.
with db_connection() as conn:
yield _format_revision_event(_read_revision(conn, user_id))
while True:
try:
revision = await asyncio.wait_for(
queue.get(), timeout=SSE_KEEPALIVE_SECONDS
)
yield _format_revision_event(revision)
except asyncio.TimeoutError:
if await request.is_disconnected():
break
# Comment line: keeps proxies from idling the connection out.
yield ": keepalive\n\n"
finally:
events.unsubscribe(user_id, queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
# Disable nginx response buffering for this stream even if the
# location block forgets `proxy_buffering off`.
"X-Accel-Buffering": "no",
},
)

View file

@ -0,0 +1,55 @@
"""Bearer token extraction, UUIDv4 validation, and DB lookup."""
from __future__ import annotations
from fastapi import HTTPException, Request
from .db import db_connection
from .models import _canonical_uuidv4
# Single generic detail used for ALL 401 responses. Per spec, the response
# must not distinguish between missing / malformed / unknown tokens — that
# would let an attacker enumerate registered tokens.
_UNAUTHORIZED_DETAIL = "Authentication required"
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=401,
detail={"error": "unauthorized", "detail": _UNAUTHORIZED_DETAIL},
)
def extract_bearer_token(request: Request) -> str | None:
"""Return the raw Bearer token from the Authorization header, or None."""
auth_header = request.headers.get("Authorization") or request.headers.get(
"authorization"
)
if not auth_header:
return None
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1]
return None
def get_current_user(request: Request) -> str:
"""Dependency that extracts and validates a Bearer token, returns user_id."""
token = extract_bearer_token(request)
if token is None:
raise _unauthorized()
try:
token = _canonical_uuidv4(token)
except ValueError:
raise _unauthorized()
with db_connection() as conn:
row = conn.execute(
"SELECT id FROM users WHERE id = ?", (token,)
).fetchone()
if row is None:
raise _unauthorized()
return token

View file

@ -0,0 +1,95 @@
"""SQLite connection factory, WAL/FK pragmas, and migration runner."""
from __future__ import annotations
import os
import sqlite3
import time
from contextlib import contextmanager
from importlib import resources
from pathlib import Path
from typing import Generator
import structlog
logger = structlog.get_logger(__name__)
_DB_PATH: Path | None = None
def get_db_path() -> Path:
global _DB_PATH
if _DB_PATH is None:
_DB_PATH = Path(os.environ.get("LIFE_TOWERS_DB_PATH", "/data/life-towers.db"))
return _DB_PATH
def _apply_pragmas(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
def get_connection() -> sqlite3.Connection:
"""Open a new connection with required pragmas applied."""
path = get_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), check_same_thread=False)
conn.row_factory = sqlite3.Row
_apply_pragmas(conn)
return conn
@contextmanager
def db_connection() -> Generator[sqlite3.Connection, None, None]:
"""Context manager yielding a connection that is closed on exit."""
conn = get_connection()
try:
yield conn
finally:
conn.close()
def _migration_files() -> list[tuple[str, str]]:
"""Return [(filename, sql)] for every migration, in lexical order.
Migrations ship as package data under `life_towers/migrations/`, so they
travel with the wheel and are resolvable regardless of cwd or how the
package is installed (editable, wheel, or PYTHONPATH).
"""
pkg_files = resources.files("life_towers").joinpath("migrations")
out: list[tuple[str, str]] = []
for entry in sorted(pkg_files.iterdir(), key=lambda p: p.name):
if entry.name.endswith(".sql"):
out.append((entry.name, entry.read_text()))
return out
def run_migrations(conn: sqlite3.Connection) -> None:
"""Apply pending SQL migrations in lexical order."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)
"""
)
conn.commit()
for filename, sql in _migration_files():
row = conn.execute(
"SELECT filename FROM schema_migrations WHERE filename = ?",
(filename,),
).fetchone()
if row is not None:
continue
logger.info("applying_migration", filename=filename)
conn.executescript(sql)
conn.execute(
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
(filename, int(time.time())),
)
conn.commit()
logger.info("migration_applied", filename=filename)

View file

@ -0,0 +1,77 @@
"""In-process pub/sub for Server-Sent Events.
Single-worker deployment (see Dockerfile: uvicorn with no ``--workers``), so a
plain in-memory registry is enough there is one event loop and every
subscriber queue lives on it. If this ever grows to multiple workers, this
module is the seam to swap for a cross-process bus (Redis pub/sub, LISTEN/NOTIFY,
or polling the ``users.revision`` column).
Each subscriber gets a 1-slot queue that always holds the *latest* revision to
notify. Coalescing is intentional: the SSE payload is just "something changed,
current revision is N", so a burst of writes collapses to a single refetch
signal rather than a backlog.
"""
from __future__ import annotations
import asyncio
import structlog
logger = structlog.get_logger(__name__)
# user_id -> set of per-connection queues. Each queue carries the most recent
# revision the connection still needs to flush to its client.
_subscribers: dict[str, set[asyncio.Queue[int]]] = {}
def subscribe(user_id: str) -> asyncio.Queue[int]:
"""Register a new SSE connection for ``user_id`` and return its queue.
Must be called from the event loop (the SSE route handler is async), so the
queue binds to the running loop.
"""
queue: asyncio.Queue[int] = asyncio.Queue(maxsize=1)
_subscribers.setdefault(user_id, set()).add(queue)
return queue
def unsubscribe(user_id: str, queue: asyncio.Queue[int]) -> None:
"""Drop a connection's queue; prune the user entry when the last one goes."""
subs = _subscribers.get(user_id)
if subs is None:
return
subs.discard(queue)
if not subs:
_subscribers.pop(user_id, None)
def publish(user_id: str, revision: int) -> None:
"""Notify every live connection for ``user_id`` of the new revision.
Called from the (single) event loop right after a PUT commits, so the
``put_nowait`` calls are loop-safe. Coalesces into each 1-slot queue: if a
connection has not yet drained its previous signal, replace it with the
newer revision rather than block or grow unbounded.
"""
subs = _subscribers.get(user_id)
if not subs:
return
for queue in subs:
try:
queue.put_nowait(revision)
except asyncio.QueueFull:
# Connection is behind — drop the stale value and keep the newest.
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(revision)
except asyncio.QueueFull:
pass
def connection_count(user_id: str) -> int:
"""Number of live SSE connections for a user (used in tests)."""
return len(_subscribers.get(user_id, ()))

View file

@ -0,0 +1,83 @@
"""Payload size middleware and rate-limit setup."""
from __future__ import annotations
import json
from fastapi import Request, Response
from slowapi import Limiter
from slowapi.util import get_remote_address
from .auth import extract_bearer_token
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
_TOO_LARGE_BODY = json.dumps(
{
"error": "payload_too_large",
"detail": f"Request body exceeds {PAYLOAD_LIMIT_BYTES} bytes",
}
).encode()
def _get_token_or_ip(request: Request) -> str:
"""Key function for rate limiting: use Bearer token if present, else IP."""
return extract_bearer_token(request) or get_remote_address(request)
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
def _too_large() -> Response:
return Response(
content=_TOO_LARGE_BODY,
status_code=413,
media_type="application/json",
)
async def payload_size_middleware(request: Request, call_next) -> Response:
"""Reject requests larger than PAYLOAD_LIMIT_BYTES.
Two-layer enforcement:
1. If `Content-Length` is present, reject up front (cheap and avoids
buffering anything).
2. Otherwise (chunked encoding / missing header) wrap `request.receive`
so the body stream is tallied as it arrives; abort the moment the
running total exceeds the cap. This is defense-in-depth against
clients that omit Content-Length.
"""
content_length = request.headers.get("content-length")
if content_length is not None:
try:
length = int(content_length)
except ValueError:
length = 0
if length > PAYLOAD_LIMIT_BYTES:
return _too_large()
# Trust the declared length; the ASGI server enforces it.
return await call_next(request)
# No Content-Length: tally bytes as they arrive.
received_total = 0
too_large = False
original_receive = request.receive
async def guarded_receive() -> dict:
nonlocal received_total, too_large
message = await original_receive()
if message.get("type") == "http.request":
body = message.get("body") or b""
received_total += len(body)
if received_total > PAYLOAD_LIMIT_BYTES:
too_large = True
# Tell downstream "no more body" — they may still try to
# parse what's been seen, but we'll override the response.
return {"type": "http.disconnect"}
return message
request._receive = guarded_receive # type: ignore[attr-defined]
response = await call_next(request)
if too_large:
return _too_large()
return response

View file

@ -0,0 +1,66 @@
"""structlog JSON config and request logging middleware."""
from __future__ import annotations
import time
import uuid as _uuid_mod
from hashlib import sha256
import structlog
from fastapi import Request, Response
from .auth import extract_bearer_token
def configure_logging() -> None:
"""Configure structlog for JSON output."""
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(0),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def token_log_id(token: str) -> str:
return sha256(token.encode("utf-8")).hexdigest()[:12]
async def request_logging_middleware(request: Request, call_next) -> Response:
"""Log each request without writing bearer credentials to the log stream."""
request_id = str(_uuid_mod.uuid4())
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
# Extract user_id from Authorization header for logging (no DB call here)
token = extract_bearer_token(request)
user_id = token_log_id(token) if token else None
start = time.monotonic()
response = await call_next(request)
duration_ms = round((time.monotonic() - start) * 1000, 2)
# request.client is rewritten by uvicorn's ProxyHeadersMiddleware
# (--proxy-headers) to the real client IP when behind a trusted reverse proxy.
client_ip = request.client.host if request.client else None
log = structlog.get_logger("access")
log.info(
"request",
method=request.method,
path=request.url.path,
status=response.status_code,
duration_ms=duration_ms,
user_id=user_id,
client_ip=client_ip,
)
response.headers["X-Request-Id"] = request_id
return response

View file

@ -0,0 +1,227 @@
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from html import escape
from pathlib import Path
from typing import AsyncGenerator
from urllib.parse import urlsplit, urlunsplit
import structlog
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from .api import router
from .db import db_connection, run_migrations
from .limits import limiter, payload_size_middleware
from .logging import configure_logging, request_logging_middleware
logger = structlog.get_logger(__name__)
# Map HTTP status codes to error code strings
STATUS_CODE_MAP: dict[int, str] = {
400: "bad_request",
401: "unauthorized",
403: "forbidden",
404: "not_found",
405: "method_not_allowed",
409: "conflict",
413: "payload_too_large",
422: "bad_request",
429: "rate_limited",
500: "server_error",
}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
configure_logging()
logger.info("starting_up")
with db_connection() as conn:
run_migrations(conn)
logger.info("migrations_complete")
yield
logger.info("shutting_down")
def create_app() -> FastAPI:
app = FastAPI(
title="Life Towers",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
)
# Rate limiter state
app.state.limiter = limiter
# CORS (only if env var set)
allowed_origin = os.environ.get("LIFE_TOWERS_ALLOWED_ORIGIN")
if allowed_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[allowed_origin],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
# Payload size middleware
app.add_middleware(BaseHTTPMiddleware, dispatch=payload_size_middleware)
# Request logging middleware
app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware)
# Rate limit exceeded handler
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Pydantic / FastAPI validation errors → 400 bad_request.
# We do NOT echo the user-supplied input back in the detail string —
# pydantic's default messages include the offending value, which would
# reflect arbitrary attacker-controlled content. Instead we just list
# the failing field paths.
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
fields = sorted(
field
for field in {
".".join(str(loc) for loc in e.get("loc", ()) if loc != "body")
for e in exc.errors()
}
if field
)
if fields:
detail_str = "Validation failed for: " + ", ".join(fields)
else:
detail_str = "Validation failed"
return JSONResponse(
status_code=400,
content={"error": "bad_request", "detail": detail_str},
)
# HTTP exception handler → normalized JSON
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
if isinstance(exc.detail, dict):
# Already shaped correctly (from auth.py etc.)
detail = exc.detail
else:
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
detail = {"error": code, "detail": str(exc.detail)}
headers = exc.headers or {}
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
# Generic 500 handler
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("unhandled_exception", exc_info=exc)
return JSONResponse(
status_code=500,
content={"error": "server_error", "detail": "Internal server error"},
)
# Register API router
app.include_router(router)
# Static files
static_dir = Path(os.environ.get("LIFE_TOWERS_STATIC_DIR", "/app/static"))
if static_dir.exists() and static_dir.is_dir():
_mount_static(app, static_dir)
else:
logger.warning("static_dir_missing", path=str(static_dir))
return app
def _mount_static(app: FastAPI, static_dir: Path) -> None:
"""Mount static files with SPA fallback and proper cache headers."""
import re
# Matches Angular's content-hashed asset filenames, e.g.
# main-L5B7PG5E.js, chunk-deadbeef.css, styles-6JWNHNC2.css, font.AbCdEf12.woff2
# The hash is at least 8 chars of alphanumerics, separated from the base name
# by either '-' (modern Angular) or '.' (older Angular / generic).
HASHED_PATTERN = re.compile(
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
)
def _absolute_meta_urls(request: Request) -> tuple[str, str]:
configured_public_url = os.environ.get("LIFE_TOWERS_PUBLIC_URL", "").strip()
if configured_public_url:
public_root = configured_public_url.rstrip("/") + "/"
return public_root, f"{public_root}og-image.png"
parts = urlsplit(str(request.url))
canonical_url = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", "", ""))
root_path = str(request.scope.get("root_path") or "").strip("/")
og_image_path = f"/{root_path}/og-image.png" if root_path else "/og-image.png"
og_image_url = urlunsplit((parts.scheme, parts.netloc, og_image_path, "", ""))
return canonical_url, og_image_url
def _serve_index(file_path: Path, request: Request) -> HTMLResponse:
canonical_url, og_image_url = _absolute_meta_urls(request)
html = file_path.read_text(encoding="utf-8")
html = html.replace(
'href="/" data-dynamic-url="canonical"',
f'href="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
)
html = html.replace(
'content="/" data-dynamic-url="canonical"',
f'content="{escape(canonical_url, quote=True)}" data-dynamic-url="canonical"',
)
html = html.replace(
'content="/og-image.png" data-dynamic-url="og-image"',
f'content="{escape(og_image_url, quote=True)}" data-dynamic-url="og-image"',
)
resp = HTMLResponse(html)
resp.headers["Cache-Control"] = "no-cache"
return resp
def _serve_file(file_path: Path, request: Request) -> Response:
if file_path.name == "index.html":
return _serve_index(file_path, request)
resp = FileResponse(str(file_path))
if HASHED_PATTERN.search(file_path.name):
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
resp.headers["Cache-Control"] = "no-cache"
return resp
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(request: Request, full_path: str) -> Response:
# API routes are handled by the API router (registered before this);
# if execution reaches here for an /api/* path, it really is unknown.
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not found")
# Real file? Serve it (with cache headers based on hash detection).
# Guard against path traversal by resolving and re-checking containment.
candidate = (static_dir / full_path).resolve()
try:
candidate.relative_to(static_dir.resolve())
except ValueError:
raise HTTPException(status_code=404, detail="Not found")
if candidate.is_file():
return _serve_file(candidate, request)
# SPA fallback to index.html.
index = static_dir / "index.html"
if index.is_file():
return _serve_file(index, request)
raise HTTPException(status_code=404, detail="Not found")
app = create_app()

View file

@ -0,0 +1,54 @@
-- Life Towers v4 initial schema.
-- WAL mode, foreign keys, and busy_timeout are applied per-connection in
-- db._apply_pragmas(), so they are not (and need not be) set here.
-- All timestamps are unix epoch seconds (INTEGER).
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS pages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
name TEXT NOT NULL,
hide_create_tower_button INTEGER NOT NULL DEFAULT 0 CHECK (hide_create_tower_button IN (0, 1)),
keep_tasks_open INTEGER NOT NULL DEFAULT 0 CHECK (keep_tasks_open IN (0, 1)),
default_date_from INTEGER,
default_date_to INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_pages_user_position ON pages(user_id, position);
CREATE TABLE IF NOT EXISTS towers (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
name TEXT NOT NULL,
base_color_h REAL NOT NULL CHECK (base_color_h BETWEEN 0 AND 1),
base_color_s REAL NOT NULL CHECK (base_color_s BETWEEN 0 AND 1),
base_color_l REAL NOT NULL CHECK (base_color_l BETWEEN 0 AND 1),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_towers_page_position ON towers(page_id, position);
CREATE INDEX IF NOT EXISTS idx_towers_user ON towers(user_id);
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
tower_id TEXT NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
tag TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)),
difficulty INTEGER NOT NULL DEFAULT 1 CHECK (difficulty >= 1),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_blocks_tower_position ON blocks(tower_id, position);
CREATE INDEX IF NOT EXISTS idx_blocks_user ON blocks(user_id);

View file

@ -0,0 +1,7 @@
-- Per-user revision counter for multi-client sync.
-- Bumped inside the same transaction as every PUT /data so clients can:
-- * detect another client's write (cheap compare, no full-tree diff), and
-- * guard their own writes with compare-and-swap (stale If-Match -> 409).
-- Existing users start at 0; their next confirmed GET seeds the client base.
ALTER TABLE users ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,154 @@
"""Pydantic v2 models matching the API spec exactly."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
import uuid as _uuid_mod
def _canonical_uuidv4(value: str) -> str:
try:
u = _uuid_mod.UUID(value)
if u.version == 4:
return str(u)
except (ValueError, AttributeError):
pass
raise ValueError("must be a UUIDv4")
class HslColor(BaseModel):
h: float = Field(ge=0.0, le=1.0)
s: float = Field(ge=0.0, le=1.0)
l: float = Field(ge=0.0, le=1.0)
class BlockIn(BaseModel):
id: str
tag: str = Field(max_length=200)
description: str = Field(max_length=10_000)
is_done: bool
difficulty: int = Field(default=1, ge=1, le=100)
created_at: Optional[int] = None
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
return _canonical_uuidv4(v)
class BlockOut(BaseModel):
id: str
tag: str
description: str
is_done: bool
difficulty: int
created_at: int
class TowerIn(BaseModel):
id: str
name: str = Field(max_length=200)
base_color: HslColor
blocks: list[BlockIn] = Field(max_length=1000)
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
return _canonical_uuidv4(v)
class TowerOut(BaseModel):
id: str
name: str
base_color: HslColor
blocks: list[BlockOut]
class PageIn(BaseModel):
id: str
name: str = Field(max_length=200)
hide_create_tower_button: bool = False
keep_tasks_open: bool = False
default_date_from: Optional[int] = None
default_date_to: Optional[int] = None
towers: list[TowerIn] = Field(max_length=100)
@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
return _canonical_uuidv4(v)
class PageOut(BaseModel):
id: str
name: str
hide_create_tower_button: bool
keep_tasks_open: bool
default_date_from: Optional[int]
default_date_to: Optional[int]
towers: list[TowerOut]
class DataIn(BaseModel):
pages: list[PageIn] = Field(max_length=100)
@model_validator(mode="after")
def check_unique_ids(self) -> "DataIn":
page_ids: set[str] = set()
tower_ids: set[str] = set()
block_ids: set[str] = set()
total_blocks = 0
for page in self.pages:
if page.id in page_ids:
raise ValueError(f"Duplicate page id: {page.id}")
page_ids.add(page.id)
for tower in page.towers:
if tower.id in tower_ids:
raise ValueError(f"Duplicate tower id: {tower.id}")
tower_ids.add(tower.id)
for block in tower.blocks:
if block.id in block_ids:
raise ValueError(f"Duplicate block id: {block.id}")
block_ids.add(block.id)
total_blocks += 1
if total_blocks > 50_000:
raise ValueError(
f"Total blocks ({total_blocks}) exceeds maximum of 50,000"
)
return self
class DataOut(BaseModel):
pages: list[PageOut]
# Monotonic per-user version, bumped on every successful PUT. Clients keep
# it as their compare-and-swap base and to detect another client's writes.
revision: int
class PutDataResponse(BaseModel):
# The new revision after this write; the client adopts it as its CAS base.
revision: int
class RegisterRequest(BaseModel):
token: str
@field_validator("token")
@classmethod
def validate_token(cls, v: str) -> str:
return _canonical_uuidv4(v)
class RegisterResponse(BaseModel):
user_id: str
class HealthResponse(BaseModel):
status: str

View file

@ -0,0 +1 @@
# Life Towers tests

652
backend/tests/test_api.py Normal file
View file

@ -0,0 +1,652 @@
"""pytest + httpx AsyncClient tests for the Life Towers API."""
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
import life_towers.db as db_module
from life_towers.db import run_migrations, get_connection
from life_towers.limits import limiter
from life_towers.main import create_app
def make_uuidv4() -> str:
return str(uuid.uuid4())
@pytest.fixture()
def anyio_backend():
return "asyncio"
@pytest_asyncio.fixture()
async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
"""Create a test client backed by a fresh SQLite database."""
db_path = tmp_path / "test.db"
db_module._DB_PATH = db_path
# Reset rate limiter state so each test starts clean
limiter.reset()
# Run migrations using a fresh connection
conn = get_connection()
run_migrations(conn)
conn.close()
app = create_app()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as c:
yield c
# Reset DB path for next test
db_module._DB_PATH = None
@pytest.mark.asyncio
async def test_spa_index_injects_absolute_open_graph_urls(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
static_dir = tmp_path / "static"
static_dir.mkdir()
(static_dir / "index.html").write_text(
"""
<!doctype html>
<html>
<head>
<link rel="canonical" href="/" data-dynamic-url="canonical" />
<meta property="og:url" content="/" data-dynamic-url="canonical" />
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
</head>
</html>
""",
encoding="utf-8",
)
(static_dir / "og-image.png").write_bytes(b"fake png")
monkeypatch.setenv("LIFE_TOWERS_STATIC_DIR", str(static_dir))
app = create_app()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="https://towers.example",
) as c:
resp = await c.get("/tasks?utm_source=test")
assert resp.status_code == 200
assert (
'<link rel="canonical" href="https://towers.example/tasks" '
'data-dynamic-url="canonical" />'
) in resp.text
assert (
'<meta property="og:url" content="https://towers.example/tasks" '
'data-dynamic-url="canonical" />'
) in resp.text
assert (
'<meta property="og:image" content="https://towers.example/og-image.png" '
'data-dynamic-url="og-image" />'
) in resp.text
assert (
'<meta name="twitter:image" content="https://towers.example/og-image.png" '
'data-dynamic-url="og-image" />'
) in resp.text
monkeypatch.setenv("LIFE_TOWERS_PUBLIC_URL", "https://public.example/towers")
app = create_app()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="https://internal.example",
) as c:
resp = await c.get("/")
assert resp.status_code == 200
assert (
'<link rel="canonical" href="https://public.example/towers/" '
'data-dynamic-url="canonical" />'
) in resp.text
assert (
'<meta property="og:image" content="https://public.example/towers/og-image.png" '
'data-dynamic-url="og-image" />'
) in resp.text
# ---------------------------------------------------------------------------
# Register
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_register_new_token(client: AsyncClient) -> None:
token = make_uuidv4()
resp = await client.post("/api/v1/register", json={"token": token})
assert resp.status_code == 200
assert resp.json() == {"user_id": token}
@pytest.mark.asyncio
async def test_register_idempotent(client: AsyncClient) -> None:
token = make_uuidv4()
r1 = await client.post("/api/v1/register", json={"token": token})
r2 = await client.post("/api/v1/register", json={"token": token})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json() == {"user_id": token}
assert r2.json() == {"user_id": token}
@pytest.mark.asyncio
async def test_register_non_uuid_token(client: AsyncClient) -> None:
resp = await client.post("/api/v1/register", json={"token": "not-a-uuid"})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_register_non_uuidv4_token(client: AsyncClient) -> None:
# UUIDv1
v1 = str(uuid.uuid1())
resp = await client.post("/api/v1/register", json={"token": v1})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_uuid_inputs_are_canonicalized(client: AsyncClient) -> None:
token = make_uuidv4()
upper_token = token.upper()
register_resp = await client.post("/api/v1/register", json={"token": upper_token})
assert register_resp.status_code == 200
assert register_resp.json() == {"user_id": token}
data_resp = await client.get(
"/api/v1/data",
headers={"Authorization": f"Bearer {upper_token}"},
)
assert data_resp.status_code == 200
# ---------------------------------------------------------------------------
# Auth / GET /data
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_data_no_token(client: AsyncClient) -> None:
resp = await client.get("/api/v1/data")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_get_data_bogus_token(client: AsyncClient) -> None:
resp = await client.get(
"/api/v1/data",
headers={"Authorization": f"Bearer {make_uuidv4()}"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_get_data_empty_user(client: AsyncClient) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
resp = await client.get(
"/api/v1/data",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json() == {"pages": [], "revision": 0}
# ---------------------------------------------------------------------------
# Round-trip: register → PUT → GET
# ---------------------------------------------------------------------------
def _make_tree() -> dict:
"""Build a valid tree with 2 pages × 2 towers × 3 blocks."""
pages = []
for pi in range(2):
towers = []
for ti in range(2):
blocks = []
for bi in range(3):
blocks.append(
{
"id": make_uuidv4(),
"tag": f"tag-{pi}-{ti}-{bi}",
"description": f"desc-{pi}-{ti}-{bi}",
"is_done": False,
"difficulty": bi + 1,
"created_at": 1700000000 + bi,
}
)
towers.append(
{
"id": make_uuidv4(),
"name": f"Tower {pi}-{ti}",
"base_color": {"h": 0.1 * ti, "s": 0.5, "l": 0.6},
"blocks": blocks,
}
)
pages.append(
{
"id": make_uuidv4(),
"name": f"Page {pi}",
"hide_create_tower_button": False,
"keep_tasks_open": False,
"default_date_from": None,
"default_date_to": None,
"towers": towers,
}
)
return {"pages": pages}
@pytest.mark.asyncio
async def test_round_trip(client: AsyncClient) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
tree = _make_tree()
put_resp = await client.put("/api/v1/data", json=tree, headers=headers)
assert put_resp.status_code == 200
assert put_resp.json() == {"revision": 1}
get_resp = await client.get("/api/v1/data", headers=headers)
assert get_resp.status_code == 200
data = get_resp.json()
assert data["revision"] == 1
assert len(data["pages"]) == 2
for pi, page in enumerate(data["pages"]):
assert page["id"] == tree["pages"][pi]["id"]
assert page["name"] == tree["pages"][pi]["name"]
assert len(page["towers"]) == 2
for ti, tower in enumerate(page["towers"]):
assert tower["id"] == tree["pages"][pi]["towers"][ti]["id"]
assert tower["name"] == tree["pages"][pi]["towers"][ti]["name"]
assert len(tower["blocks"]) == 3
for bi, block in enumerate(tower["blocks"]):
assert block["id"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["id"]
assert block["tag"] == tree["pages"][pi]["towers"][ti]["blocks"][bi]["tag"]
assert (
block["difficulty"]
== tree["pages"][pi]["towers"][ti]["blocks"][bi]["difficulty"]
)
@pytest.mark.asyncio
async def test_difficulty_defaults_to_one_when_omitted(client: AsyncClient) -> None:
"""A block sent without `difficulty` is stored and returned as 1."""
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
tree = _make_tree()
# Drop difficulty from the very first block.
del tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"]
put_resp = await client.put("/api/v1/data", json=tree, headers=headers)
assert put_resp.status_code == 200
data = (await client.get("/api/v1/data", headers=headers)).json()
assert data["pages"][0]["towers"][0]["blocks"][0]["difficulty"] == 1
@pytest.mark.asyncio
async def test_difficulty_must_be_positive(client: AsyncClient) -> None:
"""difficulty < 1 is rejected by validation."""
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
tree = _make_tree()
tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"] = 0
resp = await client.put("/api/v1/data", json=tree, headers=headers)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_put_duplicate_page_id(client: AsyncClient) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
dup_id = make_uuidv4()
tree = _make_tree()
tree["pages"][0]["id"] = dup_id
tree["pages"][1]["id"] = dup_id # duplicate
resp = await client.put("/api/v1/data", json=tree, headers=headers)
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
assert resp.json() == {"error": "bad_request", "detail": "Validation failed"}
@pytest.mark.asyncio
async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> None:
first_token = make_uuidv4()
second_token = make_uuidv4()
await client.post("/api/v1/register", json={"token": first_token})
await client.post("/api/v1/register", json={"token": second_token})
tree = _make_tree()
first_resp = await client.put(
"/api/v1/data",
json=tree,
headers={"Authorization": f"Bearer {first_token}"},
)
assert first_resp.status_code == 200
second_resp = await client.put(
"/api/v1/data",
json=tree,
headers={"Authorization": f"Bearer {second_token}"},
)
assert second_resp.status_code == 409
assert second_resp.json() == {
"error": "conflict",
"detail": "Submitted IDs conflict with existing data",
}
@pytest.mark.asyncio
async def test_put_name_too_long(client: AsyncClient) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
tree = _make_tree()
tree["pages"][0]["name"] = "A" * 201 # exceeds 200 char limit
resp = await client.put("/api/v1/data", json=tree, headers=headers)
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
@pytest.mark.asyncio
async def test_put_body_too_large_via_content_length(client: AsyncClient) -> None:
"""A body exceeding the 2 MiB cap, with Content-Length set, returns 413."""
from life_towers.limits import PAYLOAD_LIMIT_BYTES
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
over = PAYLOAD_LIMIT_BYTES + 1
headers = {
"Authorization": f"Bearer {token}",
"Content-Length": str(over),
"Content-Type": "application/json",
}
resp = await client.put("/api/v1/data", content=b"x" * over, headers=headers)
assert resp.status_code == 413
body = resp.json()
assert body == {
"error": "payload_too_large",
"detail": f"Request body exceeds {PAYLOAD_LIMIT_BYTES} bytes",
}
@pytest.mark.asyncio
async def test_unauthorized_detail_is_uniform(client: AsyncClient) -> None:
"""All 401 responses must share an identical detail to prevent enumeration."""
bodies: list[dict] = []
# missing header
bodies.append((await client.get("/api/v1/data")).json())
# wrong scheme
bodies.append(
(
await client.get("/api/v1/data", headers={"Authorization": "Basic foo"})
).json()
)
# malformed token
bodies.append(
(
await client.get(
"/api/v1/data", headers={"Authorization": "Bearer not-a-uuid"}
)
).json()
)
# well-formed but unknown token
bodies.append(
(
await client.get(
"/api/v1/data", headers={"Authorization": f"Bearer {make_uuidv4()}"}
)
).json()
)
assert len({tuple(sorted(b.items())) for b in bodies}) == 1, bodies
@pytest.mark.asyncio
async def test_put_replaces_prior_data(client: AsyncClient) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
# First PUT: 2 pages
tree1 = _make_tree()
await client.put("/api/v1/data", json=tree1, headers=headers)
# Second PUT: 1 page
tree2 = {"pages": [tree1["pages"][0]]}
await client.put("/api/v1/data", json=tree2, headers=headers)
get_resp = await client.get("/api/v1/data", headers=headers)
data = get_resp.json()
assert len(data["pages"]) == 1
assert data["pages"][0]["id"] == tree1["pages"][0]["id"]
# ---------------------------------------------------------------------------
# Rate limit (mock / direct hit)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_register_rate_limit(client: AsyncClient) -> None:
"""Hit /register past the per-IP limit; the next call returns 429."""
# Limit is 30/hour/IP. Make 31 requests; the 31st must be 429.
responses = []
for _ in range(31):
resp = await client.post("/api/v1/register", json={"token": make_uuidv4()})
responses.append(resp.status_code)
assert responses[-1] == 429, f"Expected 429 on 31st request, got: {responses[-3:]}"
# ---------------------------------------------------------------------------
# Revision counter + compare-and-swap (multi-client sync)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_revision_increments_on_each_put(client: AsyncClient) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
# Fresh user starts at revision 0.
assert (await client.get("/api/v1/data", headers=headers)).json()["revision"] == 0
r1 = await client.put("/api/v1/data", json=_make_tree(), headers=headers)
assert r1.json() == {"revision": 1}
r2 = await client.put("/api/v1/data", json=_make_tree(), headers=headers)
assert r2.json() == {"revision": 2}
assert (await client.get("/api/v1/data", headers=headers)).json()["revision"] == 2
@pytest.mark.asyncio
async def test_stale_if_match_returns_409_with_current_revision(
client: AsyncClient,
) -> None:
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
# Base 0 matches a fresh user -> succeeds, revision becomes 1.
ok = await client.put(
"/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "0"}
)
assert ok.status_code == 200
assert ok.json() == {"revision": 1}
# Re-using the now-stale base 0 is rejected; the body carries the truth.
stale = await client.put(
"/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "0"}
)
assert stale.status_code == 409
body = stale.json()
assert body["error"] == "conflict"
assert body["revision"] == 1
# The conflicting write must NOT have advanced the revision.
assert (await client.get("/api/v1/data", headers=headers)).json()["revision"] == 1
# Retrying with the fresh base succeeds.
retry = await client.put(
"/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "1"}
)
assert retry.status_code == 200
assert retry.json() == {"revision": 2}
@pytest.mark.asyncio
async def test_put_without_if_match_skips_the_guard(client: AsyncClient) -> None:
"""Absent If-Match keeps older cached clients writing (last-writer-wins)."""
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
# Advance to revision 2 first.
await client.put("/api/v1/data", json=_make_tree(), headers=headers)
await client.put("/api/v1/data", json=_make_tree(), headers=headers)
# No If-Match -> write goes through regardless of current revision.
resp = await client.put("/api/v1/data", json=_make_tree(), headers=headers)
assert resp.status_code == 200
assert resp.json() == {"revision": 3}
# ---------------------------------------------------------------------------
# Server-Sent Events stream (notify-to-refetch)
#
# The full SSE wire behaviour (incremental flush + live push) is exercised
# end-to-end against real uvicorn — httpx's in-memory ASGITransport can't model
# a long-lived streaming response with concurrent requests, so here we cover the
# two seams it CAN reach reliably: the in-process pub/sub bus, and the fact that
# a PUT publishes the new revision onto that bus (which the stream then drains).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_event_bus_subscribe_publish_unsubscribe() -> None:
from life_towers import events
user = make_uuidv4()
queue = events.subscribe(user)
assert events.connection_count(user) == 1
events.publish(user, 5)
assert await asyncio.wait_for(queue.get(), 1) == 5
events.unsubscribe(user, queue)
assert events.connection_count(user) == 0
@pytest.mark.asyncio
async def test_event_bus_coalesces_to_latest_revision() -> None:
"""A backed-up connection should see only the newest revision, not a queue."""
from life_towers import events
user = make_uuidv4()
queue = events.subscribe(user)
events.publish(user, 1)
events.publish(user, 2)
events.publish(user, 3)
assert await asyncio.wait_for(queue.get(), 1) == 3
assert queue.empty()
events.unsubscribe(user, queue)
@pytest.mark.asyncio
async def test_event_bus_fans_out_to_all_connections() -> None:
from life_towers import events
user = make_uuidv4()
q1 = events.subscribe(user)
q2 = events.subscribe(user)
assert events.connection_count(user) == 2
events.publish(user, 7)
assert await asyncio.wait_for(q1.get(), 1) == 7
assert await asyncio.wait_for(q2.get(), 1) == 7
events.unsubscribe(user, q1)
events.unsubscribe(user, q2)
assert events.connection_count(user) == 0
@pytest.mark.asyncio
async def test_event_bus_publish_without_subscribers_is_noop() -> None:
from life_towers import events
events.publish(make_uuidv4(), 99) # must not raise
@pytest.mark.asyncio
async def test_put_publishes_new_revision_to_subscribers(client: AsyncClient) -> None:
"""The integration seam: a real PUT must notify a live SSE subscriber."""
from life_towers import events
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
queue = events.subscribe(token)
try:
await client.put("/api/v1/data", json=_make_tree(), headers=headers)
assert await asyncio.wait_for(queue.get(), 2) == 1
await client.put("/api/v1/data", json=_make_tree(), headers=headers)
assert await asyncio.wait_for(queue.get(), 2) == 2
finally:
events.unsubscribe(token, queue)
@pytest.mark.asyncio
async def test_rejected_put_does_not_publish(client: AsyncClient) -> None:
"""A CAS-rejected (409) write must not emit a spurious refetch signal."""
from life_towers import events
token = make_uuidv4()
await client.post("/api/v1/register", json={"token": token})
headers = {"Authorization": f"Bearer {token}"}
# Advance to revision 1.
await client.put("/api/v1/data", json=_make_tree(), headers=headers)
queue = events.subscribe(token)
try:
# Stale base -> 409, must not publish.
stale = await client.put(
"/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "0"}
)
assert stale.status_code == 409
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.get(), 0.2)
finally:
events.unsubscribe(token, queue)
@pytest.mark.asyncio
async def test_sse_requires_auth(client: AsyncClient) -> None:
# No Bearer token: auth fails before any streaming starts, so a plain GET
# returns 401 without holding the connection open.
resp = await client.get("/api/v1/events")
assert resp.status_code == 401

831
backend/uv.lock generated Normal file
View file

@ -0,0 +1,831 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "life-towers"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "slowapi" },
{ name = "structlog" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.111.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "slowapi", specifier = ">=0.1.9" },
{ name = "structlog", specifier = ">=24.1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
]
[[package]]
name = "limits"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "slowapi"
version = "0.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "limits" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
]
[[package]]
name = "starlette"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
]
[[package]]
name = "structlog"
version = "25.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
{ url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
{ url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
{ url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
{ url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
{ url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
{ url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
{ url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
{ url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
{ url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
{ url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "wrapt"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" },
{ url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" },
{ url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" },
{ url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" },
{ url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" },
{ url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" },
{ url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" },
{ url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" },
{ url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" },
{ url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" },
{ url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" },
{ url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" },
{ url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" },
{ url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" },
{ url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" },
{ url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" },
{ url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" },
{ url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" },
{ url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" },
{ url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" },
{ url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" },
{ url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" },
{ url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" },
{ url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" },
{ url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" },
{ url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" },
{ url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" },
{ url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" },
{ url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" },
{ url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" },
{ url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" },
{ url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" },
{ url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" },
{ url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" },
{ url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" },
{ url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" },
{ url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" },
{ url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" },
{ url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" },
{ url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" },
{ url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" },
{ url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" },
{ url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" },
{ url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" },
{ url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" },
{ url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" },
{ url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" },
]

31
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,31 @@
# Dev / e2e compose. Uses an ephemeral anonymous volume so each `up` starts
# with a clean SQLite database — ideal for Playwright runs that expect a
# fresh state.
#
# Usage:
# docker compose -f docker-compose.dev.yml up --build -d
# # ... run tests against http://localhost:8000 ...
# docker compose -f docker-compose.dev.yml down -v
services:
life-towers:
build:
context: .
args:
# Strip the PWA service worker from this image so `up --build` always
# serves the freshly-built FE assets instead of a stale, SW-cached app
# shell. See the SERVICE_WORKER arg in the Dockerfile.
SERVICE_WORKER: disabled
image: life-towers:dev
ports:
- "8000:8000"
volumes:
- /data
environment:
LIFE_TOWERS_ALLOWED_ORIGIN: ""
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/v1/health"]
interval: 2s
timeout: 3s
retries: 15
start_period: 5s

39
docker-compose.yml Normal file
View file

@ -0,0 +1,39 @@
services:
life-towers:
# Default to a registry image so `docker compose pull && up -d` deploys
# a new build. Override LIFE_TOWERS_IMAGE in your `.env` to pin a tag,
# or set it to e.g. `life-towers:local` and uncomment `build: .` for
# local builds.
image: ${LIFE_TOWERS_IMAGE:-life-towers:local}
# For a local production build, uncomment the block below. BASE_HREF must
# match the sub-path the app is served under (the published image is built
# with /towers/ by .forgejo/workflows/docker.yml).
# build:
# context: .
# args:
# BASE_HREF: /towers/
pull_policy: ${LIFE_TOWERS_PULL_POLICY:-missing}
ports:
- "${LIFE_TOWERS_PORT:-8000}:8000"
volumes:
# Named volume inherits ownership from the image (UID 1000 / appuser).
# To switch to a bind mount for easy host-side backups, replace with:
# - ./data:/data
# and first run: `mkdir -p data && sudo chown 1000:1000 data`.
- life-towers-data:/data
environment:
LIFE_TOWERS_ALLOWED_ORIGIN: "${LIFE_TOWERS_ALLOWED_ORIGIN:-}"
# Absolute public URL of the deployment, used to render correct canonical
# / Open Graph / Twitter tags. Required when served under a sub-path,
# because nginx strips the prefix before proxying so the backend can't
# otherwise infer it. Override in .env for a different host/path.
LIFE_TOWERS_PUBLIC_URL: "${LIFE_TOWERS_PUBLIC_URL:-https://schmelczer.dev/towers/}"
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
life-towers-data:

7
frontend/.browserslistrc Normal file
View file

@ -0,0 +1,7 @@
last 2 Chrome versions
last 2 Firefox versions
last 2 Safari versions
last 2 Edge versions
Firefox ESR
not dead
not IE 11

View file

@ -8,6 +8,10 @@ indent_size = 2
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md] [*.md]
max_line_length = off max_line_length = off
trim_trailing_whitespace = false trim_trailing_whitespace = false

50
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,50 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Playwright
/test-results
/playwright-report
/playwright/.cache
/visuals
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

12
frontend/.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
frontend/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
frontend/.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
frontend/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

11
frontend/README.md Normal file
View file

@ -0,0 +1,11 @@
# Life Towers Frontend
Angular frontend for the Life Towers app. See the root `README.md` for the full
stack setup.
```bash
npm start # dev server on :4200, with /api proxied to :8000
npm test # Vitest unit tests
npm run test:e2e # Playwright against PLAYWRIGHT_BASE_URL or localhost:8000
npm run build # production bundle
```

91
frontend/angular.json Normal file
View file

@ -0,0 +1,91 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"schematicCollections": [
"angular-eslint"
],
"analytics": false
},
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "16kB",
"maximumError": "32kB"
}
],
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
}
}

View file

@ -0,0 +1,112 @@
import { test, expect, webkit, chromium, devices, type Browser, type Page } from '@playwright/test';
/**
* Regression: tapping a block/task must open THAT entry in the block-edit
* carousel centered and active not a different one.
*
* This was a real, mobile-only bug (https://schmelczer.dev/towers/). It does NOT
* reproduce in desktop Chromium at a small viewport it needs a true mobile
* engine. On WebKit (iOS Safari) the carousel's opening `scrollTo` ran in a
* microtask right after the modal host is reparented to <body>, before the
* snap container's scroll range was established, so it no-op'd and every tap
* surfaced the same wrong card. Hence this spec drives real WebKit + Chromium
* mobile profiles directly rather than relying on the configured project.
*
* docker compose -f docker-compose.dev.yml up --build -d
* PLAYWRIGHT_BASE_URL=http://life-towers:8000 npx playwright test carousel-active-card
* docker compose -f docker-compose.dev.yml down -v
*/
const BASE = process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000';
async function loadSample(page: Page): Promise<void> {
await page.goto(BASE + '/');
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
await page.waitForTimeout(350);
await page.getByRole('button', { name: 'Load sample towers' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(1800); // let the falling animation settle
}
/** Centre offset (px) of the active card relative to the carousel viewport. */
async function activeCardOffCenter(page: Page): Promise<number> {
return page.evaluate(() => {
const c = document.querySelector('lt-block-edit .carousel') as HTMLElement | null;
const card = document.querySelector('lt-block-edit .card.active') as HTMLElement | null;
if (!c || !card) return 9999;
const cr = c.getBoundingClientRect();
const kr = card.getBoundingClientRect();
return Math.round(kr.left + kr.width / 2 - (cr.left + cr.width / 2));
});
}
async function closeCarousel(page: Page): Promise<void> {
await page.locator('lt-block-edit .exit').first().click({ force: true });
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(150);
}
async function runSuite(browser: Browser, deviceName: string): Promise<void> {
const ctx = await browser.newContext({ ...devices[deviceName] });
const page = await ctx.newPage();
await loadSample(page);
// ── Done blocks ───────────────────────────────────────────────────────────
// Tap a sample (first / middle / last) of the Reading tower's falling squares
// and confirm each opens — and centers — its own card.
const reading = page.locator('lt-tower').nth(0);
const squares = reading.locator('lt-block');
const ids: string[] = [];
for (let i = 0; i < (await squares.count()); i++) {
const id = await squares.nth(i).getAttribute('data-block-id');
if (id && !ids.includes(id)) ids.push(id);
}
expect(ids.length).toBeGreaterThan(2);
for (const id of [ids[0], ids[Math.floor(ids.length / 2)], ids[ids.length - 1]]) {
await reading.locator(`lt-block[data-block-id="${id}"]`).first().tap();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(700); // open scroll + the 150ms adjustPosition settle
await expect(page.locator('lt-block-edit .card.active')).toHaveAttribute('data-block-id', id);
expect(Math.abs(await activeCardOffCenter(page))).toBeLessThan(30);
await closeCarousel(page);
}
// ── Pending task ──────────────────────────────────────────────────────────
// Tap the 2nd task of the Side projects tower (it has two); the active card's
// description must be the one we tapped.
const sideProjects = page.locator('lt-tower').nth(1);
const header = sideProjects.locator('lt-tasks .header');
if (await header.count()) {
await header.first().click();
await page.waitForTimeout(400);
}
const task = sideProjects.locator('lt-tasks .task-description').nth(1);
const taskText = (await task.innerText()).trim();
await task.tap();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(700);
await expect(page.locator('lt-block-edit .card.active textarea')).toHaveValue(taskText);
expect(Math.abs(await activeCardOffCenter(page))).toBeLessThan(30);
await closeCarousel(page);
await ctx.close();
}
test('WebKit mobile: tapping a block/task opens its own carousel card', async () => {
const browser = await webkit.launch();
try {
await runSuite(browser, 'iPhone 13');
} finally {
await browser.close();
}
});
test('Chromium mobile: tapping a block/task opens its own carousel card', async () => {
const browser = await chromium.launch();
try {
await runSuite(browser, 'Pixel 7');
} finally {
await browser.close();
}
});

View file

@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
/**
* Regression: navigating between pages must NOT replay the falling animation.
*
* The bug: `lt-page` (and its date-range slider) were reused across navigation,
* so the previous page's stale `dateRange` was applied to the destination page's
* towers on their first render. When the destination's blocks are NEWER than the
* stale range they render off-screen (ascending) and then visibly "fall" in a
* frame later when the slider corrects the range i.e. all blocks fall at once.
* The fix rebuilds the `lt-page` subtree on page-id change (fresh slider/filter),
* matching a reload. This guards the destination ("Newer") page against falling.
*
* We detect a fall directly: hook the WAAPI `el.animate` used by `playFall` and
* the `.descend`/`.ascend` CSS `transitionrun`, both on `lt-block` hosts. These
* fire at the JS layer regardless of headless rendering, so the check is stable.
*
* The two pages are seeded into the offline cache with DISJOINT date ranges
* (destination strictly newer) the precise condition that triggered the bug.
*
* All ids (and the token) are generated here in Node `crypto.randomUUID()`
* throws inside the page on the CI origin (http://life-towers:8000), which is
* plain HTTP and therefore not a secure context.
*/
test.describe('navigation does not replay the falling animation', () => {
test('navigating to a newer-dated page keeps blocks at rest', async ({ page }) => {
const nowSec = Math.floor(Date.now() / 1000);
const HOUR = 3600;
const DAY = 86400;
// baseAgeDays/spacingHrs place each page's done blocks in its own window.
const tower = (name: string, h: number, baseAgeDays: number) => ({
id: randomUUID(),
name,
base_color: { h, s: 0.7, l: 0.55 },
blocks: [
{ id: randomUUID(), tag: 't', description: 'pending', is_done: false, difficulty: 2, created_at: nowSec },
...Array.from({ length: 6 }, (_unused, i) => ({
id: randomUUID(),
tag: 't',
description: `done ${i}`,
is_done: true,
difficulty: 1 + (i % 3),
created_at: nowSec - baseAgeDays * DAY - (i + 1) * 6 * HOUR,
})),
],
});
const mkPage = (name: string, h: number, baseAgeDays: number) => ({
id: randomUUID(),
name,
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [tower(name + ' A', h, baseAgeDays), tower(name + ' B', h + 0.2, baseAgeDays)],
});
// Page 0 ("Older") is the post-reload default; "Newer" is strictly more recent.
const tree = { pages: [mkPage('Older', 0.05, 6), mkPage('Newer', 0.55, 1)] };
const token = randomUUID();
await page.addInitScript(
({ tree, token }) => {
localStorage.setItem('life-towers.token.v4', token);
localStorage.setItem('life-towers.cache.v4.' + token, JSON.stringify(tree));
// Fall detectors on lt-block hosts (WAAPI playFall + CSS descend/ascend).
(window as unknown as { __falls: number }).__falls = 0;
const isBlock = (el: EventTarget | null) =>
el instanceof Element && el.tagName === 'LT-BLOCK';
const origAnimate = Element.prototype.animate;
Element.prototype.animate = function (kf: unknown, opts?: unknown) {
try {
const s = JSON.stringify(kf ?? '');
if (isBlock(this) && (s.includes('translateY') || s.includes('transform')))
(window as unknown as { __falls: number }).__falls++;
} catch {
/* ignore */
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return origAnimate.call(this, kf as any, opts as any);
};
document.addEventListener(
'transitionrun',
(e) => {
if ((e as TransitionEvent).propertyName === 'transform' && isBlock(e.target))
(window as unknown as { __falls: number }).__falls++;
},
true,
);
},
{ tree, token },
);
await page.goto('/');
// Lands on "Older"; wait for its stack to render and settle.
await page.waitForSelector('lt-block', { timeout: 15000 });
await page.waitForTimeout(2000);
// Reset the detector right before navigation, then switch to "Newer".
await page.evaluate(() => ((window as unknown as { __falls: number }).__falls = 0));
await page.locator('lt-select-add .top').first().click();
await page.locator('lt-select-add .option', { hasText: 'Newer' }).first().click();
// Give any fall a full beat to fire (the animation is 1.5s).
await page.waitForTimeout(1800);
const falls = await page.evaluate(() => (window as unknown as { __falls: number }).__falls);
expect(falls, 'block fall animations fired on navigation').toBe(0);
// Sanity: the destination page actually rendered its blocks.
expect(await page.locator('lt-block').count()).toBeGreaterThan(0);
});
});

220
frontend/e2e/smoke.spec.ts Normal file
View file

@ -0,0 +1,220 @@
import { test, expect, type Page } from '@playwright/test';
async function expectTaskListOpen(page: Page, description: string): Promise<void> {
const tasks = page.locator('lt-tasks', { hasText: description }).first();
const taskBody = tasks.locator('.all-task');
const taskRow = tasks.locator('.task-container', { hasText: description }).first();
await expect(tasks.locator('.header')).toHaveCount(0);
await expect
.poll(async () =>
taskBody.evaluate((el) => {
const height = el.getBoundingClientRect().height;
return height / Math.max(1, el.scrollHeight);
}),
)
.toBeGreaterThan(0.9);
await taskRow.locator('.tickbox').click({ trial: true });
}
/**
* Smoke test: drives the legacy-styled UI end-to-end.
*
* docker compose -f docker-compose.dev.yml up --build -d
* PLAYWRIGHT_BASE_URL=http://life-towers:8000 npx playwright test
* docker compose -f docker-compose.dev.yml down -v
*/
test.describe('Life Towers smoke test', () => {
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
await page.goto('/');
// Wait for init, then dismiss the welcome modal so the page controls are reachable.
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Start empty' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await expect(page.getByText('Add a new page to get started!')).toBeVisible();
// Create a page via the select-add dropdown.
await page.locator('lt-select-add .top').first().click();
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Hobbies');
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
// The page name now appears in the dropdown top.
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies');
// Create a tower.
await page.locator('img[alt="Add tower"]').click();
await page.locator('input[placeholder="New tower"]').fill('Side projects');
await page.locator('lt-tower-settings button[type="submit"]').click();
// Tower's name input is rendered with the tower name as its value.
await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects');
// Create a block.
await page.locator('img[alt="Add block"]').first().click();
// The tag input is inside an lt-select-add — open it and add a tag.
await page.locator('lt-block-edit lt-select-add .top').click();
await page
.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]')
.fill('learn');
await page
.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]')
.press('Enter');
await page.locator('textarea[placeholder="Write a description here…"]').fill(
'Modernise the towers app',
);
await page.getByLabel('Already done').uncheck();
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
// New block is pending → appears in the tasks accordion.
// (Tasks header shows N tasks.)
await expect(page.locator('lt-tasks')).toContainText('1');
// Open the tasks accordion + click the task to edit it, then flip done.
await page.locator('lt-tasks .header').click();
await page.locator('lt-tasks .task-description').click();
// Toggle done in the block-edit modal.
const putLanded = page.waitForResponse(
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
);
await page.locator('lt-block-edit .card.active').getByLabel('Already done').check();
await putLanded;
await page.locator('lt-block-edit .card.active .exit').click();
await page.waitForSelector('section.modal', { state: 'detached' });
// Done block now appears as a colored square in the tower's falling stack.
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
// Reload — everything must come back from the server.
await page.reload();
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
timeout: 15000,
});
await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects');
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
});
test('keep tasks open shows new pending tasks and survives immediate reload', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Start empty' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.locator('lt-select-add .top').first().click();
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Work');
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await page.locator('img[alt="Add tower"]').click();
await page.locator('input[placeholder="New tower"]').fill('Today');
await page.locator('lt-tower-settings button[type="submit"]').click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByText('Keep tasks open').click();
await page.locator('lt-settings .exit').click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.locator('img[alt="Add block"]').first().click();
const createCard = page.locator('lt-block-edit .create-card');
await expect(createCard.getByLabel('Already done')).not.toBeChecked();
await createCard.locator('lt-select-add .top').click();
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('ops');
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await createCard
.locator('textarea[placeholder="Write a description here…"]')
.fill('Review deploy notes');
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await expectTaskListOpen(page, 'Review deploy notes');
await page.reload();
await expect(page.locator('lt-select-add .top').first()).toContainText('Work', {
timeout: 15000,
});
await expectTaskListOpen(page, 'Review deploy notes');
await page.locator('img[alt="Add block"]').first().click();
await expect(page.locator('lt-block-edit .create-card').getByLabel('Already done')).not.toBeChecked();
});
test('keep tasks open expands existing pending tasks after reload', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Start empty' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.locator('lt-select-add .top').first().click();
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Ops');
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await page.locator('img[alt="Add tower"]').click();
await page.locator('input[placeholder="New tower"]').fill('Queue');
await page.locator('lt-tower-settings button[type="submit"]').click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.locator('img[alt="Add block"]').first().click();
await page.locator('lt-block-edit lt-select-add .top').click();
await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').fill('triage');
await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').press('Enter');
await page
.locator('textarea[placeholder="Write a description here…"]')
.fill('Clean up alerts');
await page.getByLabel('Already done').uncheck();
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByText('Keep tasks open').click();
await page.locator('lt-settings .exit').click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.reload();
await expect(page.locator('lt-select-add .top').first()).toContainText('Ops', {
timeout: 15000,
});
await expectTaskListOpen(page, 'Clean up alerts');
});
// Regression: opening the block-edit carousel must position the target card
// INSTANTLY, not animate a scroll across the whole strip. The carousel sets
// `scroll-behavior: smooth`, so scrollTo({behavior:'auto'}) would animate —
// very visible on mobile where the strip can be thousands of px wide.
test('block-edit carousel opens centered without animating the scroll', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Load sample towers' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
// Let the falling animation settle.
await page.waitForTimeout(1800);
// Open the carousel on a done block deep in the strip (the last square).
const squares = page.locator('lt-block');
const squareCount = await squares.count();
expect(squareCount).toBeGreaterThan(0); // sample data must have produced done blocks
await squares.nth(squareCount - 1).click();
await page.waitForSelector('lt-block-edit .carousel');
// Sample scrollLeft immediately and a frame later: an animated scroll would
// still be moving; an instant jump is already at its final value.
const carousel = page.locator('lt-block-edit .carousel');
const first = await carousel.evaluate((c: HTMLElement) => c.scrollLeft);
await page.waitForTimeout(60);
const second = await carousel.evaluate((c: HTMLElement) => c.scrollLeft);
expect(Math.abs(second - first)).toBeLessThan(2); // not mid-animation
// The active card is centered: left/right viewport gaps match within a few px.
const gaps = await page.locator('lt-block-edit .card.active').evaluate((el: HTMLElement) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: window.innerWidth - r.right };
});
expect(Math.abs(gaps.left - gaps.right)).toBeLessThan(8);
});
});

View file

@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
/**
* Regression guard for the tasks accordion ("todos") double-scrollbar bug.
*
* When the pending-task list is tall it must show EXACTLY ONE scrollbar (inside
* the white card), not two. The cause was two nested scroll containers both
* firing: the `lt-tasks` host (overflow:auto) AND the inner `.container` card
* (overflow-y:auto). The fix makes the host a height-bounding flex column that
* only CLIPS, leaving the inner card as the sole scroller.
*
* Seeds many pending tasks via a direct PUT (far more robust than driving the
* carousel ~18 times), then reloads so the store renders the seeded tree. All
* ids are generated here in Node `crypto.randomUUID()` throws in the page
* because the dev origin is plain HTTP (not a secure context).
*/
test('tasks accordion with many tasks shows a single scrollbar', async ({ page }) => {
await page.goto('/');
// init() mints + registers a token on load; wait for it to land.
await page.waitForFunction(() => !!localStorage.getItem('life-towers.token.v4'), null, {
timeout: 15000,
});
// Build a tree: one page, one tower, many PENDING (is_done:false) tasks.
const now = Math.floor(Date.now() / 1000);
const tree = {
pages: [
{
id: randomUUID(),
name: 'Hobbies',
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [
{
id: randomUUID(),
name: 'Reading',
base_color: { h: 0.92, s: 0.7, l: 0.55 },
blocks: Array.from({ length: 18 }, (_, i) => ({
id: randomUUID(),
tag: 'novel',
description: `Pending task ${i + 1} — read another chapter tonight`,
is_done: false,
difficulty: 1,
created_at: now - i * 3600,
})),
},
],
},
],
};
// PUT the tree (unguarded — no If-Match), then reload to render it.
const status = await page.evaluate(async (body) => {
const res = await fetch('api/v1/data', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('life-towers.token.v4')}`,
},
body: JSON.stringify(body),
});
return res.status;
}, tree);
expect(status).toBe(200);
await page.reload();
await page.waitForSelector('lt-tower', { timeout: 15000 });
// Open the accordion (collapsed by default since keep_tasks_open is false).
await page.locator('lt-tasks .header').first().click();
await page.waitForTimeout(400); // expand animation (200ms) + buffer
// Measure the scroll topology in the accordion subtree.
const m = await page.locator('lt-tasks').first().evaluate((host) => {
const card = host.querySelector('.container') as HTMLElement;
const cs = (el: Element) => getComputedStyle(el);
const overflows = (el: HTMLElement) => el.scrollHeight > el.clientHeight + 1;
return {
hostOverflowY: cs(host).overflowY,
cardOverflowY: cs(card).overflowY,
cardOverflows: overflows(card),
hostHasOwnOverflow: host.scrollHeight > host.clientHeight + 1,
hostHeight: host.getBoundingClientRect().height,
cardHeight: card.getBoundingClientRect().height,
viewport30vh: window.innerHeight * 0.3,
};
});
// The list must actually overflow, otherwise the test proves nothing.
expect(m.cardOverflows).toBe(true);
// Exactly one scroller: the inner card. The host only clips.
expect(m.hostOverflowY).toBe('hidden');
expect(m.cardOverflowY).toBe('auto');
// The host has no overflowing content of its own → no second scrollbar.
expect(m.hostHasOwnOverflow).toBe(false);
// The card shrank to fit within the host's bound (not clipped past it).
expect(m.cardHeight).toBeLessThanOrEqual(m.hostHeight + 1);
// Host height is capped by min(30vh, 45%) → at most ~30vh.
expect(m.hostHeight).toBeLessThanOrEqual(m.viewport30vh + 2);
await page.locator('lt-tasks .container').first().screenshot({
path: 'visuals/04d-tasks-accordion-overflow-single-scrollbar.png',
});
});

View file

@ -0,0 +1,228 @@
import { test } from '@playwright/test';
test.skip(
process.env['CAPTURE_VISUALS'] !== '1',
'Set CAPTURE_VISUALS=1 to run the visual screenshot capture suite.',
);
/**
* Visual capture: drives the UI into key states and writes screenshots
* for human review of the legacy-styled design.
*/
test.describe('Life Towers visuals', () => {
test('capture key UI states', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
await page.waitForTimeout(350); // let the welcome modal finish fade-in
await page.screenshot({ path: 'visuals/01-welcome-modal.png', fullPage: true });
// Dismiss the welcome modal with Start empty, then continue.
await page.getByRole('button', { name: 'Start empty' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.screenshot({ path: 'visuals/01b-empty-state-after-dismiss.png', fullPage: true });
// Open the page dropdown (without creating a page yet).
await page.locator('lt-select-add .top').first().click();
// Wait for the slide-down animation to finish (200ms transform + buffer).
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/02-page-dropdown-open.png', fullPage: true });
// Create the page.
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Hobbies');
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await page.locator('body').click({ position: { x: 10, y: 400 } });
await page.waitForTimeout(200);
// ── Add a tower ─────────────────────────────────────────────────────────
await page.locator('img[alt="Add tower"]').click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.locator('input[placeholder="New tower"]').fill('Reading');
await page.screenshot({ path: 'visuals/03-new-tower-modal.png', fullPage: true });
await page.locator('lt-tower-settings button[type="submit"]').click();
await page.waitForSelector('section.modal', { state: 'detached' });
// ── Open the block-edit carousel (empty) ────────────────────────────────
await page.locator('img[alt="Add block"]').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/04-block-edit-carousel-empty.png', fullPage: true });
// Fill in the create card. Make this one a NOT-finished task so the
// tasks accordion has a row to show off (with the tickbox).
const createCard = page.locator('lt-block-edit .create-card');
await createCard.locator('lt-select-add .top').click();
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('novel');
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
await createCard
.locator('textarea[placeholder="Write a description here…"]')
.fill('Finish The Brothers Karamazov');
// Uncheck "Already done" so this becomes a pending task.
await createCard.getByLabel('Already done').uncheck();
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
// Open the tasks accordion to show the new tickbox.
await page.waitForTimeout(200);
await page.locator('lt-tasks .header').click();
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
// Hover the tickbox: must NOT pop a scrollbar in the accordion, must NOT
// paint the global button-underline bar across the top, and the ✓ must stay
// centred (regression guard — see tasks.component .tickbox::after).
await page.locator('lt-tasks .tickbox').first().hover();
await page.waitForTimeout(350);
await page.locator('lt-tasks .container').screenshot({
path: 'visuals/04c-tasks-tickbox-hover.png',
});
// Add a couple more blocks.
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
await page.locator('img[alt="Add block"]').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
const cc = page.locator('lt-block-edit .create-card');
// Re-use existing tag "novel" — click it from the select-add list.
await cc.locator('lt-select-add .top').click();
await page.waitForTimeout(100);
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
}
// Wait for the falling animation to finish.
await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/05-populated-falling-blocks.png', fullPage: true });
// ── Open carousel WITH existing blocks ──────────────────────────────────
await page.locator('img[alt="Add block"]').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/06-carousel-with-existing.png', fullPage: true });
// Scroll one card to the left (to focus an existing block, showing neighbour mask).
await page.locator('lt-block-edit').evaluate((el) => {
const c = el.querySelector('.carousel') as HTMLElement | null;
if (c) c.scrollBy({ left: -400, behavior: 'instant' as ScrollBehavior });
});
await page.waitForTimeout(400);
await page.screenshot({ path: 'visuals/07-carousel-existing-focused.png', fullPage: true });
// Close the carousel via the exit X.
await page.locator('lt-block-edit .exit').first().click({ force: true });
await page.waitForSelector('section.modal', { state: 'detached' });
// ── Add a second tower so we can show drag-drop ─────────────────────────
await page.locator('img[alt="Add tower"]').click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.locator('input[placeholder="New tower"]').fill('Side projects');
await page.locator('lt-tower-settings button[type="submit"]').click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(300);
// ── Drag a tower over the trash (capture mid-drag) ──────────────────────
const firstTowerHandle = page.locator('lt-tower').first();
const trash = page.locator('img.trash');
const tb = await firstTowerHandle.boundingBox();
if (tb) {
// Pick up the tower from its center, then move toward the trash.
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
await page.mouse.down();
// Move in two stages — first to dislodge cdkDrag, then over the trash.
await page.mouse.move(tb.x + tb.width / 2 + 30, tb.y + tb.height / 2 + 30, { steps: 8 });
const trb = await trash.boundingBox();
if (trb) {
await page.mouse.move(trb.x + trb.width / 2, trb.y + trb.height / 2, { steps: 12 });
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/08-dragging-over-trash.png', fullPage: true });
}
// Actually release over the trash to trigger the confirm-delete modal.
await page.mouse.up();
await page.waitForTimeout(400);
await page.screenshot({ path: 'visuals/08b-confirm-delete-modal.png', fullPage: true });
// Cancel — don't actually delete the tower.
await page.locator('.confirm-buttons button').filter({ hasText: 'Cancel' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
}
// ── Move the date-range slider thumb — blocks should ascend out ────────
const slider = page.locator('lt-double-slider input[type="range"]').first();
const sb = await slider.boundingBox();
if (sb) {
// Drag the left thumb from 0 toward the right (~80% of slider width).
await page.mouse.move(sb.x + 4, sb.y + sb.height / 2);
await page.mouse.down();
await page.mouse.move(sb.x + sb.width * 0.8, sb.y + sb.height / 2, { steps: 16 });
await page.mouse.up();
// Wait long enough for the 1.5s ascend transition.
await page.waitForTimeout(1700);
await page.screenshot({ path: 'visuals/09-date-filter-ascended.png', fullPage: true });
// Restore the range and screenshot the descend back to rest.
await page.mouse.move(sb.x + sb.width * 0.8, sb.y + sb.height / 2);
await page.mouse.down();
await page.mouse.move(sb.x + 4, sb.y + sb.height / 2, { steps: 16 });
await page.mouse.up();
await page.waitForTimeout(1700);
await page.screenshot({ path: 'visuals/10-date-filter-restored.png', fullPage: true });
}
// ── Settings modal ──────────────────────────────────────────────────────
await page.getByRole('button', { name: 'Settings' }).click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/11-settings-modal.png', fullPage: true });
});
test('"Load sample towers" populates a sample page', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
await page.waitForTimeout(350);
await page.getByRole('button', { name: 'Load sample towers' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/12-example-data.png', fullPage: true });
});
test('Mobile viewport — welcome + example + carousel', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await ctx.newPage();
await page.goto((process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000') + '/');
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/13-mobile-welcome.png', fullPage: true });
await page.getByRole('button', { name: 'Load sample towers' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
// New-tower modal on mobile — the X button must not overlap the name input.
await page.locator('img[alt="Add tower"]').click();
await page.waitForSelector('section.modal.active');
await page.locator('input[placeholder="New tower"]').fill('A long tower name to test');
await page.waitForTimeout(350);
await page.screenshot({ path: 'visuals/14b-mobile-new-tower-modal.png', fullPage: true });
await page.locator('lt-tower-settings .exit').click({ force: true });
await page.waitForSelector('section.modal', { state: 'detached' });
await page.waitForTimeout(350);
// Open the block-edit carousel for the first tower's first task. The sample
// page keeps tasks open (keep_tasks_open: true), so the accordion header is
// absent — only click it when present (i.e. on a collapsed accordion).
const tasksHeader = page.locator('lt-tasks .header').first();
if (await tasksHeader.count()) {
await tasksHeader.click();
await page.waitForTimeout(400);
}
await page.locator('lt-tasks .task-description').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(400);
await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true });
await ctx.close();
});
});

55
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,55 @@
// @ts-check
const eslint = require('@eslint/js');
const { defineConfig } = require('eslint/config');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = defineConfig([
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.stylistic,
angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: ['lt', 'app'],
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: ['lt', 'app'],
style: 'kebab-case',
},
],
// Allow output named 'close' - it is not a standard DOM event on custom elements
'@angular-eslint/no-output-native': 'off',
// Allow empty arrow functions in catch clauses for fire-and-forget patterns
'@typescript-eslint/no-empty-function': 'off',
},
},
{
files: ['**/*.html'],
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
rules: {
// Relax label association check our modal inputs are associated via id/for
'@angular-eslint/template/label-has-associated-control': 'warn',
},
},
{
// Vitest test files relax unused-vars for test utilities
files: ['**/*.vitest.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
]);

28
frontend/ngsw-config.json Normal file
View file

@ -0,0 +1,28 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"]
}
}
]
}

10592
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
frontend/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "vitest --run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"lint": "ng lint"
},
"private": true,
"packageManager": "npm@10.9.2",
"dependencies": {
"@angular/cdk": "^21.2.13",
"@plausible-analytics/tracker": "^0.4.5",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.13",
"@angular/cli": "^21.2.13",
"@angular/compiler-cli": "^21.2.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@vitest/coverage-v8": "^4.1.7",
"angular-eslint": "21.4.0",
"eslint": "^10.3.0",
"jsdom": "^28.1.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"typescript-eslint": "8.59.2",
"vitest": "^4.1.7"
}
}

View file

@ -0,0 +1,30 @@
import { defineConfig, devices } from '@playwright/test';
/**
* E2E tests target the full stack served by docker-compose.dev.yml at
* http://localhost:8000 (FastAPI serving both the API and the built SPA).
* Override with PLAYWRIGHT_BASE_URL to point at a different deployment.
*
* docker compose -f docker-compose.dev.yml up --build -d
* npm run test:e2e
* docker compose -f docker-compose.dev.yml down -v
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

7
frontend/proxy.conf.json Normal file
View file

@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false,
"changeOrigin": false
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

Before

Width:  |  Height:  |  Size: 746 B

After

Width:  |  Height:  |  Size: 746 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,13 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
</defs><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,13 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
</defs><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,13 @@
<svg width="1118" height="200" viewBox="0 0 1118 200" xmlns="http://www.w3.org/2000/svg"><defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
</defs><g transform="scale(0.39063)"><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
</g></g><g transform="translate(229.76 158.40)"><path d="M91.04 0L14.24 0L14.24-113.60L25.44-113.60L25.44-9.92L91.04-9.92L91.04 0M116.16 0L105.28 0L105.28-83.36L116.16-83.36L116.16 0M116.16-100.80L105.28-100.80L105.28-116.80L116.16-116.80L116.16-100.80M155.04 0L144.16 0L144.16-74.72L132.64-74.72L132.64-83.36L144.16-83.36L144.16-85.60Q144.16-95.84 147.12-103.20Q150.08-110.56 155.60-114.48Q161.12-118.40 168.64-118.40Q173.60-118.40 178.24-116.96Q182.88-115.52 186.24-113.12L182.88-105.28Q180.80-107.04 177.44-108Q174.08-108.96 170.72-108.96Q163.20-108.96 159.12-103.04Q155.04-97.12 155.04-85.92L155.04-83.36L178.08-83.36L178.08-74.72L155.04-74.72L155.04 0M228.96 1.60Q220 1.60 212.32-1.84Q204.64-5.28 199.04-11.28Q193.44-17.28 190.32-25.12Q187.20-32.96 187.20-41.92Q187.20-53.60 192.56-63.36Q197.92-73.12 207.36-78.96Q216.80-84.80 228.80-84.80Q241.12-84.80 250.32-78.88Q259.52-72.96 264.80-63.28Q270.08-53.60 270.08-42.08Q270.08-40.80 270.08-39.60Q270.08-38.40 269.92-37.76L198.56-37.76Q199.36-28.80 203.60-21.84Q207.84-14.88 214.64-10.80Q221.44-6.72 229.44-6.72Q237.60-6.72 244.88-10.88Q252.16-15.04 255.04-21.76L264.48-19.20Q261.92-13.28 256.64-8.48Q251.36-3.68 244.24-1.04Q237.12 1.60 228.96 1.60M198.24-45.60L259.84-45.60Q259.20-54.72 254.96-61.60Q250.72-68.48 243.92-72.40Q237.12-76.32 228.96-76.32Q220.80-76.32 214.08-72.40Q207.36-68.48 203.12-61.52Q198.88-54.56 198.24-45.60M410.56-113.60L410.56-103.68L370.08-103.68L370.08 0L358.88 0L358.88-103.68L318.40-103.68L318.40-113.60L410.56-113.60M460.80 1.60Q451.84 1.60 444.24-1.84Q436.64-5.28 431.12-11.28Q425.60-17.28 422.56-25.04Q419.52-32.80 419.52-41.44Q419.52-50.40 422.56-58.16Q425.60-65.92 431.20-71.92Q436.80-77.92 444.40-81.36Q452-84.80 460.96-84.80Q469.92-84.80 477.44-81.36Q484.96-77.92 490.56-71.92Q496.16-65.92 499.20-58.16Q502.24-50.40 502.24-41.44Q502.24-32.80 499.20-25.04Q496.16-17.28 490.64-11.28Q485.12-5.28 477.52-1.84Q469.92 1.60 460.80 1.60M430.56-41.28Q430.56-32 434.64-24.40Q438.72-16.80 445.60-12.40Q452.48-8 460.80-8Q469.12-8 476-12.48Q482.88-16.96 487.04-24.72Q491.20-32.48 491.20-41.60Q491.20-50.88 487.04-58.56Q482.88-66.24 476-70.72Q469.12-75.20 460.80-75.20Q452.48-75.20 445.68-70.56Q438.88-65.92 434.72-58.32Q430.56-50.72 430.56-41.28M595.20-11.20L625.44-83.36L636.16-83.36L600.32 0L590.88 0L573.44-41.44L556.16 0L546.72 0L510.88-83.36L521.44-83.36L551.84-11.20L567.20-48.80L553.12-83.20L562.88-83.20L573.44-56.16L584.16-83.20L593.76-83.20L579.84-48.80L595.20-11.20M686.56 1.60Q677.60 1.60 669.92-1.84Q662.24-5.28 656.64-11.28Q651.04-17.28 647.92-25.12Q644.80-32.96 644.80-41.92Q644.80-53.60 650.16-63.36Q655.52-73.12 664.96-78.96Q674.40-84.80 686.40-84.80Q698.72-84.80 707.92-78.88Q717.12-72.96 722.40-63.28Q727.68-53.60 727.68-42.08Q727.68-40.80 727.68-39.60Q727.68-38.40 727.52-37.76L656.16-37.76Q656.96-28.80 661.20-21.84Q665.44-14.88 672.24-10.80Q679.04-6.72 687.04-6.72Q695.20-6.72 702.48-10.88Q709.76-15.04 712.64-21.76L722.08-19.20Q719.52-13.28 714.24-8.48Q708.96-3.68 701.84-1.04Q694.72 1.60 686.56 1.60M655.84-45.60L717.44-45.60Q716.80-54.72 712.56-61.60Q708.32-68.48 701.52-72.40Q694.72-76.32 686.56-76.32Q678.40-76.32 671.68-72.40Q664.96-68.48 660.72-61.52Q656.48-54.56 655.84-45.60M786.08-83.68L786.08-73.76Q775.20-73.44 766.96-67.68Q758.72-61.92 755.36-51.84L755.36 0L744.48 0L744.48-83.36L754.72-83.36L754.72-63.36Q759.04-72.16 766.16-77.60Q773.28-83.04 781.28-83.68Q782.88-83.84 784.08-83.84Q785.28-83.84 786.08-83.68M828 1.60Q817.76 1.60 808.96-1.76Q800.16-5.12 793.76-12L798.24-19.68Q805.28-13.12 812.40-10.16Q819.52-7.20 827.52-7.20Q837.28-7.20 843.36-11.12Q849.44-15.04 849.44-22.40Q849.44-27.36 846.48-30Q843.52-32.64 838-34.32Q832.48-36 824.80-37.92Q816.16-40.32 810.32-42.96Q804.48-45.60 801.52-49.68Q798.56-53.76 798.56-60.32Q798.56-68.48 802.64-73.84Q806.72-79.20 813.84-82Q820.96-84.80 829.76-84.80Q839.36-84.80 846.72-81.76Q854.08-78.72 858.72-73.28L853.44-65.92Q848.96-71.04 842.80-73.52Q836.64-76 829.12-76Q824-76 819.36-74.64Q814.72-73.28 811.76-70.16Q808.80-67.04 808.80-61.60Q808.80-57.12 811.04-54.64Q813.28-52.16 817.76-50.48Q822.24-48.80 828.80-46.88Q838.24-44.32 845.28-41.68Q852.32-39.04 856.16-34.88Q860-30.72 860-23.20Q860-11.52 851.20-4.96Q842.40 1.60 828 1.60" fill="#5d576b"/></g></svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

13
frontend/public/logo.svg Normal file
View file

@ -0,0 +1,13 @@
<svg width="1118" height="200" viewBox="0 0 1118 200" xmlns="http://www.w3.org/2000/svg"><defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
</defs><g transform="scale(0.39063)"><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
</g></g><g transform="translate(229.76 158.40)"><path d="M91.04 0L14.24 0L14.24-113.60L25.44-113.60L25.44-9.92L91.04-9.92L91.04 0M116.16 0L105.28 0L105.28-83.36L116.16-83.36L116.16 0M116.16-100.80L105.28-100.80L105.28-116.80L116.16-116.80L116.16-100.80M155.04 0L144.16 0L144.16-74.72L132.64-74.72L132.64-83.36L144.16-83.36L144.16-85.60Q144.16-95.84 147.12-103.20Q150.08-110.56 155.60-114.48Q161.12-118.40 168.64-118.40Q173.60-118.40 178.24-116.96Q182.88-115.52 186.24-113.12L182.88-105.28Q180.80-107.04 177.44-108Q174.08-108.96 170.72-108.96Q163.20-108.96 159.12-103.04Q155.04-97.12 155.04-85.92L155.04-83.36L178.08-83.36L178.08-74.72L155.04-74.72L155.04 0M228.96 1.60Q220 1.60 212.32-1.84Q204.64-5.28 199.04-11.28Q193.44-17.28 190.32-25.12Q187.20-32.96 187.20-41.92Q187.20-53.60 192.56-63.36Q197.92-73.12 207.36-78.96Q216.80-84.80 228.80-84.80Q241.12-84.80 250.32-78.88Q259.52-72.96 264.80-63.28Q270.08-53.60 270.08-42.08Q270.08-40.80 270.08-39.60Q270.08-38.40 269.92-37.76L198.56-37.76Q199.36-28.80 203.60-21.84Q207.84-14.88 214.64-10.80Q221.44-6.72 229.44-6.72Q237.60-6.72 244.88-10.88Q252.16-15.04 255.04-21.76L264.48-19.20Q261.92-13.28 256.64-8.48Q251.36-3.68 244.24-1.04Q237.12 1.60 228.96 1.60M198.24-45.60L259.84-45.60Q259.20-54.72 254.96-61.60Q250.72-68.48 243.92-72.40Q237.12-76.32 228.96-76.32Q220.80-76.32 214.08-72.40Q207.36-68.48 203.12-61.52Q198.88-54.56 198.24-45.60" fill="#5d576b"/><path d="M410.56-113.60L410.56-103.68L370.08-103.68L370.08 0L358.88 0L358.88-103.68L318.40-103.68L318.40-113.60L410.56-113.60M460.80 1.60Q451.84 1.60 444.24-1.84Q436.64-5.28 431.12-11.28Q425.60-17.28 422.56-25.04Q419.52-32.80 419.52-41.44Q419.52-50.40 422.56-58.16Q425.60-65.92 431.20-71.92Q436.80-77.92 444.40-81.36Q452-84.80 460.96-84.80Q469.92-84.80 477.44-81.36Q484.96-77.92 490.56-71.92Q496.16-65.92 499.20-58.16Q502.24-50.40 502.24-41.44Q502.24-32.80 499.20-25.04Q496.16-17.28 490.64-11.28Q485.12-5.28 477.52-1.84Q469.92 1.60 460.80 1.60M430.56-41.28Q430.56-32 434.64-24.40Q438.72-16.80 445.60-12.40Q452.48-8 460.80-8Q469.12-8 476-12.48Q482.88-16.96 487.04-24.72Q491.20-32.48 491.20-41.60Q491.20-50.88 487.04-58.56Q482.88-66.24 476-70.72Q469.12-75.20 460.80-75.20Q452.48-75.20 445.68-70.56Q438.88-65.92 434.72-58.32Q430.56-50.72 430.56-41.28M595.20-11.20L625.44-83.36L636.16-83.36L600.32 0L590.88 0L573.44-41.44L556.16 0L546.72 0L510.88-83.36L521.44-83.36L551.84-11.20L567.20-48.80L553.12-83.20L562.88-83.20L573.44-56.16L584.16-83.20L593.76-83.20L579.84-48.80L595.20-11.20M686.56 1.60Q677.60 1.60 669.92-1.84Q662.24-5.28 656.64-11.28Q651.04-17.28 647.92-25.12Q644.80-32.96 644.80-41.92Q644.80-53.60 650.16-63.36Q655.52-73.12 664.96-78.96Q674.40-84.80 686.40-84.80Q698.72-84.80 707.92-78.88Q717.12-72.96 722.40-63.28Q727.68-53.60 727.68-42.08Q727.68-40.80 727.68-39.60Q727.68-38.40 727.52-37.76L656.16-37.76Q656.96-28.80 661.20-21.84Q665.44-14.88 672.24-10.80Q679.04-6.72 687.04-6.72Q695.20-6.72 702.48-10.88Q709.76-15.04 712.64-21.76L722.08-19.20Q719.52-13.28 714.24-8.48Q708.96-3.68 701.84-1.04Q694.72 1.60 686.56 1.60M655.84-45.60L717.44-45.60Q716.80-54.72 712.56-61.60Q708.32-68.48 701.52-72.40Q694.72-76.32 686.56-76.32Q678.40-76.32 671.68-72.40Q664.96-68.48 660.72-61.52Q656.48-54.56 655.84-45.60M786.08-83.68L786.08-73.76Q775.20-73.44 766.96-67.68Q758.72-61.92 755.36-51.84L755.36 0L744.48 0L744.48-83.36L754.72-83.36L754.72-63.36Q759.04-72.16 766.16-77.60Q773.28-83.04 781.28-83.68Q782.88-83.84 784.08-83.84Q785.28-83.84 786.08-83.68M828 1.60Q817.76 1.60 808.96-1.76Q800.16-5.12 793.76-12L798.24-19.68Q805.28-13.12 812.40-10.16Q819.52-7.20 827.52-7.20Q837.28-7.20 843.36-11.12Q849.44-15.04 849.44-22.40Q849.44-27.36 846.48-30Q843.52-32.64 838-34.32Q832.48-36 824.80-37.92Q816.16-40.32 810.32-42.96Q804.48-45.60 801.52-49.68Q798.56-53.76 798.56-60.32Q798.56-68.48 802.64-73.84Q806.72-79.20 813.84-82Q820.96-84.80 829.76-84.80Q839.36-84.80 846.72-81.76Q854.08-78.72 858.72-73.28L853.44-65.92Q848.96-71.04 842.80-73.52Q836.64-76 829.12-76Q824-76 819.36-74.64Q814.72-73.28 811.76-70.16Q808.80-67.04 808.80-61.60Q808.80-57.12 811.04-54.64Q813.28-52.16 817.76-50.48Q822.24-48.80 828.80-46.88Q838.24-44.32 845.28-41.68Q852.32-39.04 856.16-34.88Q860-30.72 860-23.20Q860-11.52 851.20-4.96Q842.40 1.60 828 1.60" fill="#a2666f"/></g></svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -0,0 +1,59 @@
{
"name": "Life Towers",
"short_name": "Towers",
"display": "standalone",
"scope": "./",
"start_url": "./",
"theme_color": "#a2666f",
"background_color": "#fffcf0",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

38
frontend/safety-worker.js Normal file
View file

@ -0,0 +1,38 @@
/*
* Safety service worker shipped ONLY in the dev / e2e Docker image
* (Dockerfile build arg SERVICE_WORKER=disabled) in place of Angular's real
* ngsw-worker.js.
*
* Why it exists: the dev compose builds a production SPA bundle, so the PWA
* service worker is enabled (`enabled: !isDevMode()` in app.config.ts). On the
* fixed localhost:8000 origin it would keep serving a stale, client-cached app
* shell across `docker compose -f docker-compose.dev.yml up --build` rebuilds,
* shadowing freshly-built FE assets.
*
* This worker's only job is to disappear: it claims any open clients, deletes
* every Cache Storage entry, then unregisters itself. Any previously-installed
* real service worker is evicted on its next update check (the browser fetches
* this changed ngsw-worker.js and replaces the old one with this), and no
* service worker is ever left controlling the page so assets are always
* fetched from network, where index.html is `no-cache` and bundles are
* content-hashed.
*
* It deliberately does NOT call client.navigate(): the bundle re-registers on
* every load, so a forced reload would create a loop. The only cost is a
* harmless register -> unregister cycle per load.
*/
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
try {
await self.clients.claim();
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
} finally {
await self.registration.unregister();
}
})(),
);
});

View file

@ -0,0 +1,20 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
isDevMode,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideHttpClient(withFetch()),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};

21
frontend/src/app/app.ts Normal file
View file

@ -0,0 +1,21 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
import { StoreService } from './services/store.service';
import { AnalyticsService } from './services/analytics.service';
import { PagesComponent } from './components/pages/pages.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [PagesComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<lt-pages />`,
})
export class App implements OnInit {
private readonly store = inject(StoreService);
private readonly analytics = inject(AnalyticsService);
ngOnInit(): void {
this.analytics.init();
void this.store.init();
}
}

View file

@ -0,0 +1,62 @@
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
/**
* A block rendered as a small COLORED SQUARE (1/6 of tower width).
* Only DONE blocks appear here; pending blocks appear in the tasks accordion.
* Clicking opens the block-edit modal in the parent tower component.
*/
@Component({
selector: 'lt-block',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
role="button"
tabindex="0"
aria-label="Edit completed task"
[class.hovered]="hovered()"
[style.background-color]="color()"
(click)="clicked.emit()"
(keydown.enter)="clicked.emit()"
(keydown.space)="$event.preventDefault(); clicked.emit()"
></div>
`,
styles: `
@import '../../../library/main';
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6);
div {
position: absolute;
width: 100%;
height: 100%;
cursor: pointer;
@media (hover: hover) and (pointer: fine) {
transition: transform $long-animation-time;
&:hover,
&.hovered {
transform: translateY(4px);
}
}
}
}
`,
})
export class BlockComponent {
readonly block = input.required<Block>();
readonly baseColor = input.required<HslColor>();
readonly hovered = input(false);
/** Emits when the square is clicked — parent opens the block-edit modal. */
readonly clicked = output<void>();
readonly color = computed(() => getColorOfTag(this.block().tag, this.baseColor()));
}

View file

@ -0,0 +1,949 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
effect,
viewChild,
ElementRef,
AfterViewInit,
HostListener,
untracked,
} from '@angular/core';
import { Block, HslColor } from '../../models';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { getColorOfTag } from '../../utils/color';
export interface BlockEditSave {
/** null = create a new block */
id: string | null;
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
interface EditedValue {
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
function clampDifficulty(value: number): number {
return Math.max(1, Math.min(100, value));
}
export function createDoneValue(defaultDone: boolean, currentDone: boolean, edited: boolean): boolean {
return edited ? currentDone : defaultDone;
}
@Component({
selector: 'lt-block-edit',
standalone: true,
imports: [SelectAddComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (viewTitle()) {
<h2 class="view-title">{{ viewTitle() }}</h2>
}
<section
#container
class="carousel"
(scroll)="onScroll()"
(click)="onBackdropClick($event)"
(keydown.enter)="onBackdropClick($any($event))"
tabindex="-1"
>
<div class="card placeholder"></div>
@for (b of blocks(); track b.id; let i = $index) {
<div
class="card"
[attr.data-block-id]="b.id"
[class.active]="activeIdx() === i + 1"
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
role="button"
tabindex="0"
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
(click)="onCardClick(i + 1)"
(keydown.enter)="onCardKey($event, i + 1)"
(keydown.space)="onCardKey($event, i + 1)"
>
<div class="mask"></div>
<div class="header">
<button
class="exit"
type="button"
aria-label="Close"
(click)="close.emit(); $event.stopPropagation()"
></button>
<div
class="block-dot"
[style.background-color]="colorOfTagForBlock(b.id)"
></div>
<h1>{{ formatDate(b.created_at, true) }}</h1>
</div>
<div class="select-add-container">
<lt-select-add
[items]="tags()"
[selected]="editedFor(b.id).tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
[placeholder]="tagPlaceholder('Tag this item…')"
(select)="updateTag(b.id, $event)"
(add)="updateTag(b.id, $event)"
/>
</div>
<textarea
placeholder="Write a description here…"
maxlength="10000"
[value]="editedFor(b.id).description"
(input)="updateDescription(b.id, $any($event.target).value)"
(blur)="flushExisting(b.id)"
></textarea>
<label class="done-checkbox">
<input
type="checkbox"
[checked]="editedFor(b.id).is_done"
(change)="updateDone(b.id, $any($event.target).checked)"
/>
<span>Already done</span>
</label>
<div class="difficulty">
<span class="label">Difficulty</span>
<div class="stepper">
<button
type="button"
class="step"
aria-label="Decrease difficulty"
[disabled]="editedFor(b.id).difficulty <= 1"
(click)="updateDifficulty(b.id, -1); $event.stopPropagation()"
></button>
<span class="value">{{ editedFor(b.id).difficulty }}</span>
<button
type="button"
class="step"
aria-label="Increase difficulty"
[disabled]="editedFor(b.id).difficulty >= 100"
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
>+</button>
</div>
</div>
<div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
<button (click)="saveAndExit(b.id); $event.stopPropagation()">Save and exit</button>
</div>
</div>
}
<div
class="card create-card"
[class.active]="activeIdx() === blocks().length + 1"
[class.near-active]="activeIdx() === blocks().length"
role="button"
tabindex="0"
aria-label="Focus create card"
(click)="onCardClick(blocks().length + 1)"
(keydown.enter)="onCardKey($event, blocks().length + 1)"
(keydown.space)="onCardKey($event, blocks().length + 1)"
>
<div class="mask"></div>
<div class="header">
<button
class="exit"
type="button"
aria-label="Close"
(click)="close.emit(); $event.stopPropagation()"
></button>
<div
class="block-dot"
[style.background-color]="colorOfNewTag()"
></div>
<h1>Create now</h1>
</div>
<div class="select-add-container" [class.required-empty]="!newValue().tag">
<lt-select-add
[items]="tags()"
[selected]="newValue().tag"
[alwaysDropShadow]="true"
[onlyShadowBorder]="true"
[placeholder]="tagPlaceholder('Set a category…')"
(select)="updateNewTag($event)"
(add)="updateNewTag($event)"
/>
</div>
<textarea
placeholder="Write a description here…"
maxlength="10000"
[value]="newValue().description"
(input)="updateNewDescription($any($event.target).value)"
(keydown.enter)="onNewDescriptionEnter($event)"
></textarea>
<label class="done-checkbox">
<input
type="checkbox"
[checked]="newValue().is_done"
(change)="updateNewDone($any($event.target).checked)"
/>
<span>Already done</span>
</label>
<div class="difficulty">
<span class="label">Difficulty</span>
<div class="stepper">
<button
type="button"
class="step"
aria-label="Decrease difficulty"
[disabled]="newValue().difficulty <= 1"
(click)="updateNewDifficulty(-1); $event.stopPropagation()"
></button>
<span class="value">{{ newValue().difficulty }}</span>
<button
type="button"
class="step"
aria-label="Increase difficulty"
[disabled]="newValue().difficulty >= 100"
(click)="updateNewDifficulty(1); $event.stopPropagation()"
>+</button>
</div>
</div>
<div class="bottom">
<button
(click)="submitNew(); $event.stopPropagation()"
[disabled]="!newValue().tag"
>
Create and exit
</button>
</div>
</div>
<div class="card placeholder"></div>
</section>
`,
styles: `
@import '../../../library/main';
:host {
@include center-child();
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
// Cover the *visible* viewport. Sizing via top:0/bottom:0 uses the layout
// viewport, which on mobile extends behind the browser's bottom toolbar
// (and the soft keyboard) — so the vertically-centered card gets its
// bottom cut off. 100dvh tracks the area that's actually on screen;
// browsers without dvh fall back to the top/bottom inset above.
height: 100dvh;
z-index: 10001; // above modal backdrop (10000)
@media (max-height: $min-height) {
align-items: flex-start;
overflow-y: auto;
}
}
.view-title {
position: fixed;
top: var(--large-padding);
left: var(--large-padding);
right: var(--large-padding);
z-index: 10002;
margin: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.carousel {
--title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size));
width: 100%;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
padding: var(--title-clearance) 0 var(--large-padding);
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
@media (max-width: $mobile-width) {
padding: var(--title-clearance) var(--medium-padding) var(--medium-padding);
// Keep the card centered when it fits, but never clip it: 'safe center'
// falls back to top-alignment when the card is taller than the visible
// viewport, and overflow-y lets the user scroll down to the bottom
// (delete/create button) — e.g. when the soft keyboard shrinks the view.
align-items: safe center;
overflow-y: auto;
}
@media (max-height: $min-height) {
min-height: max-content;
align-items: flex-start;
padding-top: var(--title-clearance);
padding-bottom: var(--medium-padding);
overflow-y: visible;
}
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.card {
@include card();
box-shadow: $shadow;
display: block;
transform-origin: center center;
flex: 0 0 auto;
width: 66vw;
max-width: 400px;
scroll-snap-align: center;
@media (max-width: $mobile-width) {
width: min(88vw, 360px);
max-width: calc(100vw - (2 * var(--medium-padding)));
padding: var(--medium-padding);
margin: 0 calc(var(--small-padding) / 2);
opacity: 1 !important;
}
box-sizing: border-box;
padding: var(--large-padding);
margin: calc(var(--large-padding) / 2);
position: relative;
@include inner-spacing(var(--large-padding));
opacity: 0.6;
transition: opacity $long-animation-time;
&.near-active {
cursor: pointer;
opacity: 0.85;
}
&.active {
opacity: 1;
}
.mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10000;
@include card();
opacity: 1;
transition: opacity $long-animation-time;
pointer-events: none;
@media (max-width: $mobile-width) {
opacity: 0 !important;
}
}
&.active .mask {
opacity: 0;
}
&.near-active .mask {
opacity: 0.55;
}
&:first-child {
margin-left: var(--large-padding);
}
&.placeholder {
opacity: 0 !important;
width: 60vw;
max-width: 60vw;
@media (max-width: $mobile-width) {
width: var(--medium-padding);
max-width: var(--medium-padding);
min-width: var(--medium-padding);
}
box-shadow: none;
background: transparent;
}
.header {
@include center-child();
position: relative;
gap: var(--small-padding);
h1 {
min-width: 0;
overflow-wrap: anywhere;
}
.exit {
position: absolute;
right: 0;
@include exit();
}
.block-dot {
@include square(12px);
margin-right: 10px;
border-radius: 2px;
}
}
.select-add-container {
// When the create card has no tag chosen, glow the dropdown red as a
// gentle "required" cue — matches the legacy ghost-button affordance.
&.required-empty lt-select-add {
box-shadow: 0 0 0 0.75px rgba(181, 63, 63, 0.5);
border-radius: var(--border-radius);
}
}
textarea {
// The global reset (styles.scss) zeroes padding, so the focus outline
// hugs the text. Re-pad so the outline clears the description text.
// box-sizing: border-box (forms.scss) keeps the outer size unchanged.
padding: 6px 8px;
}
.done-checkbox {
@include medium-text();
display: flex;
align-items: center;
justify-content: center;
gap: var(--small-padding);
width: max-content;
max-width: 100%;
margin: 0 auto var(--medium-padding);
cursor: pointer;
input[type='checkbox'] {
-webkit-appearance: none;
appearance: none;
@include square(22px);
flex: 0 0 auto;
position: relative;
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: 4px;
background: $light-color;
box-shadow: $shadow-border;
cursor: pointer;
transition: background-color $short-animation-time, box-shadow $long-animation-time, transform $short-animation-time;
&::after {
content: '';
position: absolute;
left: 7px;
top: 3px;
width: 6px;
height: 12px;
border: solid $light-color;
border-width: 0 2px 2px 0;
opacity: 0;
transform: rotate(45deg) scale(0.8);
transition: opacity $short-animation-time, transform $short-animation-time;
}
&:checked {
background: $text-color;
&::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
}
&:hover,
&:focus-visible {
box-shadow: $shadow;
}
&:active {
transform: scale(0.95);
}
}
span {
line-height: 1.3;
}
}
.difficulty {
@include medium-text();
display: flex;
align-items: center;
justify-content: center;
gap: var(--small-padding);
width: max-content;
max-width: 100%;
margin: 0 auto var(--medium-padding);
.stepper {
display: flex;
align-items: center;
gap: var(--small-padding);
.value {
min-width: 1.5em;
text-align: center;
font-variant-numeric: tabular-nums;
}
button.step {
all: unset; // strip native + global button styles
@include square(22px);
@include center-child();
flex: 0 0 auto;
box-sizing: border-box;
border-radius: 4px;
background: $light-color;
box-shadow: $shadow-border;
cursor: pointer;
// all:unset drops the font to serif and the global button's hover
// underline (button::after) survives the reset — re-assert both.
font: bold 18px/1 $normal-font;
color: $text-color;
user-select: none;
transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time;
&::after { content: none; }
@media (max-width: $mobile-width) {
@include square(26px);
}
&:hover,
&:focus-visible {
box-shadow: $shadow;
}
&:active {
transform: scale(0.95);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
box-shadow: $shadow-border;
}
}
}
}
.bottom {
min-height: 32px;
@media (max-width: $mobile-width) {
min-height: 24px;
}
display: flex;
align-items: center;
// Existing-block cards carry two buttons (Delete + Save and exit) —
// push them to opposite edges of the card. The create card has a single
// button, re-centered below.
justify-content: space-between;
gap: var(--medium-padding);
button {
margin: 0;
}
}
&.create-card .bottom {
justify-content: center;
}
@media (max-width: $mobile-width) {
lt-select-add,
.done-checkbox {
max-width: 100%;
width: 100%;
}
.bottom {
min-height: 42px;
gap: var(--medium-padding);
button {
width: max-content;
max-width: 100%;
min-height: 42px;
}
}
}
}
`,
})
export class BlockEditComponent implements AfterViewInit {
readonly viewTitle = input<string>('');
readonly blocks = input.required<Block[]>();
readonly activeBlockId = input<string | null>(null);
readonly tags = input<string[]>([]);
/** Tag to pre-select on the create card (the previous block's tag). */
readonly lastTag = input<string>('');
readonly baseColor = input.required<HslColor>();
/** Default for `is_done` on the create card. */
readonly defaultDone = input<boolean>(true);
readonly save = output<BlockEditSave>();
readonly delete = output<string>();
readonly close = output<void>();
private readonly container =
viewChild<ElementRef<HTMLElement>>('container');
// Per-block edited values, keyed by block ID.
readonly editedValues = signal<Map<string, EditedValue>>(new Map());
// Pending new block being authored on the create card.
readonly newValue = signal<EditedValue>({
tag: '',
description: '',
is_done: true,
difficulty: 1,
});
private newDoneEdited = false;
// 1-based index of the centered card. 0/N+2 are placeholders.
readonly activeIdx = signal(1);
private scrollToken = 0;
constructor() {
// Seed editedValues from input blocks (and re-seed if input changes).
effect(() => {
const bs = this.blocks();
const m = new Map<string, EditedValue>();
for (const b of bs) {
m.set(b.id, {
tag: b.tag,
description: b.description,
is_done: b.is_done,
difficulty: b.difficulty ?? 1,
});
}
untracked(() => this.editedValues.set(m));
});
// Seed the newValue tag on first run: prefer the last tag the user picked
// in the create view, falling back to the tower's first tag.
effect(() => {
const t = this.tags();
const last = this.lastTag();
untracked(() => {
const cur = this.newValue();
if (!cur.tag) {
this.newValue.set({
...cur,
tag: last || (t.length > 0 ? t[0] : ''),
});
}
});
});
effect(() => {
const isDone = this.defaultDone();
untracked(() => {
this.newValue.update((v) => ({
...v,
is_done: createDoneValue(isDone, v.is_done, this.newDoneEdited),
}));
});
});
}
ngAfterViewInit(): void {
const blocks = this.blocks();
const focusId = this.activeBlockId();
const focusIdx = focusId
? Math.max(0, blocks.findIndex((b) => b.id === focusId))
: blocks.length;
// Position scroll on the focused card (or the create card if none).
//
// Deferred to a rendered FRAME (not a microtask): the modal host is
// reparented to <body> during the same change-detection flush (see
// modal.component), and on WebKit the carousel's horizontal scroll range
// isn't established until the next layout after that reparent. A
// microtask-time scroll therefore clamps to 0, leaving the wrong card
// centered — then `adjustPosition` locks `activeIdx` onto it. One frame
// later the layout is settled and the scroll lands. We re-assert across two
// frames to also survive the focus-trap's initial focus scroll.
this.afterFrame(() => {
this.scrollToChild(focusIdx + 1, false);
this.afterFrame(() => this.scrollToChild(focusIdx + 1, false));
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
editedFor(id: string): EditedValue {
return (
this.editedValues().get(id) ?? {
tag: '',
description: '',
is_done: false,
difficulty: 1,
}
);
}
private colorOfTag(tag: string): string {
return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent';
}
colorOfTagForBlock(id: string): string {
return this.colorOfTag(this.editedFor(id).tag);
}
tagPlaceholder(fallback: string): string {
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
}
colorOfNewTag(): string {
return this.colorOfTag(this.newValue().tag);
}
formatDate(ts: number, compact = false): string {
const d = new Date(ts * 1000);
if (compact) {
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// e.g. "May 28, 2026, 14:32"
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// ── Existing-card mutations (auto-save on each change) ─────────────────────
updateTag(id: string, tag: string): void {
this.patchEdited(id, { tag });
this.flushExisting(id);
}
updateDescription(id: string, description: string): void {
this.patchEdited(id, { description });
// Description flush deferred to (blur) to avoid PUT per keystroke.
}
updateDone(id: string, is_done: boolean): void {
this.patchEdited(id, { is_done });
this.flushExisting(id);
}
updateDifficulty(id: string, delta: number): void {
const next = clampDifficulty(this.editedFor(id).difficulty + delta);
this.patchEdited(id, { difficulty: next });
this.flushExisting(id);
}
private patchEdited(id: string, patch: Partial<EditedValue>): void {
this.editedValues.update((m) => {
const v = m.get(id);
if (!v) return m;
const next = new Map(m);
next.set(id, { ...v, ...patch });
return next;
});
}
flushExisting(id: string): void {
const v = this.editedFor(id);
if (!v.tag) return; // Skip empty saves
this.save.emit({
id,
tag: v.tag,
description: v.description,
is_done: v.is_done,
difficulty: v.difficulty,
});
}
onDelete(id: string): void {
this.delete.emit(id);
}
/**
* Flush any pending edits for the card (notably the description, which is
* otherwise only saved on blur) and close the carousel mirrors the create
* card's "Create and exit" affordance for the existing-block cards.
*/
saveAndExit(id: string): void {
this.flushExisting(id);
this.close.emit();
}
// ── Create-card mutations ──────────────────────────────────────────────────
updateNewTag(tag: string): void {
this.newValue.update((v) => ({ ...v, tag }));
}
updateNewDescription(description: string): void {
this.newValue.update((v) => ({ ...v, description }));
}
updateNewDone(is_done: boolean): void {
this.newDoneEdited = true;
this.newValue.update((v) => ({ ...v, is_done }));
}
updateNewDifficulty(delta: number): void {
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
}
/**
* Bare Enter in the create-card description submits the new task and exits.
* Angular's `keydown.enter` pseudo-event matches *only* unmodified Enter, so
* Ctrl+Enter / Shift+Enter never reach here they fall through to the
* textarea's default behaviour and insert a newline.
*/
onNewDescriptionEnter(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.submitNew();
}
submitNew(): void {
const v = this.newValue();
if (!v.tag) return;
this.save.emit({
id: null,
tag: v.tag,
description: v.description,
is_done: v.is_done,
difficulty: v.difficulty,
});
this.close.emit();
}
// ── Scroll handling ────────────────────────────────────────────────────────
onCardClick(idx: number): void {
if (idx !== this.activeIdx()) {
this.scrollToChild(idx, true);
}
}
/**
* Activate a card via Space/Enter only when the card itself is focused. The
* card is a role="button" that wraps the description textarea and the tag
* input; without this guard the keydown bubbles up from those fields and the
* space handler's preventDefault() swallows the space, making it impossible
* to type spaces while editing a block.
*/
onCardKey(event: Event, idx: number): void {
if (event.target !== event.currentTarget) return;
event.preventDefault();
this.onCardClick(idx);
}
/** Close the carousel when the user clicks anywhere that isn't a real card. */
onBackdropClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target && !target.closest('.card:not(.placeholder)')) {
this.close.emit();
}
}
onScroll(): void {
const token = ++this.scrollToken;
setTimeout(() => {
if (token === this.scrollToken) this.adjustPosition();
}, 150);
}
@HostListener('window:resize')
onResize(): void {
this.scrollToChild(this.activeIdx(), false);
}
private scrollToChild(idx: number, smooth: boolean): void {
const container = this.container()?.nativeElement;
if (!container) return;
const card = container.children.item(idx) as HTMLElement | null;
if (!card) return;
// Use live viewport rects (getBoundingClientRect) rather than offsetLeft:
// the carousel is position:static, so the cards' offsetParent is the
// fixed :host, and on mobile the carousel's 15px horizontal padding skews
// offsetLeft out of the scroll container's coordinate space — enough that
// the snap tips to a neighbour card. Rects are absolute, so the delta
// between the card's centre and the container's centre is exact on every
// engine and at any padding.
const containerRect = container.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const delta =
cardRect.left + cardRect.width / 2 - (containerRect.left + containerRect.width / 2);
const left = container.scrollLeft + delta;
// 'instant' (not 'auto') is required: the carousel sets `scroll-behavior:
// smooth`, and 'auto' (and a bare `scrollLeft =`) defer to it — so the jump
// would *animate*, and the 150ms `adjustPosition` would then read a
// mid-flight position and snap to a neighbour. 'instant' lands in one frame.
// Tap-to-navigate keeps smooth=true for the nice slide between cards.
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' });
this.activeIdx.set(idx);
}
/** Run `cb` after the next rendered frame (falls back to sync where rAF is
* unavailable, e.g. SSR / unit tests). */
private afterFrame(cb: () => void): void {
if (typeof requestAnimationFrame !== 'function') {
cb();
return;
}
requestAnimationFrame(() => cb());
}
private adjustPosition(): void {
const container = this.container()?.nativeElement;
if (!container) return;
// Live viewport centre of the scroll viewport (see scrollToChild for why
// rects beat offsetLeft here).
const containerRect = container.getBoundingClientRect();
const center = containerRect.left + containerRect.width / 2;
let nearestIdx = 1;
let minDist = Infinity;
// children[0] and children[last] are the placeholders — skip.
for (let i = 1; i < container.children.length - 1; i++) {
const child = container.children.item(i) as HTMLElement;
const rect = child.getBoundingClientRect();
const c = rect.left + rect.width / 2;
const d = Math.abs(c - center);
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
if (nearestIdx !== this.activeIdx()) {
this.scrollToChild(nearestIdx, true);
}
}
}

View file

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { createDoneValue } from './block-edit.component';
describe('createDoneValue', () => {
it('uses the create-card default before the user edits the checkbox', () => {
expect(createDoneValue(false, true, false)).toBe(false);
expect(createDoneValue(true, false, false)).toBe(true);
});
it('keeps the user-edited checkbox value', () => {
expect(createDoneValue(false, true, true)).toBe(true);
expect(createDoneValue(true, false, true)).toBe(false);
});
});

View file

@ -0,0 +1,134 @@
import {
Component,
ChangeDetectionStrategy,
output,
input,
signal,
AfterViewInit,
OnDestroy,
viewChild,
ElementRef,
inject,
} from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
import { ModalStateService } from '../../services/modal-state.service';
@Component({
selector: 'lt-modal',
standalone: true,
imports: [A11yModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section
class="modal"
[class.active]="active()"
(click)="onBackdropClick($event)"
(keydown.enter)="onBackdropClick($any($event))"
tabindex="-1"
>
<div
class="modal__dialog"
#dialog
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="labelledBy()"
[attr.aria-describedby]="describedBy()"
cdkTrapFocus
cdkTrapFocusAutoCapture
(keydown.escape)="onClose()"
>
<ng-content></ng-content>
</div>
</section>
`,
styles: `
@import '../../../library/main';
/* Keep the component host out of parent flex/grid flow. Parent containers
apply spacing to direct children, so relying on the fixed child alone can
still let <lt-modal> become a layout item when it mounts. */
:host {
position: fixed;
inset: 0;
z-index: 10000;
display: block;
margin: 0 !important;
}
section.modal {
position: absolute;
inset: 0;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: rgba(255, 248, 248, 0.94);
transition: opacity 300ms;
opacity: 1;
overflow-y: auto;
@media (max-width: $mobile-width) {
padding: var(--medium-padding);
}
@media (max-height: $min-height) {
align-items: flex-start;
}
&:not(.active) {
opacity: 0;
pointer-events: none;
}
button {
margin-top: var(--medium-padding);
}
}
`,
})
export class ModalComponent implements AfterViewInit, OnDestroy {
readonly labelledBy = input<string | null>(null);
readonly describedBy = input<string | null>(null);
readonly close = output<void>();
// The active signal starts false; AfterViewInit flips it true on next tick
// so the 300ms opacity transition fires on entry (0 → 1).
readonly active = signal(false);
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
private previousFocus: HTMLElement | null = null;
private readonly modalState = inject(ModalStateService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
ngAfterViewInit(): void {
this.previousFocus = document.activeElement as HTMLElement;
// Track open state so towers can be locked while any modal is mounted.
this.modalState.open();
// Hoist the modal to <body> so its position:fixed references the viewport.
// Tower-level modals (block-edit, tower-settings) are rendered inside the
// .towers horizontal scroll container; on iOS Safari a position:fixed
// descendant of a scrolling/overflow ancestor is clipped to that ancestor's
// box instead of the viewport — cutting off the card's bottom and confining
// the backdrop to the towers band (title + sliders show through). Moving the
// host out of that ancestor restores true viewport-fixed behaviour. Angular
// removes the node via its *current* parent on destroy, so this is safe.
document.body.appendChild(this.host.nativeElement);
// Defer one tick so the opacity transition runs (0 → 1).
setTimeout(() => this.active.set(true), 0);
}
ngOnDestroy(): void {
this.modalState.close();
this.previousFocus?.focus();
}
onBackdropClick(event: MouseEvent): void {
const dialog = this.dialogRef()?.nativeElement;
if (dialog && !dialog.contains(event.target as Node)) {
this.onClose();
}
}
onClose(): void {
this.close.emit();
}
}

View file

@ -0,0 +1,318 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
} from '@angular/core';
import { Page } from '../../models';
import { ToggleComponent } from '../shared/toggle/toggle.component';
export interface UpdatePagePayload {
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
}
@Component({
selector: 'lt-settings',
standalone: true,
imports: [ToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Settings</h2>
@if (page()) {
<section class="page-section">
<h3>This page</h3>
<input
type="text"
[value]="pageName()"
(blur)="onRenamePage($any($event.target))"
placeholder="Page name…"
maxlength="200"
autocomplete="off"
aria-label="Page name"
/>
<div class="toggle-list">
<lt-toggle
class="setting-toggle"
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<lt-toggle
class="setting-toggle"
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
</div>
<button class="danger" type="button" (click)="deletePage.emit()">
Delete this page
</button>
</section>
<hr />
}
<section class="account-section">
<h3>Account</h3>
<p class="hint">Copy this token to another device to permanently sync your progress</p>
<div class="token-row">
<input
type="text"
readonly
[value]="token()"
aria-label="Your account token"
/>
<button type="button" (click)="onCopy()">Copy</button>
</div>
<p class="hint">Paste a token to switch accounts</p>
<div class="token-row">
<input
type="text"
[class.error]="tokenInputTouched() && tokenInput() && !isValidToken()"
placeholder="Paste a UUID…"
[value]="tokenInput()"
(input)="onTokenInput($any($event.target).value)"
(blur)="tokenInputTouched.set(true)"
aria-label="Paste token to switch account"
/>
<button type="button" [disabled]="!isValidToken()" (click)="onSwitch()">
Switch
</button>
</div>
@if (tokenInputTouched() && tokenInput() && !isValidToken()) {
<p class="error-message">Not a valid token. Must be a UUIDv4.</p>
}
</section>
</div>
`,
styles: `
@import '../../../library/main';
:host {
display: block;
}
.card {
@include card();
width: 66vw;
max-width: 480px;
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
text-align: left;
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
}
h2 {
margin: 0 0 var(--large-padding) 0;
padding: 0 36px;
line-height: 1.3;
text-align: center;
}
h3 {
margin: 0 0 var(--medium-padding) 0;
font-size: var(--medium-font-size);
line-height: 1.35;
}
section {
@include inner-spacing(var(--medium-padding));
margin-bottom: var(--large-padding);
&:last-child {
margin-bottom: 0;
}
}
hr {
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin: var(--large-padding) 0;
}
input[type='text'],
button:not(.exit) {
font-size: var(--medium-font-size);
line-height: 1.35;
}
.toggle-list {
display: flex;
flex-direction: column;
gap: var(--small-padding);
}
lt-toggle.setting-toggle {
--toggle-label-width: 145px;
box-sizing: border-box;
justify-content: center;
width: 100%;
min-height: 52px;
padding: var(--small-padding);
border-radius: var(--border-radius);
background: rgba($text-color, 0.035);
@media (max-width: $mobile-width) {
min-height: 48px;
}
}
.hint {
font-size: var(--medium-font-size);
line-height: 1.35;
color: rgba($text-color, 0.7);
margin: 0 0 4px 0;
}
.token-row {
display: flex;
gap: var(--small-padding);
align-items: center;
input {
flex: 1;
min-width: 0;
text-align: left;
}
button {
margin: 0;
flex: 0 0 auto;
max-width: 100%;
}
@media (max-width: $mobile-width) {
flex-wrap: wrap;
input { width: 100%; }
button { margin-left: auto; }
}
}
input.error {
box-shadow: 0 1px #b53f3f;
}
.error-message {
color: #b53f3f;
font-size: var(--small-font-size);
margin-top: 4px;
}
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after {
background-color: #b53f3f;
}
}
}
`,
})
export class SettingsComponent {
readonly token = input.required<string>();
readonly page = input<Page | null>(null);
readonly close = output<void>();
readonly switchAccount = output<string>();
readonly updatePage = output<UpdatePagePayload>();
readonly deletePage = output<void>();
// Page-settings state — seeded from page() input
readonly pageName = signal('');
readonly hideCreateTowerButton = signal(false);
readonly keepTasksOpen = signal(false);
private static readonly UUIDV4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Token-switch state
readonly tokenInput = signal('');
readonly tokenInputTouched = signal(false);
readonly isValidToken = computed(() =>
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
);
constructor() {
effect(() => {
const p = this.page();
if (p) {
this.pageName.set(p.name);
this.hideCreateTowerButton.set(p.hide_create_tower_button);
this.keepTasksOpen.set(p.keep_tasks_open);
}
});
}
onRenamePage(input: HTMLInputElement): void {
const trimmed = input.value.trim();
if (!trimmed) {
input.value = this.pageName();
return;
}
this.pageName.set(trimmed);
input.value = trimmed;
this.flushPageUpdate();
}
onHideCreateTowerButtonChange(value: boolean): void {
this.hideCreateTowerButton.set(value);
this.flushPageUpdate();
}
onKeepTasksOpenChange(value: boolean): void {
this.keepTasksOpen.set(value);
this.flushPageUpdate();
}
private flushPageUpdate(): void {
this.updatePage.emit({
name: this.pageName(),
hide_create_tower_button: this.hideCreateTowerButton(),
keep_tasks_open: this.keepTasksOpen(),
});
}
onCopy(): void {
navigator.clipboard.writeText(this.token()).catch(() => {});
}
onTokenInput(value: string): void {
this.tokenInput.set(value.trim());
}
onSwitch(): void {
if (!this.isValidToken()) return;
this.switchAccount.emit(this.tokenInput());
this.close.emit();
}
}

View file

@ -0,0 +1,165 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
OnInit,
inject,
DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Tower, HslColor } from '../../models';
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
export interface TowerSettingsResult {
name: string;
base_color: HslColor;
}
@Component({
selector: 'lt-tower-settings',
standalone: true,
imports: [ReactiveFormsModule, ColorPickerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input
id="ts-name"
name="towerName"
type="text"
formControlName="name"
[placeholder]="tower() ? 'Tower name…' : 'New tower'"
maxlength="200"
autocomplete="off"
class="title-input"
/>
<div class="picker-row">
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
</div>
@if (tower()) {
<!-- Editing an existing tower: changes auto-save, so there's no Save
button only the destructive action remains explicit. -->
<button type="button" (click)="delete.emit()">Delete tower</button>
} @else {
<button type="submit" [disabled]="form.invalid">Create tower</button>
}
</form>
`,
styles: `
@import '../../../library/main';
:host {
@include card();
width: 66vw;
max-width: 400px;
box-sizing: border-box;
padding: var(--large-padding);
padding-top: calc(var(--large-padding) + var(--medium-padding));
position: relative;
box-shadow: $shadow;
display: block;
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
padding-top: calc(var(--large-padding) + 2 * var(--medium-padding));
}
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
}
form {
@include inner-spacing(var(--large-padding));
}
.title-input {
@include title-text();
text-align: center;
width: 100%;
background: transparent;
border: 0;
}
button {
display: block;
// Stay full-width on mobile, but switch to flex so forms.scss's
// bottom-alignment keeps the underline hugging the label in the 42px
// tap target (plain block centres the text and strands the underline).
@media (max-width: $mobile-width) {
display: flex;
}
}
}
`,
})
export class TowerSettingsComponent implements OnInit {
readonly tower = input<Tower | null>(null);
readonly save = output<TowerSettingsResult>();
readonly delete = output<void>();
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
});
currentColor: HslColor = randomDefaultColor();
ngOnInit(): void {
const t = this.tower();
if (t) {
this.form.patchValue({ name: t.name });
this.currentColor = { ...t.base_color };
// Edit mode: persist name changes as they happen. Wire this up *after*
// the initial patchValue so seeding the form doesn't fire a save.
this.form.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.autoSave());
}
}
onColorChange(color: HslColor): void {
this.currentColor = color;
// In edit mode the picker is a live control — commit each change.
if (this.tower()) this.autoSave();
}
onSubmit(): void {
// Only the create flow reaches here via its Submit button; edit mode
// auto-saves (and Enter on the single field is a harmless redundant save).
this.tryEmitSave();
}
private autoSave(): void {
this.tryEmitSave();
}
private tryEmitSave(): void {
if (this.form.invalid) return;
this.emitSave();
}
private emitSave(): void {
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
}
}
function randomDefaultColor(): HslColor {
// Pick a hue in [0°, 30°] [200°, 360°] — warm or cool, avoid green.
const warm = Math.random() < 0.5;
const hueDeg = warm ? Math.random() * 30 : 200 + Math.random() * 160;
return { h: hueDeg / 360, s: 0.7, l: 0.55 };
}

View file

@ -0,0 +1,80 @@
<section
class="towers"
cdkDropList
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="onTowerDropped($event)"
>
@for (tower of page().towers; track tower.id) {
<lt-tower
cdkDrag
[attr.data-tower-id]="tower.id"
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
[tower]="tower"
[dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open"
[animateInitialStack]="animateInitialStack()"
(cdkDragStarted)="onTowerDragStart(tower.id)"
(updateTower)="onUpdateTower(tower.id, $event)"
(deleteTowerRequest)="onDeleteTower(tower.id)"
(saveBlock)="onSaveBlock(tower.id, $event)"
(addBlock)="onAddBlock(tower.id, $event)"
(deleteBlock)="onDeleteBlock(tower.id, $event)"
/>
}
@if (!page().hide_create_tower_button) {
<div class="add-tower-wrapper">
<img
class="add-tower"
src="assets/plus-sign.svg"
alt="Add tower"
role="button"
tabindex="0"
(click)="showAddTower.set(true)"
(keydown.enter)="showAddTower.set(true)"
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
/>
</div>
}
</section>
<!-- Trash zone is positioned relative to :host, not .towers — matches legacy. -->
<img
class="trash"
src="assets/trash.svg"
alt="Delete tower"
[class.active]="isDragging()"
(pointerenter)="onTrashEnter()"
(pointerleave)="onTrashLeave()"
/>
@if (showSlider()) {
<div class="double-slider-container" [class.transparent]="isDragging()">
<lt-double-slider
[values]="blockDates()"
[labels]="dateLabels()"
(rangeChange)="onSliderRangeChange($event)"
/>
</div>
}
@if (showAddTower()) {
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
</lt-modal>
}
@if (confirmDeleteTowerId(); as towerId) {
<lt-modal (close)="cancelTowerDelete()">
<div class="confirm-delete">
<div class="header">
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
<h2>Delete tower</h2>
</div>
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
<div class="confirm-buttons">
<button type="button" (click)="cancelTowerDelete()">Cancel</button>
<button type="button" class="danger" (click)="confirmTowerDelete()">Delete tower</button>
</div>
</div>
</lt-modal>
}

View file

@ -0,0 +1,208 @@
@import '../../../library/main';
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding));
button {
margin-top: 0;
}
.towers {
display: flex;
justify-content: center;
width: 100%;
box-sizing: border-box;
margin-left: auto;
margin-right: auto;
flex: 1 1 auto;
min-height: 0;
transition: box-shadow $short-animation-time;
max-width: 100%;
gap: var(--medium-padding);
&.cdk-drop-list-dragging {
*:not(.cdk-drag-placeholder) {
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
}
}
.add-tower-wrapper {
@include center-child();
img.add-tower {
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
&>* {
width: 100%;
max-width: 200px;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
position: relative;
@media (max-width: $mobile-width) {
width: auto;
margin-inline: calc(-1 * var(--large-padding));
--mobile-tower-width: calc(66vw - var(--small-padding));
--mobile-tower-side-padding: max(var(--medium-padding),
calc((100% - var(--mobile-tower-width)) / 2));
overflow-x: auto;
overflow-y: hidden;
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--mobile-tower-side-padding);
flex-wrap: nowrap;
justify-content: flex-start;
padding: 0 var(--mobile-tower-side-padding);
max-width: none;
gap: var(--medium-padding);
&::-webkit-scrollbar {
display: none;
}
&>* {
width: var(--mobile-tower-width) !important;
max-width: var(--mobile-tower-width) !important;
min-width: var(--mobile-tower-width) !important;
scroll-snap-align: center;
flex: 0 0 var(--mobile-tower-width);
}
}
}
.double-slider-container {
width: 100%;
transition: opacity $long-animation-time;
@media (max-height: $min-height) {
display: none;
}
&.transparent {
opacity: 0;
pointer-events: none;
}
}
// Projected into <lt-modal>, which hoists itself to <body> (see
// modal.component) that moves this content out of :host, so a :host-scoped
// selector no longer matches. @at-root drops the :host prefix; the
// [_ngcontent] encapsulation attribute still scopes it to this component.
@at-root .confirm-delete {
@include card();
width: 66vw;
max-width: 500px;
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
text-align: center;
.header {
@include center-child();
.exit {
position: absolute;
right: var(--large-padding);
@include exit();
}
}
p {
strong {
font-weight: bold;
}
}
.confirm-buttons {
display: flex;
justify-content: space-between;
gap: var(--large-padding);
button {
max-width: 100%;
}
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after {
background-color: #b53f3f;
}
}
@media (max-width: $mobile-width) {
flex-direction: column;
gap: var(--small-padding);
button {
width: 100%;
}
}
}
}
img.trash {
@include square(48px);
padding: 16px;
position: absolute;
z-index: 1500;
bottom: 8px;
left: 50%;
margin: 0 !important;
transform: translateX(-50%) scale(0);
transition: transform $long-animation-time;
&.active {
transform: translateX(-50%) scale(1);
}
&:hover {
transform: translateX(-50%) scale(1.1);
}
}
}

View file

@ -0,0 +1,259 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
inject,
HostListener,
effect,
untracked,
ElementRef,
Injector,
afterNextRender,
} from '@angular/core';
import { Page } from '../../models';
import { StoreService } from '../../services/store.service';
import { TowerComponent } from '../tower/tower.component';
import { ModalComponent } from '../modal/modal.component';
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
import {
DoubleSliderComponent,
DoubleSliderRange,
} from '../shared/double-slider/double-slider.component';
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
import { ModalStateService } from '../../services/modal-state.service';
// ── Relative-time helpers ──────────────────────────────────────────────────
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' });
function formatRelative(ts: number, nowSec: number): string {
const diff = ts - nowSec; // negative = past
const absDiff = Math.abs(diff);
if (absDiff < 45) return rtf.format(Math.round(diff), 'second');
if (absDiff < 60 * 45) return rtf.format(Math.round(diff / 60), 'minute');
if (absDiff < 60 * 60 * 22) return rtf.format(Math.round(diff / 3600), 'hour');
if (absDiff < 86400 * 26) return rtf.format(Math.round(diff / 86400), 'day');
if (absDiff < 86400 * 320) return rtf.format(Math.round(diff / 86400 / 30), 'month');
return rtf.format(Math.round(diff / 86400 / 365), 'year');
}
interface BlockPatch {
tag: string;
description: string;
is_done: boolean;
difficulty: number;
}
/** Minimum blocks before the date-range slider becomes visible. */
const MIN_BLOCKS_FOR_SLIDER = 2;
@Component({
selector: 'lt-page',
standalone: true,
imports: [
TowerComponent,
ModalComponent,
TowerSettingsComponent,
DoubleSliderComponent,
CdkDropList,
CdkDrag,
],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './page.component.html',
styleUrl: './page.component.scss',
})
export class PageComponent {
readonly page = input.required<Page>();
readonly animateInitialStack = input<boolean>(false);
readonly dragHappening = output<boolean>();
protected readonly store = inject(StoreService);
private readonly modalState = inject(ModalStateService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
/** True while any lt-modal is mounted — used to lock tower drag. */
readonly modalOpen = this.modalState.anyOpen;
readonly mobileDragDisabled = signal(this.isMobileViewport());
readonly showAddTower = signal(false);
readonly isDragging = signal(false);
/** When set, opens a confirmation modal before the dragged tower is deleted. */
readonly confirmDeleteTowerId = signal<string | null>(null);
private draggedTowerId: string | null = null;
private nearTrashcan = false;
readonly confirmDeleteTowerName = computed(() => {
const id = this.confirmDeleteTowerId();
if (!id) return '';
return this.page().towers.find((t) => t.id === id)?.name ?? '';
});
// ── Date-range slider state ────────────────────────────────────────────────
/** Sorted unique `created_at` timestamps (seconds) across all done blocks
* in this page. Empty list when no blocks yet. */
readonly blockDates = computed<number[]>(() => {
const set = new Set<number>();
for (const tower of this.page().towers) {
for (const b of tower.blocks) if (b.is_done) set.add(b.created_at);
}
return [...set].sort((a, b) => a - b);
});
/** Date labels formatted for slider display (deduplicated, insertion order). */
readonly dateLabels = computed<string[]>(() => {
const now = Math.floor(Date.now() / 1000);
const seen = new Set<string>();
const labels: string[] = [];
for (const t of this.blockDates()) {
const lbl = formatRelative(t, now);
if (!seen.has(lbl)) {
seen.add(lbl);
labels.push(lbl);
}
}
return labels;
});
readonly showSlider = computed(() => this.blockDates().length >= MIN_BLOCKS_FOR_SLIDER);
/** Selected date range — `null` = show everything. */
readonly dateRange = signal<{ from: number; to: number } | null>(null);
constructor() {
effect(() => {
if (!this.showSlider()) {
untracked(() => this.dateRange.set(null));
}
});
}
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
this.dateRange.set({ from: range.from as number, to: range.to as number });
}
@HostListener('window:resize')
onResize(): void {
this.mobileDragDisabled.set(this.isMobileViewport());
}
private isMobileViewport(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
);
}
// ── Tower mutations ────────────────────────────────────────────────────────
onAddTower(result: TowerSettingsResult): void {
this.showAddTower.set(false);
const towerId = this.store.addTower(this.page().id, result.name, result.base_color);
this.centerTowerOnMobile(towerId);
}
/**
* On mobile the tower row is a horizontal scroll-snap container. Adding a
* tower appends it next to the (far-right) "+" button, so without this the
* view stays scrolled on the "+" button rather than the new tower. Center
* the freshly-created tower once it's painted. No-op on desktop, where the
* row is centered and doesn't scroll.
*/
private centerTowerOnMobile(towerId: string): void {
if (!this.isMobileViewport()) return;
afterNextRender(
() => {
const container = this.host.nativeElement.querySelector<HTMLElement>('.towers');
const tower = container?.querySelector<HTMLElement>(`[data-tower-id="${towerId}"]`);
if (!container || !tower) return;
const containerRect = container.getBoundingClientRect();
const towerRect = tower.getBoundingClientRect();
const delta =
towerRect.left + towerRect.width / 2 - (containerRect.left + containerRect.width / 2);
container.scrollTo({ left: container.scrollLeft + delta, behavior: 'smooth' });
},
{ injector: this.injector },
);
}
onUpdateTower(towerId: string, result: TowerSettingsResult): void {
this.store.updateTower(this.page().id, towerId, {
name: result.name,
base_color: result.base_color,
});
}
onDeleteTower(towerId: string): void {
this.confirmDeleteTowerId.set(towerId);
}
// ── Block mutations ────────────────────────────────────────────────────────
onAddBlock(towerId: string, result: BlockPatch): void {
this.store.addBlock(
this.page().id,
towerId,
result.tag,
result.description,
result.is_done,
result.difficulty,
);
}
onSaveBlock(towerId: string, event: { blockId: string; result: BlockPatch }): void {
this.store.updateBlock(this.page().id, towerId, event.blockId, event.result);
}
onDeleteBlock(towerId: string, blockId: string): void {
this.store.deleteBlock(this.page().id, towerId, blockId);
}
// ── Drag-and-drop + trash ──────────────────────────────────────────────────
onTowerDragStart(towerId: string): void {
this.draggedTowerId = towerId;
this.isDragging.set(true);
this.dragHappening.emit(true);
}
onTowerDropped(event: CdkDragDrop<unknown>): void {
this.isDragging.set(false);
this.dragHappening.emit(false);
if (this.nearTrashcan && this.draggedTowerId) {
// Open confirm dialog instead of deleting immediately.
this.confirmDeleteTowerId.set(this.draggedTowerId);
} else if (event.previousIndex !== event.currentIndex) {
this.store.reorderTowers(this.page().id, event.previousIndex, event.currentIndex);
}
this.draggedTowerId = null;
this.nearTrashcan = false;
}
confirmTowerDelete(): void {
const id = this.confirmDeleteTowerId();
if (id) this.store.deleteTower(this.page().id, id);
this.confirmDeleteTowerId.set(null);
}
cancelTowerDelete(): void {
this.confirmDeleteTowerId.set(null);
}
onTrashEnter(): void {
this.nearTrashcan = true;
this.dragPreview()?.classList.add('trash-highlight');
}
onTrashLeave(): void {
this.nearTrashcan = false;
this.dragPreview()?.classList.remove('trash-highlight');
}
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
private dragPreview(): Element | null {
return document.querySelector('.cdk-drag-preview');
}
}

View file

@ -0,0 +1,75 @@
<div class="select-add-container">
<lt-select-add
[options]="pageNames()"
[selectedIndex]="selectedPageIndex()"
[compact]="true"
placeholder="Add a new page…"
(selectionChange)="onSelectPage($event)"
(add)="onAddPage($event)"
/>
</div>
<div class="page-container">
<!-- 0-or-1 element @for keyed by page.id: rebuilds the page (fresh date-range
slider/filter) on navigation, reuses it on same-page edits. See
PagesComponent.selectedPageList. -->
@for (page of selectedPageList(); track page.id) {
<lt-page
[page]="page"
[animateInitialStack]="page.id === animateInitialStackPageId()"
(dragHappening)="dragHappening.set($event)"
/>
} @empty {
<p>Add a new page to get started!</p>
}
</div>
<button [class.transparent]="dragHappening()" (click)="showSettings.set(true)">
Settings
</button>
@if (showSettings()) {
<lt-modal (close)="showSettings.set(false)">
<lt-settings
[token]="store.token()"
[page]="selectedPage()"
(close)="showSettings.set(false)"
(updatePage)="onUpdatePage($event)"
(deletePage)="onRequestRemovePage()"
(switchAccount)="onSwitchAccount($event)"
/>
</lt-modal>
}
@if (confirmDeletePageId()) {
<lt-modal (close)="cancelRemovePage()">
<div class="confirm-delete">
<div class="header">
<button class="exit" type="button" (click)="cancelRemovePage()" aria-label="Cancel"></button>
<h2>Delete page</h2>
</div>
<p>
Delete <strong>{{ confirmDeletePageName() || 'this page' }}</strong> and all of its towers and blocks?
This can't be undone.
</p>
<div class="confirm-buttons">
<button type="button" (click)="cancelRemovePage()">Cancel</button>
<button type="button" class="danger" (click)="confirmRemovePage()">Delete page</button>
</div>
</div>
</lt-modal>
}
@if (showWelcome()) {
<lt-modal
labelledBy="welcome-title"
describedBy="welcome-description"
(close)="showWelcome.set(false)"
>
<lt-welcome
(close)="showWelcome.set(false)"
(startFresh)="showWelcome.set(false)"
(loadExample)="onLoadExample()"
/>
</lt-modal>
}

View file

@ -0,0 +1,109 @@
@import '../../../library/main';
:host {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
@include inner-spacing(var(--large-padding));
.select-add-container {
width: 250px;
margin: 0 auto;
position: relative;
z-index: 1000;
@media (max-width: $mobile-width) {
width: 80vw;
max-width: 320px;
}
}
.page-container {
flex: 1 1 auto;
min-height: 0;
// Generous breathing room between the page selector dropdown and the
// towers the dropdown can open downward without crowding the towers.
padding-top: var(--large-padding);
@media (max-width: $mobile-width) {
padding-top: var(--small-padding);
}
}
button {
transition: opacity $long-animation-time;
&.transparent {
opacity: 0;
}
@media (max-width: $mobile-width) {
font-size: var(--medium-font-size);
}
}
// Projected into <lt-modal>, which hoists itself to <body> (see
// modal.component) that moves this content out of :host, so a :host-scoped
// selector no longer matches. @at-root drops the :host prefix; the
// [_ngcontent] encapsulation attribute still scopes it to this component.
@at-root .confirm-delete {
@include card();
width: 66vw;
max-width: 500px;
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
text-align: center;
.header {
@include center-child();
.exit {
position: absolute;
right: var(--large-padding);
@include exit();
}
}
.confirm-buttons {
display: flex;
justify-content: space-between;
gap: var(--large-padding);
button {
max-width: 100%;
}
button.danger {
color: #b53f3f;
border-bottom-color: #b53f3f55;
&:after {
background-color: #b53f3f;
}
}
@media (max-width: $mobile-width) {
flex-direction: column;
gap: var(--small-padding);
button {
width: 100%;
}
}
}
}
}

View file

@ -0,0 +1,164 @@
import {
Component,
ChangeDetectionStrategy,
inject,
signal,
computed,
effect,
OnDestroy,
} from '@angular/core';
import { StoreService } from '../../services/store.service';
import { PageComponent } from '../page/page.component';
import { ModalComponent } from '../modal/modal.component';
import { SettingsComponent, UpdatePagePayload } from '../modal/settings.component';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { WelcomeComponent } from '../welcome/welcome.component';
import { Page } from '../../models';
@Component({
selector: 'lt-pages',
standalone: true,
imports: [PageComponent, ModalComponent, SettingsComponent, SelectAddComponent, WelcomeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './pages.component.html',
styleUrl: './pages.component.scss',
})
export class PagesComponent implements OnDestroy {
protected readonly store = inject(StoreService);
/** ID of currently selected page within store.pages(). */
private readonly selectedPageId = signal<string | null>(null);
readonly showSettings = signal(false);
readonly dragHappening = signal(false);
readonly showWelcome = signal(false);
readonly confirmDeletePageId = signal<string | null>(null);
readonly animateInitialStackPageId = signal<string | null>(null);
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
effect(() => {
const pages = this.store.pages();
if (!this.store.loading() && pages.length === 0) {
this.showWelcome.set(true);
} else if (pages.length > 0) {
this.showWelcome.set(false);
}
});
}
onLoadExample(): void {
const pageId = this.store.loadExample();
this.selectedPageId.set(pageId);
this.animateInitialStackPageId.set(pageId);
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
this.exampleAnimationTimer = setTimeout(() => {
if (this.animateInitialStackPageId() === pageId) {
this.animateInitialStackPageId.set(null);
}
this.exampleAnimationTimer = null;
}, 2500);
this.showWelcome.set(false);
}
ngOnDestroy(): void {
if (this.exampleAnimationTimer !== null) {
clearTimeout(this.exampleAnimationTimer);
}
}
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
readonly selectedPage = computed<Page | null>(() => {
const pages = this.store.pages();
if (pages.length === 0) return null;
const id = this.selectedPageId();
if (id) {
const found = pages.find((p) => p.id === id);
if (found) return found;
}
// Default to first page.
return pages[0] ?? null;
});
/**
* The selected page as a 0-or-1 element list, so the template's
* `@for (… track page.id)` REBUILDS the `lt-page` subtree when the page *id*
* changes (navigation) but REUSES it on same-page edits (id unchanged).
*
* Recreating on navigation gives each page a fresh date-range slider + filter,
* exactly like a page reload. Without it, `lt-page` (and its slider) are reused
* across pages, so the previous page's stale `dateRange` is applied to the new
* page's towers on their very first render. When the new page's blocks fall
* outside that stale range they render out-of-range (ascending, off-screen) and
* then visibly "fall" into place a frame later when the slider corrects the
* range the bug this guards against.
*/
readonly selectedPageList = computed<Page[]>(() => {
const page = this.selectedPage();
return page ? [page] : [];
});
readonly confirmDeletePageName = computed(() => {
const id = this.confirmDeletePageId();
if (!id) return '';
return this.store.pages().find((p) => p.id === id)?.name ?? '';
});
readonly selectedPageIndex = computed(() => {
const pages = this.store.pages();
const page = this.selectedPage();
if (!page) return -1;
return pages.findIndex((p) => p.id === page.id);
});
onSelectPage(index: number): void {
const pages = this.store.pages();
const page = pages[index];
if (page) {
this.selectedPageId.set(page.id);
}
}
onAddPage(name: string): void {
this.store.addPage(name);
// Select the newly added page.
const pages = this.store.pages();
const newPage = pages[pages.length - 1];
if (newPage) {
this.selectedPageId.set(newPage.id);
}
}
onUpdatePage(payload: UpdatePagePayload): void {
const page = this.selectedPage();
if (page) {
this.store.updatePage(page.id, payload);
}
}
onRequestRemovePage(): void {
const page = this.selectedPage();
if (!page) return;
this.confirmDeletePageId.set(page.id);
}
confirmRemovePage(): void {
const pageId = this.confirmDeletePageId();
if (!pageId) return;
this.store.deletePage(pageId);
this.selectedPageId.set(null);
this.showSettings.set(false);
this.confirmDeletePageId.set(null);
}
cancelRemovePage(): void {
this.confirmDeletePageId.set(null);
}
onSwitchAccount(token: string): void {
this.store.switchToken(token);
}
}

View file

@ -0,0 +1,198 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
computed,
} from '@angular/core';
import { HslColor } from '../../../models';
import { toCss } from '../../../utils/color';
// 12 hand-picked hues. Rationale:
// Warm cluster (045°): coral/red, orange-red, orange, amber — vivid warm tones
// Skipped 60180° (yellows + greens) — most read as muddy next to the rose UI accent
// Cool cluster (195260°): sky-cyan, azure, blue, indigo — clean, distinct from rose
// Purple-rose cluster (280355°): violet, magenta, rose-pink, near-red — complements the accent
const PRESETS: number[] = [0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355];
const FIXED_S = 0.7;
const FIXED_L = 0.55;
@Component({
selector: 'lt-color-picker',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="picker">
<div class="swatches" role="group" aria-label="Preset colors">
@for (h of presetHues; track h) {
<button
type="button"
class="swatch"
[class.active]="isActiveHue(h)"
[style.background-color]="hueToCss(h)"
[attr.aria-label]="'Pick hue ' + h + ' degrees'"
[attr.aria-pressed]="isActiveHue(h)"
(click)="pickHue(h)"
></button>
}
</div>
<div class="hue-slider">
<input
type="range"
min="0"
max="360"
step="1"
[value]="hueDeg()"
(input)="onSlider($any($event.target).value)"
[style.--thumb-color]="toCss(color())"
aria-label="Hue"
/>
</div>
<div class="preview" [style.background-color]="toCss(color())" aria-hidden="true"></div>
</div>
`,
styles: `
@import '../../../../library/main';
:host {
display: block;
padding: var(--medium-padding);
@include card();
border: 1px solid rgba($text-color, 0.14);
box-shadow: inset 0 0 0 1px rgba($light-color, 0.7);
background-color: rgba($text-color, 0.025);
box-sizing: border-box;
}
.picker {
display: flex;
flex-direction: column;
gap: var(--medium-padding);
width: 100%;
}
.swatches {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 6px;
@media (max-width: $mobile-width) {
grid-template-columns: repeat(6, 1fr);
gap: var(--small-padding);
}
.swatch {
all: unset;
cursor: pointer;
aspect-ratio: 1;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
transition: transform $short-animation-time, box-shadow $long-animation-time;
&:hover,
&:focus-visible {
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
transform: scale(1.1);
}
&.active {
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
transform: scale(1.15);
}
}
}
.hue-slider {
padding: 8px 0;
input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 16px;
border-radius: 1000px;
background: linear-gradient(
to right,
hsl(0, 70%, 55%),
hsl(60, 70%, 55%),
hsl(120, 70%, 55%),
hsl(180, 70%, 55%),
hsl(240, 70%, 55%),
hsl(300, 70%, 55%),
hsl(360, 70%, 55%)
);
outline: none;
cursor: pointer;
&:focus-visible {
box-shadow: 0 0 0 3px rgba($text-color, 0.35);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 32px;
width: 32px;
border-radius: 1000px;
background-color: var(--thumb-color, #{$light-color});
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
cursor: grab;
&:active {
cursor: grabbing;
}
}
&::-moz-range-thumb {
height: 32px;
width: 32px;
border-radius: 1000px;
background-color: var(--thumb-color, white);
border: 2px solid white;
box-shadow: $shadow;
cursor: grab;
&:active {
cursor: grabbing;
}
}
}
}
.preview {
height: 40px;
border-radius: var(--border-radius);
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
}
`,
})
export class ColorPickerComponent {
readonly color = input.required<HslColor>();
readonly colorChange = output<HslColor>();
readonly presetHues = PRESETS;
readonly hueDeg = computed(() => Math.round(this.color().h * 360));
isActiveHue(h: number): boolean {
return Math.abs(this.hueDeg() - h) < 8;
}
hueToCss(h: number): string {
return `hsl(${h}, 70%, 55%)`;
}
/** Re-exported so the template can call the utility directly. */
readonly toCss = toCss;
pickHue(h: number): void {
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
}
onSlider(value: string): void {
this.colorChange.emit({ h: Number(value) / 360, s: FIXED_S, l: FIXED_L });
}
}

View file

@ -0,0 +1,246 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
effect,
untracked,
} from '@angular/core';
export interface DoubleSliderRange<T> {
from: T;
to: T;
}
/**
* Two-thumb range slider legacy "double-slider".
* Hands an indexed range over an arbitrary values array; emits the
* underlying values on each change. Labels magnetically lift as a thumb
* approaches them (rotated -30°), per the legacy.
*/
@Component({
selector: 'lt-double-slider',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="container">
<label for="ds-1">From</label>
<label for="ds-2">To</label>
<input
id="ds-1"
type="range"
min="0"
[max]="maxIndex()"
[value]="oneValue()"
(input)="oneValue.set(+$any($event.target).value)"
/>
<input
id="ds-2"
type="range"
min="0"
[max]="maxIndex()"
[value]="otherValue()"
(input)="otherValue.set(+$any($event.target).value)"
/>
<div class="value-container">
@for (i of drawnIndices(); track i) {
<span [style.transform]="getOffset(i)">{{ drawnLabels()[i] }}</span>
}
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$line-height: 2px;
$height: 90px;
$slider-size: 40px;
.container {
width: 100%;
max-width: 800px;
height: $height;
position: relative;
margin: calc(#{$slider-size} / 2) auto 0 auto;
@media (max-width: $mobile-width) {
max-width: 90vw;
margin-top: calc(#{$slider-size} / 2);
height: 54px;
}
label { display: none; }
input[type='range'] {
width: 100%;
position: absolute;
left: 0;
-webkit-appearance: none;
appearance: none;
outline: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
box-shadow: $shadow-border;
transform-origin: center center;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2));
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(calc(-1 * #{$slider-size} / 2 + #{$line-height} / 2))
scale(1.1);
}
}
cursor: pointer;
position: relative;
z-index: 2;
}
&::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: $slider-size;
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
border: none;
box-shadow: $shadow-border;
cursor: pointer;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-range-track {
-moz-appearance: none;
width: 100%;
height: $line-height;
background-color: $text-color;
border-radius: 1000px;
}
&::-moz-focus-outer { border: 0; }
}
.value-container {
font-family: $normal-font;
color: $text-color;
font-size: var(--medium-font-size);
display: flex;
justify-content: space-evenly;
margin-top: calc(#{$slider-size} + 8px);
span {
display: block;
margin-top: 14px;
transform-origin: center bottom;
transition: transform $long-animation-time;
white-space: nowrap;
}
@media (max-width: $mobile-width) {
font-size: var(--small-font-size);
margin-top: calc(#{$slider-size} - 12px);
span { margin-top: 8px; }
}
}
}
`,
})
export class DoubleSliderComponent {
/** Ordered list of underlying values (e.g. dates). */
readonly values = input.required<unknown[]>();
/** Display labels for evenly-spaced ticks (≤ values.length). */
readonly labels = input.required<string[]>();
readonly rangeChange = output<DoubleSliderRange<unknown>>();
readonly oneValue = signal(0);
readonly otherValue = signal(0);
readonly maxIndex = computed(() => Math.max(0, this.values().length - 1));
private prevValuesLength = 0;
readonly drawnLabels = computed(() => {
const labels = this.labels();
const count = Math.min(labels.length, 6);
if (count === 0) return [] as string[];
const jump = Math.max(1, Math.round(labels.length / count));
return labels.filter((_, i) => i % jump === 0);
});
readonly drawnIndices = computed(() =>
Array.from({ length: this.drawnLabels().length }, (_, i) => i),
);
constructor() {
// Re-emit the value range whenever the slider thumbs or values change.
effect(() => {
const a = this.oneValue();
const b = this.otherValue();
const vs = this.values();
if (vs.length === 0) return;
const lo = Math.min(a, b);
const hi = Math.max(a, b);
untracked(() => {
this.rangeChange.emit({
from: vs[this.clampIndex(lo)],
to: vs[this.clampIndex(hi)],
});
});
});
// Snap the higher thumb to the newest value when a new entry is appended.
effect(() => {
const len = this.values().length;
untracked(() => {
const max = Math.max(0, len - 1);
if (len > this.prevValuesLength) {
const a = this.oneValue();
const b = this.otherValue();
if (a > b) this.oneValue.set(max);
else this.otherValue.set(max);
} else {
if (this.oneValue() > max) this.oneValue.set(max);
if (this.otherValue() > max) this.otherValue.set(max);
}
this.prevValuesLength = len;
});
});
}
private clampIndex(value: number): number {
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
}
/**
* Magnetic label position: returns a CSS `transform` that lifts the label
* upward and rotates -30° as a thumb approaches.
*/
getOffset(index: number): string {
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
const max = Math.max(1, this.maxIndex());
const a = this.oneValue() / max - 0.1;
const b = this.otherValue() / max - 0.1;
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
const ACTIVE_ZONE = 0.2;
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
if (dist > ACTIVE_ZONE) return base;
const lift = ((ACTIVE_ZONE - dist) / ACTIVE_ZONE) * 36;
return `translateY(${lift}px) ${base}`;
}
}

View file

@ -0,0 +1,29 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
export type IconName = 'arrow' | 'pen' | 'plus-sign' | 'trash' | 'x-sign';
@Component({
selector: 'lt-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<img
[src]="'assets/' + name() + '.svg'"
[alt]="name()"
[style.width]="size()"
[style.height]="size()"
aria-hidden="true"
/>
`,
styles: `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
}
`,
})
export class IconComponent {
readonly name = input.required<IconName>();
readonly size = input<string>('1.25rem');
}

View file

@ -0,0 +1,507 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
ElementRef,
HostListener,
inject,
} from '@angular/core';
@Component({
selector: 'lt-select-add',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="container"
[class.shadow-border]="onlyShadowBorder()"
[class.always-shadow]="alwaysDropShadow()"
[class.compact]="compact()"
>
<div class="background" [class.active]="open()"></div>
<div
class="top"
role="button"
tabindex="0"
[attr.aria-expanded]="open()"
(click)="toggleOpen($event)"
(keydown.enter)="toggleOpen($event)"
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
>
<p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div>
<div class="bottom-container">
<div class="bottom" [class.open]="open()">
@for (item of displayedItems(); track item) {
@if (editing()) {
<input
type="text"
[value]="item"
maxlength="200"
(blur)="onRename(item, $any($event.target).value)"
/>
} @else {
<button class="option" type="button" (click)="onSelectItem(item)">
{{ item }}
</button>
}
}
<div class="add-row">
<input
type="text"
#addInput
placeholder="Add a value…"
maxlength="200"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/>
<button
class="add-button"
type="button"
(click)="onAdd(addInput.value); addInput.value = ''"
>
Add
</button>
@if (editable()) {
<button
class="pen"
type="button"
[class.active]="editing()"
(click)="editing.update(v => !v)"
>
<img src="assets/pen.svg" alt="Edit" />
</button>
}
</div>
</div>
</div>
</div>
`,
styles: `
@import '../../../../library/main';
$inner-padding: var(--medium-padding);
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
:host {
display: block;
width: 100%;
}
.container {
width: 100%;
position: relative;
.top,
.bottom {
padding: $inner-padding;
z-index: 4;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
cursor: pointer;
min-height: 46px;
box-sizing: border-box;
gap: var(--small-padding);
p {
display: block;
@include sub-title-text();
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
img.arrow {
flex: 0 0 auto;
@include square(16px);
transition: transform $long-animation-time;
&.upside-down {
transform: rotate(-180deg);
}
}
}
.bottom-container {
width: 100%;
position: absolute;
top: 100%;
left: 0;
right: 0;
overflow: visible;
pointer-events: none;
z-index: 5;
.bottom {
position: relative;
width: 100%;
pointer-events: none;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding: $inner-padding;
padding-top: var(--small-padding);
gap: var(--small-padding);
// Default (closed) state — also the target of the close transition.
background-color: transparent;
box-shadow: none;
transform: translateY(-8px);
opacity: 0;
visibility: hidden;
// Clip the top edge so the panel's shadow can't bleed back up into
// the chip area; sides + bottom get a 10px slack for the shadow.
clip-path: inset(0 -10px -10px -10px);
// Delay the visibility change until after the slide animation finishes
// so the panel stays visible while it animates closed.
transition:
transform $long-animation-time,
opacity $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s $long-animation-time;
&.open {
visibility: visible;
pointer-events: all;
transform: none;
opacity: 1;
background-color: $light-color;
box-shadow: $dropdown-shadow;
// On open, visibility flips immediately (no delay); transform +
// colors + shadow animate over $long-animation-time.
transition:
transform $long-animation-time,
opacity $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s 0s;
}
.option {
@include sub-title-text();
display: flex;
align-items: center;
width: 100%;
min-height: 36px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
&:after {
display: none;
}
@media (max-width: $mobile-width) {
min-height: 42px;
}
}
input[type='text'] {
@include sub-title-text();
width: 100%;
min-height: 36px;
box-sizing: border-box;
text-align: left;
&::placeholder {
color: rgba($text-color, 0.72);
opacity: 1;
}
@media (max-width: $mobile-width) {
min-height: 42px;
}
}
.add-row {
min-height: 40px;
position: relative;
width: 100%;
display: flex;
align-items: flex-end;
gap: var(--small-padding);
input[type='text'] {
flex: 1;
min-height: 0;
padding: 0;
border-bottom: solid 2px transparent;
&:focus,
&:focus-visible {
box-shadow: none;
border-bottom-color: $text-color;
}
}
button {
margin: 0;
position: relative;
flex: 0 0 auto;
&.add-button {
align-self: flex-end;
}
&.pen {
opacity: 0.25;
cursor: pointer;
display: flex;
align-items: center;
padding: 0;
border: none;
background: transparent;
position: relative;
// Kill the global button's hover-grow underline pseudo-element
// for the icon-only edit control.
&:after { content: none; display: none; }
img {
@include square(16px);
}
&:before {
content: '';
display: block;
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
background-color: $text-color;
width: 0;
transition: width $long-animation-time;
}
@media (min-width: $mobile-width) {
&:hover { opacity: 0.5; }
&:hover:before { width: 100% !important; }
}
&.active {
opacity: 1;
&:before { width: 100% !important; }
}
transition: opacity $short-animation-time;
}
}
}
}
}
.background {
position: absolute;
top: 0;
height: 100%;
width: 100%;
@include card();
z-index: 3;
box-sizing: border-box;
transition:
box-shadow $long-animation-time,
height $long-animation-time,
border-radius $long-animation-time;
&.active {
// Same shadow recipe as the panel below so the two halves read as
// one continuous card. Clip the bottom edge so this shadow doesn't
// bleed across the seam where .top meets .bottom.
box-shadow: $dropdown-shadow;
clip-path: inset(-10px -10px 0 -10px);
}
}
.top {
transition: border-radius $long-animation-time;
}
// When the drawer is open, square the BOTTOM corners on both the
// background card and the top chip so they merge into one continuous
// surface. (Animated via the border-radius transitions above.)
&:has(.bottom.open) {
.top,
.background {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
}
// Hover lifts the chip — but only when the dropdown is closed. When
// it's open the chip is already showing $dropdown-shadow and a hover
// override would make the top heavier than the panel below.
&:hover {
@media (min-width: $mobile-width) {
.background:not(.active) { box-shadow: $shadow; }
}
}
&.shadow-border {
.background.active {
box-shadow: $shadow-border;
clip-path: inset(-10px -10px 0 -10px);
}
.bottom.open {
box-shadow: $shadow-border;
}
}
&.shadow-border:hover {
.background:not(.active) { box-shadow: $shadow-border; }
}
&.always-shadow {
.background:not(.active) { box-shadow: $shadow; }
// When open, clip the bottom so the always-on shadow doesn't bleed
// over the seam; restore full shadow when closed.
&:has(.bottom.open) .background {
clip-path: inset(-6px -6px 0 -6px);
}
}
// Tighter footprint on mobile (the page selector). The shared defaults
// don't shrink on small screens — the chip stays a ~50px slab and the
// drawer rows sit ~50px apart — so here the chip is slimmed and the
// option list is pulled into a dense, left-aligned menu.
&.compact {
@media (max-width: $mobile-width) {
// Chip (the selected page): snug vertically, but keep the full
// horizontal inset so the name doesn't hug the card edge. The
// default ~50px slab is trimmed to ~35px here.
.top {
padding: var(--small-padding) var(--medium-padding);
min-height: 34px;
}
// Dropdown panel: a tight, left-aligned list. Short rows + a hair of
// gap pull the page names together — the global mobile button rule
// (forms.scss) otherwise pads each to 42px and centers its label.
// Selectors are nested through .bottom-container to out-specify the
// default .bottom / .option mobile rules (which sit at 0,3,0 / 0,4,0).
.bottom-container .bottom {
padding: var(--small-padding) var(--medium-padding);
gap: 2px;
.option {
justify-content: flex-start;
min-height: 30px;
}
}
}
}
}
`,
})
export class SelectAddComponent {
// ── New API (spec) ─────────────────────────────────────────────────────────
/** List of string options */
readonly items = input<string[]>([]);
/** Currently selected string value */
readonly selected = input<string | null>(null);
readonly editable = input<boolean>(false);
readonly placeholder = input<string>('Select…');
readonly alwaysDropShadow = input<boolean>(false);
readonly onlyShadowBorder = input<boolean>(false);
/** Trim the chip's padding/min-height on mobile (e.g. the page selector). */
readonly compact = input<boolean>(false);
// ── Legacy compat API (used by pages.component.html until Agent B updates) ─
/** @deprecated Use items instead */
readonly options = input<string[]>([]);
/** @deprecated Use selected (string) instead */
readonly selectedIndex = input<number>(-1);
// ── Outputs — new API ──────────────────────────────────────────────────────
readonly select = output<string>();
readonly add = output<string>();
readonly rename = output<{ old: string; new: string }>();
readonly remove = output<string>();
// ── Legacy compat outputs ──────────────────────────────────────────────────
/** @deprecated Use select instead */
readonly selectionChange = output<number>();
// ── Internal state ─────────────────────────────────────────────────────────
readonly open = signal(false);
readonly editing = signal(false);
private readonly host = inject(ElementRef<HTMLElement>);
// Resolved values that merge old + new API
protected resolvedItems(): string[] {
const newItems = this.items();
const oldOptions = this.options();
return newItems.length ? newItems : oldOptions;
}
/**
* The options shown in the drawer. Excludes the currently-selected value
* re-picking what's already selected is a no-op, so it just adds noise.
* In rename mode we list everything so every item stays editable.
*/
protected displayedItems(): string[] {
if (this.editing()) return this.resolvedItems();
const selected = this.resolvedSelected();
return this.resolvedItems().filter((item) => item !== selected);
}
protected resolvedSelected(): string | null {
// New API: string
const s = this.selected();
if (s != null && s.trim()) return s;
// Legacy API: index into options
const idx = this.selectedIndex();
const opts = this.resolvedItems();
if (idx >= 0 && idx < opts.length) return opts[idx];
return null;
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.open()) return;
if (!this.host.nativeElement.contains(event.target as Node)) {
this.open.set(false);
this.editing.set(false);
}
}
toggleOpen(event: Event): void {
event.stopPropagation();
this.open.update((v) => !v);
}
onSelectItem(item: string): void {
this.select.emit(item);
// Legacy compat: also emit the index
const idx = this.resolvedItems().indexOf(item);
if (idx >= 0) this.selectionChange.emit(idx);
this.open.set(false);
this.editing.set(false);
}
onAdd(value: string): void {
const v = value.trim();
if (!v) return;
this.add.emit(v);
this.open.set(false);
}
onRename(oldValue: string, newValue: string): void {
const n = newValue.trim();
if (n && n !== oldValue) {
this.rename.emit({ old: oldValue, new: n });
}
}
}

View file

@ -0,0 +1,133 @@
import {
Component,
ChangeDetectionStrategy,
input,
model,
} from '@angular/core';
@Component({
selector: 'lt-toggle',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toggle">
<span
role="button"
tabindex="0"
[class.active]="!checked()"
(click)="set(false)"
(keydown.enter)="set(false)"
(keydown.space)="$event.preventDefault(); set(false)"
>{{ offLabel() }}</span>
<label>
<input
type="checkbox"
[class.on]="checked()"
[checked]="checked()"
(change)="set(!checked())"
/>
</label>
<span
role="button"
tabindex="0"
[class.active]="checked()"
(click)="set(true)"
(keydown.enter)="set(true)"
(keydown.space)="$event.preventDefault(); set(true)"
>{{ onLabel() }}</span>
</div>
`,
styles: `
@import '../../../../library/main';
:host {
$size: 30px;
@include center-child();
gap: var(--medium-padding);
@media (max-width: $mobile-width) {
width: 100%;
gap: var(--small-padding);
}
.toggle {
display: contents;
}
span {
@include medium-text();
// Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label.
flex: 0 0 auto;
width: var(--toggle-label-width, #{4 * $size});
box-sizing: border-box;
padding: 0 var(--small-padding);
line-height: 1.3;
cursor: pointer;
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
@media (max-width: $mobile-width) {
flex: 1 1 0;
width: auto;
min-width: 0;
padding: 0;
overflow-wrap: anywhere;
}
}
label {
display: block;
flex: 0 0 auto;
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
width: 2 * $size;
height: $size;
border-radius: 1000px;
box-shadow: $shadow-border;
position: relative;
cursor: pointer;
&:after {
content: '';
position: absolute;
display: block;
left: 0;
@include square($size);
border-radius: 1000px;
background-color: $text-color;
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
}
&.on:after { left: $size; }
}
input[type='checkbox'] {
@media (min-width: $mobile-width) {
&:hover:after {
box-shadow: $shadow;
transform: translateX(2px);
}
&.on:hover:after {
transform: translateX(-2px);
}
}
}
}
}
`,
})
export class ToggleComponent {
readonly checked = model<boolean>(false);
readonly offLabel = input<string>('No');
readonly onLabel = input<string>('Yes');
set(value: boolean): void {
this.checked.set(value);
}
}

View file

@ -0,0 +1,294 @@
import {
Component,
ChangeDetectionStrategy,
computed,
effect,
input,
output,
signal,
untracked,
} from '@angular/core';
import { Block, HslColor } from '../../models';
import { getColorOfTag } from '../../utils/color';
export function shouldExpandTasks(keepTasksOpen: boolean, manuallyExpanded: boolean): boolean {
return keepTasksOpen || manuallyExpanded;
}
export function taskListMaxHeight(expanded: boolean): string {
return expanded ? 'none' : '0px';
}
/**
* Tasks accordion shows pending (not-done) blocks inside a tower.
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses
* unless the page setting is keeping tasks open.
* Clicking the colored tickbox marks the task done.
* Clicking the description opens the block-edit modal via the `edit` output.
*/
@Component({
selector: 'lt-tasks',
standalone: true,
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="container"
[class.show-hover]="pending().length > 0"
(pointerdown)="$event.stopPropagation()"
(mousedown)="$event.stopPropagation()"
(touchstart)="$event.stopPropagation()"
>
@if (!initiallyOpen()) {
<button
type="button"
class="header"
(click)="toggleExpanded($event)"
[attr.aria-expanded]="expanded()"
>
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
@if (pending().length === 0) {
<span aria-hidden="true">&nbsp;</span>
} @else {
{{ pending().length === 1 ? 'task' : 'tasks' }}
}
</button>
}
<div
#all
class="all-task"
[style.max-height]="taskListMaxHeight(expanded())"
>
@for (b of pending(); track b.id) {
<div class="task-container">
<button
type="button"
class="tickbox"
[style.background-color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button>
<button
type="button"
class="task-description"
[style.color]="colorOf(b.tag)"
(click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</button>
</div>
}
</div>
</div>
`,
styles: `
@import '../../../library/main';
:host {
width: 100%;
box-sizing: border-box;
position: relative;
// Within the tower stacking context: high enough to float above the
// falling-blocks layer. Globally low enough that modals + the carousel
// (10000+) always cover us.
z-index: 5;
.container {
@include card();
cursor: pointer;
transition: box-shadow $long-animation-time;
&.show-hover:hover {
box-shadow: $shadow-border;
}
padding: calc(var(--small-padding) / 2);
margin: calc(var(--small-padding) / 2);
// Height is bounded by the host (lt-tasks) flex column, which clips but
// does not scroll. As the sole scroller, this card shrinks to that
// bound (min-height: 0) and scrolls a tall list inside itself — one
// scrollbar, sitting within the white card.
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
.header {
all: unset;
@include medium-text();
display: block;
width: 100%;
box-sizing: border-box;
cursor: pointer;
text-align: center;
&::after {
content: none;
}
}
.all-task {
@include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); }
box-sizing: border-box;
transition: max-height $long-animation-time;
/*
* Clip while collapsed only. When open, let the outer .container own
* scrolling via max-height: 30vh; a nested scroller here pops a
* scrollbar the instant a tickbox grows on hover.
*/
overflow: hidden;
// Sideways breathing room so the clip doesn't shear the tickbox's
// hover shadow; negative side margins keep rows flush with the header,
// and the bottom padding clears the last row's shadow.
margin: 0 calc(var(--small-padding) / -2);
padding: 0 calc(var(--small-padding) / 2) calc(var(--small-padding) / 2);
.task-container {
display: flex;
align-items: center;
gap: var(--small-padding);
@media (max-width: $mobile-width) {
gap: calc(var(--small-padding) / 2);
}
&:hover .task-description {
@media (min-width: $mobile-width) {
color: inherit !important;
}
}
// Tickbox: a generously sized colored button that marks the task
// done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark.
.tickbox {
all: unset; // strip native button styles
flex: 0 0 24px;
cursor: pointer;
position: relative;
box-sizing: border-box;
@include square(24px);
min-width: 24px;
min-height: 24px;
@media (max-width: $mobile-width) {
@include square(24px);
}
border-radius: 4px;
box-shadow: $shadow-border;
transition: transform $short-animation-time, box-shadow $long-animation-time;
&::after {
content: '✓';
position: absolute;
inset: 0;
/*
* Neutralise the global animated-underline bar from
* forms.scss (button:after { height: 2px; width: 0->100% on
* hover; background-color: $text-color }). The all:unset on the
* button does NOT reach the pseudo-element, so without these
* resets the bar paints a dark stripe across the top AND
* squashes this box to 2px which centres the glyph near the
* top instead of the middle.
*/
width: 100%;
height: 100%;
background: none;
@include center-child();
color: $light-color;
font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
opacity: 0; // hidden at rest — only revealed on hover/focus/active
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
// The ✓ glyph sits a touch high in its em-box; nudge to optical centre.
transform: translateY(1px);
transition: opacity $short-animation-time, transform $short-animation-time;
}
// Reveal on hover only on real hover-capable pointers. On touch,
// :hover sticks to whatever ends up under the finger after the
// tapped task is removed — the next task slides up and would show
// its ✓. Keyboard focus + the genuine press still reveal it.
&:focus-visible {
box-shadow: $shadow;
transform: scale(1.05);
&::after { opacity: 0.85; }
}
@media (hover: hover) and (pointer: fine) {
&:hover {
box-shadow: $shadow;
transform: scale(1.05);
&::after { opacity: 0.85; }
}
}
&:active {
transform: scale(0.95);
&::after { opacity: 1; transform: translateY(1px) scale(1.05); }
}
}
.task-description {
all: unset;
@include medium-text();
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
text-align: left;
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
@media (max-width: $mobile-width) {
font-size: var(--medium-font-size);
font-weight: 500;
filter: saturate(0.85) brightness(0.82);
}
position: relative;
&::after { content: none; }
}
}
}
}
}
`,
})
export class TasksComponent {
readonly pending = input.required<Block[]>();
readonly baseColor = input.required<HslColor>();
/** When true, the accordion starts expanded on first render. */
readonly initiallyOpen = input<boolean>(false);
/** Emitted when the colored tickbox is clicked — parent flips is_done to true. */
readonly markDone = output<Block>();
/** Emitted when the description is clicked — parent opens the block-edit modal. */
readonly edit = output<Block>();
private readonly manuallyExpanded = signal(false);
readonly expanded = computed(() =>
shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()),
);
readonly taskListMaxHeight = taskListMaxHeight;
constructor() {
// When the page setting switches back to collapsed, discard any older manual
// open state so the setting is reflected immediately.
effect(() => {
const keepOpen = this.initiallyOpen();
if (!keepOpen) untracked(() => this.manuallyExpanded.set(false));
});
}
colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor());
}
toggleExpanded(event: Event): void {
event.stopPropagation();
if (this.initiallyOpen()) return;
this.manuallyExpanded.update((v) => !v);
}
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { shouldExpandTasks, taskListMaxHeight } from './tasks.component';
describe('shouldExpandTasks', () => {
it('expands when tasks should be kept open by page setting', () => {
expect(shouldExpandTasks(true, false)).toBe(true);
});
it('expands when the user manually opens the accordion', () => {
expect(shouldExpandTasks(false, true)).toBe(true);
});
it('collapses when keep-open is disabled', () => {
expect(shouldExpandTasks(false, false)).toBe(false);
});
});
describe('taskListMaxHeight', () => {
it('does not cap open task lists by a measured height', () => {
expect(taskListMaxHeight(true)).toBe('none');
});
it('clips collapsed task lists', () => {
expect(taskListMaxHeight(false)).toBe('0px');
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,214 @@
import { describe, expect, it } from 'vitest';
import type { StyledBlock } from './tower.component';
import { decideFalls, editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component';
function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
return {
id,
tag: 'tag',
description: id,
is_done: true,
difficulty,
created_at: 1,
_anim: '',
_transform: opacity === '1' ? 'translateY(0)' : 'translateY(500%)',
_opacity: opacity,
};
}
describe('selectVisibleStyledBlocks', () => {
it('reserves a capped visible slot for the newly completed block', () => {
const styled = [
block('newly-done'),
block('done-1'),
block('done-2'),
block('done-3'),
block('done-4'),
block('done-5'),
block('done-6'),
block('done-7'),
];
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
expect(result.hiddenCount).toBe(2);
expect(result.visibleStyled.map((b) => b.id)).toEqual([
'newly-done',
'done-3',
'done-4',
'done-5',
'done-6',
'done-7',
]);
});
it('uses the normal capped window when no new block is entering', () => {
const styled = [
block('done-0'),
block('done-1'),
block('done-2'),
block('done-3'),
block('done-4'),
block('done-5'),
block('done-6'),
block('done-7'),
];
const result = selectVisibleStyledBlocks(styled, 6, null);
expect(result.hiddenCount).toBe(2);
expect(result.visibleStyled.map((b) => b.id)).toEqual([
'done-2',
'done-3',
'done-4',
'done-5',
'done-6',
'done-7',
]);
});
it('caps by square cost (difficulty), not by raw block count', () => {
// Each block draws 2 squares, so only 3 blocks fit in 6 square slots.
const hard = (id: string): StyledBlock => block(id, '1', 2);
const styled = [hard('a'), hard('b'), hard('c'), hard('d'), hard('e')];
const result = selectVisibleStyledBlocks(styled, 6, null);
expect(result.visibleStyled.map((b) => b.id)).toEqual(['c', 'd', 'e']);
expect(result.hiddenCount).toBe(4);
});
it('keeps a previously-visible block that is now flying out, even on a full stack', () => {
// Budget is full with three resting blocks; a fourth block has just left the
// range (opacity 0 → ascending). It was visible a moment ago, so it must stay
// rendered so its fly-up transition can play instead of vanishing instantly.
const styled = [
block('old-1'),
block('old-2'),
block('old-3'),
block('leaving', '0'),
];
const prevVisible = new Set(['old-1', 'old-2', 'old-3', 'leaving']);
const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible);
expect(result.visibleStyled.map((b) => b.id)).toContain('leaving');
expect(result.hiddenCount).toBe(0);
});
it('does not resurrect an off-screen block that leaves the range', () => {
// 'hidden-leaving' was never rendered (not in prevVisible) — nothing to
// animate from, so keep it out and avoid an unbounded phantom render set.
const styled = [
block('old-1'),
block('old-2'),
block('old-3'),
block('hidden-leaving', '0'),
];
const prevVisible = new Set(['old-1', 'old-2', 'old-3']);
const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible);
expect(result.visibleStyled.map((b) => b.id)).not.toContain('hidden-leaving');
});
it('counts hidden squares when reserving the entering block', () => {
const styled = [
block('newly-done', '1', 3),
block('done-1', '1', 2),
block('done-2', '1', 2),
block('done-3', '1', 2),
block('done-4', '1', 2),
];
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
expect(result.hiddenCount).toBe(6);
expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
});
});
describe('decideFalls', () => {
it('never falls on the first render of real data (no fall on page load)', () => {
expect(
decideFalls({
firstRender: true,
animateInitialStack: false,
pendingFallIds: ['a', 'b', 'c'],
restingVisibleIds: new Set(['a', 'b', 'c']),
}),
).toEqual([]);
});
it('falls the whole initial stack only for the example showcase', () => {
expect(
decideFalls({
firstRender: true,
animateInitialStack: true,
pendingFallIds: ['a', 'b'],
restingVisibleIds: new Set(['a', 'b']),
}),
).toEqual(['a', 'b']);
});
it('falls a newly ticked/added block once the stack has rendered', () => {
expect(
decideFalls({
firstRender: false,
animateInitialStack: false,
pendingFallIds: ['new-1'],
restingVisibleIds: new Set(['old-1', 'old-2', 'new-1']),
}),
).toEqual(['new-1']);
});
it('does not fall a pending block that is capped out of view or out of range', () => {
// Out of range / capped out ⇒ not resting-visible ⇒ stays pending, no fall
// THIS round. The caller keeps it in the accumulator so it falls later.
expect(
decideFalls({
firstRender: false,
animateInitialStack: false,
pendingFallIds: ['new-hidden'],
restingVisibleIds: new Set(['old-1', 'old-2']),
}),
).toEqual([]);
});
it('falls a still-pending arrival on the LATER reconcile that brings it to rest', () => {
// Regression: ticking with the date-slider live runs two reconciles. The
// first sees the block out of range (it can't fall — see the case above);
// the second, after the slider snaps the range wider, brings it to rest.
// Because the id is still pending (not a one-shot "new this round" diff), it
// falls now instead of appearing instantly as a static square.
expect(
decideFalls({
firstRender: false,
animateInitialStack: false,
pendingFallIds: ['ticked'],
restingVisibleIds: new Set(['old-1', 'ticked']),
}),
).toEqual(['ticked']);
});
it('falls nothing when nothing is pending (e.g. a date-range reshuffle of settled blocks)', () => {
expect(
decideFalls({
firstRender: false,
animateInitialStack: false,
pendingFallIds: [],
restingVisibleIds: new Set(['old-1', 'old-2', 'old-3']),
}),
).toEqual([]);
});
});
describe('editEntryForNewBlock', () => {
it('opens the create card in the pending task view when tasks are kept open', () => {
expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null });
});
it('keeps the existing completed-task default when tasks are collapsed', () => {
expect(editEntryForNewBlock(false)).toEqual({ filter: 'done', activeId: null });
});
});

View file

@ -0,0 +1,387 @@
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
@Component({
selector: 'lt-welcome',
standalone: true,
imports: [A11yModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="welcome-card">
<h2 id="welcome-title" tabindex="-1" cdkFocusInitial>Welcome to Life Towers</h2>
<button
class="exit"
type="button"
(click)="close.emit()"
aria-label="Dismiss welcome"
></button>
<p class="lead" id="welcome-description">
Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies,
home, or any project. Add towers for task groups, then check off tasks to build them
block by block.
</p>
<p class="sr-only">
Preview showing three towers with pending task bars at the top and completed task
blocks stacked below.
</p>
<div class="tower-preview" aria-hidden="true">
<div class="preview-shell">
<div class="preview-page-tab"></div>
<div class="preview-towers">
<div class="preview-tower preview-tower--reading">
<span class="preview-task"></span>
<div class="preview-stack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="preview-tower preview-tower--projects">
<span class="preview-task"></span>
<span class="preview-task preview-task--short"></span>
<div class="preview-stack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="preview-tower preview-tower--exercise">
<span class="preview-task"></span>
<div class="preview-stack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
</div>
<h3 class="sr-only" id="welcome-basics-title">How Life Towers works</h3>
<dl class="basics" aria-labelledby="welcome-basics-title">
<div class="basic">
<dt class="basic__label">Pages</dt>
<dd class="basic__text">Keep each area separate.</dd>
</div>
<div class="basic">
<dt class="basic__label">Towers</dt>
<dd class="basic__text">Stack related tasks together.</dd>
</div>
<div class="basic">
<dt class="basic__label">Blocks</dt>
<dd class="basic__text">Finished tasks become colored blocks.</dd>
</div>
</dl>
<div class="actions">
<button type="button" (click)="startFresh.emit()">Start empty</button>
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
</div>
</div>
`,
styles: `
@import '../../../library/main';
:host { display: block; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.welcome-card {
@include card();
width: min(560px, calc(100vw - (2 * var(--large-padding))));
max-width: 560px;
max-height: calc(100svh - (2 * var(--medium-padding)));
overflow-y: auto;
@media (max-width: $mobile-width) {
width: min(88vw, calc(100vw - (2 * var(--medium-padding))));
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
text-align: left;
font-family: $normal-font;
font-weight: 300;
font-size: var(--medium-font-size);
line-height: 1.45;
@include inner-spacing(var(--medium-padding));
h2,
h3,
p,
dl,
dd {
margin: 0;
}
.exit {
position: absolute;
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
}
h2 {
font-family: $title-font;
font-weight: 400;
font-size: var(--larger-font-size);
text-align: center;
padding: 0 36px;
line-height: 1.25;
@media (max-width: $mobile-width) {
padding: 0 28px;
}
}
.lead {
color: $text-color;
font-family: inherit;
font-weight: inherit;
font-size: inherit;
line-height: inherit;
max-width: 46ch;
margin-inline: auto;
text-align: center;
}
.tower-preview {
box-sizing: border-box;
padding: var(--medium-padding) 0;
border-top: 1px solid rgba($text-color, 0.08);
border-bottom: 1px solid rgba($text-color, 0.08);
@media (max-width: $mobile-width) {
padding-block: var(--small-padding);
}
}
.preview-shell {
width: min(100%, 370px);
margin: 0 auto;
}
.preview-page-tab {
width: 96px;
height: 14px;
margin: 0 auto var(--small-padding);
border-radius: var(--border-radius);
background: rgba($text-color, 0.08);
box-shadow: $shadow-border;
}
.preview-towers {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
gap: var(--small-padding);
}
.preview-tower {
display: flex;
flex-direction: column;
min-height: 128px;
padding: 6px;
box-sizing: border-box;
border-radius: var(--border-radius);
background: rgba($text-color, 0.025);
box-shadow: $shadow-border;
}
.preview-task {
display: block;
width: 74%;
height: 5px;
margin-bottom: 4px;
border-radius: var(--border-radius);
background: var(--task-color);
}
.preview-task--short {
width: 48%;
}
.preview-stack {
display: flex;
flex-flow: row wrap-reverse;
align-content: flex-start;
gap: 2px;
min-height: 86px;
margin-top: auto;
span {
display: block;
flex: 0 0 calc((100% - 4px) / 3);
aspect-ratio: 1;
border-radius: 2px;
background: var(--block-color);
box-shadow: $shadow-border;
}
span:nth-child(2n) {
filter: saturate(0.9) brightness(1.06);
}
span:nth-child(3n) {
filter: saturate(1.08) brightness(0.96);
}
}
@media (prefers-reduced-motion: no-preference) {
@keyframes preview-task-pulse {
0%, 100% {
opacity: 0.42;
transform: scaleX(0.86);
}
45%, 70% {
opacity: 1;
transform: scaleX(1);
}
}
@keyframes preview-block-fall {
0% {
opacity: 0;
transform: translateY(-220%);
}
60%, 100% {
opacity: 1;
transform: translateY(0);
}
}
.preview-task {
transform-origin: left center;
animation: preview-task-pulse 2600ms ease-in-out infinite;
}
.preview-stack span {
opacity: 0;
animation: preview-block-fall 1200ms cubic-bezier(0.5, 0, 1, 0) forwards;
animation-delay: calc(160ms + var(--fall-index, 0) * 95ms);
}
.preview-stack span:nth-child(1) { --fall-index: 0; }
.preview-stack span:nth-child(2) { --fall-index: 1; }
.preview-stack span:nth-child(3) { --fall-index: 2; }
.preview-stack span:nth-child(4) { --fall-index: 3; }
.preview-stack span:nth-child(5) { --fall-index: 4; }
.preview-stack span:nth-child(6) { --fall-index: 5; }
.preview-stack span:nth-child(7) { --fall-index: 6; }
.preview-stack span:nth-child(8) { --fall-index: 7; }
.preview-stack span:nth-child(9) { --fall-index: 8; }
}
.preview-tower--reading {
--block-color: hsl(18, 70%, 58%);
--task-color: hsla(18, 70%, 58%, 0.26);
}
.preview-tower--projects {
--block-color: hsl(209, 65%, 52%);
--task-color: hsla(209, 65%, 52%, 0.24);
}
.preview-tower--exercise {
--block-color: hsl(130, 45%, 44%);
--task-color: hsla(130, 45%, 44%, 0.22);
}
.basics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--small-padding);
@media (max-width: $mobile-width) {
grid-template-columns: 1fr;
}
}
.basic {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.basic__label,
.basic__text {
font-family: inherit;
font-weight: inherit;
font-size: inherit;
line-height: inherit;
}
.basic__label {
color: $text-color;
}
.basic__text {
color: rgba($text-color, 0.82);
}
.actions {
display: flex;
justify-content: space-around;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--large-padding);
margin-top: var(--large-padding);
button {
font-family: inherit;
font-weight: inherit;
font-size: inherit;
margin: 0;
max-width: 100%;
line-height: inherit;
text-align: center;
}
button.primary {
color: $accent-color;
border-bottom-color: rgba($accent-color, 0.33);
&:after { background-color: $accent-color; }
}
@media (max-width: $mobile-width) {
gap: var(--small-padding);
}
}
}
`,
})
export class WelcomeComponent {
readonly close = output<void>();
readonly startFresh = output<void>();
readonly loadExample = output<void>();
}

View file

@ -0,0 +1,52 @@
export interface HslColor {
h: number; // 0-1
s: number; // 0-1
l: number; // 0-1
}
export interface Block {
id: string;
tag: string;
description: string;
is_done: boolean;
/** How many squares this block draws in the tower (>= 1). */
difficulty: number;
created_at: number;
}
export interface Tower {
id: string;
name: string;
base_color: HslColor;
blocks: Block[];
}
export interface Page {
id: string;
name: string;
hide_create_tower_button: boolean;
keep_tasks_open: boolean;
default_date_from: number | null;
default_date_to: number | null;
towers: Tower[];
}
export interface TreeDto {
pages: Page[];
}
/** Response of GET /data: the tree plus the user's current sync revision. */
export interface DataResponse {
pages: Page[];
revision: number;
}
export type SaveStatus =
| 'idle'
| 'saving'
| 'saved'
| 'retrying'
| 'error' // generic / network — retries exhausted until the next mutation
| 'too-large' // 413 — payload exceeds the server cap, will not retry
| 'rate-limited' // 429 — will retry after Retry-After
| 'invalid'; // 400 — server rejected the body, will not retry

View file

@ -0,0 +1,65 @@
import { Injectable, isDevMode } from '@angular/core';
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'schmelczer.dev/towers';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private isInitialized = false;
private hasTrackedStart = false;
init(): void {
if (this.isInitialized) return;
try {
plausibleInit({
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: isDevMode(),
});
this.isInitialized = true;
} catch (error) {
console.warn('Could not initialize analytics.', error);
}
}
private track(eventName: string, options: PlausibleEventOptions = {}): void {
try {
plausibleTrack(eventName, options);
} catch (error) {
console.warn(`Could not track analytics event "${eventName}".`, error);
}
}
trackStart(): void {
if (this.hasTrackedStart) return;
this.hasTrackedStart = true;
this.track('Start');
}
trackExampleLoaded(): void {
this.track('Example Loaded');
}
trackPageCreated(): void {
this.track('Page Created');
}
trackTowerCreated(): void {
this.track('Tower Created');
}
trackBlockCreated({ isDone }: { isDone: boolean }): void {
this.track('Block Created', { props: { isDone: String(isDone) } });
}
trackBlockCompleted(): void {
this.track('Block Completed');
}
}

View file

@ -0,0 +1,102 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { DataResponse, TreeDto } from '../models';
import { createSseParser } from '../utils/sse';
/** Callbacks for a live event stream. */
export interface EventStreamHandlers {
/** A revision the server says is current; the store decides whether to refetch. */
onRevision: (revision: number) => void;
/** The stream ended on its own (server closed or network error) not an
* intentional close. The caller may reconnect. */
onClosed: () => void;
}
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient);
health(): Promise<{ status: string }> {
return firstValueFrom(this.http.get<{ status: string }>('api/v1/health'));
}
register(token: string): Promise<{ user_id: string }> {
return firstValueFrom(
this.http.post<{ user_id: string }>('api/v1/register', { token }),
);
}
getData(token: string): Promise<DataResponse> {
return firstValueFrom(
this.http.get<DataResponse>('api/v1/data', { headers: this.authHeaders(token) }),
);
}
/**
* Replace the user's tree. `baseRevision` is the client's compare-and-swap
* base, sent as If-Match; the server rejects with 409 if it has moved on.
* Resolves to the new revision the write produced.
*/
async putData(token: string, tree: TreeDto, baseRevision: number): Promise<number> {
const headers = this.authHeaders(token).set('If-Match', String(baseRevision));
const res = await firstValueFrom(
this.http.put<{ revision: number }>('api/v1/data', tree, { headers }),
);
return res.revision;
}
/**
* Open the SSE stream for a token via fetch()+ReadableStream so the Bearer
* token travels in a header (EventSource can't do that). Returns a function
* that closes the stream; closing it does NOT invoke `onClosed`.
*/
openEventStream(token: string, handlers: EventStreamHandlers): () => void {
const controller = new AbortController();
void this.consumeEventStream(token, handlers, controller.signal);
return () => controller.abort();
}
private async consumeEventStream(
token: string,
handlers: EventStreamHandlers,
signal: AbortSignal,
): Promise<void> {
try {
const response = await fetch('api/v1/events', {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
signal,
cache: 'no-store',
});
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const feed = createSseParser((message) => {
if (message.event !== null && message.event !== 'revision') return;
try {
const parsed = JSON.parse(message.data) as { revision?: unknown };
if (typeof parsed.revision === 'number') handlers.onRevision(parsed.revision);
} catch {
/* ignore malformed frames */
}
});
for (;;) {
const { value, done } = await reader.read();
if (done) break;
feed(decoder.decode(value, { stream: true }));
}
} catch {
/* aborted or network error — handled below */
} finally {
// An intentional close (controller.abort()) should not trigger a
// reconnect; only an unexpected end does.
if (!signal.aborted) handlers.onClosed();
}
}
private authHeaders(token: string): HttpHeaders {
return new HttpHeaders({ Authorization: `Bearer ${token}` });
}
}

View file

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ApiService } from './api.service';
import type { DataResponse, TreeDto } from '../models';
describe('ApiService', () => {
let service: ApiService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), ApiService],
});
service = TestBed.inject(ApiService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => {
http.verify();
});
it('gets data with a bearer token and returns the revision', async () => {
const body: DataResponse = { pages: [], revision: 7 };
const promise = service.getData('token-1');
const req = http.expectOne('api/v1/data');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
req.flush(body);
await expect(promise).resolves.toEqual(body);
});
it('puts data with a bearer token + If-Match base revision and returns the new revision', async () => {
const tree: TreeDto = { pages: [] };
const promise = service.putData('token-1', tree, 4);
const req = http.expectOne('api/v1/data');
expect(req.request.method).toBe('PUT');
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
expect(req.request.headers.get('If-Match')).toBe('4');
expect(req.request.body).toBe(tree);
req.flush({ revision: 5 });
await expect(promise).resolves.toBe(5);
});
});

View file

@ -0,0 +1,25 @@
import { Injectable, computed, signal } from '@angular/core';
/**
* Shared counter of currently-open modals. Each `lt-modal` increments on
* mount and decrements on destroy. Consumers read `anyOpen` to react.
*
* Used by `page.component` to disable tower drag-and-drop while any modal
* (block-edit carousel, settings, tower-settings, confirm-delete,
* settings) is on screen otherwise the user can drag towers from behind
* the open card.
*/
@Injectable({ providedIn: 'root' })
export class ModalStateService {
private readonly _openCount = signal(0);
readonly anyOpen = computed(() => this._openCount() > 0);
open(): void {
this._openCount.update((n) => n + 1);
}
close(): void {
this._openCount.update((n) => Math.max(0, n - 1));
}
}

View file

@ -0,0 +1,923 @@
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
import { ApiService } from './api.service';
import { AnalyticsService } from './analytics.service';
import { Page, Tower, Block, TreeDto, DataResponse, SaveStatus, HslColor } from '../models';
const TOKEN_KEY = 'life-towers.token.v4';
const CACHE_KEY_PREFIX = 'life-towers.cache.v4';
const PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4';
const DEBOUNCE_MS = 750;
const MAX_RETRIES = 5;
// SSE reconnect backoff after the stream drops (network blip, server restart).
const SSE_RECONNECT_BASE_MS = 1000;
const SSE_RECONNECT_MAX_MS = 30_000;
// RFC 4122 v4 UUID. Prefers crypto.randomUUID (secure contexts only) and
// falls back to crypto.getRandomValues — which works on plain http origins
// behind a non-localhost reverse proxy too.
function uuidV4(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
try {
return crypto.randomUUID();
} catch {
/* fall through */
}
}
const b = new Uint8Array(16);
crypto.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
function isUuidV4(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
value.toLowerCase(),
);
}
// localStorage can throw (private mode, quota exceeded, disabled). All
// access goes through these helpers so a transient failure never bubbles
// up and breaks app init.
function safeGet(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function safeSet(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
function safeRemove(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
function cacheKeyForToken(token: string): string {
return `${CACHE_KEY_PREFIX}.${token}`;
}
function pendingCacheKeyForToken(token: string): string {
return `${PENDING_CACHE_KEY_PREFIX}.${token}`;
}
interface PendingPut {
token: string;
tree: TreeDto;
revision: number;
}
interface ExampleBlockSeed {
tag: string;
desc: string;
done: boolean;
difficulty?: number;
ageHrs: number;
}
interface ExampleDonePattern {
tag: string;
desc: (sequence: number) => string;
}
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 12;
@Injectable({ providedIn: 'root' })
export class StoreService implements OnDestroy {
private readonly api = inject(ApiService);
private readonly analytics = inject(AnalyticsService);
// ── State ──────────────────────────────────────────────────────────────────
private readonly _pages = signal<Page[]>([]);
private readonly _saveStatus = signal<SaveStatus>('idle');
private readonly _token = signal<string>('');
private readonly _loading = signal<boolean>(true);
readonly pages = this._pages.asReadonly();
readonly saveStatus = this._saveStatus.asReadonly();
readonly loading = this._loading.asReadonly();
readonly token = this._token.asReadonly();
// ── Debounce / retry ───────────────────────────────────────────────────────
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private retryResolver: (() => void) | null = null;
private flushInFlight = false;
// True while in-flight if a new mutation arrived; we'll re-flush after.
private dirtyDuringFlush = false;
// Monotonic local mutation version. Prevents an older in-flight PUT from
// clearing a newer pending cache entry when it completes.
private localMutationRevision = 0;
// ── Server revision (compare-and-swap base) ─────────────────────────────────
// The revision the server last confirmed for us; sent as the If-Match base on
// every PUT and compared against SSE notifications to decide whether to refetch.
private serverRevision = 0;
// ── Live sync (SSE) ─────────────────────────────────────────────────────────
private closeEventStream: (() => void) | null = null;
private eventStreamToken = '';
private sseReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private sseReconnectAttempts = 0;
// ── Cross-tab sync ─────────────────────────────────────────────────────────
private readonly storageListener = (e: StorageEvent) => {
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
this.switchToken(e.newValue);
} else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) {
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
// (to avoid clobbering our own state with the other tab's older view).
try {
const tree: TreeDto = JSON.parse(e.newValue);
this._pages.set(tree.pages);
} catch {
/* ignore */
}
}
};
// ── Single-flight init ─────────────────────────────────────────────────────
private initPromise: Promise<void> | null = null;
private initGeneration = 0;
// ── Init ───────────────────────────────────────────────────────────────────
async init(): Promise<void> {
if (this.initPromise) return this.initPromise;
const generation = ++this.initGeneration;
this.initPromise = this.doInit(generation).finally(() => {
if (this.initGeneration === generation) {
this.initPromise = null;
}
});
return this.initPromise;
}
private async doInit(generation: number): Promise<void> {
if (typeof window !== 'undefined') {
// (idempotent — adding the same listener twice is a no-op)
window.addEventListener('storage', this.storageListener);
}
let stored = safeGet(TOKEN_KEY);
if (stored && !isUuidV4(stored)) {
// Garbage in localStorage from a buggy past version — refuse it.
stored = null;
} else if (stored) {
stored = stored.toLowerCase();
safeSet(TOKEN_KEY, stored);
}
const token = stored ?? uuidV4();
if (!stored) {
safeSet(TOKEN_KEY, token);
}
this._token.set(token);
if (!stored) {
try {
await this.api.register(token);
} catch {
// Non-fatal; the 401 path below will re-attempt registration.
}
}
try {
const tree = await this.api.getData(token);
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
} catch (err: unknown) {
const status = (err as { status?: number })?.status;
if (status === 401) {
// Token unknown to server — re-register (idempotent) and retry.
try {
await this.api.register(token);
const tree = await this.api.getData(token);
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
} catch {
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
}
} else {
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
}
} finally {
if (this.initGeneration === generation) {
this._loading.set(false);
}
// Subscribe to live updates for this token. Started even if the data load
// failed (we'll have fallen back to cache) — the stream self-heals when
// connectivity returns and its first event triggers a refetch.
if (this.isCurrentInit(generation, token)) {
this.startEventStream(token);
}
}
}
private isCurrentInit(generation: number, token: string): boolean {
return this.initGeneration === generation && this._token() === token;
}
/**
* Apply a freshly-fetched server tree. If the server is empty but our local
* cache holds data, the cache wins and we schedule a push otherwise the
* "server forgot me" recovery would silently wipe offline edits.
*
* The server's revision becomes our CAS base regardless of which view we
* display: even when the cache wins, the next PUT is guarded against it.
*/
private adoptServerTree(data: DataResponse, token: string): void {
this.setServerRevision(data, token);
if (safeGet(pendingCacheKeyForToken(token))) {
const cachedTree = this.readCachedTree(token);
if (cachedTree?.pages && cachedTree.pages.length > 0) {
this._pages.set(cachedTree.pages);
this.scheduleSave();
return;
}
}
if (data.pages.length === 0) {
const cachedTree = this.readCachedTree(token);
if (cachedTree?.pages && cachedTree.pages.length > 0) {
this._pages.set(cachedTree.pages);
this.scheduleSave();
return;
}
}
this._pages.set(data.pages);
this.updateCache(token, { pages: data.pages });
}
/** Record the server's revision as our compare-and-swap base. */
private setServerRevision(data: DataResponse, token: string): void {
if (this._token() !== token) return;
this.serverRevision = data.revision ?? 0;
}
private loadFromCache(token: string): void {
const tree = this.readCachedTree(token);
if (tree) this._pages.set(tree.pages);
}
private readCachedTree(token: string): TreeDto | null {
const raw = safeGet(cacheKeyForToken(token));
if (!raw) return null;
try {
return JSON.parse(raw) as TreeDto;
} catch {
return null;
}
}
private updateCache(token: string, tree: TreeDto, pending = false): void {
safeSet(cacheKeyForToken(token), JSON.stringify(tree));
if (pending) {
safeSet(pendingCacheKeyForToken(token), '1');
} else {
safeRemove(pendingCacheKeyForToken(token));
}
}
private cancelPendingWrites(): void {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.retryTimer !== null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
if (this.retryResolver !== null) {
const resolve = this.retryResolver;
this.retryResolver = null;
resolve();
}
this.dirtyDuringFlush = false;
}
// ── Mutations ──────────────────────────────────────────────────────────────
addPage(name: string): void {
const page: Page = {
id: uuidV4(),
name,
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [],
};
this._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackPageCreated();
this.scheduleSave();
}
updatePage(id: string, patch: Partial<Omit<Page, 'id' | 'towers'>>): void {
this._pages.update((pages) =>
pages.map((p) => (p.id === id ? { ...p, ...patch } : p)),
);
this.scheduleSave();
}
deletePage(id: string): void {
this._pages.update((pages) => pages.filter((p) => p.id !== id));
this.scheduleSave();
}
reorderPages(fromIndex: number, toIndex: number): void {
let changed = false;
this._pages.update((pages) => {
const reordered = reorder(pages, fromIndex, toIndex);
changed = reordered !== null;
return reordered ?? pages;
});
if (changed) this.scheduleSave();
}
addTower(pageId: string, name: string, base_color: HslColor): string {
const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] };
this._pages.update((pages) =>
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
);
this.analytics.trackStart();
this.analytics.trackTowerCreated();
this.scheduleSave();
return tower.id;
}
updateTower(pageId: string, towerId: string, patch: Partial<Omit<Tower, 'id' | 'blocks'>>): void {
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId
? { ...p, towers: p.towers.map((t) => (t.id === towerId ? { ...t, ...patch } : t)) }
: p,
),
);
this.scheduleSave();
}
deleteTower(pageId: string, towerId: string): void {
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId ? { ...p, towers: p.towers.filter((t) => t.id !== towerId) } : p,
),
);
this.scheduleSave();
}
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
let changed = false;
this._pages.update((pages) =>
pages.map((p) => {
if (p.id !== pageId) return p;
const towers = reorder(p.towers, fromIndex, toIndex);
if (towers === null) return p;
changed = true;
return { ...p, towers };
}),
);
if (changed) this.scheduleSave();
}
addBlock(
pageId: string,
towerId: string,
tag: string,
description: string,
is_done = false,
difficulty = 1,
): void {
const block: Block = {
id: uuidV4(),
tag,
description,
is_done,
difficulty,
created_at: Math.floor(Date.now() / 1000),
};
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
),
}
: p,
),
);
this.analytics.trackStart();
this.analytics.trackBlockCreated({ isDone: is_done });
this.scheduleSave();
}
updateBlock(
pageId: string,
towerId: string,
blockId: string,
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
): void {
let becameDone = false;
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId
? (() => {
const result = this.patchBlockList(t.blocks, blockId, patch);
becameDone = result.becameDone;
return { ...t, blocks: result.blocks };
})()
: t,
),
}
: p,
),
);
if (becameDone) {
this.analytics.trackStart();
this.analytics.trackBlockCompleted();
}
this.scheduleSave();
}
deleteBlock(pageId: string, towerId: string, blockId: string): void {
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId
? {
...p,
towers: p.towers.map((t) =>
t.id === towerId
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
: t,
),
}
: p,
),
);
this.scheduleSave();
}
private patchBlockList(
blocks: Block[],
blockId: string,
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
): { blocks: Block[]; becameDone: boolean } {
const nextBlocks: Block[] = [];
let completedBlock: Block | null = null;
let becameDone = false;
for (const block of blocks) {
if (block.id !== blockId) {
nextBlocks.push(block);
continue;
}
const updated: Block = { ...block, ...patch };
becameDone = !block.is_done && updated.is_done;
if (becameDone) {
// Display newly completed todos as the newest square, without changing
// created_at because the date slider still filters on creation time.
completedBlock = updated;
} else {
nextBlocks.push(updated);
}
}
return {
blocks: completedBlock ? [...nextBlocks, completedBlock] : nextBlocks,
becameDone,
};
}
/**
* Switch to a different user's token. Any pending writes for the OLD
* account must be cancelled first otherwise a queued PUT could fire
* against the NEW account with the old account's data (or vice versa,
* if the timing flipped).
*/
switchToken(newToken: string): void {
const token = newToken.toLowerCase();
if (!isUuidV4(token)) return;
this.cancelPendingWrites();
// Tear down the old account's live stream before init() opens a new one.
this.stopEventStream();
this.sseReconnectAttempts = 0;
this.initGeneration++;
this.initPromise = null;
safeSet(TOKEN_KEY, token);
this._token.set(token);
this._pages.set([]);
this._loading.set(true);
this._saveStatus.set('idle');
this.localMutationRevision = 0;
this.serverRevision = 0;
void this.init();
}
// ── Save / sync ────────────────────────────────────────────────────────────
private scheduleSave(): void {
this.localMutationRevision += 1;
const token = this._token();
if (token) this.updateCache(token, { pages: this._pages() }, true);
if (this.flushInFlight) {
// A save is already happening. Mark dirty so we re-flush when it finishes.
this.dirtyDuringFlush = true;
return;
}
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
void this.runFlush();
}, DEBOUNCE_MS);
}
/**
* One save attempt with bounded retries. Captures the token and tree
* snapshot up front so a mid-flight switchToken can't redirect this
* write to a different account.
*/
private async runFlush(): Promise<void> {
if (this.flushInFlight) {
this.dirtyDuringFlush = true;
return;
}
const token = this._token();
if (!token) return;
this.flushInFlight = true;
this.dirtyDuringFlush = false;
const revision = this.localMutationRevision;
// Cancel any pending retry — runFlush() supersedes it.
if (this.retryTimer !== null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
try {
await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0);
} finally {
this.flushInFlight = false;
// Coalesce mutations that arrived during the flush into a fresh save.
if (this.dirtyDuringFlush) {
this.dirtyDuringFlush = false;
this.scheduleSave();
}
}
}
private async attempt(put: PendingPut, attempt: number): Promise<void> {
this._saveStatus.set(attempt === 0 ? 'saving' : 'retrying');
try {
const newRevision = await this.api.putData(put.token, put.tree, this.serverRevision);
this._saveStatus.set('saved');
if (this._token() === put.token) {
// A successful write advances the revision by exactly one, so fall back
// to that if the response didn't carry a number.
this.serverRevision = Number.isFinite(newRevision)
? newRevision
: this.serverRevision + 1;
if (put.revision === this.localMutationRevision && !this.dirtyDuringFlush) {
this.updateCache(put.token, put.tree);
}
}
return;
} catch (err: unknown) {
const status = (err as { status?: number })?.status;
const headers = (err as { headers?: { get(name: string): string | null } })?.headers;
// Permanent failures: no point retrying.
if (status === 400) {
this._saveStatus.set('invalid');
return;
}
if (status === 413) {
this._saveStatus.set('too-large');
return;
}
// 409: another client wrote since our base revision. Resolve server-wins —
// refetch the current server tree and adopt it, discarding this device's
// un-pushed edit. The CAS still prevents a stale write from clobbering the
// other device's data; we just don't merge the two views.
if (status === 409) {
this._saveStatus.set('retrying');
try {
const remote = await this.api.getData(put.token);
if (this._token() !== put.token) return;
this.adoptServerData(remote, put.token);
this._saveStatus.set('saved');
return;
} catch {
// Couldn't refetch (network); fall through to backoff and retry the
// PUT, which will 409 again and re-attempt the refetch.
}
}
// 401 mid-PUT: server forgot us. Re-register (idempotent) and retry.
if (status === 401) {
try {
await this.api.register(put.token);
} catch {
// fall through to retry/backoff
}
}
if (attempt >= MAX_RETRIES) {
this._saveStatus.set('error');
return;
}
// Honor server's Retry-After when present (429 in particular).
let delayMs = Math.min(1000 * 2 ** attempt, 30000);
if (status === 429) {
this._saveStatus.set('rate-limited');
const ra = headers?.get('Retry-After') ?? headers?.get('retry-after');
if (ra) {
const seconds = parseInt(ra, 10);
if (Number.isFinite(seconds) && seconds > 0) {
delayMs = Math.min(seconds * 1000, 60_000);
}
}
}
await new Promise<void>((resolve) => {
this.retryResolver = resolve;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.retryResolver = null;
resolve();
}, delayMs);
});
// The token may have changed during the wait — re-check before retrying.
if (this._token() !== put.token) return;
// Re-snapshot the latest pages so the retry pushes current state.
await this.attempt(
{ token: put.token, tree: { pages: this._pages() }, revision: put.revision },
attempt + 1,
);
}
}
// ── Live sync (SSE) ─────────────────────────────────────────────────────────
private startEventStream(token: string): void {
if (typeof window === 'undefined') return;
if (this.eventStreamToken === token && this.closeEventStream) return;
this.stopEventStream();
this.eventStreamToken = token;
this.closeEventStream = this.api.openEventStream(token, {
onRevision: (revision) => this.onRemoteRevision(token, revision),
onClosed: () => this.onEventStreamClosed(token),
});
}
private onRemoteRevision(token: string, revision: number): void {
if (this._token() !== token) return;
// A delivered event proves the stream works — reset the reconnect backoff.
this.sseReconnectAttempts = 0;
// Our own echo, or an out-of-order/stale frame: nothing new to pull.
if (revision <= this.serverRevision) return;
// If a save is pending/in-flight its compare-and-swap will reconcile via a
// 409; refetching now would race it. Only adopt when we're clean.
if (this.hasPendingWork()) return;
void this.pullFromRemote(token);
}
private onEventStreamClosed(token: string): void {
this.closeEventStream = null;
this.eventStreamToken = '';
if (this._token() !== token) return;
if (this.sseReconnectTimer !== null) clearTimeout(this.sseReconnectTimer);
const delay = Math.min(
SSE_RECONNECT_BASE_MS * 2 ** this.sseReconnectAttempts,
SSE_RECONNECT_MAX_MS,
);
this.sseReconnectAttempts += 1;
this.sseReconnectTimer = setTimeout(() => {
this.sseReconnectTimer = null;
if (this._token() === token) this.startEventStream(token);
}, delay);
}
private stopEventStream(): void {
if (this.sseReconnectTimer !== null) {
clearTimeout(this.sseReconnectTimer);
this.sseReconnectTimer = null;
}
if (this.closeEventStream) {
this.closeEventStream();
this.closeEventStream = null;
}
this.eventStreamToken = '';
}
/**
* Pull the server tree after a remote-change notification. Reached only when
* we're clean, so the server is authoritative and we adopt it wholesale
* unless the user starts editing during the fetch, in which case we back off
* and let the next save's compare-and-swap reconcile (so the edit survives).
*/
private async pullFromRemote(token: string): Promise<void> {
if (this.hasPendingWork()) return;
let remote: DataResponse;
try {
remote = await this.api.getData(token);
} catch {
return; // transient; a later event or reconnect retries
}
if (this._token() !== token) return;
if (this.hasPendingWork()) return; // edited mid-fetch → defer to CAS
this.adoptServerData(remote, token);
}
/**
* Adopt a server tree as the new truth. Used both when a clean client pulls a
* remote change (nothing local to lose) and on the 409 server-wins path (any
* un-pushed local edit on this device is intentionally discarded).
*/
private adoptServerData(data: DataResponse, token: string): void {
if (this._token() !== token) return;
this.setServerRevision(data, token);
this._pages.set(data.pages);
this.updateCache(token, { pages: data.pages });
}
/** True while any local change is unsaved, being saved, or awaiting retry. */
private hasPendingWork(): boolean {
return (
this.debounceTimer !== null ||
this.retryTimer !== null ||
this.flushInFlight ||
this.dirtyDuringFlush ||
!!safeGet(pendingCacheKeyForToken(this._token()))
);
}
// ── Example data ──────────────────────────────────────────────────────────
loadExample(): string {
const now = Math.floor(Date.now() / 1000);
const page: Page = {
id: uuidV4(),
name: 'Hobbies',
hide_create_tower_button: false,
keep_tasks_open: true,
default_date_from: null,
default_date_to: null,
towers: [
// Done blocks are listed oldest-first so they fill the falling stack
// oldest → newest (left → right), matching the slider's old → new
// labels; pending tasks stay on top for the accordion.
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
{
tag: 'novel',
desc: 'Finish The Brothers Karamazov',
done: false,
difficulty: 4,
ageHrs: 0,
},
...this.makeExampleDoneBlocks(
[
{ tag: 'novel', desc: (n) => `Read chapter ${n}` },
{ tag: 'paper', desc: (n) => `Annotated paper ${n}` },
{ tag: 'article', desc: (n) => `Saved article notes ${n}` },
{ tag: 'essay', desc: (n) => `Drafted reading response ${n}` },
],
2,
8,
),
]),
this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
{
tag: 'angular',
desc: 'Modernise the towers app',
done: false,
difficulty: 3,
ageHrs: 0,
},
{
tag: 'rust',
desc: 'Port the sync layer to Tauri',
done: false,
difficulty: 5,
ageHrs: 1,
},
...this.makeExampleDoneBlocks(
[
{ tag: 'angular', desc: (n) => `Refined UI pass ${n}` },
{ tag: 'rust', desc: (n) => `Completed systems spike ${n}` },
{ tag: 'infra', desc: (n) => `Tuned deploy workflow ${n}` },
{ tag: 'docs', desc: (n) => `Captured project note ${n}` },
],
4,
9,
),
]),
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
{ tag: 'run', desc: '10k Sunday', done: false, difficulty: 2, ageHrs: 0 },
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, difficulty: 4, ageHrs: 4 },
...this.makeExampleDoneBlocks(
[
{ tag: 'run', desc: (n) => `Easy run ${n}` },
{ tag: 'climb', desc: (n) => `Bouldering session ${n}` },
{ tag: 'mobility', desc: (n) => `Mobility circuit ${n}` },
{ tag: 'strength', desc: (n) => `Strength session ${n}` },
],
6,
11,
),
]),
],
};
this._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackExampleLoaded();
this.scheduleSave();
return page.id;
}
private makeExampleTower(
name: string,
base_color: HslColor,
nowSec: number,
blocks: ExampleBlockSeed[],
): Tower {
return {
id: uuidV4(),
name,
base_color,
blocks: blocks.map((b) => ({
id: uuidV4(),
tag: b.tag,
description: b.desc,
is_done: b.done,
difficulty: b.difficulty ?? 1,
created_at: nowSec - Math.floor(b.ageHrs * 3600),
})),
};
}
private makeExampleDoneBlocks(
patterns: ExampleDonePattern[],
newestAgeHrs: number,
spacingHrs: number,
): ExampleBlockSeed[] {
return Array.from({ length: EXAMPLE_DONE_BLOCKS_PER_TOWER }, (_, i) => {
const pattern = patterns[i % patterns.length];
const sequence = Math.floor(i / patterns.length) + 1;
return {
tag: pattern.tag,
desc: pattern.desc(sequence),
done: true,
difficulty: 1 + ((i + (i % patterns.length) * 2) % 5),
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
};
});
}
ngOnDestroy(): void {
this.cancelPendingWrites();
this.stopEventStream();
if (typeof window !== 'undefined') {
window.removeEventListener('storage', this.storageListener);
}
}
}
function reorder<T>(items: readonly T[], fromIndex: number, toIndex: number): T[] | null {
if (
fromIndex === toIndex ||
!Number.isInteger(fromIndex) ||
!Number.isInteger(toIndex) ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= items.length ||
toIndex >= items.length
) {
return null;
}
const next = [...items];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return next;
}

View file

@ -0,0 +1,845 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { StoreService } from './store.service';
import { ApiService } from './api.service';
import { AnalyticsService } from './analytics.service';
import type { TreeDto } from '../models';
// ── localStorage stub ────────────────────────────────────────────────────────
const storage: Record<string, string> = {};
let storageThrowsOnSet = false;
const localStorageStub = {
getItem: (k: string) => storage[k] ?? null,
setItem: (k: string, v: string) => {
if (storageThrowsOnSet) throw new Error('QuotaExceededError');
storage[k] = v;
},
removeItem: (k: string) => {
delete storage[k];
},
clear: () => Object.keys(storage).forEach((k) => delete storage[k]),
key: (i: number) => Object.keys(storage)[i] ?? null,
get length() {
return Object.keys(storage).length;
},
};
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageStub,
configurable: true,
writable: true,
});
// ── crypto stub for tests that need deterministic UUIDs ─────────────────────
const realCrypto = globalThis.crypto;
function withFixedUuid<T>(uuid: string, fn: () => T): T {
const stub = {
randomUUID: () => uuid,
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
};
Object.defineProperty(globalThis, 'crypto', { value: stub, configurable: true });
try {
return fn();
} finally {
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
}
}
// ── Mock ApiService factory ──────────────────────────────────────────────────
interface MockApi {
register: ReturnType<typeof vi.fn>;
getData: ReturnType<typeof vi.fn>;
putData: ReturnType<typeof vi.fn>;
health: ReturnType<typeof vi.fn>;
openEventStream: ReturnType<typeof vi.fn>;
}
function makeMockApi(): MockApi {
return {
health: vi.fn().mockResolvedValue({ status: 'ok' }),
register: vi.fn().mockResolvedValue({ user_id: 'u' }),
getData: vi.fn().mockResolvedValue({ pages: [], revision: 0 }),
putData: vi.fn().mockResolvedValue(1),
// Returns the stream's close handle; tests override to capture callbacks.
openEventStream: vi.fn().mockReturnValue(() => {}),
};
}
// Grab the handlers the store last passed to openEventStream so a test can
// simulate a server push.
function lastStreamHandlers(api: MockApi): {
onRevision: (revision: number) => void;
onClosed: () => void;
} {
const calls = api.openEventStream.mock.calls;
return calls[calls.length - 1][1];
}
// Flush awaited promise chains that contain no timers.
async function flush(): Promise<void> {
await vi.advanceTimersByTimeAsync(0);
}
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
const TOKEN_KEY = 'life-towers.token.v4';
const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`;
const PENDING_CACHE_KEY = `life-towers.cache-pending.v4.${FIXED_UUID}`;
const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`;
// ── Helpers ──────────────────────────────────────────────────────────────────
function configure(api: MockApi): StoreService {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
{ provide: ApiService, useValue: api },
{
provide: AnalyticsService,
useValue: {
init: vi.fn(),
trackStart: vi.fn(),
trackExampleLoaded: vi.fn(),
trackPageCreated: vi.fn(),
trackTowerCreated: vi.fn(),
trackBlockCreated: vi.fn(),
trackBlockCompleted: vi.fn(),
},
},
StoreService,
],
});
return TestBed.inject(StoreService);
}
function mkPage(name: string): TreeDto['pages'][number] {
return {
id: FIXED_UUID,
name,
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [],
};
}
// HttpErrorResponse-compatible shape for rejected promises.
function httpError(status: number, headers: Record<string, string> = {}) {
const headersObj = {
get: (n: string) =>
headers[n] ?? headers[n.toLowerCase()] ?? headers[n.toUpperCase()] ?? null,
};
const err: { status: number; headers: typeof headersObj } = { status, headers: headersObj };
return err;
}
describe('StoreService', () => {
beforeEach(() => {
localStorageStub.clear();
storageThrowsOnSet = false;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ── Init ───────────────────────────────────────────────────────────────────
it('mints + persists a UUIDv4 token and calls register on first launch', async () => {
const api = makeMockApi();
const store = configure(api);
await withFixedUuid(FIXED_UUID, async () => {
await store.init();
});
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
expect(store.loading()).toBe(false);
});
it('reuses an existing stored token without re-registering', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
expect(api.register).not.toHaveBeenCalled();
expect(store.token()).toBe(FIXED_UUID);
});
it('canonicalizes a stored uppercase token', async () => {
storage[TOKEN_KEY] = FIXED_UUID.toUpperCase();
const api = makeMockApi();
const store = configure(api);
await store.init();
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
expect(store.token()).toBe(FIXED_UUID);
});
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
storage[TOKEN_KEY] = 'not-a-uuid';
const api = makeMockApi();
const store = configure(api);
await withFixedUuid(FIXED_UUID, async () => {
await store.init();
});
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
});
it('on 401 from getData, re-registers the SAME token (idempotent) and retries', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData
.mockRejectedValueOnce(httpError(401))
.mockResolvedValueOnce({ pages: [mkPage('after-401')] });
const store = configure(api);
await store.init();
expect(api.register).toHaveBeenCalledTimes(1);
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
expect(api.getData).toHaveBeenCalledTimes(2);
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('after-401');
});
it('falls back to cache on non-401 network error', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('cached')] } satisfies TreeDto);
const api = makeMockApi();
api.getData.mockRejectedValue(httpError(0));
const store = configure(api);
await store.init();
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('cached');
});
it('keeps local cache when server returns empty but cache has data', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('offline-edit')] } satisfies TreeDto);
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [] });
const store = configure(api);
await store.init();
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('offline-edit');
});
it('init() doesn\'t crash if localStorage.setItem throws (private mode)', async () => {
storageThrowsOnSet = true;
const api = makeMockApi();
const store = configure(api);
await withFixedUuid(FIXED_UUID, async () => {
await expect(store.init()).resolves.toBeUndefined();
});
expect(store.loading()).toBe(false);
});
it('init() is single-flight — concurrent calls return the same promise', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
let resolveGet: ((v: TreeDto) => void) | null = null;
api.getData.mockReturnValue(new Promise<TreeDto>((res) => (resolveGet = res)));
const store = configure(api);
const p1 = store.init();
const p2 = store.init();
resolveGet!({ pages: [] });
await Promise.all([p1, p2]);
expect(api.getData).toHaveBeenCalledTimes(1);
});
it('moves a pending block to the end when it becomes done', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({
pages: [
{
id: 'page-1',
name: 'Page',
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [
{
id: 'tower-1',
name: 'Tower',
base_color: { h: 0.5, s: 0.5, l: 0.5 },
blocks: [
{
id: 'old-pending',
tag: 'a',
description: 'Created first, completed last',
is_done: false,
difficulty: 1,
created_at: 100,
},
{
id: 'newer-pending',
tag: 'b',
description: 'Still pending',
is_done: false,
difficulty: 1,
created_at: 300,
},
{
id: 'existing-done',
tag: 'c',
description: 'Already done',
is_done: true,
difficulty: 1,
created_at: 200,
},
],
},
],
},
],
} satisfies TreeDto);
const store = configure(api);
await store.init();
store.updateBlock('page-1', 'tower-1', 'old-pending', { is_done: true });
const blocks = store.pages()[0].towers[0].blocks;
expect(blocks.map((b) => b.id)).toEqual([
'newer-pending',
'existing-done',
'old-pending',
]);
expect(blocks[2].created_at).toBe(100);
});
it('loads welcome example data with a stack of completed squares per tower', () => {
const api = makeMockApi();
const store = configure(api);
const pageId = store.loadExample();
const [page] = store.pages();
expect(page.id).toBe(pageId);
expect(page.name).toBe('Hobbies');
expect(page.towers).toHaveLength(3);
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0);
expect(doneSquares).toBeGreaterThanOrEqual(90);
expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
.toBeGreaterThan(1);
for (const tower of page.towers) {
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
const doneSquareCount = tower.blocks
.filter((b) => b.is_done)
.reduce((sum, block) => sum + block.difficulty, 0);
expect(doneSquareCount).toBeGreaterThanOrEqual(30);
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
}
store.ngOnDestroy();
});
// ── Debounced save ─────────────────────────────────────────────────────────
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
store.addPage('A');
store.addPage('B');
store.addPage('C');
expect(api.putData).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(1);
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
const [, tree] = api.putData.mock.calls[0];
expect((tree as TreeDto).pages).toHaveLength(3);
});
it('mutation while a save is in-flight triggers a follow-up save', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
let resolveFirstPut: (() => void) | null = null;
api.putData
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
.mockResolvedValueOnce(undefined);
const store = configure(api);
await store.init();
store.addPage('first');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(1);
// Mutate while first PUT is still hanging.
store.addPage('second');
// Finish the first save → follow-up should be scheduled.
resolveFirstPut!();
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(2);
const lastTree = api.putData.mock.calls[1][1] as TreeDto;
expect(lastTree.pages).toHaveLength(2);
});
it('does not let an older in-flight save clear a newer pending cache entry', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
let resolveFirstPut: (() => void) | null = null;
api.putData
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
.mockResolvedValueOnce(undefined);
const store = configure(api);
await store.init();
store.addPage('first');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(1);
store.addPage('second');
expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name))
.toEqual(['first', 'second']);
expect(storage[PENDING_CACHE_KEY]).toBe('1');
resolveFirstPut!();
await vi.advanceTimersByTimeAsync(0);
expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name))
.toEqual(['first', 'second']);
expect(storage[PENDING_CACHE_KEY]).toBe('1');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(2);
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
});
it('keeps a pending local mutation across reload before the debounce saves', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const serverPage = mkPage('server');
api.getData.mockResolvedValue({ pages: [serverPage] });
const store = configure(api);
await store.init();
store.updatePage(FIXED_UUID, { keep_tasks_open: true });
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
expect(storage[PENDING_CACHE_KEY]).toBe('1');
store.ngOnDestroy();
const reloadedApi = makeMockApi();
reloadedApi.getData.mockResolvedValue({ pages: [serverPage] });
const reloadedStore = configure(reloadedApi);
await reloadedStore.init();
expect(reloadedStore.pages()[0].keep_tasks_open).toBe(true);
await vi.advanceTimersByTimeAsync(750);
expect(reloadedApi.putData).toHaveBeenCalledTimes(1);
const [, tree] = reloadedApi.putData.mock.calls[0];
expect((tree as TreeDto).pages[0].keep_tasks_open).toBe(true);
});
it('does not let a stale in-flight save clear a newer pending settings cache', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const serverPage = mkPage('server');
let resolveFirstPut: (() => void) | null = null;
api.getData.mockResolvedValue({ pages: [serverPage] });
api.putData
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
.mockResolvedValueOnce(undefined);
const store = configure(api);
await store.init();
store.updatePage(FIXED_UUID, { name: 'renamed' });
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(1);
expect((api.putData.mock.calls[0][1] as TreeDto).pages[0].keep_tasks_open).toBe(false);
store.updatePage(FIXED_UUID, { keep_tasks_open: true });
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
expect(storage[PENDING_CACHE_KEY]).toBe('1');
resolveFirstPut!();
await vi.advanceTimersByTimeAsync(0);
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
expect(storage[PENDING_CACHE_KEY]).toBe('1');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(2);
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].keep_tasks_open).toBe(true);
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
});
// ── Error handling ────────────────────────────────────────────────────────
it('marks status "too-large" on 413 and does NOT retry', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.putData.mockRejectedValue(httpError(413));
const store = configure(api);
await store.init();
store.addPage('big');
await vi.advanceTimersByTimeAsync(750);
await vi.runAllTimersAsync();
expect(store.saveStatus()).toBe('too-large');
expect(api.putData).toHaveBeenCalledTimes(1);
});
it('marks status "invalid" on 400 and does NOT retry', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.putData.mockRejectedValue(httpError(400));
const store = configure(api);
await store.init();
store.addPage('bad');
await vi.advanceTimersByTimeAsync(750);
await vi.runAllTimersAsync();
expect(store.saveStatus()).toBe('invalid');
expect(api.putData).toHaveBeenCalledTimes(1);
});
it('honors Retry-After on 429 (uses it as the next delay)', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.putData
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '2' }))
.mockResolvedValueOnce(undefined);
const store = configure(api);
await store.init();
store.addPage('x');
await vi.advanceTimersByTimeAsync(750);
// First attempt failed with 429 — we're now in the Retry-After window.
expect(store.saveStatus()).toBe('rate-limited');
expect(api.putData).toHaveBeenCalledTimes(1);
// Advance 1.9s → still waiting (Retry-After was 2s).
await vi.advanceTimersByTimeAsync(1900);
expect(api.putData).toHaveBeenCalledTimes(1);
// The full 2s → retry fires and succeeds.
await vi.advanceTimersByTimeAsync(100);
await vi.runAllTimersAsync();
expect(api.putData).toHaveBeenCalledTimes(2);
expect(store.saveStatus()).toBe('saved');
});
it('re-registers and retries on 401 mid-save', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.putData
.mockRejectedValueOnce(httpError(401))
.mockResolvedValueOnce(undefined);
const store = configure(api);
await store.init();
store.addPage('x');
await vi.advanceTimersByTimeAsync(750);
await vi.runAllTimersAsync();
expect(api.register).toHaveBeenCalledTimes(1);
expect(api.putData).toHaveBeenCalledTimes(2);
expect(store.saveStatus()).toBe('saved');
});
// ── switchToken ───────────────────────────────────────────────────────────
it('switchToken cancels pending writes and does not flush old tree to new account', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
// Mutate, then switch BEFORE the debounce fires.
store.addPage('old-account');
api.getData.mockResolvedValue({ pages: [] });
store.switchToken(OTHER_TOKEN);
// Run all timers — the OLD debounce must have been cancelled,
// so no PUT should have happened.
await vi.advanceTimersByTimeAsync(2000);
expect(api.putData).not.toHaveBeenCalled();
expect(store.token()).toBe(OTHER_TOKEN);
});
it('switchToken invalidates an init already in flight', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
let resolveFirstGet: ((v: TreeDto) => void) | null = null;
api.getData
.mockReturnValueOnce(new Promise<TreeDto>((res) => (resolveFirstGet = res)))
.mockResolvedValueOnce({ pages: [mkPage('new-account')] });
const store = configure(api);
const firstInit = store.init();
store.switchToken(OTHER_TOKEN);
resolveFirstGet!({ pages: [mkPage('old-account')] });
await firstInit;
expect(api.getData).toHaveBeenCalledWith(OTHER_TOKEN);
expect(store.token()).toBe(OTHER_TOKEN);
expect(store.pages()[0].name).toBe('new-account');
});
it('switchToken cancels a pending retry without wedging future saves', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.putData
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '30' }))
.mockResolvedValue(undefined);
const store = configure(api);
await store.init();
store.addPage('old-account');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(1);
api.getData.mockResolvedValue({ pages: [] });
store.switchToken(OTHER_TOKEN);
await vi.advanceTimersByTimeAsync(0);
store.addPage('new-account');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenCalledTimes(2);
expect(api.putData.mock.calls[1][0]).toBe(OTHER_TOKEN);
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].name).toBe('new-account');
});
it('does not load another account cache after switching tokens', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('old-cache')] } satisfies TreeDto);
const api = makeMockApi();
const store = configure(api);
await store.init();
api.getData.mockRejectedValue(httpError(0));
store.switchToken(OTHER_TOKEN);
await vi.advanceTimersByTimeAsync(0);
expect(storage[OTHER_CACHE_KEY]).toBeUndefined();
expect(store.pages()).toHaveLength(0);
});
it('switchToken rejects a non-UUIDv4 input', () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
store.switchToken('not-a-uuid');
expect(store.token()).toBe(''); // never initialized
});
it('switchToken canonicalizes uppercase UUID input', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
store.switchToken(OTHER_TOKEN.toUpperCase());
await vi.advanceTimersByTimeAsync(0);
expect(store.token()).toBe(OTHER_TOKEN);
expect(storage[TOKEN_KEY]).toBe(OTHER_TOKEN);
});
// ── Cross-tab sync ────────────────────────────────────────────────────────
it('adopts a fresh cache written by another tab via the storage event', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
expect(store.pages()).toHaveLength(0);
const otherTabTree = { pages: [mkPage('from-other-tab')] };
window.dispatchEvent(
new StorageEvent('storage', {
key: CACHE_KEY,
newValue: JSON.stringify(otherTabTree),
}),
);
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('from-other-tab');
});
// ── Multi-client sync: revision + compare-and-swap + SSE ────────────────────
it('sends the server revision as the PUT base and adopts the returned one', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [], revision: 3 });
api.putData.mockResolvedValue(4);
const store = configure(api);
await store.init();
store.addPage('x');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenLastCalledWith(FIXED_UUID, expect.anything(), 3);
// The 4 returned by the first PUT becomes the base of the next one.
api.putData.mockResolvedValue(5);
store.addPage('y');
await vi.advanceTimersByTimeAsync(750);
expect(api.putData).toHaveBeenLastCalledWith(FIXED_UUID, expect.anything(), 4);
});
it('opens an event stream for the token on init', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const store = configure(api);
await store.init();
expect(api.openEventStream).toHaveBeenCalledTimes(1);
expect(api.openEventStream.mock.calls[0][0]).toBe(FIXED_UUID);
});
it('refetches and adopts the server tree on a newer-revision SSE event when clean', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValueOnce({ pages: [], revision: 1 });
const store = configure(api);
await store.init();
api.getData.mockResolvedValueOnce({
pages: [mkPage('from-other-device')],
revision: 5,
});
lastStreamHandlers(api).onRevision(5);
await flush();
expect(api.getData).toHaveBeenCalledTimes(2);
expect(store.pages()).toHaveLength(1);
expect(store.pages()[0].name).toBe('from-other-device');
});
it('ignores an SSE event that is not newer than our revision (our own echo)', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [], revision: 3 });
const store = configure(api);
await store.init();
api.getData.mockClear();
lastStreamHandlers(api).onRevision(3);
await flush();
expect(api.getData).not.toHaveBeenCalled();
});
it('defers an SSE refetch while there are pending local edits (CAS handles it)', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({ pages: [], revision: 1 });
const store = configure(api);
await store.init();
store.addPage('local'); // now dirty: debounce pending + pending cache
api.getData.mockClear();
lastStreamHandlers(api).onRevision(9);
await flush();
expect(api.getData).not.toHaveBeenCalled();
});
it('on 409 adopts the server tree (server wins) and discards the local edit', async () => {
const PAGE_A = 'aaaaaaaa-1111-4111-8111-111111111111';
const PAGE_B = 'bbbbbbbb-2222-4222-8222-222222222222';
const pageWith = (id: string, name: string): TreeDto['pages'][number] => ({
id,
name,
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [],
});
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValueOnce({ pages: [pageWith(PAGE_A, 'A')], revision: 1 });
// The PUT is rejected as stale; under server-wins we do NOT retry it.
api.putData.mockRejectedValueOnce(httpError(409));
// The 409 refetch returns a tree where another device added page B.
api.getData.mockResolvedValueOnce({
pages: [pageWith(PAGE_A, 'A'), pageWith(PAGE_B, 'from-other-device')],
revision: 5,
});
const store = configure(api);
await store.init();
// Local edit to page A, then save.
store.updatePage(PAGE_A, { name: 'A-edited-locally' });
await vi.advanceTimersByTimeAsync(750);
await vi.runAllTimersAsync();
const byId = new Map(store.pages().map((p) => [p.id, p.name]));
// Server wins: the local edit to A is discarded and the remote tree adopted.
expect(byId.get(PAGE_A)).toBe('A');
expect(byId.get(PAGE_B)).toBe('from-other-device');
// The stale PUT fired once and was not retried; the refetched revision (5)
// is now our CAS base.
expect(api.putData).toHaveBeenCalledTimes(1);
expect(store.saveStatus()).toBe('saved');
});
it('closes the event stream on destroy', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const close = vi.fn();
api.openEventStream.mockReturnValue(close);
const store = configure(api);
await store.init();
store.ngOnDestroy();
expect(close).toHaveBeenCalledTimes(1);
});
it('closes the old stream and opens a new one on switchToken', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
const closeOld = vi.fn();
api.openEventStream.mockReturnValueOnce(closeOld).mockReturnValue(() => {});
const store = configure(api);
await store.init();
store.switchToken(OTHER_TOKEN);
await vi.advanceTimersByTimeAsync(0);
expect(closeOld).toHaveBeenCalledTimes(1);
expect(api.openEventStream.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(api.openEventStream.mock.calls[api.openEventStream.mock.calls.length - 1][0]).toBe(
OTHER_TOKEN,
);
});
});

View file

@ -0,0 +1,32 @@
import { HslColor } from '../models';
import { hash } from './hash';
/**
* Lighten (or darken) an HslColor by a number of percentage points.
* `byPercentPoints` is in raw percent (e.g. 25 means +25 lightness points out of 100).
* Clamps the result to [0, 1].
*/
export function lighten(byPercentPoints: number, c: HslColor): HslColor {
let newL = c.l * 100 + byPercentPoints;
if (newL > 100) newL = 100;
else if (newL < 0) newL = 0;
return { h: c.h, s: c.s, l: newL / 100 };
}
/**
* Converts an HslColor (all values 01) to a CSS hsl() string.
* Note: the new app stores h/s/l normalised to [0, 1].
*/
export function toCss(c: HslColor): string {
return `hsl(${c.h * 360}, ${c.s * 100}%, ${c.l * 100}%)`;
}
/**
* Derive a per-tag color by offsetting the tower's base lightness deterministically.
* Uses FNV-1a hash offset in [25, +25) lightness percentage points.
* All blocks in the same tower vary in lightness only, preserving the hue and saturation.
*/
export function getColorOfTag(tag: string, base: HslColor): string {
const offset = (hash(tag) - 0.5) * 50; // → [25, +25) percentage points
return toCss(lighten(offset, base));
}

Some files were not shown because too many files have changed in this diff Show more