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..45f396a 100644 --- a/backend/src/life_towers/auth.py +++ b/backend/src/life_towers/auth.py @@ -2,11 +2,10 @@ from __future__ import annotations -import uuid - from fastapi import HTTPException, Request from .db import db_connection +from .models import _canonical_uuidv4 # Single generic detail used for ALL 401 responses. Per spec, the response # must not distinguish between missing / malformed / unknown tokens — that @@ -21,25 +20,28 @@ def _unauthorized() -> HTTPException: ) -def get_current_user(request: Request) -> str: - """Dependency that extracts and validates a Bearer token, returns user_id.""" +def extract_bearer_token(request: Request) -> str | None: + """Return the raw Bearer token from the Authorization header, or None.""" auth_header = request.headers.get("Authorization") or request.headers.get( "authorization" ) if not auth_header: - raise _unauthorized() - + return None parts = auth_header.split() - if len(parts) != 2 or parts[0].lower() != "bearer": - raise _unauthorized() + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return None - token = parts[1] + +def get_current_user(request: Request) -> str: + """Dependency that extracts and validates a Bearer token, returns user_id.""" + token = extract_bearer_token(request) + if token is None: + raise _unauthorized() try: - u = uuid.UUID(token) - if u.version != 4: - raise ValueError("Not v4") - except (ValueError, AttributeError): + token = _canonical_uuidv4(token) + except ValueError: raise _unauthorized() with db_connection() as conn: diff --git a/backend/src/life_towers/limits.py b/backend/src/life_towers/limits.py index b566d84..bc6f90d 100644 --- a/backend/src/life_towers/limits.py +++ b/backend/src/life_towers/limits.py @@ -8,6 +8,8 @@ from fastapi import Request, Response from slowapi import Limiter from slowapi.util import get_remote_address +from .auth import extract_bearer_token + PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB _TOO_LARGE_BODY = json.dumps( @@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps( def _get_token_or_ip(request: Request) -> str: """Key function for rate limiting: use Bearer token if present, else IP.""" - auth = request.headers.get("Authorization") or request.headers.get("authorization") - if auth: - parts = auth.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - return parts[1] - return get_remote_address(request) + return extract_bearer_token(request) or get_remote_address(request) limiter = Limiter(key_func=_get_token_or_ip, default_limits=[]) diff --git a/backend/src/life_towers/logging.py b/backend/src/life_towers/logging.py index fea46c0..539b01b 100644 --- a/backend/src/life_towers/logging.py +++ b/backend/src/life_towers/logging.py @@ -4,10 +4,13 @@ from __future__ import annotations import time import uuid as _uuid_mod +from hashlib import sha256 import structlog from fastapi import Request, Response +from .auth import extract_bearer_token + def configure_logging() -> None: """Configure structlog for JSON output.""" @@ -26,19 +29,19 @@ 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) # Extract user_id from Authorization header for logging (no DB call here) - auth = request.headers.get("Authorization") or request.headers.get("authorization") - user_id: str | None = None - if auth: - parts = auth.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - user_id = parts[1] + token = extract_bearer_token(request) + user_id = token_log_id(token) if token else None 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..b9be419 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,10 +90,15 @@ 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) + detail_str = "Validation failed for: " + ", ".join(fields) else: detail_str = "Validation failed" return JSONResponse( @@ -115,7 +116,7 @@ def create_app() -> FastAPI: code = STATUS_CODE_MAP.get(exc.status_code, "server_error") detail = {"error": code, "detail": str(exc.detail)} - headers = getattr(exc, "headers", None) or {} + headers = exc.headers or {} return JSONResponse(status_code=exc.status_code, content=detail, headers=headers) # Generic 500 handler diff --git a/backend/src/life_towers/migrations/001_initial.sql b/backend/src/life_towers/migrations/001_initial.sql index b8d3761..372c02f 100644 --- a/backend/src/life_towers/migrations/001_initial.sql +++ b/backend/src/life_towers/migrations/001_initial.sql @@ -1,10 +1,8 @@ -- Life Towers v4 initial schema. --- SQLite with WAL mode and foreign keys enabled at connection time. +-- WAL mode, foreign keys, and busy_timeout are applied per-connection in +-- db._apply_pragmas(), so they are not (and need not be) set here. -- All timestamps are unix epoch seconds (INTEGER). -PRAGMA journal_mode = WAL; -PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, 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..a4f8538 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -51,17 +51,6 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]: db_module._DB_PATH = None -# --------------------------------------------------------------------------- -# Health -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_health(client: AsyncClient) -> None: - resp = await client.get("/api/v1/health") - assert resp.status_code == 200 - assert resp.json() == {"status": "ok"} - - @pytest.mark.asyncio async def test_spa_index_injects_absolute_open_graph_urls( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -166,6 +155,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 +326,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..e8f741a 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -8,7 +8,6 @@ ], "analytics": false }, - "newProjectRoot": "projects", "projects": { "frontend": { "projectType": "application", @@ -77,9 +76,6 @@ "proxyConfig": "proxy.conf.json" } }, - "test": { - "builder": "@angular/build:unit-test" - }, "lint": { "builder": "@angular-eslint/builder:lint", "options": { @@ -92,4 +88,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 4a20b30..2493014 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -1,4 +1,21 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; + +async function expectTaskListOpen(page: Page, description: string): Promise { + const tasks = page.locator('lt-tasks', { hasText: description }).first(); + const taskBody = tasks.locator('.all-task'); + const taskRow = tasks.locator('.task-container', { hasText: description }).first(); + + await expect(tasks.locator('.header')).toHaveCount(0); + await expect + .poll(async () => + taskBody.evaluate((el) => { + const height = el.getBoundingClientRect().height; + return height / Math.max(1, el.scrollHeight); + }), + ) + .toBeGreaterThan(0.9); + await taskRow.locator('.tickbox').click({ trial: true }); +} /** * Smoke test: drives the legacy-styled UI end-to-end. @@ -11,8 +28,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 +44,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 +63,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 +91,93 @@ 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(); }); + + test('keep tasks open shows new pending tasks and survives immediate reload', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: 'Start empty' }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('lt-select-add .top').first().click(); + await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Work'); + await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter'); + + await page.locator('img[alt="Add tower"]').click(); + await page.locator('input[placeholder="New tower"]').fill('Today'); + await page.locator('lt-tower-settings button[type="submit"]').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByText('Keep tasks open').click(); + await page.locator('lt-settings .exit').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('img[alt="Add block"]').first().click(); + const createCard = page.locator('lt-block-edit .create-card'); + await expect(createCard.getByLabel('Already done')).not.toBeChecked(); + await createCard.locator('lt-select-add .top').click(); + await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('ops'); + await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter'); + await createCard + .locator('textarea[placeholder="Write a description here…"]') + .fill('Review deploy notes'); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await expectTaskListOpen(page, 'Review deploy notes'); + + await page.reload(); + + await expect(page.locator('lt-select-add .top').first()).toContainText('Work', { + timeout: 15000, + }); + await expectTaskListOpen(page, 'Review deploy notes'); + + await page.locator('img[alt="Add block"]').first().click(); + await expect(page.locator('lt-block-edit .create-card').getByLabel('Already done')).not.toBeChecked(); + }); + + test('keep tasks open expands existing pending tasks after reload', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: 'Start empty' }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('lt-select-add .top').first().click(); + await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Ops'); + await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter'); + + await page.locator('img[alt="Add tower"]').click(); + await page.locator('input[placeholder="New tower"]').fill('Queue'); + await page.locator('lt-tower-settings button[type="submit"]').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.locator('img[alt="Add block"]').first().click(); + await page.locator('lt-block-edit lt-select-add .top').click(); + await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').fill('triage'); + await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').press('Enter'); + await page + .locator('textarea[placeholder="Write a description here…"]') + .fill('Clean up alerts'); + await page.getByLabel('Already done').uncheck(); + await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByText('Keep tasks open').click(); + await page.locator('lt-settings .exit').click(); + await page.waitForSelector('section.modal', { state: 'detached' }); + + await page.reload(); + + await expect(page.locator('lt-select-add .top').first()).toContainText('Ops', { + timeout: 15000, + }); + await expectTaskListOpen(page, 'Clean up alerts'); + }); }); 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..58cad90 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -3,9 +3,7 @@ import { ChangeDetectionStrategy, input, output, - inject, signal, - computed, effect, viewChild, ElementRef, @@ -33,6 +31,14 @@ interface EditedValue { difficulty: number; } +function clampDifficulty(value: number): number { + return Math.max(1, Math.min(100, value)); +} + +export function createDoneValue(defaultDone: boolean, currentDone: boolean, edited: boolean): boolean { + return edited ? currentDone : defaultDone; +} + @Component({ selector: 'lt-block-edit', standalone: true, @@ -48,6 +54,8 @@ interface EditedValue { class="carousel" (scroll)="onScroll()" (click)="onBackdropClick($event)" + (keydown.enter)="onBackdropClick($any($event))" + tabindex="-1" >
@@ -56,12 +64,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 +147,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 +213,7 @@ interface EditedValue { type="button" class="step" aria-label="Increase difficulty" + [disabled]="newValue().difficulty >= 100" (click)="updateNewDifficulty(1); $event.stopPropagation()" >+
@@ -558,6 +590,7 @@ export class BlockEditComponent implements AfterViewInit { is_done: true, difficulty: 1, }); + private newDoneEdited = false; // 1-based index of the centered card. 0/N+2 are placeholders. readonly activeIdx = signal(1); @@ -585,13 +618,24 @@ export class BlockEditComponent implements AfterViewInit { const t = this.tags(); untracked(() => { const cur = this.newValue(); - if (!cur.tag && t.length > 0) { - this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() }); - } else if (!cur.tag) { - this.newValue.set({ ...cur, is_done: this.defaultDone() }); + if (!cur.tag) { + this.newValue.set({ + ...cur, + tag: t.length > 0 ? t[0] : '', + }); } }); }); + + effect(() => { + const isDone = this.defaultDone(); + untracked(() => { + this.newValue.update((v) => ({ + ...v, + is_done: createDoneValue(isDone, v.is_done, this.newDoneEdited), + })); + }); + }); } ngAfterViewInit(): void { @@ -619,19 +663,21 @@ export class BlockEditComponent implements AfterViewInit { ); } + private colorOfTag(tag: string): string { + return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent'; + } + colorOfTagForBlock(id: string): string { - const v = this.editedFor(id); - return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent'; + return this.colorOfTag(this.editedFor(id).tag); } tagPlaceholder(fallback: string): string { return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback; } - colorOfNewTag = computed(() => { - const t = this.newValue().tag; - return t ? getColorOfTag(t, this.baseColor()) : 'transparent'; - }); + colorOfNewTag(): string { + return this.colorOfTag(this.newValue().tag); + } formatDate(ts: number, compact = false): string { const d = new Date(ts * 1000); @@ -671,7 +717,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); } @@ -713,11 +759,12 @@ export class BlockEditComponent implements AfterViewInit { } updateNewDone(is_done: boolean): void { + this.newDoneEdited = true; this.newValue.update((v) => ({ ...v, is_done })); } updateNewDifficulty(delta: number): void { - this.newValue.update((v) => ({ ...v, difficulty: 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/block-edit.component.vitest.ts b/frontend/src/app/components/modal/block-edit.component.vitest.ts new file mode 100644 index 0000000..b69ca31 --- /dev/null +++ b/frontend/src/app/components/modal/block-edit.component.vitest.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { createDoneValue } from './block-edit.component'; + +describe('createDoneValue', () => { + it('uses the create-card default before the user edits the checkbox', () => { + expect(createDoneValue(false, true, false)).toBe(false); + expect(createDoneValue(true, false, false)).toBe(true); + }); + + it('keeps the user-edited checkbox value', () => { + expect(createDoneValue(false, true, true)).toBe(true); + expect(createDoneValue(true, false, true)).toBe(false); + }); +}); 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" > } @@ -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..544ed13 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 ──────────────────────────────────────────────────────── @@ -204,13 +214,16 @@ export class PageComponent { onTrashEnter(): void { this.nearTrashcan = true; - const preview = document.querySelector('.cdk-drag-preview'); - if (preview) preview.classList.add('trash-highlight'); + this.dragPreview()?.classList.add('trash-highlight'); } onTrashLeave(): void { this.nearTrashcan = false; - const preview = document.querySelector('.cdk-drag-preview'); - if (preview) preview.classList.remove('trash-highlight'); + this.dragPreview()?.classList.remove('trash-highlight'); + } + + /** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */ + private dragPreview(): Element | null { + return document.querySelector('.cdk-drag-preview'); } } diff --git a/frontend/src/app/components/pages/pages.component.scss b/frontend/src/app/components/pages/pages.component.scss index 631de3e..cdb7480 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%; @@ -71,12 +71,6 @@ } } - p { - strong { - font-weight: bold; - } - } - .confirm-buttons { display: flex; justify-content: center; diff --git a/frontend/src/app/components/pages/pages.component.ts b/frontend/src/app/components/pages/pages.component.ts index 900ba40..46e01b7 100644 --- a/frontend/src/app/components/pages/pages.component.ts +++ b/frontend/src/app/components/pages/pages.component.ts @@ -38,9 +38,10 @@ export class PagesComponent implements OnDestroy { constructor() { effect(() => { - if (!this.store.loading() && this.store.pages().length === 0) { + const pages = this.store.pages(); + if (!this.store.loading() && pages.length === 0) { this.showWelcome.set(true); - } else if (this.store.pages().length > 0) { + } else if (pages.length > 0) { this.showWelcome.set(false); } }); @@ -82,8 +83,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 ''; @@ -91,9 +90,10 @@ export class PagesComponent implements OnDestroy { }); readonly selectedPageIndex = computed(() => { + const pages = this.store.pages(); const page = this.selectedPage(); if (!page) return -1; - return this.store.pages().findIndex((p) => p.id === page.id); + return pages.findIndex((p) => p.id === page.id); }); onSelectPage(index: number): void { diff --git a/frontend/src/app/components/shared/color-picker/color-picker.component.ts b/frontend/src/app/components/shared/color-picker/color-picker.component.ts index 29292c3..e12ee89 100644 --- a/frontend/src/app/components/shared/color-picker/color-picker.component.ts +++ b/frontend/src/app/components/shared/color-picker/color-picker.component.ts @@ -185,9 +185,8 @@ export class ColorPickerComponent { return `hsl(${h}, 70%, 55%)`; } - toCss(c: HslColor): string { - return toCss(c); - } + /** Re-exported so the template can call the utility directly. */ + readonly toCss = toCss; pickHue(h: number): void { this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L }); 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..f4db1e4 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 @@ -18,7 +18,7 @@ export interface DoubleSliderRange { * Two-thumb range slider — legacy "double-slider". * Hands an indexed range over an arbitrary values array; emits the * underlying values on each change. Labels magnetically lift as a thumb - * approaches them (rotated -45°), per the legacy. + * approaches them (rotated -30°), per the legacy. */ @Component({ selector: 'lt-double-slider', @@ -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,42 +197,44 @@ 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))); } /** * Magnetic label position: returns a CSS `transform` that lifts the label - * upward and rotates -45° as a thumb approaches. + * upward and rotates -30° 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 = ''" /> + }
@for (b of pending(); track b.id) {
@@ -49,12 +70,12 @@ import { getColorOfTag } from '../../utils/color'; (click)="$event.stopPropagation(); markDone.emit(b)" [attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'" > -

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

+ >{{ b.description || b.tag }}
}
@@ -87,9 +108,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)); @@ -100,12 +131,9 @@ import { getColorOfTag } from '../../utils/color'; transition: max-height $long-animation-time; /* - * Clip during the open/close animation. We animate to the element's - * exact content height (read off #all in the template) so tall lists - * simply scroll inside the outer .container (max-height: 30vh) — - * .all-task itself is NOT a nested scroller. A nested overflow-y:auto - * here pops a scrollbar the instant a tickbox grows on hover, because - * its scale(1.05) + shadow widen the scrollable-overflow box. + * Clip while collapsed only. When open, let the outer .container own + * scrolling via max-height: 30vh; a nested scroller here pops a + * scrollbar the instant a tickbox grows on hover. */ overflow: hidden; @@ -124,7 +152,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 +215,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 +233,7 @@ import { getColorOfTag } from '../../utils/color'; } position: relative; + &::after { content: none; } } } } @@ -221,17 +252,18 @@ export class TasksComponent { /** Emitted when the description is clicked — parent opens the block-edit modal. */ readonly edit = output(); - readonly expanded = signal(false); - private lastToggleAt = 0; + private readonly manuallyExpanded = signal(false); + readonly expanded = computed(() => + shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()), + ); + readonly taskListMaxHeight = taskListMaxHeight; constructor() { - // Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping - // the "Keep tasks open" page setting expands/collapses the accordion live. - // User clicks (which mutate `expanded` directly) are respected until the - // setting changes again. + // When the page setting switches back to collapsed, discard any older manual + // open state so the setting is reflected immediately. effect(() => { - const open = this.initiallyOpen(); - untracked(() => this.expanded.set(open)); + const keepOpen = this.initiallyOpen(); + if (!keepOpen) untracked(() => this.manuallyExpanded.set(false)); }); } @@ -241,12 +273,7 @@ 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); + if (this.initiallyOpen()) return; + this.manuallyExpanded.update((v) => !v); } } diff --git a/frontend/src/app/components/tasks/tasks.component.vitest.ts b/frontend/src/app/components/tasks/tasks.component.vitest.ts new file mode 100644 index 0000000..fe12ec6 --- /dev/null +++ b/frontend/src/app/components/tasks/tasks.component.vitest.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { shouldExpandTasks, taskListMaxHeight } from './tasks.component'; + +describe('shouldExpandTasks', () => { + it('expands when tasks should be kept open by page setting', () => { + expect(shouldExpandTasks(true, false)).toBe(true); + }); + + it('expands when the user manually opens the accordion', () => { + expect(shouldExpandTasks(false, true)).toBe(true); + }); + + it('collapses when keep-open is disabled', () => { + expect(shouldExpandTasks(false, false)).toBe(false); + }); +}); + +describe('taskListMaxHeight', () => { + it('does not cap open task lists by a measured height', () => { + expect(taskListMaxHeight(true)).toBe('none'); + }); + + it('clips collapsed task lists', () => { + expect(taskListMaxHeight(false)).toBe('0px'); + }); +}); diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index be0db59..4dc87d0 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -21,11 +21,18 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett import { toCss } from '../../utils/color'; /** Tracks which entry path the block-edit modal was opened from. */ -interface EditEntry { +export interface EditEntry { filter: 'done' | 'pending'; activeId: string | null; } +export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry { + return { + filter: keepTasksOpen ? 'pending' : 'done', + activeId: null, + }; +} + /** A done block augmented with per-render animation state. */ export interface StyledBlock extends Block { _anim: '' | 'descend' | 'ascend'; @@ -140,7 +147,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 +551,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 +644,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(); } @@ -693,6 +702,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy { if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) { this._visibleBlocks.set([]); this.hiddenBlockCount.set(0); + this.prevDoneIds = allDone.map((b) => b.id); + this.isFirstRun = false; return; } @@ -706,9 +717,6 @@ export class TowerComponent implements AfterViewInit, OnDestroy { newIds.length === 1 && prev.every((id) => ids.includes(id)); // no IDs disappeared - const inRange = (b: Block) => - !range || (b.created_at >= range.from && b.created_at <= range.to); - const styled: StyledBlock[] = []; for (const b of allDone) { if (range && b.created_at < range.from) { @@ -753,7 +761,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { this.hiddenBlockCount.set(hiddenCount); if (this.isFirstRun && animateInitialStack) { - const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b)); + const initialBlocks = visibleStyled.filter((b) => b._opacity === '1'); if (initialBlocks.length > 0) { this.startDescendAnimation(visibleStyled, initialBlocks); this.prevDoneIds = ids; @@ -765,7 +773,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { if (grewByOne) { const newId = newIds[0]; const newBlock = visibleStyled.find((b) => b.id === newId); - if (newBlock && inRange(newBlock)) { + if (newBlock) { // Snap newly-added in-range block to start position, then on the next // paint flip it back to rest — that's what makes it visibly fall. this.startDescendAnimation(visibleStyled, [newBlock]); @@ -788,25 +796,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; } } @@ -851,7 +882,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { /** Called by the template "Add block" plus-icon. */ openAddBlock(): void { - this.editEntry.set({ filter: 'done', activeId: null }); + this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen())); } closeEdit(): void { diff --git a/frontend/src/app/components/tower/tower.component.vitest.ts b/frontend/src/app/components/tower/tower.component.vitest.ts index c54cc83..495f141 100644 --- a/frontend/src/app/components/tower/tower.component.vitest.ts +++ b/frontend/src/app/components/tower/tower.component.vitest.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { StyledBlock } from './tower.component'; -import { selectVisibleStyledBlocks } from './tower.component'; +import { editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component'; function block(id: string, opacity = '1', difficulty = 1): StyledBlock { return { @@ -127,3 +127,13 @@ describe('selectVisibleStyledBlocks', () => { expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']); }); }); + +describe('editEntryForNewBlock', () => { + it('opens the create card in the pending task view when tasks are kept open', () => { + expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null }); + }); + + it('keeps the existing completed-task default when tasks are collapsed', () => { + expect(editEntryForNewBlock(false)).toEqual({ filter: 'done', activeId: null }); + }); +}); diff --git a/frontend/src/app/components/welcome/welcome.component.ts b/frontend/src/app/components/welcome/welcome.component.ts index eefa8de..b571af2 100644 --- a/frontend/src/app/components/welcome/welcome.component.ts +++ b/frontend/src/app/components/welcome/welcome.component.ts @@ -333,20 +333,20 @@ import { A11yModule } from '@angular/cdk/a11y'; min-width: 0; } - .basic__label { + .basic__label, + .basic__text { font-family: inherit; font-weight: inherit; - color: $text-color; font-size: inherit; line-height: inherit; } + .basic__label { + color: $text-color; + } + .basic__text { - font-family: inherit; - font-weight: inherit; color: rgba($text-color, 0.82); - font-size: inherit; - line-height: inherit; } .actions { 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..4b9b4e3 100644 --- a/frontend/src/app/services/api.service.vitest.ts +++ b/frontend/src/app/services/api.service.vitest.ts @@ -1,28 +1,44 @@ -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 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('formats Authorization header correctly', () => { - const token = 'abc-def-123'; - const header = `Bearer ${token}`; - expect(header).toBe('Bearer abc-def-123'); + 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..71589bc 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -1,10 +1,11 @@ -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 PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4'; const DEBOUNCE_MS = 750; const MAX_RETRIES = 5; @@ -29,7 +30,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(), ); } @@ -50,10 +51,26 @@ function safeSet(key: string, value: string): void { /* ignore */ } } +function safeRemove(key: string): void { + try { + localStorage.removeItem(key); + } catch { + /* ignore */ + } +} + +function cacheKeyForToken(token: string): string { + return `${CACHE_KEY_PREFIX}.${token}`; +} + +function pendingCacheKeyForToken(token: string): string { + return `${PENDING_CACHE_KEY_PREFIX}.${token}`; +} interface PendingPut { token: string; tree: TreeDto; + revision: number; } interface ExampleBlockSeed { @@ -87,26 +104,22 @@ 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; + // Monotonic local mutation version. Prevents an older in-flight PUT from + // clearing a newer pending cache entry when it completes. + private localMutationRevision = 0; // ── Cross-tab sync ───────────────────────────────────────────────────────── private readonly storageListener = (e: StorageEvent) => { if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) { - // 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 +133,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 +157,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 +177,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,57 +185,73 @@ 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 (safeGet(pendingCacheKeyForToken(token))) { + const cachedTree = this.readCachedTree(token); + if (cachedTree?.pages && cachedTree.pages.length > 0) { + this._pages.set(cachedTree.pages); + this.scheduleSave(); + return; + } + } + if (tree.pages.length === 0) { - const cached = safeGet(CACHE_KEY); - if (cached) { - try { - const cachedTree: TreeDto = JSON.parse(cached); - if (cachedTree.pages && cachedTree.pages.length > 0) { - this._pages.set(cachedTree.pages); - this.scheduleSave(); - return; - } - } catch { - /* fall through to server-empty */ - } + const cachedTree = this.readCachedTree(token); + if (cachedTree?.pages && cachedTree.pages.length > 0) { + this._pages.set(cachedTree.pages); + this.scheduleSave(); + return; } } this._pages.set(tree.pages); - this.updateCache(tree); + this.updateCache(token, tree); } - private loadFromCache(): void { - const raw = safeGet(CACHE_KEY); - if (raw) { - try { - const tree: TreeDto = JSON.parse(raw); - this._pages.set(tree.pages); - } catch { - /* ignore */ - } + private loadFromCache(token: string): void { + const tree = this.readCachedTree(token); + if (tree) this._pages.set(tree.pages); + } + + private readCachedTree(token: string): TreeDto | null { + const raw = safeGet(cacheKeyForToken(token)); + if (!raw) return null; + try { + return JSON.parse(raw) as TreeDto; + } catch { + return null; } } - private updateCache(tree: TreeDto): void { - safeSet(CACHE_KEY, JSON.stringify(tree)); + private updateCache(token: string, tree: TreeDto, pending = false): void { + safeSet(cacheKeyForToken(token), JSON.stringify(tree)); + if (pending) { + safeSet(pendingCacheKeyForToken(token), '1'); + } else { + safeRemove(pendingCacheKeyForToken(token)); + } } private cancelPendingWrites(): void { @@ -227,6 +263,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 +302,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 +342,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 +394,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 +402,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 +438,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,19 +477,27 @@ 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'); + this.localMutationRevision = 0; void this.init(); } // ── Save / sync ──────────────────────────────────────────────────────────── private scheduleSave(): void { + this.localMutationRevision += 1; + const token = this._token(); + if (token) this.updateCache(token, { pages: this._pages() }, true); + if (this.flushInFlight) { // A save is already happening. Mark dirty so we re-flush when it finishes. this.dirtyDuringFlush = true; @@ -498,6 +525,7 @@ export class StoreService implements OnDestroy { this.flushInFlight = true; this.dirtyDuringFlush = false; + const revision = this.localMutationRevision; // Cancel any pending retry — runFlush() supersedes it. if (this.retryTimer !== null) { @@ -506,7 +534,7 @@ export class StoreService implements OnDestroy { } try { - await this.attempt({ token, tree: { pages: this._pages() } }, 0); + await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0); } finally { this.flushInFlight = false; // Coalesce mutations that arrived during the flush into a fresh save. @@ -522,7 +550,13 @@ export class StoreService implements OnDestroy { try { await this.api.putData(put.token, put.tree); this._saveStatus.set('saved'); - this.updateCache(put.tree); + if ( + this._token() === put.token && + put.revision === this.localMutationRevision && + !this.dirtyDuringFlush + ) { + this.updateCache(put.token, put.tree); + } return; } catch (err: unknown) { const status = (err as { status?: number })?.status; @@ -566,8 +600,10 @@ export class StoreService implements OnDestroy { } await new Promise((resolve) => { + this.retryResolver = resolve; this.retryTimer = setTimeout(() => { this.retryTimer = null; + this.retryResolver = null; resolve(); }, delayMs); }); @@ -575,7 +611,10 @@ export class StoreService implements OnDestroy { // The token may have changed during the wait — re-check before retrying. if (this._token() !== put.token) return; // Re-snapshot the latest pages so the retry pushes current state. - await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1); + await this.attempt( + { token: put.token, tree: { pages: this._pages() }, revision: put.revision }, + attempt + 1, + ); } } @@ -710,3 +749,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..2ecbe76 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,10 @@ 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 PENDING_CACHE_KEY = `life-towers.cache-pending.v4.${FIXED_UUID}`; +const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff'; +const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`; // ── Helpers ────────────────────────────────────────────────────────────────── function configure(api: MockApi): StoreService { @@ -72,6 +76,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 +153,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 +320,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); } @@ -321,6 +353,7 @@ describe('StoreService', () => { expect(api.putData).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(750); expect(api.putData).toHaveBeenCalledTimes(1); + expect(storage[PENDING_CACHE_KEY]).toBeUndefined(); const [, tree] = api.putData.mock.calls[0]; expect((tree as TreeDto).pages).toHaveLength(3); }); @@ -351,6 +384,97 @@ describe('StoreService', () => { expect(lastTree.pages).toHaveLength(2); }); + it('does not let an older in-flight save clear a newer pending cache entry', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + let resolveFirstPut: (() => void) | null = null; + api.putData + .mockReturnValueOnce(new Promise((res) => (resolveFirstPut = () => res()))) + .mockResolvedValueOnce(undefined); + const store = configure(api); + await store.init(); + + store.addPage('first'); + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(1); + + store.addPage('second'); + expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name)) + .toEqual(['first', 'second']); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + resolveFirstPut!(); + await vi.advanceTimersByTimeAsync(0); + + expect(JSON.parse(storage[CACHE_KEY]).pages.map((p: TreeDto['pages'][number]) => p.name)) + .toEqual(['first', 'second']); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(2); + expect(storage[PENDING_CACHE_KEY]).toBeUndefined(); + }); + + it('keeps a pending local mutation across reload before the debounce saves', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + const serverPage = mkPage('server'); + api.getData.mockResolvedValue({ pages: [serverPage] }); + const store = configure(api); + await store.init(); + + store.updatePage(FIXED_UUID, { keep_tasks_open: true }); + + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + store.ngOnDestroy(); + + const reloadedApi = makeMockApi(); + reloadedApi.getData.mockResolvedValue({ pages: [serverPage] }); + const reloadedStore = configure(reloadedApi); + await reloadedStore.init(); + + expect(reloadedStore.pages()[0].keep_tasks_open).toBe(true); + + await vi.advanceTimersByTimeAsync(750); + expect(reloadedApi.putData).toHaveBeenCalledTimes(1); + const [, tree] = reloadedApi.putData.mock.calls[0]; + expect((tree as TreeDto).pages[0].keep_tasks_open).toBe(true); + }); + + it('does not let a stale in-flight save clear a newer pending settings cache', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + const serverPage = mkPage('server'); + let resolveFirstPut: (() => void) | null = null; + api.getData.mockResolvedValue({ pages: [serverPage] }); + api.putData + .mockReturnValueOnce(new Promise((res) => (resolveFirstPut = () => res()))) + .mockResolvedValueOnce(undefined); + const store = configure(api); + await store.init(); + + store.updatePage(FIXED_UUID, { name: 'renamed' }); + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(1); + expect((api.putData.mock.calls[0][1] as TreeDto).pages[0].keep_tasks_open).toBe(false); + + store.updatePage(FIXED_UUID, { keep_tasks_open: true }); + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + resolveFirstPut!(); + await vi.advanceTimersByTimeAsync(0); + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBe('1'); + + await vi.advanceTimersByTimeAsync(750); + expect(api.putData).toHaveBeenCalledTimes(2); + expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].keep_tasks_open).toBe(true); + expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true); + expect(storage[PENDING_CACHE_KEY]).toBeUndefined(); + }); + // ── Error handling ──────────────────────────────────────────────────────── it('marks status "too-large" on 413 and does NOT retry', async () => { @@ -438,15 +562,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 +640,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/app/utils/hash.ts b/frontend/src/app/utils/hash.ts index fffe090..d95c6da 100644 --- a/frontend/src/app/utils/hash.ts +++ b/frontend/src/app/utils/hash.ts @@ -16,5 +16,5 @@ export function hash(s: string): number { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } // Map the signed int32 to [0, 1) — same formula as legacy - return h / (Math.pow(2, 32) - 2) + 0.5; + return h / (2 ** 32 - 2) + 0.5; } 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; } diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 264f459..bec29af 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -10,6 +10,6 @@ "src/**/*.ts" ], "exclude": [ - "src/**/*.spec.ts" + "src/**/*.vitest.ts" ] } diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index d383706..6883a29 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -10,6 +10,6 @@ }, "include": [ "src/**/*.d.ts", - "src/**/*.spec.ts" + "src/**/*.vitest.ts" ] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index af0b622..8dad81b 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', - include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'], + include: ['src/**/*.vitest.ts'], setupFiles: ['./vitest.setup.ts'], coverage: { provider: 'v8',