diff --git a/CLAUDE.md b/CLAUDE.md index 9c696f9..0fddce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,7 @@ The app is deployed under a path: `https://schmelczer.dev/towers/`. The mechanis proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } ``` +- **SSE (`/api/v1/events`) through nginx**: the endpoint sends `X-Accel-Buffering: no`, which nginx honors to disable response buffering for that stream — so the block above works without a dedicated location. The 20s server-side keepalive comment stays under nginx's default 60s `proxy_read_timeout`, so idle streams don't get culled. If you ever lower `proxy_read_timeout` below ~20s, add `proxy_buffering off;` + a larger read timeout on a `location /towers/api/v1/events`. - **Dev/e2e stay at the root**: `docker-compose.dev.yml` builds with the default `BASE_HREF=/`, and Playwright hits `http://life-towers:8000/`. The relative API paths work identically there, so e2e exercises the same code. ## Build / dev / test / deploy @@ -174,13 +175,22 @@ Font sizes (`library/text.scss`): `--larger/large/medium/small-font-size` = `22/ - **No granular endpoints** for individual entity CRUD. Keep it tree-replace. - Spec is in `docs/api-spec.md`. Backend pydantic models match it. Frontend `models/index.ts` matches it. Field names are **snake_case** on both sides. +### Multi-client sync (revision + CAS + SSE) + +Multiple clients can share one token (same user on phone + laptop). Three pieces keep them in step — see `backend/src/life_towers/events.py`, `api.py`, and `frontend/src/app/{services,utils}`: + +- **Per-user `revision`** (`users.revision`, migration `002_revision.sql`): a monotonic counter bumped inside the same `BEGIN IMMEDIATE` as every PUT. `GET /data` returns it; `PUT /data` returns the new one (`{ "revision": N }`, status 200 — **not** 204 anymore). +- **Compare-and-swap**: the client sends its base revision as the `If-Match` header on PUT. If the stored revision moved underneath it, the server rolls back and returns **409** with `{ "error": "conflict", "revision": }`. Absent `If-Match` ⇒ unguarded write (keeps older cached clients working across a deploy). On 409 the store resolves **server-wins**: it refetches the current server tree and adopts it, discarding this device's un-pushed edit (`store.service.adoptServerData`). The CAS still prevents a stale write from clobbering the other device's data; there is deliberately **no client-side merge** — a conflicting in-flight edit on the losing device is dropped, which is acceptable for a single user across a few devices. (An earlier `utils/sync-merge.ts` 3-way merge was removed in favour of this simpler policy.) +- **SSE push** (`GET /api/v1/events`, `text/event-stream`): an in-process asyncio pub/sub (`events.py`, single worker — see Dockerfile) pushes the new revision to every live connection right after a PUT commits. The client consumes it with **fetch()+ReadableStream**, not `EventSource`, so the Bearer token rides in a header instead of the URL (`api.service.openEventStream` + `utils/sse.ts` parser). On a newer-revision event the store refetches **only when clean**; if it has pending edits it lets the next PUT's CAS resolve it (server-wins). The stream auto-reconnects with backoff; `switchToken`/`ngOnDestroy` tear it down. +- **Gotcha**: Starlette's `BaseHTTPMiddleware` is fine with streaming responses, but **httpx's in-memory `ASGITransport` is not** — it can't open a long-lived stream with concurrent requests, so SSE wire behaviour is verified against real uvicorn, not in pytest. Pytest covers the pub/sub bus + that a PUT publishes onto it; `tests/test_api.py` has the pattern. + ### Backend - All endpoints are inside `APIRouter(prefix="/api/v1")`. Spec drives behavior — if you change a limit, update both spec and code. - Migrations: package data under `src/life_towers/migrations/`, loaded via `importlib.resources.files("life_towers").joinpath("migrations")`. The runner tracks applied state in a `schema_migrations(filename TEXT PRIMARY KEY, applied_at INTEGER)` table. - All sqlite connections must do `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000`. The `get_connection()` factory does this. - Errors → JSON `{"error": code, "detail": str}` via a single `HTTPException` handler in `main.py`. Stack traces never leak (logged server-side). -- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token. +- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token. `GET /events` (SSE) is intentionally **un**-limited — it's one long-lived connection per client. ## Visual + interaction details that bit me diff --git a/backend/src/life_towers/api.py b/backend/src/life_towers/api.py index 8fbcc7f..6cc7e04 100644 --- a/backend/src/life_towers/api.py +++ b/backend/src/life_towers/api.py @@ -1,12 +1,16 @@ from __future__ import annotations +import asyncio +import json import sqlite3 import time from typing import Annotated import structlog -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi.responses import StreamingResponse +from . import events from .auth import get_current_user from .db import db_connection from .limits import limiter @@ -18,6 +22,7 @@ from .models import ( HealthResponse, HslColor, PageOut, + PutDataResponse, RegisterRequest, RegisterResponse, TowerOut, @@ -26,6 +31,34 @@ from .models import ( router = APIRouter(prefix="/api/v1") logger = structlog.get_logger(__name__) +# How long the SSE loop waits for a new revision before emitting a keepalive +# comment. Keeps the connection warm through proxies and bounds disconnect +# detection latency. +SSE_KEEPALIVE_SECONDS = 20 + + +def _read_revision(conn: sqlite3.Connection, user_id: str) -> int: + row = conn.execute( + "SELECT revision FROM users WHERE id = ?", (user_id,) + ).fetchone() + return int(row["revision"]) if row is not None else 0 + + +def _parse_if_match(value: str | None) -> int | None: + """Parse the client's CAS base revision from the If-Match header. + + Tolerates an ETag-style quoted form (``"5"``). Returns None when the header + is absent or unparseable, in which case the write proceeds unguarded + (last-writer-wins) — this keeps older cached clients working across a deploy. + """ + if value is None: + return None + cleaned = value.strip().strip('"') + try: + return int(cleaned) + except ValueError: + return None + @router.get("/health", response_model=HealthResponse) async def health() -> HealthResponse: @@ -63,6 +96,8 @@ async def get_data( user_id: Annotated[str, Depends(get_current_user)], ) -> DataOut: with db_connection() as conn: + revision = _read_revision(conn, user_id) + pages_rows = conn.execute( """ SELECT id, name, hide_create_tower_button, keep_tasks_open, default_date_from, default_date_to @@ -140,24 +175,44 @@ async def get_data( ) ) - return DataOut(pages=pages_out) + return DataOut(pages=pages_out, revision=revision) -@router.put("/data", status_code=204) +@router.put("/data", response_model=PutDataResponse) @limiter.limit("30/minute") async def put_data( request: Request, body: DataIn, user_id: Annotated[str, Depends(get_current_user)], -) -> None: + if_match: Annotated[str | None, Header(alias="If-Match")] = None, +) -> PutDataResponse: # Tree-replace semantics mean the request size IS the user's total storage, # so the 2 MiB request cap (enforced by payload_size_middleware) is the only # quota we need. now = int(time.time()) + base_revision = _parse_if_match(if_match) with db_connection() as conn: conn.execute("BEGIN IMMEDIATE") try: + current_revision = _read_revision(conn, user_id) + + # Compare-and-swap: reject if another client advanced the revision + # under us. The client refetches, merges, and retries with the + # fresh base. An absent If-Match opts out of the guard. + if base_revision is not None and base_revision != current_revision: + conn.rollback() + raise HTTPException( + status_code=409, + detail={ + "error": "conflict", + "detail": "Revision is stale; refetch and retry", + "revision": current_revision, + }, + ) + + new_revision = current_revision + 1 + # Delete existing data for this user (cascades to towers + blocks) conn.execute("DELETE FROM pages WHERE user_id = ?", (user_id,)) @@ -231,13 +286,16 @@ async def put_data( ), ) - # Update last_seen_at + # Bump the revision and refresh last_seen_at in the same transaction. conn.execute( - "UPDATE users SET last_seen_at = ? WHERE id = ?", - (now, user_id), + "UPDATE users SET last_seen_at = ?, revision = ? WHERE id = ?", + (now, new_revision, user_id), ) conn.commit() + except HTTPException: + # Already rolled back (e.g. the CAS 409); just propagate. + raise except sqlite3.IntegrityError as exc: conn.rollback() raise HTTPException( @@ -248,4 +306,66 @@ async def put_data( conn.rollback() raise - logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages)) + # Notify other live clients for this user to refetch. Done after commit so + # subscribers never observe a revision that isn't durably stored yet. + events.publish(user_id, new_revision) + + logger.info( + "data_replaced", + user_id=token_log_id(user_id), + pages=len(body.pages), + revision=new_revision, + ) + return PutDataResponse(revision=new_revision) + + +def _format_revision_event(revision: int) -> str: + return f"event: revision\ndata: {json.dumps({'revision': revision})}\n\n" + + +@router.get("/events") +async def events_stream( + request: Request, + user_id: Annotated[str, Depends(get_current_user)], +) -> StreamingResponse: + """Server-Sent Events stream that notifies a client to refetch. + + Authenticated with the same Bearer token as the rest of the API — clients + consume it via fetch()+ReadableStream (EventSource cannot set headers), so + the token never leaks into a URL. Each event carries the current revision; + the client refetches GET /data when it sees a revision newer than its own. + """ + queue = events.subscribe(user_id) + + async def event_generator(): + try: + # Emit the current revision immediately so a (re)connecting client + # can catch up on anything it missed while disconnected. + with db_connection() as conn: + yield _format_revision_event(_read_revision(conn, user_id)) + + while True: + try: + revision = await asyncio.wait_for( + queue.get(), timeout=SSE_KEEPALIVE_SECONDS + ) + yield _format_revision_event(revision) + except asyncio.TimeoutError: + if await request.is_disconnected(): + break + # Comment line: keeps proxies from idling the connection out. + yield ": keepalive\n\n" + finally: + events.unsubscribe(user_id, queue) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + # Disable nginx response buffering for this stream even if the + # location block forgets `proxy_buffering off`. + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/src/life_towers/events.py b/backend/src/life_towers/events.py new file mode 100644 index 0000000..d6aa06a --- /dev/null +++ b/backend/src/life_towers/events.py @@ -0,0 +1,77 @@ +"""In-process pub/sub for Server-Sent Events. + +Single-worker deployment (see Dockerfile: uvicorn with no ``--workers``), so a +plain in-memory registry is enough — there is one event loop and every +subscriber queue lives on it. If this ever grows to multiple workers, this +module is the seam to swap for a cross-process bus (Redis pub/sub, LISTEN/NOTIFY, +or polling the ``users.revision`` column). + +Each subscriber gets a 1-slot queue that always holds the *latest* revision to +notify. Coalescing is intentional: the SSE payload is just "something changed, +current revision is N", so a burst of writes collapses to a single refetch +signal rather than a backlog. +""" + +from __future__ import annotations + +import asyncio + +import structlog + +logger = structlog.get_logger(__name__) + +# user_id -> set of per-connection queues. Each queue carries the most recent +# revision the connection still needs to flush to its client. +_subscribers: dict[str, set[asyncio.Queue[int]]] = {} + + +def subscribe(user_id: str) -> asyncio.Queue[int]: + """Register a new SSE connection for ``user_id`` and return its queue. + + Must be called from the event loop (the SSE route handler is async), so the + queue binds to the running loop. + """ + queue: asyncio.Queue[int] = asyncio.Queue(maxsize=1) + _subscribers.setdefault(user_id, set()).add(queue) + return queue + + +def unsubscribe(user_id: str, queue: asyncio.Queue[int]) -> None: + """Drop a connection's queue; prune the user entry when the last one goes.""" + subs = _subscribers.get(user_id) + if subs is None: + return + subs.discard(queue) + if not subs: + _subscribers.pop(user_id, None) + + +def publish(user_id: str, revision: int) -> None: + """Notify every live connection for ``user_id`` of the new revision. + + Called from the (single) event loop right after a PUT commits, so the + ``put_nowait`` calls are loop-safe. Coalesces into each 1-slot queue: if a + connection has not yet drained its previous signal, replace it with the + newer revision rather than block or grow unbounded. + """ + subs = _subscribers.get(user_id) + if not subs: + return + for queue in subs: + try: + queue.put_nowait(revision) + except asyncio.QueueFull: + # Connection is behind — drop the stale value and keep the newest. + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + queue.put_nowait(revision) + except asyncio.QueueFull: + pass + + +def connection_count(user_id: str) -> int: + """Number of live SSE connections for a user (used in tests).""" + return len(_subscribers.get(user_id, ())) diff --git a/backend/src/life_towers/migrations/002_revision.sql b/backend/src/life_towers/migrations/002_revision.sql new file mode 100644 index 0000000..aaba6e9 --- /dev/null +++ b/backend/src/life_towers/migrations/002_revision.sql @@ -0,0 +1,7 @@ +-- Per-user revision counter for multi-client sync. +-- Bumped inside the same transaction as every PUT /data so clients can: +-- * detect another client's write (cheap compare, no full-tree diff), and +-- * guard their own writes with compare-and-swap (stale If-Match -> 409). +-- Existing users start at 0; their next confirmed GET seeds the client base. + +ALTER TABLE users ADD COLUMN revision INTEGER NOT NULL DEFAULT 0; diff --git a/backend/src/life_towers/models.py b/backend/src/life_towers/models.py index b40d5e3..1d78a8b 100644 --- a/backend/src/life_towers/models.py +++ b/backend/src/life_towers/models.py @@ -127,6 +127,14 @@ class DataIn(BaseModel): class DataOut(BaseModel): pages: list[PageOut] + # Monotonic per-user version, bumped on every successful PUT. Clients keep + # it as their compare-and-swap base and to detect another client's writes. + revision: int + + +class PutDataResponse(BaseModel): + # The new revision after this write; the client adopts it as its CAS base. + revision: int class RegisterRequest(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a4f8538..4080f5c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import uuid from pathlib import Path from typing import AsyncGenerator @@ -198,7 +199,7 @@ async def test_get_data_empty_user(client: AsyncClient) -> None: headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 - assert resp.json() == {"pages": []} + assert resp.json() == {"pages": [], "revision": 0} # --------------------------------------------------------------------------- @@ -253,12 +254,14 @@ async def test_round_trip(client: AsyncClient) -> None: tree = _make_tree() put_resp = await client.put("/api/v1/data", json=tree, headers=headers) - assert put_resp.status_code == 204 + assert put_resp.status_code == 200 + assert put_resp.json() == {"revision": 1} get_resp = await client.get("/api/v1/data", headers=headers) assert get_resp.status_code == 200 data = get_resp.json() + assert data["revision"] == 1 assert len(data["pages"]) == 2 for pi, page in enumerate(data["pages"]): assert page["id"] == tree["pages"][pi]["id"] @@ -289,7 +292,7 @@ async def test_difficulty_defaults_to_one_when_omitted(client: AsyncClient) -> N del tree["pages"][0]["towers"][0]["blocks"][0]["difficulty"] put_resp = await client.put("/api/v1/data", json=tree, headers=headers) - assert put_resp.status_code == 204 + assert put_resp.status_code == 200 data = (await client.get("/api/v1/data", headers=headers)).json() assert data["pages"][0]["towers"][0]["blocks"][0]["difficulty"] == 1 @@ -342,7 +345,7 @@ async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> No json=tree, headers={"Authorization": f"Bearer {first_token}"}, ) - assert first_resp.status_code == 204 + assert first_resp.status_code == 200 second_resp = await client.put( "/api/v1/data", @@ -455,3 +458,195 @@ async def test_register_rate_limit(client: AsyncClient) -> None: resp = await client.post("/api/v1/register", json={"token": make_uuidv4()}) responses.append(resp.status_code) assert responses[-1] == 429, f"Expected 429 on 31st request, got: {responses[-3:]}" + + +# --------------------------------------------------------------------------- +# Revision counter + compare-and-swap (multi-client sync) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_revision_increments_on_each_put(client: AsyncClient) -> None: + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + # Fresh user starts at revision 0. + assert (await client.get("/api/v1/data", headers=headers)).json()["revision"] == 0 + + r1 = await client.put("/api/v1/data", json=_make_tree(), headers=headers) + assert r1.json() == {"revision": 1} + r2 = await client.put("/api/v1/data", json=_make_tree(), headers=headers) + assert r2.json() == {"revision": 2} + + assert (await client.get("/api/v1/data", headers=headers)).json()["revision"] == 2 + + +@pytest.mark.asyncio +async def test_stale_if_match_returns_409_with_current_revision( + client: AsyncClient, +) -> None: + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + # Base 0 matches a fresh user -> succeeds, revision becomes 1. + ok = await client.put( + "/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "0"} + ) + assert ok.status_code == 200 + assert ok.json() == {"revision": 1} + + # Re-using the now-stale base 0 is rejected; the body carries the truth. + stale = await client.put( + "/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "0"} + ) + assert stale.status_code == 409 + body = stale.json() + assert body["error"] == "conflict" + assert body["revision"] == 1 + + # The conflicting write must NOT have advanced the revision. + assert (await client.get("/api/v1/data", headers=headers)).json()["revision"] == 1 + + # Retrying with the fresh base succeeds. + retry = await client.put( + "/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "1"} + ) + assert retry.status_code == 200 + assert retry.json() == {"revision": 2} + + +@pytest.mark.asyncio +async def test_put_without_if_match_skips_the_guard(client: AsyncClient) -> None: + """Absent If-Match keeps older cached clients writing (last-writer-wins).""" + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + # Advance to revision 2 first. + await client.put("/api/v1/data", json=_make_tree(), headers=headers) + await client.put("/api/v1/data", json=_make_tree(), headers=headers) + + # No If-Match -> write goes through regardless of current revision. + resp = await client.put("/api/v1/data", json=_make_tree(), headers=headers) + assert resp.status_code == 200 + assert resp.json() == {"revision": 3} + + +# --------------------------------------------------------------------------- +# Server-Sent Events stream (notify-to-refetch) +# +# The full SSE wire behaviour (incremental flush + live push) is exercised +# end-to-end against real uvicorn — httpx's in-memory ASGITransport can't model +# a long-lived streaming response with concurrent requests, so here we cover the +# two seams it CAN reach reliably: the in-process pub/sub bus, and the fact that +# a PUT publishes the new revision onto that bus (which the stream then drains). +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_event_bus_subscribe_publish_unsubscribe() -> None: + from life_towers import events + + user = make_uuidv4() + queue = events.subscribe(user) + assert events.connection_count(user) == 1 + + events.publish(user, 5) + assert await asyncio.wait_for(queue.get(), 1) == 5 + + events.unsubscribe(user, queue) + assert events.connection_count(user) == 0 + + +@pytest.mark.asyncio +async def test_event_bus_coalesces_to_latest_revision() -> None: + """A backed-up connection should see only the newest revision, not a queue.""" + from life_towers import events + + user = make_uuidv4() + queue = events.subscribe(user) + events.publish(user, 1) + events.publish(user, 2) + events.publish(user, 3) + + assert await asyncio.wait_for(queue.get(), 1) == 3 + assert queue.empty() + events.unsubscribe(user, queue) + + +@pytest.mark.asyncio +async def test_event_bus_fans_out_to_all_connections() -> None: + from life_towers import events + + user = make_uuidv4() + q1 = events.subscribe(user) + q2 = events.subscribe(user) + assert events.connection_count(user) == 2 + + events.publish(user, 7) + assert await asyncio.wait_for(q1.get(), 1) == 7 + assert await asyncio.wait_for(q2.get(), 1) == 7 + + events.unsubscribe(user, q1) + events.unsubscribe(user, q2) + assert events.connection_count(user) == 0 + + +@pytest.mark.asyncio +async def test_event_bus_publish_without_subscribers_is_noop() -> None: + from life_towers import events + + events.publish(make_uuidv4(), 99) # must not raise + + +@pytest.mark.asyncio +async def test_put_publishes_new_revision_to_subscribers(client: AsyncClient) -> None: + """The integration seam: a real PUT must notify a live SSE subscriber.""" + from life_towers import events + + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + queue = events.subscribe(token) + try: + await client.put("/api/v1/data", json=_make_tree(), headers=headers) + assert await asyncio.wait_for(queue.get(), 2) == 1 + + await client.put("/api/v1/data", json=_make_tree(), headers=headers) + assert await asyncio.wait_for(queue.get(), 2) == 2 + finally: + events.unsubscribe(token, queue) + + +@pytest.mark.asyncio +async def test_rejected_put_does_not_publish(client: AsyncClient) -> None: + """A CAS-rejected (409) write must not emit a spurious refetch signal.""" + from life_towers import events + + token = make_uuidv4() + await client.post("/api/v1/register", json={"token": token}) + headers = {"Authorization": f"Bearer {token}"} + + # Advance to revision 1. + await client.put("/api/v1/data", json=_make_tree(), headers=headers) + + queue = events.subscribe(token) + try: + # Stale base -> 409, must not publish. + stale = await client.put( + "/api/v1/data", json=_make_tree(), headers={**headers, "If-Match": "0"} + ) + assert stale.status_code == 409 + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(queue.get(), 0.2) + finally: + events.unsubscribe(token, queue) + + +@pytest.mark.asyncio +async def test_sse_requires_auth(client: AsyncClient) -> None: + # No Bearer token: auth fails before any streaming starts, so a plain GET + # returns 401 without holding the connection open. + resp = await client.get("/api/v1/events") + assert resp.status_code == 401 diff --git a/frontend/e2e/tasks-overflow.spec.ts b/frontend/e2e/tasks-overflow.spec.ts new file mode 100644 index 0000000..8f42805 --- /dev/null +++ b/frontend/e2e/tasks-overflow.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; + +/** + * Regression guard for the tasks accordion ("todos") double-scrollbar bug. + * + * When the pending-task list is tall it must show EXACTLY ONE scrollbar (inside + * the white card), not two. The cause was two nested scroll containers both + * firing: the `lt-tasks` host (overflow:auto) AND the inner `.container` card + * (overflow-y:auto). The fix makes the host a height-bounding flex column that + * only CLIPS, leaving the inner card as the sole scroller. + * + * Seeds many pending tasks via a direct PUT (far more robust than driving the + * carousel ~18 times), then reloads so the store renders the seeded tree. All + * ids are generated here in Node — `crypto.randomUUID()` throws in the page + * because the dev origin is plain HTTP (not a secure context). + */ +test('tasks accordion with many tasks shows a single scrollbar', async ({ page }) => { + await page.goto('/'); + + // init() mints + registers a token on load; wait for it to land. + await page.waitForFunction(() => !!localStorage.getItem('life-towers.token.v4'), null, { + timeout: 15000, + }); + + // Build a tree: one page, one tower, many PENDING (is_done:false) tasks. + const now = Math.floor(Date.now() / 1000); + const tree = { + pages: [ + { + id: randomUUID(), + name: 'Hobbies', + hide_create_tower_button: false, + keep_tasks_open: false, + default_date_from: null, + default_date_to: null, + towers: [ + { + id: randomUUID(), + name: 'Reading', + base_color: { h: 0.92, s: 0.7, l: 0.55 }, + blocks: Array.from({ length: 18 }, (_, i) => ({ + id: randomUUID(), + tag: 'novel', + description: `Pending task ${i + 1} — read another chapter tonight`, + is_done: false, + difficulty: 1, + created_at: now - i * 3600, + })), + }, + ], + }, + ], + }; + + // PUT the tree (unguarded — no If-Match), then reload to render it. + const status = await page.evaluate(async (body) => { + const res = await fetch('api/v1/data', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('life-towers.token.v4')}`, + }, + body: JSON.stringify(body), + }); + return res.status; + }, tree); + expect(status).toBe(200); + + await page.reload(); + await page.waitForSelector('lt-tower', { timeout: 15000 }); + + // Open the accordion (collapsed by default since keep_tasks_open is false). + await page.locator('lt-tasks .header').first().click(); + await page.waitForTimeout(400); // expand animation (200ms) + buffer + + // Measure the scroll topology in the accordion subtree. + const m = await page.locator('lt-tasks').first().evaluate((host) => { + const card = host.querySelector('.container') as HTMLElement; + const cs = (el: Element) => getComputedStyle(el); + const overflows = (el: HTMLElement) => el.scrollHeight > el.clientHeight + 1; + return { + hostOverflowY: cs(host).overflowY, + cardOverflowY: cs(card).overflowY, + cardOverflows: overflows(card), + hostHasOwnOverflow: host.scrollHeight > host.clientHeight + 1, + hostHeight: host.getBoundingClientRect().height, + cardHeight: card.getBoundingClientRect().height, + viewport30vh: window.innerHeight * 0.3, + }; + }); + + // The list must actually overflow, otherwise the test proves nothing. + expect(m.cardOverflows).toBe(true); + + // Exactly one scroller: the inner card. The host only clips. + expect(m.hostOverflowY).toBe('hidden'); + expect(m.cardOverflowY).toBe('auto'); + // The host has no overflowing content of its own → no second scrollbar. + expect(m.hostHasOwnOverflow).toBe(false); + // The card shrank to fit within the host's bound (not clipped past it). + expect(m.cardHeight).toBeLessThanOrEqual(m.hostHeight + 1); + // Host height is capped by min(30vh, 45%) → at most ~30vh. + expect(m.hostHeight).toBeLessThanOrEqual(m.viewport30vh + 2); + + await page.locator('lt-tasks .container').first().screenshot({ + path: 'visuals/04d-tasks-accordion-overflow-single-scrollbar.png', + }); +}); diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts index 60223fc..c7ee5e1 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -187,6 +187,7 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit maxlength="10000" [value]="newValue().description" (input)="updateNewDescription($any($event.target).value)" + (keydown.enter)="onNewDescriptionEnter($event)" >