This commit is contained in:
Andras Schmelczer 2026-05-30 14:33:39 +01:00
parent e2a60e71a3
commit 003f38ea60
36 changed files with 1543 additions and 1287 deletions

View file

@ -8,77 +8,150 @@ on:
jobs: jobs:
backend: backend:
name: Backend tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
run: | uses: astral-sh/setup-uv@v4
curl -LsSf https://astral.sh/uv/install.sh | sh with:
echo "$HOME/.local/bin" >> $GITHUB_PATH version: latest
- name: Set up Python
run: uv python install 3.13
- name: Sync dependencies - name: Sync dependencies
working-directory: backend
run: uv sync run: uv sync
- name: Run tests - name: Run pytest
working-directory: backend
run: uv run pytest -v run: uv run pytest -v
frontend: frontend-lint:
name: Frontend lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
cache: 'npm' cache: npm
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install dependencies - name: Install dependencies
working-directory: frontend
run: npm ci run: npm ci
- name: Lint (if configured) - name: Run ESLint
working-directory: frontend run: npm run lint
run: |
if [ -f eslint.config.js ] || [ -f .eslintrc.json ] || [ -f .eslintrc.js ]; then
npm run lint
else
echo "No ESLint config found, skipping lint"
fi
- name: Build frontend-test:
working-directory: frontend name: Frontend unit tests
run: npm run build
- name: Test
working-directory: frontend
run: npm test
docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend, frontend] defaults:
run:
working-directory: frontend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build Docker image - uses: actions/setup-node@v4
run: docker build -t life-towers:${{ github.sha }} . with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Push to registry - name: Install dependencies
if: github.event_name == 'push' && github.ref == 'refs/heads/master' run: npm ci
env:
REGISTRY_URL: ${{ secrets.REGISTRY_URL }} - name: Run Vitest
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} run: npm test
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
frontend-build:
name: Frontend build
runs-on: ubuntu-latest
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: ubuntu-latest
needs: [backend, frontend-build]
steps:
- uses: actions/checkout@v4
- name: Start stack
run: docker compose -p life-towers -f docker-compose.dev.yml up --build -d
- name: Wait for /api/v1/health
run: | run: |
if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then set -e
echo "Registry secrets not configured, skipping push" cid=$(docker compose -p life-towers -f docker-compose.dev.yml ps -q life-towers)
exit 0 for i in $(seq 1 60); do
fi status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting)
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin if [ "$status" = healthy ]; then
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:${{ github.sha }}" echo "stack healthy after ${i} attempts"
docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:latest" exit 0
docker push "$REGISTRY_URL/life-towers:${{ github.sha }}" fi
docker push "$REGISTRY_URL/life-towers:latest" 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: |
docker run --rm \
--network life-towers_default \
-v "$(pwd)/frontend:/work" \
-w /work \
-e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
-e CI=true \
mcr.microsoft.com/playwright:v1.60.0-noble \
sh -c 'npm ci && npx playwright test'
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
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@v4
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

View file

@ -1,52 +0,0 @@
# IMPORTANT: Before this workflow will function, configure the following
# repository secrets in Forgejo (Settings → Secrets):
# DEPLOY_HOST — hostname or IP of the target server
# DEPLOY_USER — SSH user on the target server
# DEPLOY_SSH_KEY — private SSH key (PEM or OpenSSH format)
# DEPLOY_PATH — absolute path to the project directory on the server
# (must contain a docker-compose.yml + a .env file
# that sets LIFE_TOWERS_IMAGE to the registry tag,
# e.g. LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest)
name: Deploy
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Install SSH key
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy via SSH
run: |
set -euo pipefail
# Pulls the new image referenced by $LIFE_TOWERS_IMAGE in the
# server's .env, restarts the service, then verifies health.
ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=yes \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"set -euo pipefail
cd '${{ secrets.DEPLOY_PATH }}'
docker compose pull
docker compose up -d --remove-orphans
# Wait for healthcheck (max ~60s)
for i in \$(seq 1 30); do
status=\$(docker compose ps --format json life-towers | python3 -c 'import sys,json;[print(json.loads(l).get(\"Health\",\"\")) for l in sys.stdin]' || true)
if [ \"\$status\" = healthy ]; then echo deploy_healthy; exit 0; fi
sleep 2
done
echo deploy_unhealthy >&2
docker compose logs --tail 50 life-towers >&2
exit 1"

View file

@ -0,0 +1,50 @@
name: Docker
on:
push:
branches: [master]
workflow_dispatch:
env:
REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }}
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=sha,prefix=sha-,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -224,9 +224,11 @@ When `values.length` grows (new block added), the slider snaps the **higher** of
### Tickbox (tasks/tasks.component) ### Tickbox (tasks/tasks.component)
- Always-visible `✓` glyph at `opacity: 0.45` (rest), `0.85` (hover), `1` (active) - `✓` 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
- `transform: translateY(2px)` is critical — Unicode `✓` has uneven font-metrics, geometric centering looks half a square too high. The 2px nudge moves it to optical center - 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
- `:active` state must re-state the translateY or the glyph jumps when pressed - `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 responsive

View file

@ -35,6 +35,7 @@ LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
| `LIFE_TOWERS_PORT` | `8000` | Host port mapped to the container. | | `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_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_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. | | `LIFE_TOWERS_FORWARDED_ALLOW_IPS` | `*` | (Optional, advanced.) Override uvicorn's `--forwarded-allow-ips` if you want to narrow the set of trusted proxies. |
## Data persistence ## Data persistence

View file

@ -5,14 +5,16 @@ from __future__ import annotations
import json import json
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from html import escape
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
from urllib.parse import urlsplit, urlunsplit
import structlog import structlog
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@ -151,7 +153,44 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$" r"[-.][A-Za-z0-9]{8,}\.(?:js|css|woff2?|png|jpe?g|svg|ico|map)$"
) )
def _serve_file(file_path: Path) -> FileResponse: 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)) resp = FileResponse(str(file_path))
if HASHED_PATTERN.search(file_path.name): if HASHED_PATTERN.search(file_path.name):
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
@ -160,7 +199,7 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
return resp return resp
@app.get("/{full_path:path}", include_in_schema=False) @app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str) -> Response: async def spa_fallback(request: Request, full_path: str) -> Response:
# API routes are handled by the API router (registered before this); # API routes are handled by the API router (registered before this);
# if execution reaches here for an /api/* path, it really is unknown. # if execution reaches here for an /api/* path, it really is unknown.
if full_path.startswith("api/"): if full_path.startswith("api/"):
@ -174,12 +213,12 @@ def _mount_static(app: FastAPI, static_dir: Path) -> None:
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
if candidate.is_file(): if candidate.is_file():
return _serve_file(candidate) return _serve_file(candidate, request)
# SPA fallback to index.html. # SPA fallback to index.html.
index = static_dir / "index.html" index = static_dir / "index.html"
if index.is_file(): if index.is_file():
return _serve_file(index) return _serve_file(index, request)
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")

View file

