diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9c0d13b..21d230e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -8,77 +8,150 @@ on: jobs: backend: + name: Backend tests runs-on: ubuntu-latest + defaults: + run: + working-directory: backend steps: - uses: actions/checkout@v4 - name: Install uv - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH + uses: astral-sh/setup-uv@v4 + with: + version: latest + + - name: Set up Python + run: uv python install 3.13 - name: Sync dependencies - working-directory: backend run: uv sync - - name: Run tests - working-directory: backend + - name: Run pytest run: uv run pytest -v - frontend: + frontend-lint: + name: Frontend lint runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' + cache: npm cache-dependency-path: frontend/package-lock.json - name: Install dependencies - working-directory: frontend run: npm ci - - name: Lint (if configured) - working-directory: frontend - run: | - if [ -f eslint.config.js ] || [ -f .eslintrc.json ] || [ -f .eslintrc.js ]; then - npm run lint - else - echo "No ESLint config found, skipping lint" - fi + - name: Run ESLint + run: npm run lint - - name: Build - working-directory: frontend - run: npm run build - - - name: Test - working-directory: frontend - run: npm test - - docker: + frontend-test: + name: Frontend unit tests runs-on: ubuntu-latest - needs: [backend, frontend] + defaults: + run: + working-directory: frontend steps: - uses: actions/checkout@v4 - - name: Build Docker image - run: docker build -t life-towers:${{ github.sha }} . + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json - - name: Push to registry - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - env: - REGISTRY_URL: ${{ secrets.REGISTRY_URL }} - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + - name: Install dependencies + run: npm ci + + - name: Run Vitest + run: npm test + + frontend-build: + name: Frontend build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build production bundle + run: npm run build + + e2e: + name: Playwright e2e + runs-on: ubuntu-latest + needs: [backend, frontend-build] + steps: + - uses: actions/checkout@v4 + + - name: Start stack + run: docker compose -p life-towers -f docker-compose.dev.yml up --build -d + + - name: Wait for /api/v1/health run: | - if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_PASSWORD" ]; then - echo "Registry secrets not configured, skipping push" - exit 0 - fi - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" -u "$REGISTRY_USER" --password-stdin - docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:${{ github.sha }}" - docker tag life-towers:${{ github.sha }} "$REGISTRY_URL/life-towers:latest" - docker push "$REGISTRY_URL/life-towers:${{ github.sha }}" - docker push "$REGISTRY_URL/life-towers:latest" + set -e + cid=$(docker compose -p life-towers -f docker-compose.dev.yml ps -q life-towers) + for i in $(seq 1 60); do + status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting) + if [ "$status" = healthy ]; then + echo "stack healthy after ${i} attempts" + exit 0 + fi + sleep 2 + done + echo "stack failed to become healthy" >&2 + docker compose -p life-towers -f docker-compose.dev.yml logs >&2 + exit 1 + + - name: Run Playwright + run: | + docker run --rm \ + --network life-towers_default \ + -v "$(pwd)/frontend:/work" \ + -w /work \ + -e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \ + -e CI=true \ + mcr.microsoft.com/playwright:v1.60.0-noble \ + sh -c 'npm ci && npx playwright test' + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report + if-no-files-found: ignore + retention-days: 14 + + - name: Upload visual screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-visuals + path: frontend/visuals + if-no-files-found: ignore + retention-days: 14 + + - name: Dump container logs on failure + if: failure() + run: docker compose -p life-towers -f docker-compose.dev.yml logs + + - name: Tear down stack + if: always() + run: docker compose -p life-towers -f docker-compose.dev.yml down -v diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml deleted file mode 100644 index 598350d..0000000 --- a/.forgejo/workflows/deploy.yml +++ /dev/null @@ -1,52 +0,0 @@ -# IMPORTANT: Before this workflow will function, configure the following -# repository secrets in Forgejo (Settings → Secrets): -# DEPLOY_HOST — hostname or IP of the target server -# DEPLOY_USER — SSH user on the target server -# DEPLOY_SSH_KEY — private SSH key (PEM or OpenSSH format) -# DEPLOY_PATH — absolute path to the project directory on the server -# (must contain a docker-compose.yml + a .env file -# that sets LIFE_TOWERS_IMAGE to the registry tag, -# e.g. LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest) - -name: Deploy - -on: - workflow_dispatch: - push: - tags: - - 'v*' - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Install SSH key - run: | - mkdir -p ~/.ssh - chmod 700 ~/.ssh - printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts - - - name: Deploy via SSH - run: | - set -euo pipefail - # Pulls the new image referenced by $LIFE_TOWERS_IMAGE in the - # server's .env, restarts the service, then verifies health. - ssh -i ~/.ssh/deploy_key \ - -o StrictHostKeyChecking=yes \ - "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ - "set -euo pipefail - cd '${{ secrets.DEPLOY_PATH }}' - docker compose pull - docker compose up -d --remove-orphans - # Wait for healthcheck (max ~60s) - for i in \$(seq 1 30); do - status=\$(docker compose ps --format json life-towers | python3 -c 'import sys,json;[print(json.loads(l).get(\"Health\",\"\")) for l in sys.stdin]' || true) - if [ \"\$status\" = healthy ]; then echo deploy_healthy; exit 0; fi - sleep 2 - done - echo deploy_unhealthy >&2 - docker compose logs --tail 50 life-towers >&2 - exit 1" diff --git a/.forgejo/workflows/docker.yml b/.forgejo/workflows/docker.yml new file mode 100644 index 0000000..8e4f6d7 --- /dev/null +++ b/.forgejo/workflows/docker.yml @@ -0,0 +1,50 @@ +name: Docker + +on: + push: + branches: [master] + workflow_dispatch: + +env: + REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }} + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix=sha-,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..091d70c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,291 @@ +# Life Towers — agent notes + +This file captures the load-bearing things to know about this codebase. Read it before making non-trivial changes. + +## What this is + +A personal-productivity TODO app where each user has multiple **pages**, each page holds several **towers** (vertical task columns), and each tower has **blocks** (atomic tasks). Pending blocks live in a **tasks accordion** at the top of the tower; completed ones fall into the tower as colored squares (the "falling animation" is the defining visual). Date-range slider filters which done blocks are visible. + +This is a port/modernisation of a legacy Angular 7 app. **Design parity with the legacy is a hard requirement** — the user cares deeply about the original aesthetic. The legacy source lives at `_legacy_reference/` (gitignored) and is the source of truth for any visual question. A detailed style guide is at `docs/DESIGN.md`. + +## Repo layout + +``` +backend/ # FastAPI + SQLite (WAL) + src/life_towers/ + main.py # ASGI app, lifespan, static mount + SPA fallback + api.py # Routes (/api/v1/{health,register,data}) + auth.py # Bearer UUIDv4 → user_id + db.py # sqlite3 factory + migration runner + limits.py # slowapi limiter + payload-size middleware + models.py # pydantic v2 schemas + logging.py # structlog JSON + migrations/ # 001_initial.sql consolidated schema + tests/test_api.py # pytest + httpx AsyncClient + pyproject.toml # uv-managed +frontend/ # Angular 21+ standalone, signals, zoneless + src/app/ + components/ + pages/ # Top-level: page selector + Settings button + page/ # Tower row + slider + trash zone + confirm-delete + tower/ # White tower card: tasks accordion + add-block + falling stack + name input + block/ # Colored square (1/6 tower width) + tasks/ # Pending-blocks accordion with tickbox + welcome/ # Zero-state intro modal + "Try an example" + modal/ # Generic backdrop shell + sub-modals (block-edit carousel, settings, tower-settings) + shared/ # select-add, toggle, double-slider, color-picker, icon + services/ + api.service.ts # HttpClient wrapper, exact API contract + store.service.ts # signal-based store, debounced PUT, retry, loadExample + modal-state.service.ts # global open-modal counter (drag locking) + models/index.ts # Page / Tower / Block / HslColor TS interfaces + utils/{color,hash}.ts + library/ # Legacy SCSS dropped in verbatim — DO NOT REFACTOR + public/assets/ # SVG icons (arrow, pen, plus-sign, trash, x-sign) + src/assets/fonts/ # Self-hosted woff2 (Open Sans Condensed, Raleway, …) + e2e/ # Playwright (smoke + visuals) +docs/ + api-spec.md # Single source of truth for the HTTP API contract + DESIGN.md # Legacy visual spec (verbatim SCSS quotes) +_legacy_reference/ # Original Angular 7 source — read-only, gitignored +Dockerfile # Multi-stage: node:22-alpine build → python:3.13-slim runtime +docker-compose.yml # Production single-container +docker-compose.dev.yml # Ephemeral volume for Playwright runs +.forgejo/workflows/ # CI + deploy +``` + +## Tech stack quickrefs + +- **Frontend**: Angular 21+, **standalone components** (no NgModule), `signal()` / `computed()` / `effect()` / `linkedSignal()`, **zoneless change detection** (`provideZonelessChangeDetection`), `@if`/`@for` control flow, **OnPush** everywhere, esbuild builder (`@angular/build:application`), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap) +- **Backend**: FastAPI, pydantic v2, slowapi (rate limiting), structlog, **sqlite3 with WAL + foreign_keys ON** every connection, uv-managed deps +- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*` +- **Storage**: SQLite at `/data/life-towers.db` on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside `BEGIN IMMEDIATE`) + +## Build / dev / test / deploy + +```bash +# Full local stack (ephemeral data) +docker compose -f docker-compose.dev.yml up --build -d # http://localhost:8000 +docker compose -f docker-compose.dev.yml down -v # teardown + +# Frontend dev (with backend proxied via proxy.conf.json) +cd frontend && npm start # ng serve on :4200, /api → :8000 +cd frontend && npm run build # production bundle to dist/frontend/browser/ +cd frontend && npm test # vitest (--run already in the script) +cd frontend && npm run test:e2e # Playwright + +# Backend dev +cd backend && uv sync +cd backend && uv run pytest -v +cd backend && uv run uvicorn life_towers.main:app --reload # :8000 + +# E2E in the sandbox: host-to-container port forwarding is broken here. +# Run Playwright INSIDE a container on the docker network instead: +docker run --rm \ + --network life-towers_default \ + -v "$(pwd)/frontend:/work" -w /work \ + -e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \ + mcr.microsoft.com/playwright:v1.60.0-noble \ + npx playwright test +``` + +## Design system — the legacy is the source of truth + +`docs/DESIGN.md` is the comprehensive spec. Key tokens: + +```scss +// _legacy_reference/frontend/src/library/common-variables.scss +$accent-color: #a2666f; // rose +$text-color: #5d576b; // muted purple-grey (also iOS theme-color) +$light-color: #ffffff; +$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%); // 50% alpha — modal backdrop +$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%); // body background +$shadow: 0 0 1.5px 1.5px rgba(0,0,0,0.1), 0 0 3px 2px rgba(0,0,0,0.05); +$shadow-border: 0 0 0 0.75px rgba(0,0,0,0.1); // hairline +$normal-font: 'Open Sans Condensed', sans-serif; +$title-font: 'Raleway', serif; +$mobile-width: 520px; +``` + +Spacing tokens (`library/main.scss`): +```scss +:root { + --large-padding: 30px; // 20px on mobile + --medium-padding: 15px; + --small-padding: 10px; // 7.5px on mobile + --border-radius: 5px; // 3px on mobile +} +``` + +Font sizes (`library/text.scss`): `--larger/large/medium/small-font-size` = `22/18/16/11` desktop, `20/16/14/10` mobile. + +**Animation timings**: +- `$long-animation-time: 200ms` — opacity, hover transforms, modal entry +- `$short-animation-time: 100ms` — tighter transitions (red trash-highlight overlay) +- **Falling animation**: `transform 1.5s cubic-bezier(0.5, 0, 1, 0)` (gravity ease-in) +- **Modal opacity entry/exit**: `300ms` + +## Architectural conventions to follow + +### Components + +- Every component is **standalone**, **OnPush**, with `signal()`-based local state. No NgModule. +- Templates use **`@if` / `@for` / `@switch`** — never `*ngIf`/`*ngFor`. +- Inline templates + inline styles in `@Component({ template: \`...\`, styles: \`...\` })` is the norm. Larger components use templateUrl + styleUrl (only `pages.component`, `page.component`). +- SCSS inside `styles:` template literal — **`//` comments are fine inside SCSS but watch for backticks**; `// `display: contents`` inside a template literal closes it early. Use `/* */` if you need to mention CSS strings in comments. +- Library files (`library/*.scss`) are dropped from the legacy verbatim. Don't refactor them. Components `@import '../../../library/main'` (or similar relative depth). + +### State + +- `StoreService` is the single source of truth (signal-based). +- Mutations update the signal immediately (**optimistic**) and call `scheduleSave()` which debounces 750ms and PUTs the full tree. +- Failure mode: exponential backoff up to 5 attempts (1s, 2s, 4s, 8s, 16s). +- LocalStorage cache key: `life-towers.cache.v4`. Token: `life-towers.token.v4`. +- `init()` flow: stored token? Use it. No token? Mint via `uuidV4()` → register → GET data. On 401: re-register the SAME token (idempotent server-side), never silently mint a fresh one (would orphan data). + +### Reactivity caveats with zoneless + +- Plain field mutations in event handlers (`(click)="x = true"`) still trigger CD because Angular's event manager marks the view dirty — even with zoneless. But any async mutation outside an Angular event (setTimeout, raw addEventListener, MutationObserver) **will not** trigger CD; use signals there. +- `effect()` running on `signal()` reads triggers re-runs; wrap writes in `untracked()` to avoid loops. +- For input-driven derived state that the user can also override (e.g. `tasks.expanded` seeded from `initiallyOpen` but also clickable), use either `linkedSignal` or `effect()` + a flag (the codebase uses the latter for `tasks.component`). + +### Sync model + +- **Tree-replace**: `PUT /api/v1/data` sends the entire user hierarchy atomically. Backend wraps in `BEGIN IMMEDIATE`, deletes existing pages (cascading to towers + blocks via FK), inserts new rows. `position` columns track ordering. +- **No granular endpoints** for individual entity CRUD. Keep it tree-replace. +- Spec is in `docs/api-spec.md`. Backend pydantic models match it. Frontend `models/index.ts` matches it. Field names are **snake_case** on both sides. + +### Backend + +- All endpoints are inside `APIRouter(prefix="/api/v1")`. Spec drives behavior — if you change a limit, update both spec and code. +- Migrations: package data under `src/life_towers/migrations/`, loaded via `importlib.resources.files("life_towers").joinpath("migrations")`. The runner tracks applied state in a `schema_migrations(filename TEXT PRIMARY KEY, applied_at INTEGER)` table. +- All sqlite connections must do `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000`. The `get_connection()` factory does this. +- Errors → JSON `{"error": code, "detail": str}` via a single `HTTPException` handler in `main.py`. Stack traces never leak (logged server-side). +- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token. + +## Visual + interaction details that bit me + +### Falling animation (tower.component) + +The `.block-container` has `transform: scaleY(-1)` so blocks visually fall from the TOP into the bottom of the tower. Each block default-positions at `translateY(500%)` via a `*` rule; the inline `[style.transform]="b._transform"` binding overrides per-block. + +When exactly **one** new done block is added, the `reconcile()` method: +1. Sets the new block's `_transform: 'translateY(500%)'` and `_opacity: '0'` (off-screen) +2. Calls `requestAnimationFrame` → `requestAnimationFrame` to let the browser paint the initial state +3. Sets `_anim: 'descend'`, `_transform: 'translateY(0)'`, `_opacity: '1'` — the CSS transition fires + +**Critical**: `grewByOne` detection is position-independent (set-difference). When a tickbox flips a pending block to done, the new entry inserts at its original `tower.blocks` index, not appended. Use the new ID, not `styled[length-1]`. + +The **date range** slider asymmetry: blocks below `range.from` are removed from `visibleBlocks` entirely (instant shuffle, no gap), blocks above `range.to` get `_anim: 'ascend'` and stay in the list flying up. `prevDoneIds` tracks the full `allDone` array — not the filtered styled list — so range expansions don't mis-fire as "new block". + +### Block-edit carousel (modal/block-edit.component) + +- Lives at `position: fixed; z-index: 10001` to escape the modal dialog wrapper and cover the viewport +- Two placeholder cards flank the real cards so the active card can fully center via `scroll-snap-align: center` +- `.mask` overlay on non-active cards has three tiers: `active opacity 0`, `near-active 0.55`, default `1`. Card opacity also tiered (1 / 0.85 / 0.6) mimicking the legacy `1.33*(1-t/2)` curve +- Backdrop click (anywhere not a non-placeholder card) closes the modal +- Delete on an existing card **does not** close the modal — it stays open and the card re-renders out of the list +- Auto-save on tag/toggle change; description deferred to blur + +### Select-add (shared/select-add) + +- Has a `.top` chip and a `.bottom` slide-down panel. `:has(.bottom.open) .top, .background { border-radius: var(--border-radius) var(--border-radius) 0 0 }` squares the bottom corners when open so the chip and panel read as one card +- Shadow seam between chip + panel solved with `clip-path: inset(...)`: `.background.active` clips bottom (`inset(-6px -6px 0 -6px)`), `.bottom.open` clips top (`inset(0 -6px -6px -6px)`). 6px > the total $shadow spread (5px) so neither edge bleeds across the seam +- Closing animation requires a two-transition setup: default state has `transition: ... visibility 0s $long-animation-time` (visibility delays on close), `.open` overrides with `visibility 0s 0s` (instant on open) + +### Modal shell (modal/modal.component) + +- `:host { display: contents }` — critical. Without this, the `lt-modal` host element takes a flex slot in `pages.component`'s `inner-spacing` layout, pushing the Settings button up when the modal mounts +- `section.modal` is `position: fixed; z-index: 10000` with `transition: opacity 300ms`. The component flips `active = true` in `ngAfterViewInit` via `setTimeout(0)` so the opacity 0 → 1 transition runs +- `ModalStateService.open()` / `.close()` are called in `AfterViewInit`/`OnDestroy`. `page.component` reads `modalState.anyOpen` and binds it to every tower's `[cdkDragDisabled]` so users can't drag towers behind an open modal + +### Carousel card date format + +```ts +formatDate(ts: number): string { + return new Date(ts * 1000).toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); // "May 28, 2026, 14:32" +} +``` + +### Double-slider relative-time labels + +`page.component.ts` formats with `Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })`. Buckets: `<45s` second, `<45min` minute, `<22h` hour, `<26d` day, `<320d` month, else year. Labels are **deduped** by string (multiple distinct timestamps that round to the same "5 hr ago" appear once). + +When `values.length` grows (new block added), the slider snaps the **higher** of `oneValue`/`otherValue` to `MAX - 1` so the newest entry is always visible; the lower thumb (the user's left edge) stays put. + +### Color picker (shared/color-picker) + +- Row of 12 preset color swatches + a rainbow hue slider + a big preview swatch +- Saturation and lightness are FIXED at 0.7 / 0.55. Only hue varies +- Preset hues `[0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355]` — skips the green/yellow zone (60°–180°) which muddies with the rose accent palette + +### Tickbox (tasks/tasks.component) + +- `✓` glyph is hidden at rest (`opacity: 0`) and only revealed on interaction: `0.85` (hover/focus-visible), `1` (active). It fades via the `opacity` transition +- The tickbox is a `
-

