Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 006ae81c3b | |||
| bec981f601 | |||
| e94ea62ee1 | |||
| 18f6d3a173 | |||
| b7f460bb68 | |||
| dc10b3323a | |||
| f6696a6553 | |||
| 40e2c478fb | |||
| 4fd9e6f6bc | |||
| c2c2598eab | |||
| 8390ece334 | |||
| 108cfb9a19 | |||
| bcca7f4f2e | |||
| e3dcf75eb5 | |||
| 9b8bb96001 | |||
| 85d565ba7b | |||
| d50aa53a73 | |||
| af4216f383 | |||
| ce5f8995f7 | |||
| d9724a462d | |||
| 4156d1d469 | |||
| 757cae5dcf | |||
| 5a364ce638 | |||
| afce46ccf8 | |||
| f74ee43cb4 | |||
| 3ad2766f82 | |||
| 706fe745d3 | |||
|
|
1598260ce3 | ||
|
|
19aad2b2af | ||
|
|
674f07f5f1 | ||
|
|
97e94ec154 | ||
|
|
32704c5561 | ||
|
|
fc0d64fce7 | ||
|
|
3a1accaae1 |
35
.dockerignore
Normal 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
|
||||||
190
.forgejo/workflows/ci.yml
Normal 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
|
||||||
75
.forgejo/workflows/docker.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
curl -fsSL "https://download.docker.com/linux/static/stable/${ARCH}/docker-27.5.1.tgz" \
|
||||||
|
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: |
|
||||||
|
network=host
|
||||||
|
|
||||||
|
- name: Resolve registry vars
|
||||||
|
id: registry
|
||||||
|
env:
|
||||||
|
CONTAINER_REGISTRY_HOST: ${{ vars.CONTAINER_REGISTRY_HOST }}
|
||||||
|
run: |
|
||||||
|
host="${CONTAINER_REGISTRY_HOST:-${{ gitea.server_url }}}"
|
||||||
|
host="${host#https://}"
|
||||||
|
host="${host#http://}"
|
||||||
|
host="${host%/}"
|
||||||
|
if [ "$host" = "forgejo:3000" ]; then
|
||||||
|
host="127.0.0.1:13000"
|
||||||
|
fi
|
||||||
|
repo=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
owner="${repo%%/*}"
|
||||||
|
{
|
||||||
|
echo "host=${host}"
|
||||||
|
echo "owner=${owner}"
|
||||||
|
echo "image=${host}/${repo}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to Forgejo Container Registry
|
||||||
|
uses: https://github.com/docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.registry.outputs.host }}
|
||||||
|
username: ${{ steps.registry.outputs.owner }}
|
||||||
|
password: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: https://github.com/docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ steps.registry.outputs.image }}
|
||||||
|
tags: |
|
||||||
|
type=sha,format=short
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: https://github.com/docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache,mode=max
|
||||||
47
.gitignore
vendored
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"printWidth": 120,
|
|
||||||
"singleQuote": true,
|
|
||||||
"useTabs": false,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": true,
|
|
||||||
"bracketSpacing": true
|
|
||||||
}
|
|
||||||
291
CLAUDE.md
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# Life Towers — agent notes
|
||||||
|
|
||||||
|
This file captures the load-bearing things to know about this codebase. Read it before making non-trivial changes.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A personal-productivity TODO app where each user has multiple **pages**, each page holds several **towers** (vertical task columns), and each tower has **blocks** (atomic tasks). Pending blocks live in a **tasks accordion** at the top of the tower; completed ones fall into the tower as colored squares (the "falling animation" is the defining visual). Date-range slider filters which done blocks are visible.
|
||||||
|
|
||||||
|
This is a port/modernisation of a legacy Angular 7 app. **Design parity with the legacy is a hard requirement** — the user cares deeply about the original aesthetic. The legacy source lives at `_legacy_reference/` (gitignored) and is the source of truth for any visual question. A detailed style guide is at `docs/DESIGN.md`.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ # FastAPI + SQLite (WAL)
|
||||||
|
src/life_towers/
|
||||||
|
main.py # ASGI app, lifespan, static mount + SPA fallback
|
||||||
|
api.py # Routes (/api/v1/{health,register,data})
|
||||||
|
auth.py # Bearer UUIDv4 → user_id
|
||||||
|
db.py # sqlite3 factory + migration runner
|
||||||
|
limits.py # slowapi limiter + payload-size middleware
|
||||||
|
models.py # pydantic v2 schemas
|
||||||
|
logging.py # structlog JSON
|
||||||
|
migrations/ # 001_initial.sql consolidated schema
|
||||||
|
tests/test_api.py # pytest + httpx AsyncClient
|
||||||
|
pyproject.toml # uv-managed
|
||||||
|
frontend/ # Angular 21+ standalone, signals, zoneless
|
||||||
|
src/app/
|
||||||
|
components/
|
||||||
|
pages/ # Top-level: page selector + Settings button
|
||||||
|
page/ # Tower row + slider + trash zone + confirm-delete
|
||||||
|
tower/ # White tower card: tasks accordion + add-block + falling stack + name input
|
||||||
|
block/ # Colored square (1/6 tower width)
|
||||||
|
tasks/ # Pending-blocks accordion with tickbox
|
||||||
|
welcome/ # Zero-state intro modal + "Try an example"
|
||||||
|
modal/ # Generic backdrop shell + sub-modals (block-edit carousel, settings, tower-settings)
|
||||||
|
shared/ # select-add, toggle, double-slider, color-picker, icon
|
||||||
|
services/
|
||||||
|
api.service.ts # HttpClient wrapper, exact API contract
|
||||||
|
store.service.ts # signal-based store, debounced PUT, retry, loadExample
|
||||||
|
modal-state.service.ts # global open-modal counter (drag locking)
|
||||||
|
models/index.ts # Page / Tower / Block / HslColor TS interfaces
|
||||||
|
utils/{color,hash}.ts
|
||||||
|
library/ # Legacy SCSS dropped in verbatim — DO NOT REFACTOR
|
||||||
|
public/assets/ # SVG icons (arrow, pen, plus-sign, trash, x-sign)
|
||||||
|
src/assets/fonts/ # Self-hosted woff2 (Open Sans Condensed, Raleway, …)
|
||||||
|
e2e/ # Playwright (smoke + visuals)
|
||||||
|
docs/
|
||||||
|
api-spec.md # Single source of truth for the HTTP API contract
|
||||||
|
DESIGN.md # Legacy visual spec (verbatim SCSS quotes)
|
||||||
|
_legacy_reference/ # Original Angular 7 source — read-only, gitignored
|
||||||
|
Dockerfile # Multi-stage: node:22-alpine build → python:3.13-slim runtime
|
||||||
|
docker-compose.yml # Production single-container
|
||||||
|
docker-compose.dev.yml # Ephemeral volume for Playwright runs
|
||||||
|
.forgejo/workflows/ # CI + deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech stack quickrefs
|
||||||
|
|
||||||
|
- **Frontend**: Angular 21+, **standalone components** (no NgModule), `signal()` / `computed()` / `effect()` / `linkedSignal()`, **zoneless change detection** (`provideZonelessChangeDetection`), `@if`/`@for` control flow, **OnPush** everywhere, esbuild builder (`@angular/build:application`), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap)
|
||||||
|
- **Backend**: FastAPI, pydantic v2, slowapi (rate limiting), structlog, **sqlite3 with WAL + foreign_keys ON** every connection, uv-managed deps
|
||||||
|
- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*`
|
||||||
|
- **Storage**: SQLite at `/data/life-towers.db` on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside `BEGIN IMMEDIATE`)
|
||||||
|
|
||||||
|
## Build / dev / test / deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full local stack (ephemeral data)
|
||||||
|
docker compose -f docker-compose.dev.yml up --build -d # http://localhost:8000
|
||||||
|
docker compose -f docker-compose.dev.yml down -v # teardown
|
||||||
|
|
||||||
|
# Frontend dev (with backend proxied via proxy.conf.json)
|
||||||
|
cd frontend && npm start # ng serve on :4200, /api → :8000
|
||||||
|
cd frontend && npm run build # production bundle to dist/frontend/browser/
|
||||||
|
cd frontend && npm test # vitest (--run already in the script)
|
||||||
|
cd frontend && npm run test:e2e # Playwright
|
||||||
|
|
||||||
|
# Backend dev
|
||||||
|
cd backend && uv sync
|
||||||
|
cd backend && uv run pytest -v
|
||||||
|
cd backend && uv run uvicorn life_towers.main:app --reload # :8000
|
||||||
|
|
||||||
|
# E2E in the sandbox: host-to-container port forwarding is broken here.
|
||||||
|
# Run Playwright INSIDE a container on the docker network instead:
|
||||||
|
docker run --rm \
|
||||||
|
--network life-towers_default \
|
||||||
|
-v "$(pwd)/frontend:/work" -w /work \
|
||||||
|
-e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
|
||||||
|
mcr.microsoft.com/playwright:v1.60.0-noble \
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design system — the legacy is the source of truth
|
||||||
|
|
||||||
|
`docs/DESIGN.md` is the comprehensive spec. Key tokens:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// _legacy_reference/frontend/src/library/common-variables.scss
|
||||||
|
$accent-color: #a2666f; // rose
|
||||||
|
$text-color: #5d576b; // muted purple-grey (also iOS theme-color)
|
||||||
|
$light-color: #ffffff;
|
||||||
|
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%); // 50% alpha — modal backdrop
|
||||||
|
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%); // body background
|
||||||
|
$shadow: 0 0 1.5px 1.5px rgba(0,0,0,0.1), 0 0 3px 2px rgba(0,0,0,0.05);
|
||||||
|
$shadow-border: 0 0 0 0.75px rgba(0,0,0,0.1); // hairline
|
||||||
|
$normal-font: 'Open Sans Condensed', sans-serif;
|
||||||
|
$title-font: 'Raleway', serif;
|
||||||
|
$mobile-width: 520px;
|
||||||
|
```
|
||||||
|
|
||||||
|
Spacing tokens (`library/main.scss`):
|
||||||
|
```scss
|
||||||
|
:root {
|
||||||
|
--large-padding: 30px; // 20px on mobile
|
||||||
|
--medium-padding: 15px;
|
||||||
|
--small-padding: 10px; // 7.5px on mobile
|
||||||
|
--border-radius: 5px; // 3px on mobile
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Font sizes (`library/text.scss`): `--larger/large/medium/small-font-size` = `22/18/16/11` desktop, `20/16/14/10` mobile.
|
||||||
|
|
||||||
|
**Animation timings**:
|
||||||
|
- `$long-animation-time: 200ms` — opacity, hover transforms, modal entry
|
||||||
|
- `$short-animation-time: 100ms` — tighter transitions (red trash-highlight overlay)
|
||||||
|
- **Falling animation**: `transform 1.5s cubic-bezier(0.5, 0, 1, 0)` (gravity ease-in)
|
||||||
|
- **Modal opacity entry/exit**: `300ms`
|
||||||
|
|
||||||
|
## Architectural conventions to follow
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- Every component is **standalone**, **OnPush**, with `signal()`-based local state. No NgModule.
|
||||||
|
- Templates use **`@if` / `@for` / `@switch`** — never `*ngIf`/`*ngFor`.
|
||||||
|
- Inline templates + inline styles in `@Component({ template: \`...\`, styles: \`...\` })` is the norm. Larger components use templateUrl + styleUrl (only `pages.component`, `page.component`).
|
||||||
|
- SCSS inside `styles:` template literal — **`//` comments are fine inside SCSS but watch for backticks**; `// `display: contents`` inside a template literal closes it early. Use `/* */` if you need to mention CSS strings in comments.
|
||||||
|
- Library files (`library/*.scss`) are dropped from the legacy verbatim. Don't refactor them. Components `@import '../../../library/main'` (or similar relative depth).
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
- `StoreService` is the single source of truth (signal-based).
|
||||||
|
- Mutations update the signal immediately (**optimistic**) and call `scheduleSave()` which debounces 750ms and PUTs the full tree.
|
||||||
|
- Failure mode: exponential backoff up to 5 attempts (1s, 2s, 4s, 8s, 16s).
|
||||||
|
- LocalStorage cache key: `life-towers.cache.v4`. Token: `life-towers.token.v4`.
|
||||||
|
- `init()` flow: stored token? Use it. No token? Mint via `uuidV4()` → register → GET data. On 401: re-register the SAME token (idempotent server-side), never silently mint a fresh one (would orphan data).
|
||||||
|
|
||||||
|
### Reactivity caveats with zoneless
|
||||||
|
|
||||||
|
- Plain field mutations in event handlers (`(click)="x = true"`) still trigger CD because Angular's event manager marks the view dirty — even with zoneless. But any async mutation outside an Angular event (setTimeout, raw addEventListener, MutationObserver) **will not** trigger CD; use signals there.
|
||||||
|
- `effect()` running on `signal()` reads triggers re-runs; wrap writes in `untracked()` to avoid loops.
|
||||||
|
- For input-driven derived state that the user can also override (e.g. `tasks.expanded` seeded from `initiallyOpen` but also clickable), use either `linkedSignal` or `effect()` + a flag (the codebase uses the latter for `tasks.component`).
|
||||||
|
|
||||||
|
### Sync model
|
||||||
|
|
||||||
|
- **Tree-replace**: `PUT /api/v1/data` sends the entire user hierarchy atomically. Backend wraps in `BEGIN IMMEDIATE`, deletes existing pages (cascading to towers + blocks via FK), inserts new rows. `position` columns track ordering.
|
||||||
|
- **No granular endpoints** for individual entity CRUD. Keep it tree-replace.
|
||||||
|
- Spec is in `docs/api-spec.md`. Backend pydantic models match it. Frontend `models/index.ts` matches it. Field names are **snake_case** on both sides.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- All endpoints are inside `APIRouter(prefix="/api/v1")`. Spec drives behavior — if you change a limit, update both spec and code.
|
||||||
|
- Migrations: package data under `src/life_towers/migrations/`, loaded via `importlib.resources.files("life_towers").joinpath("migrations")`. The runner tracks applied state in a `schema_migrations(filename TEXT PRIMARY KEY, applied_at INTEGER)` table.
|
||||||
|
- All sqlite connections must do `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000`. The `get_connection()` factory does this.
|
||||||
|
- Errors → JSON `{"error": code, "detail": str}` via a single `HTTPException` handler in `main.py`. Stack traces never leak (logged server-side).
|
||||||
|
- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token.
|
||||||
|
|
||||||
|
## Visual + interaction details that bit me
|
||||||
|
|
||||||
|
### Falling animation (tower.component)
|
||||||
|
|
||||||
|
The `.block-container` has `transform: scaleY(-1)` so blocks visually fall from the TOP into the bottom of the tower. Each block default-positions at `translateY(500%)` via a `*` rule; the inline `[style.transform]="b._transform"` binding overrides per-block.
|
||||||
|
|
||||||
|
When exactly **one** new done block is added, the `reconcile()` method:
|
||||||
|
1. Sets the new block's `_transform: 'translateY(500%)'` and `_opacity: '0'` (off-screen)
|
||||||
|
2. Calls `requestAnimationFrame` → `requestAnimationFrame` to let the browser paint the initial state
|
||||||
|
3. Sets `_anim: 'descend'`, `_transform: 'translateY(0)'`, `_opacity: '1'` — the CSS transition fires
|
||||||
|
|
||||||
|
**Critical**: `grewByOne` detection is position-independent (set-difference). When a tickbox flips a pending block to done, the new entry inserts at its original `tower.blocks` index, not appended. Use the new ID, not `styled[length-1]`.
|
||||||
|
|
||||||
|
The **date range** slider asymmetry: blocks below `range.from` are removed from `visibleBlocks` entirely (instant shuffle, no gap), blocks above `range.to` get `_anim: 'ascend'` and stay in the list flying up. `prevDoneIds` tracks the full `allDone` array — not the filtered styled list — so range expansions don't mis-fire as "new block".
|
||||||
|
|
||||||
|
### Block-edit carousel (modal/block-edit.component)
|
||||||
|
|
||||||
|
- Lives at `position: fixed; z-index: 10001` to escape the modal dialog wrapper and cover the viewport
|
||||||
|
- Two placeholder cards flank the real cards so the active card can fully center via `scroll-snap-align: center`
|
||||||
|
- `.mask` overlay on non-active cards has three tiers: `active opacity 0`, `near-active 0.55`, default `1`. Card opacity also tiered (1 / 0.85 / 0.6) mimicking the legacy `1.33*(1-t/2)` curve
|
||||||
|
- Backdrop click (anywhere not a non-placeholder card) closes the modal
|
||||||
|
- Delete on an existing card **does not** close the modal — it stays open and the card re-renders out of the list
|
||||||
|
- Auto-save on tag/toggle change; description deferred to blur
|
||||||
|
|
||||||
|
### Select-add (shared/select-add)
|
||||||
|
|
||||||
|
- Has a `.top` chip and a `.bottom` slide-down panel. `:has(.bottom.open) .top, .background { border-radius: var(--border-radius) var(--border-radius) 0 0 }` squares the bottom corners when open so the chip and panel read as one card
|
||||||
|
- Shadow seam between chip + panel solved with `clip-path: inset(...)`: `.background.active` clips bottom (`inset(-6px -6px 0 -6px)`), `.bottom.open` clips top (`inset(0 -6px -6px -6px)`). 6px > the total $shadow spread (5px) so neither edge bleeds across the seam
|
||||||
|
- Closing animation requires a two-transition setup: default state has `transition: ... visibility 0s $long-animation-time` (visibility delays on close), `.open` overrides with `visibility 0s 0s` (instant on open)
|
||||||
|
|
||||||
|
### Modal shell (modal/modal.component)
|
||||||
|
|
||||||
|
- `:host { display: contents }` — critical. Without this, the `lt-modal` host element takes a flex slot in `pages.component`'s `inner-spacing` layout, pushing the Settings button up when the modal mounts
|
||||||
|
- `section.modal` is `position: fixed; z-index: 10000` with `transition: opacity 300ms`. The component flips `active = true` in `ngAfterViewInit` via `setTimeout(0)` so the opacity 0 → 1 transition runs
|
||||||
|
- `ModalStateService.open()` / `.close()` are called in `AfterViewInit`/`OnDestroy`. `page.component` reads `modalState.anyOpen` and binds it to every tower's `[cdkDragDisabled]` so users can't drag towers behind an open modal
|
||||||
|
|
||||||
|
### Carousel card date format
|
||||||
|
|
||||||
|
```ts
|
||||||
|
formatDate(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}); // "May 28, 2026, 14:32"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Double-slider relative-time labels
|
||||||
|
|
||||||
|
`page.component.ts` formats with `Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })`. Buckets: `<45s` second, `<45min` minute, `<22h` hour, `<26d` day, `<320d` month, else year. Labels are **deduped** by string (multiple distinct timestamps that round to the same "5 hr ago" appear once).
|
||||||
|
|
||||||
|
When `values.length` grows (new block added), the slider snaps the **higher** of `oneValue`/`otherValue` to `MAX - 1` so the newest entry is always visible; the lower thumb (the user's left edge) stays put.
|
||||||
|
|
||||||
|
### Color picker (shared/color-picker)
|
||||||
|
|
||||||
|
- Row of 12 preset color swatches + a rainbow hue slider + a big preview swatch
|
||||||
|
- Saturation and lightness are FIXED at 0.7 / 0.55. Only hue varies
|
||||||
|
- Preset hues `[0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355]` — skips the green/yellow zone (60°–180°) which muddies with the rose accent palette
|
||||||
|
|
||||||
|
### Tickbox (tasks/tasks.component)
|
||||||
|
|
||||||
|
- `✓` glyph is hidden at rest (`opacity: 0`) and only revealed on interaction: `0.85` (hover/focus-visible), `1` (active). It fades via the `opacity` transition
|
||||||
|
- The tickbox is a `<button>`, so the global animated-underline bar from `forms.scss` (`button:after { content:''; height: 2px; width: 0→100% on hover; background-color: $text-color }`) applies to it. `all: unset` strips the button's own styling but does NOT reach the pseudo-element — so `.tickbox::after` MUST re-assert `width: 100%; height: 100%; background: none`, otherwise on hover a dark `$text-color` bar paints across the top AND the box collapses to 2px (pinned at `top:0`), which centers the glyph near the top
|
||||||
|
- `font: bold 18px/1 $normal-font` is re-asserted on `::after` because `all: unset` drops the font to serif (Times New Roman)
|
||||||
|
- `transform: translateY(1px)` nudges the `✓` to optical centre (it sits a touch high in its em-box); `:active` must re-state the translateY or the glyph jumps when pressed
|
||||||
|
- `.all-task` is `overflow: hidden` (NOT a scroller) and animates to `#all.scrollHeight`; tall lists scroll in the outer `.container` (`overflow-y: auto; max-height: 30vh`). Making `.all-task` itself `overflow-y: auto` pops a scrollbar the moment the tickbox `scale(1.05)`s on hover (transforms widen the scrollable-overflow box)
|
||||||
|
|
||||||
|
### Mobile responsive
|
||||||
|
|
||||||
|
- `$mobile-width: 520px` is the single breakpoint
|
||||||
|
- Tower row: on mobile, `width: calc(66vw - var(--medium-padding)) !important` per tower with `overflow-x: auto` + `scroll-snap-type: x mandatory`. About 1.5 columns visible by default
|
||||||
|
- Carousel cards: `width: 85vw`, placeholders `7.5vw`, carousel `padding: 0 7.5vw` → snap-center lines up perfectly
|
||||||
|
- Modal cards (settings/tower-settings/welcome/confirm-delete): `width: 88vw; padding: var(--medium-padding)` on mobile. Confirm-buttons stack vertically with full width
|
||||||
|
- Block hover effect (`gravitate`) gated behind `@media (hover: hover) and (pointer: fine)` so touch devices don't get stuck scale-up
|
||||||
|
- Viewport meta blocks pinch-zoom: `<meta name="viewport" content="..., maximum-scale=1.0, user-scalable=no" />`
|
||||||
|
- `scrollbar-gutter: stable` + `overflow-y: scroll` on `html` so modal opens don't shift content sideways
|
||||||
|
|
||||||
|
### Tower drag-drop
|
||||||
|
|
||||||
|
- CDK drag-drop on `.towers cdkDropList[orientation=horizontal]`, each tower is `cdkDrag`
|
||||||
|
- Trash zone: `<img class="trash">` is OUTSIDE `.towers` (anchored to `page.component :host` at `bottom: 8px; left: 50%`). The legacy structure — moving it inside `.towers` breaks because it becomes a flex item
|
||||||
|
- Trash-highlight: `pointerenter` on trash → direct DOM `document.querySelector('.cdk-drag-preview').classList.add('trash-highlight')`. Matches legacy approach
|
||||||
|
- Drop over trash → opens a confirm modal (no immediate delete)
|
||||||
|
|
||||||
|
### Welcome modal + example data
|
||||||
|
|
||||||
|
- Shows when `!store.loading() && store.pages().length === 0`. Auto-dismisses when `pages().length > 0`
|
||||||
|
- "Try an example" calls `store.loadExample()` which creates a "Hobbies" page with three towers (Reading, Side projects, Exercise) at varied `created_at` ages so the slider has interesting labels
|
||||||
|
|
||||||
|
## Frontend sharp edges
|
||||||
|
|
||||||
|
- **`crypto.randomUUID()` requires a secure context** (HTTPS or localhost). On a plain-HTTP origin behind nginx, it throws and `init()` rejects, leaving `loading = true` forever. Always fall back to `crypto.getRandomValues` (`uuidV4()` helper in `store.service.ts`)
|
||||||
|
- **Angular 17+ deprecated `fileReplacements`** for env-per-build. Don't use `environment.ts` — use relative API paths everywhere + `proxy.conf.json` for ng serve
|
||||||
|
- **Angular 19+ `application` builder copies `public/`, NOT `src/assets/`**. SVG icons must live at `frontend/public/assets/` to be served at `/assets/foo.svg`. Fonts referenced via `url()` in styles.scss go through the CSS asset pipeline and emit to `/media/`
|
||||||
|
- **Backticks inside `styles:` template literal** close the string early — break TS parsing
|
||||||
|
- **Async iframe-like spawning of agents in this sandbox** (host port forwarding) doesn't work — run e2e via Playwright Docker image on the same `life-towers_default` network
|
||||||
|
|
||||||
|
## Backend sharp edges
|
||||||
|
|
||||||
|
- The 256 KiB payload cap is enforced by middleware reading `Content-Length`. Chunked encoding bypasses the check. Defense-in-depth would also stream `request.stream()`
|
||||||
|
- 10 MiB per-user quota is checked against the request body, NOT the existing user's stored total. With the 256 KiB request cap, the 10 MiB check is effectively unreachable. Documented spec gap
|
||||||
|
- The migrations directory was moved to `src/life_towers/migrations/` (inside the package) to ship as `importlib.resources` data — don't recreate `backend/migrations/`
|
||||||
|
|
||||||
|
## Visual e2e
|
||||||
|
|
||||||
|
- `frontend/e2e/visuals.spec.ts` is the source-of-truth for "what should this look like." It captures ~15 screenshots into `frontend/visuals/` (gitignored)
|
||||||
|
- Add a screenshot every time we land a visual change. Don't merge if it broke the visuals run
|
||||||
|
- Mobile screenshots use a separate test that spawns its own `browser.newContext({ viewport: { width: 390, height: 844 } })` — that's the iPhone 14 Pro viewport
|
||||||
|
|
||||||
|
## Conventions to enforce on changes
|
||||||
|
|
||||||
|
- Never use the legacy `frontend-legacy/` or `backend-legacy/` paths — those folders are GONE. `_legacy_reference/` is the reference, gitignored
|
||||||
|
- Never refactor `library/*.scss` — those files are dropped verbatim from the legacy and downstream components depend on their exports
|
||||||
|
- Never change the API contract in `docs/api-spec.md` without updating both pydantic models, api.py, frontend `models/index.ts`, and the backend tests in `tests/test_api.py`
|
||||||
|
- Never put modal-related elements as direct flex children of a layout container without `:host { display: contents }` on the modal — they'll add invisible flex slots
|
||||||
|
- Always run `npm run build` after a frontend change to catch TS/template errors that don't surface in the IDE
|
||||||
|
- Always run the visuals test after a visual change. Pull the screenshots, look at them, compare to the legacy
|
||||||
|
|
||||||
|
## Where to look next
|
||||||
|
|
||||||
|
- For the API contract: `docs/api-spec.md`
|
||||||
|
- For visual design questions: `docs/DESIGN.md` + `_legacy_reference/frontend/src/library/`
|
||||||
|
- For the sync flow: `frontend/src/app/services/store.service.ts` (the `init()`, `flush()`, `scheduleSave()` chain)
|
||||||
|
- For the falling animation: `frontend/src/app/components/tower/tower.component.ts:reconcile()`
|
||||||
|
- For drag-drop + trash: `frontend/src/app/components/page/page.component.{ts,html,scss}`
|
||||||
|
- For deploy: `Dockerfile`, `docker-compose.yml`, `.forgejo/workflows/`
|
||||||
57
Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Stage 1: SPA build
|
||||||
|
FROM node:22-alpine AS spa-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
# Angular's application builder outputs to dist/frontend/browser/
|
||||||
|
|
||||||
|
# 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}"]
|
||||||
99
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
86
angular.json
|
|
@ -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
|
|
@ -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",
|
||||||
|
]
|
||||||
1
backend/src/life_towers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Life Towers FastAPI backend
|
||||||
251
backend/src/life_towers/api.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
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,
|
||||||
|
RegisterRequest,
|
||||||
|
RegisterResponse,
|
||||||
|
TowerOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/data", status_code=204)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def put_data(
|
||||||
|
request: Request,
|
||||||
|
body: DataIn,
|
||||||
|
user_id: Annotated[str, Depends(get_current_user)],
|
||||||
|
) -> None:
|
||||||
|
# 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())
|
||||||
|
|
||||||
|
with db_connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
# 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last_seen_at
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET last_seen_at = ? WHERE id = ?",
|
||||||
|
(now, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
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
|
||||||
|
|
||||||
|
logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages))
|
||||||
55
backend/src/life_towers/auth.py
Normal 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
|
||||||
95
backend/src/life_towers/db.py
Normal 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)
|
||||||
83
backend/src/life_towers/limits.py
Normal 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
|
||||||
66
backend/src/life_towers/logging.py
Normal 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
|
||||||
227
backend/src/life_towers/main.py
Normal 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()
|
||||||
54
backend/src/life_towers/migrations/001_initial.sql
Normal 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);
|
||||||
146
backend/src/life_towers/models.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""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]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
1
backend/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Life Towers tests
|
||||||
457
backend/tests/test_api.py
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
"""pytest + httpx AsyncClient tests for the Life Towers API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 == 204
|
||||||
|
|
||||||
|
get_resp = await client.get("/api/v1/data", headers=headers)
|
||||||
|
assert get_resp.status_code == 200
|
||||||
|
data = get_resp.json()
|
||||||
|
|
||||||
|
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 == 204
|
||||||
|
|
||||||
|
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 == 204
|
||||||
|
|
||||||
|
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:]}"
|
||||||
831
backend/uv.lock
generated
Normal 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" },
|
||||||
|
]
|
||||||
25
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# 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: .
|
||||||
|
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
|
||||||
28
docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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}
|
||||||
|
# build: . # uncomment for local builds (or use docker-compose.dev.yml)
|
||||||
|
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:-}"
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
life-towers-data:
|
||||||
7
frontend/.browserslistrc
Normal 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
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
frontend/.vscode/extensions.json
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
frontend/e2e/smoke.spec.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
213
frontend/e2e/visuals.spec.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Open the block-edit carousel for the first tower's first task.
|
||||||
|
await page.locator('lt-tasks .header').first().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
|
|
@ -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
|
|
@ -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
43
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/playwright.config.ts
Normal 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
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 746 B |
4
frontend/public/assets/pen.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 528.899 528.899" style="enable-background:new 0 0 528.899 528.899;" xml:space="preserve"><g><g>
|
||||||
|
<path d="M328.883,89.125l107.59,107.589l-272.34,272.34L56.604,361.465L328.883,89.125z M518.113,63.177l-47.981-47.981 c-18.543-18.543-48.653-18.543-67.259,0l-45.961,45.961l107.59,107.59l53.611-53.611 C532.495,100.753,532.495,77.559,518.113,63.177z M0.3,512.69c-1.958,8.812,5.998,16.708,14.811,14.565l119.891-29.069 L27.473,390.597L0.3,512.69z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||||
|
</g></g> </svg>
|
||||||
|
After Width: | Height: | Size: 737 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 816 B |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
13
frontend/public/favicon.svg
Normal 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 |
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
13
frontend/public/logo-mark.svg
Normal 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 |
13
frontend/public/logo-mono.svg
Normal 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
|
|
@ -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 |
59
frontend/public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
frontend/public/og-image.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
20
frontend/src/app/app.config.ts
Normal 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
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/src/app/components/block/block.component.ts
Normal 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()));
|
||||||
|
}
|
||||||
842
frontend/src/app/components/modal/block-edit.component.ts
Normal file
|
|
@ -0,0 +1,842 @@
|
||||||
|
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"
|
||||||
|
[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)="onCardClick(i + 1)"
|
||||||
|
(keydown.space)="$event.preventDefault(); onCardClick(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>
|
||||||
|
</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)="onCardClick(blocks().length + 1)"
|
||||||
|
(keydown.space)="$event.preventDefault(); onCardClick(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)"
|
||||||
|
></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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
height: 32px;
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
lt-select-add,
|
||||||
|
.done-checkbox {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
min-height: 42px;
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
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 from tags input on first run.
|
||||||
|
effect(() => {
|
||||||
|
const t = this.tags();
|
||||||
|
untracked(() => {
|
||||||
|
const cur = this.newValue();
|
||||||
|
if (!cur.tag) {
|
||||||
|
this.newValue.set({
|
||||||
|
...cur,
|
||||||
|
tag: 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 {
|
||||||
|
// Position scroll on the focused card (or the create card if none).
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const blocks = this.blocks();
|
||||||
|
const focusId = this.activeBlockId();
|
||||||
|
const focusIdx = focusId
|
||||||
|
? Math.max(0, blocks.findIndex((b) => b.id === focusId))
|
||||||
|
: blocks.length;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
const left =
|
||||||
|
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||||
|
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
|
||||||
|
this.activeIdx.set(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private adjustPosition(): void {
|
||||||
|
const container = this.container()?.nativeElement;
|
||||||
|
if (!container) return;
|
||||||
|
const center = container.scrollLeft + container.clientWidth / 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 c = child.offsetLeft + child.offsetWidth / 2;
|
||||||
|
const d = Math.abs(c - center);
|
||||||
|
if (d < minDist) {
|
||||||
|
minDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearestIdx !== this.activeIdx()) {
|
||||||
|
this.scrollToChild(nearestIdx, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
frontend/src/app/components/modal/modal.component.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.previousFocus = document.activeElement as HTMLElement;
|
||||||
|
// Track open state so towers can be locked while any modal is mounted.
|
||||||
|
this.modalState.open();
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
318
frontend/src/app/components/modal/settings.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
frontend/src/app/components/modal/tower-settings.component.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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;
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
width: 88vw;
|
||||||
|
max-width: 88vw;
|
||||||
|
padding: var(--medium-padding);
|
||||||
|
}
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: var(--large-padding);
|
||||||
|
padding-top: calc(var(--large-padding) + var(--medium-padding));
|
||||||
|
position: relative;
|
||||||
|
box-shadow: $shadow;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.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 };
|
||||||
|
}
|
||||||
79
frontend/src/app/components/page/page.component.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<section
|
||||||
|
class="towers"
|
||||||
|
cdkDropList
|
||||||
|
cdkDropListOrientation="horizontal"
|
||||||
|
(cdkDropListDropped)="onTowerDropped($event)"
|
||||||
|
>
|
||||||
|
@for (tower of page().towers; track tower.id) {
|
||||||
|
<lt-tower
|
||||||
|
cdkDrag
|
||||||
|
[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>
|
||||||
|
}
|
||||||
183
frontend/src/app/components/page/page.component.scss
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
@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;
|
||||||
|
|
||||||
|
// Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
--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: visible;
|
||||||
|
-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: 100%;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: center;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
frontend/src/app/components/page/page.component.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
HostListener,
|
||||||
|
effect,
|
||||||
|
untracked,
|
||||||
|
} 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);
|
||||||
|
/** 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);
|
||||||
|
this.store.addTower(this.page().id, result.name, result.base_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/src/app/components/pages/pages.component.html
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<div class="select-add-container">
|
||||||
|
<lt-select-add
|
||||||
|
[options]="pageNames()"
|
||||||
|
[selectedIndex]="selectedPageIndex()"
|
||||||
|
placeholder="Add a new page…"
|
||||||
|
(selectionChange)="onSelectPage($event)"
|
||||||
|
(add)="onAddPage($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
@if (selectedPage(); as page) {
|
||||||
|
<lt-page
|
||||||
|
[page]="page"
|
||||||
|
[animateInitialStack]="page.id === animateInitialStackPageId()"
|
||||||
|
(dragHappening)="dragHappening.set($event)"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
102
frontend/src/app/components/pages/pages.component.scss
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
@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(--medium-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
transition: opacity $long-animation-time;
|
||||||
|
|
||||||
|
&.transparent {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
margin-top: var(--medium-padding);
|
||||||
|
font-size: var(--medium-font-size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: center;
|
||||||
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
frontend/src/app/components/pages/pages.component.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (0–45°): coral/red, orange-red, orange, amber — vivid warm tones
|
||||||
|
// – Skipped 60–180° (yellows + greens) — most read as muddy next to the rose UI accent
|
||||||
|
// – Cool cluster (195–260°): sky-cyan, azure, blue, indigo — clean, distinct from rose
|
||||||
|
// – Purple-rose cluster (280–355°): 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: $slider-size;
|
||||||
|
span { margin-top: 10px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/app/components/shared/icon/icon.component.ts
Normal 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');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,462 @@
|
||||||
|
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()"
|
||||||
|
>
|
||||||
|
<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 resolvedItems(); 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
frontend/src/app/components/shared/toggle/toggle.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
279
frontend/src/app/components/tasks/tasks.component.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
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"> </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);
|
||||||
|
|
||||||
|
max-height: 30vh;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
929
frontend/src/app/components/tower/tower.component.ts
Normal file
|
|
@ -0,0 +1,929 @@
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
untracked,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
ElementRef,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Tower, Block } from '../../models';
|
||||||
|
import { BlockComponent } from '../block/block.component';
|
||||||
|
import { TasksComponent } from '../tasks/tasks.component';
|
||||||
|
import { BlockEditComponent, BlockEditSave } from '../modal/block-edit.component';
|
||||||
|
import { ModalComponent } from '../modal/modal.component';
|
||||||
|
import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-settings.component';
|
||||||
|
import { toCss } from '../../utils/color';
|
||||||
|
|
||||||
|
/** Tracks which entry path the block-edit modal was opened from. */
|
||||||
|
export interface EditEntry {
|
||||||
|
filter: 'done' | 'pending';
|
||||||
|
activeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry {
|
||||||
|
return {
|
||||||
|
filter: keepTasksOpen ? 'pending' : 'done',
|
||||||
|
activeId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A done block augmented with per-render animation state. */
|
||||||
|
export interface StyledBlock extends Block {
|
||||||
|
_anim: '' | 'descend' | 'ascend';
|
||||||
|
_transform: string;
|
||||||
|
_opacity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One rendered square. A block draws `difficulty` squares, all sharing the
|
||||||
|
* block's color + animation state. */
|
||||||
|
interface RenderSquare {
|
||||||
|
key: string;
|
||||||
|
block: StyledBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCKS_PER_ROW = 6;
|
||||||
|
|
||||||
|
/** How many squares a block draws (its difficulty, clamped to >= 1). */
|
||||||
|
function squareCount(block: Block): number {
|
||||||
|
const n = Math.floor(block.difficulty ?? 1);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalSquares(blocks: Block[]): number {
|
||||||
|
return blocks.reduce((sum, block) => sum + squareCount(block), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the newest blocks (array tail) whose cumulative square-count (each
|
||||||
|
* block costs `difficulty` squares) fits within `limit`, preserving order. */
|
||||||
|
function fitNewestBySquares(blocks: StyledBlock[], limit: number): StyledBlock[] {
|
||||||
|
const chosen: StyledBlock[] = [];
|
||||||
|
let used = 0;
|
||||||
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||||
|
const cost = squareCount(blocks[i]);
|
||||||
|
if (used + cost > limit) break;
|
||||||
|
used += cost;
|
||||||
|
chosen.unshift(blocks[i]);
|
||||||
|
}
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectVisibleStyledBlocks(
|
||||||
|
styled: StyledBlock[],
|
||||||
|
visibleLimit: number,
|
||||||
|
enteringInRangeId: string | null,
|
||||||
|
prevVisibleIds: ReadonlySet<string> = new Set(),
|
||||||
|
): { visibleStyled: StyledBlock[]; hiddenCount: number } {
|
||||||
|
// `visibleLimit` is a number of SQUARE slots. A block draws `difficulty`
|
||||||
|
// squares, so we cap by cumulative square cost, not by raw block count.
|
||||||
|
const normalizedLimit = Math.max(0, visibleLimit);
|
||||||
|
const restingBlocks = styled.filter((b) => b._opacity === '1');
|
||||||
|
let shownRestingBlocks = fitNewestBySquares(restingBlocks, normalizedLimit);
|
||||||
|
const enteringBlock =
|
||||||
|
enteringInRangeId === null ? undefined : restingBlocks.find((b) => b.id === enteringInRangeId);
|
||||||
|
|
||||||
|
if (enteringBlock && !shownRestingBlocks.some((b) => b.id === enteringBlock.id)) {
|
||||||
|
// Guarantee the just-completed block a slot, then fill the remaining
|
||||||
|
// square budget with the newest of the others.
|
||||||
|
const reservedBudget = Math.max(0, normalizedLimit - squareCount(enteringBlock));
|
||||||
|
const others = restingBlocks.filter((b) => b.id !== enteringBlock.id);
|
||||||
|
shownRestingBlocks = [enteringBlock, ...fitNewestBySquares(others, reservedBudget)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks));
|
||||||
|
|
||||||
|
// Blocks leaving past the upper date bound (opacity 0, `_anim: 'ascend'`) must
|
||||||
|
// stay in the render list so their fly-up transition actually plays — even
|
||||||
|
// when the resting stack already fills the whole square budget. Without this,
|
||||||
|
// a capped stack (e.g. the example page) destroys the element the instant the
|
||||||
|
// slider hides it and it just vanishes. Restrict to blocks that were visible a
|
||||||
|
// moment ago: ones already off-screen have nothing to animate from, so leaving
|
||||||
|
// them out keeps the rendered set (and the phantom flex slots) bounded.
|
||||||
|
const exitingBlocks = styled.filter((b) => b._opacity !== '1' && prevVisibleIds.has(b.id));
|
||||||
|
|
||||||
|
const shownIds = new Set([
|
||||||
|
...shownRestingBlocks.map((b) => b.id),
|
||||||
|
...exitingBlocks.map((b) => b.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hiddenCount,
|
||||||
|
visibleStyled: styled.filter((b) => shownIds.has(b.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'lt-tower',
|
||||||
|
standalone: true,
|
||||||
|
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div #towerRoot class="tower">
|
||||||
|
<div class="tower-header">
|
||||||
|
<input
|
||||||
|
#nameInput
|
||||||
|
type="text"
|
||||||
|
[value]="tower().name"
|
||||||
|
[style.color]="towerNameCss()"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(blur)="onRename($event)"
|
||||||
|
(keydown.enter)="nameInput.blur()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edit-tower"
|
||||||
|
aria-label="Edit tower"
|
||||||
|
(click)="$event.stopPropagation(); showSettings.set(true)"
|
||||||
|
>
|
||||||
|
<img src="assets/pen.svg" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<lt-tasks
|
||||||
|
[pending]="pending()"
|
||||||
|
[baseColor]="tower().base_color"
|
||||||
|
[initiallyOpen]="keepTasksOpen()"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(markDone)="onMarkTaskDone($event)"
|
||||||
|
(edit)="onEditBlock($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
#stackZone
|
||||||
|
class="stack-zone"
|
||||||
|
[style.--block-stack-height]="blockStackHeight()"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/plus-sign.svg"
|
||||||
|
class="add-block"
|
||||||
|
alt="Add block"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="$event.stopPropagation(); openAddBlock()"
|
||||||
|
(keydown.enter)="$event.stopPropagation(); openAddBlock()"
|
||||||
|
(keydown.space)="$event.preventDefault(); $event.stopPropagation(); openAddBlock()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="block-container-container">
|
||||||
|
<div class="block-container">
|
||||||
|
@for (sq of squares(); track sq.key; let i = $index) {
|
||||||
|
<lt-block
|
||||||
|
[block]="sq.block"
|
||||||
|
[baseColor]="tower().base_color"
|
||||||
|
[hovered]="hoveredBlockId() === sq.block.id"
|
||||||
|
[attr.data-block-id]="sq.block.id"
|
||||||
|
[class.descend]="sq.block._anim === 'descend'"
|
||||||
|
[class.ascend]="sq.block._anim === 'ascend'"
|
||||||
|
[style.transform]="sq.block._transform"
|
||||||
|
[style.opacity]="sq.block._opacity"
|
||||||
|
[style.z-index]="squares().length - i"
|
||||||
|
(pointerenter)="onBlockPointerEnter(sq.block.id)"
|
||||||
|
(pointerleave)="onBlockPointerLeave(sq.block.id, $event)"
|
||||||
|
(clicked)="onEditBlock(sq.block)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hiddenBlockCount() > 0) {
|
||||||
|
<p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editEntry(); as entry) {
|
||||||
|
<lt-modal (close)="closeEdit()">
|
||||||
|
<lt-block-edit
|
||||||
|
[viewTitle]="editViewTitle()"
|
||||||
|
[blocks]="filteredForEntry()"
|
||||||
|
[activeBlockId]="entry.activeId"
|
||||||
|
[tags]="towerTags()"
|
||||||
|
[baseColor]="tower().base_color"
|
||||||
|
[defaultDone]="entry.filter === 'done'"
|
||||||
|
(save)="onBlockSave($event)"
|
||||||
|
(delete)="onBlockDelete($event)"
|
||||||
|
(close)="closeEdit()"
|
||||||
|
/>
|
||||||
|
</lt-modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showSettings()) {
|
||||||
|
<lt-modal (close)="showSettings.set(false)">
|
||||||
|
<lt-tower-settings
|
||||||
|
[tower]="tower()"
|
||||||
|
(save)="onTowerSave($event)"
|
||||||
|
(delete)="onTowerDelete()"
|
||||||
|
(close)="showSettings.set(false)"
|
||||||
|
/>
|
||||||
|
</lt-modal>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
@import '../../../library/main';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
&.cdk-drag-animating {
|
||||||
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cdk-drag-placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@media (min-width: $mobile-width) {
|
||||||
|
div.container {
|
||||||
|
box-shadow: $shadow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cdk-drag-preview {
|
||||||
|
div.container {
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
@keyframes shadow {
|
||||||
|
from { box-shadow: none; }
|
||||||
|
to { box-shadow: $shadow; }
|
||||||
|
}
|
||||||
|
animation: shadow $long-animation-time forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.trash-highlight {
|
||||||
|
.container {
|
||||||
|
transform: scale(0.75);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 0.5 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tower-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tower {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
@include inner-spacing(var(--small-padding));
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
container-type: inline-size;
|
||||||
|
--block-stack-height: 0px;
|
||||||
|
--add-block-size: 48px;
|
||||||
|
--add-block-clearance: var(--medium-padding);
|
||||||
|
--add-block-center-offset: 0px;
|
||||||
|
|
||||||
|
@include card();
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||||
|
|
||||||
|
@include inner-spacing(var(--medium-padding));
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
@include inner-spacing(var(--small-padding));
|
||||||
|
padding: 0;
|
||||||
|
--add-block-size: 32px;
|
||||||
|
--add-block-clearance: var(--small-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: red;
|
||||||
|
opacity: 0;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: opacity $short-animation-time;
|
||||||
|
}
|
||||||
|
|
||||||
|
lt-tasks {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 56px;
|
||||||
|
max-height: min(30vh, 45%);
|
||||||
|
overflow: auto;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: min(25vh, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-zone {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
height: 48px;
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
opacity: 0.33;
|
||||||
|
transition: opacity $long-animation-time;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.add-block {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
left: 50%;
|
||||||
|
top: max(
|
||||||
|
0px,
|
||||||
|
min(
|
||||||
|
calc(50% - var(--add-block-size) / 2 - var(--add-block-center-offset)),
|
||||||
|
calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-container-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
.block-container {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-end;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
transform: scaleY(-1);
|
||||||
|
|
||||||
|
/* Default resting position for all blocks before JS sets them */
|
||||||
|
* {
|
||||||
|
transform: translateY(500%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.descend {
|
||||||
|
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
||||||
|
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ascend {
|
||||||
|
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
|
||||||
|
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-blocks {
|
||||||
|
@include small-text();
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--small-padding));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba($text-color, 0.72);
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tower-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
/* Truncate long titles with an ellipsis instead of wrapping. */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
/* Reserve a symmetric gutter on each side. The right one keeps the
|
||||||
|
title and its focus underline clear of the absolutely-positioned
|
||||||
|
pen (so the underline stops before it); the equal left one keeps
|
||||||
|
the centered title optically centered. (pen 22px + 4px gap.) */
|
||||||
|
width: calc(100% - 52px);
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
width: calc(100% - 60px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tower {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.35;
|
||||||
|
transition: opacity $short-animation-time, box-shadow $long-animation-time, background-color $short-animation-time;
|
||||||
|
|
||||||
|
/* Suppress the global button's animated hover underline
|
||||||
|
(button::after), which the 'all: unset' reset doesn't reach. */
|
||||||
|
&:after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: rgba($light-color, 0.86);
|
||||||
|
box-shadow: $shadow-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mobile-width) {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
|
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||||
|
readonly tower = input.required<Tower>();
|
||||||
|
/** Optional date range filter — when set, blocks with `created_at`
|
||||||
|
* outside [from, to] are hidden from the falling stack. */
|
||||||
|
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||||
|
/** When true, the tasks accordion starts expanded on load. */
|
||||||
|
readonly keepTasksOpen = input<boolean>(false);
|
||||||
|
/** When true, completed blocks descend on this tower's first measured render. */
|
||||||
|
readonly animateInitialStack = input<boolean>(false);
|
||||||
|
|
||||||
|
// ── Outputs ────────────────────────────────────────────────────────────────
|
||||||
|
readonly updateTower = output<TowerSettingsResult>();
|
||||||
|
readonly deleteTowerRequest = output<void>();
|
||||||
|
/** Emitted when a new block is created from the carousel's "Create now" card. */
|
||||||
|
readonly addBlock = output<{ tag: string; description: string; is_done: boolean; difficulty: number }>();
|
||||||
|
/** Emitted when an existing block is patched from the carousel. */
|
||||||
|
readonly saveBlock = output<{
|
||||||
|
blockId: string;
|
||||||
|
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||||
|
}>();
|
||||||
|
readonly deleteBlock = output<string>();
|
||||||
|
|
||||||
|
// ── UI state ───────────────────────────────────────────────────────────────
|
||||||
|
/** The single source of truth for "block-edit modal open" — encodes both
|
||||||
|
* which list of blocks to show and which one to focus initially. */
|
||||||
|
readonly editEntry = signal<EditEntry | null>(null);
|
||||||
|
readonly showSettings = signal(false);
|
||||||
|
readonly hiddenBlockCount = signal(0);
|
||||||
|
readonly hoveredBlockId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
|
||||||
|
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||||
|
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
private destroyed = false;
|
||||||
|
private readonly animationFrames = new Set<number>();
|
||||||
|
|
||||||
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||||
|
readonly pending = computed(() => this.tower().blocks.filter((b) => !b.is_done));
|
||||||
|
|
||||||
|
/** CSS color string for the tower name input. */
|
||||||
|
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
||||||
|
|
||||||
|
/** Filtered list passed to the block-edit carousel. */
|
||||||
|
readonly filteredForEntry = computed(() => {
|
||||||
|
const entry = this.editEntry();
|
||||||
|
if (!entry) return [];
|
||||||
|
const isDone = entry.filter === 'done';
|
||||||
|
return this.tower().blocks.filter((b) => b.is_done === isDone);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly editViewTitle = computed(() => {
|
||||||
|
const entry = this.editEntry();
|
||||||
|
if (!entry) return '';
|
||||||
|
const prefix = entry.filter === 'done' ? 'Completed tasks' : 'Tasks';
|
||||||
|
return `${prefix} of ${this.tower().name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Unique tags from existing blocks of this tower. */
|
||||||
|
readonly towerTags = computed(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const b of this.tower().blocks) if (b.tag) set.add(b.tag);
|
||||||
|
return [...set];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Falling animation ──────────────────────────────────────────────────────
|
||||||
|
// Same approach as the legacy: detect "exactly one done block was added"
|
||||||
|
// and snap that last block to translateY(500%)/opacity:0, then on next
|
||||||
|
// tick flip it back to translateY(0)/opacity:1 with .descend so the
|
||||||
|
// 1.5s gravity transition fires.
|
||||||
|
|
||||||
|
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||||
|
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
||||||
|
|
||||||
|
/** Flat list of squares to render: each visible block expands into
|
||||||
|
* `difficulty` adjacent squares that wrap (via the flex container) into
|
||||||
|
* the row above. All squares of a block share its animation state. */
|
||||||
|
readonly squares = computed<RenderSquare[]>(() => {
|
||||||
|
const out: RenderSquare[] = [];
|
||||||
|
for (const b of this.visibleBlocks()) {
|
||||||
|
const n = squareCount(b);
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
out.push({ key: `${b.id}#${k}`, block: b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly blockStackHeight = computed(() => {
|
||||||
|
const rows = Math.ceil(this.squares().length / BLOCKS_PER_ROW);
|
||||||
|
const cqw = rows * (100 / BLOCKS_PER_ROW);
|
||||||
|
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
|
||||||
|
});
|
||||||
|
|
||||||
|
private prevDoneIds: string[] = [];
|
||||||
|
private isFirstRun = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const range = this.dateRange();
|
||||||
|
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||||
|
const animateInitialStack = this.animateInitialStack();
|
||||||
|
// Reconcile all done blocks, then cap the rendered stack to the rows
|
||||||
|
// that fit below the tasks and add button.
|
||||||
|
const allDone = this.tower().blocks.filter((b) => b.is_done);
|
||||||
|
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
const stackZone = this.stackZone()?.nativeElement;
|
||||||
|
if (!stackZone) return;
|
||||||
|
|
||||||
|
this.measureBlockCapacity();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this.measureBlockCapacity());
|
||||||
|
this.resizeObserver.observe(stackZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
requestAnimationFrame(() => this.measureBlockCapacity());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
for (const id of this.animationFrames) cancelAnimationFrame(id);
|
||||||
|
this.animationFrames.clear();
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private measureBlockCapacity(): void {
|
||||||
|
const stackZone = this.stackZone()?.nativeElement;
|
||||||
|
if (!stackZone) return;
|
||||||
|
|
||||||
|
const width = stackZone.clientWidth;
|
||||||
|
const height = stackZone.clientHeight;
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
this.maxVisibleBlocks.set(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = getComputedStyle(stackZone);
|
||||||
|
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
|
||||||
|
this.measureAddBlockCenterOffset(stackZone);
|
||||||
|
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
|
||||||
|
const clearance = this.parseCssPixels(
|
||||||
|
styles.getPropertyValue('--add-block-clearance'),
|
||||||
|
fallbackClearance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowHeight = width / BLOCKS_PER_ROW;
|
||||||
|
const availableHeight = Math.max(0, height - addBlockSize - 2 * clearance);
|
||||||
|
const rows = Math.floor((availableHeight + 0.5) / rowHeight);
|
||||||
|
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
|
||||||
|
}
|
||||||
|
|
||||||
|
private measureAddBlockCenterOffset(stackZone: HTMLElement): void {
|
||||||
|
const towerRoot = this.towerRoot()?.nativeElement;
|
||||||
|
if (!towerRoot) return;
|
||||||
|
|
||||||
|
const stackRect = stackZone.getBoundingClientRect();
|
||||||
|
const towerRect = towerRoot.getBoundingClientRect();
|
||||||
|
const stackCenter = stackRect.top + stackRect.height / 2;
|
||||||
|
const towerCenter = towerRect.top + towerRect.height / 2;
|
||||||
|
const offset = Math.max(0, stackCenter - towerCenter);
|
||||||
|
stackZone.style.setProperty('--add-block-center-offset', `${offset}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCssPixels(value: string, fallback: number): number {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconcile(
|
||||||
|
allDone: Block[],
|
||||||
|
range: { from: number; to: number } | null,
|
||||||
|
maxVisibleBlocks: number | null,
|
||||||
|
animateInitialStack: boolean,
|
||||||
|
): void {
|
||||||
|
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
||||||
|
this._visibleBlocks.set([]);
|
||||||
|
this.hiddenBlockCount.set(0);
|
||||||
|
this.prevDoneIds = allDone.map((b) => b.id);
|
||||||
|
this.isFirstRun = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = allDone.map((b) => b.id);
|
||||||
|
const prev = this.prevDoneIds;
|
||||||
|
const prevSet = new Set(prev);
|
||||||
|
const newIds = ids.filter((id) => !prevSet.has(id));
|
||||||
|
const grewByOne =
|
||||||
|
!this.isFirstRun &&
|
||||||
|
ids.length === prev.length + 1 &&
|
||||||
|
newIds.length === 1 &&
|
||||||
|
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||||
|
|
||||||
|
const styled: StyledBlock[] = [];
|
||||||
|
for (const b of allDone) {
|
||||||
|
if (range && b.created_at < range.from) {
|
||||||
|
// Below min-thumb boundary → drop entirely (instant shuffle, no animation).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (range && b.created_at > range.to) {
|
||||||
|
// Above max-thumb boundary → fly up off the tower with gravity animation.
|
||||||
|
styled.push({
|
||||||
|
...b,
|
||||||
|
_anim: 'ascend',
|
||||||
|
_transform: 'translateY(500%)',
|
||||||
|
_opacity: '0',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// In range — descend into position (or appear instantly on first run).
|
||||||
|
styled.push({
|
||||||
|
...b,
|
||||||
|
_anim: this.isFirstRun ? '' : 'descend',
|
||||||
|
_transform: 'translateY(0)',
|
||||||
|
_opacity: '1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newInRangeId = grewByOne ? newIds[0] : null;
|
||||||
|
// Captured before any `_visibleBlocks.set` below — lets the cap keep
|
||||||
|
// currently-shown blocks that are now flying out so their exit animates.
|
||||||
|
const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id));
|
||||||
|
const visibleLimit =
|
||||||
|
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
|
||||||
|
let visibleStyled = styled;
|
||||||
|
let hiddenCount = 0;
|
||||||
|
if (Number.isFinite(visibleLimit)) {
|
||||||
|
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
|
||||||
|
styled,
|
||||||
|
visibleLimit,
|
||||||
|
newInRangeId,
|
||||||
|
prevVisibleIds,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
this.hiddenBlockCount.set(hiddenCount);
|
||||||
|
|
||||||
|
if (this.isFirstRun && animateInitialStack) {
|
||||||
|
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1');
|
||||||
|
if (initialBlocks.length > 0) {
|
||||||
|
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||||
|
this.prevDoneIds = ids;
|
||||||
|
this.isFirstRun = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grewByOne) {
|
||||||
|
const newId = newIds[0];
|
||||||
|
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||||
|
if (newBlock) {
|
||||||
|
// Snap newly-added in-range block to start position, then on the next
|
||||||
|
// paint flip it back to rest — that's what makes it visibly fall.
|
||||||
|
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||||
|
this.prevDoneIds = ids;
|
||||||
|
this.isFirstRun = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// existing fall-through path (no growth, first run, or new block out of range):
|
||||||
|
this._visibleBlocks.set(visibleStyled);
|
||||||
|
|
||||||
|
this.prevDoneIds = ids;
|
||||||
|
this.isFirstRun = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void {
|
||||||
|
for (const block of blocks) {
|
||||||
|
block._anim = '';
|
||||||
|
block._transform = 'translateY(500%)';
|
||||||
|
block._opacity = '0';
|
||||||
|
}
|
||||||
|
this._visibleBlocks.set(visibleStyled);
|
||||||
|
this.requestFrame(() => {
|
||||||
|
this.requestFrame(() => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.finishDescendAnimation(blocks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestFrame(callback: () => void): void {
|
||||||
|
if (typeof requestAnimationFrame !== 'function') {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
this.animationFrames.delete(id);
|
||||||
|
if (!this.destroyed) callback();
|
||||||
|
});
|
||||||
|
this.animationFrames.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishDescendAnimation(blocks: StyledBlock[]): void {
|
||||||
|
for (const block of blocks) {
|
||||||
|
block._anim = 'descend';
|
||||||
|
block._transform = 'translateY(0)';
|
||||||
|
block._opacity = '1';
|
||||||
|
}
|
||||||
|
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onRename(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const newName = input.value.trim();
|
||||||
|
if (!newName) {
|
||||||
|
input.value = this.tower().name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName !== this.tower().name) {
|
||||||
|
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||||
|
} else {
|
||||||
|
input.value = this.tower().name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditBlock(block: Block): void {
|
||||||
|
this.hoveredBlockId.set(null);
|
||||||
|
this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlockPointerEnter(blockId: string): void {
|
||||||
|
this.hoveredBlockId.set(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlockPointerLeave(blockId: string, event: PointerEvent): void {
|
||||||
|
if (this.relatedTargetBelongsToBlock(event.relatedTarget, blockId)) return;
|
||||||
|
if (this.hoveredBlockId() === blockId) this.hoveredBlockId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private relatedTargetBelongsToBlock(target: EventTarget | null, blockId: string): boolean {
|
||||||
|
const towerRoot = this.towerRoot()?.nativeElement;
|
||||||
|
let element = target instanceof Element ? target : null;
|
||||||
|
|
||||||
|
while (element && element !== towerRoot) {
|
||||||
|
if (element instanceof HTMLElement && element.dataset['blockId'] === blockId) return true;
|
||||||
|
element = element.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */
|
||||||
|
onMarkTaskDone(block: Block): void {
|
||||||
|
this.saveBlock.emit({
|
||||||
|
blockId: block.id,
|
||||||
|
result: {
|
||||||
|
tag: block.tag,
|
||||||
|
description: block.description,
|
||||||
|
is_done: true,
|
||||||
|
difficulty: block.difficulty,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the template "Add block" plus-icon. */
|
||||||
|
openAddBlock(): void {
|
||||||
|
this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen()));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEdit(): void {
|
||||||
|
this.editEntry.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlockSave(ev: BlockEditSave): void {
|
||||||
|
if (ev.id === null) {
|
||||||
|
this.addBlock.emit({
|
||||||
|
tag: ev.tag,
|
||||||
|
description: ev.description,
|
||||||
|
is_done: ev.is_done,
|
||||||
|
difficulty: ev.difficulty,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.saveBlock.emit({
|
||||||
|
blockId: ev.id,
|
||||||
|
result: {
|
||||||
|
tag: ev.tag,
|
||||||
|
description: ev.description,
|
||||||
|
is_done: ev.is_done,
|
||||||
|
difficulty: ev.difficulty,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlockDelete(id: string): void {
|
||||||
|
// Don't close the carousel — the deleted block disappears from `blocks()`
|
||||||
|
// and the carousel re-renders in place. The user keeps editing siblings.
|
||||||
|
this.deleteBlock.emit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTowerSave(result: TowerSettingsResult): void {
|
||||||
|
// Tower edits auto-save, so this fires on every change and must NOT close
|
||||||
|
// the modal — the user closes it via the exit button / backdrop.
|
||||||
|
this.updateTower.emit(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTowerDelete(): void {
|
||||||
|
this.showSettings.set(false);
|
||||||
|
this.deleteTowerRequest.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
139
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { StyledBlock } from './tower.component';
|
||||||
|
import { 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('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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
387
frontend/src/app/components/welcome/welcome.component.ts
Normal 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>();
|
||||||
|
}
|
||||||
46
frontend/src/app/models/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
65
frontend/src/app/services/analytics.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/app/services/api.service.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { TreeDto } from '../models';
|
||||||
|
|
||||||
|
@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<TreeDto> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.get<TreeDto>('/api/v1/data', { headers: this.authHeaders(token) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async putData(token: string, tree: TreeDto): Promise<void> {
|
||||||
|
await firstValueFrom(
|
||||||
|
this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private authHeaders(token: string): HttpHeaders {
|
||||||
|
return new HttpHeaders({ Authorization: `Bearer ${token}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/src/app/services/api.service.vitest.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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 { 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', async () => {
|
||||||
|
const tree: TreeDto = { pages: [] };
|
||||||
|
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(tree);
|
||||||
|
await expect(promise).resolves.toEqual(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts data with a bearer token', async () => {
|
||||||
|
const tree: TreeDto = { pages: [] };
|
||||||
|
const promise = service.putData('token-1', tree);
|
||||||
|
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.body).toBe(tree);
|
||||||
|
req.flush(null);
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
frontend/src/app/services/modal-state.service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
770
frontend/src/app/services/store.service.ts
Normal file
|
|
@ -0,0 +1,770 @@
|
||||||
|
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
|
||||||
|
import { ApiService } from './api.service';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
import { Page, Tower, Block, TreeDto, 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
private adoptServerTree(tree: TreeDto, token: string): void {
|
||||||
|
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 (tree.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(tree.pages);
|
||||||
|
this.updateCache(token, tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
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): void {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
await this.api.putData(put.token, put.tree);
|
||||||
|
this._saveStatus.set('saved');
|
||||||
|
if (
|
||||||
|
this._token() === put.token &&
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
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;
|
||||||
|
}
|
||||||
676
frontend/src/app/services/store.service.vitest.ts
Normal file
|
|
@ -0,0 +1,676 @@
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockApi(): MockApi {
|
||||||
|
return {
|
||||||
|
health: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||||
|
register: vi.fn().mockResolvedValue({ user_id: 'u' }),
|
||||||
|
getData: vi.fn().mockResolvedValue({ pages: [] } satisfies TreeDto),
|
||||||
|
putData: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
frontend/src/app/utils/color.ts
Normal 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 0–1) 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));
|
||||||
|
}
|
||||||
20
frontend/src/app/utils/hash.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Deterministic hash of a string returning a value in [0, 1).
|
||||||
|
*
|
||||||
|
* Ports the legacy hash.ts exactly:
|
||||||
|
* h = ((h << 5) - h + charCode) | 0, seed = 7
|
||||||
|
* result = h / (2^32 - 2) + 0.5
|
||||||
|
*
|
||||||
|
* The legacy formula keeps the same distribution as the original Angular 7
|
||||||
|
* reference so per-tag block colours are stable across the port.
|
||||||
|
*/
|
||||||
|
export function hash(s: string): number {
|
||||||
|
if (!s) return 0;
|
||||||
|
let h = 7;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
// Same bit-ops as legacy: (h << 5) - h == h * 31, truncated to int32
|
||||||
|
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
// Map the signed int32 to [0, 1) — same formula as legacy
|
||||||
|
return h / (2 ** 32 - 2) + 0.5;
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/fonts/open-sans-condensed-300.woff2
Normal file
BIN
frontend/src/assets/fonts/raleway-400.woff2
Normal file
39
frontend/src/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Life Towers</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#5d576b" />
|
||||||
|
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
|
||||||
|
<link rel="canonical" href="/" data-dynamic-url="canonical" />
|
||||||
|
<meta property="og:title" content="Life Towers" />
|
||||||
|
<meta property="og:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="/" data-dynamic-url="canonical" />
|
||||||
|
<meta property="og:site_name" content="Life Towers" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||||
|
<meta property="og:image:secure_url" content="/og-image.png" data-dynamic-url="og-image" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:width" content="3333" />
|
||||||
|
<meta property="og:image:height" content="1750" />
|
||||||
|
<meta property="og:image:alt" content="Life Towers task towers preview" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Life Towers" />
|
||||||
|
<meta name="twitter:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
|
||||||
|
<meta name="twitter:image:alt" content="Life Towers task towers preview" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||