Build Admin Übersicht — dashboard with KPI strip and category table #13

Open
opened 2026-05-05 10:58:17 +02:00 by marcel · 8 comments
Owner

Task 13 — Plan reference: docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.md

User story (US-ADM-008):
As an admin, I see a dashboard with total/reserved/free article counts and a breakdown by category.

Acceptance criteria

  • KPI strip: 3 cards side-by-side — "Gesamt", "Reserviert", "Frei" with large Lora serif number in text-primary
  • Category table: Kategorie · Gesamt · Reserviert · Frei columns
  • Only categories that have at least 1 article are shown in the table
  • "Reserviert" column in text-status-taken, "Frei" in text-status-free
  • No charts or diagrams (spec explicitly excludes them)
  • All data from aggregate SQL queries, no N+1

Files to create

  • src/routes/admin/uebersicht/+page.svelte
  • src/routes/admin/uebersicht/+page.server.ts

Depends on: #8 | Size: S | Spec: reservierung-design §5.5, views spec View 09

## Task 13 — Plan reference: `docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.md` **User story (US-ADM-008):** As an admin, I see a dashboard with total/reserved/free article counts and a breakdown by category. ### Acceptance criteria - [ ] KPI strip: 3 cards side-by-side — "Gesamt", "Reserviert", "Frei" with large Lora serif number in `text-primary` - [ ] Category table: Kategorie · Gesamt · Reserviert · Frei columns - [ ] Only categories that have at least 1 article are shown in the table - [ ] "Reserviert" column in `text-status-taken`, "Frei" in `text-status-free` - [ ] No charts or diagrams (spec explicitly excludes them) - [ ] All data from aggregate SQL queries, no N+1 ### Files to create - `src/routes/admin/uebersicht/+page.svelte` - `src/routes/admin/uebersicht/+page.server.ts` **Depends on:** #8 | **Size:** S | **Spec:** reservierung-design §5.5, views spec View 09
marcel added this to the v1.0 — MVP milestone 2026-05-05 10:58:17 +02:00
Author
Owner

👤 Markus Keller — Application Architect

Observations

  • The load function in the plan iterates over KATEGORIEN and fires two prepared-statement calls per category (one COUNT for total, one for reserved). With 8 categories that is 16 synchronous DB calls per page load. For this project's scale that is acceptable, but it is not aggregate SQL — the issue's own acceptance criterion says "all data from aggregate SQL queries, no N+1".
  • The plan re-prepares the same two statements inside KATEGORIEN.map() on every request (db.prepare(...) called N times). Statements must be prepared once at module load, not inside the load function body.
  • getDb() is called once and the result assigned to const db — that is correct. But db.prepare(...) inside .map() should be module-level constants.
  • frei = gesamt - reserviert is computed in application code. This is correct — it is a trivial arithmetic derive, not a second query. No issue here.
  • The auth guard is handled by +layout.server.ts under /admin/, so this route needs no explicit locals.admin check — that is architecturally correct.
  • The empty-state branch (byKat.length === 0) is handled in the template. Good.
  • No mutation actions are needed on this page — load-only is correct.

Recommendations

  • Replace the per-category loop with a single GROUP BY aggregate query. This satisfies the acceptance criterion explicitly and eliminates the N+1:
    // module level — prepared once
    const stmtKpiGesamt = db.prepare('SELECT COUNT(*) AS n FROM artikel')
    const stmtKpiReserviert = db.prepare('SELECT COUNT(*) AS n FROM reservierungen')
    const stmtByKat = db.prepare(`
      SELECT a.kategorie,
             COUNT(*)                                        AS gesamt,
             COUNT(r.artikel_id)                             AS reserviert,
             COUNT(*) - COUNT(r.artikel_id)                  AS frei
      FROM   artikel a
      LEFT JOIN reservierungen r ON r.artikel_id = a.id
      GROUP BY a.kategorie
      ORDER BY a.kategorie
    `)
    
    The load function then becomes three synchronous calls total, regardless of the number of categories.
  • Move all db.prepare() calls to module scope in +page.server.ts (or into lib/db.ts alongside the other prepared statements). The current plan's inline .prepare() inside .map() re-prepares on every request — that is the anti-pattern the architecture explicitly warns against.
  • The GROUP BY query automatically excludes categories with zero articles (they produce no rows), satisfying the "only categories with at least 1 article" criterion without the .filter(r => r.gesamt > 0) application-level filter.
  • Export the type returned by stmtByKat.all() explicitly so TypeScript can verify the shape at compile time rather than relying on as { n: number } casts.
