Compare commits
3 commits
3930982bd8
...
0ba28b0a14
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ba28b0a14 | |||
| 5bf8e752e7 | |||
| ad7968dadd |
64 changed files with 1000 additions and 1151 deletions
|
|
@ -44,6 +44,7 @@ USER appuser
|
|||
|
||||
ENV LIFE_TOWERS_DB_PATH=/data/life-towers.db \
|
||||
LIFE_TOWERS_STATIC_DIR=/app/static \
|
||||
LIFE_TOWERS_FORWARDED_ALLOW_IPS=* \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app/src \
|
||||
PATH=/app/.venv/bin:$PATH
|
||||
|
|
@ -53,4 +54,4 @@ EXPOSE 8000
|
|||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "life_towers.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
CMD ["sh", "-c", "uvicorn life_towers.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${LIFE_TOWERS_FORWARDED_ALLOW_IPS}"]
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ Then visit http://localhost:8000.
|
|||
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
||||
docker compose pull && docker compose up -d
|
||||
export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
|
@ -92,6 +93,6 @@ docker compose -f docker-compose.dev.yml down -v
|
|||
|
||||
## Deployment
|
||||
|
||||
Forgejo CI (`.forgejo/workflows/ci.yml`) builds and tests the backend, frontend, and Docker image on every push to `master`. On successful push to `master` it also tags and pushes the image to a registry — configure `REGISTRY_URL`, `REGISTRY_USER`, and `REGISTRY_PASSWORD` as repository secrets.
|
||||
Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`.
|
||||
|
||||
The deploy workflow (`.forgejo/workflows/deploy.yml`) triggers on `workflow_dispatch` or a `v*` tag push. It SSHs into the target server and runs `docker compose pull && docker compose up -d`, then polls the healthcheck. The server must have a `.env` file alongside `docker-compose.yml` that pins `LIFE_TOWERS_IMAGE` to the registry tag pushed by CI — otherwise `docker compose pull` is a no-op against the placeholder `life-towers:local`. Configure `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, and `DEPLOY_PATH` as repository secrets before use.
|
||||
The Docker workflow (`.forgejo/workflows/docker.yml`) builds and pushes images tagged `latest` and `sha-<commit>` 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.
|
||||
|
|
|
|||
|
|
@ -11,13 +11,6 @@ dependencies = [
|
|||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""APIRouter with all Life Towers endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -12,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from .auth import get_current_user
|
||||
from .db import db_connection
|
||||
from .limits import limiter
|
||||
from .logging import token_log_id
|
||||
from .models import (
|
||||
BlockOut,
|
||||
DataIn,
|
||||
|
|
@ -53,7 +52,7 @@ async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
|
|||
(now, token),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("user_registered", user_id=token, new=existing is None)
|
||||
logger.info("user_registered", user_id=token_log_id(token), new=existing is None)
|
||||
return RegisterResponse(user_id=token)
|
||||
|
||||
|
||||
|
|
@ -239,8 +238,14 @@ async def put_data(
|
|||
)
|
||||
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Submitted IDs conflict with existing data",
|
||||
) from exc
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))
|
||||
logger.info("data_replaced", user_id=token_log_id(user_id), pages=len(body.pages))
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import db_connection
|
||||
from .models import _canonical_uuidv4
|
||||
|
||||
# Single generic detail used for ALL 401 responses. Per spec, the response
|
||||
# must not distinguish between missing / malformed / unknown tokens — that
|
||||
|
|
@ -21,25 +20,28 @@ def _unauthorized() -> HTTPException:
|
|||
)
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||
def extract_bearer_token(request: Request) -> str | None:
|
||||
"""Return the raw Bearer token from the Authorization header, or None."""
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get(
|
||||
"authorization"
|
||||
)
|
||||
if not auth_header:
|
||||
raise _unauthorized()
|
||||
|
||||
return None
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise _unauthorized()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
token = parts[1]
|
||||
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||
token = extract_bearer_token(request)
|
||||
if token is None:
|
||||
raise _unauthorized()
|
||||
|
||||
try:
|
||||
u = uuid.UUID(token)
|
||||
if u.version != 4:
|
||||
raise ValueError("Not v4")
|
||||
except (ValueError, AttributeError):
|
||||
token = _canonical_uuidv4(token)
|
||||
except ValueError:
|
||||
raise _unauthorized()
|
||||
|
||||
with db_connection() as conn:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from fastapi import Request, Response
|
|||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from .auth import extract_bearer_token
|
||||
|
||||
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||
|
||||
_TOO_LARGE_BODY = json.dumps(
|
||||
|
|
@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps(
|
|||
|
||||
def _get_token_or_ip(request: Request) -> str:
|
||||
"""Key function for rate limiting: use Bearer token if present, else IP."""
|
||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1]
|
||||
return get_remote_address(request)
|
||||
return extract_bearer_token(request) or get_remote_address(request)
|
||||
|
||||
|
||||
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ from __future__ import annotations
|
|||
|
||||
import time
|
||||
import uuid as _uuid_mod
|
||||
from hashlib import sha256
|
||||
|
||||
import structlog
|
||||
from fastapi import Request, Response
|
||||
|
||||
from .auth import extract_bearer_token
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure structlog for JSON output."""
|
||||
|
|
@ -26,19 +29,19 @@ def configure_logging() -> None:
|
|||
)
|
||||
|
||||
|
||||
def token_log_id(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||
"""Log each request with method, path, status, duration_ms, user_id, request_id."""
|
||||
"""Log each request without writing bearer credentials to the log stream."""
|
||||
request_id = str(_uuid_mod.uuid4())
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||
|
||||
# Extract user_id from Authorization header for logging (no DB call here)
|
||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
user_id: str | None = None
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
user_id = parts[1]
|
||||
token = extract_bearer_token(request)
|
||||
user_id = token_log_id(token) if token else None
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
"""ASGI app, lifespan, static files mount, route registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from html import escape
|
||||
|
|
@ -37,7 +34,6 @@ STATUS_CODE_MAP: dict[int, str] = {
|
|||
413: "payload_too_large",
|
||||
422: "bad_request",
|
||||
429: "rate_limited",
|
||||
507: "quota_exceeded",
|
||||
500: "server_error",
|
||||
}
|
||||
|
||||
|
|
@ -94,10 +90,15 @@ def create_app() -> FastAPI:
|
|||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
fields = sorted(
|
||||
{".".join(str(loc) for loc in e.get("loc", ()) if loc != "body") for e in exc.errors()}
|
||||
field
|
||||
for field in {
|
||||
".".join(str(loc) for loc in e.get("loc", ()) if loc != "body")
|
||||
for e in exc.errors()
|
||||
}
|
||||
if field
|
||||
)
|
||||
if fields:
|
||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
||||
detail_str = "Validation failed for: " + ", ".join(fields)
|
||||
else:
|
||||
detail_str = "Validation failed"
|
||||
return JSONResponse(
|
||||
|
|
@ -115,7 +116,7 @@ def create_app() -> FastAPI:
|
|||
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
||||
detail = {"error": code, "detail": str(exc.detail)}
|
||||
|
||||
headers = getattr(exc, "headers", None) or {}
|
||||
headers = exc.headers or {}
|
||||
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
||||
|
||||
# Generic 500 handler
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
-- Life Towers v4 initial schema.
|
||||
-- SQLite with WAL mode and foreign keys enabled at connection time.
|
||||
-- WAL mode, foreign keys, and busy_timeout are applied per-connection in
|
||||
-- db._apply_pragmas(), so they are not (and need not be) set here.
|
||||
-- All timestamps are unix epoch seconds (INTEGER).
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
|||
import uuid as _uuid_mod
|
||||
|
||||
|
||||
def _is_uuidv4(value: str) -> bool:
|
||||
def _canonical_uuidv4(value: str) -> str:
|
||||
try:
|
||||
u = _uuid_mod.UUID(value)
|
||||
return u.version == 4
|
||||
if u.version == 4:
|
||||
return str(u)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
pass
|
||||
raise ValueError("must be a UUIDv4")
|
||||
|
||||
|
||||
class HslColor(BaseModel):
|
||||
|
|
@ -33,9 +35,7 @@ class BlockIn(BaseModel):
|
|||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class BlockOut(BaseModel):
|
||||
|
|
@ -56,9 +56,7 @@ class TowerIn(BaseModel):
|
|||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class TowerOut(BaseModel):
|
||||
|
|
@ -80,9 +78,7 @@ class PageIn(BaseModel):
|
|||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError(f"id must be a UUIDv4, got: {v!r}")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class PageOut(BaseModel):
|
||||
|
|
@ -139,9 +135,7 @@ class RegisterRequest(BaseModel):
|
|||
@field_validator("token")
|
||||
@classmethod
|
||||
def validate_token(cls, v: str) -> str:
|
||||
if not _is_uuidv4(v):
|
||||
raise ValueError("token must be a UUIDv4")
|
||||
return v
|
||||
return _canonical_uuidv4(v)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -51,17 +51,6 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
|
|||
db_module._DB_PATH = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spa_index_injects_absolute_open_graph_urls(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
|
|
@ -166,6 +155,21 @@ async def test_register_non_uuidv4_token(client: AsyncClient) -> None:
|
|||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uuid_inputs_are_canonicalized(client: AsyncClient) -> None:
|
||||
token = make_uuidv4()
|
||||
upper_token = token.upper()
|
||||
register_resp = await client.post("/api/v1/register", json={"token": upper_token})
|
||||
assert register_resp.status_code == 200
|
||||
assert register_resp.json() == {"user_id": token}
|
||||
|
||||
data_resp = await client.get(
|
||||
"/api/v1/data",
|
||||
headers={"Authorization": f"Bearer {upper_token}"},
|
||||
)
|
||||
assert data_resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth / GET /data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -322,6 +326,34 @@ async def test_put_duplicate_page_id(client: AsyncClient) -> None:
|
|||
|
||||
resp = await client.put("/api/v1/data", json=tree, headers=headers)
|
||||
assert resp.status_code == 400 # pydantic validation error → 400 bad_request per spec
|
||||
assert resp.json() == {"error": "bad_request", "detail": "Validation failed"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_cross_user_id_conflict_returns_409(client: AsyncClient) -> None:
|
||||
first_token = make_uuidv4()
|
||||
second_token = make_uuidv4()
|
||||
await client.post("/api/v1/register", json={"token": first_token})
|
||||
await client.post("/api/v1/register", json={"token": second_token})
|
||||
|
||||
tree = _make_tree()
|
||||
first_resp = await client.put(
|
||||
"/api/v1/data",
|
||||
json=tree,
|
||||
headers={"Authorization": f"Bearer {first_token}"},
|
||||
)
|
||||
assert first_resp.status_code == 204
|
||||
|
||||
second_resp = await client.put(
|
||||
"/api/v1/data",
|
||||
json=tree,
|
||||
headers={"Authorization": f"Bearer {second_token}"},
|
||||
)
|
||||
assert second_resp.status_code == 409
|
||||
assert second_resp.json() == {
|
||||
"error": "conflict",
|
||||
"detail": "Submitted IDs conflict with existing data",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
11
backend/uv.lock
generated
11
backend/uv.lock
generated
|
|
@ -201,13 +201,6 @@ dependencies = [
|
|||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
|
|
@ -218,15 +211,11 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.111.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "slowapi", specifier = ">=0.1.9" },
|
||||
{ name = "structlog", specifier = ">=24.1.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
|
|
|||
|
|
@ -1,59 +1,11 @@
|
|||
# Frontend
|
||||
# Life Towers Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.13.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
Angular frontend for the Life Towers app. See the root `README.md` for the full
|
||||
stack setup.
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
npm start # dev server on :4200, with /api proxied to :8000
|
||||
npm test # Vitest unit tests
|
||||
npm run test:e2e # Playwright against PLAYWRIGHT_BASE_URL or localhost:8000
|
||||
npm run build # production bundle
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
],
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
|
|
@ -77,9 +76,6 @@
|
|||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
|
|
@ -92,4 +88,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,21 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
async function expectTaskListOpen(page: Page, description: string): Promise<void> {
|
||||
const tasks = page.locator('lt-tasks', { hasText: description }).first();
|
||||
const taskBody = tasks.locator('.all-task');
|
||||
const taskRow = tasks.locator('.task-container', { hasText: description }).first();
|
||||
|
||||
await expect(tasks.locator('.header')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
taskBody.evaluate((el) => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
return height / Math.max(1, el.scrollHeight);
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(0.9);
|
||||
await taskRow.locator('.tickbox').click({ trial: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke test: drives the legacy-styled UI end-to-end.
|
||||
|
|
@ -11,8 +28,11 @@ test.describe('Life Towers smoke test', () => {
|
|||
test('create page → tower → block, mark done, reload, persists', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the empty-state hint that means init() completed.
|
||||
await expect(page.getByText('Add a new page to get started!')).toBeVisible({ timeout: 15000 });
|
||||
// Wait for init, then dismiss the welcome modal so the page controls are reachable.
|
||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await expect(page.getByText('Add a new page to get started!')).toBeVisible();
|
||||
|
||||
// Create a page via the select-add dropdown.
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
|
|
@ -24,7 +44,7 @@ test.describe('Life Towers smoke test', () => {
|
|||
|
||||
// Create a tower.
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
|
||||
await page.locator('input[placeholder="New tower"]').fill('Side projects');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
|
||||
// Tower's name input is rendered with the tower name as its value.
|
||||
|
|
@ -43,27 +63,25 @@ test.describe('Life Towers smoke test', () => {
|
|||
await page.locator('textarea[placeholder="Write a description here…"]').fill(
|
||||
'Modernise the towers app',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
|
||||
// New block is pending → appears in the tasks accordion.
|
||||
// (Tasks header shows N tasks.)
|
||||
await expect(page.locator('lt-tasks')).toContainText('1');
|
||||
|
||||
// Open the tasks accordion + click the task to edit it, then flip done.
|
||||
await page.locator('lt-tasks .container').click();
|
||||
await page.locator('lt-tasks .task-container').click();
|
||||
await page.locator('lt-tasks .header').click();
|
||||
await page.locator('lt-tasks .task-description').click();
|
||||
|
||||
// Toggle done in the block-edit modal — the right label is "Done".
|
||||
// Toggle done in the block-edit modal.
|
||||
const putLanded = page.waitForResponse(
|
||||
(r) => r.url().endsWith('/api/v1/data') && r.request().method() === 'PUT' && r.ok(),
|
||||
);
|
||||
// Toggle uses verbose labels — "Goal accomplished" flips it to done.
|
||||
await page
|
||||
.locator('lt-block-edit lt-toggle span')
|
||||
.filter({ hasText: 'Goal accomplished' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.locator('lt-block-edit .card.active').getByLabel('Already done').check();
|
||||
await putLanded;
|
||||
await page.locator('lt-block-edit .card.active .exit').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
// Done block now appears as a colored square in the tower's falling stack.
|
||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||
|
|
@ -73,7 +91,93 @@ test.describe('Life Towers smoke test', () => {
|
|||
await expect(page.locator('lt-select-add .top').first()).toContainText('Hobbies', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(page.getByDisplayValue('Side projects')).toBeVisible();
|
||||
await expect(page.locator('lt-tower input').first()).toHaveValue('Side projects');
|
||||
await expect(page.locator('lt-tower lt-block').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('keep tasks open shows new pending tasks and survives immediate reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Work');
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="New tower"]').fill('Today');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByText('Keep tasks open').click();
|
||||
await page.locator('lt-settings .exit').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
const createCard = page.locator('lt-block-edit .create-card');
|
||||
await expect(createCard.getByLabel('Already done')).not.toBeChecked();
|
||||
await createCard.locator('lt-select-add .top').click();
|
||||
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').fill('ops');
|
||||
await createCard.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
await createCard
|
||||
.locator('textarea[placeholder="Write a description here…"]')
|
||||
.fill('Review deploy notes');
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await expectTaskListOpen(page, 'Review deploy notes');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Work', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expectTaskListOpen(page, 'Review deploy notes');
|
||||
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
await expect(page.locator('lt-block-edit .create-card').getByLabel('Already done')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('keep tasks open expands existing pending tasks after reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Start empty' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').fill('Ops');
|
||||
await page.locator('lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
|
||||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.locator('input[placeholder="New tower"]').fill('Queue');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.locator('img[alt="Add block"]').first().click();
|
||||
await page.locator('lt-block-edit lt-select-add .top').click();
|
||||
await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').fill('triage');
|
||||
await page.locator('lt-block-edit lt-select-add input[placeholder="Add a value…"]').press('Enter');
|
||||
await page
|
||||
.locator('textarea[placeholder="Write a description here…"]')
|
||||
.fill('Clean up alerts');
|
||||
await page.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByText('Keep tasks open').click();
|
||||
await page.locator('lt-settings .exit').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('lt-select-add .top').first()).toContainText('Ops', {
|
||||
timeout: 15000,
|
||||
});
|
||||
await expectTaskListOpen(page, 'Clean up alerts');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { test } from '@playwright/test';
|
||||
|
||||
test.skip(
|
||||
process.env['CAPTURE_VISUALS'] !== '1',
|
||||
'Set CAPTURE_VISUALS=1 to run the visual screenshot capture suite.',
|
||||
);
|
||||
|
||||
/**
|
||||
* Visual capture: drives the UI into key states and writes screenshots
|
||||
* for human review of the legacy-styled design.
|
||||
|
|
@ -55,12 +60,12 @@ test.describe('Life Towers visuals', () => {
|
|||
.fill('Finish The Brothers Karamazov');
|
||||
// Uncheck "Already done" so this becomes a pending task.
|
||||
await createCard.getByLabel('Already done').uncheck();
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
// Open the tasks accordion to show the new tickbox.
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator('lt-tasks .container').click();
|
||||
await page.locator('lt-tasks .header').click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
|
||||
|
||||
|
|
@ -83,7 +88,7 @@ test.describe('Life Towers visuals', () => {
|
|||
await cc.locator('lt-select-add .top').click();
|
||||
await page.waitForTimeout(100);
|
||||
await cc.locator('textarea[placeholder="Write a description here…"]').fill(desc);
|
||||
await page.getByRole('button', { name: 'Create and exit' }).click();
|
||||
await page.getByRole('button', { name: 'Create and exit', exact: true }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
}
|
||||
|
||||
|
|
@ -196,9 +201,9 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
|
||||
|
||||
// Open the block-edit carousel for the first tower's first task.
|
||||
await page.locator('lt-tasks .container').first().click();
|
||||
await page.locator('lt-tasks .header').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('lt-tasks .task-container').first().click();
|
||||
await page.locator('lt-tasks .task-description').first().click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true });
|
||||
|
|
|
|||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
|
|
@ -14,7 +14,6 @@
|
|||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"rxjs": "~7.8.0",
|
||||
|
|
@ -719,24 +718,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz",
|
||||
"integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "21.2.14",
|
||||
"@angular/core": "21.2.14",
|
||||
"@angular/platform-browser": "21.2.14",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/service-worker": {
|
||||
"version": "21.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.14.tgz",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
isDevMode,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
|
|
@ -12,7 +11,6 @@ export const appConfig: ApplicationConfig = {
|
|||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideHttpClient(withFetch()),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@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) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,6 @@ export class App implements OnInit {
|
|||
|
||||
ngOnInit(): void {
|
||||
this.analytics.init();
|
||||
this.store.init();
|
||||
void this.store.init();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
|
|
@ -33,6 +31,14 @@ interface EditedValue {
|
|||
difficulty: number;
|
||||
}
|
||||
|
||||
function clampDifficulty(value: number): number {
|
||||
return Math.max(1, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function createDoneValue(defaultDone: boolean, currentDone: boolean, edited: boolean): boolean {
|
||||
return edited ? currentDone : defaultDone;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-block-edit',
|
||||
standalone: true,
|
||||
|
|
@ -48,6 +54,8 @@ interface EditedValue {
|
|||
class="carousel"
|
||||
(scroll)="onScroll()"
|
||||
(click)="onBackdropClick($event)"
|
||||
(keydown.enter)="onBackdropClick($any($event))"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="card placeholder"></div>
|
||||
|
||||
|
|
@ -56,12 +64,22 @@ interface EditedValue {
|
|||
class="card"
|
||||
[class.active]="activeIdx() === i + 1"
|
||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')"
|
||||
(click)="onCardClick(i + 1)"
|
||||
(keydown.enter)="onCardClick(i + 1)"
|
||||
(keydown.space)="$event.preventDefault(); onCardClick(i + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
(click)="close.emit(); $event.stopPropagation()"
|
||||
></button>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfTagForBlock(b.id)"
|
||||
|
|
@ -83,6 +101,7 @@ interface EditedValue {
|
|||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
maxlength="10000"
|
||||
[value]="editedFor(b.id).description"
|
||||
(input)="updateDescription(b.id, $any($event.target).value)"
|
||||
(blur)="flushExisting(b.id)"
|
||||
|
|
@ -112,6 +131,7 @@ interface EditedValue {
|
|||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
[disabled]="editedFor(b.id).difficulty >= 100"
|
||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
|
|
@ -127,12 +147,22 @@ interface EditedValue {
|
|||
class="card create-card"
|
||||
[class.active]="activeIdx() === blocks().length + 1"
|
||||
[class.near-active]="activeIdx() === blocks().length"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Focus create card"
|
||||
(click)="onCardClick(blocks().length + 1)"
|
||||
(keydown.enter)="onCardClick(blocks().length + 1)"
|
||||
(keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit(); $event.stopPropagation()"></div>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
(click)="close.emit(); $event.stopPropagation()"
|
||||
></button>
|
||||
<div
|
||||
class="block-dot"
|
||||
[style.background-color]="colorOfNewTag()"
|
||||
|
|
@ -140,7 +170,7 @@ interface EditedValue {
|
|||
<h1>Create now</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<div class="select-add-container" [class.required-empty]="!newValue().tag">
|
||||
<lt-select-add
|
||||
[items]="tags()"
|
||||
[selected]="newValue().tag"
|
||||
|
|
@ -154,6 +184,7 @@ interface EditedValue {
|
|||
|
||||
<textarea
|
||||
placeholder="Write a description here…"
|
||||
maxlength="10000"
|
||||
[value]="newValue().description"
|
||||
(input)="updateNewDescription($any($event.target).value)"
|
||||
></textarea>
|
||||
|
|
@ -182,6 +213,7 @@ interface EditedValue {
|
|||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
[disabled]="newValue().difficulty >= 100"
|
||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
|
|
@ -558,6 +590,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
is_done: true,
|
||||
difficulty: 1,
|
||||
});
|
||||
private newDoneEdited = false;
|
||||
|
||||
// 1-based index of the centered card. 0/N+2 are placeholders.
|
||||
readonly activeIdx = signal(1);
|
||||
|
|
@ -585,13 +618,24 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
const t = this.tags();
|
||||
untracked(() => {
|
||||
const cur = this.newValue();
|
||||
if (!cur.tag && t.length > 0) {
|
||||
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
|
||||
} else if (!cur.tag) {
|
||||
this.newValue.set({ ...cur, is_done: this.defaultDone() });
|
||||
if (!cur.tag) {
|
||||
this.newValue.set({
|
||||
...cur,
|
||||
tag: t.length > 0 ? t[0] : '',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const isDone = this.defaultDone();
|
||||
untracked(() => {
|
||||
this.newValue.update((v) => ({
|
||||
...v,
|
||||
is_done: createDoneValue(isDone, v.is_done, this.newDoneEdited),
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
|
@ -619,19 +663,21 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
);
|
||||
}
|
||||
|
||||
private colorOfTag(tag: string): string {
|
||||
return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent';
|
||||
}
|
||||
|
||||
colorOfTagForBlock(id: string): string {
|
||||
const v = this.editedFor(id);
|
||||
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
|
||||
return this.colorOfTag(this.editedFor(id).tag);
|
||||
}
|
||||
|
||||
tagPlaceholder(fallback: string): string {
|
||||
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
||||
}
|
||||
|
||||
colorOfNewTag = computed(() => {
|
||||
const t = this.newValue().tag;
|
||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
||||
});
|
||||
colorOfNewTag(): string {
|
||||
return this.colorOfTag(this.newValue().tag);
|
||||
}
|
||||
|
||||
formatDate(ts: number, compact = false): string {
|
||||
const d = new Date(ts * 1000);
|
||||
|
|
@ -671,7 +717,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
updateDifficulty(id: string, delta: number): void {
|
||||
const next = Math.max(1, this.editedFor(id).difficulty + delta);
|
||||
const next = clampDifficulty(this.editedFor(id).difficulty + delta);
|
||||
this.patchEdited(id, { difficulty: next });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
|
@ -713,11 +759,12 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
updateNewDone(is_done: boolean): void {
|
||||
this.newDoneEdited = true;
|
||||
this.newValue.update((v) => ({ ...v, is_done }));
|
||||
}
|
||||
|
||||
updateNewDifficulty(delta: number): void {
|
||||
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
|
||||
this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) }));
|
||||
}
|
||||
|
||||
submitNew(): void {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createDoneValue } from './block-edit.component';
|
||||
|
||||
describe('createDoneValue', () => {
|
||||
it('uses the create-card default before the user edits the checkbox', () => {
|
||||
expect(createDoneValue(false, true, false)).toBe(false);
|
||||
expect(createDoneValue(true, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the user-edited checkbox value', () => {
|
||||
expect(createDoneValue(false, true, true)).toBe(true);
|
||||
expect(createDoneValue(true, false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
class="modal"
|
||||
[class.active]="active()"
|
||||
(click)="onBackdropClick($event)"
|
||||
(keydown.enter)="onBackdropClick($any($event))"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="modal__dialog"
|
||||
|
|
@ -94,7 +96,6 @@ export class ModalComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
||||
private previousFocus: HTMLElement | null = null;
|
||||
private escListener!: (e: KeyboardEvent) => void;
|
||||
private readonly modalState = inject(ModalStateService);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Page } from '../../models';
|
||||
import { ToggleComponent } from '../shared/toggle/toggle.component';
|
||||
|
||||
export interface PageSettingsResult {
|
||||
name: string;
|
||||
hide_create_tower_button: boolean;
|
||||
keep_tasks_open: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-page-settings',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, ToggleComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<h2>{{ page() ? 'Page settings' : 'New page' }}</h2>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
id="ps-name"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
placeholder="Page name…"
|
||||
/>
|
||||
|
||||
<div class="toggle-row">
|
||||
<lt-toggle
|
||||
[checked]="hideCreateTowerButton()"
|
||||
(checkedChange)="hideCreateTowerButton.set($event)"
|
||||
offLabel="Show add-tower button"
|
||||
onLabel="Hide add-tower button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<lt-toggle
|
||||
[checked]="keepTasksOpen()"
|
||||
(checkedChange)="keepTasksOpen.set($event)"
|
||||
offLabel="Show tasks collapsed"
|
||||
onLabel="Keep tasks open"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="form.invalid">
|
||||
{{ page() ? 'Save' : 'Create page' }}
|
||||
</button>
|
||||
|
||||
@if (page()) {
|
||||
<button type="button" (click)="delete.emit()">Delete page</button>
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
display: block;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
// Stay full-width on mobile, but switch to flex so forms.scss's
|
||||
// bottom-alignment keeps the underline hugging the label in the 42px
|
||||
// tap target (plain block centres the text and strands the underline).
|
||||
@media (max-width: $mobile-width) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PageSettingsComponent implements OnInit {
|
||||
readonly page = input<Page | null>(null);
|
||||
readonly save = output<PageSettingsResult>();
|
||||
readonly delete = output<void>();
|
||||
readonly close = output<void>();
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
});
|
||||
|
||||
hideCreateTowerButton = signal(false);
|
||||
readonly keepTasksOpen = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
const p = this.page();
|
||||
if (p) {
|
||||
this.form.patchValue({ name: p.name });
|
||||
this.hideCreateTowerButton.set(p.hide_create_tower_button);
|
||||
this.keepTasksOpen.set(p.keep_tasks_open);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const v = this.form.value;
|
||||
this.save.emit({
|
||||
name: v.name ?? '',
|
||||
hide_create_tower_button: this.hideCreateTowerButton(),
|
||||
keep_tasks_open: this.keepTasksOpen(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,6 @@ export interface UpdatePagePayload {
|
|||
keep_tasks_open: boolean;
|
||||
}
|
||||
|
||||
const UUIDV4_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@Component({
|
||||
selector: 'lt-settings',
|
||||
standalone: true,
|
||||
|
|
@ -36,7 +33,7 @@ const UUIDV4_RE =
|
|||
<input
|
||||
type="text"
|
||||
[value]="pageName()"
|
||||
(blur)="onRenamePage($any($event.target).value)"
|
||||
(blur)="onRenamePage($any($event.target))"
|
||||
placeholder="Page name…"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
|
|
@ -255,10 +252,15 @@ export class SettingsComponent {
|
|||
readonly hideCreateTowerButton = signal(false);
|
||||
readonly keepTasksOpen = signal(false);
|
||||
|
||||
private static readonly UUIDV4_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Token-switch state
|
||||
readonly tokenInput = signal('');
|
||||
readonly tokenInputTouched = signal(false);
|
||||
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
|
||||
readonly isValidToken = computed(() =>
|
||||
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
|
@ -271,10 +273,14 @@ export class SettingsComponent {
|
|||
});
|
||||
}
|
||||
|
||||
onRenamePage(value: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
onRenamePage(input: HTMLInputElement): void {
|
||||
const trimmed = input.value.trim();
|
||||
if (!trimmed) {
|
||||
input.value = this.pageName();
|
||||
return;
|
||||
}
|
||||
this.pageName.set(trimmed);
|
||||
input.value = trimmed;
|
||||
this.flushPageUpdate();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface TowerSettingsResult {
|
|||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
|
|
@ -138,12 +138,14 @@ export class TowerSettingsComponent implements OnInit {
|
|||
onSubmit(): void {
|
||||
// Only the create flow reaches here via its Submit button; edit mode
|
||||
// auto-saves (and Enter on the single field is a harmless redundant save).
|
||||
if (this.form.invalid) return;
|
||||
this.emitSave();
|
||||
this.tryEmitSave();
|
||||
}
|
||||
|
||||
/** Emit a save only when the form is valid (skips e.g. an empty name). */
|
||||
private autoSave(): void {
|
||||
this.tryEmitSave();
|
||||
}
|
||||
|
||||
private tryEmitSave(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.emitSave();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,16 @@
|
|||
}
|
||||
@if (!page().hide_create_tower_button) {
|
||||
<div class="add-tower-wrapper">
|
||||
<img class="add-tower" src="assets/plus-sign.svg" alt="Add tower" (click)="showAddTower.set(true)" />
|
||||
<img
|
||||
class="add-tower"
|
||||
src="assets/plus-sign.svg"
|
||||
alt="Add tower"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="showAddTower.set(true)"
|
||||
(keydown.enter)="showAddTower.set(true)"
|
||||
(keydown.space)="$event.preventDefault(); showAddTower.set(true)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
|
@ -57,7 +66,7 @@
|
|||
<lt-modal (close)="cancelTowerDelete()">
|
||||
<div class="confirm-delete">
|
||||
<div class="header">
|
||||
<div class="exit" (click)="cancelTowerDelete()" role="button" aria-label="Cancel"></div>
|
||||
<button class="exit" type="button" (click)="cancelTowerDelete()" aria-label="Cancel"></button>
|
||||
<h2>Delete tower</h2>
|
||||
</div>
|
||||
<p>Delete <strong>{{ confirmDeleteTowerName() || 'this tower' }}</strong> and all of its blocks? This can't be undone.</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
HostListener,
|
||||
effect,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Page } from '../../models';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
|
|
@ -16,7 +19,6 @@ import {
|
|||
DoubleSliderComponent,
|
||||
DoubleSliderRange,
|
||||
} from '../shared/double-slider/double-slider.component';
|
||||
import { computed } from '@angular/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import { ModalStateService } from '../../services/modal-state.service';
|
||||
|
||||
|
|
@ -116,6 +118,14 @@ export class PageComponent {
|
|||
/** Selected date range — `null` = show everything. */
|
||||
readonly dateRange = signal<{ from: number; to: number } | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.showSlider()) {
|
||||
untracked(() => this.dateRange.set(null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSliderRangeChange(range: DoubleSliderRange<unknown>): void {
|
||||
this.dateRange.set({ from: range.from as number, to: range.to as number });
|
||||
}
|
||||
|
|
@ -147,7 +157,7 @@ export class PageComponent {
|
|||
}
|
||||
|
||||
onDeleteTower(towerId: string): void {
|
||||
this.store.deleteTower(this.page().id, towerId);
|
||||
this.confirmDeleteTowerId.set(towerId);
|
||||
}
|
||||
|
||||
// ── Block mutations ────────────────────────────────────────────────────────
|
||||
|
|
@ -204,13 +214,16 @@ export class PageComponent {
|
|||
|
||||
onTrashEnter(): void {
|
||||
this.nearTrashcan = true;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.add('trash-highlight');
|
||||
this.dragPreview()?.classList.add('trash-highlight');
|
||||
}
|
||||
|
||||
onTrashLeave(): void {
|
||||
this.nearTrashcan = false;
|
||||
const preview = document.querySelector('.cdk-drag-preview');
|
||||
if (preview) preview.classList.remove('trash-highlight');
|
||||
this.dragPreview()?.classList.remove('trash-highlight');
|
||||
}
|
||||
|
||||
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
|
||||
private dragPreview(): Element | null {
|
||||
return document.querySelector('.cdk-drag-preview');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../../styles';
|
||||
@import '../../../library/main';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
|
|
@ -71,12 +71,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
p {
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ export class PagesComponent implements OnDestroy {
|
|||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.store.loading() && this.store.pages().length === 0) {
|
||||
const pages = this.store.pages();
|
||||
if (!this.store.loading() && pages.length === 0) {
|
||||
this.showWelcome.set(true);
|
||||
} else if (this.store.pages().length > 0) {
|
||||
} else if (pages.length > 0) {
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
});
|
||||
|
|
@ -82,8 +83,6 @@ export class PagesComponent implements OnDestroy {
|
|||
return pages[0] ?? null;
|
||||
});
|
||||
|
||||
readonly selectedPageName = computed(() => this.selectedPage()?.name ?? null);
|
||||
|
||||
readonly confirmDeletePageName = computed(() => {
|
||||
const id = this.confirmDeletePageId();
|
||||
if (!id) return '';
|
||||
|
|
@ -91,9 +90,10 @@ export class PagesComponent implements OnDestroy {
|
|||
});
|
||||
|
||||
readonly selectedPageIndex = computed(() => {
|
||||
const pages = this.store.pages();
|
||||
const page = this.selectedPage();
|
||||
if (!page) return -1;
|
||||
return this.store.pages().findIndex((p) => p.id === page.id);
|
||||
return pages.findIndex((p) => p.id === page.id);
|
||||
});
|
||||
|
||||
onSelectPage(index: number): void {
|
||||
|
|
|
|||
|
|
@ -185,9 +185,8 @@ export class ColorPickerComponent {
|
|||
return `hsl(${h}, 70%, 55%)`;
|
||||
}
|
||||
|
||||
toCss(c: HslColor): string {
|
||||
return toCss(c);
|
||||
}
|
||||
/** Re-exported so the template can call the utility directly. */
|
||||
readonly toCss = toCss;
|
||||
|
||||
pickHue(h: number): void {
|
||||
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface DoubleSliderRange<T> {
|
|||
* Two-thumb range slider — legacy "double-slider".
|
||||
* Hands an indexed range over an arbitrary values array; emits the
|
||||
* underlying values on each change. Labels magnetically lift as a thumb
|
||||
* approaches them (rotated -45°), per the legacy.
|
||||
* approaches them (rotated -30°), per the legacy.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lt-double-slider',
|
||||
|
|
@ -32,7 +32,7 @@ export interface DoubleSliderRange<T> {
|
|||
id="ds-1"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[max]="maxIndex()"
|
||||
[value]="oneValue()"
|
||||
(input)="oneValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
|
|
@ -40,7 +40,7 @@ export interface DoubleSliderRange<T> {
|
|||
id="ds-2"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="MAX - 1"
|
||||
[max]="maxIndex()"
|
||||
[value]="otherValue()"
|
||||
(input)="otherValue.set(+$any($event.target).value)"
|
||||
/>
|
||||
|
|
@ -168,10 +168,9 @@ export class DoubleSliderComponent {
|
|||
|
||||
readonly rangeChange = output<DoubleSliderRange<unknown>>();
|
||||
|
||||
readonly MAX = 100;
|
||||
|
||||
readonly oneValue = signal(0);
|
||||
readonly otherValue = signal(this.MAX - 1);
|
||||
readonly otherValue = signal(0);
|
||||
readonly maxIndex = computed(() => Math.max(0, this.values().length - 1));
|
||||
|
||||
private prevValuesLength = 0;
|
||||
|
||||
|
|
@ -198,42 +197,44 @@ export class DoubleSliderComponent {
|
|||
const hi = Math.max(a, b);
|
||||
untracked(() => {
|
||||
this.rangeChange.emit({
|
||||
from: vs[this.indexFromValue(lo)],
|
||||
to: vs[this.indexFromValue(hi)],
|
||||
from: vs[this.clampIndex(lo)],
|
||||
to: vs[this.clampIndex(hi)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Snap the higher thumb to MAX - 1 when a new entry is appended.
|
||||
// Snap the higher thumb to the newest value when a new entry is appended.
|
||||
effect(() => {
|
||||
const len = this.values().length;
|
||||
untracked(() => {
|
||||
const max = Math.max(0, len - 1);
|
||||
if (len > this.prevValuesLength) {
|
||||
const a = this.oneValue();
|
||||
const b = this.otherValue();
|
||||
if (a > b) this.oneValue.set(this.MAX - 1);
|
||||
else this.otherValue.set(this.MAX - 1);
|
||||
if (a > b) this.oneValue.set(max);
|
||||
else this.otherValue.set(max);
|
||||
} else {
|
||||
if (this.oneValue() > max) this.oneValue.set(max);
|
||||
if (this.otherValue() > max) this.otherValue.set(max);
|
||||
}
|
||||
this.prevValuesLength = len;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private indexFromValue(value: number): number {
|
||||
return Math.min(
|
||||
this.values().length - 1,
|
||||
Math.floor((value / this.MAX) * this.values().length),
|
||||
);
|
||||
private clampIndex(value: number): number {
|
||||
return Math.max(0, Math.min(this.values().length - 1, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Magnetic label position: returns a CSS `transform` that lifts the label
|
||||
* upward and rotates -45° as a thumb approaches.
|
||||
* upward and rotates -30° as a thumb approaches.
|
||||
*/
|
||||
getOffset(index: number): string {
|
||||
const labelIndex = index / Math.max(1, this.drawnLabels().length);
|
||||
const a = this.oneValue() / this.MAX - 0.1;
|
||||
const b = this.otherValue() / this.MAX - 0.1;
|
||||
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
||||
const max = Math.max(1, this.maxIndex());
|
||||
const a = this.oneValue() / max - 0.1;
|
||||
const b = this.otherValue() / max - 0.1;
|
||||
const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b));
|
||||
const ACTIVE_ZONE = 0.2;
|
||||
const base = 'translateX(-50%) rotate(-30deg) translateY(100%)';
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import {
|
|||
input,
|
||||
output,
|
||||
signal,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
|
|
@ -22,7 +20,15 @@ import {
|
|||
[class.always-shadow]="alwaysDropShadow()"
|
||||
>
|
||||
<div class="background" [class.active]="open()"></div>
|
||||
<div class="top" (click)="toggleOpen($event)">
|
||||
<div
|
||||
class="top"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-expanded]="open()"
|
||||
(click)="toggleOpen($event)"
|
||||
(keydown.enter)="toggleOpen($event)"
|
||||
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
|
||||
>
|
||||
<p>{{ resolvedSelected() ?? placeholder() }}</p>
|
||||
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
|
||||
</div>
|
||||
|
|
@ -33,6 +39,7 @@ import {
|
|||
<input
|
||||
type="text"
|
||||
[value]="item"
|
||||
maxlength="200"
|
||||
(blur)="onRename(item, $any($event.target).value)"
|
||||
/>
|
||||
} @else {
|
||||
|
|
@ -46,6 +53,7 @@ import {
|
|||
type="text"
|
||||
#addInput
|
||||
placeholder="Add a value…"
|
||||
maxlength="200"
|
||||
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
|
||||
/>
|
||||
<button
|
||||
|
|
@ -365,7 +373,7 @@ import {
|
|||
}
|
||||
`,
|
||||
})
|
||||
export class SelectAddComponent implements OnChanges {
|
||||
export class SelectAddComponent {
|
||||
// ── New API (spec) ─────────────────────────────────────────────────────────
|
||||
/** List of string options */
|
||||
readonly items = input<string[]>([]);
|
||||
|
|
@ -415,10 +423,6 @@ export class SelectAddComponent implements OnChanges {
|
|||
return null;
|
||||
}
|
||||
|
||||
ngOnChanges(_changes: SimpleChanges): void {
|
||||
// Nothing to do — signals handle reactivity
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.open()) return;
|
||||
|
|
@ -428,7 +432,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
}
|
||||
}
|
||||
|
||||
toggleOpen(event: MouseEvent): void {
|
||||
toggleOpen(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.open.update((v) => !v);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import {
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="toggle">
|
||||
<span [class.active]="!checked()" (click)="set(false)">{{ offLabel() }}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[class.active]="!checked()"
|
||||
(click)="set(false)"
|
||||
(keydown.enter)="set(false)"
|
||||
(keydown.space)="$event.preventDefault(); set(false)"
|
||||
>{{ offLabel() }}</span>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -20,7 +27,14 @@ import {
|
|||
(change)="set(!checked())"
|
||||
/>
|
||||
</label>
|
||||
<span [class.active]="checked()" (click)="set(true)">{{ onLabel() }}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[class.active]="checked()"
|
||||
(click)="set(true)"
|
||||
(keydown.enter)="set(true)"
|
||||
(keydown.space)="$event.preventDefault(); set(true)"
|
||||
>{{ onLabel() }}</span>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import { Component, ChangeDetectionStrategy, input, output, signal, effect, untracked } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Block, HslColor } from '../../models';
|
||||
import { getColorOfTag } from '../../utils/color';
|
||||
|
||||
export function shouldExpandTasks(keepTasksOpen: boolean, manuallyExpanded: boolean): boolean {
|
||||
return keepTasksOpen || manuallyExpanded;
|
||||
}
|
||||
|
||||
export function taskListMaxHeight(expanded: boolean): string {
|
||||
return expanded ? 'none' : '0px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks accordion — shows pending (not-done) blocks inside a tower.
|
||||
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses.
|
||||
* Sits ABOVE the falling-blocks area. Clicking the header expands/collapses
|
||||
* unless the page setting is keeping tasks open.
|
||||
* Clicking the colored tickbox marks the task done.
|
||||
* Clicking the description opens the block-edit modal via the `edit` output.
|
||||
*/
|
||||
|
|
@ -20,23 +38,26 @@ import { getColorOfTag } from '../../utils/color';
|
|||
(pointerdown)="$event.stopPropagation()"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(touchstart)="$event.stopPropagation()"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<p
|
||||
class="header"
|
||||
(pointerup)="toggleExpanded($event)"
|
||||
(touchend)="toggleExpanded($event)"
|
||||
(click)="toggleExpanded($event)"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
|
||||
</p>
|
||||
@if (!initiallyOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="header"
|
||||
(click)="toggleExpanded($event)"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
|
||||
@if (pending().length === 0) {
|
||||
<span aria-hidden="true"> </span>
|
||||
} @else {
|
||||
{{ pending().length === 1 ? 'task' : 'tasks' }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
#all
|
||||
class="all-task"
|
||||
[style.max-height.px]="expanded() ? all.scrollHeight : 0"
|
||||
[style.max-height]="taskListMaxHeight(expanded())"
|
||||
>
|
||||
@for (b of pending(); track b.id) {
|
||||
<div class="task-container">
|
||||
|
|
@ -49,12 +70,12 @@ import { getColorOfTag } from '../../utils/color';
|
|||
(click)="$event.stopPropagation(); markDone.emit(b)"
|
||||
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
|
||||
></button>
|
||||
<p
|
||||
<button
|
||||
type="button"
|
||||
class="task-description"
|
||||
[style.color]="colorOf(b.tag)"
|
||||
(pointerup)="$event.stopPropagation()"
|
||||
(touchend)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); edit.emit(b)"
|
||||
>{{ b.description || b.tag }}</p>
|
||||
>{{ b.description || b.tag }}</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -87,9 +108,19 @@ import { getColorOfTag } from '../../utils/color';
|
|||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.header { cursor: pointer; }
|
||||
.header {
|
||||
all: unset;
|
||||
@include medium-text();
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
||||
p { font-size: var(--medium-font-size); }
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.all-task {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
|
@ -100,12 +131,9 @@ import { getColorOfTag } from '../../utils/color';
|
|||
transition: max-height $long-animation-time;
|
||||
|
||||
/*
|
||||
* Clip during the open/close animation. We animate to the element's
|
||||
* exact content height (read off #all in the template) so tall lists
|
||||
* simply scroll inside the outer .container (max-height: 30vh) —
|
||||
* .all-task itself is NOT a nested scroller. A nested overflow-y:auto
|
||||
* here pops a scrollbar the instant a tickbox grows on hover, because
|
||||
* its scale(1.05) + shadow widen the scrollable-overflow box.
|
||||
* Clip while collapsed only. When open, let the outer .container own
|
||||
* scrolling via max-height: 30vh; a nested scroller here pops a
|
||||
* scrollbar the instant a tickbox grows on hover.
|
||||
*/
|
||||
overflow: hidden;
|
||||
|
||||
|
|
@ -124,7 +152,7 @@ import { getColorOfTag } from '../../utils/color';
|
|||
gap: calc(var(--small-padding) / 2);
|
||||
}
|
||||
|
||||
&:hover p {
|
||||
&:hover .task-description {
|
||||
@media (min-width: $mobile-width) {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
|
@ -187,7 +215,9 @@ import { getColorOfTag } from '../../utils/color';
|
|||
}
|
||||
}
|
||||
|
||||
p {
|
||||
.task-description {
|
||||
all: unset;
|
||||
@include medium-text();
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
|
|
@ -203,6 +233,7 @@ import { getColorOfTag } from '../../utils/color';
|
|||
}
|
||||
|
||||
position: relative;
|
||||
&::after { content: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -221,17 +252,18 @@ export class TasksComponent {
|
|||
/** Emitted when the description is clicked — parent opens the block-edit modal. */
|
||||
readonly edit = output<Block>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private lastToggleAt = 0;
|
||||
private readonly manuallyExpanded = signal(false);
|
||||
readonly expanded = computed(() =>
|
||||
shouldExpandTasks(this.initiallyOpen(), this.manuallyExpanded()),
|
||||
);
|
||||
readonly taskListMaxHeight = taskListMaxHeight;
|
||||
|
||||
constructor() {
|
||||
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
|
||||
// the "Keep tasks open" page setting expands/collapses the accordion live.
|
||||
// User clicks (which mutate `expanded` directly) are respected until the
|
||||
// setting changes again.
|
||||
// When the page setting switches back to collapsed, discard any older manual
|
||||
// open state so the setting is reflected immediately.
|
||||
effect(() => {
|
||||
const open = this.initiallyOpen();
|
||||
untracked(() => this.expanded.set(open));
|
||||
const keepOpen = this.initiallyOpen();
|
||||
if (!keepOpen) untracked(() => this.manuallyExpanded.set(false));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -241,12 +273,7 @@ export class TasksComponent {
|
|||
|
||||
toggleExpanded(event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (event.type === 'touchend') {
|
||||
event.preventDefault();
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this.lastToggleAt < 250) return;
|
||||
this.lastToggleAt = now;
|
||||
this.expanded.update((v) => !v);
|
||||
if (this.initiallyOpen()) return;
|
||||
this.manuallyExpanded.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal file
26
frontend/src/app/components/tasks/tasks.component.vitest.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldExpandTasks, taskListMaxHeight } from './tasks.component';
|
||||
|
||||
describe('shouldExpandTasks', () => {
|
||||
it('expands when tasks should be kept open by page setting', () => {
|
||||
expect(shouldExpandTasks(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('expands when the user manually opens the accordion', () => {
|
||||
expect(shouldExpandTasks(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses when keep-open is disabled', () => {
|
||||
expect(shouldExpandTasks(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskListMaxHeight', () => {
|
||||
it('does not cap open task lists by a measured height', () => {
|
||||
expect(taskListMaxHeight(true)).toBe('none');
|
||||
});
|
||||
|
||||
it('clips collapsed task lists', () => {
|
||||
expect(taskListMaxHeight(false)).toBe('0px');
|
||||
});
|
||||
});
|
||||
|
|
@ -21,11 +21,18 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
|
|||
import { toCss } from '../../utils/color';
|
||||
|
||||
/** Tracks which entry path the block-edit modal was opened from. */
|
||||
interface EditEntry {
|
||||
export interface EditEntry {
|
||||
filter: 'done' | 'pending';
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry {
|
||||
return {
|
||||
filter: keepTasksOpen ? 'pending' : 'done',
|
||||
activeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A done block augmented with per-render animation state. */
|
||||
export interface StyledBlock extends Block {
|
||||
_anim: '' | 'descend' | 'ascend';
|
||||
|
|
@ -140,7 +147,6 @@ export function selectVisibleStyledBlocks(
|
|||
|
||||
<div
|
||||
class="container"
|
||||
[class.trash-highlight]="trashHighlight()"
|
||||
>
|
||||
<lt-tasks
|
||||
[pending]="pending()"
|
||||
|
|
@ -264,7 +270,7 @@ export function selectVisibleStyledBlocks(
|
|||
transform: scale(0.75);
|
||||
position: relative;
|
||||
|
||||
:before {
|
||||
&::before {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +320,7 @@ export function selectVisibleStyledBlocks(
|
|||
|
||||
width: 100%;
|
||||
|
||||
:before {
|
||||
&::before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
|
@ -513,8 +519,6 @@ export function selectVisibleStyledBlocks(
|
|||
export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||
// ── Inputs ─────────────────────────────────────────────────────────────────
|
||||
readonly tower = input.required<Tower>();
|
||||
/** Set by page component when this tower is being dragged over trash. */
|
||||
readonly trashHighlight = input<boolean>(false);
|
||||
/** Optional date range filter — when set, blocks with `created_at`
|
||||
* outside [from, to] are hidden from the falling stack. */
|
||||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||
|
|
@ -547,6 +551,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private destroyed = false;
|
||||
private readonly animationFrames = new Set<number>();
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
|
|
@ -638,6 +644,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
for (const id of this.animationFrames) cancelAnimationFrame(id);
|
||||
this.animationFrames.clear();
|
||||
this.resizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
|
|
@ -693,6 +702,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
||||
this._visibleBlocks.set([]);
|
||||
this.hiddenBlockCount.set(0);
|
||||
this.prevDoneIds = allDone.map((b) => b.id);
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -706,9 +717,6 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
newIds.length === 1 &&
|
||||
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||
|
||||
const inRange = (b: Block) =>
|
||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
||||
|
||||
const styled: StyledBlock[] = [];
|
||||
for (const b of allDone) {
|
||||
if (range && b.created_at < range.from) {
|
||||
|
|
@ -753,7 +761,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
this.hiddenBlockCount.set(hiddenCount);
|
||||
|
||||
if (this.isFirstRun && animateInitialStack) {
|
||||
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
|
||||
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1');
|
||||
if (initialBlocks.length > 0) {
|
||||
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||
this.prevDoneIds = ids;
|
||||
|
|
@ -765,7 +773,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
if (grewByOne) {
|
||||
const newId = newIds[0];
|
||||
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||
if (newBlock && inRange(newBlock)) {
|
||||
if (newBlock) {
|
||||
// Snap newly-added in-range block to start position, then on the next
|
||||
// paint flip it back to rest — that's what makes it visibly fall.
|
||||
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||
|
|
@ -788,25 +796,48 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
block._opacity = '0';
|
||||
}
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
this.requestFrame(() => {
|
||||
this.requestFrame(() => {
|
||||
if (this.destroyed) return;
|
||||
this.finishDescendAnimation(blocks);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private requestFrame(callback: () => void): void {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => {
|
||||
this.animationFrames.delete(id);
|
||||
if (!this.destroyed) callback();
|
||||
});
|
||||
this.animationFrames.add(id);
|
||||
}
|
||||
|
||||
private finishDescendAnimation(blocks: StyledBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
onRename(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const newName = input.value.trim();
|
||||
if (newName && newName !== this.tower().name) {
|
||||
if (!newName) {
|
||||
input.value = this.tower().name;
|
||||
return;
|
||||
}
|
||||
if (newName !== this.tower().name) {
|
||||
this.updateTower.emit({ name: newName, base_color: this.tower().base_color });
|
||||
} else {
|
||||
input.value = this.tower().name;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -851,7 +882,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
/** Called by the template "Add block" plus-icon. */
|
||||
openAddBlock(): void {
|
||||
this.editEntry.set({ filter: 'done', activeId: null });
|
||||
this.editEntry.set(editEntryForNewBlock(this.keepTasksOpen()));
|
||||
}
|
||||
|
||||
closeEdit(): void {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { StyledBlock } from './tower.component';
|
||||
import { selectVisibleStyledBlocks } from './tower.component';
|
||||
import { editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component';
|
||||
|
||||
function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
|
||||
return {
|
||||
|
|
@ -127,3 +127,13 @@ describe('selectVisibleStyledBlocks', () => {
|
|||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editEntryForNewBlock', () => {
|
||||
it('opens the create card in the pending task view when tasks are kept open', () => {
|
||||
expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null });
|
||||
});
|
||||
|
||||
it('keeps the existing completed-task default when tasks are collapsed', () => {
|
||||
expect(editEntryForNewBlock(false)).toEqual({ filter: 'done', activeId: null });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -333,20 +333,20 @@ import { A11yModule } from '@angular/cdk/a11y';
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.basic__label {
|
||||
.basic__label,
|
||||
.basic__text {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: $text-color;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.basic__label {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.basic__text {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: rgba($text-color, 0.82);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export type SaveStatus =
|
|||
| 'saving'
|
||||
| 'saved'
|
||||
| 'retrying'
|
||||
| 'error' // generic / network — will keep trying
|
||||
| 'too-large' // 413 — payload exceeds the server cap, won't retry
|
||||
| 'error' // generic / network — retries exhausted until the next mutation
|
||||
| 'too-large' // 413 — payload exceeds the server cap, will not retry
|
||||
| 'rate-limited' // 429 — will retry after Retry-After
|
||||
| 'invalid'; // 400 — server rejected the body, won't retry
|
||||
| 'invalid'; // 400 — server rejected the body, will not retry
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ export class ApiService {
|
|||
);
|
||||
}
|
||||
|
||||
putData(token: string, tree: TreeDto): Promise<void> {
|
||||
return firstValueFrom(
|
||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
async putData(token: string, tree: TreeDto): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,44 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ApiService } from './api.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// Mock environment
|
||||
vi.mock('../../environments/environment', () => ({
|
||||
environment: { apiBase: 'http://test-api', production: false },
|
||||
}));
|
||||
describe('ApiService', () => {
|
||||
let service: ApiService;
|
||||
let http: HttpTestingController;
|
||||
|
||||
describe('ApiService URL patterns', () => {
|
||||
const baseUrl = 'http://test-api';
|
||||
|
||||
it('constructs correct health URL', () => {
|
||||
expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health');
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), ApiService],
|
||||
});
|
||||
service = TestBed.inject(ApiService);
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
it('constructs correct register URL', () => {
|
||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
||||
afterEach(() => {
|
||||
http.verify();
|
||||
});
|
||||
|
||||
it('constructs correct data URL', () => {
|
||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
||||
it('gets data with a bearer token', async () => {
|
||||
const tree: TreeDto = { pages: [] };
|
||||
const promise = service.getData('token-1');
|
||||
const req = http.expectOne('/api/v1/data');
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
|
||||
req.flush(tree);
|
||||
await expect(promise).resolves.toEqual(tree);
|
||||
});
|
||||
|
||||
it('formats Authorization header correctly', () => {
|
||||
const token = 'abc-def-123';
|
||||
const header = `Bearer ${token}`;
|
||||
expect(header).toBe('Bearer abc-def-123');
|
||||
it('puts data with a bearer token', async () => {
|
||||
const tree: TreeDto = { pages: [] };
|
||||
const promise = service.putData('token-1', tree);
|
||||
const req = http.expectOne('/api/v1/data');
|
||||
expect(req.request.method).toBe('PUT');
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
|
||||
expect(req.request.body).toBe(tree);
|
||||
req.flush(null);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Injectable, computed, signal } from '@angular/core';
|
|||
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
||||
*
|
||||
* Used by `page.component` to disable tower drag-and-drop while any modal
|
||||
* (block-edit carousel, page-settings, tower-settings, confirm-delete,
|
||||
* (block-edit carousel, settings, tower-settings, confirm-delete,
|
||||
* settings) is on screen — otherwise the user can drag towers from behind
|
||||
* the open card.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
const CACHE_KEY_PREFIX = 'life-towers.cache.v4';
|
||||
const PENDING_CACHE_KEY_PREFIX = 'life-towers.cache-pending.v4';
|
||||
const DEBOUNCE_MS = 750;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ function uuidV4(): string {
|
|||
|
||||
function isUuidV4(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
value,
|
||||
value.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -50,10 +51,26 @@ function safeSet(key: string, value: string): void {
|
|||
/* ignore */
|
||||
}
|
||||
}
|
||||
function safeRemove(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKeyForToken(token: string): string {
|
||||
return `${CACHE_KEY_PREFIX}.${token}`;
|
||||
}
|
||||
|
||||
function pendingCacheKeyForToken(token: string): string {
|
||||
return `${PENDING_CACHE_KEY_PREFIX}.${token}`;
|
||||
}
|
||||
|
||||
interface PendingPut {
|
||||
token: string;
|
||||
tree: TreeDto;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface ExampleBlockSeed {
|
||||
|
|
@ -87,26 +104,22 @@ export class StoreService implements OnDestroy {
|
|||
readonly loading = this._loading.asReadonly();
|
||||
readonly token = this._token.asReadonly();
|
||||
|
||||
readonly pageCount = computed(() => this._pages().length);
|
||||
|
||||
// ── Debounce / retry ───────────────────────────────────────────────────────
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryResolver: (() => void) | null = null;
|
||||
private flushInFlight = false;
|
||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||
private dirtyDuringFlush = false;
|
||||
// Monotonic local mutation version. Prevents an older in-flight PUT from
|
||||
// clearing a newer pending cache entry when it completes.
|
||||
private localMutationRevision = 0;
|
||||
|
||||
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||
private readonly storageListener = (e: StorageEvent) => {
|
||||
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
||||
// Another tab switched accounts. Re-init with the new token instead of
|
||||
// continuing to sync the old account's state to the new one.
|
||||
this._token.set(e.newValue);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this.cancelPendingWrites();
|
||||
void this.init();
|
||||
} else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) {
|
||||
this.switchToken(e.newValue);
|
||||
} else if (e.key === cacheKeyForToken(this._token()) && e.newValue && !this.flushInFlight) {
|
||||
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
||||
// (to avoid clobbering our own state with the other tab's older view).
|
||||
try {
|
||||
|
|
@ -120,17 +133,21 @@ export class StoreService implements OnDestroy {
|
|||
|
||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private initGeneration = 0;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.doInit().finally(() => {
|
||||
this.initPromise = null;
|
||||
const generation = ++this.initGeneration;
|
||||
this.initPromise = this.doInit(generation).finally(() => {
|
||||
if (this.initGeneration === generation) {
|
||||
this.initPromise = null;
|
||||
}
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInit(): Promise<void> {
|
||||
private async doInit(generation: number): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
// (idempotent — adding the same listener twice is a no-op)
|
||||
window.addEventListener('storage', this.storageListener);
|
||||
|
|
@ -140,6 +157,9 @@ export class StoreService implements OnDestroy {
|
|||
if (stored && !isUuidV4(stored)) {
|
||||
// Garbage in localStorage from a buggy past version — refuse it.
|
||||
stored = null;
|
||||
} else if (stored) {
|
||||
stored = stored.toLowerCase();
|
||||
safeSet(TOKEN_KEY, stored);
|
||||
}
|
||||
const token = stored ?? uuidV4();
|
||||
if (!stored) {
|
||||
|
|
@ -157,7 +177,7 @@ export class StoreService implements OnDestroy {
|
|||
|
||||
try {
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 401) {
|
||||
|
|
@ -165,57 +185,73 @@ export class StoreService implements OnDestroy {
|
|||
try {
|
||||
await this.api.register(token);
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
if (this.isCurrentInit(generation, token)) this.adoptServerTree(tree, token);
|
||||
} catch {
|
||||
this.loadFromCache();
|
||||
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||
}
|
||||
} else {
|
||||
this.loadFromCache();
|
||||
if (this.isCurrentInit(generation, token)) this.loadFromCache(token);
|
||||
}
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
if (this.initGeneration === generation) {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrentInit(generation: number, token: string): boolean {
|
||||
return this.initGeneration === generation && this._token() === token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a freshly-fetched server tree. If the server is empty but our local
|
||||
* cache holds data, the cache wins and we schedule a push — otherwise the
|
||||
* "server forgot me" recovery would silently wipe offline edits.
|
||||
*/
|
||||
private adoptServerTree(tree: TreeDto): void {
|
||||
private adoptServerTree(tree: TreeDto, token: string): void {
|
||||
if (safeGet(pendingCacheKeyForToken(token))) {
|
||||
const cachedTree = this.readCachedTree(token);
|
||||
if (cachedTree?.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (tree.pages.length === 0) {
|
||||
const cached = safeGet(CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const cachedTree: TreeDto = JSON.parse(cached);
|
||||
if (cachedTree.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to server-empty */
|
||||
}
|
||||
const cachedTree = this.readCachedTree(token);
|
||||
if (cachedTree?.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._pages.set(tree.pages);
|
||||
this.updateCache(tree);
|
||||
this.updateCache(token, tree);
|
||||
}
|
||||
|
||||
private loadFromCache(): void {
|
||||
const raw = safeGet(CACHE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(raw);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
private loadFromCache(token: string): void {
|
||||
const tree = this.readCachedTree(token);
|
||||
if (tree) this._pages.set(tree.pages);
|
||||
}
|
||||
|
||||
private readCachedTree(token: string): TreeDto | null {
|
||||
const raw = safeGet(cacheKeyForToken(token));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as TreeDto;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateCache(tree: TreeDto): void {
|
||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
||||
private updateCache(token: string, tree: TreeDto, pending = false): void {
|
||||
safeSet(cacheKeyForToken(token), JSON.stringify(tree));
|
||||
if (pending) {
|
||||
safeSet(pendingCacheKeyForToken(token), '1');
|
||||
} else {
|
||||
safeRemove(pendingCacheKeyForToken(token));
|
||||
}
|
||||
}
|
||||
|
||||
private cancelPendingWrites(): void {
|
||||
|
|
@ -227,6 +263,11 @@ export class StoreService implements OnDestroy {
|
|||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
if (this.retryResolver !== null) {
|
||||
const resolve = this.retryResolver;
|
||||
this.retryResolver = null;
|
||||
resolve();
|
||||
}
|
||||
this.dirtyDuringFlush = false;
|
||||
}
|
||||
|
||||
|
|
@ -261,13 +302,13 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
reorderPages(fromIndex: number, toIndex: number): void {
|
||||
let changed = false;
|
||||
this._pages.update((pages) => {
|
||||
const arr = [...pages];
|
||||
const [item] = arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, item);
|
||||
return arr;
|
||||
const reordered = reorder(pages, fromIndex, toIndex);
|
||||
changed = reordered !== null;
|
||||
return reordered ?? pages;
|
||||
});
|
||||
this.scheduleSave();
|
||||
if (changed) this.scheduleSave();
|
||||
}
|
||||
|
||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||
|
|
@ -301,16 +342,17 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||
let changed = false;
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => {
|
||||
if (p.id !== pageId) return p;
|
||||
const towers = [...p.towers];
|
||||
const [item] = towers.splice(fromIndex, 1);
|
||||
towers.splice(toIndex, 0, item);
|
||||
const towers = reorder(p.towers, fromIndex, toIndex);
|
||||
if (towers === null) return p;
|
||||
changed = true;
|
||||
return { ...p, towers };
|
||||
}),
|
||||
);
|
||||
this.scheduleSave();
|
||||
if (changed) this.scheduleSave();
|
||||
}
|
||||
|
||||
addBlock(
|
||||
|
|
@ -352,6 +394,7 @@ export class StoreService implements OnDestroy {
|
|||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): void {
|
||||
let becameDone = false;
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
|
|
@ -359,16 +402,21 @@ export class StoreService implements OnDestroy {
|
|||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
|
||||
}
|
||||
? (() => {
|
||||
const result = this.patchBlockList(t.blocks, blockId, patch);
|
||||
becameDone = result.becameDone;
|
||||
return { ...t, blocks: result.blocks };
|
||||
})()
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
if (becameDone) {
|
||||
this.analytics.trackStart();
|
||||
this.analytics.trackBlockCompleted();
|
||||
}
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
|
|
@ -390,35 +438,6 @@ export class StoreService implements OnDestroy {
|
|||
this.scheduleSave();
|
||||
}
|
||||
|
||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
let becameDone = false;
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? (() => {
|
||||
const block = t.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return t;
|
||||
const result = this.patchBlockList(t.blocks, blockId, {
|
||||
is_done: !block.is_done,
|
||||
});
|
||||
becameDone = result.becameDone;
|
||||
return { ...t, blocks: result.blocks };
|
||||
})()
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.analytics.trackStart();
|
||||
if (becameDone) this.analytics.trackBlockCompleted();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private patchBlockList(
|
||||
blocks: Block[],
|
||||
blockId: string,
|
||||
|
|
@ -458,19 +477,27 @@ export class StoreService implements OnDestroy {
|
|||
* if the timing flipped).
|
||||
*/
|
||||
switchToken(newToken: string): void {
|
||||
if (!isUuidV4(newToken)) return;
|
||||
const token = newToken.toLowerCase();
|
||||
if (!isUuidV4(token)) return;
|
||||
this.cancelPendingWrites();
|
||||
safeSet(TOKEN_KEY, newToken);
|
||||
this._token.set(newToken);
|
||||
this.initGeneration++;
|
||||
this.initPromise = null;
|
||||
safeSet(TOKEN_KEY, token);
|
||||
this._token.set(token);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this._saveStatus.set('idle');
|
||||
this.localMutationRevision = 0;
|
||||
void this.init();
|
||||
}
|
||||
|
||||
// ── Save / sync ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSave(): void {
|
||||
this.localMutationRevision += 1;
|
||||
const token = this._token();
|
||||
if (token) this.updateCache(token, { pages: this._pages() }, true);
|
||||
|
||||
if (this.flushInFlight) {
|
||||
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
||||
this.dirtyDuringFlush = true;
|
||||
|
|
@ -498,6 +525,7 @@ export class StoreService implements OnDestroy {
|
|||
|
||||
this.flushInFlight = true;
|
||||
this.dirtyDuringFlush = false;
|
||||
const revision = this.localMutationRevision;
|
||||
|
||||
// Cancel any pending retry — runFlush() supersedes it.
|
||||
if (this.retryTimer !== null) {
|
||||
|
|
@ -506,7 +534,7 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.attempt({ token, tree: { pages: this._pages() } }, 0);
|
||||
await this.attempt({ token, tree: { pages: this._pages() }, revision }, 0);
|
||||
} finally {
|
||||
this.flushInFlight = false;
|
||||
// Coalesce mutations that arrived during the flush into a fresh save.
|
||||
|
|
@ -522,7 +550,13 @@ export class StoreService implements OnDestroy {
|
|||
try {
|
||||
await this.api.putData(put.token, put.tree);
|
||||
this._saveStatus.set('saved');
|
||||
this.updateCache(put.tree);
|
||||
if (
|
||||
this._token() === put.token &&
|
||||
put.revision === this.localMutationRevision &&
|
||||
!this.dirtyDuringFlush
|
||||
) {
|
||||
this.updateCache(put.token, put.tree);
|
||||
}
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
|
|
@ -566,8 +600,10 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.retryResolver = resolve;
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.retryResolver = null;
|
||||
resolve();
|
||||
}, delayMs);
|
||||
});
|
||||
|
|
@ -575,7 +611,10 @@ export class StoreService implements OnDestroy {
|
|||
// The token may have changed during the wait — re-check before retrying.
|
||||
if (this._token() !== put.token) return;
|
||||
// Re-snapshot the latest pages so the retry pushes current state.
|
||||
await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1);
|
||||
await this.attempt(
|
||||
{ token: put.token, tree: { pages: this._pages() }, revision: put.revision },
|
||||
attempt + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -710,3 +749,22 @@ export class StoreService implements OnDestroy {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reorder<T>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
|||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { StoreService } from './store.service';
|
||||
import { ApiService } from './api.service';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||
|
|
@ -63,7 +64,10 @@ function makeMockApi(): MockApi {
|
|||
|
||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
const CACHE_KEY = `life-towers.cache.v4.${FIXED_UUID}`;
|
||||
const PENDING_CACHE_KEY = `life-towers.cache-pending.v4.${FIXED_UUID}`;
|
||||
const OTHER_TOKEN = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||
const OTHER_CACHE_KEY = `life-towers.cache.v4.${OTHER_TOKEN}`;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function configure(api: MockApi): StoreService {
|
||||
|
|
@ -72,6 +76,18 @@ function configure(api: MockApi): StoreService {
|
|||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: ApiService, useValue: api },
|
||||
{
|
||||
provide: AnalyticsService,
|
||||
useValue: {
|
||||
init: vi.fn(),
|
||||
trackStart: vi.fn(),
|
||||
trackExampleLoaded: vi.fn(),
|
||||
trackPageCreated: vi.fn(),
|
||||
trackTowerCreated: vi.fn(),
|
||||
trackBlockCreated: vi.fn(),
|
||||
trackBlockCompleted: vi.fn(),
|
||||
},
|
||||
},
|
||||
StoreService,
|
||||
],
|
||||
});
|
||||
|
|
@ -137,6 +153,18 @@ describe('StoreService', () => {
|
|||
expect(store.token()).toBe(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('canonicalizes a stored uppercase token', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID.toUpperCase();
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(store.token()).toBe(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
||||
storage[TOKEN_KEY] = 'not-a-uuid';
|
||||
const api = makeMockApi();
|
||||
|
|
@ -292,13 +320,17 @@ describe('StoreService', () => {
|
|||
expect(page.towers).toHaveLength(3);
|
||||
|
||||
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
|
||||
expect(doneBlocks.length).toBeGreaterThanOrEqual(90);
|
||||
const doneSquares = doneBlocks.reduce((sum, block) => sum + block.difficulty, 0);
|
||||
expect(doneSquares).toBeGreaterThanOrEqual(90);
|
||||
expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size)
|
||||
.toBeGreaterThan(1);
|
||||
|
||||
for (const tower of page.towers) {
|
||||
const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at);
|
||||
expect(doneDates.length).toBeGreaterThanOrEqual(30);
|
||||
const doneSquareCount = tower.blocks
|
||||
.filter((b) => b.is_done)
|
||||
.reduce((sum, block) => sum + block.difficulty, 0);
|
||||
expect(doneSquareCount).toBeGreaterThanOrEqual(30);
|
||||
expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b));
|
||||
expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1);
|
||||
}
|
||||
|
|
@ -321,6 +353,7 @@ describe('StoreService', () => {
|
|||
expect(api.putData).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
|
||||
const [, tree] = api.putData.mock.calls[0];
|
||||
expect((tree as TreeDto).pages).toHaveLength(3);
|
||||
});
|
||||
|
|
@ -351,6 +384,97 @@ describe('StoreService', () => {
|
|||
expect(lastTree.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not let an older in-flight save clear a newer pending cache entry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveFirstPut: (() => void) | null = null;
|
||||
api.putData
|
||||
.mockReturnValueOnce(new Promise<void>((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<void>((res) => (resolveFirstPut = () => res())))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.updatePage(FIXED_UUID, { name: 'renamed' });
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
expect((api.putData.mock.calls[0][1] as TreeDto).pages[0].keep_tasks_open).toBe(false);
|
||||
|
||||
store.updatePage(FIXED_UUID, { keep_tasks_open: true });
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
|
||||
resolveFirstPut!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBe('1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(JSON.parse(storage[CACHE_KEY]).pages[0].keep_tasks_open).toBe(true);
|
||||
expect(storage[PENDING_CACHE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('marks status "too-large" on 413 and does NOT retry', async () => {
|
||||
|
|
@ -438,15 +562,73 @@ describe('StoreService', () => {
|
|||
|
||||
// Mutate, then switch BEFORE the debounce fires.
|
||||
store.addPage('old-account');
|
||||
const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
store.switchToken(newToken);
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
|
||||
// Run all timers — the OLD debounce must have been cancelled,
|
||||
// so no PUT should have happened.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(newToken);
|
||||
expect(store.token()).toBe(OTHER_TOKEN);
|
||||
});
|
||||
|
||||
it('switchToken invalidates an init already in flight', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveFirstGet: ((v: TreeDto) => void) | null = null;
|
||||
api.getData
|
||||
.mockReturnValueOnce(new Promise<TreeDto>((res) => (resolveFirstGet = res)))
|
||||
.mockResolvedValueOnce({ pages: [mkPage('new-account')] });
|
||||
const store = configure(api);
|
||||
|
||||
const firstInit = store.init();
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
resolveFirstGet!({ pages: [mkPage('old-account')] });
|
||||
await firstInit;
|
||||
|
||||
expect(api.getData).toHaveBeenCalledWith(OTHER_TOKEN);
|
||||
expect(store.token()).toBe(OTHER_TOKEN);
|
||||
expect(store.pages()[0].name).toBe('new-account');
|
||||
});
|
||||
|
||||
it('switchToken cancels a pending retry without wedging future saves', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '30' }))
|
||||
.mockResolvedValue(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('old-account');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
store.addPage('new-account');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(api.putData.mock.calls[1][0]).toBe(OTHER_TOKEN);
|
||||
expect((api.putData.mock.calls[1][1] as TreeDto).pages[0].name).toBe('new-account');
|
||||
});
|
||||
|
||||
it('does not load another account cache after switching tokens', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('old-cache')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
api.getData.mockRejectedValue(httpError(0));
|
||||
store.switchToken(OTHER_TOKEN);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(storage[OTHER_CACHE_KEY]).toBeUndefined();
|
||||
expect(store.pages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||
|
|
@ -458,6 +640,19 @@ describe('StoreService', () => {
|
|||
expect(store.token()).toBe(''); // never initialized
|
||||
});
|
||||
|
||||
it('switchToken canonicalizes uppercase UUID input', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.switchToken(OTHER_TOKEN.toUpperCase());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(store.token()).toBe(OTHER_TOKEN);
|
||||
expect(storage[TOKEN_KEY]).toBe(OTHER_TOKEN);
|
||||
});
|
||||
|
||||
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||
|
||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-ui);
|
||||
transition: background 0.15s;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: transparent;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #e53e3e;
|
||||
margin-right: auto;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(229, 62, 62, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,5 +16,5 @@ export function hash(s: string): number {
|
|||
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
// Map the signed int32 to [0, 1) — same formula as legacy
|
||||
return h / (Math.pow(2, 32) - 2) + 0.5;
|
||||
return h / (2 ** 32 - 2) + 0.5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 292.362 292.362" style="enable-background:new 0 0 292.362 292.362;" xml:space="preserve" class=""><g><g>
|
||||
<path d="M286.935,69.377c-3.614-3.617-7.898-5.424-12.848-5.424H18.274c-4.952,0-9.233,1.807-12.85,5.424 C1.807,72.998,0,77.279,0,82.228c0,4.948,1.807,9.229,5.424,12.847l127.907,127.907c3.621,3.617,7.902,5.428,12.85,5.428 s9.233-1.811,12.847-5.428L286.935,95.074c3.613-3.617,5.427-7.898,5.427-12.847C292.362,77.279,290.548,72.998,286.935,69.377z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 746 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 528.899 528.899" style="enable-background:new 0 0 528.899 528.899;" xml:space="preserve"><g><g>
|
||||
<path d="M328.883,89.125l107.59,107.589l-272.34,272.34L56.604,361.465L328.883,89.125z M518.113,63.177l-47.981-47.981 c-18.543-18.543-48.653-18.543-67.259,0l-45.961,45.961l107.59,107.59l53.611-53.611 C532.495,100.753,532.495,77.559,518.113,63.177z M0.3,512.69c-1.958,8.812,5.998,16.708,14.811,14.565l119.891-29.069 L27.473,390.597L0.3,512.69z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 737 B |
|
|
@ -1,12 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 31.059 31.059" style="enable-background:new 0 0 31.059 31.059;" xml:space="preserve" width="512px" height="512px" class=""><g><g>
|
||||
<g>
|
||||
<path d="M15.529,31.059C6.966,31.059,0,24.092,0,15.529C0,6.966,6.966,0,15.529,0 c8.563,0,15.529,6.966,15.529,15.529C31.059,24.092,24.092,31.059,15.529,31.059z M15.529,1.774 c-7.585,0-13.755,6.171-13.755,13.755s6.17,13.754,13.755,13.754c7.584,0,13.754-6.17,13.754-13.754S23.113,1.774,15.529,1.774z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M21.652,16.416H9.406c-0.49,0-0.888-0.396-0.888-0.887c0-0.49,0.397-0.888,0.888-0.888h12.246 c0.49,0,0.887,0.398,0.887,0.888C22.539,16.02,22.143,16.416,21.652,16.416z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M15.529,22.539c-0.49,0-0.888-0.397-0.888-0.887V9.406c0-0.49,0.398-0.888,0.888-0.888 c0.49,0,0.887,0.398,0.887,0.888v12.246C16.416,22.143,16.02,22.539,15.529,22.539z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
|
||||
</g>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,13 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 729.837 729.838" style="enable-background:new 0 0 729.837 729.838;" xml:space="preserve"><g><g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M589.193,222.04c0-6.296,5.106-11.404,11.402-11.404S612,215.767,612,222.04v437.476c0,19.314-7.936,36.896-20.67,49.653 c-12.733,12.734-30.339,20.669-49.653,20.669H188.162c-19.315,0-36.943-7.935-49.654-20.669 c-12.734-12.734-20.669-30.313-20.669-49.653V222.04c0-6.296,5.108-11.404,11.403-11.404c6.296,0,11.404,5.131,11.404,11.404 v437.476c0,13.02,5.37,24.922,13.97,33.521c8.6,8.601,20.503,13.993,33.522,13.993h353.517c13.019,0,24.896-5.394,33.498-13.993 c8.624-8.624,13.992-20.503,13.992-33.498V222.04H589.193z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M279.866,630.056c0,6.296-5.108,11.403-11.404,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.404,5.108,11.404,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M376.323,630.056c0,6.296-5.107,11.403-11.403,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.403,5.108,11.403,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M472.803,630.056c0,6.296-5.106,11.403-11.402,11.403c-6.297,0-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.107-11.404,11.404-11.404c6.296,0,11.402,5.108,11.402,11.404V630.056L472.803,630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M273.214,70.323c0,6.296-5.108,11.404-11.404,11.404c-6.295,0-11.403-5.108-11.403-11.404 c0-19.363,7.911-36.943,20.646-49.677C283.787,7.911,301.368,0,320.73,0h88.379c19.339,0,36.92,7.935,49.652,20.669 c12.734,12.734,20.67,30.362,20.67,49.654c0,6.296-5.107,11.404-11.403,11.404s-11.403-5.108-11.403-11.404 c0-13.019-5.369-24.922-13.97-33.522c-8.602-8.601-20.503-13.994-33.522-13.994h-88.378c-13.043,0-24.922,5.369-33.546,13.97 C278.583,45.401,273.214,57.28,273.214,70.323z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
<path d="M99.782,103.108h530.273c11.189,0,21.405,4.585,28.818,11.998l0.047,0.048c7.413,7.412,11.998,17.628,11.998,28.818 v29.46c0,6.295-5.108,11.403-11.404,11.403h-0.309H70.323c-6.296,0-11.404-5.108-11.404-11.403v-0.285v-29.175 c0-11.166,4.585-21.406,11.998-28.818l0.048-0.048C78.377,107.694,88.616,103.108,99.782,103.108L99.782,103.108z M630.056,125.916H99.782c-4.965,0-9.503,2.02-12.734,5.274L87,131.238c-3.255,3.23-5.274,7.745-5.274,12.734v18.056h566.361 v-18.056c0-4.965-2.02-9.503-5.273-12.734l-0.049-0.048C639.536,127.936,635.021,125.916,630.056,125.916z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g>
|
||||
</g>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 3 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 212.982 212.982" style="enable-background:new 0 0 212.982 212.982;" xml:space="preserve" width="512px" height="512px" class=""><g><g id="Close">
|
||||
<path d="M131.804,106.491l75.936-75.936c6.99-6.99,6.99-18.323,0-25.312 c-6.99-6.99-18.322-6.99-25.312,0l-75.937,75.937L30.554,5.242c-6.99-6.99-18.322-6.99-25.312,0c-6.989,6.99-6.989,18.323,0,25.312 l75.937,75.936L5.242,182.427c-6.989,6.99-6.989,18.323,0,25.312c6.99,6.99,18.322,6.99,25.312,0l75.937-75.937l75.937,75.937 c6.989,6.99,18.322,6.99,25.312,0c6.99-6.99,6.99-18.322,0-25.312L131.804,106.491z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
|
||||
</g></g> </svg>
|
||||
|
Before Width: | Height: | Size: 816 B |
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<title>Life Towers</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5d576b" />
|
||||
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||
|
||||
|
|
|
|||
|
|
@ -15,38 +15,6 @@
|
|||
src: url('assets/fonts/raleway-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/roboto-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/roboto-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/roboto-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url('assets/fonts/material-icons.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@import 'library/main';
|
||||
|
||||
$line-height: 2px;
|
||||
|
|
@ -56,7 +24,7 @@ $line-height: 2px;
|
|||
padding: 0;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@
|
|||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
"src/**/*.vitest.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@
|
|||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
"src/**/*.vitest.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
|||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'],
|
||||
include: ['src/**/*.vitest.ts'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue