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 index c6d0986..91799c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,9 +224,11 @@ When `values.length` grows (new block added), the slider snaps the **higher** of ### Tickbox (tasks/tasks.component) -- Always-visible `✓` glyph at `opacity: 0.45` (rest), `0.85` (hover), `1` (active) -- `transform: translateY(2px)` is critical — Unicode `✓` has uneven font-metrics, geometric centering looks half a square too high. The 2px nudge moves it to optical center -- `:active` state must re-state the translateY or the glyph jumps when pressed +- `✓` 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 ` @@ -133,14 +136,14 @@ interface EditedValue { (input)="updateNewDescription($any($event.target).value)" > -
- + -
+ Already done +
+

Settings

@if (page()) { @@ -43,19 +43,23 @@ const UUIDV4_RE = aria-label="Page name" /> - +
+ - + +
- @if (tower()) { + + } @else { + } `, @@ -86,14 +88,14 @@ export interface TowerSettingsResult { border: 0; } - // Generous gap between the name input and the color picker — the picker - // is a substantial control and crowding it against the title looks busy. - .picker-row { - padding-top: var(--medium-padding); - } - 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; + } } } `, @@ -105,6 +107,7 @@ export class TowerSettingsComponent implements OnInit { readonly close = output(); private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); form = this.fb.group({ name: ['', [Validators.required, Validators.maxLength(200)]], @@ -117,17 +120,36 @@ export class TowerSettingsComponent implements OnInit { if (t) { this.form.patchValue({ name: t.name }); this.currentColor = { ...t.base_color }; + + // Edit mode: persist name changes as they happen. Wire this up *after* + // the initial patchValue so seeding the form doesn't fire a save. + this.form.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.autoSave()); } } onColorChange(color: HslColor): void { this.currentColor = color; + // In edit mode the picker is a live control — commit each change. + if (this.tower()) this.autoSave(); } 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; - const v = this.form.value; - this.save.emit({ name: v.name ?? '', base_color: this.currentColor }); + this.emitSave(); + } + + /** Emit a save only when the form is valid (skips e.g. an empty name). */ + private autoSave(): void { + if (this.form.invalid) return; + this.emitSave(); + } + + private emitSave(): void { + this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor }); } } diff --git a/frontend/src/app/components/page/page.component.html b/frontend/src/app/components/page/page.component.html index 34d0292..32c2c86 100644 --- a/frontend/src/app/components/page/page.component.html +++ b/frontend/src/app/components/page/page.component.html @@ -7,7 +7,7 @@ @for (tower of page().towers; track tower.id) { - + } diff --git a/frontend/src/app/components/page/page.component.scss b/frontend/src/app/components/page/page.component.scss index 9c9cc9f..4ae3ad3 100644 --- a/frontend/src/app/components/page/page.component.scss +++ b/frontend/src/app/components/page/page.component.scss @@ -5,6 +5,7 @@ flex-direction: column; height: 100%; + min-height: 0; position: relative; // anchor for absolute-positioned .trash @include inner-spacing(var(--large-padding)); @@ -18,10 +19,12 @@ justify-content: center; width: 100%; + box-sizing: border-box; margin-left: auto; margin-right: auto; - flex: 1 0 auto; + flex: 1 1 auto; + min-height: 0; transition: box-shadow $short-animation-time; @@ -55,6 +58,7 @@ max-width: 200px; box-sizing: content-box; flex: 0 0 auto; + min-height: 0; &:not(:nth-last-child(1)) { margin-right: var(--medium-padding); @@ -84,15 +88,22 @@ // without needing !important on every property — only the width triples need it // to beat the per-child-count selectors generated above. @media (max-width: $mobile-width) { + --mobile-tower-width: calc(66vw - var(--small-padding)); + --mobile-tower-side-padding: max( + var(--medium-padding), + calc((100% - var(--mobile-tower-width)) / 2) + ); + overflow-x: auto; overflow-y: visible; -webkit-overflow-scrolling: touch; scroll-snap-type: x mandatory; + scroll-padding-inline: var(--mobile-tower-side-padding); flex-wrap: nowrap; justify-content: flex-start; - // Side padding lets the first and last tower scroll fully into view. - padding: 0 var(--medium-padding); + padding: 0 var(--mobile-tower-side-padding); max-width: 100%; + gap: var(--medium-padding); &::-webkit-scrollbar { display: none; @@ -100,12 +111,16 @@ // Override the @for width-calc rules above. & > * { - width: calc(66vw - var(--small-padding)) !important; - max-width: calc(66vw - var(--small-padding)) !important; - min-width: calc(66vw - var(--small-padding)) !important; - scroll-snap-align: start; + width: var(--mobile-tower-width) !important; + max-width: var(--mobile-tower-width) !important; + min-width: var(--mobile-tower-width) !important; + scroll-snap-align: center; flex-shrink: 0; } + + & > *:not(:nth-last-child(1)) { + margin-right: 0; + } } } diff --git a/frontend/src/app/components/page/page.component.ts b/frontend/src/app/components/page/page.component.ts index 8e152a1..33c5b77 100644 --- a/frontend/src/app/components/page/page.component.ts +++ b/frontend/src/app/components/page/page.component.ts @@ -5,6 +5,7 @@ import { output, signal, inject, + HostListener, } from '@angular/core'; import { Page } from '../../models'; import { StoreService } from '../../services/store.service'; @@ -66,6 +67,7 @@ export class PageComponent { private readonly modalState = inject(ModalStateService); /** True while any lt-modal is mounted — used to lock tower drag. */ readonly modalOpen = this.modalState.anyOpen; + readonly mobileDragDisabled = signal(this.isMobileViewport()); readonly showAddTower = signal(false); readonly isDragging = signal(false); @@ -116,6 +118,18 @@ export class PageComponent { this.dateRange.set({ from: range.from as number, to: range.to as number }); } + @HostListener('window:resize') + onResize(): void { + this.mobileDragDisabled.set(this.isMobileViewport()); + } + + private isMobileViewport(): boolean { + return ( + typeof window !== 'undefined' && + window.matchMedia('(max-width: 520px), (pointer: coarse)').matches + ); + } + // ── Tower mutations ──────────────────────────────────────────────────────── onAddTower(result: TowerSettingsResult): void { diff --git a/frontend/src/app/components/pages/pages.component.scss b/frontend/src/app/components/pages/pages.component.scss index 786d65d..8a218ab 100644 --- a/frontend/src/app/components/pages/pages.component.scss +++ b/frontend/src/app/components/pages/pages.component.scss @@ -2,6 +2,7 @@ :host { height: 100%; + min-height: 0; display: flex; flex-direction: column; @@ -21,7 +22,8 @@ } .page-container { - flex: 1 0 auto; + flex: 1 1 auto; + min-height: 0; // Generous breathing room between the page selector dropdown and the // towers — the dropdown can open downward without crowding the towers. padding-top: var(--large-padding); diff --git a/frontend/src/app/components/shared/color-picker/color-picker.component.ts b/frontend/src/app/components/shared/color-picker/color-picker.component.ts index fe5104c..29292c3 100644 --- a/frontend/src/app/components/shared/color-picker/color-picker.component.ts +++ b/frontend/src/app/components/shared/color-picker/color-picker.component.ts @@ -61,7 +61,10 @@ const FIXED_L = 0.55; display: block; padding: var(--medium-padding); @include card(); - box-shadow: $shadow-border; + border: 1px solid rgba($text-color, 0.14); + box-shadow: inset 0 0 0 1px rgba($light-color, 0.7); + background-color: rgba($text-color, 0.025); + box-sizing: border-box; } .picker { @@ -76,35 +79,40 @@ const FIXED_L = 0.55; grid-template-columns: repeat(12, 1fr); gap: 6px; + @media (max-width: $mobile-width) { + grid-template-columns: repeat(6, 1fr); + gap: var(--small-padding); + } + .swatch { all: unset; cursor: pointer; aspect-ratio: 1; border-radius: 4px; - box-shadow: $shadow-border; + box-shadow: 0 0 0 1px rgba($text-color, 0.18); transition: transform $short-animation-time, box-shadow $long-animation-time; &:hover, &:focus-visible { - box-shadow: $shadow; + box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5); transform: scale(1.1); } &.active { - box-shadow: $shadow; + box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5); transform: scale(1.15); - outline: 2px solid $light-color; - outline-offset: 1px; } } } .hue-slider { + padding: 8px 0; + input[type='range'] { -webkit-appearance: none; appearance: none; width: 100%; - height: 12px; + height: 16px; border-radius: 1000px; background: linear-gradient( to right, @@ -119,11 +127,15 @@ const FIXED_L = 0.55; outline: none; cursor: pointer; + &:focus-visible { + box-shadow: 0 0 0 3px rgba($text-color, 0.35); + } + &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - height: 24px; - width: 24px; + height: 32px; + width: 32px; border-radius: 1000px; background-color: var(--thumb-color, #{$light-color}); box-shadow: 0 0 0 2px #{$light-color}, #{$shadow}; @@ -135,8 +147,8 @@ const FIXED_L = 0.55; } &::-moz-range-thumb { - height: 24px; - width: 24px; + height: 32px; + width: 32px; border-radius: 1000px; background-color: var(--thumb-color, white); border: 2px solid white; @@ -153,7 +165,7 @@ const FIXED_L = 0.55; .preview { height: 40px; border-radius: var(--border-radius); - box-shadow: $shadow-border; + box-shadow: 0 0 0 1px rgba($text-color, 0.18); } `, }) diff --git a/frontend/src/app/components/shared/select-add/select-add.component.ts b/frontend/src/app/components/shared/select-add/select-add.component.ts index 7e01d73..399763d 100644 --- a/frontend/src/app/components/shared/select-add/select-add.component.ts +++ b/frontend/src/app/components/shared/select-add/select-add.component.ts @@ -6,6 +6,9 @@ import { signal, OnChanges, SimpleChanges, + ElementRef, + HostListener, + inject, } from '@angular/core'; @Component({ @@ -19,7 +22,7 @@ import { [class.always-shadow]="alwaysDropShadow()" >
-
+

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

@@ -33,7 +36,9 @@ import { (blur)="onRename(item, $any($event.target).value)" /> } @else { -

{{ item }}

+ } }
@@ -43,9 +48,20 @@ import { placeholder="Add a value…" (keydown.enter)="onAdd(addInput.value); addInput.value = ''" /> - + @if (editable()) { - } @@ -58,6 +74,7 @@ import { @import '../../../../library/main'; $inner-padding: var(--medium-padding); + $dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border; :host { display: block; @@ -80,13 +97,22 @@ import { align-items: center; position: relative; cursor: pointer; + min-height: 46px; + box-sizing: border-box; + gap: var(--small-padding); p { - display: inline-block; + display: block; @include sub-title-text(); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; } img.arrow { + flex: 0 0 auto; @include square(16px); transition: transform $long-animation-time; @@ -98,81 +124,129 @@ import { .bottom-container { width: 100%; - height: 300px; position: absolute; - overflow-y: hidden; + top: 100%; + left: 0; + right: 0; + overflow: visible; pointer-events: none; + z-index: 5; .bottom { - position: absolute; + position: relative; width: 100%; - pointer-events: all; + pointer-events: none; box-sizing: border-box; display: flex; flex-direction: column; align-items: flex-start; border-radius: 0 0 var(--border-radius) var(--border-radius); padding: $inner-padding; - padding-top: 0; - @include inner-spacing($inner-padding); + padding-top: var(--small-padding); + gap: var(--small-padding); // Default (closed) state — also the target of the close transition. background-color: transparent; box-shadow: none; - transform: translateY(-100%); + transform: translateY(-8px); + opacity: 0; visibility: hidden; + // Clip the top edge so the panel's shadow can't bleed back up into + // the chip area; sides + bottom get a 10px slack for the shadow. + clip-path: inset(0 -10px -10px -10px); // Delay the visibility change until after the slide animation finishes // so the panel stays visible while it animates closed. transition: transform $long-animation-time, + opacity $long-animation-time, background-color $long-animation-time, box-shadow $long-animation-time, visibility 0s $long-animation-time; &.open { visibility: visible; + pointer-events: all; transform: none; + opacity: 1; background-color: $light-color; - box-shadow: $shadow; - // Show shadow on left/right/bottom only; clip the top edge so the - // shadow doesn't bleed over the seam where .bottom meets .top. - clip-path: inset(0 -6px -6px -6px); + box-shadow: $dropdown-shadow; // On open, visibility flips immediately (no delay); transform + // colors + shadow animate over $long-animation-time. transition: transform $long-animation-time, + opacity $long-animation-time, background-color $long-animation-time, box-shadow $long-animation-time, visibility 0s 0s; } - p { + .option { @include sub-title-text(); - display: inline-block; + display: flex; + align-items: center; + width: 100%; + min-height: 36px; + margin: 0; + padding: 0; + border: 0; + background: transparent; text-align: left; cursor: pointer; + + &:after { + display: none; + } + + @media (max-width: $mobile-width) { + min-height: 42px; + } } input[type='text'] { @include sub-title-text(); width: 100%; + min-height: 36px; + box-sizing: border-box; + text-align: left; + + &::placeholder { + color: rgba($text-color, 0.72); + opacity: 1; + } + + @media (max-width: $mobile-width) { + min-height: 42px; + } } .add-row { - height: 32px; - @media (max-width: $mobile-width) { height: 24px; } + min-height: 40px; position: relative; width: 100%; display: flex; - align-items: center; + align-items: flex-end; gap: var(--small-padding); input[type='text'] { flex: 1; + min-height: 0; + padding: 0; + border-bottom: solid 2px transparent; + + &:focus, + &:focus-visible { + box-shadow: none; + border-bottom-color: $text-color; + } } button { margin: 0; - position: static; + position: relative; + flex: 0 0 auto; + + &.add-button { + align-self: flex-end; + } &.pen { opacity: 0.25; @@ -184,6 +258,10 @@ import { background: transparent; position: relative; + // Kill the global button's hover-grow underline pseudo-element + // for the icon-only edit control. + &:after { content: none; display: none; } + img { @include square(16px); } @@ -224,16 +302,18 @@ import { width: 100%; @include card(); z-index: 3; + box-sizing: border-box; transition: box-shadow $long-animation-time, height $long-animation-time, border-radius $long-animation-time; &.active { - box-shadow: $shadow; - // Show shadow on top/left/right only; clip the bottom edge so the - // shadow doesn't bleed over the seam where .top meets .bottom. - clip-path: inset(-6px -6px 0 -6px); + // Same shadow recipe as the panel below so the two halves read as + // one continuous card. Clip the bottom edge so this shadow doesn't + // bleed across the seam where .top meets .bottom. + box-shadow: $dropdown-shadow; + clip-path: inset(-10px -10px 0 -10px); } } @@ -251,25 +331,31 @@ import { } } + // Hover lifts the chip — but only when the dropdown is closed. When + // it's open the chip is already showing $dropdown-shadow and a hover + // override would make the top heavier than the panel below. &:hover { @media (min-width: $mobile-width) { - .background { box-shadow: $shadow; } + .background:not(.active) { box-shadow: $shadow; } } } &.shadow-border { .background.active { box-shadow: $shadow-border; - clip-path: inset(-6px -6px 0 -6px); + clip-path: inset(-10px -10px 0 -10px); + } + .bottom.open { + box-shadow: $shadow-border; } } &.shadow-border:hover { - .background { box-shadow: $shadow-border; } + .background:not(.active) { box-shadow: $shadow-border; } } &.always-shadow { - .background { box-shadow: $shadow; } + .background:not(.active) { box-shadow: $shadow; } // When open, clip the bottom so the always-on shadow doesn't bleed // over the seam; restore full shadow when closed. &:has(.bottom.open) .background { @@ -309,6 +395,7 @@ export class SelectAddComponent implements OnChanges { // ── Internal state ───────────────────────────────────────────────────────── readonly open = signal(false); readonly editing = signal(false); + private readonly host = inject(ElementRef); // Resolved values that merge old + new API protected resolvedItems(): string[] { @@ -332,6 +419,20 @@ export class SelectAddComponent implements OnChanges { // Nothing to do — signals handle reactivity } + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.open()) return; + if (!this.host.nativeElement.contains(event.target as Node)) { + this.open.set(false); + this.editing.set(false); + } + } + + toggleOpen(event: MouseEvent): void { + event.stopPropagation(); + this.open.update((v) => !v); + } + onSelectItem(item: string): void { this.select.emit(item); // Legacy compat: also emit the index diff --git a/frontend/src/app/components/shared/toggle/toggle.component.ts b/frontend/src/app/components/shared/toggle/toggle.component.ts index 5ec865e..3adc84e 100644 --- a/frontend/src/app/components/shared/toggle/toggle.component.ts +++ b/frontend/src/app/components/shared/toggle/toggle.component.ts @@ -30,7 +30,12 @@ import { $size: 30px; @include center-child(); - @include inner-spacing(var(--medium-padding), $horizontal: true); + gap: var(--medium-padding); + + @media (max-width: $mobile-width) { + width: 100%; + gap: var(--small-padding); + } .toggle { display: contents; @@ -41,7 +46,7 @@ import { // Fixed width (not max-width) so multiple toggles align column-wise // — the thumb position is identical across rows regardless of label. flex: 0 0 auto; - width: 4 * $size; + width: var(--toggle-label-width, #{4 * $size}); box-sizing: border-box; padding: 0 var(--small-padding); line-height: 1.3; @@ -50,10 +55,19 @@ import { &.active { font-weight: bold; } &:first-of-type { text-align: right; } &:last-of-type { text-align: left; } + + @media (max-width: $mobile-width) { + flex: 1 1 0; + width: auto; + min-width: 0; + padding: 0; + overflow-wrap: anywhere; + } } label { display: block; + flex: 0 0 auto; input[type='checkbox'] { -webkit-appearance: none; diff --git a/frontend/src/app/components/tasks/tasks.component.ts b/frontend/src/app/components/tasks/tasks.component.ts index 5a035d3..9ad521a 100644 --- a/frontend/src/app/components/tasks/tasks.component.ts +++ b/frontend/src/app/components/tasks/tasks.component.ts @@ -17,16 +17,26 @@ import { getColorOfTag } from '../../utils/color';
-

+

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

@for (b of pending(); track b.id) {
@@ -34,11 +44,15 @@ import { getColorOfTag } from '../../utils/color'; type="button" class="tickbox" [style.background-color]="colorOf(b.tag)" + (pointerup)="$event.stopPropagation()" + (touchend)="$event.stopPropagation()" (click)="$event.stopPropagation(); markDone.emit(b)" [attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'" >

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

@@ -82,10 +96,24 @@ import { getColorOfTag } from '../../utils/color'; :first-child { margin-top: var(--small-padding); } - height: 0; box-sizing: border-box; - transition: height $long-animation-time; - overflow-y: hidden; + 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. + */ + overflow: hidden; + + // Sideways breathing room so the clip doesn't shear the tickbox's + // hover shadow; negative side margins keep rows flush with the header, + // and the bottom padding clears the last row's shadow. + margin: 0 calc(var(--small-padding) / -2); + padding: 0 calc(var(--small-padding) / 2) calc(var(--small-padding) / 2); .task-container { display: flex; @@ -106,14 +134,16 @@ import { getColorOfTag } from '../../utils/color'; // done without opening the edit carousel. Hover & focus reveal a // subtle inner check mark. .tickbox { - flex: 0 0 auto; all: unset; // strip native button styles + flex: 0 0 24px; cursor: pointer; position: relative; box-sizing: border-box; @include square(24px); + min-width: 24px; + min-height: 24px; @media (max-width: $mobile-width) { - @include square(20px); + @include square(24px); } border-radius: 4px; box-shadow: $shadow-border; @@ -123,14 +153,25 @@ import { getColorOfTag } from '../../utils/color'; content: '✓'; position: absolute; inset: 0; + /* + * Neutralise the global animated-underline bar from + * forms.scss (button:after { height: 2px; width: 0->100% on + * hover; background-color: $text-color }). The all:unset on the + * button does NOT reach the pseudo-element, so without these + * resets the bar paints a dark stripe across the top AND + * squashes this box to 2px — which centres the glyph near the + * top instead of the middle. + */ + width: 100%; + height: 100%; + background: none; @include center-child(); color: $light-color; - font-size: 18px; - font-weight: bold; - line-height: 1; - opacity: 0.5; + font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif) + opacity: 0; // hidden at rest — only revealed on hover/focus/active text-shadow: 0 0 1px rgba(0, 0, 0, 0.4); - transform: translateY(2px); + // The ✓ glyph sits a touch high in its em-box; nudge to optical centre. + transform: translateY(1px); transition: opacity $short-animation-time, transform $short-animation-time; } @@ -142,7 +183,7 @@ import { getColorOfTag } from '../../utils/color'; } &:active { transform: scale(0.95); - &::after { opacity: 1; transform: translateY(2px) scale(1.05); } + &::after { opacity: 1; transform: translateY(1px) scale(1.05); } } } @@ -152,10 +193,13 @@ import { getColorOfTag } from '../../utils/color'; overflow-x: hidden; text-align: left; flex: 1 1 auto; + min-width: 0; cursor: pointer; @media (max-width: $mobile-width) { - font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2); + font-size: var(--medium-font-size); + font-weight: 500; + filter: saturate(0.85) brightness(0.82); } position: relative; @@ -178,6 +222,7 @@ export class TasksComponent { readonly edit = output(); readonly expanded = signal(false); + private lastToggleAt = 0; constructor() { // Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping @@ -193,4 +238,15 @@ export class TasksComponent { colorOf(tag: string): string { return getColorOfTag(tag, this.baseColor()); } + + toggleExpanded(event: Event): void { + event.stopPropagation(); + if (event.type === 'touchend') { + event.preventDefault(); + } + const now = Date.now(); + if (now - this.lastToggleAt < 250) return; + this.lastToggleAt = now; + this.expanded.update((v) => !v); + } } diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index 4e56d36..2141ce0 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -7,6 +7,10 @@ import { computed, effect, untracked, + AfterViewInit, + OnDestroy, + ElementRef, + viewChild, } from '@angular/core'; import { Tower, Block } from '../../models'; import { BlockComponent } from '../block/block.component'; @@ -26,6 +30,8 @@ interface StyledBlock extends Block { _opacity: string; } +const BLOCKS_PER_ROW = 6; + @Component({ selector: 'lt-tower', standalone: true, @@ -33,51 +39,79 @@ interface StyledBlock extends Block { changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
+
+ + + +
+ +
- Add block +
+ Add block -
-
- @for (b of visibleBlocks(); track b.id) { - - } +
+
+ @for (b of visibleBlocks(); track b.id) { + + } +
- + @if (hiddenBlockCount() > 0) { +

+ {{ hiddenBlockCount() }} more

+ }
@if (editEntry(); as entry) { - + } `, @@ -100,7 +139,9 @@ interface StyledBlock extends Block { @import '../../../library/main'; :host { + display: block; cursor: pointer; + min-height: 0; &.cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); @@ -140,7 +181,7 @@ interface StyledBlock extends Block { } } - input { + .tower-header { display: none; } } @@ -149,8 +190,10 @@ interface StyledBlock extends Block { display: flex; flex-direction: column; align-items: center; + position: relative; max-width: 100%; height: 100%; + min-height: 0; @include inner-spacing(var(--small-padding)); @@ -158,7 +201,14 @@ interface StyledBlock extends Block { display: flex; flex-direction: column; flex: 1 1 auto; + margin-bottom: 0; + min-height: 0; position: relative; + box-sizing: border-box; + container-type: inline-size; + --block-stack-height: 0px; + --add-block-size: 48px; + --add-block-clearance: var(--medium-padding); @include card(); overflow: hidden; @@ -168,7 +218,9 @@ interface StyledBlock extends Block { @media (max-width: $mobile-width) { @include inner-spacing(var(--small-padding)); - padding: var(--small-padding); + padding: 0; + --add-block-size: 32px; + --add-block-clearance: var(--small-padding); } width: 100%; @@ -189,16 +241,16 @@ interface StyledBlock extends Block { } lt-tasks { - flex: 0 0 auto; + flex: 0 1 auto; min-height: 56px; - max-height: 30vh; - overflow: hidden; + max-height: min(30vh, 45%); + overflow: auto; display: block; width: 100%; @media (max-width: $mobile-width) { min-height: 44px; - max-height: 25vh; + max-height: min(25vh, 45%); } .container { @@ -207,80 +259,169 @@ interface StyledBlock extends Block { } } - img.add-block { - flex: 0 0 auto; - align-self: center; - margin: var(--medium-padding) 0; - } - - img { + .stack-zone { position: relative; - z-index: 2; - height: 48px; + flex: 1 1 auto; + min-height: 0; + width: 100%; - @media (max-width: $mobile-width) { - height: 32px; + img { + position: relative; + z-index: 2; + height: 48px; + + @media (max-width: $mobile-width) { + height: 32px; + } + + opacity: 0.33; + transition: opacity $long-animation-time; + cursor: pointer; + + &:hover { + opacity: 1; + } } - opacity: 0.33; - transition: opacity $long-animation-time; - cursor: pointer; - - &:hover { - opacity: 1; - } - } - - .block-container-container { - position: relative; - flex: 1; - - .block-container { - display: flex; - flex-flow: row wrap; - justify-content: flex-start; - align-content: flex-start; - align-items: flex-end; + img.add-block { position: absolute; - bottom: 0; - width: 100%; - transform: scaleY(-1); + z-index: 3; + left: 50%; + top: max( + 0px, + min( + calc(50% - var(--add-block-size) / 2), + calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size)) + ) + ); + transform: translateX(-50%); + } - /* Default resting position for all blocks before JS sets them */ - * { - transform: translateY(500%); - } + .block-container-container { + position: absolute; + inset: 0; - .descend { - transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), - opacity 500ms cubic-bezier(0.5, 0, 1, 0); - } + .block-container { + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + align-content: flex-start; + align-items: flex-end; + position: absolute; + bottom: 0; + width: 100%; + transform: scaleY(-1); - .ascend { - transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), - opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s; + /* Default resting position for all blocks before JS sets them */ + * { + transform: translateY(500%); + } + + .descend { + transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), + opacity 500ms cubic-bezier(0.5, 0, 1, 0); + } + + .ascend { + transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), + opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s; + } } } } } - input[type='text'] { - font-size: var(--small-font-size); + .more-blocks { + @include small-text(); + position: absolute; + top: calc(100% + var(--small-padding)); + left: 0; + right: 0; + margin: 0; + line-height: 1; + color: rgba($text-color, 0.72); text-align: center; + pointer-events: none; + user-select: none; + } - @media (min-width: $mobile-width) { - width: 50%; + .tower-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + + input[type='text'] { + box-sizing: border-box; + min-width: 0; + font-size: var(--small-font-size); + text-align: center; + + /* Truncate long titles with an ellipsis instead of wrapping. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + /* Reserve a symmetric gutter on each side. The right one keeps the + title and its focus underline clear of the absolutely-positioned + pen (so the underline stops before it); the equal left one keeps + the centered title optically centered. (pen 22px + 4px gap.) */ + width: calc(100% - 52px); + + @media (max-width: $mobile-width) { + width: calc(100% - 60px); + } } - @media (max-width: $mobile-width) { - width: 100%; + .edit-tower { + all: unset; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + box-sizing: border-box; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius); + cursor: pointer; + opacity: 0.35; + transition: opacity $short-animation-time, box-shadow $long-animation-time, background-color $short-animation-time; + + /* Suppress the global button's animated hover underline + (button::after), which the 'all: unset' reset doesn't reach. */ + &:after { + content: none; + } + + img { + width: 13px; + height: 13px; + opacity: 1; + } + + &:hover, + &:focus-visible { + opacity: 1; + background-color: rgba($light-color, 0.86); + box-shadow: $shadow-border; + } + + @media (max-width: $mobile-width) { + width: 26px; + height: 26px; + opacity: 0.55; + } } } } } `, }) -export class TowerComponent { +export class TowerComponent implements AfterViewInit, OnDestroy { // ── Inputs ───────────────────────────────────────────────────────────────── readonly tower = input.required(); /** Set by page component when this tower is being dragged over trash. */ @@ -308,6 +449,11 @@ export class TowerComponent { * which list of blocks to show and which one to focus initially. */ readonly editEntry = signal(null); readonly showSettings = signal(false); + readonly hiddenBlockCount = signal(0); + + private readonly stackZone = viewChild>('stackZone'); + private readonly maxVisibleBlocks = signal(null); + private resizeObserver: ResizeObserver | null = null; // ── Derived ──────────────────────────────────────────────────────────────── /** Pending (not-done) blocks — fed to the tasks accordion. */ @@ -324,6 +470,13 @@ export class TowerComponent { return this.tower().blocks.filter(b => b.is_done === isDone); }); + readonly editViewTitle = computed(() => { + const entry = this.editEntry(); + if (!entry) return ''; + const prefix = entry.filter === 'done' ? 'Completed tasks' : 'Tasks'; + return `${prefix} of ${this.tower().name}`; + }); + /** Unique tags from existing blocks of this tower. */ readonly towerTags = computed(() => { const set = new Set(); @@ -339,6 +492,11 @@ export class TowerComponent { private readonly _visibleBlocks = signal([]); readonly visibleBlocks = this._visibleBlocks.asReadonly(); + readonly blockStackHeight = computed(() => { + const rows = Math.ceil(this.visibleBlocks().length / BLOCKS_PER_ROW); + const cqw = rows * (100 / BLOCKS_PER_ROW); + return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`; + }); private prevDoneIds: string[] = []; private isFirstRun = true; @@ -346,15 +504,69 @@ export class TowerComponent { constructor() { effect(() => { const range = this.dateRange(); - // Always keep ALL done blocks in the visible list. The date filter - // only flips per-block `_anim` so the 1.5s ascend/descend transition - // can animate them in and out of the falling stack. + const maxVisibleBlocks = this.maxVisibleBlocks(); + // Reconcile all done blocks, then cap the rendered stack to the rows + // that fit below the tasks and add button. const allDone = this.tower().blocks.filter((b) => b.is_done); - untracked(() => this.reconcile(allDone, range)); + untracked(() => this.reconcile(allDone, range, maxVisibleBlocks)); }); } - private reconcile(allDone: Block[], range: { from: number; to: number } | null): void { + ngAfterViewInit(): void { + const stackZone = this.stackZone()?.nativeElement; + if (!stackZone) return; + + this.measureBlockCapacity(); + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.measureBlockCapacity()); + this.resizeObserver.observe(stackZone); + } + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => this.measureBlockCapacity()); + } + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + } + + private measureBlockCapacity(): void { + const stackZone = this.stackZone()?.nativeElement; + if (!stackZone) return; + + const width = stackZone.clientWidth; + const height = stackZone.clientHeight; + if (width <= 0 || height <= 0) { + this.maxVisibleBlocks.set(0); + return; + } + + const styles = getComputedStyle(stackZone); + const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48); + const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15; + const clearance = this.parseCssPixels( + styles.getPropertyValue('--add-block-clearance'), + fallbackClearance, + ); + + const rowHeight = width / BLOCKS_PER_ROW; + const availableHeight = Math.max(0, height - addBlockSize - 2 * clearance); + const rows = Math.floor((availableHeight + 0.5) / rowHeight); + this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW); + } + + private parseCssPixels(value: string, fallback: number): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + + private reconcile( + allDone: Block[], + range: { from: number; to: number } | null, + maxVisibleBlocks: number | null, + ): void { const ids = allDone.map((b) => b.id); const prev = this.prevDoneIds; const prevSet = new Set(prev); @@ -393,16 +605,39 @@ export class TowerComponent { }); } + const visibleLimit = + maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks); + const restingBlocks = styled.filter((b) => b._opacity === '1'); + const hiddenCount = Number.isFinite(visibleLimit) + ? Math.max(0, restingBlocks.length - visibleLimit) + : 0; + let visibleStyled = styled; + if (Number.isFinite(visibleLimit)) { + const shownRestingBlocks = + hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks; + const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length); + const transitioningBlocks = + remainingSlots > 0 + ? styled.filter((b) => b._opacity !== '1').slice(-remainingSlots) + : []; + const shownIds = new Set([ + ...shownRestingBlocks.map((b) => b.id), + ...transitioningBlocks.map((b) => b.id), + ]); + visibleStyled = styled.filter((b) => shownIds.has(b.id)); + } + this.hiddenBlockCount.set(hiddenCount); + if (grewByOne) { const newId = newIds[0]; - const newBlock = styled.find(b => b.id === newId); + const newBlock = visibleStyled.find(b => b.id === newId); if (newBlock && inRange(newBlock)) { // Snap newly-added in-range block to start position, then on the next // paint flip it back to rest — that's what makes it visibly fall. newBlock._anim = ''; newBlock._transform = 'translateY(500%)'; newBlock._opacity = '0'; - this._visibleBlocks.set(styled); + this._visibleBlocks.set(visibleStyled); requestAnimationFrame(() => { requestAnimationFrame(() => { newBlock._anim = 'descend'; @@ -417,7 +652,7 @@ export class TowerComponent { } } // existing fall-through path (no growth, first run, or new block out of range): - this._visibleBlocks.set(styled); + this._visibleBlocks.set(visibleStyled); this.prevDoneIds = ids; this.isFirstRun = false; @@ -472,7 +707,8 @@ export class TowerComponent { } onTowerSave(result: TowerSettingsResult): void { - this.showSettings.set(false); + // Tower edits auto-save, so this fires on every change and must NOT close + // the modal — the user closes it via the exit button / backdrop. this.updateTower.emit(result); } diff --git a/frontend/src/app/components/welcome/welcome.component.ts b/frontend/src/app/components/welcome/welcome.component.ts index 5842d25..e1168b2 100644 --- a/frontend/src/app/components/welcome/welcome.component.ts +++ b/frontend/src/app/components/welcome/welcome.component.ts @@ -7,7 +7,7 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
- +

Welcome to Life Towers

@@ -60,6 +60,11 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; h2 { text-align: center; margin-bottom: var(--medium-padding); + padding: 0 36px; + + @media (max-width: $mobile-width) { + padding: 0 28px; + } } p.lead { color: $text-color; } @@ -85,6 +90,10 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; border-bottom-color: rgba($accent-color, 0.33); &:after { background-color: $accent-color; } } + + @media (max-width: $mobile-width) { + gap: var(--small-padding); + } } } `, diff --git a/frontend/src/app/services/analytics.service.ts b/frontend/src/app/services/analytics.service.ts new file mode 100644 index 0000000..84d3e0c --- /dev/null +++ b/frontend/src/app/services/analytics.service.ts @@ -0,0 +1,65 @@ +import { Injectable, isDevMode } from '@angular/core'; +import { + init as plausibleInit, + track as plausibleTrack, + type PlausibleEventOptions, +} from '@plausible-analytics/tracker'; + +const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true; +const ANALYTICS_DOMAIN = 'schmelczer.dev/towers'; +const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status'; + +@Injectable({ providedIn: 'root' }) +export class AnalyticsService { + private isInitialized = false; + private hasTrackedStart = false; + + init(): void { + if (this.isInitialized) return; + try { + plausibleInit({ + domain: ANALYTICS_DOMAIN, + endpoint: ANALYTICS_ENDPOINT, + autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS, + logging: isDevMode(), + }); + this.isInitialized = true; + } catch (error) { + console.warn('Could not initialize analytics.', error); + } + } + + private track(eventName: string, options: PlausibleEventOptions = {}): void { + try { + plausibleTrack(eventName, options); + } catch (error) { + console.warn(`Could not track analytics event "${eventName}".`, error); + } + } + + trackStart(): void { + if (this.hasTrackedStart) return; + this.hasTrackedStart = true; + this.track('Start'); + } + + trackExampleLoaded(): void { + this.track('Example Loaded'); + } + + trackPageCreated(): void { + this.track('Page Created'); + } + + trackTowerCreated(): void { + this.track('Tower Created'); + } + + trackBlockCreated({ isDone }: { isDone: boolean }): void { + this.track('Block Created', { props: { isDone: String(isDone) } }); + } + + trackBlockCompleted(): void { + this.track('Block Completed'); + } +} diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index a1e3e5b..f158445 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -1,5 +1,6 @@ import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core'; import { ApiService } from './api.service'; +import { AnalyticsService } from './analytics.service'; import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models'; const TOKEN_KEY = 'life-towers.token.v4'; @@ -55,9 +56,24 @@ interface PendingPut { tree: TreeDto; } +interface ExampleBlockSeed { + tag: string; + desc: string; + done: boolean; + ageHrs: number; +} + +interface ExampleDonePattern { + tag: string; + desc: (sequence: number) => string; +} + +const EXAMPLE_DONE_BLOCKS_PER_TOWER = 120; + @Injectable({ providedIn: 'root' }) export class StoreService implements OnDestroy { private readonly api = inject(ApiService); + private readonly analytics = inject(AnalyticsService); // ── State ────────────────────────────────────────────────────────────────── private readonly _pages = signal([]); @@ -226,6 +242,8 @@ export class StoreService implements OnDestroy { towers: [], }; this._pages.update((pages) => [...pages, page]); + this.analytics.trackStart(); + this.analytics.trackPageCreated(); this.scheduleSave(); } @@ -256,6 +274,8 @@ export class StoreService implements OnDestroy { this._pages.update((pages) => pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)), ); + this.analytics.trackStart(); + this.analytics.trackTowerCreated(); this.scheduleSave(); } @@ -318,6 +338,8 @@ export class StoreService implements OnDestroy { : p, ), ); + this.analytics.trackStart(); + this.analytics.trackBlockCreated({ isDone: is_done }); this.scheduleSave(); } @@ -336,7 +358,7 @@ export class StoreService implements OnDestroy { t.id === towerId ? { ...t, - blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)), + blocks: this.patchBlockList(t.blocks, blockId, patch).blocks, } : t, ), @@ -366,6 +388,7 @@ export class StoreService implements OnDestroy { } toggleBlock(pageId: string, towerId: string, blockId: string): void { + let becameDone = false; this._pages.update((pages) => pages.map((p) => p.id === pageId @@ -373,21 +396,58 @@ export class StoreService implements OnDestroy { ...p, towers: p.towers.map((t) => t.id === towerId - ? { - ...t, - blocks: t.blocks.map((b) => - b.id === blockId ? { ...b, is_done: !b.is_done } : b, - ), - } + ? (() => { + 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, + patch: Partial>, + ): { blocks: Block[]; becameDone: boolean } { + const nextBlocks: Block[] = []; + let completedBlock: Block | null = null; + let becameDone = false; + + for (const block of blocks) { + if (block.id !== blockId) { + nextBlocks.push(block); + continue; + } + + const updated: Block = { ...block, ...patch }; + becameDone = !block.is_done && updated.is_done; + if (becameDone) { + // Display newly completed todos as the newest square, without changing + // created_at because the date slider still filters on creation time. + completedBlock = updated; + } else { + nextBlocks.push(updated); + } + } + + return { + blocks: completedBlock ? [...nextBlocks, completedBlock] : nextBlocks, + becameDone, + }; + } + /** * Switch to a different user's token. Any pending writes for the OLD * account must be cancelled first — otherwise a queued PUT could fire @@ -529,30 +589,56 @@ export class StoreService implements OnDestroy { default_date_from: null, default_date_to: null, towers: [ + // Done blocks are listed oldest-first so they fill the falling stack + // oldest → newest (left → right), matching the slider's old → new + // labels; pending tasks stay on top for the accordion. this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [ - { tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 }, - { tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 }, - { tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 }, - { tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 }, - { tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 }, + { tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 }, + ...this.makeExampleDoneBlocks( + [ + { tag: 'novel', desc: (n) => `Read chapter ${n}` }, + { tag: 'paper', desc: (n) => `Annotated paper ${n}` }, + { tag: 'article', desc: (n) => `Saved article notes ${n}` }, + { tag: 'essay', desc: (n) => `Drafted reading response ${n}` }, + ], + 2, + 8, + ), ]), this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [ - { tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 }, - { tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 }, - { tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 }, - { tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 }, + { tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 }, + { tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 }, + ...this.makeExampleDoneBlocks( + [ + { tag: 'angular', desc: (n) => `Refined UI pass ${n}` }, + { tag: 'rust', desc: (n) => `Completed systems spike ${n}` }, + { tag: 'infra', desc: (n) => `Tuned deploy workflow ${n}` }, + { tag: 'docs', desc: (n) => `Captured project note ${n}` }, + ], + 4, + 9, + ), ]), this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [ - { tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 }, - { tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 }, - { tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 }, - { tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 }, - { tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 }, + { tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 }, + { tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 }, + ...this.makeExampleDoneBlocks( + [ + { tag: 'run', desc: (n) => `Easy run ${n}` }, + { tag: 'climb', desc: (n) => `Bouldering session ${n}` }, + { tag: 'mobility', desc: (n) => `Mobility circuit ${n}` }, + { tag: 'strength', desc: (n) => `Strength session ${n}` }, + ], + 6, + 11, + ), ]), ], }; this._pages.update((pages) => [...pages, page]); + this.analytics.trackStart(); + this.analytics.trackExampleLoaded(); this.scheduleSave(); } @@ -560,7 +646,7 @@ export class StoreService implements OnDestroy { name: string, base_color: HslColor, nowSec: number, - blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>, + blocks: ExampleBlockSeed[], ): Tower { return { id: uuidV4(), @@ -576,6 +662,23 @@ export class StoreService implements OnDestroy { }; } + private makeExampleDoneBlocks( + patterns: ExampleDonePattern[], + newestAgeHrs: number, + spacingHrs: number, + ): ExampleBlockSeed[] { + return Array.from({ length: EXAMPLE_DONE_BLOCKS_PER_TOWER }, (_, i) => { + const pattern = patterns[i % patterns.length]; + const sequence = Math.floor(i / patterns.length) + 1; + return { + tag: pattern.tag, + desc: pattern.desc(sequence), + done: true, + ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs, + }; + }); + } + ngOnDestroy(): void { this.cancelPendingWrites(); if (typeof window !== 'undefined') { diff --git a/frontend/src/app/services/store.service.vitest.ts b/frontend/src/app/services/store.service.vitest.ts index 0836e9b..75d3f30 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -218,6 +218,87 @@ describe('StoreService', () => { expect(api.getData).toHaveBeenCalledTimes(1); }); + it('moves a pending block to the end when it becomes done', async () => { + storage[TOKEN_KEY] = FIXED_UUID; + const api = makeMockApi(); + api.getData.mockResolvedValue({ + pages: [ + { + id: 'page-1', + name: 'Page', + hide_create_tower_button: false, + keep_tasks_open: false, + default_date_from: null, + default_date_to: null, + towers: [ + { + id: 'tower-1', + name: 'Tower', + base_color: { h: 0.5, s: 0.5, l: 0.5 }, + blocks: [ + { + id: 'old-pending', + tag: 'a', + description: 'Created first, completed last', + is_done: false, + created_at: 100, + }, + { + id: 'newer-pending', + tag: 'b', + description: 'Still pending', + is_done: false, + created_at: 300, + }, + { + id: 'existing-done', + tag: 'c', + description: 'Already done', + is_done: true, + created_at: 200, + }, + ], + }, + ], + }, + ], + } satisfies TreeDto); + const store = configure(api); + await store.init(); + + store.updateBlock('page-1', 'tower-1', 'old-pending', { is_done: true }); + + const blocks = store.pages()[0].towers[0].blocks; + expect(blocks.map((b) => b.id)).toEqual([ + 'newer-pending', + 'existing-done', + 'old-pending', + ]); + expect(blocks[2].created_at).toBe(100); + }); + + it('loads welcome example data with hundreds of completed squares', () => { + const api = makeMockApi(); + const store = configure(api); + + store.loadExample(); + + const [page] = store.pages(); + expect(page.name).toBe('Hobbies'); + expect(page.towers).toHaveLength(3); + + const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); + expect(doneBlocks.length).toBeGreaterThanOrEqual(300); + + for (const tower of page.towers) { + const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at); + expect(doneDates.length).toBeGreaterThanOrEqual(100); + expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); + } + + store.ngOnDestroy(); + }); + // ── Debounced save ───────────────────────────────────────────────────────── it('debounces saves: multiple mutations within 750ms → one PUT', async () => { diff --git a/frontend/src/index.html b/frontend/src/index.html index cfcc28d..032f734 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -13,11 +13,24 @@ + - + - + + + + + + + + + + + + + diff --git a/frontend/src/library/forms.scss b/frontend/src/library/forms.scss index 43df3cf..086af81 100644 --- a/frontend/src/library/forms.scss +++ b/frontend/src/library/forms.scss @@ -38,6 +38,10 @@ input[type='text'] { &:focus { box-shadow: 0 1px $text-color; } + + &:focus-visible { + box-shadow: 0 2px $text-color; + } } button { @@ -56,6 +60,19 @@ button { border-bottom: solid $height #5d576b55; position: relative; + // Mobile: grow buttons to a ~42px tap target, but pin the label to the bottom + // so the bottom-border underline keeps hugging it (as on desktop). A bare + // min-height vertically centres the text and strands the underline well below + // it. Using flex (not a hit-area pseudo-element) means buttons that `all: unset` + // their styling — .tickbox, .swatch, .edit-tower — reset these props and opt out + // automatically, instead of inheriting a stray absolute overlay. + @media (max-width: $mobile-width) { + min-height: 42px; + display: inline-flex; + align-items: flex-end; + justify-content: center; + } + &:disabled { color: #5d576b55; border-bottom: solid $height #5d576b33; diff --git a/frontend/src/library/main.scss b/frontend/src/library/main.scss index 0156b7f..6c4fee6 100644 --- a/frontend/src/library/main.scss +++ b/frontend/src/library/main.scss @@ -34,12 +34,31 @@ @mixin exit { @include square(16px); - background: url('/assets/x-sign.svg') no-repeat center center; - background-size: 50% 50%; + -webkit-appearance: none; + appearance: none; + background: transparent url('/assets/x-sign.svg') no-repeat center center / 50% 50%; + border: 0 !important; + border-bottom: 0 !important; + box-shadow: none; box-sizing: content-box; + color: transparent; + display: block; + flex: 0 0 auto; + font-size: 0; + line-height: 0; + margin: 0; + min-height: 0; + overflow: hidden; padding: 8px; + text-decoration: none !important; @include jump(); + + &:before, + &:after { + content: none !important; + display: none; + } } img { diff --git a/frontend/src/library/utils.scss b/frontend/src/library/utils.scss index 6f367f3..5190f07 100644 --- a/frontend/src/library/utils.scss +++ b/frontend/src/library/utils.scss @@ -6,4 +6,22 @@ margin-bottom: $spacing; } } + + & > lt-modal { + @if $horizontal { + margin-right: 0 !important; + } @else { + margin-bottom: 0 !important; + } + } + + // Fixed modal hosts should not make the previous content child count as + // "not last" for spacing. + & > *:not(:last-child):not(lt-modal):not(:has(~ *:not(lt-modal))) { + @if $horizontal { + margin-right: 0; + } @else { + margin-bottom: 0; + } + } } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 79a3348..af0b622 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + // The package ships only a `module` field, which Vite's resolver + // can't always find — point it at the file directly. + '@plausible-analytics/tracker': + '@plausible-analytics/tracker/plausible.js', + }, + }, test: { globals: true, environment: 'jsdom',