## 👤 Markus Keller — Application Architect ### Observations - The load function in the plan iterates over `KATEGORIEN` and fires **two prepared-statement calls per category** (one `COUNT` for total, one for reserved). With 8 categories that is 16 synchronous DB calls per page load. For this project's scale that is acceptable, but it is not aggregate SQL — the issue's own acceptance criterion says "all data from aggregate SQL queries, no N+1". - The plan re-prepares the same two statements inside `KATEGORIEN.map()` on every request (`db.prepare(...)` called N times). Statements must be prepared once at module load, not inside the load function body. - `getDb()` is called once and the result assigned to `const db` — that is correct. But `db.prepare(...)` inside `.map()` should be module-level constants. - `frei = gesamt - reserviert` is computed in application code. This is correct — it is a trivial arithmetic derive, not a second query. No issue here. - The auth guard is handled by `+layout.server.ts` under `/admin/`, so this route needs no explicit `locals.admin` check — that is architecturally correct. - The empty-state branch (`byKat.length === 0`) is handled in the template. Good. - No mutation actions are needed on this page — load-only is correct. ### Recommendations - Replace the per-category loop with a single `GROUP BY` aggregate query. This satisfies the acceptance criterion explicitly and eliminates the N+1: ```typescript // module level — prepared once const stmtKpiGesamt = db.prepare('SELECT COUNT(*) AS n FROM artikel') const stmtKpiReserviert = db.prepare('SELECT COUNT(*) AS n FROM reservierungen') const stmtByKat = db.prepare(` SELECT a.kategorie, COUNT(*) AS gesamt, COUNT(r.artikel_id) AS reserviert, COUNT(*) - COUNT(r.artikel_id) AS frei FROM artikel a LEFT JOIN reservierungen r ON r.artikel_id = a.id GROUP BY a.kategorie ORDER BY a.kategorie `) ``` The load function then becomes three synchronous calls total, regardless of the number of categories. - Move all `db.prepare()` calls to module scope in `+page.server.ts` (or into `lib/db.ts` alongside the other prepared statements). The current plan's inline `.prepare()` inside `.map()` re-prepares on every request — that is the anti-pattern the architecture explicitly warns against. - The `GROUP BY` query automatically excludes categories with zero articles (they produce no rows), satisfying the "only categories with at least 1 article" criterion without the `.filter(r => r.gesamt > 0)` application-level filter. - Export the type returned by `stmtByKat.all()` explicitly so TypeScript can verify the shape at compile time rather than relying on `as { n: number }` casts.
Author
Owner

👤 Felix Brandt — Fullstack Developer

