diff --git a/web/e2e/home-dashboard.spec.ts b/web/e2e/home-dashboard.spec.ts new file mode 100644 index 00000000..23061ddd --- /dev/null +++ b/web/e2e/home-dashboard.spec.ts @@ -0,0 +1,117 @@ +import { test, expect, type Page } from "@playwright/test"; +import { parseCategoryItems } from "@udine/shared"; + +// Coverage for #64 (part of epic #63): `/` becomes a Today-first dashboard -- today's device-local +// macro stats up top (empty state when nothing's logged), the four halls as compact cards +// (favorite-star contract intact, see CLAUDE.md/other specs), and quick links to Rank/Favorites. +// `/today` remains the full detail view (log list, split bar, exports) -- see today.spec.ts, which +// this file does not duplicate. + +// Same hydration pitfall documented in vertical-slice.spec.ts: a click before SvelteKit's client +// router attaches falls through to a full navigation, invisible to page.route() mocks below. +async function proveHydrated(page: Page) { + const star = page.getByRole("listitem").filter({ hasText: "Hampshire" }).getByRole("button", { name: "favorite" }); + await expect(async () => { + await star.click(); + await expect(star).toHaveText("★"); + }).toPass({ timeout: 10_000 }); + // Un-favorite again so this proof doesn't leak state into the assertions below. + await star.click(); + await expect(star).toHaveText("☆"); +} + +const REAL_FRAGMENT = + `French Toast`; + +const HAMPSHIRE_TID = 3; +const MENU_ITEMS = parseCategoryItems(REAL_FRAGMENT, "Breakfast Entrees", "breakfast", HAMPSHIRE_TID, "2026-08-18"); + +test("home shows an empty state when nothing is logged today, with halls still reachable in one tap", async ({ + page, +}) => { + await page.goto("/"); + await proveHydrated(page); + + await expect(page.getByText("Nothing logged yet today.")).toBeVisible(); + // #40/#37's empty-state treatment (dashed outline), not a bare paragraph. + const emptyState = page.getByText("Nothing logged yet today.").locator("xpath=.."); + await expect(emptyState).toHaveCSS("border-style", "dashed"); + + // Halls are still one tap away -- not demoted below a fold the empty state hides. + const hampshireRow = page.getByRole("listitem").filter({ hasText: "Hampshire" }); + await expect(hampshireRow.getByRole("link", { name: "Hampshire" })).toBeVisible(); +}); + +test("logging a dish updates today's macro stats on the home dashboard itself", async ({ page }) => { + let menuRequests = 0; + await page.route("**/api/menu**", (route) => { + menuRequests++; + return route.fulfill({ json: MENU_ITEMS }); + }); + + await page.goto("/"); + await proveHydrated(page); + + await page + .getByRole("listitem") + .filter({ hasText: "Hampshire" }) + .getByRole("link", { name: "Hampshire" }) + .click(); + + await expect + .poll(() => menuRequests, { message: "menu mock never fired — this would have hit real umassdining.com" }) + .toBeGreaterThan(0); + + await page.getByRole("listitem").filter({ hasText: "French Toast" }).getByRole("button", { name: "Log" }).click(); + await expect(page.getByRole("status")).toHaveText("Logged 1 × French Toast"); + + // Back to the dashboard via nav (not a deep link into /today) -- the point of #64 is that the + // stats are visible right here, not one more click away. + await page.getByRole("link", { name: "Dining Halls" }).click(); + + await expect(page.getByText("Nothing logged yet today.")).toHaveCount(0); + await expect(page.getByText("Calories: 127")).toBeVisible(); + await expect(page.getByText("Protein: 5.3g")).toBeVisible(); + await expect(page.getByText("from 1 entry")).toBeVisible(); + + // A deep link into the full detail view is offered, but the numbers themselves are already here. + await expect(page.getByRole("link", { name: "Full day" })).toBeVisible(); +}); + +test("quick links reach Rank and Favorites without ambiguity against the primary nav", async ({ page }) => { + await page.goto("/"); + await proveHydrated(page); + + // getByRole's default name match is a case-insensitive substring -- if these labels ever + // collided with the primary nav's "Rank dishes" / "Favorites" links, this click would throw a + // strict-mode violation (multiple matches) instead of silently picking one. That failure mode is + // exactly what this test exists to catch. + await page.getByRole("link", { name: "Compare dishes" }).click(); + await expect(page.getByRole("heading", { name: "Rank Dishes", level: 1 })).toBeVisible(); + + await page.getByRole("link", { name: "Dining Halls" }).click(); + await page.getByRole("link", { name: "Favorite dishes" }).click(); + await expect(page.getByRole("heading", { name: "Favorites", level: 1 })).toBeVisible(); +}); + +test("home issues zero API requests -- macro stats are device-local, not a server call", async ({ page }) => { + let apiRequests = 0; + await page.route("**/api/**", (route) => { + apiRequests++; + return route.continue(); + }); + + await page.goto("/"); + await proveHydrated(page); + + // Give any accidental fetch a beat to land before asserting its absence. + await page.waitForTimeout(500); + expect(apiRequests).toBe(0); +}); diff --git a/web/e2e/shell.spec.ts b/web/e2e/shell.spec.ts index d6d8f208..bdfdf8fe 100644 --- a/web/e2e/shell.spec.ts +++ b/web/e2e/shell.spec.ts @@ -126,6 +126,20 @@ test("Friends and Notifications are in primary nav, not behind the disclosure", await expect(nav.getByRole("link", { name: "Notifications" })).toBeVisible(); }); +// #64: the home route becomes a Today-first dashboard, and nav order should say so -- "Today's +// macros" is the identity's primary destination, not one entry among ten after the "Dining Halls" +// home link. Labels are untouched (renaming "Dining Halls" would break the locator every other +// spec in this suite uses to get back home), only the order changes. +test("Today's macros precedes the Dining Halls home link in primary nav", async ({ page }) => { + await page.goto("/"); + const linkNames = await page.locator("nav").getByRole("link").allTextContents(); + const todayIndex = linkNames.findIndex((name) => name.includes("Today's macros")); + const diningHallsIndex = linkNames.findIndex((name) => name.includes("Dining Halls")); + expect(todayIndex).toBeGreaterThanOrEqual(0); + expect(diningHallsIndex).toBeGreaterThanOrEqual(0); + expect(todayIndex).toBeLessThan(diningHallsIndex); +}); + test("a skip-to-content link and a device-only-data footer are present", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("link", { name: "Skip to content" })).toBeAttached(); diff --git a/web/e2e/vertical-slice.spec.ts b/web/e2e/vertical-slice.spec.ts index 5e21fe2c..9c58f415 100644 --- a/web/e2e/vertical-slice.spec.ts +++ b/web/e2e/vertical-slice.spec.ts @@ -73,6 +73,11 @@ test("browse a hall's menu, log a dish, and see it reflected in today's macro to await expect(page.getByRole("status")).toHaveText("Logged 1 × French Toast"); await page.getByRole("link", { name: "Dining Halls" }).click(); + + // #64: the home dashboard itself shows today's macro stats -- the whole point of the Today-first + // IA change is that this doesn't require a further click into /today to see. + await expect(page.getByText("Calories: 127")).toBeVisible(); + await page.getByRole("link", { name: "Today's macros" }).click(); await expect(page.getByRole("heading", { name: /^Today/ })).toBeVisible(); diff --git a/web/src/lib/MacroStats.svelte b/web/src/lib/MacroStats.svelte new file mode 100644 index 00000000..2b69017d --- /dev/null +++ b/web/src/lib/MacroStats.svelte @@ -0,0 +1,36 @@ + + +
+
+ Calories: + {totals.calories} + from {entryCount} {entryCount === 1 ? "entry" : "entries"} +
+
+ Protein: + {totals.proteinG.toFixed(1)}g + {breakdown.shares.protein}% of calories +
+
+ Carbs: + {totals.totalCarbG.toFixed(1)}g + {breakdown.shares.carbs}% of calories +
+
+ Fat: + {totals.totalFatG.toFixed(1)}g + {breakdown.shares.fat}% of calories +
+
diff --git a/web/src/lib/macroShares.ts b/web/src/lib/macroShares.ts new file mode 100644 index 00000000..a05255a2 --- /dev/null +++ b/web/src/lib/macroShares.ts @@ -0,0 +1,27 @@ +import type { DailyMacroTotals } from "@udine/shared"; + +/** + * Calories contributed by each macro (4/4/9 kcal per gram) as a share of the day, plus their sum. + * This is the cheap honest answer to "is 1800 a lot?" — it needs no goal, no profile and no server: + * it describes the composition of what's logged rather than judging the amount. Shared between + * MacroStats.svelte's per-macro "% of calories" labels and /today's split bar, both of which derive + * the same percentages from the same totals — see #64 (web IA: Today-first home). + */ +export interface MacroCalorieBreakdown { + total: number; + shares: { protein: number; carbs: number; fat: number }; +} + +export function macroCalorieBreakdown(totals: DailyMacroTotals): MacroCalorieBreakdown { + const calories = { + protein: totals.proteinG * 4, + carbs: totals.totalCarbG * 4, + fat: totals.totalFatG * 9, + }; + const total = calories.protein + calories.carbs + calories.fat; + const pct = (kcal: number) => (total > 0 ? Math.round((kcal / total) * 100) : 0); + return { + total, + shares: { protein: pct(calories.protein), carbs: pct(calories.carbs), fat: pct(calories.fat) }, + }; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 908c857c..3088ed14 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -101,13 +101,10 @@ aria-label="Main" class="mx-auto flex max-w-5xl flex-wrap items-center gap-x-5 gap-y-2 px-4 py-3 font-display text-sm tracking-wide uppercase sm:px-6" > - - Dining Halls - + {#each primaryNav as item (item.href)} {/each} + + Dining Halls + - +
+ {#if loaded && entries.length === 0} +
+

Nothing logged yet today.

+

Pick a hall below and log what you eat.

+
+ {:else} +
+ +
+

+ + Full day → + +

+ {/if} +
-
- +

Dining Halls

+
+ +
+ {isFavorite ? '★' : '☆'} + + +
+ + {hall.name} + +

+ {#if isFavorite}Favorite{/if} + Today’s menu & nutrition +

+
+ + + + {/each} + + - - - {/each} - +
+

More

+
+
+ Compare dishes + Favorite dishes +
+
diff --git a/web/src/routes/today/+page.svelte b/web/src/routes/today/+page.svelte index 5e706a1e..af68123f 100644 --- a/web/src/routes/today/+page.svelte +++ b/web/src/routes/today/+page.svelte @@ -3,6 +3,8 @@ import { computeDailyTotals, exportEntriesAsCsv, exportEntriesAsJson, type DailyMacroTotals, type LogEntry } from "@udine/shared"; import { IndexedDbLogStorage } from "$lib/indexedDbStorage"; import { todayIso } from "$lib/date"; + import { macroCalorieBreakdown } from "$lib/macroShares"; + import MacroStats from "$lib/MacroStats.svelte"; // IndexedDB only exists in the browser — this page has no SSR-safe data to render on // its own, so the storage instance and the initial load both wait for the client. @@ -23,20 +25,10 @@ day: "numeric", }); - // Calories contributed by each macro (4/4/9 kcal per gram) as a share of the day. This is the - // cheap honest answer to "is 1800 a lot?" — it needs no goal, no profile and no server: it - // describes the composition of what you logged rather than judging the amount. A target/goal - // feature would be device-only per CLAUDE.md's data-residency table; deliberately not built here. - const macroCalories = $derived({ - protein: totals.proteinG * 4, - carbs: totals.totalCarbG * 4, - fat: totals.totalFatG * 9, - }); - const macroCalorieTotal = $derived(macroCalories.protein + macroCalories.carbs + macroCalories.fat); - - function share(kind: "protein" | "carbs" | "fat"): number { - return macroCalorieTotal > 0 ? Math.round((macroCalories[kind] / macroCalorieTotal) * 100) : 0; - } + // A target/goal feature (e.g. "is 1800 a lot?") would be device-only per CLAUDE.md's + // data-residency table; deliberately not built here. The per-macro % of calories this page and + // the home dashboard both show is the cheap honest substitute — see macroShares.ts. + const breakdown = $derived(macroCalorieBreakdown(totals)); function entryName(entry: LogEntry): string { return entry.source.type === "umass-menu" ? entry.source.dishName : entry.source.productName; @@ -109,34 +101,13 @@
-
-
- Calories: - {totals.calories} - from {entries.length} {entries.length === 1 ? "entry" : "entries"} -
-
- Protein: - {totals.proteinG.toFixed(1)}g - {share("protein")}% of calories -
-
- Carbs: - {totals.totalCarbG.toFixed(1)}g - {share("carbs")}% of calories -
-
- Fat: - {totals.totalFatG.toFixed(1)}g - {share("fat")}% of calories -
-
+ - {#if macroCalorieTotal > 0} + {#if breakdown.total > 0}

Protein