diff --git a/CLAUDE.md b/CLAUDE.md index 0fddce3..9c696f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,6 @@ 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 @@ -175,22 +174,13 @@ 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. `GET /events` (SSE) is intentionally **un**-limited — it's one long-lived connection per client. +- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token. ## Visual + interaction details that bit me diff --git a/backend/src/life_towers/api.py b/backend/src/life_towers/api.py index 6cc7e04..8fbcc7f 100644 --- a/backend/src/life_towers/api.py +++ b/backend/src/life_towers/api.py @@ -1,16 +1,12 @@ from __future__ import annotations -import asyncio -import json import sqlite3 import time from typing import Annotated import structlog -from fastapi import APIRouter, Depends, Header, HTTPException, Request -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Depends, HTTPException, Request -from . import events from .auth import get_current_user from .db import db_connection from .limits import limiter @@ -22,7 +18,6 @@ from .models import ( HealthResponse, HslColor, PageOut, - PutDataResponse, RegisterRequest, RegisterResponse, TowerOut, @@ -31,34 +26,6 @@ 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: @@ -96,8 +63,6 @@ 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 @@ -175,44 +140,24 @@ async def get_data( ) ) - return DataOut(pages=pages_out, revision=revision) + return DataOut(pages=pages_out) -@router.put("/data", response_model=PutDataResponse) +@router.put("/data", status_code=204) @limiter.limit("30/minute") async def put_data( request: Request, body: DataIn, user_id: Annotated[str, Depends(get_current_user)], - if_match: Annotated[str | None, Header(alias="If-Match")] = None, -) -> PutDataResponse: +) -> None: # Tree-replace semantics mean the request size IS the user's total storage, # so the 2 MiB request cap (enforced by payload_size_middleware) is the only # quota we need. now = int(time.time()) - 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,)) @@ -286,16 +231,13 @@ async def put_data( ), ) - # Bump the revision and refresh last_seen_at in the same transaction. + # Update last_seen_at conn.execute( - "UPDATE users SET last_seen_at = ?, revision = ? WHERE id = ?", - (now, new_revision, user_id), + "UPDATE users SET last_seen_at = ? WHERE id = ?", + (now, 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( @@ -306,66 +248,4 @@ async def put_data( conn.rollback() raise - # Notify other live clients for this user to refetch. Done after commit so - # subscribers never observe a revision that isn't durably stored yet. - events.publish(user_id, new_revision) - - logger.info( - "data_replaced", - user_id=token_log_id(user_id), - pages=len(body.pages), - revision=new_revision, - ) - return PutDataResponse(revision=new_revision) - - -def _format_revision_event(revision: int) -> str: - return f"event: revision\ndata: {json.dumps({'revision': revision})}\n\n" - - -@router.get("/events") -async def events_stream( - request: Request, - user_id: Annotated[str, Depends(get_current_user)], -) -> StreamingResponse: - """Server-Sent Events stream that notifies a client to refetch. - - Authenticated with the same Bearer token as the rest of the API — clients - consume it via fetch()+ReadableStream (EventSource cannot set headers), so - the token never leaks into a URL. Each event carries the current revision; - the client refetches GET /data when it sees a revision newer than its own. - """ - queue = events.subscribe(user_id) - - async def event_generator(): - try: - # Emit the current revision immediately so a (re)connecting client - # can catch up on anything it missed while disconnected. - with db_connection() as conn: - yield _format_revision_event(_read_revision(conn, user_id)) - - while True: - try: - revision = await asyncio.wait_for( - queue.get(), timeout=SSE_KEEPALIVE_SECONDS - ) - yield _format_revision_event(revision) - except asyncio.TimeoutError: - if await request.is_disconnected(): - break - # Comment line: keeps proxies from idling the connection out. - yield ": keepalive\n\n" - finally: - events.unsubscribe(user_id, queue) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - # Disable nginx response buffering for this stream even if the - # location block forgets `proxy_buffering off`. - "X-Accel-Buffering": "no", - }, - ) + logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages)) diff --git a/backend/src/life_towers/events.py b/backend/src/life_towers/events.py deleted file mode 100644 index d6aa06a..0000000 --- a/backend/src/life_towers/events.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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 deleted file mode 100644 index aaba6e9..0000000 --- a/backend/src/life_towers/migrations/002_revision.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 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 1d78a8b..b40d5e3 100644 --- a/backend/src/life_towers/models.py +++ b/backend/src/life_towers/models.py @@ -127,14 +127,6 @@ 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 4080f5c..a4f8538 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import uuid from pathlib import Path from typing import AsyncGenerator @@ -199,7 +198,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": [], "revision": 0} + assert resp.json() == {"pages": []} # --------------------------------------------------------------------------- @@ -254,14 +253,12 @@ 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 == 200 - assert put_resp.json() == {"revision": 1} + assert put_resp.status_code == 204 get_resp = await client.get("/api/v1/data", headers=headers) assert get_resp.status_code == 200 data = get_resp.json() - assert data["revision"] == 1 assert len(data["pages"]) == 2 for pi, page in enumerate(data["pages"]): assert page["id"] == tree["pages"][pi]["id"] @@ -292,7 +289,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 == 200 + assert put_resp.status_code == 204 data = (await client.get("/api/v1/data", headers=headers)).json() assert data["pages"][0]["towers"][0]["blocks"][0]["difficulty"] == 1 @@ -345,7 +342,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 == 200 + assert first_resp.status_code == 204 second_resp = await client.put( "/api/v1/data", @@ -458,195 +455,3 @@ 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 deleted file mode 100644 index 8f42805..0000000 --- a/frontend/e2e/tasks-overflow.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 c7ee5e1..60223fc 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -187,7 +187,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit maxlength="10000" [value]="newValue().description" (input)="updateNewDescription($any($event.target).value)" - (keydown.enter)="onNewDescriptionEnter($event)" >