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 @@ + + +
Nothing logged yet today.
+Pick a hall below and log what you eat.
++ + Full day → + +
+ {/if} +- {#if isFavorite}Favorite{/if} - Today’s menu & nutrition -
-+ {#if isFavorite}Favorite{/if} + Today’s menu & nutrition +
+Protein