diff --git a/Dockerfile b/Dockerfile index aad08e4..82fba50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,7 @@ USER appuser ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \ LIFE_TOWERS_STATIC_DIR=/app/static \ + LIFE_TOWERS_FORWARDED_ALLOW_IPS=* \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app/src \ PATH=/app/.venv/bin:$PATH @@ -53,4 +54,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD curl -fsS http://localhost:8000/api/v1/health || exit 1 -CMD ["uvicorn", "life_towers.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] +CMD ["sh", "-c", "uvicorn life_towers.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${LIFE_TOWERS_FORWARDED_ALLOW_IPS}"] diff --git a/README.md b/README.md index 05bd0f8..b555e61 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ Then visit http://localhost:8000. For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`: ```bash -LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \ - docker compose pull && docker compose up -d +export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest +docker compose pull +docker compose up -d ``` ## Environment variables @@ -92,6 +93,6 @@ docker compose -f docker-compose.dev.yml down -v ## Deployment -Forgejo CI (`.forgejo/workflows/ci.yml`) builds and tests the backend, frontend, and Docker image on every push to `master`. On successful push to `master` it also tags and pushes the image to a registry — configure `REGISTRY_URL`, `REGISTRY_USER`, and `REGISTRY_PASSWORD` as repository secrets. +Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`. -The deploy workflow (`.forgejo/workflows/deploy.yml`) triggers on `workflow_dispatch` or a `v*` tag push. It SSHs into the target server and runs `docker compose pull && docker compose up -d`, then polls the healthcheck. The server must have a `.env` file alongside `docker-compose.yml` that pins `LIFE_TOWERS_IMAGE` to the registry tag pushed by CI — otherwise `docker compose pull` is a no-op against the placeholder `life-towers:local`. Configure `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, and `DEPLOY_PATH` as repository secrets before use. +The Docker workflow (`.forgejo/workflows/docker.yml`) builds and pushes images tagged `latest` and `sha-` on pushes to `master` or manual dispatch. It publishes to `vars.REGISTRY` (default `ghcr.io`) under the repository name, using `secrets.GITHUB_TOKEN` for registry auth. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3e93509..c643010 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,13 +11,6 @@ dependencies = [ "pydantic>=2.0.0", ] -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", - "httpx>=0.27.0", -] - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/backend/src/life_towers/api.py b/backend/src/life_towers/api.py index 9fcee2a..8fbcc7f 100644 --- a/backend/src/life_towers/api.py +++ b/backend/src/life_towers/api.py @@ -1,8 +1,6 @@ -"""APIRouter with all Life Towers endpoints.""" - from __future__ import annotations -import json +import sqlite3 import time from typing import Annotated @@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from .auth import get_current_user from .db import db_connection from .limits import limiter +from .logging import token_log_id from .models import ( BlockOut, DataIn, @@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse: (now, token), ) conn.commit() - logger.info("user_registered", user_id=token, new=existing is None) + logger.info("user_registered", user_id=token_log_id(token), new=existing is None) return RegisterResponse(user_id=token) @@ -239,8 +238,14 @@ async def put_data( ) conn.commit() + except sqlite3.IntegrityError as exc: + conn.rollback() + raise HTTPException( + status_code=409, + detail="Submitted IDs conflict with existing data", + ) from exc except Exception: conn.rollback() raise - logger.info("data_replaced", user_id=user_id, pages=len(body.pages)) + logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages)) diff --git a/backend/src/life_towers/auth.py b/backend/src/life_towers/auth.py index 4142948..8f7e01f 100644 --- a/backend/src/life_towers/auth.py +++ b/backend/src/life_towers/auth.py @@ -39,6 +39,7 @@ def get_current_user(request: Request) -> str: u = uuid.UUID(token) if u.version != 4: raise ValueError("Not v4") + token = str(u) except (ValueError, AttributeError): raise _unauthorized() diff --git a/backend/src/life_towers/logging.py b/backend/src/life_towers/logging.py index fea46c0..1cb9288 100644 --- a/backend/src/life_towers/logging.py +++ b/backend/src/life_towers/logging.py @@ -4,6 +4,7 @@ from __future__ import annotations import time import uuid as _uuid_mod +from hashlib import sha256 import structlog from fastapi import Request, Response @@ -26,8 +27,12 @@ def configure_logging() -> None: ) +def token_log_id(token: str) -> str: + return sha256(token.encode("utf-8")).hexdigest()[:12] + + async def request_logging_middleware(request: Request, call_next) -> Response: - """Log each request with method, path, status, duration_ms, user_id, request_id.""" + """Log each request without writing bearer credentials to the log stream.""" request_id = str(_uuid_mod.uuid4()) structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(request_id=request_id) @@ -38,7 +43,7 @@ async def request_logging_middleware(request: Request, call_next) -> Response: if auth: parts = auth.split() if len(parts) == 2 and parts[0].lower() == "bearer": - user_id = parts[1] + user_id = token_log_id(parts[1]) start = time.monotonic() response = await call_next(request) diff --git a/backend/src/life_towers/main.py b/backend/src/life_towers/main.py index b9dd444..0e2a29a 100644 --- a/backend/src/life_towers/main.py +++ b/backend/src/life_towers/main.py @@ -1,8 +1,5 @@ -"""ASGI app, lifespan, static files mount, route registration.""" - from __future__ import annotations -import json import os from contextlib import asynccontextmanager from html import escape @@ -37,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = { 413: "payload_too_large", 422: "bad_request", 429: "rate_limited", - 507: "quota_exceeded", 500: "server_error", } @@ -94,7 +90,12 @@ def create_app() -> FastAPI: request: Request, exc: RequestValidationError ) -> JSONResponse: fields = sorted( - {".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()} + field + for field in { + ".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") + for e in exc.errors() + } + if field ) if fields: detail_str = "Validation failed for: " + ", ".join(f for f in fields if f) diff --git a/backend/src/life_towers/models.py b/backend/src/life_towers/models.py index 4fe8694..b40d5e3 100644 --- a/backend/src/life_towers/models.py +++ b/backend/src/life_towers/models.py @@ -8,12 +8,14 @@ from pydantic import BaseModel, Field, field_validator, model_validator import uuid as _uuid_mod -def _is_uuidv4(value: str) -> bool: +def _canonical_uuidv4(value: str) -> str: try: u = _uuid_mod.UUID(value) - return u.version == 4 + if u.version == 4: + return str(u) except (ValueError, AttributeError): - return False + pass + raise ValueError("must be a UUIDv4") class HslColor(BaseModel): @@ -33,9 +35,7 @@ class BlockIn(BaseModel): @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: - if not _is_uuidv4(v): - raise ValueError(f"id must be a UUIDv4, got: {v!r}") - return v + return _canonical_uuidv4(v) class BlockOut(BaseModel): @@ -56,9 +56,7 @@ class TowerIn(BaseModel): @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: - if not _is_uuidv4(v): - raise ValueError(f"id must be a UUIDv4, got: {v!r}") - return v + return _canonical_uuidv4(v) class TowerOut(BaseModel): @@ -80,9 +78,7 @@ class PageIn(BaseModel): @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: - if not _is_uuidv4(v): - raise ValueError(f"id must be a UUIDv4, got: {v!r}") - return v + return _canonical_uuidv4(v) class PageOut(BaseModel): @@ -139,9 +135,7 @@ class RegisterRequest(BaseModel): @field_validator("token") @classmethod def validate_token(cls, v: str) -> str: - if not _is_uuidv4(v): - raise ValueError("token must be a UUIDv4") - return v + return _canonical_uuidv4(v) class RegisterResponse(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0037c88..404fa09 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -166,6 +166,21 @@ async def test_register_non_uuidv4_token(client: AsyncClient) -> None: assert resp.status_code == 400 +@pytest.mark.asyncio +async def test_uuid_inputs_are_canonicalized(client: AsyncClient) -> None: + token = make_uuidv4() + upper_token = token.upper() + register_resp = await client.post("/api/v1/register", json={"token": upper_token}) + assert register_resp.status_code == 200 + assert register_resp.json() == {"user_id": token} + + data_resp = await client.get( + "/api/v1/data", + headers={"Authorization": f"Bearer {upper_token}"}, + ) + assert data_resp.status_code == 200 + + # --------------------------------------------------------------------------- # Auth / GET /data # --------------------------------------------------------------------------- @@ -322,6 +337,34 @@ async def test_put_duplicate_page_id(client: AsyncClient) -> None: resp = await client.put("/api/v1/data", json=tree, headers=headers) assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec + assert resp.json() == {"error": "bad_request", "detail": "Validation failed"} + + +@pytest.mark.asyncio +async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> None: + first_token = make_uuidv4() + second_token = make_uuidv4() + await client.post("/api/v1/register", json={"token": first_token}) + await client.post("/api/v1/register", json={"token": second_token}) + + tree = _make_tree() + first_resp = await client.put( + "/api/v1/data", + json=tree, + headers={"Authorization": f"Bearer {first_token}"}, + ) + assert first_resp.status_code == 204 + + second_resp = await client.put( + "/api/v1/data", + json=tree, + headers={"Authorization": f"Bearer {second_token}"}, + ) + assert second_resp.status_code == 409 + assert second_resp.json() == { + "error": "conflict", + "detail": "Submitted IDs conflict with existing data", + } @pytest.mark.asyncio diff --git a/backend/uv.lock b/backend/uv.lock index 7f77e7c..90f555b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -201,13 +201,6 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] -[package.optional-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, -] - [package.dev-dependencies] dev = [ { name = "httpx" }, @@ -218,15 +211,11 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.111.0" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "structlog", specifier = ">=24.1.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" }, ] -provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ diff --git a/frontend/README.md b/frontend/README.md index 78d1bf3..3fe60d1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,59 +1,11 @@ -# Frontend +# Life Towers Frontend -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.13. - -## Development server - -To start a local development server, run: +Angular frontend for the Life Towers app. See the root `README.md` for the full +stack setup. ```bash -ng serve +npm start # dev server on :4200, with /api proxied to :8000 +npm test # Vitest unit tests +npm run test:e2e # Playwright against PLAYWRIGHT_BASE_URL or localhost:8000 +npm run build # production bundle ``` - -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. - -## Code scaffolding - -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the project run: - -```bash -ng build -``` - -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/frontend/angular.json b/frontend/angular.json index e4ba9a9..2639328 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -77,9 +77,6 @@ "proxyConfig": "proxy.conf.json" } }, - "test": { - "builder": "@angular/build:unit-test" - }, "lint": { "builder": "@angular-eslint/builder:lint", "options": { @@ -92,4 +89,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 4a20b30..f9bb312 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -11,8 +11,11 @@ test.describe('Life Towers smoke test', () => { test('create page → tower → block, mark done, reload, persists', async ({ page }) => { await page.goto('/'); - // Wait for the empty-state hint that means init() completed. - await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 }); + // Wait for init, then dismiss the welcome modal so the page controls are reachable. + await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: 'Start empty' }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + await expect(page.getByText('Add a new page to get started!')).toBeVisible(); // Create a page via the select-add dropdown. await page.locator('lt-select-add .top').first().click(); @@ -24,7 +27,7 @@ test.describe('Life Towers smoke test', () => { // Create a tower. await page.locator('img[alt="Add tower"]').click(); - await page.locator('input[placeholder="Tower name…"]').fill('Side projects'); + await page.locator('input[placeholder="New tower"]').fill('Side projects'); await page.locator('lt-tower-settings button[type="submit"]').click(); // Tower's name input is rendered with the tower name as its value. @@ -43,27 +46,25 @@ test.describe('Life Towers smoke test', () => { await page.locator('textarea[placeholder="Write a description here…"]').fill( 'Modernise the towers app', ); - await page.getByRole('button', { name: 'Create and exit' }).click(); + await page.getByLabel('Already done').uncheck(); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); // New block is pending → appears in the tasks accordion. // (Tasks header shows N tasks.) await expect(page.locator('lt-tasks')).toContainText('1'); // Open the tasks accordion + click the task to edit it, then flip done. - await page.locator('lt-tasks .container').click(); - await page.locator('lt-tasks .task-container').click(); + await page.locator('lt-tasks .header').click(); + await page.locator('lt-tasks .task-description').click(); - // Toggle done in the block-edit modal — the right label is "Done". + // Toggle done in the block-edit modal. const putLanded = page.waitForResponse( (r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(), ); - // Toggle uses verbose labels — "Goal accomplished" flips it to done. - await page - .locator('lt-block-edit lt-toggle span') - .filter({ hasText: 'Goal accomplished' }) - .click(); - await page.getByRole('button', { name: 'Create and exit' }).click(); + await page.locator('lt-block-edit .card.active').getByLabel('Already done').check(); await putLanded; + await page.locator('lt-block-edit .card.active .exit').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); // Done block now appears as a colored square in the tower's falling stack. await expect(page.locator('lt-tower lt-block').first()).toBeVisible(); @@ -73,7 +74,7 @@ test.describe('Life Towers smoke test', () => { await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', { timeout: 15000, }); - await expect(page.getByDisplayValue('Side projects')).toBeVisible(); + await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects'); await expect(page.locator('lt-tower lt-block').first()).toBeVisible(); }); }); diff --git a/frontend/e2e/visuals.spec.ts b/frontend/e2e/visuals.spec.ts index a1e3384..dc538ab 100644 --- a/frontend/e2e/visuals.spec.ts +++ b/frontend/e2e/visuals.spec.ts @@ -1,5 +1,10 @@ import { test } from '@playwright/test'; +test.skip( + process.env['CAPTURE_VISUALS'] !== '1', + 'Set CAPTURE_VISUALS=1 to run the visual screenshot capture suite.', +); + /** * Visual capture: drives the UI into key states and writes screenshots * for human review of the legacy-styled design. @@ -55,12 +60,12 @@ test.describe('Life Towers visuals', () => { .fill('Finish The Brothers Karamazov'); // Uncheck "Already done" so this becomes a pending task. await createCard.getByLabel('Already done').uncheck(); - await page.getByRole('button', { name: 'Create and exit' }).click(); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); // Open the tasks accordion to show the new tickbox. await page.waitForTimeout(200); - await page.locator('lt-tasks .container').click(); + await page.locator('lt-tasks .header').click(); await page.waitForTimeout(300); await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true }); @@ -83,7 +88,7 @@ test.describe('Life Towers visuals', () => { await cc.locator('lt-select-add .top').click(); await page.waitForTimeout(100); await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc); - await page.getByRole('button', { name: 'Create and exit' }).click(); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); } @@ -196,9 +201,9 @@ test.describe('Life Towers visuals', () => { await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true }); // Open the block-edit carousel for the first tower's first task. - await page.locator('lt-tasks .container').first().click(); + await page.locator('lt-tasks .header').first().click(); await page.waitForTimeout(400); - await page.locator('lt-tasks .task-container').first().click(); + await page.locator('lt-tasks .task-description').first().click(); await page.waitForSelector('section.modal.active'); await page.waitForTimeout(400); await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84bcb8a..8ea82d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,6 @@ "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", - "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", "@plausible-analytics/tracker": "^0.4.5", "rxjs": "~7.8.0", @@ -719,24 +718,6 @@ } } }, - "node_modules/@angular/router": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz", - "integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.14", - "@angular/core": "21.2.14", - "@angular/platform-browser": "21.2.14", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, "node_modules/@angular/service-worker": { "version": "21.2.14", "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c0c02c..a9aaf77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,6 @@ "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", - "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index b8fcf5c..8a08685 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -4,7 +4,6 @@ import { isDevMode, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter } from '@angular/router'; import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideServiceWorker } from '@angular/service-worker'; @@ -12,7 +11,6 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideRouter([]), provideHttpClient(withFetch()), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html deleted file mode 100644 index 0edf11a..0000000 --- a/frontend/src/app/app.html +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - diff --git a/frontend/src/app/app.scss b/frontend/src/app/app.scss deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 05f109e..ec56fbb 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -16,6 +16,6 @@ export class App implements OnInit { ngOnInit(): void { this.analytics.init(); - this.store.init(); + void this.store.init(); } } diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts index cd50b5b..64e73e1 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -3,7 +3,6 @@ import { ChangeDetectionStrategy, input, output, - inject, signal, computed, effect, @@ -33,6 +32,10 @@ interface EditedValue { difficulty: number; } +function clampDifficulty(value: number): number { + return Math.max(1, Math.min(100, value)); +} + @Component({ selector: 'lt-block-edit', standalone: true, @@ -48,6 +51,8 @@ interface EditedValue { class="carousel" (scroll)="onScroll()" (click)="onBackdropClick($event)" + (keydown.enter)="onBackdropClick($any($event))" + tabindex="-1" >
@@ -56,12 +61,22 @@ interface EditedValue { class="card" [class.active]="activeIdx() === i + 1" [class.near-active]="activeIdx() === i || activeIdx() === i + 2" + role="button" + tabindex="0" + [attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')" (click)="onCardClick(i + 1)" + (keydown.enter)="onCardClick(i + 1)" + (keydown.space)="$event.preventDefault(); onCardClick(i + 1)" >
-
+
+
@@ -127,12 +144,22 @@ interface EditedValue { class="card create-card" [class.active]="activeIdx() === blocks().length + 1" [class.near-active]="activeIdx() === blocks().length" + role="button" + tabindex="0" + aria-label="Focus create card" (click)="onCardClick(blocks().length + 1)" + (keydown.enter)="onCardClick(blocks().length + 1)" + (keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)" >
-
+
Create now
-
+
@@ -182,6 +210,7 @@ interface EditedValue { type="button" class="step" aria-label="Increase difficulty" + [disabled]="newValue().difficulty >= 100" (click)="updateNewDifficulty(1); $event.stopPropagation()" >+
@@ -671,7 +700,7 @@ export class BlockEditComponent implements AfterViewInit { } updateDifficulty(id: string, delta: number): void { - const next = Math.max(1, this.editedFor(id).difficulty + delta); + const next = clampDifficulty(this.editedFor(id).difficulty + delta); this.patchEdited(id, { difficulty: next }); this.flushExisting(id); } @@ -717,7 +746,7 @@ export class BlockEditComponent implements AfterViewInit { } updateNewDifficulty(delta: number): void { - this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) })); + this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) })); } submitNew(): void { diff --git a/frontend/src/app/components/modal/modal.component.ts b/frontend/src/app/components/modal/modal.component.ts index 3a0fda2..47c9eb8 100644 --- a/frontend/src/app/components/modal/modal.component.ts +++ b/frontend/src/app/components/modal/modal.component.ts @@ -23,6 +23,8 @@ import { ModalStateService } from '../../services/modal-state.service'; class="modal" [class.active]="active()" (click)="onBackdropClick($event)" + (keydown.enter)="onBackdropClick($any($event))" + tabindex="-1" > +
- Add tower + Add tower
} @@ -57,7 +66,7 @@
-
+