{{ formatDate(b.created_at) }}

+

{{ formatDate(b.created_at, true) }}

@@ -70,7 +93,7 @@ interface EditedValue { [selected]="editedFor(b.id).tag" [alwaysDropShadow]="true" [onlyShadowBorder]="true" - placeholder="Tag this item…" + [placeholder]="tagPlaceholder('Tag this item…')" (select)="updateTag(b.id, $event)" (add)="updateTag(b.id, $event)" /> @@ -78,18 +101,40 @@ interface EditedValue { -
- + + Already done + + +
+ Difficulty +
+ + {{ editedFor(b.id).difficulty }} + +
@@ -102,12 +147,22 @@ interface EditedValue { class="card create-card" [class.active]="activeIdx() === blocks().length + 1" [class.near-active]="activeIdx() === blocks().length" + role="button" + tabindex="0" + aria-label="Focus create card" (click)="onCardClick(blocks().length + 1)" + (keydown.enter)="onCardClick(blocks().length + 1)" + (keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)" >
-
+
Create now
-
+
@@ -129,17 +184,39 @@ interface EditedValue { -
- + + Already done + + +
+ Difficulty +
+ + {{ newValue().difficulty }} + +
@@ -166,17 +243,51 @@ interface EditedValue { right: 0; bottom: 0; z-index: 10001; // above modal backdrop (10000) + + @media (max-height: $min-height) { + align-items: flex-start; + overflow-y: auto; + } + } + + .view-title { + position: fixed; + top: var(--large-padding); + left: var(--large-padding); + right: var(--large-padding); + z-index: 10002; + margin: 0; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; } .carousel { + --title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size)); width: 100%; height: 100%; display: flex; align-items: center; box-sizing: border-box; + padding: var(--title-clearance) 0 var(--large-padding); overflow-x: auto; overflow-y: hidden; scroll-behavior: smooth; + scroll-snap-type: x mandatory; + + @media (max-width: $mobile-width) { + padding: var(--title-clearance) var(--medium-padding) var(--medium-padding); + } + + @media (max-height: $min-height) { + min-height: max-content; + align-items: flex-start; + padding-top: var(--title-clearance); + padding-bottom: var(--medium-padding); + overflow-y: visible; + } &::-webkit-scrollbar { width: 0; @@ -192,8 +303,12 @@ interface EditedValue { flex: 0 0 auto; width: 66vw; max-width: 400px; + scroll-snap-align: center; @media (max-width: $mobile-width) { - width: 300px; + width: min(88vw, 360px); + max-width: calc(100vw - (2 * var(--medium-padding))); + padding: var(--medium-padding); + margin: 0 calc(var(--small-padding) / 2); opacity: 1 !important; } box-sizing: border-box; @@ -246,6 +361,11 @@ interface EditedValue { opacity: 0 !important; width: 60vw; max-width: 60vw; + @media (max-width: $mobile-width) { + width: var(--medium-padding); + max-width: var(--medium-padding); + min-width: var(--medium-padding); + } box-shadow: none; background: transparent; } @@ -253,10 +373,16 @@ interface EditedValue { .header { @include center-child(); position: relative; + gap: var(--small-padding); + + h1 { + min-width: 0; + overflow-wrap: anywhere; + } .exit { position: absolute; - left: 0; + right: 0; @include exit(); } @@ -276,6 +402,132 @@ interface EditedValue { } } + .done-checkbox { + @include medium-text(); + display: flex; + align-items: center; + justify-content: center; + gap: var(--small-padding); + width: max-content; + max-width: 100%; + margin: 0 auto var(--medium-padding); + cursor: pointer; + + input[type='checkbox'] { + -webkit-appearance: none; + appearance: none; + @include square(22px); + flex: 0 0 auto; + position: relative; + box-sizing: border-box; + margin: 0; + border: 0; + border-radius: 4px; + background: $light-color; + box-shadow: $shadow-border; + cursor: pointer; + transition: background-color $short-animation-time, box-shadow $long-animation-time, transform $short-animation-time; + + &::after { + content: ''; + position: absolute; + left: 7px; + top: 3px; + width: 6px; + height: 12px; + border: solid $light-color; + border-width: 0 2px 2px 0; + opacity: 0; + transform: rotate(45deg) scale(0.8); + transition: opacity $short-animation-time, transform $short-animation-time; + } + + &:checked { + background: $text-color; + + &::after { + opacity: 1; + transform: rotate(45deg) scale(1); + } + } + + &:hover, + &:focus-visible { + box-shadow: $shadow; + } + + &:active { + transform: scale(0.95); + } + } + + span { + line-height: 1.3; + } + } + + .difficulty { + @include medium-text(); + display: flex; + align-items: center; + justify-content: center; + gap: var(--small-padding); + width: max-content; + max-width: 100%; + margin: 0 auto var(--medium-padding); + + .stepper { + display: flex; + align-items: center; + gap: var(--small-padding); + + .value { + min-width: 1.5em; + text-align: center; + font-variant-numeric: tabular-nums; + } + + button.step { + all: unset; // strip native + global button styles + @include square(22px); + @include center-child(); + flex: 0 0 auto; + box-sizing: border-box; + border-radius: 4px; + background: $light-color; + box-shadow: $shadow-border; + cursor: pointer; + // all:unset drops the font to serif and the global button's hover + // underline (button::after) survives the reset — re-assert both. + font: bold 18px/1 $normal-font; + color: $text-color; + user-select: none; + transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time; + + &::after { content: none; } + + @media (max-width: $mobile-width) { + @include square(26px); + } + + &:hover, + &:focus-visible { + box-shadow: $shadow; + } + + &:active { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + box-shadow: $shadow-border; + } + } + } + } + .bottom { height: 32px; @media (max-width: $mobile-width) { @@ -291,10 +543,29 @@ interface EditedValue { transform: translateY(-50%) translateX(-50%); } } + + @media (max-width: $mobile-width) { + lt-select-add, + .done-checkbox { + max-width: 100%; + width: 100%; + } + + .bottom { + min-height: 42px; + + button { + width: max-content; + max-width: 100%; + min-height: 42px; + } + } + } } `, }) export class BlockEditComponent implements AfterViewInit { + readonly viewTitle = input(''); readonly blocks = input.required(); readonly activeBlockId = input(null); readonly tags = input([]); @@ -317,7 +588,9 @@ export class BlockEditComponent implements AfterViewInit { tag: '', description: '', is_done: true, + difficulty: 1, }); + private newDoneEdited = false; // 1-based index of the centered card. 0/N+2 are placeholders. readonly activeIdx = signal(1); @@ -334,6 +607,7 @@ export class BlockEditComponent implements AfterViewInit { tag: b.tag, description: b.description, is_done: b.is_done, + difficulty: b.difficulty ?? 1, }); } untracked(() => this.editedValues.set(m)); @@ -344,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 { @@ -373,26 +658,44 @@ export class BlockEditComponent implements AfterViewInit { tag: '', description: '', is_done: false, + difficulty: 1, } ); } - colorOfTagForBlock(id: string): string { - const v = this.editedFor(id); - return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent'; + private colorOfTag(tag: string): string { + return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent'; } - colorOfNewTag = computed(() => { - const t = this.newValue().tag; - return t ? getColorOfTag(t, this.baseColor()) : 'transparent'; - }); + colorOfTagForBlock(id: string): string { + return this.colorOfTag(this.editedFor(id).tag); + } - formatDate(ts: number): string { + tagPlaceholder(fallback: string): string { + return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback; + } + + colorOfNewTag(): string { + return this.colorOfTag(this.newValue().tag); + } + + formatDate(ts: number, compact = false): string { const d = new Date(ts * 1000); - return d.toLocaleDateString(undefined, { + if (compact) { + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + // e.g. "May 28, 2026, 14:32" + return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', + minute: '2-digit', }); } @@ -413,6 +716,12 @@ export class BlockEditComponent implements AfterViewInit { this.flushExisting(id); } + updateDifficulty(id: string, delta: number): void { + const next = clampDifficulty(this.editedFor(id).difficulty + delta); + this.patchEdited(id, { difficulty: next }); + this.flushExisting(id); + } + private patchEdited(id: string, patch: Partial): void { this.editedValues.update((m) => { const v = m.get(id); @@ -426,7 +735,13 @@ export class BlockEditComponent implements AfterViewInit { flushExisting(id: string): void { const v = this.editedFor(id); if (!v.tag) return; // Skip empty saves - this.save.emit({ id, tag: v.tag, description: v.description, is_done: v.is_done }); + this.save.emit({ + id, + tag: v.tag, + description: v.description, + is_done: v.is_done, + difficulty: v.difficulty, + }); } onDelete(id: string): void { @@ -444,9 +759,14 @@ export class BlockEditComponent implements AfterViewInit { } updateNewDone(is_done: boolean): void { + this.newDoneEdited = true; this.newValue.update((v) => ({ ...v, is_done })); } + updateNewDifficulty(delta: number): void { + this.newValue.update((v) => ({ ...v, difficulty: clampDifficulty(v.difficulty + delta) })); + } + submitNew(): void { const v = this.newValue(); if (!v.tag) return; @@ -455,6 +775,7 @@ export class BlockEditComponent implements AfterViewInit { tag: v.tag, description: v.description, is_done: v.is_done, + difficulty: v.difficulty, }); this.close.emit(); } diff --git a/frontend/src/app/components/modal/block-edit.component.vitest.ts b/frontend/src/app/components/modal/block-edit.component.vitest.ts new file mode 100644 index 0000000..b69ca31 --- /dev/null +++ b/frontend/src/app/components/modal/block-edit.component.vitest.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { createDoneValue } from './block-edit.component'; + +describe('createDoneValue', () => { + it('uses the create-card default before the user edits the checkbox', () => { + expect(createDoneValue(false, true, false)).toBe(false); + expect(createDoneValue(true, false, false)).toBe(true); + }); + + it('keeps the user-edited checkbox value', () => { + expect(createDoneValue(false, true, true)).toBe(true); + expect(createDoneValue(true, false, true)).toBe(false); + }); +}); diff --git a/frontend/src/app/components/modal/modal.component.ts b/frontend/src/app/components/modal/modal.component.ts index 4c854ce..47c9eb8 100644 --- a/frontend/src/app/components/modal/modal.component.ts +++ b/frontend/src/app/components/modal/modal.component.ts @@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, output, + input, signal, AfterViewInit, OnDestroy, @@ -22,8 +23,20 @@ import { ModalStateService } from '../../services/modal-state.service'; class="modal" [class.active]="active()" (click)="onBackdropClick($event)" + (keydown.enter)="onBackdropClick($any($event))" + tabindex="-1" > -