Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions web/e2e/home-dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -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 =
`<a data-healthfulness="30" data-carbon-list="B" data-ingredient-list="FREIHOFFER&#039;S Country White Bread" ` +
`data-allergens="Milk, Eggs, Gluten, Soy, Corn , Wheat" data-recipe-webcode="LPR SUS VGT H3 CR2" ` +
`data-clean-diet-str="Local, Sustainable, Vegetarian" data-serving-size="1 each" data-calories="127" ` +
`data-calories-from-fat="26" data-total-fat="2.9g" data-total-fat-dv="4" data-sat-fat="0.5g" data-sat-fat-dv="" ` +
`data-trans-fat="0g" data-cholesterol="50.9mg" data-cholesterol_dv="" data-sodium="237.2mg" data-sodium-dv="10" ` +
`data-total-carb="20.4g" data-total-carb-dv="16" data-dietary-fiber="1g" data-dietary-fiber-dv="3" ` +
`data-sugars="3.4g" data-sugars-dv="" data-protein="5.3g" data-protein-dv="9" data-dish-name="French Toast" ` +
`href="#inline">French Toast</a>`;

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);
});
14 changes: 14 additions & 0 deletions web/e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions web/e2e/vertical-slice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions web/src/lib/MacroStats.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script lang="ts">
import type { DailyMacroTotals } from "@udine/shared";
import { macroCalorieBreakdown } from "./macroShares";

// The 4-up calorie/protein/carb/fat grid shared between /today's full detail view and the home
// dashboard's macros-so-far summary (#64) — same numbers, same source (computeDailyTotals), so
// this is the one place that renders them rather than two copies drifting apart. No card wrapper,
// no split bar here: /today keeps those (see its own <section class="card"> and the bar markup
// below this component's usage there) since the home dashboard doesn't show the bar.
let { totals, entryCount }: { totals: DailyMacroTotals; entryCount: number } = $props();

const breakdown = $derived(macroCalorieBreakdown(totals));
</script>

<div class="grid grid-cols-2 gap-5 sm:grid-cols-4">
<div class="stat">
<span class="stat-label">Calories:</span>
<span class="stat-value">{totals.calories}</span>
<span class="mt-1 block text-xs text-ink-900/50">from {entryCount} {entryCount === 1 ? "entry" : "entries"}</span>
</div>
<div class="stat">
<span class="stat-label">Protein:</span>
<span class="stat-value">{totals.proteinG.toFixed(1)}g</span>
<span class="mt-1 block text-xs text-ink-900/50">{breakdown.shares.protein}% of calories</span>
</div>
<div class="stat">
<span class="stat-label">Carbs:</span>
<span class="stat-value">{totals.totalCarbG.toFixed(1)}g</span>
<span class="mt-1 block text-xs text-ink-900/50">{breakdown.shares.carbs}% of calories</span>
</div>
<div class="stat">
<span class="stat-label">Fat:</span>
<span class="stat-value">{totals.totalFatG.toFixed(1)}g</span>
<span class="mt-1 block text-xs text-ink-900/50">{breakdown.shares.fat}% of calories</span>
</div>
</div>
27 changes: 27 additions & 0 deletions web/src/lib/macroShares.ts
Original file line number Diff line number Diff line change
@@ -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) },
};
}
18 changes: 11 additions & 7 deletions web/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<a
href="/"
aria-current={isActive("/") ? "page" : undefined}
class="border-b-2 border-transparent text-paper-50/90 no-underline hover:text-gold-500 aria-[current=page]:border-gold-500 aria-[current=page]:text-gold-500"
>
Dining Halls
</a>
<!-- #64: Today's macros leads, "Dining Halls" (now the Today-first dashboard's own route)
follows the rest of primaryNav — reflects the Today-first IA without touching any label,
so every spec's getByRole("link", { name: "Dining Halls" | "Today's macros" | ... })
locator still resolves to the same element it always did. -->
{#each primaryNav as item (item.href)}
<a
href={item.href}
Expand All @@ -117,6 +114,13 @@
{item.label}
</a>
{/each}
<a
href="/"
aria-current={isActive("/") ? "page" : undefined}
class="border-b-2 border-transparent text-paper-50/90 no-underline hover:text-gold-500 aria-[current=page]:border-gold-500 aria-[current=page]:text-gold-500"
>
Dining Halls
</a>

<!-- Keyed on the path so a client-side navigation destroys and recreates the <details>,
which is what actually closes it — a <details> holds `open` across SvelteKit's
Expand Down
Loading
Loading