Delete tower

Delete {{ confirmDeleteTowerName() || 'this tower' }} and all of its blocks? This can't be undone.

diff --git a/frontend/src/app/components/page/page.component.scss b/frontend/src/app/components/page/page.component.scss index cf5689d..ed8aa0c 100644 --- a/frontend/src/app/components/page/page.component.scss +++ b/frontend/src/app/components/page/page.component.scss @@ -1,4 +1,4 @@ -@import '../../../styles'; +@import '../../../library/main'; :host { display: flex; diff --git a/frontend/src/app/components/page/page.component.ts b/frontend/src/app/components/page/page.component.ts index 7311479..5b0d990 100644 --- a/frontend/src/app/components/page/page.component.ts +++ b/frontend/src/app/components/page/page.component.ts @@ -4,8 +4,11 @@ import { input, output, signal, + computed, inject, HostListener, + effect, + untracked, } from '@angular/core'; import { Page } from '../../models'; import { StoreService } from '../../services/store.service'; @@ -16,7 +19,6 @@ import { DoubleSliderComponent, DoubleSliderRange, } from '../shared/double-slider/double-slider.component'; -import { computed } from '@angular/core'; import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop'; import { ModalStateService } from '../../services/modal-state.service'; @@ -116,6 +118,14 @@ export class PageComponent { /** Selected date range — `null` = show everything. */ readonly dateRange = signal<{ from: number; to: number } | null>(null); + constructor() { + effect(() => { + if (!this.showSlider()) { + untracked(() => this.dateRange.set(null)); + } + }); + } + onSliderRangeChange(range: DoubleSliderRange): void { this.dateRange.set({ from: range.from as number, to: range.to as number }); } @@ -147,7 +157,7 @@ export class PageComponent { } onDeleteTower(towerId: string): void { - this.store.deleteTower(this.page().id, towerId); + this.confirmDeleteTowerId.set(towerId); } // ── Block mutations ──────────────────────────────────────────────────────── diff --git a/frontend/src/app/components/pages/pages.component.scss b/frontend/src/app/components/pages/pages.component.scss index 631de3e..13f10bb 100644 --- a/frontend/src/app/components/pages/pages.component.scss +++ b/frontend/src/app/components/pages/pages.component.scss @@ -1,4 +1,4 @@ -@import '../../../styles'; +@import '../../../library/main'; :host { height: 100%; diff --git a/frontend/src/app/components/pages/pages.component.ts b/frontend/src/app/components/pages/pages.component.ts index 900ba40..cbce254 100644 --- a/frontend/src/app/components/pages/pages.component.ts +++ b/frontend/src/app/components/pages/pages.component.ts @@ -82,8 +82,6 @@ export class PagesComponent implements OnDestroy { return pages[0] ?? null; }); - readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null); - readonly confirmDeletePageName = computed(() => { const id = this.confirmDeletePageId(); if (!id) return ''; diff --git a/frontend/src/app/components/shared/double-slider/double-slider.component.ts b/frontend/src/app/components/shared/double-slider/double-slider.component.ts index f118e44..892e935 100644 --- a/frontend/src/app/components/shared/double-slider/double-slider.component.ts +++ b/frontend/src/app/components/shared/double-slider/double-slider.component.ts @@ -32,7 +32,7 @@ export interface DoubleSliderRange { id="ds-1" type="range" min="0" - [max]="MAX - 1" + [max]="maxIndex()" [value]="oneValue()" (input)="oneValue.set(+$any($event.target).value)" /> @@ -40,7 +40,7 @@ export interface DoubleSliderRange { id="ds-2" type="range" min="0" - [max]="MAX - 1" + [max]="maxIndex()" [value]="otherValue()" (input)="otherValue.set(+$any($event.target).value)" /> @@ -168,10 +168,9 @@ export class DoubleSliderComponent { readonly rangeChange = output>(); - readonly MAX = 100; - readonly oneValue = signal(0); - readonly otherValue = signal(this.MAX - 1); + readonly otherValue = signal(0); + readonly maxIndex = computed(() => Math.max(0, this.values().length - 1)); private prevValuesLength = 0; @@ -198,32 +197,33 @@ export class DoubleSliderComponent { const hi = Math.max(a, b); untracked(() => { this.rangeChange.emit({ - from: vs[this.indexFromValue(lo)], - to: vs[this.indexFromValue(hi)], + from: vs[this.clampIndex(lo)], + to: vs[this.clampIndex(hi)], }); }); }); - // Snap the higher thumb to MAX - 1 when a new entry is appended. + // Snap the higher thumb to the newest value when a new entry is appended. effect(() => { const len = this.values().length; untracked(() => { + const max = Math.max(0, len - 1); if (len > this.prevValuesLength) { const a = this.oneValue(); const b = this.otherValue(); - if (a > b) this.oneValue.set(this.MAX - 1); - else this.otherValue.set(this.MAX - 1); + if (a > b) this.oneValue.set(max); + else this.otherValue.set(max); + } else { + if (this.oneValue() > max) this.oneValue.set(max); + if (this.otherValue() > max) this.otherValue.set(max); } this.prevValuesLength = len; }); }); } - private indexFromValue(value: number): number { - return Math.min( - this.values().length - 1, - Math.floor((value / this.MAX) * this.values().length), - ); + private clampIndex(value: number): number { + return Math.max(0, Math.min(this.values().length - 1, Math.round(value))); } /** @@ -231,9 +231,10 @@ export class DoubleSliderComponent { * upward and rotates -45° as a thumb approaches. */ getOffset(index: number): string { - const labelIndex = index / Math.max(1, this.drawnLabels().length); - const a = this.oneValue() / this.MAX - 0.1; - const b = this.otherValue() / this.MAX - 0.1; + const labelIndex = index / Math.max(1, this.drawnLabels().length - 1); + const max = Math.max(1, this.maxIndex()); + const a = this.oneValue() / max - 0.1; + const b = this.otherValue() / max - 0.1; const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b)); const ACTIVE_ZONE = 0.2; const base = 'translateX(-50%) rotate(-30deg) translateY(100%)'; diff --git a/frontend/src/app/components/shared/select-add/select-add.component.ts b/frontend/src/app/components/shared/select-add/select-add.component.ts index 5ee768e..baac459 100644 --- a/frontend/src/app/components/shared/select-add/select-add.component.ts +++ b/frontend/src/app/components/shared/select-add/select-add.component.ts @@ -4,8 +4,6 @@ import { input, output, signal, - OnChanges, - SimpleChanges, ElementRef, HostListener, inject, @@ -22,7 +20,15 @@ import { [class.always-shadow]="alwaysDropShadow()" >
-
+

{{ resolvedSelected() ?? placeholder() }}

@@ -33,6 +39,7 @@ import { } @else { @@ -46,6 +53,7 @@ import { type="text" #addInput placeholder="Add a value…" + maxlength="200" (keydown.enter)="onAdd(addInput.value); addInput.value = ''" />
-

{{ b.description || b.tag }}

+ >{{ b.description || b.tag }}
}
@@ -87,9 +88,19 @@ import { getColorOfTag } from '../../utils/color'; max-height: 30vh; overflow-y: auto; - .header { cursor: pointer; } + .header { + all: unset; + @include medium-text(); + display: block; + width: 100%; + box-sizing: border-box; + cursor: pointer; + text-align: center; - p { font-size: var(--medium-font-size); } + &::after { + content: none; + } + } .all-task { @include inner-spacing(var(--small-padding)); @@ -124,7 +135,7 @@ import { getColorOfTag } from '../../utils/color'; gap: calc(var(--small-padding) / 2); } - &:hover p { + &:hover .task-description { @media (min-width: $mobile-width) { color: inherit !important; } @@ -187,7 +198,9 @@ import { getColorOfTag } from '../../utils/color'; } } - p { + .task-description { + all: unset; + @include medium-text(); white-space: nowrap; text-overflow: ellipsis; overflow-x: hidden; @@ -203,6 +216,7 @@ import { getColorOfTag } from '../../utils/color'; } position: relative; + &::after { content: none; } } } } @@ -222,7 +236,6 @@ export class TasksComponent { readonly edit = output(); readonly expanded = signal(false); - private lastToggleAt = 0; constructor() { // Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping @@ -241,12 +254,6 @@ export class TasksComponent { 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); } } diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index be0db59..a6422c9 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -140,7 +140,6 @@ export function selectVisibleStyledBlocks(
(); - /** Set by page component when this tower is being dragged over trash. */ - readonly trashHighlight = input(false); /** Optional date range filter — when set, blocks with `created_at` * outside [from, to] are hidden from the falling stack. */ readonly dateRange = input<{ from: number; to: number } | null>(null); @@ -547,6 +544,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy { private readonly towerRoot = viewChild>('towerRoot'); private readonly maxVisibleBlocks = signal(null); private resizeObserver: ResizeObserver | null = null; + private destroyed = false; + private readonly animationFrames = new Set(); // ── Derived ──────────────────────────────────────────────────────────────── /** Pending (not-done) blocks — fed to the tasks accordion. */ @@ -638,6 +637,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.destroyed = true; + for (const id of this.animationFrames) cancelAnimationFrame(id); + this.animationFrames.clear(); this.resizeObserver?.disconnect(); } @@ -788,25 +790,48 @@ export class TowerComponent implements AfterViewInit, OnDestroy { block._opacity = '0'; } this._visibleBlocks.set(visibleStyled); - requestAnimationFrame(() => { - requestAnimationFrame(() => { - for (const block of blocks) { - block._anim = 'descend'; - block._transform = 'translateY(0)'; - block._opacity = '1'; - } - this._visibleBlocks.set([...this._visibleBlocks()]); + this.requestFrame(() => { + this.requestFrame(() => { + if (this.destroyed) return; + this.finishDescendAnimation(blocks); }); }); } + private requestFrame(callback: () => void): void { + if (typeof requestAnimationFrame !== 'function') { + callback(); + return; + } + const id = requestAnimationFrame(() => { + this.animationFrames.delete(id); + if (!this.destroyed) callback(); + }); + this.animationFrames.add(id); + } + + private finishDescendAnimation(blocks: StyledBlock[]): void { + for (const block of blocks) { + block._anim = 'descend'; + block._transform = 'translateY(0)'; + block._opacity = '1'; + } + this._visibleBlocks.set([...this._visibleBlocks()]); + } + // ── Event handlers ───────────────────────────────────────────────────────── onRename(event: Event): void { const input = event.target as HTMLInputElement; const newName = input.value.trim(); - if (newName && newName !== this.tower().name) { + if (!newName) { + input.value = this.tower().name; + return; + } + if (newName !== this.tower().name) { this.updateTower.emit({ name: newName, base_color: this.tower().base_color }); + } else { + input.value = this.tower().name; } } diff --git a/frontend/src/app/models/index.ts b/frontend/src/app/models/index.ts index e9216d0..083f09d 100644 --- a/frontend/src/app/models/index.ts +++ b/frontend/src/app/models/index.ts @@ -40,7 +40,7 @@ export type SaveStatus = | 'saving' | 'saved' | 'retrying' - | 'error' // generic / network — will keep trying - | 'too-large' // 413 — payload exceeds the server cap, won't retry + | 'error' // generic / network — retries exhausted until the next mutation + | 'too-large' // 413 — payload exceeds the server cap, will not retry | 'rate-limited' // 429 — will retry after Retry-After - | 'invalid'; // 400 — server rejected the body, won't retry + | 'invalid'; // 400 — server rejected the body, will not retry diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 2e9b8e8..58bf117 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -23,9 +23,9 @@ export class ApiService { ); } - putData(token: string, tree: TreeDto): Promise { - return firstValueFrom( - this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }), + async putData(token: string, tree: TreeDto): Promise { + await firstValueFrom( + this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }), ); } diff --git a/frontend/src/app/services/api.service.vitest.ts b/frontend/src/app/services/api.service.vitest.ts index d8a681f..e9cf798 100644 --- a/frontend/src/app/services/api.service.vitest.ts +++ b/frontend/src/app/services/api.service.vitest.ts @@ -1,28 +1,61 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ApiService } from './api.service'; +import type { TreeDto } from '../models'; -// Mock environment -vi.mock('../../environments/environment', () => ({ - environment: { apiBase: 'http://test-api', production: false }, -})); +describe('ApiService', () => { + let service: ApiService; + let http: HttpTestingController; -describe('ApiService URL patterns', () => { - const baseUrl = 'http://test-api'; - - it('constructs correct health URL', () => { - expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health'); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), ApiService], + }); + service = TestBed.inject(ApiService); + http = TestBed.inject(HttpTestingController); }); - it('constructs correct register URL', () => { - expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register'); + afterEach(() => { + http.verify(); }); - it('constructs correct data URL', () => { - expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data'); + it('gets health', async () => { + const promise = service.health(); + const req = http.expectOne('/api/v1/health'); + expect(req.request.method).toBe('GET'); + req.flush({ status: 'ok' }); + await expect(promise).resolves.toEqual({ status: 'ok' }); }); - it('formats Authorization header correctly', () => { - const token = 'abc-def-123'; - const header = `Bearer ${token}`; - expect(header).toBe('Bearer abc-def-123'); + it('registers a token', async () => { + const promise = service.register('token-1'); + const req = http.expectOne('/api/v1/register'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ token: 'token-1' }); + req.flush({ user_id: 'token-1' }); + await expect(promise).resolves.toEqual({ user_id: 'token-1' }); + }); + + it('gets data with a bearer token', async () => { + const tree: TreeDto = { pages: [] }; + const promise = service.getData('token-1'); + const req = http.expectOne('/api/v1/data'); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe('Bearer token-1'); + req.flush(tree); + await expect(promise).resolves.toEqual(tree); + }); + + it('puts data with a bearer token', async () => { + const tree: TreeDto = { pages: [] }; + const promise = service.putData('token-1', tree); + const req = http.expectOne('/api/v1/data'); + expect(req.request.method).toBe('PUT'); + expect(req.request.headers.get('Authorization')).toBe('Bearer token-1'); + expect(req.request.body).toBe(tree); + req.flush(null); + await expect(promise).resolves.toBeUndefined(); }); }); diff --git a/frontend/src/app/services/modal-state.service.ts b/frontend/src/app/services/modal-state.service.ts index c1565ed..eb78a78 100644 --- a/frontend/src/app/services/modal-state.service.ts +++ b/frontend/src/app/services/modal-state.service.ts @@ -5,7 +5,7 @@ import { Injectable, computed, signal } from '@angular/core'; * mount and decrements on destroy. Consumers read `anyOpen` to react. * * Used by `page.component` to disable tower drag-and-drop while any modal - * (block-edit carousel, page-settings, tower-settings, confirm-delete, + * (block-edit carousel, settings, tower-settings, confirm-delete, * settings) is on screen — otherwise the user can drag towers from behind * the open card. */ diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index fd35146..280b049 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -1,10 +1,10 @@ -import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core'; +import { Injectable, inject, signal, OnDestroy } from '@angular/core'; import { ApiService } from './api.service'; import { AnalyticsService } from './analytics.service'; import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models'; const TOKEN_KEY = 'life-towers.token.v4'; -const CACHE_KEY = 'life-towers.cache.v4'; +const CACHE_KEY_PREFIX = 'life-towers.cache.v4'; const DEBOUNCE_MS = 750; const MAX_RETRIES = 5; @@ -29,7 +29,7 @@ function uuidV4(): string { function isUuidV4(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( - value, + value.toLowerCase(), ); } @@ -51,6 +51,10 @@ function safeSet(key: string, value: string): void { } } +function cacheKeyForToken(token: string): string { + return `${CACHE_KEY_PREFIX}.${token}`; +} + interface PendingPut { token: string; tree: TreeDto; @@ -87,11 +91,10 @@ export class StoreService implements OnDestroy { readonly loading = this._loading.asReadonly(); readonly token = this._token.asReadonly(); - readonly pageCount = computed(() => this._pages().length); - // ── Debounce / retry ─────────────────────────────────────────────────────── private debounceTimer: ReturnType | null = null; private retryTimer: ReturnType | null = null; + private retryResolver: (() => void) | null = null; private flushInFlight = false; // True while in-flight if a new mutation arrived; we'll re-flush after. private dirtyDuringFlush = false; @@ -99,14 +102,8 @@ export class StoreService implements OnDestroy { // ── Cross-tab sync ───────────────────────────────────────────────────────── private readonly storageListener = (e: StorageEvent) => { if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) { - // Another tab switched accounts. Re-init with the new token instead of - // continuing to sync the old account's state to the new one. - this._token.set(e.newValue); - this._pages.set([]); - this._loading.set(true); - this.cancelPendingWrites(); - void this.init(); - } else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) { + this.switchToken(e.newValue); + } else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) { // Another tab just wrote a fresh cache; adopt it if we're not mid-save // (to avoid clobbering our own state with the other tab's older view). try { @@ -120,17 +117,21 @@ export class StoreService implements OnDestroy { // ── Single-flight init ───────────────────────────────────────────────────── private initPromise: Promise | null = null; + private initGeneration = 0; // ── Init ─────────────────────────────────────────────────────────────────── async init(): Promise { if (this.initPromise) return this.initPromise; - this.initPromise = this.doInit().finally(() => { - this.initPromise = null; + const generation = ++this.initGeneration; + this.initPromise = this.doInit(generation).finally(() => { + if (this.initGeneration === generation) { + this.initPromise = null; + } }); return this.initPromise; } - private async doInit(): Promise { + private async doInit(generation: number): Promise { if (typeof window !== 'undefined') { // (idempotent — adding the same listener twice is a no-op) window.addEventListener('storage', this.storageListener); @@ -140,6 +141,9 @@ export class StoreService implements OnDestroy { if (stored && !isUuidV4(stored)) { // Garbage in localStorage from a buggy past version — refuse it. stored = null; + } else if (stored) { + stored = stored.toLowerCase(); + safeSet(TOKEN_KEY, stored); } const token = stored ?? uuidV4(); if (!stored) { @@ -157,7 +161,7 @@ export class StoreService implements OnDestroy { try { const tree = await this.api.getData(token); - this.adoptServerTree(tree); + if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token); } catch (err: unknown) { const status = (err as { status?: number })?.status; if (status === 401) { @@ -165,26 +169,32 @@ export class StoreService implements OnDestroy { try { await this.api.register(token); const tree = await this.api.getData(token); - this.adoptServerTree(tree); + if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token); } catch { - this.loadFromCache(); + if (this.isCurrentInit(generation, token)) this.loadFromCache(token); } } else { - this.loadFromCache(); + if (this.isCurrentInit(generation, token)) this.loadFromCache(token); } } finally { - this._loading.set(false); + if (this.initGeneration === generation) { + this._loading.set(false); + } } } + private isCurrentInit(generation: number, token: string): boolean { + return this.initGeneration === generation && this._token() === token; + } + /** * Apply a freshly-fetched server tree. If the server is empty but our local * cache holds data, the cache wins and we schedule a push — otherwise the * "server forgot me" recovery would silently wipe offline edits. */ - private adoptServerTree(tree: TreeDto): void { + private adoptServerTree(tree: TreeDto, token: string): void { if (tree.pages.length === 0) { - const cached = safeGet(CACHE_KEY); + const cached = safeGet(cacheKeyForToken(token)); if (cached) { try { const cachedTree: TreeDto = JSON.parse(cached); @@ -199,11 +209,11 @@ export class StoreService implements OnDestroy { } } this._pages.set(tree.pages); - this.updateCache(tree); + this.updateCache(token, tree); } - private loadFromCache(): void { - const raw = safeGet(CACHE_KEY); + private loadFromCache(token: string): void { + const raw = safeGet(cacheKeyForToken(token)); if (raw) { try { const tree: TreeDto = JSON.parse(raw); @@ -214,8 +224,8 @@ export class StoreService implements OnDestroy { } } - private updateCache(tree: TreeDto): void { - safeSet(CACHE_KEY, JSON.stringify(tree)); + private updateCache(token: string, tree: TreeDto): void { + safeSet(cacheKeyForToken(token), JSON.stringify(tree)); } private cancelPendingWrites(): void { @@ -227,6 +237,11 @@ export class StoreService implements OnDestroy { clearTimeout(this.retryTimer); this.retryTimer = null; } + if (this.retryResolver !== null) { + const resolve = this.retryResolver; + this.retryResolver = null; + resolve(); + } this.dirtyDuringFlush = false; } @@ -261,13 +276,13 @@ export class StoreService implements OnDestroy { } reorderPages(fromIndex: number, toIndex: number): void { + let changed = false; this._pages.update((pages) => { - const arr = [...pages]; - const [item] = arr.splice(fromIndex, 1); - arr.splice(toIndex, 0, item); - return arr; + const reordered = reorder(pages, fromIndex, toIndex); + changed = reordered !== null; + return reordered ?? pages; }); - this.scheduleSave(); + if (changed) this.scheduleSave(); } addTower(pageId: string, name: string, base_color: HslColor): void { @@ -301,16 +316,17 @@ export class StoreService implements OnDestroy { } reorderTowers(pageId: string, fromIndex: number, toIndex: number): void { + let changed = false; this._pages.update((pages) => pages.map((p) => { if (p.id !== pageId) return p; - const towers = [...p.towers]; - const [item] = towers.splice(fromIndex, 1); - towers.splice(toIndex, 0, item); + const towers = reorder(p.towers, fromIndex, toIndex); + if (towers === null) return p; + changed = true; return { ...p, towers }; }), ); - this.scheduleSave(); + if (changed) this.scheduleSave(); } addBlock( @@ -352,6 +368,7 @@ export class StoreService implements OnDestroy { blockId: string, patch: Partial>, ): void { + let becameDone = false; this._pages.update((pages) => pages.map((p) => p.id === pageId @@ -359,16 +376,21 @@ export class StoreService implements OnDestroy { ...p, towers: p.towers.map((t) => t.id === towerId - ? { - ...t, - blocks: this.patchBlockList(t.blocks, blockId, patch).blocks, - } + ? (() => { + const result = this.patchBlockList(t.blocks, blockId, patch); + becameDone = result.becameDone; + return { ...t, blocks: result.blocks }; + })() : t, ), } : p, ), ); + if (becameDone) { + this.analytics.trackStart(); + this.analytics.trackBlockCompleted(); + } this.scheduleSave(); } @@ -390,35 +412,6 @@ export class StoreService implements OnDestroy { this.scheduleSave(); } - toggleBlock(pageId: string, towerId: string, blockId: string): void { - let becameDone = false; - this._pages.update((pages) => - pages.map((p) => - p.id === pageId - ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? (() => { - const block = t.blocks.find((b) => b.id === blockId); - if (!block) return t; - const result = this.patchBlockList(t.blocks, blockId, { - is_done: !block.is_done, - }); - becameDone = result.becameDone; - return { ...t, blocks: result.blocks }; - })() - : t, - ), - } - : p, - ), - ); - this.analytics.trackStart(); - if (becameDone) this.analytics.trackBlockCompleted(); - this.scheduleSave(); - } - private patchBlockList( blocks: Block[], blockId: string, @@ -458,10 +451,13 @@ export class StoreService implements OnDestroy { * if the timing flipped). */ switchToken(newToken: string): void { - if (!isUuidV4(newToken)) return; + const token = newToken.toLowerCase(); + if (!isUuidV4(token)) return; this.cancelPendingWrites(); - safeSet(TOKEN_KEY, newToken); - this._token.set(newToken); + this.initGeneration++; + this.initPromise = null; + safeSet(TOKEN_KEY, token); + this._token.set(token); this._pages.set([]); this._loading.set(true); this._saveStatus.set('idle'); @@ -522,7 +518,7 @@ export class StoreService implements OnDestroy { try { await this.api.putData(put.token, put.tree); this._saveStatus.set('saved'); - this.updateCache(put.tree); + this.updateCache(put.token, put.tree); return; } catch (err: unknown) { const status = (err as { status?: number })?.status; @@ -566,8 +562,10 @@ export class StoreService implements OnDestroy { } await new Promise((resolve) => { + this.retryResolver = resolve; this.retryTimer = setTimeout(() => { this.retryTimer = null; + this.retryResolver = null; resolve(); }, delayMs); }); @@ -710,3 +708,22 @@ export class StoreService implements OnDestroy { } } } + +function reorder(items: readonly T[], fromIndex: number, toIndex: number): T[] | null { + if ( + fromIndex === toIndex || + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length + ) { + return null; + } + + const next = [...items]; + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +} diff --git a/frontend/src/app/services/store.service.vitest.ts b/frontend/src/app/services/store.service.vitest.ts index 273841a..bd04358 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { StoreService } from './store.service'; import { ApiService } from './api.service'; +import { AnalyticsService } from './analytics.service'; import type { TreeDto } from '../models'; // ── localStorage stub ──────────────────────────────────────────────────────── @@ -63,7 +64,9 @@ function makeMockApi(): MockApi { const FIXED_UUID = '11111111-2222-4333-8444-555555555555'; const TOKEN_KEY = 'life-towers.token.v4'; -const CACHE_KEY = 'life-towers.cache.v4'; +const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`; +const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff'; +const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`; // ── Helpers ────────────────────────────────────────────────────────────────── function configure(api: MockApi): StoreService { @@ -72,6 +75,18 @@ function configure(api: MockApi): StoreService { providers: [ provideZonelessChangeDetection(), { provide: ApiService, useValue: api }, + { + provide: AnalyticsService, + useValue: { + init: vi.fn(), + trackStart: vi.fn(), + trackExampleLoaded: vi.fn(), + trackPageCreated: vi.fn(), + trackTowerCreated: vi.fn(), + trackBlockCreated: vi.fn(), + trackBlockCompleted: vi.fn(), + }, + }, StoreService, ], }); @@ -137,6 +152,18 @@ describe('StoreService', () => { expect(store.token()).toBe(FIXED_UUID); }); + it('canonicalizes a stored uppercase token', async () => { + storage[TOKEN_KEY] = FIXED_UUID.toUpperCase(); + const api = makeMockApi(); + const store = configure(api); + + await store.init(); + + expect(storage[TOKEN_KEY]).toBe(FIXED_UUID); + expect(api.getData).toHaveBeenCalledWith(FIXED_UUID); + expect(store.token()).toBe(FIXED_UUID); + }); + it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => { storage[TOKEN_KEY] = 'not-a-uuid'; const api = makeMockApi(); @@ -292,13 +319,17 @@ describe('StoreService', () => { expect(page.towers).toHaveLength(3); const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); - expect(doneBlocks.length).toBeGreaterThanOrEqual(90); + const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0); + expect(doneSquares).toBeGreaterThanOrEqual(90); expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size) .toBeGreaterThan(1); for (const tower of page.towers) { const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at); - expect(doneDates.length).toBeGreaterThanOrEqual(30); + const doneSquareCount = tower.blocks + .filter((b) => b.is_done) + .reduce((sum, block) => sum + block.difficulty, 0); + expect(doneSquareCount).toBeGreaterThanOrEqual(30); expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1); } @@ -438,15 +469,73 @@ describe('StoreService', () => { // Mutate, then switch BEFORE the debounce fires. store.addPage('old-account'); - const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff'; api.getData.mockResolvedValue({ pages: [] }); - store.switchToken(newToken); + store.switchToken(OTHER_TOKEN); // Run all timers — the OLD debounce must have been cancelled, // so no PUT should have happened. await vi.advanceTimersByTimeAsync(2000); expect(api.putData).not.toHaveBeenCalled(); - expect(store.token()).toBe(newToken); + expect(store.token()).toBe(OTHER_TOKEN); + }); + + it('switchToken invalidates an init already in flight', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + let resolveFirstGet: ((v: TreeDto) => void) | null = null; + api.getData + .mockReturnValueOnce(new Promise((res) => (resolveFirstGet = res))) + .mockResolvedValueOnce({ pages: [mkPage('new-account')] }); + const store = configure(api); + + const firstInit = store.init(); + store.switchToken(OTHER_TOKEN); + resolveFirstGet!({ pages: [mkPage('old-account')] }); + await firstInit; + + expect(api.getData).toHaveBeenCalledWith(OTHER_TOKEN); + expect(store.token()).toBe(OTHER_TOKEN); + expect(store.pages()[0].name).toBe('new-account'); + }); + + it('switchToken cancels a pending retry without wedging future saves', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + api.putData + .mockRejectedValueOnce(httpError(429, { 'Retry-After': '30' })) + .mockResolvedValue(undefined); + const store = configure(api); + await store.init(); + + store.addPage('old-account'); + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(1); + + api.getData.mockResolvedValue({ pages: [] }); + store.switchToken(OTHER_TOKEN); + await vi.advanceTimersByTimeAsync(0); + + store.addPage('new-account'); + await vi.advanceTimersByTimeAsync(750); + + expect(api.putData).toHaveBeenCalledTimes(2); + expect(api.putData.mock.calls[1][0]).toBe(OTHER_TOKEN); + expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].name).toBe('new-account'); + }); + + it('does not load another account cache after switching tokens', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('old-cache')] } satisfies TreeDto); + const api = makeMockApi(); + const store = configure(api); + await store.init(); + + api.getData.mockRejectedValue(httpError(0)); + store.switchToken(OTHER_TOKEN); + await vi.advanceTimersByTimeAsync(0); + + expect(storage[OTHER_CACHE_KEY]).toBeUndefined(); + expect(store.pages()).toHaveLength(0); }); it('switchToken rejects a non-UUIDv4 input', () => { @@ -458,6 +547,19 @@ describe('StoreService', () => { expect(store.token()).toBe(''); // never initialized }); + it('switchToken canonicalizes uppercase UUID input', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + const store = configure(api); + await store.init(); + + store.switchToken(OTHER_TOKEN.toUpperCase()); + await vi.advanceTimersByTimeAsync(0); + + expect(store.token()).toBe(OTHER_TOKEN); + expect(storage[TOKEN_KEY]).toBe(OTHER_TOKEN); + }); + // ── Cross-tab sync ──────────────────────────────────────────────────────── it('adopts a fresh cache written by another tab via the storage event', async () => { diff --git a/frontend/src/app/styles/_form-shared.scss b/frontend/src/app/styles/_form-shared.scss deleted file mode 100644 index 54c4044..0000000 --- a/frontend/src/app/styles/_form-shared.scss +++ /dev/null @@ -1,106 +0,0 @@ -.form { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.field { - display: flex; - flex-direction: column; - gap: 0.35rem; - - label { - font-size: 0.85rem; - font-weight: 500; - color: var(--text-muted); - font-family: var(--font-ui); - } - - input[type='text'], - input[type='email'], - textarea { - width: 100%; - padding: 0.5rem 0.75rem; - border-radius: 0.5rem; - border: 1px solid var(--border); - background: var(--bg); - color: var(--text); - font-family: var(--font-ui); - font-size: 0.95rem; - resize: vertical; - - &:focus { - outline: 2px solid var(--accent); - outline-offset: 1px; - } - } - - &--checkbox { - flex-direction: row; - align-items: center; - - label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - color: var(--text); - } - } -} - -.actions { - display: flex; - gap: 0.75rem; - justify-content: flex-end; - padding-top: 0.5rem; -} - -.btn { - padding: 0.5rem 1.25rem; - border-radius: 0.5rem; - border: none; - cursor: pointer; - font-size: 0.95rem; - font-family: var(--font-ui); - transition: background 0.15s; - - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } - - &--primary { - background: var(--accent); - color: #fff; - - &:hover:not(:disabled) { - background: var(--accent-dark); - } - } - - &--secondary { - background: var(--surface-hover); - color: var(--text); - - &:hover:not(:disabled) { - background: var(--border); - } - } - - &--danger { - background: transparent; - color: #e53e3e; - border: 1px solid #e53e3e; - margin-right: auto; - - &:hover:not(:disabled) { - background: rgba(229, 62, 62, 0.1); - } - } -} diff --git a/frontend/src/assets/arrow.svg b/frontend/src/assets/arrow.svg deleted file mode 100644 index bf07063..0000000 --- a/frontend/src/assets/arrow.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/src/assets/fonts/material-icons.woff2 b/frontend/src/assets/fonts/material-icons.woff2 deleted file mode 100644 index 5492a6e..0000000 Binary files a/frontend/src/assets/fonts/material-icons.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/roboto-300.woff2 b/frontend/src/assets/fonts/roboto-300.woff2 deleted file mode 100644 index 9b0f141..0000000 Binary files a/frontend/src/assets/fonts/roboto-300.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/roboto-400.woff2 b/frontend/src/assets/fonts/roboto-400.woff2 deleted file mode 100644 index 9b0f141..0000000 Binary files a/frontend/src/assets/fonts/roboto-400.woff2 and /dev/null differ diff --git a/frontend/src/assets/fonts/roboto-500.woff2 b/frontend/src/assets/fonts/roboto-500.woff2 deleted file mode 100644 index 9b0f141..0000000 Binary files a/frontend/src/assets/fonts/roboto-500.woff2 and /dev/null differ diff --git a/frontend/src/assets/pen.svg b/frontend/src/assets/pen.svg deleted file mode 100644 index d798204..0000000 --- a/frontend/src/assets/pen.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/src/assets/plus-sign.svg b/frontend/src/assets/plus-sign.svg deleted file mode 100644 index 928fbee..0000000 --- a/frontend/src/assets/plus-sign.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/src/assets/trash.svg b/frontend/src/assets/trash.svg deleted file mode 100644 index 73e0a95..0000000 --- a/frontend/src/assets/trash.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/src/assets/x-sign.svg b/frontend/src/assets/x-sign.svg deleted file mode 100644 index 6f353d8..0000000 --- a/frontend/src/assets/x-sign.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/src/index.html b/frontend/src/index.html index 032f734..147335a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -4,7 +4,7 @@ Life Towers - + diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index e93bdc4..7b4c607 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -15,38 +15,6 @@ src: url('assets/fonts/raleway-400.woff2') format('woff2'); } -@font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url('assets/fonts/roboto-300.woff2') format('woff2'); -} - -@font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url('assets/fonts/roboto-400.woff2') format('woff2'); -} - -@font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url('assets/fonts/roboto-500.woff2') format('woff2'); -} - -@font-face { - font-family: 'Material Icons'; - font-style: normal; - font-weight: 400; - font-display: block; - src: url('assets/fonts/material-icons.woff2') format('woff2'); -} - @import 'library/main'; $line-height: 2px; @@ -56,7 +24,7 @@ $line-height: 2px; padding: 0; &:active, - &:focus { + &:focus:not(:focus-visible) { outline: 0; }