@ -62,6 +62,73 @@ async def test_health(client: AsyncClient) -> None:
assert resp.json() == {"status": "ok"} assert resp.json() == {"status": "ok"}
@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 # Register
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,795 +0,0 @@
# DESIGN.md — Life Towers Legacy Visual Spec (Angular 7 → 21 port)
Forensic reference for restoring pixel-perfect parity. Every snippet is quoted verbatim from `_legacy_reference/frontend/src/`. File paths are absolute.
---
## 1. Color tokens
`_legacy_reference/frontend/src/library/common-variables.scss:1-9`:
```scss
$accent-color: #a2666f;
$text-color: #5d576b;
$light-color: #ffffff;
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%);
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%);
$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);
```
`index.html:14`: `<meta name="theme-color" content="#5d576b" />` — iOS/Android theme bar = `$text-color`.
- `7f` hex alpha in `$background-gradient` is ~50% opacity. Opaque variant is used on `<body>`; semi-transparent is the **modal backdrop**.
- `$shadow` is a layered "soft-glow border" — first ring 1.5px tight (10% black), second 3px diffuse (5% black). Reuse exactly.
- `$shadow-border` is a 0.75px hairline used in place of CSS `border:` everywhere.
---
## 2. Typography
Fonts loaded in `index.html:8-11, 17-18`:
```html
<link href="https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300|Raleway&display=swap&subset=latin-ext" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
```
`common-variables.scss:11-12`:
```scss
$normal-font: 'Open Sans Condensed', sans-serif;
$title-font: 'Raleway', serif;
```
Only **Open Sans Condensed Light 300** and **Raleway 400** are actually used in the visual design.
`library/text.scss:3-58`:
```scss
:root {
--larger-font-size: 22px;
--large-font-size: 18px;
--medium-font-size: 16px;
--small-font-size: 11px;
@media (max-width: $mobile-width) { // 520px
--larger-font-size: 20px;
--large-font-size: 16px;
--medium-font-size: 14px;
--small-font-size: 10px;
}
}
@mixin title-text { font-family: $title-font; color: $text-color; font-size: var(--larger-font-size); user-select: none; }
@mixin sub-title-text { font-family: $title-font; color: $text-color; font-size: var(--medium-font-size); user-select: none; }
@mixin normal-text { font-family: $normal-font; color: $text-color; font-size: var(--larger-font-size); }
@mixin medium-text { font-family: $normal-font; color: $text-color; font-size: var(--medium-font-size); }
@mixin small-text { font-family: $normal-font; color: $text-color; font-size: var(--small-font-size); }
h1, h2, h3 { @include title-text(); }
p { @include normal-text(); }
```
---
## 3. Spacing tokens
`library/main.scss:8-22`:
```scss
:root {
--border-radius: 5px;
--large-padding: 30px;
--medium-padding: 15px;
--small-padding: 10px;
@media (max-width: $mobile-width) { // 520px
--border-radius: 3px;
--large-padding: 20px;
--medium-padding: 15px;
--small-padding: 7.5px;
}
}
```
Body padding is `var(--large-padding)` — 30px desktop / 20px mobile around everything.
Breakpoints:
```scss
$mobile-width: 520px;
$min-height: 400px;
```
---
## 4. Animations
`library/animations.scss:1-22` (full file):
```scss
@import 'common-variables';
$long-animation-time: 200ms;
$short-animation-time: 100ms;
@mixin gravitate {
cursor: pointer;
transition: box-shadow $long-animation-time, transform $long-animation-time;
&:hover {
box-shadow: $shadow;
transform: scale(1.1);
}
}
@mixin jump {
cursor: pointer;
transition: transform $long-animation-time;
&:hover { transform: translateY(-2px); }
}
```
### 4a. The "falling animation" (THE critical interaction)
A block transitions from above the tower top (`translateY(500%)`) down into its slot. The `.block-container` is `transform: scaleY(-1)` (flipped). Visually each new block drops from the top of the tower and lands on top of the previous ones. The ease curve `cubic-bezier(0.5, 0, 1, 0)` is a steep accelerating ease-in (gravity).
`tower.component.scss:115-140`:
```scss
.block-container-container {
position: relative;
flex: 1;
.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);
* { 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;
}
}
}
```
Driver pattern (`tower.component.ts:67-98`):
```ts
const lastBlock = top(this.styledBlocks);
if (lastBlock) {
lastBlock.style = { transform: 'translateY(500%)', opacity: '0' };
setTimeout(() => {
this.makeBlockDescend(lastBlock);
this.changeDetection.markForCheck();
}, 0);
}
...
makeBlockDescend(block) { block.cssClass = 'descend'; block.style = { transform: 'translateY(0)', opacity: '1' }; }
makeBlockAscend(block) { block.cssClass = 'ascend'; block.style = { transform: 'translateY(500%)', opacity: '0' }; }
```
Sequence on add: place the new block at `translateY(500%)/opacity:0` synchronously, then on next tick apply `.descend` class + reset transform to `translateY(0)/opacity:1`. The 1.5s gravity cubic curve does the fall; opacity fades in over the first 500ms.
On ascend: same curve but the opacity delay is **1s** so the block stays visible for most of the upward flight, then fades just before leaving.
### 4b. Other timed transitions
- Modal backdrop opacity: `300ms`.
- Tower hover-shadow + scale: `gravitate()` mixin = 200ms.
- Tower drag-and-drop reflow: `transform 200ms cubic-bezier(0, 0, 0.2, 1)`.
- cdkDrag animating: `transform 250ms cubic-bezier(0, 0, 0.2, 1)`.
- Trash icon scale-in: `transform 200ms`.
- Toggle thumb slide: `box-shadow/left/transform 200ms`.
- Select-add dropdown: `transform 200ms translateY(-100%) → none`; background height 200ms.
- Button underline (`forms.scss:78`): `width 300ms`.
- Tasks accordion `.all-task`: `height 200ms`.
---
## 5. Mixins (verbatim, ready to port)
`library/utils.scss`:
```scss
@mixin inner-spacing($spacing, $horizontal: false) {
& > *:not(:last-child) {
@if $horizontal { margin-right: $spacing; }
@else { margin-bottom: $spacing; }
}
}
```
`library/spacing.scss` (despite filename, contains `square`):
```scss
@mixin square($size) {
width: $size;
height: $size;
}
```
`library/main.scss:24-43`:
```scss
@mixin card {
border-radius: var(--border-radius);
background-color: $light-color;
}
@mixin center-child {
display: flex;
justify-content: center;
align-items: center;
}
@mixin exit {
@include square(16px);
background: url('/assets/x-sign.svg') no-repeat center center;
background-size: 50% 50%;
box-sizing: content-box;
padding: 8px;
@include jump();
}
```
`library/forms.scss:1-85` — global form styling:
```scss
@import 'text';
@import 'animations';
textarea {
@include normal-text();
&:disabled { background-color: $light-color; }
display: block; width: 100%; height: 150px;
@media (max-width: $mobile-width) { height: 100px; }
resize: none; box-sizing: border-box; border: none;
}
input[type='text'] {
@include sub-title-text();
width: 100%; background: transparent; display: block; border: 0;
&::placeholder { color: inherit; opacity: 0.6; }
&:focus { box-shadow: 0 1px $text-color; }
}
button {
-webkit-appearance: none;
margin: 8px auto 0 auto;
user-select: none;
background: transparent;
border: 0;
@include medium-text();
font-size: var(--large-font-size);
$height: 2px;
cursor: pointer;
border-bottom: solid $height #5d576b55;
position: relative;
&:disabled {
color: #5d576b55;
border-bottom: solid $height #5d576b33;
cursor: not-allowed;
}
&:not(:disabled):hover { &:after { width: 100%; } }
&:after {
content: '';
width: 0; height: $height;
position: absolute; left: 0;
bottom: calc(-1 * #{$height});
background-color: $text-color;
transition: width 300ms;
}
}
label { display: none; }
```
Global root + scrollbar (`styles.scss` + `main.scss:45-68`):
```scss
* { margin: 0; padding: 0;
&:active, &:focus { outline: 0; }
&::selection { background: $text-color; color: $light-color; }
&::placeholder { user-select: none; }
}
html { height: 100%; background-color: $text-color; }
body { height: 100%; background: $background-gradient-opaque; text-align: center; padding: var(--large-padding); box-sizing: border-box; position: relative; }
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { box-shadow: $shadow-border; border-radius: var(--border-radius); }
*::-webkit-scrollbar-thumb { background-color: $text-color; border-radius: var(--border-radius); cursor: pointer; }
* { -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
```
`$line-height: 2px;` is declared in `styles.scss:3` and reused by exit-pen underline, slider track, etc.
---
## 6. Layout rules per component
### 6a. Pages (top page selector) — `pages.component`
There is **no traditional tab strip**. The "page picker" is the `<app-select-add>` dropdown inside `.select-add-container` with `width: 250px; margin: auto`. It serves as both selector ("Add a new page…" placeholder) and editor (`editable=true` shows pen icon).
```scss
:host {
height: 100%;
display: flex; flex-direction: column; justify-content: space-between;
@include inner-spacing(var(--large-padding));
.select-add-container { width: 250px; margin: 0 auto; }
.page-container { flex: 1 0 auto; }
button {
transition: opacity $long-animation-time;
&.transparent { opacity: 0; }
}
}
```
Settings button at bottom fades to opacity 0 while a tower is dragging.
### 6b. Page (towers container) — `page.component`
Magic geometry:
```scss
.towers {
display: flex; justify-content: center;
width: 100%; margin: 0 auto;
flex: 1 0 auto;
transition: box-shadow $short-animation-time;
max-width: 800px;
&.cdk-drop-list-dragging {
*:not(.cdk-drag-placeholder) {
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
}
}
div { @include center-child(); // add-tower wrapper
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; }
}
}
& > * {
max-width: 200px;
box-sizing: content-box;
flex: 0 0 auto;
&:not(:nth-last-child(1)) {
margin-right: var(--medium-padding);
@media (max-width: $mobile-width) { margin-right: var(--small-padding); }
}
}
position: relative;
@for $i from 1 to 6 {
& > *:first-child:nth-last-child(#{$i}),
& > *:first-child:nth-last-child(#{$i}) ~ * {
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
@media (max-width: $mobile-width) {
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
}
}
}
}
```
Max 5 towers per page. Each tower gets an equal column up to 200px wide.
Trash icon:
```scss
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); }
}
```
### 6c. Tower — `tower.component`
Tower header: the `<input type="text">` for the tower name (font: `var(--small-font-size)`, centered, width 50% desktop). Color = tower's `baseColor` HSL.
Card body:
```scss
.container {
display: flex; flex-direction: column;
flex: 1 1 auto;
position: relative;
@include card();
overflow: hidden;
transition: transform $short-animation-time, box-shadow $long-animation-time;
@include inner-spacing(var(--medium-padding));
width: 100%;
:before { // red flash overlay during trash-highlight
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;
}
img { // the plus-sign button inside the tower
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; }
}
}
```
Hover shows `$shadow` above `$mobile-width`. `.trash-highlight` class shrinks to `scale(0.75)`, bumps `:before` to 0.5 opacity, hides the name input.
### 6d. Block — `block.component` ⭐ CRITICAL VISUAL DIVERGENCE
**A block is purely a colored square** — sized to 1/6th of the tower width. No tag label, no description, no done-state. The visual distinction is "it's IN THE TOWER" (done) vs "it's in the TASKS accordion" (pending).
```html
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="handleClick()"></div>
```
```scss
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6); // forces aspect-ratio 1:1
div {
position: absolute;
width: 100%; height: 100%;
@include gravitate(); // hover shadow + scale 1.1
}
}
```
Per-block color (`model/tower.ts:52-54`):
```ts
getColorOfTag(tag: string): IColor {
return lighten((hash(tag) - 0.5) * 50, this.baseColor);
}
```
```ts
// utils/color.ts
export const lighten = (by: number, { h, s, l }: IColor): IColor => {
let newL = l + by;
if (newL > 100) newL = 100;
else if (newL < 0) newL = 0;
return { h, s, l: newL };
};
```
Deterministic hash of tag → `[0,1)`, centered at 0.5 → `[-0.5, +0.5)`, scaled ×50 → `[-25, +25)` lightness offset added to tower's HSL. **All blocks in a tower vary in lightness only**, around the tower's baseColor.
### 6e. Tasks (pending blocks accordion) — `tasks.component` ⭐ MISSING IN NEW APP
Tasks is **not** a sub-modal — it's an in-tower accordion listing **pending** (not-done) blocks. Sits ABOVE the falling-blocks area, inside each tower.
```scss
:host {
width: 100%; box-sizing: border-box;
position: relative; z-index: 100000;
.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 { cursor: pointer; }
p { font-size: var(--medium-font-size); }
.all-task {
@include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
.task-container {
display: flex; align-items: center;
&:hover p { @media (min-width: $mobile-width) { color: inherit !important; } }
div { // colored dot per task
flex: 0 0 auto;
margin: 0 calc(var(--small-padding) / 2) 0 0;
@include square(var(--small-padding));
@media (max-width: $mobile-width) { @include square(calc(var(--small-padding) / 2)); }
}
p {
white-space: nowrap; text-overflow: ellipsis; overflow-x: hidden;
text-align: left;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
}
position: relative;
}
}
}
}
}
```
Header: `<strong>N</strong> task(s)`. Click expands `.all-task` from `height: 0` to `scrollHeight` in 200ms. Each row: colored dot (size `var(--small-padding)`) + description with ellipsis. Text color is the block's color; hover resets to inherit.
### 6f. Modal shell — `modal.component`
```scss
section {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 10000;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient; // semi-transparent warm gradient!
transition: opacity 300ms;
&:not(.active) { opacity: 0; pointer-events: none; }
button { margin-top: var(--medium-padding); }
}
```
Modal backdrop = the warm cream→pink gradient at 50% alpha layered over the app. **Distinctive.** Open transition opacity 0→1 in 300ms.
### 6g. Sub-modals (settings / remove-page / remove-tower / blocks-edit)
All small modals share the same card shell:
```scss
section / :host {
@include card();
width: 66vw;
max-width: 400px; // settings = 400, remove-* = 500
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit(); // x-sign.svg, 16px box, 8px padding, jump hover
}
}
}
```
Block-edit modal is a **horizontally scrolling carousel** of cards. Each card 66vw / max 400px. Two transparent spacer cards at start/end so the active card centers. `.mask` overlay fades opaque on inactive neighbours. Snap-to-center on scroll-end (150ms idle).
`get-started.component`: stub — skip in port.
### 6h. Toggle (custom switch)
A **dual-label switch**: left label + oval track + right label. Active-side label gets `font-weight: bold`. Hover nudges the thumb 2px toward the other side.
```scss
:host {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
span {
@include medium-text();
max-width: 3 * $size;
cursor: pointer;
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
}
label { display: block;
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; }
@media (min-width: $mobile-width) {
&:hover:after { box-shadow: $shadow; transform: translateX(2px); }
&.on:hover:after { transform: translateX(-2px); }
}
}
}
}
```
### 6i. Select-add — `select-add.component`
A custom dropdown that doubles as inline creator (with `+ Add` button) and optional inline editor (pen icon, `editable=true`).
Top bar = white card showing selected text + arrow. Click → `.bottom` slides via `transform: translateY(-100%) → none` over 200ms. Other options listed as `<p>` rows. Bottom: text input + Add button + optional pen icon. Arrow rotates 180° when open.
```scss
.background {
position: absolute; top: 0;
height: 100%; width: 100%;
@include card();
z-index: 3;
transition: box-shadow $long-animation-time, height $long-animation-time;
&.active { box-shadow: $shadow; }
}
&:hover { @media (min-width: $mobile-width) { .background { box-shadow: $shadow; } } }
&.shadow-border { .background.active { box-shadow: $shadow-border; } }
```
Flags: `alwaysDropShadow` pre-applies open shadow. `onlyShadowBorder` swaps soft shadow for hairline (used inside block-edit modal).
### 6j. Double-slider (date-range — NOT an HSL picker)
CRITICAL CLARIFICATION: legacy `double-slider` is a **two-thumb date-range slider** filtering blocks by `created` date — not an HSL color picker. The HSL color picker for tower base-color doesn't exist in the legacy reference (the tower color was likely set elsewhere or hardcoded). This is what makes the page "beautiful": as a thumb approaches a date label, the label slides upward and rotates -45°, like magnetic markers.
```scss
$height: 70px;
$width: 300px;
$slider-size: 40px;
.container {
width: $width;
height: $height;
position: relative;
margin: $slider-size / 2 auto 0 auto;
label { display: none; }
input[type='range'] {
width: 100%;
position: absolute; left: 0;
-webkit-appearance: none; outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: $slider-size; // 40px
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
transform-origin: center center;
transform: translateY(-$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(-$slider-size / 2 + $line-height / 2) scale(1.1);
}
}
cursor: pointer;
position: relative; z-index: 2;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height; // 2px
background-color: $text-color;
border-radius: 1000px;
}
}
.value-container {
@include small-text();
display: flex; justify-content: space-evenly;
span { display: block; margin-top: 10px; }
}
}
```
Two stacked `<input type="range">` on the same track. White 40px circular thumbs, 2px solid track. Hover scales thumb 1.1× and adds `$shadow`.
Labels: `getOffset(index)` for each of 6 evenly-spaced date labels, compute distance to nearer thumb (normalized `[0,1]`); within 0.2 "active zone" the label translates upward by `(1 - d/0.2) * 30px`, rotated -45°.
---
## 7. Drag-and-drop
Tower list is a `cdkDropList cdkDropListOrientation="horizontal"`. Each `<app-tower>` is `cdkDrag`. Cursor: `pointer` (not grab/grabbing).
- `.cdk-drag-animating` → tower `transition: transform 250ms cubic-bezier(0,0,0.2,1)`.
- `.cdk-drag-placeholder``opacity: 0`.
- `.cdk-drag-preview` → mobile fades `box-shadow` in via inline `@keyframes shadow` over 200ms.
- `.cdk-drop-list-dragging *:not(.cdk-drag-placeholder)``transition: transform 200ms cubic-bezier(0,0,0.2,1)`.
Trash interaction (`page.component.ts:69-91`):
- `pointerenter` on trash → `nearTrashcan=true`, append `' trash-highlight'` to `.cdk-drag-preview`'s className.
- `pointerleave` → remove class.
- `pointerup` on trash → open remove-tower confirm modal.
During drag:
- `isDragging` true → trash icon `.active` springs in.
- `isDragHappening` emitted up → Settings button fades to opacity 0.
- Date-slider container fades to opacity 0.
---
## 8. Image assets
All SVGs in `_legacy_reference/frontend/src/assets/`:
| File | Where used | Size | Behaviour |
|---|---|---|---|
| `arrow.svg` | `select-add` top bar | `square(16px)` | rotates `-180deg` open, transition 200ms |
| `pen.svg` | `select-add` edit button, blocks-modal edit | `square(16px)` in wrapper | `opacity: 0.25 → 0.5 (hover) → 1 (active)`; `:before` 2px underline expands 0→100% over 200ms |
| `plus-sign.svg` | Tower internal add-block, end-of-row add-tower | `height: 48px` desktop, `32px` mobile | `opacity: 0.33 → 1` (hover) over 200ms |
| `trash.svg` | Page absolute-positioned trash zone | `square(48px); padding: 16px` (80×80 hit box), `bottom: 8px; left: 50%` | `scale(0) → scale(1) (.active) → scale(1.1) (hover)`; `translateX(-50%)` preserved |
| `x-sign.svg` | All modal exit buttons | 16px inner, 8px padding, `background-size: 50% 50%` | `@include jump()` hover lift |
---
## 9. Per-state styling
### Block
- **Pending** (`!isDone`): appears **only in `<app-tasks>` accordion** as a colored-dot row.
- **Done** (`isDone === true`): appears as a 1/6-tower-width colored square in the falling stack.
- **Filtered out by date slider**: `.ascend` class, transitions out over 1.5s (opacity delayed 1s).
- **Hover** (done block): `gravitate()``box-shadow: $shadow` + `transform: scale(1.1)` in 200ms.
### Tower
- **Idle**: white card.
- **Hover** (desktop): `box-shadow: $shadow` over 200ms.
- **Dragging preview**: mobile fades shadow in over 200ms.
- **Drag placeholder**: `opacity: 0`.
- **Over trash (`.trash-highlight`)**: `scale(0.75)`, red overlay at 0.5 opacity, name input hidden.
### Page-tab (select-add for pages)
No active/inactive — only "currently selected page" at top of dropdown.
- **Closed**: white card, hairline shadow on hover.
- **Open**: `$shadow` (or `$shadow-border` if `onlyShadowBorder`).
---
## Implementation order
1. Drop `library/*.scss` into `frontend/src/library/` verbatim. Update `styles.scss` to import them.
2. Apply global body/html/scrollbar styles.
3. Load Google Fonts (Open Sans Condensed 300 + Raleway). Drop the self-hosted-fonts I added if they're locked at incorrect weights — re-verify woff2 files cover the right weights and rewrite @font-face cleanly. Keep self-hosting if you want, just match the weights exactly.
4. Set `<meta name="theme-color" content="#5d576b" />`.
5. Build `select-add`, `toggle`, `double-slider` first.
6. Build modal shell + sub-modals using the shared card recipe.
7. Build tower → block → tasks.
8. Build page with the `@for $i from 1 to 6` width calc and trash zone.
9. Wire the "falling animation" exactly per §4a — the `setTimeout(..., 0)` two-step is essential.
---
## Critical model recap for implementers
- **Block has a `tag`** (string) and **does NOT have a description** in the legacy UI. The "description" field in the new app is not in the legacy data model — the legacy block is just `{ id, tag, isDone, created }` plus optionally derived data. Verify against the legacy `block.ts` and `IBlock` interface. The NEW backend's normalized schema has a `description` — keep it, but make it OPTIONAL and don't render it as the block's primary visual.
- **Tower color picker**: NOT in the legacy reference. The new app's color-picker exists; align its visuals with the rest of the design (white card, $shadow, etc.) but don't pretend it matches a legacy that didn't exist.
- **Date-range filter**: the double-slider in the legacy filtered blocks by their `created` date. Decide whether to port this (recommended — it's the "beautiful slider" the user remembers).

View file

@ -1,127 +0,0 @@
# Life Towers API specification
This is the single source of truth for the v4 HTTP API between the Angular SPA and the FastAPI backend. Both clients and the server MUST conform to the shapes and rules defined here.
## Conventions
- All IDs are UUIDv4 strings (lowercase, canonical hex with dashes).
- All timestamps are Unix epoch seconds as integers.
- All requests and responses are `application/json` unless noted.
- Auth is `Authorization: Bearer <token>` where `<token>` is a UUIDv4 generated client-side at first launch.
- Same-origin: the frontend is served by the same FastAPI process, so CORS is locked to the deployment origin (or fully disabled in same-origin mode).
- All payloads are size-capped at **2 MiB**. Server returns `413 Payload Too Large` on overflow.
- Because `PUT /api/v1/data` atomically replaces the user's entire tree, the request size **is** the user's total storage — there is no separate per-user quota.
- Every response carries an `X-Request-Id` (UUIDv4) for log correlation.
## Authentication
A "user" is identified solely by a token (UUIDv4). There is no password, email, or recovery mechanism. The token is the credential — losing it means losing the data.
- The token is generated on the client at first launch (`crypto.randomUUID()`).
- The client calls `POST /api/v1/register` to claim the token. Idempotent — if the token already exists the server returns `200 OK` with the existing record.
- All authenticated endpoints require `Authorization: Bearer <token>`. Missing/malformed → `401`. Token not a valid UUIDv4 → `401`. Token not in DB → `401`. The `detail` string is identical across all 401 causes so the response cannot be used to enumerate tokens.
## Endpoints
### `GET /api/v1/health`
Public liveness probe. Returns `200 {"status":"ok"}`. No auth.
### `POST /api/v1/register`
Body: `{"token": "<uuidv4>"}`. Creates the user if absent; updates `last_seen_at` if present. Returns `200 {"user_id": "<uuidv4>"}`.
Rate limit: **30 requests / hour / IP**.
### `GET /api/v1/data`
Returns the full hierarchy belonging to the authenticated user. Response shape:
```json
{
"pages": [
{
"id": "uuid",
"name": "string",
"hide_create_tower_button": false,
"default_date_from": 1700000000, // or null
"default_date_to": 1700090000, // or null
"towers": [
{
"id": "uuid",
"name": "string",
"base_color": { "h": 0.5, "s": 0.8, "l": 0.6 },
"blocks": [
{
"id": "uuid",
"tag": "string",
"description": "string",
"is_done": false,
"created_at": 1700000000
}
]
}
]
}
]
}
```
Empty user → `200 {"pages": []}`.
Rate limit: **60 / minute / token**.
### `PUT /api/v1/data`
Atomically replaces the entire user hierarchy with the request body. Request body has the same shape as the `GET /api/v1/data` response. Server enforces:
- Every `id` is a valid UUIDv4. The server does NOT enforce that IDs already exist — clients are free to mint new IDs. IDs MUST be unique within the request (no duplicates at any level).
- All string fields are bounded:
- `page.name`, `tower.name`, `block.tag`: ≤ 200 chars
- `block.description`: ≤ 10 000 chars
- Numeric bounds:
- HSL components: `h ∈ [0,1]`, `s ∈ [0,1]`, `l ∈ [0,1]`
- Page-level counts: ≤ 100 pages, ≤ 100 towers per page, ≤ 1000 blocks per tower
- Total blocks across the user: ≤ 50 000
- The whole replacement happens in a single SQLite transaction. Existing rows for the user are deleted and the new tree is inserted. The `users.last_seen_at` timestamp is updated.
Returns `204 No Content` on success.
Rate limit: **30 / minute / token**.
### Error responses
All error responses are JSON: `{"error": "code", "detail": "human-readable"}`. Codes the client must handle:
| HTTP | code | When |
|------|-----------------------|---------------------------------------------------------|
| 400 | `bad_request` | Malformed JSON, missing fields, validation failures |
| 401 | `unauthorized` | Missing/invalid/unknown token |
| 413 | `payload_too_large` | Request body > 2 MiB |
| 429 | `rate_limited` | Rate limit exceeded. `Retry-After` header set |
| 500 | `server_error` | Unexpected server failure. Body is generic, no stacktrace |
## SPA hosting
Any non-`/api/*` route is served from the static frontend build:
- `GET /``index.html`
- `GET /assets/*`, `/favicon.ico`, `/manifest.webmanifest`, `/ngsw-worker.js`, hashed JS/CSS bundles → static file
- `GET /<anything-else>``index.html` (SPA fallback for client-side routing)
Static files served with:
- `Cache-Control: public, max-age=31536000, immutable` for hashed assets
- `Cache-Control: no-cache` for `index.html`
- gzip / brotli pre-compressed where available
## Removed since legacy
- `POST /` (replaced by `POST /api/v1/register`)
- `POST /me` (the `track` endpoint — pure DOS vector, dropped entirely)
- `GET /me/root`, `PUT /me/root` (folded into `GET/PUT /api/v1/data`)
- `GET /me/<id>`, `POST /me/<id>` (per-object endpoints — replaced by tree-replace semantics)
## Future extensions (not in v1, but designed to allow)
- `GET /api/v1/data/stream` (Server-Sent Events) — push notifications of remote changes. Replaces polling for multi-device sync.
- Signed share tokens for read-only sharing without giving away the full account token.

View file

@ -53,11 +53,8 @@ test.describe('Life Towers visuals', () => {
await createCard await createCard
.locator('textarea[placeholder="Write a description here…"]') .locator('textarea[placeholder="Write a description here…"]')
.fill('Finish The Brothers Karamazov'); .fill('Finish The Brothers Karamazov');
// Toggle to "Task hasn't been finished yet" so this becomes a pending task. // Uncheck "Already done" so this becomes a pending task.
await createCard await createCard.getByLabel('Already done').uncheck();
.locator('lt-toggle span')
.filter({ hasText: "This task hasn't been finished yet" })
.click();
await page.getByRole('button', { name: 'Create and exit' }).click(); await page.getByRole('button', { name: 'Create and exit' }).click();
await page.waitForSelector('section.modal', { state: 'detached' }); await page.waitForSelector('section.modal', { state: 'detached' });
@ -67,6 +64,15 @@ test.describe('Life Towers visuals', () => {
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true }); 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. // Add a couple more blocks.
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) { 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.locator('img[alt="Add block"]').first().click();

View file

@ -16,6 +16,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0", "@angular/service-worker": "^21.2.0",
"@plausible-analytics/tracker": "^0.4.5",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@ -3519,6 +3520,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@plausible-analytics/tracker": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
"integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==",
"license": "MIT"
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.60.0", "version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",

View file

@ -15,6 +15,7 @@
"packageManager": "npm@10.9.2", "packageManager": "npm@10.9.2",
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.13", "@angular/cdk": "^21.2.13",
"@plausible-analytics/tracker": "^0.4.5",
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.0",

View file

@ -1,5 +1,6 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core'; import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
import { StoreService } from './services/store.service'; import { StoreService } from './services/store.service';
import { AnalyticsService } from './services/analytics.service';
import { PagesComponent } from './components/pages/pages.component'; import { PagesComponent } from './components/pages/pages.component';
@Component({ @Component({
@ -11,8 +12,10 @@ import { PagesComponent } from './components/pages/pages.component';
}) })
export class App implements OnInit { export class App implements OnInit {
private readonly store = inject(StoreService); private readonly store = inject(StoreService);
private readonly analytics = inject(AnalyticsService);
ngOnInit(): void { ngOnInit(): void {
this.analytics.init();
this.store.init(); this.store.init();
} }
} }

View file

@ -15,7 +15,6 @@ import {
} from '@angular/core'; } from '@angular/core';
import { Block, HslColor } from '../../models'; import { Block, HslColor } from '../../models';
import { SelectAddComponent } from '../shared/select-add/select-add.component'; import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { ToggleComponent } from '../shared/toggle/toggle.component';
import { getColorOfTag } from '../../utils/color'; import { getColorOfTag } from '../../utils/color';
export interface BlockEditSave { export interface BlockEditSave {
@ -35,9 +34,13 @@ interface EditedValue {
@Component({ @Component({
selector: 'lt-block-edit', selector: 'lt-block-edit',
standalone: true, standalone: true,
imports: [SelectAddComponent, ToggleComponent], imports: [SelectAddComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
@if (viewTitle()) {
<h2 class="view-title">{{ viewTitle() }}</h2>
}
<section <section
#container #container
class="carousel" class="carousel"
@ -61,7 +64,7 @@ interface EditedValue {
class="block-dot" class="block-dot"
[style.background-color]="colorOfTagForBlock(b.id)" [style.background-color]="colorOfTagForBlock(b.id)"
></div> ></div>
<h1>{{ formatDate(b.created_at) }}</h1> <h1>{{ formatDate(b.created_at, true) }}</h1>
</div> </div>
<div class="select-add-container"> <div class="select-add-container">
@ -83,14 +86,14 @@ interface EditedValue {
(blur)="flushExisting(b.id)" (blur)="flushExisting(b.id)"
></textarea> ></textarea>
<div> <label class="done-checkbox">
<lt-toggle <input
type="checkbox"
[checked]="editedFor(b.id).is_done" [checked]="editedFor(b.id).is_done"
(checkedChange)="updateDone(b.id, $event)" (change)="updateDone(b.id, $any($event.target).checked)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
/> />
</div> <span>Already done</span>
</label>
<div class="bottom"> <div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button> <button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
@ -133,14 +136,14 @@ interface EditedValue {
(input)="updateNewDescription($any($event.target).value)" (input)="updateNewDescription($any($event.target).value)"
></textarea> ></textarea>
<div> <label class="done-checkbox">
<lt-toggle <input
type="checkbox"
[checked]="newValue().is_done" [checked]="newValue().is_done"
(checkedChange)="updateNewDone($event)" (change)="updateNewDone($any($event.target).checked)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
/> />
</div> <span>Already done</span>
</label>
<div class="bottom"> <div class="bottom">
<button <button
@ -166,21 +169,50 @@ interface EditedValue {
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 10001; // above modal backdrop (10000) 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 { .carousel {
--title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size));
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
padding: var(--title-clearance) 0 var(--large-padding);
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
padding: 0 7.5vw; 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 { &::-webkit-scrollbar {
@ -199,10 +231,10 @@ interface EditedValue {
max-width: 400px; max-width: 400px;
scroll-snap-align: center; scroll-snap-align: center;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
width: 85vw; width: min(88vw, 360px);
max-width: 85vw; max-width: calc(100vw - (2 * var(--medium-padding)));
padding: var(--medium-padding); padding: var(--medium-padding);
margin: calc(var(--medium-padding) / 2); margin: 0 calc(var(--small-padding) / 2);
opacity: 1 !important; opacity: 1 !important;
} }
box-sizing: border-box; box-sizing: border-box;
@ -256,8 +288,9 @@ interface EditedValue {
width: 60vw; width: 60vw;
max-width: 60vw; max-width: 60vw;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
width: 7.5vw; width: var(--medium-padding);
max-width: 7.5vw; max-width: var(--medium-padding);
min-width: var(--medium-padding);
} }
box-shadow: none; box-shadow: none;
background: transparent; background: transparent;
@ -266,6 +299,12 @@ interface EditedValue {
.header { .header {
@include center-child(); @include center-child();
position: relative; position: relative;
gap: var(--small-padding);
h1 {
min-width: 0;
overflow-wrap: anywhere;
}
.exit { .exit {
position: absolute; position: absolute;
@ -289,6 +328,70 @@ interface EditedValue {
} }
} }
.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;
}
}
.bottom { .bottom {
height: 32px; height: 32px;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
@ -306,15 +409,27 @@ interface EditedValue {
} }
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
lt-select-add, lt-toggle { lt-select-add,
.done-checkbox {
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
} }
.bottom {
min-height: 42px;
button {
width: max-content;
max-width: 100%;
min-height: 42px;
}
}
} }
} }
`, `,
}) })
export class BlockEditComponent implements AfterViewInit { export class BlockEditComponent implements AfterViewInit {
readonly viewTitle = input<string>('');
readonly blocks = input.required<Block[]>(); readonly blocks = input.required<Block[]>();
readonly activeBlockId = input<string | null>(null); readonly activeBlockId = input<string | null>(null);
readonly tags = input<string[]>([]); readonly tags = input<string[]>([]);
@ -407,8 +522,16 @@ export class BlockEditComponent implements AfterViewInit {
return t ? getColorOfTag(t, this.baseColor()) : 'transparent'; return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
}); });
formatDate(ts: number): string { formatDate(ts: number, compact = false): string {
const d = new Date(ts * 1000); 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" // e.g. "May 28, 2026, 14:32"
return d.toLocaleString(undefined, { return d.toLocaleString(undefined, {
year: 'numeric', year: 'numeric',

View file

@ -31,27 +31,35 @@ import { ModalStateService } from '../../services/modal-state.service';
styles: ` styles: `
@import '../../../library/main'; @import '../../../library/main';
/* lt-modal host must not occupy a flex slot in its parent section.modal /* Keep the component host out of parent flex/grid flow. Parent containers
is position:fixed, but the host element itself would otherwise take a apply spacing to direct children, so relying on the fixed child alone can
slot and push siblings around when it mounts/unmounts. still let <lt-modal> become a layout item when it mounts. */
display: contents removes the host box without affecting descendants. */
:host { :host {
display: contents; position: fixed;
inset: 0;
z-index: 10000;
display: block;
margin: 0 !important;
} }
section.modal { section.modal {
position: fixed; position: absolute;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
@include center-child(); @include center-child();
padding: var(--large-padding); padding: var(--large-padding);
box-sizing: border-box; box-sizing: border-box;
background: $background-gradient; background: rgba(255, 248, 248, 0.94);
transition: opacity 300ms; transition: opacity 300ms;
opacity: 1; 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) { &:not(.active) {
opacity: 0; opacity: 0;

View file

@ -72,7 +72,11 @@ export interface PageSettingsResult {
@include card(); @include card();
width: 66vw; width: 66vw;
max-width: 400px; max-width: 400px;
@media (max-width: $mobile-width) { width: 300px; } @media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box; box-sizing: border-box;
padding: var(--large-padding); padding: var(--large-padding);
position: relative; position: relative;
@ -101,6 +105,12 @@ export interface PageSettingsResult {
button { button {
display: block; 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;
}
} }
} }
`, `,

View file

@ -26,7 +26,7 @@ const UUIDV4_RE =
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="card"> <div class="card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button> <button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Settings</h2> <h2>Settings</h2>
@if (page()) { @if (page()) {
@ -43,19 +43,23 @@ const UUIDV4_RE =
aria-label="Page name" aria-label="Page name"
/> />
<lt-toggle <div class="toggle-list">
[checked]="hideCreateTowerButton()" <lt-toggle
(checkedChange)="onHideCreateTowerButtonChange($event)" class="setting-toggle"
offLabel="Show add-tower button" [checked]="hideCreateTowerButton()"
onLabel="Hide add-tower button" (checkedChange)="onHideCreateTowerButtonChange($event)"
/> offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<lt-toggle <lt-toggle
[checked]="keepTasksOpen()" class="setting-toggle"
(checkedChange)="onKeepTasksOpenChange($event)" [checked]="keepTasksOpen()"
offLabel="Show tasks collapsed" (checkedChange)="onKeepTasksOpenChange($event)"
onLabel="Keep tasks open" offLabel="Show tasks collapsed"
/> onLabel="Keep tasks open"
/>
</div>
<button class="danger" type="button" (click)="deletePage.emit()"> <button class="danger" type="button" (click)="deletePage.emit()">
Delete this page Delete this page
@ -68,7 +72,7 @@ const UUIDV4_RE =
<section class="account-section"> <section class="account-section">
<h3>Account</h3> <h3>Account</h3>
<p class="hint">Your token (keep it secret it IS your account)</p> <p class="hint">Copy this token to another device to permanently sync your progress</p>
<div class="token-row"> <div class="token-row">
<input <input
type="text" type="text"
@ -127,17 +131,19 @@ const UUIDV4_RE =
top: var(--medium-padding); top: var(--medium-padding);
right: var(--medium-padding); right: var(--medium-padding);
@include exit(); @include exit();
font-size: 0;
} }
h2 { h2 {
margin: 0 0 var(--large-padding) 0; margin: 0 0 var(--large-padding) 0;
padding: 0 36px;
line-height: 1.3;
text-align: center; text-align: center;
} }
h3 { h3 {
margin: 0 0 var(--medium-padding) 0; margin: 0 0 var(--medium-padding) 0;
font-size: var(--large-font-size); font-size: var(--medium-font-size);
line-height: 1.35;
} }
section { section {
@ -155,8 +161,37 @@ const UUIDV4_RE =
margin: var(--large-padding) 0; 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 { .hint {
font-size: var(--small-font-size); font-size: var(--medium-font-size);
line-height: 1.35;
color: rgba($text-color, 0.7); color: rgba($text-color, 0.7);
margin: 0 0 4px 0; margin: 0 0 4px 0;
} }

View file

@ -5,7 +5,9 @@ import {
output, output,
OnInit, OnInit,
inject, inject,
DestroyRef,
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Tower, HslColor } from '../../models'; import { Tower, HslColor } from '../../models';
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component'; import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
@ -39,12 +41,12 @@ export interface TowerSettingsResult {
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" /> <lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
</div> </div>
<button type="submit" [disabled]="form.invalid">
{{ tower() ? 'Save' : 'Create tower' }}
</button>
@if (tower()) { @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> <button type="button" (click)="delete.emit()">Delete tower</button>
} @else {
<button type="submit" [disabled]="form.invalid">Create tower</button>
} }
</form> </form>
`, `,
@ -86,14 +88,14 @@ export interface TowerSettingsResult {
border: 0; border: 0;
} }
// Generous gap between the name input and the color picker — the picker
// is a substantial control and crowding it against the title looks busy.
.picker-row {
padding-top: var(--medium-padding);
}
button { button {
display: block; 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;
}
} }
} }
`, `,
@ -105,6 +107,7 @@ export class TowerSettingsComponent implements OnInit {
readonly close = output<void>(); readonly close = output<void>();
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
form = this.fb.group({ form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]], name: ['', [Validators.required, Validators.maxLength(200)]],
@ -117,17 +120,36 @@ export class TowerSettingsComponent implements OnInit {
if (t) { if (t) {
this.form.patchValue({ name: t.name }); this.form.patchValue({ name: t.name });
this.currentColor = { ...t.base_color }; 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 { onColorChange(color: HslColor): void {
this.currentColor = color; this.currentColor = color;
// In edit mode the picker is a live control — commit each change.
if (this.tower()) this.autoSave();
} }
onSubmit(): void { 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).
if (this.form.invalid) return; if (this.form.invalid) return;
const v = this.form.value; this.emitSave();
this.save.emit({ name: v.name ?? '', base_color: this.currentColor }); }
/** Emit a save only when the form is valid (skips e.g. an empty name). */
private autoSave(): void {
if (this.form.invalid) return;
this.emitSave();
}
private emitSave(): void {
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
} }
} }

View file

@ -7,7 +7,7 @@
@for (tower of page().towers; track tower.id) { @for (tower of page().towers; track tower.id) {
<lt-tower <lt-tower
cdkDrag cdkDrag
[cdkDragDisabled]="modalOpen()" [cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
[tower]="tower" [tower]="tower"
[dateRange]="dateRange()" [dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open" [keepTasksOpen]="page().keep_tasks_open"
@ -48,7 +48,7 @@
@if (showAddTower()) { @if (showAddTower()) {
<lt-modal title="New Tower" (close)="showAddTower.set(false)"> <lt-modal title="New Tower" (close)="showAddTower.set(false)">
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" /> <lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
</lt-modal> </lt-modal>
} }

View file

@ -5,6 +5,7 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0;
position: relative; // anchor for absolute-positioned .trash position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding)); @include inner-spacing(var(--large-padding));
@ -18,10 +19,12 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
box-sizing: border-box;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
flex: 1 0 auto; flex: 1 1 auto;
min-height: 0;
transition: box-shadow $short-animation-time; transition: box-shadow $short-animation-time;
@ -55,6 +58,7 @@
max-width: 200px; max-width: 200px;
box-sizing: content-box; box-sizing: content-box;
flex: 0 0 auto; flex: 0 0 auto;
min-height: 0;
&:not(:nth-last-child(1)) { &:not(:nth-last-child(1)) {
margin-right: var(--medium-padding); margin-right: var(--medium-padding);
@ -84,15 +88,22 @@
// without needing !important on every property only the width triples need it // without needing !important on every property only the width triples need it
// to beat the per-child-count selectors generated above. // to beat the per-child-count selectors generated above.
@media (max-width: $mobile-width) { @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-x: auto;
overflow-y: visible; overflow-y: visible;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scroll-padding-inline: var(--mobile-tower-side-padding);
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
// Side padding lets the first and last tower scroll fully into view. padding: 0 var(--mobile-tower-side-padding);
padding: 0 var(--medium-padding);
max-width: 100%; max-width: 100%;
gap: var(--medium-padding);
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
@ -100,12 +111,16 @@
// Override the @for width-calc rules above. // Override the @for width-calc rules above.
& > * { & > * {
width: calc(66vw - var(--small-padding)) !important; width: var(--mobile-tower-width) !important;
max-width: calc(66vw - var(--small-padding)) !important; max-width: var(--mobile-tower-width) !important;
min-width: calc(66vw - var(--small-padding)) !important; min-width: var(--mobile-tower-width) !important;
scroll-snap-align: start; scroll-snap-align: center;
flex-shrink: 0; flex-shrink: 0;
} }
& > *:not(:nth-last-child(1)) {
margin-right: 0;
}
} }
} }

View file

@ -5,6 +5,7 @@ import {
output, output,
signal, signal,
inject, inject,
HostListener,
} from '@angular/core'; } from '@angular/core';
import { Page } from '../../models'; import { Page } from '../../models';
import { StoreService } from '../../services/store.service'; import { StoreService } from '../../services/store.service';
@ -66,6 +67,7 @@ export class PageComponent {
private readonly modalState = inject(ModalStateService); private readonly modalState = inject(ModalStateService);
/** True while any lt-modal is mounted — used to lock tower drag. */ /** True while any lt-modal is mounted — used to lock tower drag. */
readonly modalOpen = this.modalState.anyOpen; readonly modalOpen = this.modalState.anyOpen;
readonly mobileDragDisabled = signal(this.isMobileViewport());
readonly showAddTower = signal(false); readonly showAddTower = signal(false);
readonly isDragging = signal(false); readonly isDragging = signal(false);
@ -116,6 +118,18 @@ export class PageComponent {
this.dateRange.set({ from: range.from as number, to: range.to as number }); 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 ──────────────────────────────────────────────────────── // ── Tower mutations ────────────────────────────────────────────────────────
onAddTower(result: TowerSettingsResult): void { onAddTower(result: TowerSettingsResult): void {

View file

@ -2,6 +2,7 @@
:host { :host {
height: 100%; height: 100%;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -21,7 +22,8 @@
} }
.page-container { .page-container {
flex: 1 0 auto; flex: 1 1 auto;
min-height: 0;
// Generous breathing room between the page selector dropdown and the // Generous breathing room between the page selector dropdown and the
// towers the dropdown can open downward without crowding the towers. // towers the dropdown can open downward without crowding the towers.
padding-top: var(--large-padding); padding-top: var(--large-padding);

View file

@ -61,7 +61,10 @@ const FIXED_L = 0.55;
display: block; display: block;
padding: var(--medium-padding); padding: var(--medium-padding);
@include card(); @include card();
box-shadow: $shadow-border; 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 { .picker {
@ -76,35 +79,40 @@ const FIXED_L = 0.55;
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
gap: 6px; gap: 6px;
@media (max-width: $mobile-width) {
grid-template-columns: repeat(6, 1fr);
gap: var(--small-padding);
}
.swatch { .swatch {
all: unset; all: unset;
cursor: pointer; cursor: pointer;
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 4px; border-radius: 4px;
box-shadow: $shadow-border; box-shadow: 0 0 0 1px rgba($text-color, 0.18);
transition: transform $short-animation-time, box-shadow $long-animation-time; transition: transform $short-animation-time, box-shadow $long-animation-time;
&:hover, &:hover,
&:focus-visible { &:focus-visible {
box-shadow: $shadow; box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
transform: scale(1.1); transform: scale(1.1);
} }
&.active { &.active {
box-shadow: $shadow; box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
transform: scale(1.15); transform: scale(1.15);
outline: 2px solid $light-color;
outline-offset: 1px;
} }
} }
} }
.hue-slider { .hue-slider {
padding: 8px 0;
input[type='range'] { input[type='range'] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 100%; width: 100%;
height: 12px; height: 16px;
border-radius: 1000px; border-radius: 1000px;
background: linear-gradient( background: linear-gradient(
to right, to right,
@ -119,11 +127,15 @@ const FIXED_L = 0.55;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
&:focus-visible {
box-shadow: 0 0 0 3px rgba($text-color, 0.35);
}
&::-webkit-slider-thumb { &::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 24px; height: 32px;
width: 24px; width: 32px;
border-radius: 1000px; border-radius: 1000px;
background-color: var(--thumb-color, #{$light-color}); background-color: var(--thumb-color, #{$light-color});
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow}; box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
@ -135,8 +147,8 @@ const FIXED_L = 0.55;
} }
&::-moz-range-thumb { &::-moz-range-thumb {
height: 24px; height: 32px;
width: 24px; width: 32px;
border-radius: 1000px; border-radius: 1000px;
background-color: var(--thumb-color, white); background-color: var(--thumb-color, white);
border: 2px solid white; border: 2px solid white;
@ -153,7 +165,7 @@ const FIXED_L = 0.55;
.preview { .preview {
height: 40px; height: 40px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: $shadow-border; box-shadow: 0 0 0 1px rgba($text-color, 0.18);
} }
`, `,
}) })

View file

@ -6,6 +6,9 @@ import {
signal, signal,
OnChanges, OnChanges,
SimpleChanges, SimpleChanges,
ElementRef,
HostListener,
inject,
} from '@angular/core'; } from '@angular/core';
@Component({ @Component({
@ -19,7 +22,7 @@ import {
[class.always-shadow]="alwaysDropShadow()" [class.always-shadow]="alwaysDropShadow()"
> >
<div class="background" [class.active]="open()"></div> <div class="background" [class.active]="open()"></div>
<div class="top" (click)="open.update(v => !v)"> <div class="top" (click)="toggleOpen($event)">
<p>{{ resolvedSelected() ?? placeholder() }}</p> <p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" /> <img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div> </div>
@ -33,7 +36,9 @@ import {
(blur)="onRename(item, $any($event.target).value)" (blur)="onRename(item, $any($event.target).value)"
/> />
} @else { } @else {
<p (click)="onSelectItem(item)">{{ item }}</p> <button class="option" type="button" (click)="onSelectItem(item)">
{{ item }}
</button>
} }
} }
<div class="add-row"> <div class="add-row">
@ -43,9 +48,20 @@ import {
placeholder="Add a value…" placeholder="Add a value…"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''" (keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/> />
<button (click)="onAdd(addInput.value); addInput.value = ''">Add</button> <button
class="add-button"
type="button"
(click)="onAdd(addInput.value); addInput.value = ''"
>
Add
</button>
@if (editable()) { @if (editable()) {
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)"> <button
class="pen"
type="button"
[class.active]="editing()"
(click)="editing.update(v => !v)"
>
<img src="assets/pen.svg" alt="Edit" /> <img src="assets/pen.svg" alt="Edit" />
</button> </button>
} }
@ -58,6 +74,7 @@ import {
@import '../../../../library/main'; @import '../../../../library/main';
$inner-padding: var(--medium-padding); $inner-padding: var(--medium-padding);
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
:host { :host {
display: block; display: block;
@ -80,13 +97,22 @@ import {
align-items: center; align-items: center;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
min-height: 46px;
box-sizing: border-box;
gap: var(--small-padding);
p { p {
display: inline-block; display: block;
@include sub-title-text(); @include sub-title-text();
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
} }
img.arrow { img.arrow {
flex: 0 0 auto;
@include square(16px); @include square(16px);
transition: transform $long-animation-time; transition: transform $long-animation-time;
@ -98,81 +124,129 @@ import {
.bottom-container { .bottom-container {
width: 100%; width: 100%;
height: 300px;
position: absolute; position: absolute;
overflow-y: hidden; top: 100%;
left: 0;
right: 0;
overflow: visible;
pointer-events: none; pointer-events: none;
z-index: 5;
.bottom { .bottom {
position: absolute; position: relative;
width: 100%; width: 100%;
pointer-events: all; pointer-events: none;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius); border-radius: 0 0 var(--border-radius) var(--border-radius);
padding: $inner-padding; padding: $inner-padding;
padding-top: 0; padding-top: var(--small-padding);
@include inner-spacing($inner-padding); gap: var(--small-padding);
// Default (closed) state — also the target of the close transition. // Default (closed) state — also the target of the close transition.
background-color: transparent; background-color: transparent;
box-shadow: none; box-shadow: none;
transform: translateY(-100%); transform: translateY(-8px);
opacity: 0;
visibility: hidden; 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 // Delay the visibility change until after the slide animation finishes
// so the panel stays visible while it animates closed. // so the panel stays visible while it animates closed.
transition: transition:
transform $long-animation-time, transform $long-animation-time,
opacity $long-animation-time,
background-color $long-animation-time, background-color $long-animation-time,
box-shadow $long-animation-time, box-shadow $long-animation-time,
visibility 0s $long-animation-time; visibility 0s $long-animation-time;
&.open { &.open {
visibility: visible; visibility: visible;
pointer-events: all;
transform: none; transform: none;
opacity: 1;
background-color: $light-color; background-color: $light-color;
box-shadow: $shadow; box-shadow: $dropdown-shadow;
// Show shadow on left/right/bottom only; clip the top edge so the
// shadow doesn't bleed over the seam where .bottom meets .top.
clip-path: inset(0 -6px -6px -6px);
// On open, visibility flips immediately (no delay); transform + // On open, visibility flips immediately (no delay); transform +
// colors + shadow animate over $long-animation-time. // colors + shadow animate over $long-animation-time.
transition: transition:
transform $long-animation-time, transform $long-animation-time,
opacity $long-animation-time,
background-color $long-animation-time, background-color $long-animation-time,
box-shadow $long-animation-time, box-shadow $long-animation-time,
visibility 0s 0s; visibility 0s 0s;
} }
p { .option {
@include sub-title-text(); @include sub-title-text();
display: inline-block; display: flex;
align-items: center;
width: 100%;
min-height: 36px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
&:after {
display: none;
}
@media (max-width: $mobile-width) {
min-height: 42px;
}
} }
input[type='text'] { input[type='text'] {
@include sub-title-text(); @include sub-title-text();
width: 100%; 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 { .add-row {
height: 32px; min-height: 40px;
@media (max-width: $mobile-width) { height: 24px; }
position: relative; position: relative;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: flex-end;
gap: var(--small-padding); gap: var(--small-padding);
input[type='text'] { input[type='text'] {
flex: 1; flex: 1;
min-height: 0;
padding: 0;
border-bottom: solid 2px transparent;
&:focus,
&:focus-visible {
box-shadow: none;
border-bottom-color: $text-color;
}
} }
button { button {
margin: 0; margin: 0;
position: static; position: relative;
flex: 0 0 auto;
&.add-button {
align-self: flex-end;
}
&.pen { &.pen {
opacity: 0.25; opacity: 0.25;
@ -184,6 +258,10 @@ import {
background: transparent; background: transparent;
position: relative; position: relative;
// Kill the global button's hover-grow underline pseudo-element
// for the icon-only edit control.
&:after { content: none; display: none; }
img { img {
@include square(16px); @include square(16px);
} }
@ -224,16 +302,18 @@ import {
width: 100%; width: 100%;
@include card(); @include card();
z-index: 3; z-index: 3;
box-sizing: border-box;
transition: transition:
box-shadow $long-animation-time, box-shadow $long-animation-time,
height $long-animation-time, height $long-animation-time,
border-radius $long-animation-time; border-radius $long-animation-time;
&.active { &.active {
box-shadow: $shadow; // Same shadow recipe as the panel below so the two halves read as
// Show shadow on top/left/right only; clip the bottom edge so the // one continuous card. Clip the bottom edge so this shadow doesn't
// shadow doesn't bleed over the seam where .top meets .bottom. // bleed across the seam where .top meets .bottom.
clip-path: inset(-6px -6px 0 -6px); box-shadow: $dropdown-shadow;
clip-path: inset(-10px -10px 0 -10px);
} }
} }
@ -251,25 +331,31 @@ import {
} }
} }
// 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 { &:hover {
@media (min-width: $mobile-width) { @media (min-width: $mobile-width) {
.background { box-shadow: $shadow; } .background:not(.active) { box-shadow: $shadow; }
} }
} }
&.shadow-border { &.shadow-border {
.background.active { .background.active {
box-shadow: $shadow-border; box-shadow: $shadow-border;
clip-path: inset(-6px -6px 0 -6px); clip-path: inset(-10px -10px 0 -10px);
}
.bottom.open {
box-shadow: $shadow-border;
} }
} }
&.shadow-border:hover { &.shadow-border:hover {
.background { box-shadow: $shadow-border; } .background:not(.active) { box-shadow: $shadow-border; }
} }
&.always-shadow { &.always-shadow {
.background { box-shadow: $shadow; } .background:not(.active) { box-shadow: $shadow; }
// When open, clip the bottom so the always-on shadow doesn't bleed // When open, clip the bottom so the always-on shadow doesn't bleed
// over the seam; restore full shadow when closed. // over the seam; restore full shadow when closed.
&:has(.bottom.open) .background { &:has(.bottom.open) .background {
@ -309,6 +395,7 @@ export class SelectAddComponent implements OnChanges {
// ── Internal state ───────────────────────────────────────────────────────── // ── Internal state ─────────────────────────────────────────────────────────
readonly open = signal(false); readonly open = signal(false);
readonly editing = signal(false); readonly editing = signal(false);
private readonly host = inject(ElementRef<HTMLElement>);
// Resolved values that merge old + new API // Resolved values that merge old + new API
protected resolvedItems(): string[] { protected resolvedItems(): string[] {
@ -332,6 +419,20 @@ export class SelectAddComponent implements OnChanges {
// Nothing to do — signals handle reactivity // Nothing to do — signals handle reactivity
} }
@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: MouseEvent): void {
event.stopPropagation();
this.open.update((v) => !v);
}
onSelectItem(item: string): void { onSelectItem(item: string): void {
this.select.emit(item); this.select.emit(item);
// Legacy compat: also emit the index // Legacy compat: also emit the index

View file

@ -30,7 +30,12 @@ import {
$size: 30px; $size: 30px;
@include center-child(); @include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true); gap: var(--medium-padding);
@media (max-width: $mobile-width) {
width: 100%;
gap: var(--small-padding);
}
.toggle { .toggle {
display: contents; display: contents;
@ -41,7 +46,7 @@ import {
// Fixed width (not max-width) so multiple toggles align column-wise // Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label. // — the thumb position is identical across rows regardless of label.
flex: 0 0 auto; flex: 0 0 auto;
width: 4 * $size; width: var(--toggle-label-width, #{4 * $size});
box-sizing: border-box; box-sizing: border-box;
padding: 0 var(--small-padding); padding: 0 var(--small-padding);
line-height: 1.3; line-height: 1.3;
@ -50,10 +55,19 @@ import {
&.active { font-weight: bold; } &.active { font-weight: bold; }
&:first-of-type { text-align: right; } &:first-of-type { text-align: right; }
&:last-of-type { text-align: left; } &: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 { label {
display: block; display: block;
flex: 0 0 auto;
input[type='checkbox'] { input[type='checkbox'] {
-webkit-appearance: none; -webkit-appearance: none;

View file

@ -17,16 +17,26 @@ import { getColorOfTag } from '../../utils/color';
<div <div
class="container" class="container"
[class.show-hover]="pending().length > 0" [class.show-hover]="pending().length > 0"
(click)="expanded.update(v => !v)" (pointerdown)="$event.stopPropagation()"
(mousedown)="$event.stopPropagation()"
(touchstart)="$event.stopPropagation()"
(pointerup)="toggleExpanded($event)"
(touchend)="toggleExpanded($event)"
(click)="toggleExpanded($event)"
> >
<p class="header"> <p
class="header"
(pointerup)="toggleExpanded($event)"
(touchend)="toggleExpanded($event)"
(click)="toggleExpanded($event)"
>
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong> <strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }} {{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
</p> </p>
<div <div
class="all-task"
#all #all
[style.height.px]="expanded() ? all.scrollHeight : 0" class="all-task"
[style.max-height.px]="expanded() ? all.scrollHeight : 0"
> >
@for (b of pending(); track b.id) { @for (b of pending(); track b.id) {
<div class="task-container"> <div class="task-container">
@ -34,11 +44,15 @@ import { getColorOfTag } from '../../utils/color';
type="button" type="button"
class="tickbox" class="tickbox"
[style.background-color]="colorOf(b.tag)" [style.background-color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); markDone.emit(b)" (click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'" [attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button> ></button>
<p <p
[style.color]="colorOf(b.tag)" [style.color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); edit.emit(b)" (click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p> >{{ b.description || b.tag }}</p>
</div> </div>
@ -82,10 +96,24 @@ import { getColorOfTag } from '../../utils/color';
:first-child { margin-top: var(--small-padding); } :first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box; box-sizing: border-box;
transition: height $long-animation-time; transition: max-height $long-animation-time;
overflow-y: hidden;
/*
* Clip during the open/close animation. We animate to the element's
* exact content height (read off #all in the template) so tall lists
* simply scroll inside the outer .container (max-height: 30vh)
* .all-task itself is NOT a nested scroller. A nested overflow-y:auto
* here pops a scrollbar the instant a tickbox grows on hover, because
* its scale(1.05) + shadow widen the scrollable-overflow box.
*/
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 { .task-container {
display: flex; display: flex;
@ -106,14 +134,16 @@ import { getColorOfTag } from '../../utils/color';
// done without opening the edit carousel. Hover & focus reveal a // done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark. // subtle inner check mark.
.tickbox { .tickbox {
flex: 0 0 auto;
all: unset; // strip native button styles all: unset; // strip native button styles
flex: 0 0 24px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
@include square(24px); @include square(24px);
min-width: 24px;
min-height: 24px;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
@include square(20px); @include square(24px);
} }
border-radius: 4px; border-radius: 4px;
box-shadow: $shadow-border; box-shadow: $shadow-border;
@ -123,14 +153,25 @@ import { getColorOfTag } from '../../utils/color';
content: '✓'; content: '✓';
position: absolute; position: absolute;
inset: 0; 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(); @include center-child();
color: $light-color; color: $light-color;
font-size: 18px; font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
font-weight: bold; opacity: 0; // hidden at rest — only revealed on hover/focus/active
line-height: 1;
opacity: 0.5;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4); text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
transform: translateY(2px); // 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; transition: opacity $short-animation-time, transform $short-animation-time;
} }
@ -142,7 +183,7 @@ import { getColorOfTag } from '../../utils/color';
} }
&:active { &:active {
transform: scale(0.95); transform: scale(0.95);
&::after { opacity: 1; transform: translateY(2px) scale(1.05); } &::after { opacity: 1; transform: translateY(1px) scale(1.05); }
} }
} }
@ -152,10 +193,13 @@ import { getColorOfTag } from '../../utils/color';
overflow-x: hidden; overflow-x: hidden;
text-align: left; text-align: left;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
cursor: pointer; cursor: pointer;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2); font-size: var(--medium-font-size);
font-weight: 500;
filter: saturate(0.85) brightness(0.82);
} }
position: relative; position: relative;
@ -178,6 +222,7 @@ export class TasksComponent {
readonly edit = output<Block>(); readonly edit = output<Block>();
readonly expanded = signal(false); readonly expanded = signal(false);
private lastToggleAt = 0;
constructor() { constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping // Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
@ -193,4 +238,15 @@ export class TasksComponent {
colorOf(tag: string): string { colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor()); return getColorOfTag(tag, this.baseColor());
} }
toggleExpanded(event: Event): void {
event.stopPropagation();
if (event.type === 'touchend') {
event.preventDefault();
}
const now = Date.now();
if (now - this.lastToggleAt < 250) return;
this.lastToggleAt = now;
this.expanded.update((v) => !v);
}
} }

View file

@ -7,6 +7,10 @@ import {
computed, computed,
effect, effect,
untracked, untracked,
AfterViewInit,
OnDestroy,
ElementRef,
viewChild,
} from '@angular/core'; } from '@angular/core';
import { Tower, Block } from '../../models'; import { Tower, Block } from '../../models';
import { BlockComponent } from '../block/block.component'; import { BlockComponent } from '../block/block.component';
@ -26,6 +30,8 @@ interface StyledBlock extends Block {
_opacity: string; _opacity: string;
} }
const BLOCKS_PER_ROW = 6;
@Component({ @Component({
selector: 'lt-tower', selector: 'lt-tower',
standalone: true, standalone: true,
@ -33,51 +39,79 @@ interface StyledBlock extends Block {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="tower"> <div class="tower">
<div class="container" [class.trash-highlight]="trashHighlight()"> <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"
[class.trash-highlight]="trashHighlight()"
>
<lt-tasks <lt-tasks
[pending]="pending()" [pending]="pending()"
[baseColor]="tower().base_color" [baseColor]="tower().base_color"
[initiallyOpen]="keepTasksOpen()" [initiallyOpen]="keepTasksOpen()"
(pointerdown)="$event.stopPropagation()"
(markDone)="onMarkTaskDone($event)" (markDone)="onMarkTaskDone($event)"
(edit)="onEditBlock($event)" (edit)="onEditBlock($event)"
/> />
<img <div
src="assets/plus-sign.svg" #stackZone
class="add-block" class="stack-zone"
alt="Add block" [style.--block-stack-height]="blockStackHeight()"
(click)="$event.stopPropagation(); openAddBlock()" >
/> <img
src="assets/plus-sign.svg"
class="add-block"
alt="Add block"
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation(); openAddBlock()"
/>
<div class="block-container-container"> <div class="block-container-container">
<div class="block-container"> <div class="block-container">
@for (b of visibleBlocks(); track b.id) { @for (b of visibleBlocks(); track b.id) {
<lt-block <lt-block
[block]="b" [block]="b"
[baseColor]="tower().base_color" [baseColor]="tower().base_color"
[class]="b._anim" [class]="b._anim"
[style.transform]="b._transform" [style.transform]="b._transform"
[style.opacity]="b._opacity" [style.opacity]="b._opacity"
(clicked)="onEditBlock(b)" (clicked)="onEditBlock(b)"
/> />
} }
</div>
</div> </div>
</div> </div>
</div> </div>
<input @if (hiddenBlockCount() > 0) {
#nameInput <p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
type="text" }
[value]="tower().name"
[style.color]="towerNameCss()"
(blur)="onRename($event)"
(keydown.enter)="nameInput.blur()"
/>
</div> </div>
@if (editEntry(); as entry) { @if (editEntry(); as entry) {
<lt-modal (close)="closeEdit()"> <lt-modal (close)="closeEdit()">
<lt-block-edit <lt-block-edit
[viewTitle]="editViewTitle()"
[blocks]="filteredForEntry()" [blocks]="filteredForEntry()"
[activeBlockId]="entry.activeId" [activeBlockId]="entry.activeId"
[tags]="towerTags()" [tags]="towerTags()"
@ -92,7 +126,12 @@ interface StyledBlock extends Block {
@if (showSettings()) { @if (showSettings()) {
<lt-modal (close)="showSettings.set(false)"> <lt-modal (close)="showSettings.set(false)">
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" /> <lt-tower-settings
[tower]="tower()"
(save)="onTowerSave($event)"
(delete)="onTowerDelete()"
(close)="showSettings.set(false)"
/>
</lt-modal> </lt-modal>
} }
`, `,
@ -100,7 +139,9 @@ interface StyledBlock extends Block {
@import '../../../library/main'; @import '../../../library/main';
:host { :host {
display: block;
cursor: pointer; cursor: pointer;
min-height: 0;
&.cdk-drag-animating { &.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
@ -140,7 +181,7 @@ interface StyledBlock extends Block {
} }
} }
input { .tower-header {
display: none; display: none;
} }
} }
@ -149,8 +190,10 @@ interface StyledBlock extends Block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
position: relative;
max-width: 100%; max-width: 100%;
height: 100%; height: 100%;
min-height: 0;
@include inner-spacing(var(--small-padding)); @include inner-spacing(var(--small-padding));
@ -158,7 +201,14 @@ interface StyledBlock extends Block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
margin-bottom: 0;
min-height: 0;
position: relative; position: relative;
box-sizing: border-box;
container-type: inline-size;
--block-stack-height: 0px;
--add-block-size: 48px;
--add-block-clearance: var(--medium-padding);
@include card(); @include card();
overflow: hidden; overflow: hidden;
@ -168,7 +218,9 @@ interface StyledBlock extends Block {
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
@include inner-spacing(var(--small-padding)); @include inner-spacing(var(--small-padding));
padding: var(--small-padding); padding: 0;
--add-block-size: 32px;
--add-block-clearance: var(--small-padding);
} }
width: 100%; width: 100%;
@ -189,16 +241,16 @@ interface StyledBlock extends Block {
} }
lt-tasks { lt-tasks {
flex: 0 0 auto; flex: 0 1 auto;
min-height: 56px; min-height: 56px;
max-height: 30vh; max-height: min(30vh, 45%);
overflow: hidden; overflow: auto;
display: block; display: block;
width: 100%; width: 100%;
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
min-height: 44px; min-height: 44px;
max-height: 25vh; max-height: min(25vh, 45%);
} }
.container { .container {
@ -207,80 +259,169 @@ interface StyledBlock extends Block {
} }
} }
img.add-block { .stack-zone {
flex: 0 0 auto;
align-self: center;
margin: var(--medium-padding) 0;
}
img {
position: relative; position: relative;
z-index: 2; flex: 1 1 auto;
height: 48px; min-height: 0;
width: 100%;
@media (max-width: $mobile-width) { img {
height: 32px; 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;
}
} }
opacity: 0.33; img.add-block {
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.block-container-container {
position: relative;
flex: 1;
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
position: absolute; position: absolute;
bottom: 0; z-index: 3;
width: 100%; left: 50%;
transform: scaleY(-1); top: max(
0px,
min(
calc(50% - var(--add-block-size) / 2),
calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
)
);
transform: translateX(-50%);
}
/* Default resting position for all blocks before JS sets them */ .block-container-container {
* { position: absolute;
transform: translateY(500%); inset: 0;
}
.descend { .block-container {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), display: flex;
opacity 500ms cubic-bezier(0.5, 0, 1, 0); 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);
.ascend { /* Default resting position for all blocks before JS sets them */
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), * {
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s; 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;
}
} }
} }
} }
} }
input[type='text'] { .more-blocks {
font-size: var(--small-font-size); @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; text-align: center;
pointer-events: none;
user-select: none;
}
@media (min-width: $mobile-width) { .tower-header {
width: 50%; 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);
}
} }
@media (max-width: $mobile-width) { .edit-tower {
width: 100%; 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 { export class TowerComponent implements AfterViewInit, OnDestroy {
// ── Inputs ───────────────────────────────────────────────────────────────── // ── Inputs ─────────────────────────────────────────────────────────────────
readonly tower = input.required<Tower>(); readonly tower = input.required<Tower>();
/** Set by page component when this tower is being dragged over trash. */ /** Set by page component when this tower is being dragged over trash. */
@ -308,6 +449,11 @@ export class TowerComponent {
* which list of blocks to show and which one to focus initially. */ * which list of blocks to show and which one to focus initially. */
readonly editEntry = signal<EditEntry | null>(null); readonly editEntry = signal<EditEntry | null>(null);
readonly showSettings = signal(false); readonly showSettings = signal(false);
readonly hiddenBlockCount = signal(0);
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
private readonly maxVisibleBlocks = signal<number | null>(null);
private resizeObserver: ResizeObserver | null = null;
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */ /** Pending (not-done) blocks — fed to the tasks accordion. */
@ -324,6 +470,13 @@ export class TowerComponent {
return this.tower().blocks.filter(b => b.is_done === isDone); 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. */ /** Unique tags from existing blocks of this tower. */
readonly towerTags = computed(() => { readonly towerTags = computed(() => {
const set = new Set<string>(); const set = new Set<string>();
@ -339,6 +492,11 @@ export class TowerComponent {
private readonly _visibleBlocks = signal<StyledBlock[]>([]); private readonly _visibleBlocks = signal<StyledBlock[]>([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly(); readonly visibleBlocks = this._visibleBlocks.asReadonly();
readonly blockStackHeight = computed(() => {
const rows = Math.ceil(this.visibleBlocks().length / BLOCKS_PER_ROW);
const cqw = rows * (100 / BLOCKS_PER_ROW);
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
});
private prevDoneIds: string[] = []; private prevDoneIds: string[] = [];
private isFirstRun = true; private isFirstRun = true;
@ -346,15 +504,69 @@ export class TowerComponent {
constructor() { constructor() {
effect(() => { effect(() => {
const range = this.dateRange(); const range = this.dateRange();
// Always keep ALL done blocks in the visible list. The date filter const maxVisibleBlocks = this.maxVisibleBlocks();
// only flips per-block `_anim` so the 1.5s ascend/descend transition // Reconcile all done blocks, then cap the rendered stack to the rows
// can animate them in and out of the falling stack. // that fit below the tasks and add button.
const allDone = this.tower().blocks.filter((b) => b.is_done); const allDone = this.tower().blocks.filter((b) => b.is_done);
untracked(() => this.reconcile(allDone, range)); untracked(() => this.reconcile(allDone, range, maxVisibleBlocks));
}); });
} }
private reconcile(allDone: Block[], range: { from: number; to: number } | null): void { 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.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);
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 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,
): void {
const ids = allDone.map((b) => b.id); const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds; const prev = this.prevDoneIds;
const prevSet = new Set(prev); const prevSet = new Set(prev);
@ -393,16 +605,39 @@ export class TowerComponent {
}); });
} }
const visibleLimit =
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
const restingBlocks = styled.filter((b) => b._opacity === '1');
const hiddenCount = Number.isFinite(visibleLimit)
? Math.max(0, restingBlocks.length - visibleLimit)
: 0;
let visibleStyled = styled;
if (Number.isFinite(visibleLimit)) {
const shownRestingBlocks =
hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks;
const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length);
const transitioningBlocks =
remainingSlots > 0
? styled.filter((b) => b._opacity !== '1').slice(-remainingSlots)
: [];
const shownIds = new Set([
...shownRestingBlocks.map((b) => b.id),
...transitioningBlocks.map((b) => b.id),
]);
visibleStyled = styled.filter((b) => shownIds.has(b.id));
}
this.hiddenBlockCount.set(hiddenCount);
if (grewByOne) { if (grewByOne) {
const newId = newIds[0]; const newId = newIds[0];
const newBlock = styled.find(b => b.id === newId); const newBlock = visibleStyled.find(b => b.id === newId);
if (newBlock && inRange(newBlock)) { if (newBlock && inRange(newBlock)) {
// Snap newly-added in-range block to start position, then on the next // 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. // paint flip it back to rest — that's what makes it visibly fall.
newBlock._anim = ''; newBlock._anim = '';
newBlock._transform = 'translateY(500%)'; newBlock._transform = 'translateY(500%)';
newBlock._opacity = '0'; newBlock._opacity = '0';
this._visibleBlocks.set(styled); this._visibleBlocks.set(visibleStyled);
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
newBlock._anim = 'descend'; newBlock._anim = 'descend';
@ -417,7 +652,7 @@ export class TowerComponent {
} }
} }
// existing fall-through path (no growth, first run, or new block out of range): // existing fall-through path (no growth, first run, or new block out of range):
this._visibleBlocks.set(styled); this._visibleBlocks.set(visibleStyled);
this.prevDoneIds = ids; this.prevDoneIds = ids;
this.isFirstRun = false; this.isFirstRun = false;
@ -472,7 +707,8 @@ export class TowerComponent {
} }
onTowerSave(result: TowerSettingsResult): void { onTowerSave(result: TowerSettingsResult): void {
this.showSettings.set(false); // 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); this.updateTower.emit(result);
} }

View file

@ -7,7 +7,7 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="welcome-card"> <div class="welcome-card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button> <button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Welcome to Life Towers</h2> <h2>Welcome to Life Towers</h2>
@ -60,6 +60,11 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
h2 { h2 {
text-align: center; text-align: center;
margin-bottom: var(--medium-padding); margin-bottom: var(--medium-padding);
padding: 0 36px;
@media (max-width: $mobile-width) {
padding: 0 28px;
}
} }
p.lead { color: $text-color; } p.lead { color: $text-color; }
@ -85,6 +90,10 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
border-bottom-color: rgba($accent-color, 0.33); border-bottom-color: rgba($accent-color, 0.33);
&:after { background-color: $accent-color; } &:after { background-color: $accent-color; }
} }
@media (max-width: $mobile-width) {
gap: var(--small-padding);
}
} }
} }
`, `,

View file

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

View file

@ -1,5 +1,6 @@
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core'; import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { AnalyticsService } from './analytics.service';
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models'; import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
const TOKEN_KEY = 'life-towers.token.v4'; const TOKEN_KEY = 'life-towers.token.v4';
@ -55,9 +56,24 @@ interface PendingPut {
tree: TreeDto; tree: TreeDto;
} }
interface ExampleBlockSeed {
tag: string;
desc: string;
done: boolean;
ageHrs: number;
}
interface ExampleDonePattern {
tag: string;
desc: (sequence: number) => string;
}
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 120;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class StoreService implements OnDestroy { export class StoreService implements OnDestroy {
private readonly api = inject(ApiService); private readonly api = inject(ApiService);
private readonly analytics = inject(AnalyticsService);
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
private readonly _pages = signal<Page[]>([]); private readonly _pages = signal<Page[]>([]);
@ -226,6 +242,8 @@ export class StoreService implements OnDestroy {
towers: [], towers: [],
}; };
this._pages.update((pages) => [...pages, page]); this._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackPageCreated();
this.scheduleSave(); this.scheduleSave();
} }
@ -256,6 +274,8 @@ export class StoreService implements OnDestroy {
this._pages.update((pages) => this._pages.update((pages) =>
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)), pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
); );
this.analytics.trackStart();
this.analytics.trackTowerCreated();
this.scheduleSave(); this.scheduleSave();
} }
@ -318,6 +338,8 @@ export class StoreService implements OnDestroy {
: p, : p,
), ),
); );
this.analytics.trackStart();
this.analytics.trackBlockCreated({ isDone: is_done });
this.scheduleSave(); this.scheduleSave();
} }
@ -336,7 +358,7 @@ export class StoreService implements OnDestroy {
t.id === towerId t.id === towerId
? { ? {
...t, ...t,
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)), blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
} }
: t, : t,
), ),
@ -366,6 +388,7 @@ export class StoreService implements OnDestroy {
} }
toggleBlock(pageId: string, towerId: string, blockId: string): void { toggleBlock(pageId: string, towerId: string, blockId: string): void {
let becameDone = false;
this._pages.update((pages) => this._pages.update((pages) =>
pages.map((p) => pages.map((p) =>
p.id === pageId p.id === pageId
@ -373,21 +396,58 @@ export class StoreService implements OnDestroy {
...p, ...p,
towers: p.towers.map((t) => towers: p.towers.map((t) =>
t.id === towerId t.id === towerId
? { ? (() => {
...t, const block = t.blocks.find((b) => b.id === blockId);
blocks: t.blocks.map((b) => if (!block) return t;
b.id === blockId ? { ...b, is_done: !b.is_done } : b, const result = this.patchBlockList(t.blocks, blockId, {
), is_done: !block.is_done,
} });
becameDone = result.becameDone;
return { ...t, blocks: result.blocks };
})()
: t, : t,
), ),
} }
: p, : p,
), ),
); );
this.analytics.trackStart();
if (becameDone) this.analytics.trackBlockCompleted();
this.scheduleSave(); 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 * Switch to a different user's token. Any pending writes for the OLD
* account must be cancelled first otherwise a queued PUT could fire * account must be cancelled first otherwise a queued PUT could fire
@ -529,30 +589,56 @@ export class StoreService implements OnDestroy {
default_date_from: null, default_date_from: null,
default_date_to: null, default_date_to: null,
towers: [ 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, [ this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
{ tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 }, { tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 },
{ tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 }, ...this.makeExampleDoneBlocks(
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 }, [
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 }, { tag: 'novel', desc: (n) => `Read chapter ${n}` },
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 }, { 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, [ this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
{ tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 }, { tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 },
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 }, { tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 }, ...this.makeExampleDoneBlocks(
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 }, [
{ 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, [ this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 }, { tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 }, { tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 }, ...this.makeExampleDoneBlocks(
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 }, [
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 }, { 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._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackExampleLoaded();
this.scheduleSave(); this.scheduleSave();
} }
@ -560,7 +646,7 @@ export class StoreService implements OnDestroy {
name: string, name: string,
base_color: HslColor, base_color: HslColor,
nowSec: number, nowSec: number,
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>, blocks: ExampleBlockSeed[],
): Tower { ): Tower {
return { return {
id: uuidV4(), id: uuidV4(),
@ -576,6 +662,23 @@ export class StoreService implements OnDestroy {
}; };
} }
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,
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
};
});
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.cancelPendingWrites(); this.cancelPendingWrites();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {

View file

@ -218,6 +218,87 @@ describe('StoreService', () => {
expect(api.getData).toHaveBeenCalledTimes(1); 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,
created_at: 100,
},
{
id: 'newer-pending',
tag: 'b',
description: 'Still pending',
is_done: false,
created_at: 300,
},
{
id: 'existing-done',
tag: 'c',
description: 'Already done',
is_done: true,
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 hundreds of completed squares', () => {
const api = makeMockApi();
const store = configure(api);
store.loadExample();
const [page] = store.pages();
expect(page.name).toBe('Hobbies');
expect(page.towers).toHaveLength(3);
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
expect(doneBlocks.length).toBeGreaterThanOrEqual(300);
for (const tower of page.towers) {
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
expect(doneDates.length).toBeGreaterThanOrEqual(100);
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
}
store.ngOnDestroy();
});
// ── Debounced save ───────────────────────────────────────────────────────── // ── Debounced save ─────────────────────────────────────────────────────────
it('debounces saves: multiple mutations within 750ms → one PUT', async () => { it('debounces saves: multiple mutations within 750ms → one PUT', async () => {

View file

@ -13,11 +13,24 @@
<link rel="apple-touch-icon" href="apple-touch-icon.png" /> <link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="manifest" href="manifest.webmanifest" /> <link rel="manifest" href="manifest.webmanifest" />
<link rel="canonical" href="/" data-dynamic-url="canonical" />
<meta property="og:title" content="Life Towers" /> <meta property="og:title" content="Life Towers" />
<meta property="og:description" content="Organise your tasks into visual towers of blocks." /> <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:type" content="website" />
<meta property="og:image" content="og-image.png" /> <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: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> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>

View file

@ -38,6 +38,10 @@ input[type='text'] {
&:focus { &:focus {
box-shadow: 0 1px $text-color; box-shadow: 0 1px $text-color;
} }
&:focus-visible {
box-shadow: 0 2px $text-color;
}
} }
button { button {
@ -56,6 +60,19 @@ button {
border-bottom: solid $height #5d576b55; border-bottom: solid $height #5d576b55;
position: relative; position: relative;
// Mobile: grow buttons to a ~42px tap target, but pin the label to the bottom
// so the bottom-border underline keeps hugging it (as on desktop). A bare
// min-height vertically centres the text and strands the underline well below
// it. Using flex (not a hit-area pseudo-element) means buttons that `all: unset`
// their styling .tickbox, .swatch, .edit-tower reset these props and opt out
// automatically, instead of inheriting a stray absolute overlay.
@media (max-width: $mobile-width) {
min-height: 42px;
display: inline-flex;
align-items: flex-end;
justify-content: center;
}
&:disabled { &:disabled {
color: #5d576b55; color: #5d576b55;
border-bottom: solid $height #5d576b33; border-bottom: solid $height #5d576b33;

View file

@ -34,12 +34,31 @@
@mixin exit { @mixin exit {
@include square(16px); @include square(16px);
background: url('/assets/x-sign.svg') no-repeat center center; -webkit-appearance: none;
background-size: 50% 50%; appearance: none;
background: transparent url('/assets/x-sign.svg') no-repeat center center / 50% 50%;
border: 0 !important;
border-bottom: 0 !important;
box-shadow: none;
box-sizing: content-box; box-sizing: content-box;
color: transparent;
display: block;
flex: 0 0 auto;
font-size: 0;
line-height: 0;
margin: 0;
min-height: 0;
overflow: hidden;
padding: 8px; padding: 8px;
text-decoration: none !important;
@include jump(); @include jump();
&:before,
&:after {
content: none !important;
display: none;
}
} }
img { img {

View file

@ -6,4 +6,22 @@
margin-bottom: $spacing; margin-bottom: $spacing;
} }
} }
& > lt-modal {
@if $horizontal {
margin-right: 0 !important;
} @else {
margin-bottom: 0 !important;
}
}
// Fixed modal hosts should not make the previous content child count as
// "not last" for spacing.
& > *:not(:last-child):not(lt-modal):not(:has(~ *:not(lt-modal))) {
@if $horizontal {
margin-right: 0;
} @else {
margin-bottom: 0;
}
}
} }

View file

@ -1,6 +1,14 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
// The package ships only a `module` field, which Vite's resolver
// can't always find — point it at the file directly.
'@plausible-analytics/tracker':
'@plausible-analytics/tracker/plausible.js',
},
},
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',