Observations

  • Prepared statements inside the load function: The plan calls db.prepare(...) inside KATEGORIEN.map(), which re-prepares statements on every HTTP request. The project convention (and better-sqlite3 best practice) is to prepare at module load time. This is the most significant implementation error in the plan.
  • {#each} on the KPI strip without a key: The KPI strip uses {#each [{ label, value }, ...] as kpi} with no key expression. For a static 3-element array this will not corrupt DOM state, but it violates the project's rule: always key {#each} blocks. (kpi.label) is a natural key here.
  • All KPI numbers rendered with text-primary: The spec (View 08) shows "Gesamt" in primary green, "Reserviert" in --color-taken (muted red), and "Frei" in --color-free (green). The plan applies text-primary uniformly. The "Reserviert" card number must use text-status-taken.
  • $props() destructuring style: The plan uses let { data }: { data: PageData } = $props() — this is Svelte 5 correct. No issue.
  • No use:enhance or form actions needed on this read-only page. Correct.
  • getDb() pattern: Calling getDb() at the top of the load function body (not at module scope) means the singleton is accessed correctly, but the statements prepared from it are not module-scoped. This needs to be split: getDb() at module scope to get the db reference, then prepared statements as module-level constants.
  • Type safety: as { n: number } casts on every .get() call are not ideal. A typed interface KpiRow { n: number } at the top of the file makes the intent explicit and is easier to maintain.

Recommendations

  • Refactor +page.server.ts so all db.prepare(...) calls are at module scope:
    import { getDb } from '$lib/db'
    const db = getDb()
    const stmtGesamt    = db.prepare('SELECT COUNT(*) AS n FROM artikel')
    const stmtReserviert = db.prepare('SELECT COUNT(*) AS n FROM reservierungen')
    const stmtByKat     = db.prepare(`
      SELECT a.kategorie,
             COUNT(*)               AS gesamt,
             COUNT(r.artikel_id)    AS reserviert,
             COUNT(*) - COUNT(r.artikel_id) AS frei
      FROM artikel a LEFT JOIN reservierungen r ON r.artikel_id = a.id
      GROUP BY a.kategorie ORDER BY a.kategorie
    `)
    
  • Add (kpi.label) key to the KPI {#each} block.
  • Fix the "Reserviert" KPI number color: use text-status-taken (not text-primary) to match the spec. "Frei" should use text-status-free. Only "Gesamt" gets text-primary.
  • The empty state text "Noch keine Artikel." is correct German and sensible UX for the zero-article case.
## 👤 Felix Brandt — Fullstack Developer ### Observations - **Prepared statements inside the load function:** The plan calls `db.prepare(...)` inside `KATEGORIEN.map()`, which re-prepares statements on every HTTP request. The project convention (and better-sqlite3 best practice) is to prepare at module load time. This is the most significant implementation error in the plan. - **`{#each}` on the KPI strip without a key:** The KPI strip uses `{#each [{ label, value }, ...] as kpi}` with no key expression. For a static 3-element array this will not corrupt DOM state, but it violates the project's rule: always key `{#each}` blocks. `(kpi.label)` is a natural key here. - **All KPI numbers rendered with `text-primary`:** The spec (View 08) shows "Gesamt" in primary green, "Reserviert" in `--color-taken` (muted red), and "Frei" in `--color-free` (green). The plan applies `text-primary` uniformly. The "Reserviert" card number must use `text-status-taken`. - **`$props()` destructuring style:** The plan uses `let { data }: { data: PageData } = $props()` — this is Svelte 5 correct. No issue. - **No `use:enhance` or form actions** needed on this read-only page. Correct. - **`getDb()` pattern:** Calling `getDb()` at the top of the load function body (not at module scope) means the singleton is accessed correctly, but the statements prepared from it are not module-scoped. This needs to be split: `getDb()` at module scope to get the db reference, then prepared statements as module-level constants. - **Type safety:** `as { n: number }` casts on every `.get()` call are not ideal. A typed `interface KpiRow { n: number }` at the top of the file makes the intent explicit and is easier to maintain. ### Recommendations - Refactor `+page.server.ts` so all `db.prepare(...)` calls are at module scope: ```typescript import { getDb } from '$lib/db' const db = getDb() const stmtGesamt = db.prepare('SELECT COUNT(*) AS n FROM artikel') const stmtReserviert = db.prepare('SELECT COUNT(*) AS n FROM reservierungen') const stmtByKat = db.prepare(` SELECT a.kategorie, COUNT(*) AS gesamt, COUNT(r.artikel_id) AS reserviert, COUNT(*) - COUNT(r.artikel_id) AS frei FROM artikel a LEFT JOIN reservierungen r ON r.artikel_id = a.id GROUP BY a.kategorie ORDER BY a.kategorie `) ``` - Add `(kpi.label)` key to the KPI `{#each}` block. - Fix the "Reserviert" KPI number color: use `text-status-taken` (not `text-primary`) to match the spec. "Frei" should use `text-status-free`. Only "Gesamt" gets `text-primary`. - The empty state text `"Noch keine Artikel."` is correct German and sensible UX for the zero-article case.
Author
Owner

👤 Nora "NullX" Steiner — Application Security Engineer

Observations

  • Attack surface on this route is minimal. It is a read-only admin dashboard — no user-supplied input, no mutations, no file operations. The primary risk is unauthorized access, which is handled by +layout.server.ts redirecting non-admins to /admin/login. This guard is not duplicated in the load function, which is the correct architecture.
  • No SQL injection vectors. The aggregate queries use no parameters at all (the GROUP BY variant) or use the ? placeholder pattern with db.prepare(...).get(kat) (the plan's per-category variant). Neither variant exposes an injection surface.
  • No sensitive data exposed. The load function returns only integer counts and category names. Category names come from the KATEGORIEN constant (server-side enum), not from user input or raw DB strings, so there is no output encoding risk.
  • Information disclosure: Aggregate counts (total articles, reserved count) are low-sensitivity data visible only to authenticated admins. No concern.
  • One subtle risk in the plan's per-category variant: db.prepare('SELECT COUNT(*) AS n FROM artikel WHERE kategorie = ?').get(kat)kat comes from KATEGORIEN (a compile-time constant), not from user input. This is safe. However, if anyone later refactors this to accept a query parameter instead of the enum value, the parameterized statement already provides the correct defence. No action needed for the current design, but worth noting for future reviewers.
  • No SESSION_SECRET or cookie handling in this route — none needed.

Recommendations

  • No security changes are required for this route as specified. The admin layout guard is the correct single enforcement point.
  • Add a comment at the top of +page.server.ts briefly stating the threat model so future maintainers understand why there is no explicit auth check here:
    // Auth enforced by src/routes/admin/+layout.server.ts — all /admin/* routes
    // redirect to /admin/login if locals.admin is not set. No per-route check needed.
    
  • When the GROUP BY query is adopted (see Markus's recommendation), verify that category names in the DB cannot be a different value from KATEGORIEN — a CHECK constraint on artikel.kategorie would enforce this at the database layer and prevent phantom rows appearing in the dashboard from data-entry bugs.
## 👤 Nora "NullX" Steiner — Application Security Engineer ### Observations - **Attack surface on this route is minimal.** It is a read-only admin dashboard — no user-supplied input, no mutations, no file operations. The primary risk is unauthorized access, which is handled by `+layout.server.ts` redirecting non-admins to `/admin/login`. This guard is not duplicated in the load function, which is the correct architecture. - **No SQL injection vectors.** The aggregate queries use no parameters at all (the `GROUP BY` variant) or use the `?` placeholder pattern with `db.prepare(...).get(kat)` (the plan's per-category variant). Neither variant exposes an injection surface. - **No sensitive data exposed.** The load function returns only integer counts and category names. Category names come from the `KATEGORIEN` constant (server-side enum), not from user input or raw DB strings, so there is no output encoding risk. - **Information disclosure:** Aggregate counts (total articles, reserved count) are low-sensitivity data visible only to authenticated admins. No concern. - **One subtle risk in the plan's per-category variant:** `db.prepare('SELECT COUNT(*) AS n FROM artikel WHERE kategorie = ?').get(kat)` — `kat` comes from `KATEGORIEN` (a compile-time constant), not from user input. This is safe. However, if anyone later refactors this to accept a query parameter instead of the enum value, the parameterized statement already provides the correct defence. No action needed for the current design, but worth noting for future reviewers. - **No `SESSION_SECRET` or cookie handling** in this route — none needed. ### Recommendations - No security changes are required for this route as specified. The admin layout guard is the correct single enforcement point. - Add a comment at the top of `+page.server.ts` briefly stating the threat model so future maintainers understand why there is no explicit auth check here: ```typescript // Auth enforced by src/routes/admin/+layout.server.ts — all /admin/* routes // redirect to /admin/login if locals.admin is not set. No per-route check needed. ``` - When the `GROUP BY` query is adopted (see Markus's recommendation), verify that category names in the DB cannot be a different value from `KATEGORIEN` — a `CHECK` constraint on `artikel.kategorie` would enforce this at the database layer and prevent phantom rows appearing in the dashboard from data-entry bugs.
Author
Owner

👤 Sara Holt — QA Engineer & Test Strategist

Observations

  • No tests are specified in the plan for Task 13. The issue has acceptance criteria but the plan's step list goes straight from implementation to git commit. For a read-only data-aggregation page, the load function is the primary thing to test — it encapsulates all business logic (count computation, category filtering).
  • The load function is a pure, importable TypeScript function — it can be tested directly with a :memory: SQLite database without a running SvelteKit server. This is exactly the integration-test pattern the project uses for all other load functions.
  • Key behaviors requiring integration tests:
    1. gesamt, reserviert, frei counts are arithmetically consistent (frei = gesamt - reserviert)
    2. Categories with zero articles are excluded from byKat
    3. Categories with articles but zero reservations appear with reserviert: 0 and frei: gesamt
    4. The empty-inventory case returns { gesamt: 0, reserviert: 0, frei: 0, byKat: [] }
  • Component tests: The KPI strip and category table have distinct rendering states (zero-article empty state, non-zero counts, "Reserviert" in taken color, "Frei" in free color). These are verifiable with vitest-browser-svelte without touching the DB.
  • E2E coverage: This route does not require a new Playwright journey — it is a subordinate admin page. The existing admin login journey can optionally navigate to /admin/uebersicht and assert the page title is visible.

Recommendations

  • Add a +page.server.test.ts alongside the implementation with these four integration tests using :memory: SQLite:
    describe('Admin Übersicht load()', () => {
      it('returns zeros when inventory is empty', () => { ... })
      it('gesamt equals reserviert + frei', () => { ... })
      it('excludes categories with zero articles from byKat', () => { ... })
      it('includes category with articles and zero reservations with reserviert=0', () => { ... })
    })
    
  • Add a component test asserting that when data.byKat is empty the table renders the "Noch keine Artikel." empty-state cell (not a blank table body).
  • Verify the "Reserviert" column value renders with the text-status-taken class (or inspect the element's computed color) in a component test — this catches the color regression Felix flagged before it reaches production.
  • The test for the gesamt = reserviert + frei invariant is the most important: it would have caught the plan's proposed arithmetic approach versus the GROUP BY approach discrepancy immediately.

Open Decisions (omit if none)

  • Test file location convention: Should +page.server.test.ts live co-located in src/routes/admin/uebersicht/ or in a top-level tests/ directory? The plan does not establish this convention yet. It needs to be decided before the first load-function test is written so all subsequent tasks follow the same pattern. (Raised by: Sara)
## 👤 Sara Holt — QA Engineer & Test Strategist ### Observations - **No tests are specified in the plan for Task 13.** The issue has acceptance criteria but the plan's step list goes straight from implementation to `git commit`. For a read-only data-aggregation page, the load function is the primary thing to test — it encapsulates all business logic (count computation, category filtering). - **The load function is a pure, importable TypeScript function** — it can be tested directly with a `:memory:` SQLite database without a running SvelteKit server. This is exactly the integration-test pattern the project uses for all other load functions. - **Key behaviors requiring integration tests:** 1. `gesamt`, `reserviert`, `frei` counts are arithmetically consistent (`frei = gesamt - reserviert`) 2. Categories with zero articles are excluded from `byKat` 3. Categories with articles but zero reservations appear with `reserviert: 0` and `frei: gesamt` 4. The empty-inventory case returns `{ gesamt: 0, reserviert: 0, frei: 0, byKat: [] }` - **Component tests:** The KPI strip and category table have distinct rendering states (zero-article empty state, non-zero counts, "Reserviert" in taken color, "Frei" in free color). These are verifiable with `vitest-browser-svelte` without touching the DB. - **E2E coverage:** This route does not require a new Playwright journey — it is a subordinate admin page. The existing admin login journey can optionally navigate to `/admin/uebersicht` and assert the page title is visible. ### Recommendations - Add a `+page.server.test.ts` alongside the implementation with these four integration tests using `:memory:` SQLite: ```typescript describe('Admin Übersicht load()', () => { it('returns zeros when inventory is empty', () => { ... }) it('gesamt equals reserviert + frei', () => { ... }) it('excludes categories with zero articles from byKat', () => { ... }) it('includes category with articles and zero reservations with reserviert=0', () => { ... }) }) ``` - Add a component test asserting that when `data.byKat` is empty the table renders the "Noch keine Artikel." empty-state cell (not a blank table body). - Verify the "Reserviert" column value renders with the `text-status-taken` class (or inspect the element's computed color) in a component test — this catches the color regression Felix flagged before it reaches production. - The test for the `gesamt = reserviert + frei` invariant is the most important: it would have caught the plan's proposed arithmetic approach versus the `GROUP BY` approach discrepancy immediately. ### Open Decisions _(omit if none)_ - **Test file location convention:** Should `+page.server.test.ts` live co-located in `src/routes/admin/uebersicht/` or in a top-level `tests/` directory? The plan does not establish this convention yet. It needs to be decided before the first load-function test is written so all subsequent tasks follow the same pattern. _(Raised by: Sara)_
Author
Owner

👤 Leonie Voss — UI/UX Design Lead

Observations

  • KPI card colors do not match the spec. View 08 in the HTML spec shows:

    • "Gesamt" number: color: var(--pr)text-primary (#5B7A66) ✓ (plan is correct for this one)
    • "Reserviert" number: color: var(--tk)text-status-taken (#9B6060) ✗ (plan renders it in text-primary)
    • "Frei" number: color: var(--fr)text-status-free (#4A7C5C) ✗ (plan renders it in text-primary)

    The plan applies text-primary to all three KPI values. This is a spec deviation. The color difference is not decorative — it gives admins an instant at-a-glance status signal.

  • Label typography is correct. text-[8.5px] font-bold uppercase tracking-[.4px] matches .stat-l in the spec exactly.

  • KPI card structure matches the spec. bg-surface border border-line rounded-lg p-2.5 text-center is correct.

  • Table header color token missing. The plan uses text-[#888] as a hardcoded hex for column headers. The spec and design system use --color-medium (#6B6050) for secondary/caption text. Use text-medium (Tailwind alias for the token) instead.

  • "Kategorie" column text weight: The spec shows category names in font-weight: 600 in the table body. The plan uses text-ink without a weight class — add font-semibold to the category cell <td>.

  • Lora font on KPI number: The spec's .stat-n uses font-family: 'Lora', Georgia, serif. The plan correctly uses font-serif for the KPI value. Good.

  • No page heading in the spec's implementation table for this view, but View 08 mockup shows a page-title element reading "Übersicht". The plan includes this as <h1 class="font-serif text-[14px] font-bold text-ink mb-3">Übersicht</h1> — this is correct and matches the spec's .page-title style.

  • Mobile rendering: This page is admin-only and primarily used on desktop, but the grid-cols-3 KPI strip should be verified at 375px — three equal columns at phone width with 14px padding on each side gives each card roughly 95px width. At font-size: 20px the numbers fit. No overflow expected, but should be confirmed during implementation.

  • Accessibility: The table lacks a <caption> or aria-label. Screen readers navigating the admin panel need to know this is the "Artikel nach Kategorie" breakdown table, not just "a table."

Recommendations

  • Fix the KPI value colors — each card must use its own color class:
    { label: 'Gesamt',    value: data.gesamt,    cls: 'text-primary' },
    { label: 'Reserviert', value: data.reserviert, cls: 'text-status-taken' },
    { label: 'Frei',      value: data.frei,       cls: 'text-status-free' },
    
    Then render: <div class="font-serif text-[20px] font-bold {kpi.cls}">{kpi.value}</div>
  • Replace text-[#888] on table headers with text-medium to stay on the design token system.
  • Add font-semibold to the <td> rendering the category name.
  • Add aria-label="Artikel nach Kategorie" to the <table> element.
## 👤 Leonie Voss — UI/UX Design Lead ### Observations - **KPI card colors do not match the spec.** View 08 in the HTML spec shows: - "Gesamt" number: `color: var(--pr)` → `text-primary` (#5B7A66) ✓ (plan is correct for this one) - "Reserviert" number: `color: var(--tk)` → `text-status-taken` (#9B6060) ✗ (plan renders it in `text-primary`) - "Frei" number: `color: var(--fr)` → `text-status-free` (#4A7C5C) ✗ (plan renders it in `text-primary`) The plan applies `text-primary` to all three KPI values. This is a spec deviation. The color difference is not decorative — it gives admins an instant at-a-glance status signal. - **Label typography is correct.** `text-[8.5px] font-bold uppercase tracking-[.4px]` matches `.stat-l` in the spec exactly. - **KPI card structure matches the spec.** `bg-surface border border-line rounded-lg p-2.5 text-center` is correct. - **Table header color token missing.** The plan uses `text-[#888]` as a hardcoded hex for column headers. The spec and design system use `--color-medium` (`#6B6050`) for secondary/caption text. Use `text-medium` (Tailwind alias for the token) instead. - **"Kategorie" column text weight:** The spec shows category names in `font-weight: 600` in the table body. The plan uses `text-ink` without a weight class — add `font-semibold` to the category cell `<td>`. - **Lora font on KPI number:** The spec's `.stat-n` uses `font-family: 'Lora', Georgia, serif`. The plan correctly uses `font-serif` for the KPI value. Good. - **No page heading in the spec's implementation table for this view**, but View 08 mockup shows a `page-title` element reading "Übersicht". The plan includes this as `<h1 class="font-serif text-[14px] font-bold text-ink mb-3">Übersicht</h1>` — this is correct and matches the spec's `.page-title` style. - **Mobile rendering:** This page is admin-only and primarily used on desktop, but the `grid-cols-3` KPI strip should be verified at 375px — three equal columns at phone width with 14px padding on each side gives each card roughly 95px width. At `font-size: 20px` the numbers fit. No overflow expected, but should be confirmed during implementation. - **Accessibility:** The table lacks a `<caption>` or `aria-label`. Screen readers navigating the admin panel need to know this is the "Artikel nach Kategorie" breakdown table, not just "a table." ### Recommendations - Fix the KPI value colors — each card must use its own color class: ```svelte { label: 'Gesamt', value: data.gesamt, cls: 'text-primary' }, { label: 'Reserviert', value: data.reserviert, cls: 'text-status-taken' }, { label: 'Frei', value: data.frei, cls: 'text-status-free' }, ``` Then render: `<div class="font-serif text-[20px] font-bold {kpi.cls}">{kpi.value}</div>` - Replace `text-[#888]` on table headers with `text-medium` to stay on the design token system. - Add `font-semibold` to the `<td>` rendering the category name. - Add `aria-label="Artikel nach Kategorie"` to the `<table>` element.
Author
Owner

👤 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Observations

  • This task creates no infrastructure files. Task 13 is a pure SvelteKit route — +page.svelte and +page.server.ts. No Dockerfile, docker-compose.yml, or Caddyfile changes are needed. From a deployment perspective this is a zero-risk change.
  • The dashboard data comes from the same SQLite file served by the existing container volume (db:/app/db). No new volumes, no new environment variables. The deployment topology is unchanged.
  • No new environment variables are introduced by this route. The existing DATABASE_PATH covers it.
  • Build output: The new route compiles into the existing SvelteKit build artifact at build/. The deployment procedure (git pull && docker compose up -d --build) picks it up automatically. No deploy procedure changes.
  • Health check not affected. The /health endpoint (or equivalent) is unchanged.
  • One forward-looking note: the +page.server.ts calls getDb() at module scope (or inside the load function). Either way, the SQLite connection is established when the Node process starts — not when the route is first hit. This is already the case for other routes, so no startup-time regression.

Recommendations

  • No infrastructure changes required for this task. Implement, build, deploy as normal.
  • After the first deploy that includes this route, run the post-deploy smoke test and add one manual check: navigate to https://erbstuecke.raddatz.cloud/admin/uebersicht while logged in as an admin and verify the KPI counts are non-zero (assuming articles have been entered). This confirms the SQLite aggregate queries resolve correctly against the production database file in the named volume.
  • If the GROUP BY query replaces the per-category loop (as Markus recommends), the production database must have foreign_keys = ON for the LEFT JOIN to be consistent — this is already enforced at startup in lib/db.ts via db.pragma('foreign_keys = ON'). No action needed.
## 👤 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer ### Observations - **This task creates no infrastructure files.** Task 13 is a pure SvelteKit route — `+page.svelte` and `+page.server.ts`. No Dockerfile, docker-compose.yml, or Caddyfile changes are needed. From a deployment perspective this is a zero-risk change. - **The dashboard data comes from the same SQLite file** served by the existing container volume (`db:/app/db`). No new volumes, no new environment variables. The deployment topology is unchanged. - **No new environment variables** are introduced by this route. The existing `DATABASE_PATH` covers it. - **Build output:** The new route compiles into the existing SvelteKit build artifact at `build/`. The deployment procedure (`git pull && docker compose up -d --build`) picks it up automatically. No deploy procedure changes. - **Health check not affected.** The `/health` endpoint (or equivalent) is unchanged. - **One forward-looking note:** the `+page.server.ts` calls `getDb()` at module scope (or inside the load function). Either way, the SQLite connection is established when the Node process starts — not when the route is first hit. This is already the case for other routes, so no startup-time regression. ### Recommendations - No infrastructure changes required for this task. Implement, build, deploy as normal. - After the first deploy that includes this route, run the post-deploy smoke test and add one manual check: navigate to `https://erbstuecke.raddatz.cloud/admin/uebersicht` while logged in as an admin and verify the KPI counts are non-zero (assuming articles have been entered). This confirms the SQLite aggregate queries resolve correctly against the production database file in the named volume. - If the `GROUP BY` query replaces the per-category loop (as Markus recommends), the production database must have `foreign_keys = ON` for the `LEFT JOIN` to be consistent — this is already enforced at startup in `lib/db.ts` via `db.pragma('foreign_keys = ON')`. No action needed.
Author
Owner

👤 Elicit — Requirements Engineer

Observations

  • US-ADM-008 is well-specified for its size (S). Title, body, acceptance criteria, dependency (#8), size, milestone, and spec references are all present. It passes the Definition of Ready checklist on all counts.
  • Acceptance criterion "All data from aggregate SQL queries, no N+1" conflicts with the plan's implementation. The plan iterates KATEGORIEN.map() and fires 2 queries per category (16 queries for 8 categories). This is a direct N+1. The acceptance criterion is testable and the implementation does not satisfy it. This is not a requirements gap — it is an implementation gap, and it is correctly flagged by the architect.
  • "Only categories that have at least 1 article are shown" is unambiguous and testable. The plan satisfies it via .filter(r => r.gesamt > 0). The GROUP BY alternative satisfies it implicitly. Both are compliant.
  • "No charts or diagrams" — the spec §5.5 states this explicitly. The implementation plan contains none. Compliant.
  • The "Reserviert" column is specified as text-status-taken and "Frei" as text-status-free. The plan deviates (both rendered in text-primary). This is a concrete acceptance criterion failure, not a cosmetic preference.
  • Spec reference "reservierung-design §5.5, views spec View 09": The actual view in 2026-05-05-erbstuecke-wannsee-views.html is labelled View 08, not View 09. The issue body says "View 09" — this is a minor documentation inconsistency. The implementation table in the HTML spec covers admin layout generally (not a dedicated View 09 entry for Übersicht). The mockup is unambiguous regardless of the numbering discrepancy.
  • Depends on #8: Issue #8 covers the admin layout and sidebar. The sidebar must include the "📊 Übersicht" navigation link pointing to /admin/uebersicht. This dependency should be verified closed (or the sidebar link confirmed to exist) before Task 13 is merged — otherwise the dashboard is unreachable from the admin UI.

Recommendations

  • Update the issue body to say "views spec View 08" instead of "View 09" to match the HTML spec label. Small fix, prevents confusion during implementation.
  • Add an explicit acceptance criterion: "The sidebar navigation item '📊 Übersicht' links to /admin/uebersicht and is visually active when on this route." This is implied by the dependency on #8 but is not stated as a testable criterion on this issue.
  • The existing acceptance criterion "'Reserviert' column in text-status-taken, 'Frei' in text-status-free" is precise and testable — make sure the implementor treats it as a hard requirement, not a visual suggestion. The plan currently fails this criterion.

Open Decisions (omit if none)

  • "Reserviert" KPI card color: The spec shows the "Reserviert" number in text-status-taken (muted red). Is this intentional — using a warning/negative color for a count that simply represents reserved items (a neutral or positive outcome for the app)? If admins should read "Reserviert" as progress (good), text-primary or text-status-free might be more appropriate. If they should read it as "items no longer available" (scarcity signal), text-status-taken is correct. The spec is explicit, but the semantic intent is worth a 30-second confirmation before coding. (Raised by: Elicit)
## 👤 Elicit — Requirements Engineer ### Observations - **US-ADM-008 is well-specified for its size (S).** Title, body, acceptance criteria, dependency (#8), size, milestone, and spec references are all present. It passes the Definition of Ready checklist on all counts. - **Acceptance criterion "All data from aggregate SQL queries, no N+1" conflicts with the plan's implementation.** The plan iterates `KATEGORIEN.map()` and fires 2 queries per category (16 queries for 8 categories). This is a direct N+1. The acceptance criterion is testable and the implementation does not satisfy it. This is not a requirements gap — it is an implementation gap, and it is correctly flagged by the architect. - **"Only categories that have at least 1 article are shown"** is unambiguous and testable. The plan satisfies it via `.filter(r => r.gesamt > 0)`. The `GROUP BY` alternative satisfies it implicitly. Both are compliant. - **"No charts or diagrams"** — the spec §5.5 states this explicitly. The implementation plan contains none. Compliant. - **The "Reserviert" column** is specified as `text-status-taken` and "Frei" as `text-status-free`. The plan deviates (both rendered in `text-primary`). This is a concrete acceptance criterion failure, not a cosmetic preference. - **Spec reference "reservierung-design §5.5, views spec View 09":** The actual view in `2026-05-05-erbstuecke-wannsee-views.html` is labelled **View 08**, not View 09. The issue body says "View 09" — this is a minor documentation inconsistency. The implementation table in the HTML spec covers admin layout generally (not a dedicated View 09 entry for Übersicht). The mockup is unambiguous regardless of the numbering discrepancy. - **Depends on #8:** Issue #8 covers the admin layout and sidebar. The sidebar must include the "📊 Übersicht" navigation link pointing to `/admin/uebersicht`. This dependency should be verified closed (or the sidebar link confirmed to exist) before Task 13 is merged — otherwise the dashboard is unreachable from the admin UI. ### Recommendations - Update the issue body to say "views spec View 08" instead of "View 09" to match the HTML spec label. Small fix, prevents confusion during implementation. - Add an explicit acceptance criterion: **"The sidebar navigation item '📊 Übersicht' links to `/admin/uebersicht` and is visually active when on this route."** This is implied by the dependency on #8 but is not stated as a testable criterion on this issue. - The existing acceptance criterion **"'Reserviert' column in `text-status-taken`, 'Frei' in `text-status-free`"** is precise and testable — make sure the implementor treats it as a hard requirement, not a visual suggestion. The plan currently fails this criterion. ### Open Decisions _(omit if none)_ - **"Reserviert" KPI card color:** The spec shows the "Reserviert" number in `text-status-taken` (muted red). Is this intentional — using a warning/negative color for a count that simply represents reserved items (a neutral or positive outcome for the app)? If admins should read "Reserviert" as progress (good), `text-primary` or `text-status-free` might be more appropriate. If they should read it as "items no longer available" (scarcity signal), `text-status-taken` is correct. The spec is explicit, but the semantic intent is worth a 30-second confirmation before coding. _(Raised by: Elicit)_
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Test Infrastructure

  • Test file location convention — Should route-level load function tests (+page.server.test.ts) live co-located inside src/routes/admin/uebersicht/ (keeps test next to the code it tests, consistent with SvelteKit's file-based routing) or in a top-level tests/integration/ directory (keeps src/routes/ clean, easier to glob for CI)? Whatever is decided here will set the convention for all subsequent tasks. (Raised by: Sara)

Design Semantics

  • "Reserviert" KPI card number color — The HTML spec (View 08) shows the "Reserviert" count in text-status-taken (muted red, #9B6060). Two readings are possible: (A) red = scarcity signal, "these items are no longer claimable" → spec's choice is correct; (B) red = negative/warning, but reserved items are actually a success metric for the event → use a neutral or green color instead. The spec is unambiguous on the value, but the semantic intent determines whether it should be followed exactly or refined. (Raised by: Elicit)
## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Test Infrastructure - **Test file location convention** — Should route-level load function tests (`+page.server.test.ts`) live co-located inside `src/routes/admin/uebersicht/` (keeps test next to the code it tests, consistent with SvelteKit's file-based routing) or in a top-level `tests/integration/` directory (keeps `src/routes/` clean, easier to glob for CI)? Whatever is decided here will set the convention for all subsequent tasks. _(Raised by: Sara)_ ### Design Semantics - **"Reserviert" KPI card number color** — The HTML spec (View 08) shows the "Reserviert" count in `text-status-taken` (muted red, #9B6060). Two readings are possible: (A) red = scarcity signal, "these items are no longer claimable" → spec's choice is correct; (B) red = negative/warning, but reserved items are actually a success metric for the event → use a neutral or green color instead. The spec is unambiguous on the value, but the semantic intent determines whether it should be followed exactly or refined. _(Raised by: Elicit)_
Sign in to join this conversation.