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
416 changes: 282 additions & 134 deletions README.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions apps/web/playwright.readme.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig, devices } from "@playwright/test";
import base from "./playwright.config";

// Captures the README screenshots (scripts/readme-shots/). Reuses the e2e
// servers (auth mock, API mock, next dev); kept out of the e2e run.
export default defineConfig({
...base,
testDir: "./scripts/readme-shots",
testMatch: "*.shots.ts",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: "list",
use: { ...base.use, colorScheme: "dark" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
150 changes: 150 additions & 0 deletions apps/web/scripts/readme-shots/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Realistic, made-up data for the README screenshots: one student on a
// maintain-weight plan eating at a South Indian hostel mess. Shapes follow
// src/lib/*-api.ts; nothing here reaches a real backend.

const today = new Date();
const iso = (d: Date) => d.toLocaleDateString("en-CA");
export const TODAY = iso(today);
const daysAgo = (n: number) => iso(new Date(today.getTime() - n * 86_400_000));

const item = (
dish_id: string,
name: string,
portions: number,
serving_unit: string,
portion_icon: string,
grams: number,
kcal: number,
protein_g: number,
carbs_g: number,
fats_g: number,
reason: string,
) => ({ dish_id, name, portions, serving_unit, portion_icon, grams, kcal, protein_g, carbs_g, fats_g, reason });

export const PLATE = {
plan: {
breakfast: [
item("d1", "Idli", 3, "piece", "piece", 120, 174, 6, 36, 1, "Light, steamed carbs to start the day"),
item("d2", "Sambar", 1, "katori", "katori", 150, 120, 6, 16, 4, "Lentils add protein to the idli"),
item("d3", "Boiled Egg", 2, "piece", "piece", 100, 155, 13, 1, 11, "Cheapest complete protein on the menu"),
],
lunch: [
item("d4", "Chapati", 3, "piece", "piece", 120, 297, 9, 54, 5, "Steadier energy than a second rice helping"),
item("d5", "Dal Tadka", 1.5, "katori", "katori", 225, 236, 14, 32, 6, "Main protein source at lunch"),
item("d6", "Steamed Rice", 1, "katori", "katori", 150, 195, 4, 43, 0, "Fills the remaining carb gap"),
item("d7", "Curd", 1, "small_katori", "small_katori", 100, 61, 4, 5, 3, "Protein plus probiotics, easy on the stomach"),
],
snack: [
item("d8", "Sundal (Chana)", 1, "small_katori", "small_katori", 100, 164, 9, 27, 3, "High-fibre evening snack"),
item("d9", "Banana", 1, "piece", "piece", 118, 105, 1, 27, 0, "Quick carbs before the workout"),
],
dinner: [
item("d10", "Chapati", 2, "piece", "piece", 80, 198, 6, 36, 3, "Kept moderate for dinner"),
item("d11", "Paneer Butter Masala", 1, "katori", "katori", 150, 330, 14, 12, 25, "Hits the protein target for the day"),
item("d12", "Mixed Veg Poriyal", 1, "katori", "katori", 120, 110, 3, 14, 5, "Vegetables and micronutrients"),
],
},
daily_totals: { kcal: 2155, protein_g: 89, carbs_g: 297, fats_g: 66 },
daily_targets: { kcal: 2250, protein_g: 110, carbs_g: 290, fats_g: 70 },
gap_fills: [
{
item_id: "c1",
name: "Milk (canteen)",
portions: 1,
cost_inr: 20,
kcal: 150,
protein_g: 8,
carbs_g: 12,
fats_g: 8,
text: "Add a glass of milk from the canteen (₹20) to close the protein gap.",
},
],
solver_status: "Optimal",
solve_time_ms: 84,
};

export const TODAY_LOGS = {
date: TODAY,
meals: [
{ id: "m1", date: TODAY, meal_type: "breakfast", status: "as_planned", notes: null, kcal: 449, protein_g: 25, carbs_g: 53, fats_g: 16 },
{ id: "m2", date: TODAY, meal_type: "lunch", status: "as_planned", notes: null, kcal: 789, protein_g: 31, carbs_g: 134, fats_g: 14 },
],
weight: { date: TODAY, weight_kg: 68.4 },
subjective: { date: TODAY, energy: 4, hunger: 3, mood: 4 },
workout_status: null,
};

// A gentle, noisy downward trend over 30 days.
const weights = [70.6, 70.4, 70.5, 70.2, 70.3, 70.0, 69.9, 70.0, 69.8, 69.6, 69.7, 69.5, 69.4, 69.5, 69.2, 69.1, 69.2, 69.0, 68.9, 69.0, 68.8, 68.7, 68.8, 68.6, 68.5, 68.6, 68.4, 68.5, 68.3, 68.4];

export const progress = (range: string) => {
const n = range === "7d" ? 7 : range === "90d" ? 30 : 30;
const series = weights.slice(-n).map((w, i, a) => ({ date: daysAgo(a.length - 1 - i), weight_kg: w }));
return {
range,
weight_series: series,
adherence_rate: 0.86,
macro_hit_rate: 0.78,
projection: {
available: true,
stalled: false,
moving_wrong_direction: false,
current_rate_kg_per_week: -0.5,
target_rate_kg_per_week: -0.5,
projected_target_date: daysAgo(-49),
on_track: true,
},
streak_days: 12,
adaptive_tdee: { available: true, tdee: 2410, confidence: "medium", data_days: 30 },
};
};

export const LEADERBOARD = {
entries: [
{ rank: 1, is_you: false, display_name: "Arjun K.", meals_followed: 26, workouts_done: 6, score: 92 },
{ rank: 2, is_you: true, display_name: "You", meals_followed: 24, workouts_done: 5, score: 85 },
{ rank: 3, is_you: false, display_name: "Divya S.", meals_followed: 22, workouts_done: 5, score: 81 },
],
user_rank: null,
};

const ex = (
exercise_id: string,
name: string,
primary_muscle: string,
sets: number,
reps: string,
rest_seconds: number,
instruction_text: string,
common_mistakes: string[],
) => ({ exercise_id, name, primary_muscle, sets, reps, rest_seconds, youtube_video_id: null, instruction_text, common_mistakes });

export const WORKOUT = {
template_id: "t1",
template_name: "Full Body A",
goal: "maintain",
week: 3,
day: 2,
day_name: "Upper body + core",
exercises: [
ex("e1", "Push-ups", "chest", 4, "12", 60, "Hands under shoulders, body in one straight line.", ["Sagging hips", "Flaring elbows"]),
ex("e2", "Pike Push-ups", "shoulders", 3, "8", 75, "Hips high, lower your head toward the floor.", ["Bending the knees"]),
ex("e3", "Backpack Rows", "back", 4, "12", 60, "Load a backpack with books; pull to your belly button.", ["Rounding the back"]),
ex("e4", "Chair Dips", "triceps", 3, "10", 60, "Use a sturdy chair; keep your back close to it.", ["Shrugging shoulders"]),
ex("e5", "Plank", "core", 3, "40s", 45, "Squeeze glutes, ribs down.", ["Hips too high"]),
],
};

export const CHAT_QUESTION = "Is mess sambar enough protein if I'm vegetarian?";

export const CHAT_ANSWER =
"A katori of sambar is about **6 g of protein**, so stack it with:\n\n" +
"- **Dal:** 1.5 katori ≈ 14 g\n" +
"- **Curd:** ≈ 4 g a meal\n" +
"- **Paneer or chana:** 9–14 g when served\n\n" +
"That reaches 90–110 g a day without supplements.";

export const CHAT_CITATIONS = [
{ chunk_id: "k1", source: "learn", title: "Cheap protein sources in India", slug: "cheap-protein-india" },
{ chunk_id: "k2", source: "learn", title: "Protein 101 for hostel students", slug: "protein-101" },
];
150 changes: 150 additions & 0 deletions apps/web/scripts/readme-shots/readme.shots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import path from "node:path";
import { test, expect, type Page } from "@playwright/test";
import { gotoReady } from "../../tests/support/page";
import { signIn } from "../../tests/support/session";
import * as F from "./fixtures";

// Regenerates the screenshots in docs/assets/readme/screens/ against mocked
// data (see fixtures.ts). Not part of the e2e suite; run with:
// pnpm exec playwright test -c playwright.readme.config.ts

const OUT = path.resolve(__dirname, "../../../../docs/assets/readme/screens");
const CORS = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
};
const CONV = { id: "c1", title: "Vegetarian protein", created_at: new Date().toISOString(), updated_at: new Date().toISOString() };

const USER = { displayName: "Priya Raman", email: "priya@messfit.app" };

async function mockApi(page: Page) {
// The browser asks the auth mock who is signed in; answer as the demo student.
await page.route("http://localhost:54321/auth/v1/user", (route) =>
route.fulfill({
status: 200,
headers: CORS,
contentType: "application/json",
body: JSON.stringify({
id: "readme",
aud: "authenticated",
role: "authenticated",
email: USER.email,
user_metadata: { display_name: USER.displayName, onboarded: true },
app_metadata: {},
created_at: "2026-08-01T00:00:00Z",
}),
}),
);
await page.route("http://localhost:8000/**", async (route) => {
const req = route.request();
if (req.method() === "OPTIONS") return route.fulfill({ status: 204, headers: CORS });
const url = new URL(req.url());
const json = (body: unknown, status = 200) =>
route.fulfill({ status, headers: CORS, contentType: "application/json", body: JSON.stringify(body) });

switch (url.pathname) {
case "/api/v1/me":
return json({ id: "readme", role: "user" });
case "/api/v1/optimize/today":
return json(F.PLATE);
case "/api/v1/logs/today":
return json(F.TODAY_LOGS);
case "/api/v1/logs/progress":
return json(F.progress(url.searchParams.get("range") ?? "30d"));
case "/api/v1/logs/leaderboard":
return json(F.LEADERBOARD);
case "/api/v1/workouts/today":
return json(F.WORKOUT);
case "/api/v1/chat/conversations":
return req.method() === "POST" ? json(CONV, 201) : json([]);
case "/api/v1/chat/conversations/c1/messages": {
if (req.method() === "GET") return json([]);
const words = F.CHAT_ANSWER.split(/(?<= )/);
const events = words.map((t) => `data: ${JSON.stringify({ token: t })}\n\n`).join("");
return route.fulfill({
status: 200,
headers: { ...CORS, "content-type": "text/event-stream" },
body: events + `data: ${JSON.stringify({ done: true, citations: F.CHAT_CITATIONS })}\n\n`,
});
}
case "/mess/dishes/feedback":
return json({ dish_id: url.searchParams.get("dish_id"), confirms: 0, denies: 0 });
default:
console.warn(`[readme-shots] unmocked ${req.method()} ${url.pathname}`);
return json({ detail: "not mocked" }, 404);
}
});
}

/** Let entrance animations and count-ups finish before the capture. */
async function settle(page: Page) {
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2_000);
}

async function shot(page: Page, name: string) {
await settle(page);
await page.screenshot({ path: path.join(OUT, `${name}.png`) });
}

test.beforeEach(async ({ context, baseURL, page }) => {
await signIn(context, baseURL!, USER);
await mockApi(page);
});

test.describe("phone", () => {
test.use({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, hasTouch: true, isMobile: true });

test("today", async ({ page }) => {
await gotoReady(page, "/dashboard");
await shot(page, "phone-today");
});

test("plate", async ({ page }) => {
await gotoReady(page, "/dashboard/plate");
await expect(page.getByText("Idli").first()).toBeVisible();
await shot(page, "phone-plate");
});

test("progress", async ({ page }) => {
await gotoReady(page, "/dashboard/progress");
await page.getByRole("group", { name: "Time range" }).getByRole("button", { name: "30d" }).click();
await shot(page, "phone-progress");
});

test("workout", async ({ page }) => {
await gotoReady(page, "/dashboard/workout");
await expect(page.getByText("Push-ups").first()).toBeVisible();
await shot(page, "phone-workout");
});

test("coach", async ({ page }) => {
await gotoReady(page, "/dashboard/chat");
const box = page.getByRole("textbox").last();
await box.fill(F.CHAT_QUESTION);
await box.press("Enter");
await expect(page.getByText("Cheap protein sources in India").first()).toBeVisible();
await shot(page, "phone-coach");
});

test("log", async ({ page }) => {
await gotoReady(page, "/dashboard/log");
await shot(page, "phone-log");
});
});

test.describe("desktop", () => {
test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1.5 });

test("today", async ({ page }) => {
await gotoReady(page, "/dashboard");
await shot(page, "desktop-today");
});

test("plate", async ({ page }) => {
await gotoReady(page, "/dashboard/plate");
await expect(page.getByText("Idli").first()).toBeVisible();
await shot(page, "desktop-plate");
});
});
4 changes: 2 additions & 2 deletions apps/web/src/components/ui/trend-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default function TrendChart({ series, lo, hi }: TrendChartProps) {
return (
<div role="img" aria-label={summary} style={{ width: "100%", height: 240 }}>
<ResponsiveContainer>
<AreaChart data={series} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<AreaChart data={series} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--accent)" stopOpacity={0.32} />
Expand All @@ -62,7 +62,7 @@ export default function TrendChart({ series, lo, hi }: TrendChartProps) {
tickMargin={10}
padding={{ left: 10, right: 10 }}
/>
<YAxis domain={[lo, hi]} tick={AXIS_TICK} axisLine={false} tickLine={false} width={36} />
<YAxis domain={[lo, hi]} tick={AXIS_TICK} axisLine={false} tickLine={false} width={44} />
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "rgba(255,255,255,0.15)", strokeDasharray: "4 4" }} />
<Area
type="monotone"
Expand Down
6 changes: 3 additions & 3 deletions apps/web/tests/support/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { BrowserContext } from "@playwright/test";
export async function signIn(
context: BrowserContext,
baseURL: string,
opts: { onboarded?: boolean; sub?: string; admin?: boolean } = {},
opts: { onboarded?: boolean; sub?: string; admin?: boolean; displayName?: string; email?: string } = {},
) {
const sub = opts.sub ?? `${opts.admin ? "admin" : "e2e"}-${randomUUID()}`;
const onboarded = opts.onboarded ?? true;
Expand All @@ -33,8 +33,8 @@ export async function signIn(
token_type: "bearer",
user: {
id: sub,
email: "e2e@messfit.local",
user_metadata: { display_name: "E2E Tester", onboarded },
email: opts.email ?? "e2e@messfit.local",
user_metadata: { display_name: opts.displayName ?? "E2E Tester", onboarded },
},
};
const value = "base64-" + Buffer.from(JSON.stringify(session)).toString("base64url");
Expand Down
1 change: 1 addition & 0 deletions docs/assets/readme/architecture-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/assets/readme/architecture-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/assets/readme/banner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading