diff --git a/Dockerfile b/Dockerfile index 82fba50..aad08e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,6 @@ 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 @@ -54,4 +53,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 ["sh", "-c", "uvicorn life_towers.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${LIFE_TOWERS_FORWARDED_ALLOW_IPS}"] +CMD ["uvicorn", "life_towers.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/README.md b/README.md index b555e61..05bd0f8 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,8 @@ 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 -export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest -docker compose pull -docker compose up -d +LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \ + docker compose pull && docker compose up -d ``` ## Environment variables @@ -93,6 +92,6 @@ docker compose -f docker-compose.dev.yml down -v ## Deployment -Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`. +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. -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. +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. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c643010..3e93509 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,13 @@ 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 8fbcc7f..9fcee2a 100644 --- a/backend/src/life_towers/api.py +++ b/backend/src/life_towers/api.py @@ -1,6 +1,8 @@ +"""APIRouter with all Life Towers endpoints.""" + from __future__ import annotations -import sqlite3 +import json import time from typing import Annotated @@ -10,7 +12,6 @@ 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, @@ -52,7 +53,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse: (now, token), ) conn.commit() - logger.info("user_registered", user_id=token_log_id(token), new=existing is None) + logger.info("user_registered", user_id=token, new=existing is None) return RegisterResponse(user_id=token) @@ -238,14 +239,8 @@ 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=token_log_id(user_id), pages=len(body.pages)) + logger.info("data_replaced", user_id=user_id, pages=len(body.pages)) diff --git a/backend/src/life_towers/auth.py b/backend/src/life_towers/auth.py index 45f396a..4142948 100644 --- a/backend/src/life_towers/auth.py +++ b/backend/src/life_towers/auth.py @@ -2,10 +2,11 @@ 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 @@ -20,28 +21,25 @@ def _unauthorized() -> HTTPException: ) -def extract_bearer_token(request: Request) -> str | None: - """Return the raw Bearer token from the Authorization header, or None.""" +def get_current_user(request: Request) -> str: + """Dependency that extracts and validates a Bearer token, returns user_id.""" auth_header = request.headers.get("Authorization") or request.headers.get( "authorization" ) if not auth_header: - return None - parts = auth_header.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - return parts[1] - return None - - -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() + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + raise _unauthorized() + + token = parts[1] + try: - token = _canonical_uuidv4(token) - except ValueError: + u = uuid.UUID(token) + if u.version != 4: + raise ValueError("Not v4") + except (ValueError, AttributeError): raise _unauthorized() with db_connection() as conn: diff --git a/backend/src/life_towers/limits.py b/backend/src/life_towers/limits.py index bc6f90d..b566d84 100644 --- a/backend/src/life_towers/limits.py +++ b/backend/src/life_towers/limits.py @@ -8,8 +8,6 @@ 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( @@ -22,7 +20,12 @@ _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.""" - return extract_bearer_token(request) or get_remote_address(request) + 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) 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 539b01b..fea46c0 100644 --- a/backend/src/life_towers/logging.py +++ b/backend/src/life_towers/logging.py @@ -4,13 +4,10 @@ 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.""" @@ -29,19 +26,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 without writing bearer credentials to the log stream.""" + """Log each request with method, path, status, duration_ms, user_id, request_id.""" 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) - token = extract_bearer_token(request) - user_id = token_log_id(token) if token else None + 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] 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 b9be419..b9dd444 100644 --- a/backend/src/life_towers/main.py +++ b/backend/src/life_towers/main.py @@ -1,5 +1,8 @@ +"""ASGI app, lifespan, static files mount, route registration.""" + from __future__ import annotations +import json import os from contextlib import asynccontextmanager from html import escape @@ -34,6 +37,7 @@ STATUS_CODE_MAP: dict[int, str] = { 413: "payload_too_large", 422: "bad_request", 429: "rate_limited", + 507: "quota_exceeded", 500: "server_error", } @@ -90,15 +94,10 @@ def create_app() -> FastAPI: request: Request, exc: RequestValidationError ) -> JSONResponse: fields = sorted( - field - for field in { - ".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") - for e in exc.errors() - } - if field + {".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()} ) if fields: - detail_str = "Validation failed for: " + ", ".join(fields) + detail_str = "Validation failed for: " + ", ".join(f for f in fields if f) else: detail_str = "Validation failed" return JSONResponse( @@ -116,7 +115,7 @@ def create_app() -> FastAPI: code = STATUS_CODE_MAP.get(exc.status_code, "server_error") detail = {"error": code, "detail": str(exc.detail)} - headers = exc.headers or {} + headers = getattr(exc, "headers", None) 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 372c02f..b8d3761 100644 --- a/backend/src/life_towers/migrations/001_initial.sql +++ b/backend/src/life_towers/migrations/001_initial.sql @@ -1,8 +1,10 @@ -- Life Towers v4 initial schema. --- 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. +-- SQLite with WAL mode and foreign keys enabled at connection time. -- 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 b40d5e3..4fe8694 100644 --- a/backend/src/life_towers/models.py +++ b/backend/src/life_towers/models.py @@ -8,14 +8,12 @@ from pydantic import BaseModel, Field, field_validator, model_validator import uuid as _uuid_mod -def _canonical_uuidv4(value: str) -> str: +def _is_uuidv4(value: str) -> bool: try: u = _uuid_mod.UUID(value) - if u.version == 4: - return str(u) + return u.version == 4 except (ValueError, AttributeError): - pass - raise ValueError("must be a UUIDv4") + return False class HslColor(BaseModel): @@ -35,7 +33,9 @@ class BlockIn(BaseModel): @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: - return _canonical_uuidv4(v) + if not _is_uuidv4(v): + raise ValueError(f"id must be a UUIDv4, got: {v!r}") + return v class BlockOut(BaseModel): @@ -56,7 +56,9 @@ class TowerIn(BaseModel): @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: - return _canonical_uuidv4(v) + if not _is_uuidv4(v): + raise ValueError(f"id must be a UUIDv4, got: {v!r}") + return v class TowerOut(BaseModel): @@ -78,7 +80,9 @@ class PageIn(BaseModel): @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: - return _canonical_uuidv4(v) + if not _is_uuidv4(v): + raise ValueError(f"id must be a UUIDv4, got: {v!r}") + return v class PageOut(BaseModel): @@ -135,7 +139,9 @@ class RegisterRequest(BaseModel): @field_validator("token") @classmethod def validate_token(cls, v: str) -> str: - return _canonical_uuidv4(v) + if not _is_uuidv4(v): + raise ValueError("token must be a UUIDv4") + return v class RegisterResponse(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a4f8538..0037c88 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -51,6 +51,17 @@ 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 @@ -155,21 +166,6 @@ 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 # --------------------------------------------------------------------------- @@ -326,34 +322,6 @@ 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 90f555b..7f77e7c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -201,6 +201,13 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.dev-dependencies] dev = [ { name = "httpx" }, @@ -211,11 +218,15 @@ 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 3fe60d1..78d1bf3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,11 +1,59 @@ -# Life Towers Frontend +# Frontend -Angular frontend for the Life Towers app. See the root `README.md` for the full -stack setup. +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: ```bash -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 +ng serve ``` + +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 e8f741a..e4ba9a9 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -8,6 +8,7 @@ ], "analytics": false }, + "newProjectRoot": "projects", "projects": { "frontend": { "projectType": "application", @@ -76,6 +77,9 @@ "proxyConfig": "proxy.conf.json" } }, + "test": { + "builder": "@angular/build:unit-test" + }, "lint": { "builder": "@angular-eslint/builder:lint", "options": { @@ -88,4 +92,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 2493014..4a20b30 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -1,21 +1,4 @@ -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 }); -} +import { test, expect } from '@playwright/test'; /** * Smoke test: drives the legacy-styled UI end-to-end. @@ -28,11 +11,8 @@ test.describe('Life Towers smoke test', () => { test('create page → tower → block, mark done, reload, persists', async ({ page }) => { await page.goto('/'); - // 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(); + // Wait for the empty-state hint that means init() completed. + await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 }); // Create a page via the select-add dropdown. await page.locator('lt-select-add .top').first().click(); @@ -44,7 +24,7 @@ test.describe('Life Towers smoke test', () => { // Create a tower. await page.locator('img[alt="Add tower"]').click(); - await page.locator('input[placeholder="New tower"]').fill('Side projects'); + await page.locator('input[placeholder="Tower name…"]').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. @@ -63,25 +43,27 @@ test.describe('Life Towers smoke test', () => { await page.locator('textarea[placeholder="Write a description here…"]').fill( 'Modernise the towers app', ); - await page.getByLabel('Already done').uncheck(); - await page.getByRole('button', { name: 'Create and exit', exact: true }).click(); + await page.getByRole('button', { name: 'Create and exit' }).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 .header').click(); - await page.locator('lt-tasks .task-description').click(); + await page.locator('lt-tasks .container').click(); + await page.locator('lt-tasks .task-container').click(); - // Toggle done in the block-edit modal. + // Toggle done in the block-edit modal — the right label is "Done". const putLanded = page.waitForResponse( (r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(), ); - await page.locator('lt-block-edit .card.active').getByLabel('Already done').check(); + // 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 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(); @@ -91,93 +73,7 @@ test.describe('Life Towers smoke test', () => { await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', { timeout: 15000, }); - await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects'); + await expect(page.getByDisplayValue('Side projects')).toBeVisible(); 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 dc538ab..a1e3384 100644 --- a/frontend/e2e/visuals.spec.ts +++ b/frontend/e2e/visuals.spec.ts @@ -1,10 +1,5 @@ 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. @@ -60,12 +55,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', exact: true }).click(); + await page.getByRole('button', { name: 'Create and exit' }).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 .header').click(); + await page.locator('lt-tasks .container').click(); await page.waitForTimeout(300); await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true }); @@ -88,7 +83,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', exact: true }).click(); + await page.getByRole('button', { name: 'Create and exit' }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); } @@ -201,9 +196,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 .header').first().click(); + await page.locator('lt-tasks .container').first().click(); await page.waitForTimeout(400); - await page.locator('lt-tasks .task-description').first().click(); + await page.locator('lt-tasks .task-container').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 8ea82d5..84bcb8a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@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", @@ -718,6 +719,24 @@ } } }, + "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 a9aaf77..8c0c02c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@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 8a08685..b8fcf5c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -4,6 +4,7 @@ import { isDevMode, provideZonelessChangeDetection, } from '@angular/core'; +import { provideRouter } from '@angular/router'; import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideServiceWorker } from '@angular/service-worker'; @@ -11,6 +12,7 @@ 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 new file mode 100644 index 0000000..0edf11a --- /dev/null +++ b/frontend/src/app/app.html @@ -0,0 +1,343 @@ + + + + + + + + + + + +
+
+
+ +

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 new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index ec56fbb..05f109e 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(); - void this.store.init(); + 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 58cad90..cd50b5b 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -3,7 +3,9 @@ import { ChangeDetectionStrategy, input, output, + inject, signal, + computed, effect, viewChild, ElementRef, @@ -31,14 +33,6 @@ 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, @@ -54,8 +48,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit class="carousel" (scroll)="onScroll()" (click)="onBackdropClick($event)" - (keydown.enter)="onBackdropClick($any($event))" - tabindex="-1" >
@@ -64,22 +56,12 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit 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)" >
- +
+
@@ -147,22 +127,12 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit 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
-
+
@@ -213,7 +182,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit type="button" class="step" aria-label="Increase difficulty" - [disabled]="newValue().difficulty >= 100" (click)="updateNewDifficulty(1); $event.stopPropagation()" >+
@@ -590,7 +558,6 @@ 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); @@ -618,24 +585,13 @@ export class BlockEditComponent implements AfterViewInit { const t = this.tags(); untracked(() => { const cur = this.newValue(); - if (!cur.tag) { - this.newValue.set({ - ...cur, - tag: t.length > 0 ? t[0] : '', - }); + 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() }); } }); }); - - effect(() => { - const isDone = this.defaultDone(); - untracked(() => { - this.newValue.update((v) => ({ - ...v, - is_done: createDoneValue(isDone, v.is_done, this.newDoneEdited), - })); - }); - }); } ngAfterViewInit(): void { @@ -663,21 +619,19 @@ export class BlockEditComponent implements AfterViewInit { ); } - private colorOfTag(tag: string): string { - return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent'; - } - colorOfTagForBlock(id: string): string { - return this.colorOfTag(this.editedFor(id).tag); + const v = this.editedFor(id); + return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent'; } tagPlaceholder(fallback: string): string { return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback; } - colorOfNewTag(): string { - return this.colorOfTag(this.newValue().tag); - } + colorOfNewTag = computed(() => { + const t = this.newValue().tag; + return t ? getColorOfTag(t, this.baseColor()) : 'transparent'; + }); formatDate(ts: number, compact = false): string { const d = new Date(ts * 1000); @@ -717,7 +671,7 @@ export class BlockEditComponent implements AfterViewInit { } updateDifficulty(id: string, delta: number): void { - const next = clampDifficulty(this.editedFor(id).difficulty + delta); + const next = Math.max(1, this.editedFor(id).difficulty + delta); this.patchEdited(id, { difficulty: next }); this.flushExisting(id); } @@ -759,12 +713,11 @@ 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: clampDifficulty(v.difficulty + delta) })); + this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, 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 deleted file mode 100644 index b69ca31..0000000 --- a/frontend/src/app/components/modal/block-edit.component.vitest.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 47c9eb8..3a0fda2 100644 --- a/frontend/src/app/components/modal/modal.component.ts +++ b/frontend/src/app/components/modal/modal.component.ts @@ -23,8 +23,6 @@ import { ModalStateService } from '../../services/modal-state.service'; class="modal" [class.active]="active()" (click)="onBackdropClick($event)" - (keydown.enter)="onBackdropClick($any($event))" - tabindex="-1" > } @@ -66,7 +57,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 ed8aa0c..cf5689d 100644 --- a/frontend/src/app/components/page/page.component.scss +++ b/frontend/src/app/components/page/page.component.scss @@ -1,4 +1,4 @@ -@import '../../../library/main'; +@import '../../../styles'; :host { display: flex; diff --git a/frontend/src/app/components/page/page.component.ts b/frontend/src/app/components/page/page.component.ts index 544ed13..7311479 100644 --- a/frontend/src/app/components/page/page.component.ts +++ b/frontend/src/app/components/page/page.component.ts @@ -4,11 +4,8 @@ import { input, output, signal, - computed, inject, HostListener, - effect, - untracked, } from '@angular/core'; import { Page } from '../../models'; import { StoreService } from '../../services/store.service'; @@ -19,6 +16,7 @@ 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'; @@ -118,14 +116,6 @@ 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 }); } @@ -157,7 +147,7 @@ export class PageComponent { } onDeleteTower(towerId: string): void { - this.confirmDeleteTowerId.set(towerId); + this.store.deleteTower(this.page().id, towerId); } // ── Block mutations ──────────────────────────────────────────────────────── @@ -214,16 +204,13 @@ export class PageComponent { onTrashEnter(): void { this.nearTrashcan = true; - this.dragPreview()?.classList.add('trash-highlight'); + const preview = document.querySelector('.cdk-drag-preview'); + if (preview) preview.classList.add('trash-highlight'); } onTrashLeave(): void { this.nearTrashcan = false; - 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'); + const preview = document.querySelector('.cdk-drag-preview'); + if (preview) preview.classList.remove('trash-highlight'); } } diff --git a/frontend/src/app/components/pages/pages.component.scss b/frontend/src/app/components/pages/pages.component.scss index cdb7480..631de3e 100644 --- a/frontend/src/app/components/pages/pages.component.scss +++ b/frontend/src/app/components/pages/pages.component.scss @@ -1,4 +1,4 @@ -@import '../../../library/main'; +@import '../../../styles'; :host { height: 100%; @@ -71,6 +71,12 @@ } } + 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 46e01b7..900ba40 100644 --- a/frontend/src/app/components/pages/pages.component.ts +++ b/frontend/src/app/components/pages/pages.component.ts @@ -38,10 +38,9 @@ export class PagesComponent implements OnDestroy { constructor() { effect(() => { - const pages = this.store.pages(); - if (!this.store.loading() && pages.length === 0) { + if (!this.store.loading() && this.store.pages().length === 0) { this.showWelcome.set(true); - } else if (pages.length > 0) { + } else if (this.store.pages().length > 0) { this.showWelcome.set(false); } }); @@ -83,6 +82,8 @@ 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 ''; @@ -90,10 +91,9 @@ export class PagesComponent implements OnDestroy { }); readonly selectedPageIndex = computed(() => { - const pages = this.store.pages(); const page = this.selectedPage(); if (!page) return -1; - return pages.findIndex((p) => p.id === page.id); + return this.store.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 e12ee89..29292c3 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,8 +185,9 @@ export class ColorPickerComponent { return `hsl(${h}, 70%, 55%)`; } - /** Re-exported so the template can call the utility directly. */ - readonly toCss = toCss; + toCss(c: HslColor): string { + return toCss(c); + } 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 f4db1e4..f118e44 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 -30°), per the legacy. + * approaches them (rotated -45°), per the legacy. */ @Component({ selector: 'lt-double-slider', @@ -32,7 +32,7 @@ export interface DoubleSliderRange { id="ds-1" type="range" min="0" - [max]="maxIndex()" + [max]="MAX - 1" [value]="oneValue()" (input)="oneValue.set(+$any($event.target).value)" /> @@ -40,7 +40,7 @@ export interface DoubleSliderRange { id="ds-2" type="range" min="0" - [max]="maxIndex()" + [max]="MAX - 1" [value]="otherValue()" (input)="otherValue.set(+$any($event.target).value)" /> @@ -168,9 +168,10 @@ export class DoubleSliderComponent { readonly rangeChange = output>(); + readonly MAX = 100; + readonly oneValue = signal(0); - readonly otherValue = signal(0); - readonly maxIndex = computed(() => Math.max(0, this.values().length - 1)); + readonly otherValue = signal(this.MAX - 1); private prevValuesLength = 0; @@ -197,44 +198,42 @@ export class DoubleSliderComponent { const hi = Math.max(a, b); untracked(() => { this.rangeChange.emit({ - from: vs[this.clampIndex(lo)], - to: vs[this.clampIndex(hi)], + from: vs[this.indexFromValue(lo)], + to: vs[this.indexFromValue(hi)], }); }); }); - // Snap the higher thumb to the newest value when a new entry is appended. + // Snap the higher thumb to MAX - 1 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(max); - else this.otherValue.set(max); - } else { - if (this.oneValue() > max) this.oneValue.set(max); - if (this.otherValue() > max) this.otherValue.set(max); + if (a > b) this.oneValue.set(this.MAX - 1); + else this.otherValue.set(this.MAX - 1); } this.prevValuesLength = len; }); }); } - private clampIndex(value: number): number { - return Math.max(0, Math.min(this.values().length - 1, Math.round(value))); + private indexFromValue(value: number): number { + return Math.min( + this.values().length - 1, + Math.floor((value / this.MAX) * this.values().length), + ); } /** * Magnetic label position: returns a CSS `transform` that lifts the label - * upward and rotates -30° as a thumb approaches. + * upward and rotates -45° as a thumb approaches. */ getOffset(index: number): string { - 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 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 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 baac459..5ee768e 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,6 +4,8 @@ import { input, output, signal, + OnChanges, + SimpleChanges, ElementRef, HostListener, inject, @@ -20,15 +22,7 @@ import { [class.always-shadow]="alwaysDropShadow()" >
-
+

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

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

+ {{ pending().length === 0 ? '' : pending().length }} + {{ pending().length === 0 ? '​' : pending().length === 1 ? 'task' : 'tasks' }} +

@for (b of pending(); track b.id) {
@@ -70,12 +49,12 @@ export function taskListMaxHeight(expanded: boolean): string { (click)="$event.stopPropagation(); markDone.emit(b)" [attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'" > - + >{{ b.description || b.tag }}

}
@@ -108,19 +87,9 @@ export function taskListMaxHeight(expanded: boolean): string { max-height: 30vh; overflow-y: auto; - .header { - all: unset; - @include medium-text(); - display: block; - width: 100%; - box-sizing: border-box; - cursor: pointer; - text-align: center; + .header { cursor: pointer; } - &::after { - content: none; - } - } + p { font-size: var(--medium-font-size); } .all-task { @include inner-spacing(var(--small-padding)); @@ -131,9 +100,12 @@ export function taskListMaxHeight(expanded: boolean): string { transition: max-height $long-animation-time; /* - * 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. + * Clip during the open/close animation. We animate to the element's + * exact content height (read off #all in the template) so tall lists + * simply scroll inside the outer .container (max-height: 30vh) — + * .all-task itself is NOT a nested scroller. A nested overflow-y:auto + * here pops a scrollbar the instant a tickbox grows on hover, because + * its scale(1.05) + shadow widen the scrollable-overflow box. */ overflow: hidden; @@ -152,7 +124,7 @@ export function taskListMaxHeight(expanded: boolean): string { gap: calc(var(--small-padding) / 2); } - &:hover .task-description { + &:hover p { @media (min-width: $mobile-width) { color: inherit !important; } @@ -215,9 +187,7 @@ export function taskListMaxHeight(expanded: boolean): string { } } - .task-description { - all: unset; - @include medium-text(); + p { white-space: nowrap; text-overflow: ellipsis; overflow-x: hidden; @@ -233,7 +203,6 @@ export function taskListMaxHeight(expanded: boolean): string { } position: relative; - &::after { content: none; } } } } @@ -252,18 +221,17 @@ export class TasksComponent { /** Emitted when the description is clicked — parent opens the block-edit modal. */ readonly edit = output(); - private readonly manuallyExpanded = signal(false); - readonly expanded = computed(() => - shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()), - ); - readonly taskListMaxHeight = taskListMaxHeight; + readonly expanded = signal(false); + private lastToggleAt = 0; constructor() { - // When the page setting switches back to collapsed, discard any older manual - // open state so the setting is reflected immediately. + // 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. effect(() => { - const keepOpen = this.initiallyOpen(); - if (!keepOpen) untracked(() => this.manuallyExpanded.set(false)); + const open = this.initiallyOpen(); + untracked(() => this.expanded.set(open)); }); } @@ -273,7 +241,12 @@ export class TasksComponent { toggleExpanded(event: Event): void { event.stopPropagation(); - if (this.initiallyOpen()) return; - this.manuallyExpanded.update((v) => !v); + if (event.type === 'touchend') { + event.preventDefault(); + } + const now = Date.now(); + if (now - this.lastToggleAt < 250) return; + this.lastToggleAt = now; + this.expanded.update((v) => !v); } } diff --git a/frontend/src/app/components/tasks/tasks.component.vitest.ts b/frontend/src/app/components/tasks/tasks.component.vitest.ts deleted file mode 100644 index fe12ec6..0000000 --- a/frontend/src/app/components/tasks/tasks.component.vitest.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 4dc87d0..be0db59 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -21,18 +21,11 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett import { toCss } from '../../utils/color'; /** Tracks which entry path the block-edit modal was opened from. */ -export interface EditEntry { +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'; @@ -147,6 +140,7 @@ 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); @@ -551,8 +547,6 @@ 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. */ @@ -644,9 +638,6 @@ 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(); } @@ -702,8 +693,6 @@ 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; } @@ -717,6 +706,9 @@ 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) { @@ -761,7 +753,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { this.hiddenBlockCount.set(hiddenCount); if (this.isFirstRun && animateInitialStack) { - const initialBlocks = visibleStyled.filter((b) => b._opacity === '1'); + const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b)); if (initialBlocks.length > 0) { this.startDescendAnimation(visibleStyled, initialBlocks); this.prevDoneIds = ids; @@ -773,7 +765,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { if (grewByOne) { const newId = newIds[0]; const newBlock = visibleStyled.find((b) => b.id === newId); - if (newBlock) { + if (newBlock && inRange(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]); @@ -796,48 +788,25 @@ export class TowerComponent implements AfterViewInit, OnDestroy { block._opacity = '0'; } this._visibleBlocks.set(visibleStyled); - this.requestFrame(() => { - this.requestFrame(() => { - if (this.destroyed) return; - this.finishDescendAnimation(blocks); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + for (const block of blocks) { + block._anim = 'descend'; + block._transform = 'translateY(0)'; + block._opacity = '1'; + } + this._visibleBlocks.set([...this._visibleBlocks()]); }); }); } - 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) { - input.value = this.tower().name; - return; - } - if (newName !== this.tower().name) { + if (newName && newName !== this.tower().name) { this.updateTower.emit({ name: newName, base_color: this.tower().base_color }); - } else { - input.value = this.tower().name; } } @@ -882,7 +851,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { /** Called by the template "Add block" plus-icon. */ openAddBlock(): void { - this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen())); + this.editEntry.set({ filter: 'done', activeId: null }); } 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 495f141..c54cc83 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 { editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component'; +import { selectVisibleStyledBlocks } from './tower.component'; function block(id: string, opacity = '1', difficulty = 1): StyledBlock { return { @@ -127,13 +127,3 @@ 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 b571af2..eefa8de 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__text { + .basic__label { 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 083f09d..e9216d0 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 — retries exhausted until the next mutation - | 'too-large' // 413 — payload exceeds the server cap, will not retry + | 'error' // generic / network — will keep trying + | 'too-large' // 413 — payload exceeds the server cap, won't retry | 'rate-limited' // 429 — will retry after Retry-After - | 'invalid'; // 400 — server rejected the body, will not retry + | 'invalid'; // 400 — server rejected the body, won't retry diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 58bf117..2e9b8e8 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -23,9 +23,9 @@ export class ApiService { ); } - async putData(token: string, tree: TreeDto): Promise { - await firstValueFrom( - this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }), + putData(token: string, tree: TreeDto): Promise { + return 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 4b9b4e3..d8a681f 100644 --- a/frontend/src/app/services/api.service.vitest.ts +++ b/frontend/src/app/services/api.service.vitest.ts @@ -1,44 +1,28 @@ -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'; +import { describe, it, expect, vi } from 'vitest'; -describe('ApiService', () => { - let service: ApiService; - let http: HttpTestingController; +// Mock environment +vi.mock('../../environments/environment', () => ({ + environment: { apiBase: 'http://test-api', production: false }, +})); - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), ApiService], - }); - service = TestBed.inject(ApiService); - http = TestBed.inject(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'); }); - afterEach(() => { - http.verify(); + it('constructs correct register URL', () => { + expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register'); }); - 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('constructs correct data URL', () => { + expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data'); }); - 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(); + it('formats Authorization header correctly', () => { + const token = 'abc-def-123'; + const header = `Bearer ${token}`; + expect(header).toBe('Bearer abc-def-123'); }); }); diff --git a/frontend/src/app/services/modal-state.service.ts b/frontend/src/app/services/modal-state.service.ts index eb78a78..c1565ed 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, settings, tower-settings, confirm-delete, + * (block-edit carousel, page-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 71589bc..fd35146 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -1,11 +1,10 @@ -import { Injectable, inject, signal, OnDestroy } from '@angular/core'; +import { Injectable, inject, signal, computed, 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_PREFIX = 'life-towers.cache.v4'; -const PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4'; +const CACHE_KEY = 'life-towers.cache.v4'; const DEBOUNCE_MS = 750; const MAX_RETRIES = 5; @@ -30,7 +29,7 @@ function uuidV4(): string { function isUuidV4(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( - value.toLowerCase(), + value, ); } @@ -51,26 +50,10 @@ 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 { @@ -104,22 +87,26 @@ 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()) { - this.switchToken(e.newValue); - } else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) { + // 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) { // 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 { @@ -133,21 +120,17 @@ 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; - const generation = ++this.initGeneration; - this.initPromise = this.doInit(generation).finally(() => { - if (this.initGeneration === generation) { - this.initPromise = null; - } + this.initPromise = this.doInit().finally(() => { + this.initPromise = null; }); return this.initPromise; } - private async doInit(generation: number): Promise { + private async doInit(): Promise { if (typeof window !== 'undefined') { // (idempotent — adding the same listener twice is a no-op) window.addEventListener('storage', this.storageListener); @@ -157,9 +140,6 @@ 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) { @@ -177,7 +157,7 @@ export class StoreService implements OnDestroy { try { const tree = await this.api.getData(token); - if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token); + this.adoptServerTree(tree); } catch (err: unknown) { const status = (err as { status?: number })?.status; if (status === 401) { @@ -185,73 +165,57 @@ export class StoreService implements OnDestroy { try { await this.api.register(token); const tree = await this.api.getData(token); - if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token); + this.adoptServerTree(tree); } catch { - if (this.isCurrentInit(generation, token)) this.loadFromCache(token); + this.loadFromCache(); } } else { - if (this.isCurrentInit(generation, token)) this.loadFromCache(token); + this.loadFromCache(); } } finally { - if (this.initGeneration === generation) { - this._loading.set(false); - } + 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, 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; - } - } - + private adoptServerTree(tree: TreeDto): void { if (tree.pages.length === 0) { - const cachedTree = this.readCachedTree(token); - if (cachedTree?.pages && cachedTree.pages.length > 0) { - this._pages.set(cachedTree.pages); - this.scheduleSave(); - return; + 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 */ + } } } this._pages.set(tree.pages); - this.updateCache(token, tree); + this.updateCache(tree); } - 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 loadFromCache(): void { + const raw = safeGet(CACHE_KEY); + if (raw) { + try { + const tree: TreeDto = JSON.parse(raw); + this._pages.set(tree.pages); + } catch { + /* ignore */ + } } } - 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 updateCache(tree: TreeDto): void { + safeSet(CACHE_KEY, JSON.stringify(tree)); } private cancelPendingWrites(): void { @@ -263,11 +227,6 @@ 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; } @@ -302,13 +261,13 @@ export class StoreService implements OnDestroy { } reorderPages(fromIndex: number, toIndex: number): void { - let changed = false; this._pages.update((pages) => { - const reordered = reorder(pages, fromIndex, toIndex); - changed = reordered !== null; - return reordered ?? pages; + const arr = [...pages]; + const [item] = arr.splice(fromIndex, 1); + arr.splice(toIndex, 0, item); + return arr; }); - if (changed) this.scheduleSave(); + this.scheduleSave(); } addTower(pageId: string, name: string, base_color: HslColor): void { @@ -342,17 +301,16 @@ 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 = reorder(p.towers, fromIndex, toIndex); - if (towers === null) return p; - changed = true; + const towers = [...p.towers]; + const [item] = towers.splice(fromIndex, 1); + towers.splice(toIndex, 0, item); return { ...p, towers }; }), ); - if (changed) this.scheduleSave(); + this.scheduleSave(); } addBlock( @@ -394,7 +352,6 @@ export class StoreService implements OnDestroy { blockId: string, patch: Partial>, ): void { - let becameDone = false; this._pages.update((pages) => pages.map((p) => p.id === pageId @@ -402,21 +359,16 @@ export class StoreService implements OnDestroy { ...p, towers: p.towers.map((t) => t.id === towerId - ? (() => { - const result = this.patchBlockList(t.blocks, blockId, patch); - becameDone = result.becameDone; - return { ...t, blocks: result.blocks }; - })() + ? { + ...t, + blocks: this.patchBlockList(t.blocks, blockId, patch).blocks, + } : t, ), } : p, ), ); - if (becameDone) { - this.analytics.trackStart(); - this.analytics.trackBlockCompleted(); - } this.scheduleSave(); } @@ -438,6 +390,35 @@ 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, @@ -477,27 +458,19 @@ export class StoreService implements OnDestroy { * if the timing flipped). */ switchToken(newToken: string): void { - const token = newToken.toLowerCase(); - if (!isUuidV4(token)) return; + if (!isUuidV4(newToken)) return; this.cancelPendingWrites(); - this.initGeneration++; - this.initPromise = null; - safeSet(TOKEN_KEY, token); - this._token.set(token); + safeSet(TOKEN_KEY, newToken); + this._token.set(newToken); 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; @@ -525,7 +498,6 @@ 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) { @@ -534,7 +506,7 @@ export class StoreService implements OnDestroy { } try { - await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0); + await this.attempt({ token, tree: { pages: this._pages() } }, 0); } finally { this.flushInFlight = false; // Coalesce mutations that arrived during the flush into a fresh save. @@ -550,13 +522,7 @@ export class StoreService implements OnDestroy { try { await this.api.putData(put.token, put.tree); this._saveStatus.set('saved'); - if ( - this._token() === put.token && - put.revision === this.localMutationRevision && - !this.dirtyDuringFlush - ) { - this.updateCache(put.token, put.tree); - } + this.updateCache(put.tree); return; } catch (err: unknown) { const status = (err as { status?: number })?.status; @@ -600,10 +566,8 @@ export class StoreService implements OnDestroy { } await new Promise((resolve) => { - this.retryResolver = resolve; this.retryTimer = setTimeout(() => { this.retryTimer = null; - this.retryResolver = null; resolve(); }, delayMs); }); @@ -611,10 +575,7 @@ 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() }, revision: put.revision }, - attempt + 1, - ); + await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1); } } @@ -749,22 +710,3 @@ 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 2ecbe76..273841a 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -3,7 +3,6 @@ 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 ──────────────────────────────────────────────────────── @@ -64,10 +63,7 @@ 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.${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}`; +const CACHE_KEY = 'life-towers.cache.v4'; // ── Helpers ────────────────────────────────────────────────────────────────── function configure(api: MockApi): StoreService { @@ -76,18 +72,6 @@ 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, ], }); @@ -153,18 +137,6 @@ 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(); @@ -320,17 +292,13 @@ describe('StoreService', () => { expect(page.towers).toHaveLength(3); const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); - const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0); - expect(doneSquares).toBeGreaterThanOrEqual(90); + expect(doneBlocks.length).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); - const doneSquareCount = tower.blocks - .filter((b) => b.is_done) - .reduce((sum, block) => sum + block.difficulty, 0); - expect(doneSquareCount).toBeGreaterThanOrEqual(30); + expect(doneDates.length).toBeGreaterThanOrEqual(30); expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1); } @@ -353,7 +321,6 @@ 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); }); @@ -384,97 +351,6 @@ 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 () => { @@ -562,73 +438,15 @@ 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(OTHER_TOKEN); + store.switchToken(newToken); // 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(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); + expect(store.token()).toBe(newToken); }); it('switchToken rejects a non-UUIDv4 input', () => { @@ -640,19 +458,6 @@ 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 new file mode 100644 index 0000000..54c4044 --- /dev/null +++ b/frontend/src/app/styles/_form-shared.scss @@ -0,0 +1,106 @@ +.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 d95c6da..fffe090 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 / (2 ** 32 - 2) + 0.5; + return h / (Math.pow(2, 32) - 2) + 0.5; } diff --git a/frontend/src/assets/arrow.svg b/frontend/src/assets/arrow.svg new file mode 100644 index 0000000..bf07063 --- /dev/null +++ b/frontend/src/assets/arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/fonts/material-icons.woff2 b/frontend/src/assets/fonts/material-icons.woff2 new file mode 100644 index 0000000..5492a6e Binary files /dev/null and b/frontend/src/assets/fonts/material-icons.woff2 differ diff --git a/frontend/src/assets/fonts/roboto-300.woff2 b/frontend/src/assets/fonts/roboto-300.woff2 new file mode 100644 index 0000000..9b0f141 Binary files /dev/null and b/frontend/src/assets/fonts/roboto-300.woff2 differ diff --git a/frontend/src/assets/fonts/roboto-400.woff2 b/frontend/src/assets/fonts/roboto-400.woff2 new file mode 100644 index 0000000..9b0f141 Binary files /dev/null and b/frontend/src/assets/fonts/roboto-400.woff2 differ diff --git a/frontend/src/assets/fonts/roboto-500.woff2 b/frontend/src/assets/fonts/roboto-500.woff2 new file mode 100644 index 0000000..9b0f141 Binary files /dev/null and b/frontend/src/assets/fonts/roboto-500.woff2 differ diff --git a/frontend/src/assets/pen.svg b/frontend/src/assets/pen.svg new file mode 100644 index 0000000..d798204 --- /dev/null +++ b/frontend/src/assets/pen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/plus-sign.svg b/frontend/src/assets/plus-sign.svg new file mode 100644 index 0000000..928fbee --- /dev/null +++ b/frontend/src/assets/plus-sign.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/trash.svg b/frontend/src/assets/trash.svg new file mode 100644 index 0000000..73e0a95 --- /dev/null +++ b/frontend/src/assets/trash.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/x-sign.svg b/frontend/src/assets/x-sign.svg new file mode 100644 index 0000000..6f353d8 --- /dev/null +++ b/frontend/src/assets/x-sign.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/index.html b/frontend/src/index.html index 147335a..032f734 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 7b4c607..e93bdc4 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -15,6 +15,38 @@ 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; @@ -24,7 +56,7 @@ $line-height: 2px; padding: 0; &:active, - &:focus:not(:focus-visible) { + &:focus { outline: 0; } diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index bec29af..264f459 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -10,6 +10,6 @@ "src/**/*.ts" ], "exclude": [ - "src/**/*.vitest.ts" + "src/**/*.spec.ts" ] } diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index 6883a29..d383706 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -10,6 +10,6 @@ }, "include": [ "src/**/*.d.ts", - "src/**/*.vitest.ts" + "src/**/*.spec.ts" ] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 8dad81b..af0b622 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'], + include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'], setupFiles: ['./vitest.setup.ts'], coverage: { provider: 'v8',