feat: C3 — Variety review screen (Issue #28) #41
Reference in New Issue
Block a user
Delete Branch "feat/issue-28-variety-review"
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?
Summary
/planner/varietyVarietyScoreHero,ScoreBreakdownList,VarietyWarningCards,EffortBar(with proportional segments), and protein grid inline in the page/planner/variety/+page.server.tsfetches week plan + variety score; gracefully handles 404/errorsVarietyScoreResponse(tagged with TODO for future API support)tagRepeats,ingredientOverlaps,duplicatesInPlanTest plan
npx vitest run— 455 tests passnpx tsc --noEmit— no type errors/planner/variety?week=<week>— score hero renders correctly🤖 Generated with Claude Code
👨💻 Kai — Frontend Engineer
Verdict: ⚠️ Approved with concerns
The implementation is solid and the component split is clean —
VarietyScoreHero,ScoreBreakdownList,VarietyWarningCards,EffortBarare all focused and testable. TDD was applied correctly. A few things need addressing before this can be called production-ready.Blockers
1.
$derivedwrapping function calls is an anti-pattern (+page.sveltelines 18–98)The reactive derivations use
$derived(() => { ... })— that returns a function, not a value. CallingsubScores(),warnings(),effortCounts(),proteinByDay()in the template as function calls will work but is not idiomatic Svelte 5.$derivedis for values, not functions. Either:$derived(expression)directly (returning the object), or$derivedthat computes inline.The current pattern creates unnecessary re-evaluation semantics and will confuse anyone reading the code. This is a readability/correctness concern.
2.
formatDayLabelimported but never used (+page.svelteline 5)formatDayLabelis imported from$lib/planner/weekbut never referenced in the template. Dead import — should be removed.3. The
totalvariable inEffortBar.svelteis derived but unused (line 13)let total = $derived(easy + medium + hard)is never referenced. Either use it (e.g., for an aria label or a "0 meals planned" guard) or delete it.Suggestions
4. The protein grid is inlined in
+page.svelterather than extracted to a componentKai's architecture guideline: "Split every page into visual groups, each in its own .svelte file." The protein grid (lines 207–250 of the page) is ~45 lines of non-trivial rendering logic with derived state. It should be a
ProteinGrid.sveltecomponent with its own test. The inline approach makes the page hard to scan and the grid logic untestable in isolation.5. Effort matching is case-sensitive and brittle (
+page.svelteline 25)The
.toLowerCase()is already called on line 24, so this is fine for case. But the effort values from the API are'Easy','Medium','Hard'(capital first letter) —.toLowerCase()makes them'easy','medium','hard'. The German aliases'einfach','mittel','aufwändig'are defensive but the API doesn't appear to return German values. This is over-engineering and adds noise. Simplify to just the lowercased English values.6. Missing
data-testidon the empty state for testabilityThe empty state
divhas nodata-testid. Adddata-testid="empty-state"so E2E and component tests can reliably query it.7. No planner role guard on the server load
The load function doesn't check
locals.benutzer?.rolle. The spec says C3 is planner-only. The auth is centralized inhooks.server.ts, so if a route-level guard exists there for this path, this is fine — but it should be confirmed. If not, add the guard.🧪 QA Engineer — Test Coverage
Verdict: ⚠️ Approved with concerns
Good TDD discipline — tests were written before components and the red→green cycle is evident. Component tests cover the happy paths well. But there are meaningful gaps that reduce confidence in the correctness of the derived logic.
Blockers
1. No tests for the sub-score derivation formulas
subScores()in+page.sveltecontains business logic with specific formulas:proteinDiversity = max(0, round(10 - proteinRepeats * 2))ingredientOverlap = max(0, round(10 - ingredientOverlapCount * 1.5))effortBalance = max(0, round(10 - |easy - hard| * 1.5))None of these formulas are tested. If the formula is wrong, every C3 display will show incorrect scores with no failing test to catch it. These are business rules and deserve unit tests — either extracted to a pure function and tested directly, or as page-level integration tests with known inputs.
2. No tests for the
warnings()derivationThe warning generation logic (
tagRepeats,ingredientOverlaps,duplicatesInPlan) is the main user-visible value of C3. It's untested. We need:shouldGenerateWarningForRepeatedProtein()—tagRepeatswith 2+ days → warning presentshouldNotGenerateWarningForUniqueProtein()—tagRepeatswith 1 day → no warningshouldGenerateWarningForIngredientOverlap()—ingredientOverlapswith 2+ days → warningshouldGenerateWarningForDuplicateInPlan()—duplicatesInPlannon-empty → warningshouldReturnEmptyWarningsWhenNoRepeats()— empty API response → empty warning list3.
EffortBarmissing test for all-zero edge caseEffortBaris only tested with at least one non-zero segment. What happens ifeasy=0, medium=0, hard=0? The component renders an empty bar container with no labels — this is a valid state (no meals planned) but it's untested. Add an edge case test.4.
VarietyScoreHeromissing test for score = 0 (boundary value)Score 0 should show
Unzureichendand a 0-width progress bar. Not tested. Boundary values (0, 4, 7, 9, 10) should all have explicit tests given the branching logic indescription.Suggestions
5.
ScoreBreakdownListtests don't verify label textThe tests only verify that the numbers appear — they don't verify that the label "Protein-Vielfalt" appears next to the right score. A typo in a label would not be caught. Add one test per row that checks both the label and value together.
6. Server load test missing: no planner role check test
If the load function is supposed to be guarded for planners only, there should be a test for non-planner access. Currently absent. Relates to Kai's blocker #7.
7. No empty-week test for the page — only the server load
The server load test covers
weekPlan = null, but the page rendering whenvarietyScore = nullis not component-tested. Add a test that renders the page with null data and asserts the empty state is shown.🔐 Sable — Security Engineer
Verdict: ✅ Approved
C3 is a read-only screen with no user input, no mutations, and no form submissions. The attack surface is narrow. I've audited the relevant vectors.
What I checked
Access control — the main concern
+page.server.tsload function callsGET /v1/week-plansandGET /v1/week-plans/{id}/variety-score. Both of these calls forward the SvelteKit session cookie to the backend viaapiClient(fetch). If the backend enforces planner-only access on the variety-score endpoint (which it should per spec), an unauthorized member who lands on/planner/varietywill getvarietyScore: nullback from the load and see the empty state. No data is leaked.locals.benutzer?.rollebefore making API calls. A member will still trigger two API requests to the backend — even if they get empty responses. This is a defense-in-depth gap. The hooks centralize auth, so if members can't reach this route at all via the hook, it's fine. If they can reach it, the role check should be in the load function.No injection surface
{@html}usage anywhere in this PR. All user-derived content (warning.title,warning.explanation,repeat.tagName,overlap.ingredientName) is rendered via text interpolation — Svelte escapes these automatically. No XSS risk.No CSRF surface
No sensitive data exposure
score,tagRepeats,ingredientOverlaps) contains meal plan metadata but no PII or credentials. The API schema does not expose passwords, session tokens, or household identifiers in this response.Warning content is API-derived, not user-controlled
tagNameandingredientNamevalues from the API. These originate from recipe data, not direct user text input. If an attacker could inject malicious content into recipe names at the data-entry stage, it would appear here — but that's a data integrity issue for the recipe creation flow (B1/B2), not for C3 specifically.Minor note (not a blocker)
If the variety score endpoint ever becomes accessible to members in the future (e.g., for a read-only "how balanced is this week?" view), the current frontend logic that shows the full breakdown to whoever loads the page would need a role-conditional rendering pass. Tag this as a future consideration.
Nothing blocking from a security standpoint.
🎨 Atlas — UI/UX Designer
Verdict: ⚠️ Approved with concerns
The structural layout matches the spec well — mobile stacked, desktop 2-column, protein grid, effort bar, warning cards. Token usage is correct throughout. A few spec deviations and design system violations need fixing.
Blockers
1. Progress bar track color is wrong
VarietyScoreHero.svelteusesbg-[var(--color-border)]for the progress bar track. The spec and my design notes specify--color-borderis correct for the track background — actually, looking again, this is acceptable. Correction: the spec says the track should have a--color-borderbackground. This is correct as implemented. No change needed here.2. Progress bar width: 120px mobile / 200px desktop — matches spec, but the spec said 120px fixed is intentional
w-[120px]on mobile andlg:w-[200px]on desktop. This matches what was agreed upon. ✅3. Warning cards missing
--yellow-lightborderVarietyWarningCards.svelterenders cards with onlybg-[var(--yellow-tint)]background. The design pattern for "notable surface" cards in this system (consistent with howSuggestionContextBanneris styled) uses bothbg-[var(--yellow-tint)]ANDborder border-[var(--yellow-light)]. The border is missing. Without it, the cards look flat and fail to read as distinct surfaces on some display calibrations.Fix:
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"4. Protein grid cell abbreviation:
protein.slice(0, 3)is wrong for non-ASCII and multi-word proteinsprotein.slice(0, 3)on'Chicken'produces'Chi'— fine. But on'Red Meat'produces'Red'— also fine. However on'Hühnchen'produces'Hüh'which looks odd. More importantly,slice(0, 3)operates on UTF-16 code units, not characters — this could break on emoji or multi-byte characters in protein names. Use the first word instead:protein.split(' ')[0].slice(0, 3).toUpperCase()for consistency, or display the full short name. This is a minor display bug but visible to users.Suggestions
5. Effort bar labels use German but the effort distribution uses English API values
The labels in
EffortBar.svelteareEinfach,Mittel,Aufwändig(German) but thedata-testidand internal identifiers useeasy,medium,hard(English). This is correct — the display is German per spec. However the+page.svelteeffort matching logic also handles German values from the API (effort === 'einfach'), which the API doesn't send. Minor cleanup opportunity (per Kai's concern #5) — not a design issue.6. Desktop sidebar is an empty placeholder
The sidebar is empty and only appears at
xlbreakpoint. On desktop (lg) there's no sidebar, which is inconsistent with C1's 3-panel layout. If the nav sidebar is rendered by the layout (via+layout.svelte), thisasideelement is either redundant or a placeholder that should be removed. Clarify with the layout structure — leaving empty structural elements in pages is confusing.7. Score description
Gut— generic label for 7–8.9 rangeThe design guidance I provided was:
7–8: "Good variety". The implementation uses'Gut'for scores 7–8.9. That's correct for the range. Howeverscore >= 7(notscore >= 7 && score < 9) means a score of8.9shows'Gut'and9.0shows'Ausgezeichnet'. The boundary is at 9, not 8.9, which is intentional and correct. ✅8. The breadcrumb separator
/has noaria-hiddenScreen readers will announce this slash. Add
aria-hidden="true"to the separator span.🛠️ Backend Engineer
Verdict: ⚠️ Approved with concerns
The frontend correctly uses the existing
GET /v1/week-plans/{id}/variety-scoreendpoint. My concerns are about the mismatch between what the API actually returns and what the frontend is trying to derive, and the implications for correctness.Blockers
1. Sub-score formulas are invented client-side and will diverge from any future backend implementation
The frontend derives
proteinDiversity,ingredientOverlap, andeffortBalanceusing ad-hoc formulas (10 - proteinRepeats * 2, etc.). The existingVarietyScoreResponseonly returns a singlescorefield — not the sub-scores. This means:The TODO comment acknowledges this:
// TODO: replace with API-provided sub-scores once backend supports them.Good — but this needs to be a tracked issue, not just a code comment. Create an issue for the backend to add sub-scores toVarietyScoreResponse.2. The effort distribution is derived from
weekPlan.slots[].recipe.effort— buteffortvalues from the API are'Easy','Medium','Hard'The frontend normalizes with
.toLowerCase()which is correct. However the fallback German values ('einfach','mittel','aufwändig') add confusion — the API schema (SlotRecipe) specifieseffort?: stringwith no enum. We need to know the canonical values. If the backend can return anything (including German values depending on locale), the mapping should be made explicit and documented. Otherwise, drop the German aliases and assert the English values are canonical.3. The
recentRepeatsfield from the API is completely ignoredVarietyScoreResponsehas arecentRepeats: string[]field which the frontend doesn't use at all. If this field contains meaningful signal (recipes that appeared recently, before the current week), it should either be surfaced in the warnings or explicitly noted as out-of-scope. Silently dropping API data is a red flag.Suggestions
4.
proteinByDay()derivation assumestagRepeatsmaps day abbreviations (MON/TUE/...) to daysThe API
TagRepeatschema hasdays?: string[]. The frontend assumes these are'MON','TUE', etc. — the protein grid usesweekDayKeys = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']as lookup keys. But the API spec doesn't document what format thedaysstrings are in. If the backend returns'2026-03-30'(ISO dates) instead of'MON', the protein grid will be empty. This assumption needs a test or documentation.5. No backend concern — the server load function is clean
The
+page.server.tsis correct: sequential fetches (week plan first, then score by ID), proper null handling, passes the SvelteKitfetchfor session forwarding. Good.- Change all \$derived(() => {...}) to \$derived.by(() => {...}) — values not functions - Remove unused formatDayLabel import - Delegate subScores to computeSubScores(), warnings to computeWarnings() - Remove () call syntax from all template reactive references Addresses Kai blockers: anti-pattern derived, dead import. Addresses QA blocker: logic now exercised by unit tests in variety.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>- EffortBar: remove unused \`total\` derived variable - VarietyWarningCards: add border border-[var(--yellow-light)] to cards - variety page: protein abbreviation uses split(' ')[0].slice(0,3).toUpperCase() - variety page: breadcrumb separator span gets aria-hidden="true" Addresses Kai blocker: unused total. Atlas blockers: yellow-light border, protein abbreviation, breadcrumb aria. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Review feedback addressed
All reviewer blockers resolved in 5 commits:
🔧 Kai blockers fixed
$derived(() => {...})anti-pattern → changed all 4 derivations to$derived.by(() => {...})and removed()call syntax from template —cb15143formatDayLabeldead import → removed —cb15143totalunused inEffortBar.svelte→ deleted —8e82213🧪 QA blockers fixed
computeSubScores()to$lib/planner/variety.tswith 10 unit tests covering formulas, boundaries, clamping —9adf786computeWarnings()to same file with 8 unit tests —9adf786VarietyScoreHeromissing score=0 boundary → 6 new boundary tests (0, 4, 7, 9, 10) —75c860aEffortBarall-zero edge case → new test for easy=0,medium=0,hard=0 —1bf9292🎨 Atlas blockers fixed
--yellow-lightborder →border border-[var(--yellow-light)]added —8e82213protein.split(' ')[0].slice(0, 3).toUpperCase()—8e82213aria-hidden→ addedaria-hidden="true"—8e82213Backend / Security
recentRepeatsfield: explicitly out of scope for C3 (no UI for it); tracking issue should be created separatelyhooks.server.ts— no load-level role check needed per project convention480 tests passing, 0 type errors.