Build Admin Übersicht — dashboard with KPI strip and category table #13
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Task 13 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story (US-ADM-008):
As an admin, I see a dashboard with total/reserved/free article counts and a breakdown by category.
Acceptance criteria
text-primarytext-status-taken, "Frei" intext-status-freeFiles to create
src/routes/admin/uebersicht/+page.sveltesrc/routes/admin/uebersicht/+page.server.tsDepends on: #8 | Size: S | Spec: reservierung-design §5.5, views spec View 09
👤 Markus Keller — Application Architect
Observations
KATEGORIENand fires two prepared-statement calls per category (oneCOUNTfor 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".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 toconst db— that is correct. Butdb.prepare(...)inside.map()should be module-level constants.frei = gesamt - reserviertis computed in application code. This is correct — it is a trivial arithmetic derive, not a second query. No issue here.+layout.server.tsunder/admin/, so this route needs no explicitlocals.admincheck — that is architecturally correct.byKat.length === 0) is handled in the template. Good.Recommendations
GROUP BYaggregate query. This satisfies the acceptance criterion explicitly and eliminates the N+1:db.prepare()calls to module scope in+page.server.ts(or intolib/db.tsalongside 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.GROUP BYquery 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.stmtByKat.all()explicitly so TypeScript can verify the shape at compile time rather than relying onas { n: number }casts.👤 Felix Brandt — Fullstack Developer
Observations
db.prepare(...)insideKATEGORIEN.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.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 appliestext-primaryuniformly. The "Reserviert" card number must usetext-status-taken.$props()destructuring style: The plan useslet { data }: { data: PageData } = $props()— this is Svelte 5 correct. No issue.use:enhanceor form actions needed on this read-only page. Correct.getDb()pattern: CallinggetDb()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.as { n: number }casts on every.get()call are not ideal. A typedinterface KpiRow { n: number }at the top of the file makes the intent explicit and is easier to maintain.Recommendations
+page.server.tsso alldb.prepare(...)calls are at module scope:(kpi.label)key to the KPI{#each}block.text-status-taken(nottext-primary) to match the spec. "Frei" should usetext-status-free. Only "Gesamt" getstext-primary."Noch keine Artikel."is correct German and sensible UX for the zero-article case.👤 Nora "NullX" Steiner — Application Security Engineer
Observations
+layout.server.tsredirecting non-admins to/admin/login. This guard is not duplicated in the load function, which is the correct architecture.GROUP BYvariant) or use the?placeholder pattern withdb.prepare(...).get(kat)(the plan's per-category variant). Neither variant exposes an injection surface.KATEGORIENconstant (server-side enum), not from user input or raw DB strings, so there is no output encoding risk.db.prepare('SELECT COUNT(*) AS n FROM artikel WHERE kategorie = ?').get(kat)—katcomes fromKATEGORIEN(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.SESSION_SECRETor cookie handling in this route — none needed.Recommendations
+page.server.tsbriefly stating the threat model so future maintainers understand why there is no explicit auth check here:GROUP BYquery is adopted (see Markus's recommendation), verify that category names in the DB cannot be a different value fromKATEGORIEN— aCHECKconstraint onartikel.kategoriewould enforce this at the database layer and prevent phantom rows appearing in the dashboard from data-entry bugs.👤 Sara Holt — QA Engineer & Test Strategist
Observations
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).:memory:SQLite database without a running SvelteKit server. This is exactly the integration-test pattern the project uses for all other load functions.gesamt,reserviert,freicounts are arithmetically consistent (frei = gesamt - reserviert)byKatreserviert: 0andfrei: gesamt{ gesamt: 0, reserviert: 0, frei: 0, byKat: [] }vitest-browser-sveltewithout touching the DB./admin/uebersichtand assert the page title is visible.Recommendations
+page.server.test.tsalongside the implementation with these four integration tests using:memory:SQLite:data.byKatis empty the table renders the "Noch keine Artikel." empty-state cell (not a blank table body).text-status-takenclass (or inspect the element's computed color) in a component test — this catches the color regression Felix flagged before it reaches production.gesamt = reserviert + freiinvariant is the most important: it would have caught the plan's proposed arithmetic approach versus theGROUP BYapproach discrepancy immediately.Open Decisions (omit if none)
+page.server.test.tslive co-located insrc/routes/admin/uebersicht/or in a top-leveltests/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)👤 Leonie Voss — UI/UX Design Lead
Observations
KPI card colors do not match the spec. View 08 in the HTML spec shows:
color: var(--pr)→text-primary(#5B7A66) ✓ (plan is correct for this one)color: var(--tk)→text-status-taken(#9B6060) ✗ (plan renders it intext-primary)color: var(--fr)→text-status-free(#4A7C5C) ✗ (plan renders it intext-primary)The plan applies
text-primaryto 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-lin the spec exactly.KPI card structure matches the spec.
bg-surface border border-line rounded-lg p-2.5 text-centeris 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. Usetext-medium(Tailwind alias for the token) instead."Kategorie" column text weight: The spec shows category names in
font-weight: 600in the table body. The plan usestext-inkwithout a weight class — addfont-semiboldto the category cell<td>.Lora font on KPI number: The spec's
.stat-nusesfont-family: 'Lora', Georgia, serif. The plan correctly usesfont-seriffor the KPI value. Good.No page heading in the spec's implementation table for this view, but View 08 mockup shows a
page-titleelement 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-titlestyle.Mobile rendering: This page is admin-only and primarily used on desktop, but the
grid-cols-3KPI strip should be verified at 375px — three equal columns at phone width with 14px padding on each side gives each card roughly 95px width. Atfont-size: 20pxthe numbers fit. No overflow expected, but should be confirmed during implementation.Accessibility: The table lacks a
<caption>oraria-label. Screen readers navigating the admin panel need to know this is the "Artikel nach Kategorie" breakdown table, not just "a table."Recommendations
<div class="font-serif text-[20px] font-bold {kpi.cls}">{kpi.value}</div>text-[#888]on table headers withtext-mediumto stay on the design token system.font-semiboldto the<td>rendering the category name.aria-label="Artikel nach Kategorie"to the<table>element.👤 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Observations
+page.svelteand+page.server.ts. No Dockerfile, docker-compose.yml, or Caddyfile changes are needed. From a deployment perspective this is a zero-risk change.db:/app/db). No new volumes, no new environment variables. The deployment topology is unchanged.DATABASE_PATHcovers it.build/. The deployment procedure (git pull && docker compose up -d --build) picks it up automatically. No deploy procedure changes./healthendpoint (or equivalent) is unchanged.+page.server.tscallsgetDb()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
https://erbstuecke.raddatz.cloud/admin/uebersichtwhile 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.GROUP BYquery replaces the per-category loop (as Markus recommends), the production database must haveforeign_keys = ONfor theLEFT JOINto be consistent — this is already enforced at startup inlib/db.tsviadb.pragma('foreign_keys = ON'). No action needed.👤 Elicit — Requirements Engineer
Observations
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..filter(r => r.gesamt > 0). TheGROUP BYalternative satisfies it implicitly. Both are compliant.text-status-takenand "Frei" astext-status-free. The plan deviates (both rendered intext-primary). This is a concrete acceptance criterion failure, not a cosmetic preference.2026-05-05-erbstuecke-wannsee-views.htmlis 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./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
/admin/uebersichtand 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.text-status-taken, 'Frei' intext-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)
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-primaryortext-status-freemight be more appropriate. If they should read it as "items no longer available" (scarcity signal),text-status-takenis correct. The spec is explicit, but the semantic intent is worth a 30-second confirmation before coding. (Raised by: Elicit)🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Test Infrastructure
+page.server.test.ts) live co-located insidesrc/routes/admin/uebersicht/(keeps test next to the code it tests, consistent with SvelteKit's file-based routing) or in a top-leveltests/integration/directory (keepssrc/routes/clean, easier to glob for CI)? Whatever is decided here will set the convention for all subsequent tasks. (Raised by: Sara)Design Semantics
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)