From 28ff08221bb01842d22a53a1afda4a0080e45098 Mon Sep 17 00:00:00 2001
From: TSVRN9 <66744142+TSVRN9@users.noreply.github.com>
Date: Wed, 19 Aug 2026 10:43:46 -0400
Subject: [PATCH] web IA: Today-first home (#64)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/ becomes a dashboard: today's device-local macro stats up top (empty
state when nothing's logged, zero server calls), the four halls as
compact cards (favorite-star text-content contract intact), then
quick links to Rank/Favorites. /today stays the full detail view (log
list, split bar, exports). Nav reorders Today's macros ahead of the
Dining Halls home link without touching any label, so existing
getByRole nav locators keep resolving.
Extracted the shared macro-calorie-share math (macroShares.ts) and the
4-up stat grid (MacroStats.svelte) out of /today so the home dashboard
reuses the same computation and markup instead of duplicating it.
New: web/e2e/home-dashboard.spec.ts. Updated with fresh red evidence:
vertical-slice.spec.ts, shell.spec.ts. favorites.spec.ts and
halls-menu.spec.ts needed no changes — the hall
markup they
depend on was preserved verbatim.
---
web/e2e/home-dashboard.spec.ts | 117 +++++++++++++++++++++++++
web/e2e/shell.spec.ts | 14 +++
web/e2e/vertical-slice.spec.ts | 5 ++
web/src/lib/MacroStats.svelte | 36 ++++++++
web/src/lib/macroShares.ts | 27 ++++++
web/src/routes/+layout.svelte | 18 ++--
web/src/routes/+page.svelte | 137 +++++++++++++++++++++---------
web/src/routes/today/+page.svelte | 51 +++--------
8 files changed, 318 insertions(+), 87 deletions(-)
create mode 100644 web/e2e/home-dashboard.spec.ts
create mode 100644 web/src/lib/MacroStats.svelte
create mode 100644 web/src/lib/macroShares.ts
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 @@
+
+
+
+
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 @@
-