Health Companion — Front-End (PWA) Implementation Plan ()
Health Companion — Front-End (PWA) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the installable /health/ PWA that reads the backend JSON, shows each person’s live “now / next” schedule, meals + meal-prep, gym, and rules, and lets each person check off today’s items (synced via the backend Log tab).
Architecture: A build-free, vanilla HTML/CSS/JS app served as a static subfolder of the existing Jekyll/GitHub Pages site at shahayush.com/health/. Pure logic (day-type resolution, now/next, macro totals, meal-prep aggregation) lives in dual-runtime modules under health/js/ — loaded by <script> in the browser AND required by vitest in a separate Jekyll-excluded health-frontend/ test project. A thin browser-only layer (api.js, app.js) does fetch + DOM rendering. A service worker caches the shell and last JSON for offline use.
Tech Stack: Vanilla JS (ES2019, no bundler), HTML, CSS, Service Worker + Web App Manifest; vitest (dev-only) for the pure modules.
Global Constraints
Apply to every task. Copy names/values verbatim.
- No build step. Files under
health/are served as-is by GitHub Pages. Use relative asset paths inindex.html(href="app.css",src="js/app.js") so nothing depends on the/health/prefix. Never use absolute/…paths for app assets. - Dual-runtime pure modules. Every file in
health/js/that holds testable logic ends withif (typeof module !== "undefined" && module.exports) { module.exports = { … }; }and defines plain global functions (no ESMimport/export). Browser-only files (api.js,app.js) may skip the guard. - Test project is CommonJS + vitest globals.
health-frontend/mirrors the backend: no"type":"module",vitest.config.mjswith{ test:{ globals:true } }, testsrequire("../../health/js/<mod>.js")and usedescribe/it/expectwithout importing vitest. - JSON contract consumed (produced by the backend, do not change):
Schedule = { office:{ayush:Event[],simran:Event[]}, wfh:{…}, saturday:{…}, sunday:{…} } ParseResult = { schedule:Schedule, gym:{day,focusAyush,focusSimran,type}[], principles:{topic,guideline}[] } Event = { id, person:"ayush"|"simran", dayType:"office"|"wfh"|"saturday"|"sunday", time:"HH:MM", timeLabel, minutes:Number, activity, details, notes, kind:"workout"|"walk"|"meal"|"wake"|"sleep"|"drive"|"rest"|"other", together:Boolean, calories:Number|null, protein:Number|null } LogRow = { timestamp, date:"YYYY-MM-DD", person, itemId, itemLabel, done:Boolean } - Backend endpoints (config-driven):
GET {EXEC_URL}?token={TOKEN}→ ParseResult;GET {EXEC_URL}?token={TOKEN}&action=log&date=YYYY-MM-DD→{rows:LogRow[]};POST {EXEC_URL}withContent-Type: text/plainbody{token,date,person,itemId,itemLabel,done}→{ok:true};GET {EXEC_URL}?token={TOKEN}&format=ics&person=ayush|simran→ iCal. - CORS rule: GET uses normal
fetch. POST MUST setContent-Type: text/plain(a “simple request” — avoids the CORS preflight Apps Script can’t answer). Never sendapplication/jsonon the POST. - Weekday → day-type:
0(Sun)→sunday, 1→office, 2→office, 3→office, 4→wfh, 5→wfh, 6(Sat)→saturday. - Protein targets (for progress bars):
{ ayush: 150, simran: 120 }(grams). - Office weekdays per week (for meal-prep counts):
3(Mon/Tue/Wed). - Person/day-type keys: exactly
ayush/simranandoffice/wfh/saturday/sunday. - Discoverability:
index.htmlincludes<meta name="robots" content="noindex">. The app is not linked from the portfolio.
File structure
Served app (under health/, all relative paths):
health/index.html— app shell: header, tab bar, four<section>panels,<script>tags.health/app.css— all styles (theme-aware).health/config.js—window.CONFIG = { EXEC_URL, API_TOKEN, PERSON_LABELS }. Committed (must be, to be served; token is already public in client JS).health/js/schedule.js— pure:dayTypeForWeekday,nowNext,proteinTotal,calorieTotal,PROTEIN_TARGET,WEEKDAY_DAYTYPE.health/js/mealprep.js— pure:mealPrepRollup,extractIngredients,groceryList,OFFICE_DAY_COUNT.health/js/api.js— browser-only:cacheGet/cacheSet(pure, testable) +fetchSchedule,fetchLog,postCheckoff.health/js/app.js— browser-only: state, tab switching, rendering, check-off handlers.health/manifest.webmanifest— PWA manifest (scoped to./).health/sw.js— service worker (cache shell + last JSON).health/icons/icon-192.png,health/icons/icon-512.png— app icons.
Dev/test tooling (Jekyll-excluded, git-ignored node_modules):
health-frontend/package.json,health-frontend/vitest.config.mjshealth-frontend/test/schedule.test.js,health-frontend/test/mealprep.test.js,health-frontend/test/api.test.js
Repo config:
_config.yml— addhealth-frontendtoexclude:(keephealthserved)..gitignore— addhealth-frontend/node_modules/.
Task 1: Front-end scaffold (served shell + test project + Jekyll config)
Files:
- Create:
health/index.html,health/app.css,health/config.js,health/js/.gitkeep - Create:
health-frontend/package.json,health-frontend/vitest.config.mjs,health-frontend/test/smoke.test.js,health-frontend/.gitignore - Modify:
_config.yml(addhealth-frontendtoexclude:), repo-root.gitignore
Interfaces:
- Consumes: nothing.
Produces: a served shell that opens in a browser, and a working
npm testinhealth-frontend/.window.CONFIGis defined for later tasks.- Step 1: Create the served shell
health/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#0e1116">
<title>Health</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="stylesheet" href="app.css">
</head>
<body>
<header class="topbar">
<div id="dayLabel" class="day-label">—</div>
<div class="person-switch" id="personSwitch">
<button data-person="ayush" class="on">You</button>
<button data-person="simran">Simran</button>
<button data-person="both">Both</button>
</div>
</header>
<main>
<section id="tab-home" class="panel on"></section>
<section id="tab-food" class="panel"></section>
<section id="tab-gym" class="panel"></section>
<section id="tab-rules" class="panel"></section>
</main>
<nav class="tabbar" id="tabbar">
<button data-tab="home" class="on"><span>🏠</span>Home</button>
<button data-tab="food"><span>🍽️</span>Food</button>
<button data-tab="gym"><span>🏋️</span>Gym</button>
<button data-tab="rules"><span>📋</span>Rules</button>
</nav>
<div id="banner" class="banner" hidden></div>
<script src="config.js"></script>
<script src="js/schedule.js"></script>
<script src="js/mealprep.js"></script>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
</body>
</html>
- Step 2: Create
health/app.css
:root { --bg:#f5f6f8; --card:#fff; --fg:#1a1f2b; --muted:#6b7280; --accent:#3b82f6; --accent2:#a855f7; --ok:#22c55e; --line:#e5e7eb; }
@media (prefers-color-scheme: dark) { :root { --bg:#0e1116; --card:#161b24; --fg:#e7eaf0; --muted:#8b93a3; --line:#222836; } }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
body { padding-bottom:70px; }
.topbar { position:sticky; top:0; z-index:5; background:var(--bg); padding:10px 14px calc(10px + env(safe-area-inset-top));
display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid var(--line); }
.day-label { font-size:13px; color:var(--muted); font-weight:600; }
.person-switch { display:flex; gap:4px; }
.person-switch button { border:none; background:var(--card); color:var(--muted); font-size:12px; padding:6px 10px; border-radius:8px; }
.person-switch button.on { background:var(--accent); color:#fff; font-weight:600; }
main { padding:14px; }
.panel { display:none; }
.panel.on { display:block; }
.card { background:var(--card); border-radius:14px; padding:12px 14px; margin-bottom:10px; border:1px solid var(--line); }
.now { color:#fff; }
.now.you { background:linear-gradient(135deg,#1e3a8a,#3b82f6); border:none; }
.now.her { background:linear-gradient(135deg,#6d28d9,#a855f7); border:none; }
.lbl { font-size:10px; letter-spacing:.06em; text-transform:uppercase; opacity:.85; }
.act { font-size:18px; font-weight:700; margin:2px 0; }
.det { font-size:12px; color:var(--muted); line-height:1.4; }
.now .det { color:rgba(255,255,255,.9); }
.count { font-size:11px; margin-top:6px; opacity:.9; }
.row { display:flex; gap:8px; }
.col { flex:1; min-width:0; }
.item { display:flex; align-items:flex-start; gap:8px; padding:8px 0; border-bottom:1px solid var(--line); }
.item:last-child { border-bottom:none; }
.item .t { font-size:11px; color:var(--muted); width:64px; flex-shrink:0; }
.item .a { font-size:13px; font-weight:600; }
.item.done .a { text-decoration:line-through; opacity:.55; }
.check { width:22px; height:22px; border-radius:6px; border:2px solid var(--line); flex-shrink:0; background:transparent; }
.check.on { background:var(--ok); border-color:var(--ok); }
.prog { height:6px; background:var(--line); border-radius:3px; overflow:hidden; margin-top:4px; }
.prog > div { height:100%; background:var(--ok); }
.seg { display:flex; gap:4px; margin-bottom:10px; }
.seg button { flex:1; border:none; background:var(--card); color:var(--muted); font-size:12px; padding:7px 0; border-radius:8px; }
.seg button.on { background:var(--accent); color:#fff; font-weight:600; }
.tabbar { position:fixed; left:0; right:0; bottom:0; display:flex; background:var(--card); border-top:1px solid var(--line);
padding-bottom:env(safe-area-inset-bottom); }
.tabbar button { flex:1; border:none; background:none; color:var(--muted); font-size:10px; padding:8px 0 10px; }
.tabbar button span { display:block; font-size:18px; }
.tabbar button.on { color:var(--accent); }
.banner { position:fixed; bottom:74px; left:14px; right:14px; background:#7c2d12; color:#fff; font-size:12px;
padding:8px 12px; border-radius:10px; text-align:center; }
h2 { font-size:15px; margin:4px 0 10px; }
button { cursor:pointer; font-family:inherit; }
- Step 3: Create
health/config.js(placeholder values; the user sets real ones at deploy)
// Set EXEC_URL and API_TOKEN to your deployed Apps Script web-app values.
// NOTE: this token is served publicly with the app — use a dedicated, rotatable token.
window.CONFIG = {
EXEC_URL: "REPLACE_WITH_APPS_SCRIPT_EXEC_URL",
API_TOKEN: "REPLACE_WITH_API_TOKEN",
PERSON_LABELS: { ayush: "You", simran: "Simran" }
};
- Step 4: Create
health/js/.gitkeep(empty file, so the dir exists before Task 2)
- Step 5: Create the test project files
health-frontend/package.json:
{
"name": "health-frontend",
"version": "0.1.0",
"private": true,
"engines": { "node": ">=18" },
"scripts": { "test": "vitest run", "test:watch": "vitest" },
"devDependencies": { "vitest": "^2.1.0" }
}
health-frontend/vitest.config.mjs:
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { globals: true } });
health-frontend/.gitignore:
node_modules/
health-frontend/test/smoke.test.js:
describe("toolchain", () => {
it("runs vitest", () => { expect(1 + 1).toBe(2); });
});
- Step 6: Jekyll + git config
In _config.yml, add health-frontend to the top-level exclude: list (keep every existing entry; do NOT exclude health — it must be served):
exclude:
- health-frontend
In the repo-root .gitignore, append:
health-frontend/node_modules/
- Step 7: Install + smoke test + open the shell
Run:
cd health-frontend && npm install && npm test
Expected: 1 passed. Then open health/index.html in a browser (double-click or open health/index.html). Expected: header with day label + You/Simran/Both, empty panels, bottom tab bar. Console may warn about the manifest/service worker (added in Task 9) — that’s fine for now.
- Step 8: Commit
git add health/ health-frontend/package.json health-frontend/vitest.config.mjs health-frontend/.gitignore health-frontend/test/smoke.test.js _config.yml .gitignore
git commit -m "chore(health-fe): scaffold /health PWA shell and test project"
Task 2: Pure — day-type + now/next (schedule.js)
Files:
- Create:
health/js/schedule.js - Create:
health-frontend/test/schedule.test.js
Interfaces:
- Consumes: nothing.
Produces (exported):
WEEKDAY_DAYTYPE(array),dayTypeForWeekday(weekday) -> dayType,nowNext(events, nowMinutes) -> { current: Event|null, next: Event|null }. (proteinTotal/calorieTotal/PROTEIN_TARGETadded in Task 3.)- Step 1: Write the failing test
health-frontend/test/schedule.test.js:
const { dayTypeForWeekday, WEEKDAY_DAYTYPE, nowNext } = require("../../health/js/schedule.js");
describe("dayTypeForWeekday", () => {
it("maps weekdays per the contract", () => {
expect(WEEKDAY_DAYTYPE).toEqual(["sunday","office","office","office","wfh","wfh","saturday"]);
expect(dayTypeForWeekday(0)).toBe("sunday");
expect(dayTypeForWeekday(1)).toBe("office");
expect(dayTypeForWeekday(4)).toBe("wfh");
expect(dayTypeForWeekday(6)).toBe("saturday");
});
});
describe("nowNext", () => {
const ev = (time, minutes, activity) => ({ time, minutes, activity });
const events = [ev("06:00",360,"Walk"), ev("07:00",420,"Gym"), ev("08:15",495,"Breakfast")];
it("finds current and next around a mid time", () => {
const r = nowNext(events, 430); // after Gym(420), before Breakfast(495)
expect(r.current.activity).toBe("Gym");
expect(r.next.activity).toBe("Breakfast");
});
it("before the first event has no current", () => {
const r = nowNext(events, 300);
expect(r.current).toBeNull();
expect(r.next.activity).toBe("Walk");
});
it("after the last event has no next", () => {
const r = nowNext(events, 600);
expect(r.current.activity).toBe("Breakfast");
expect(r.next).toBeNull();
});
it("handles empty list", () => {
expect(nowNext([], 500)).toEqual({ current: null, next: null });
});
});
- Step 2: Run to verify failure
Run: cd health-frontend && npx vitest run test/schedule.test.js Expected: FAIL — Cannot find module '../../health/js/schedule.js'.
- Step 3: Implement
health/js/schedule.js
var WEEKDAY_DAYTYPE = ["sunday", "office", "office", "office", "wfh", "wfh", "saturday"];
function dayTypeForWeekday(weekday) {
return WEEKDAY_DAYTYPE[weekday];
}
function nowNext(events, nowMinutes) {
var current = null, next = null;
var sorted = (events || []).slice().sort(function (a, b) { return a.minutes - b.minutes; });
for (var i = 0; i < sorted.length; i++) {
if (sorted[i].minutes <= nowMinutes) current = sorted[i];
else { next = sorted[i]; break; }
}
return { current: current, next: next };
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { WEEKDAY_DAYTYPE, dayTypeForWeekday, nowNext };
}
- Step 4: Run to verify pass
Run: cd health-frontend && npx vitest run test/schedule.test.js Expected: PASS.
- Step 5: Commit
git add health/js/schedule.js health-frontend/test/schedule.test.js
git commit -m "feat(health-fe): add day-type and now/next logic"
Task 3: Pure — macro totals + targets (schedule.js)
Files:
- Modify:
health/js/schedule.js(addproteinTotal,calorieTotal,PROTEIN_TARGET; extend exports) - Modify:
health-frontend/test/schedule.test.js(add cases)
Interfaces:
- Consumes: Task 2 module.
Produces (added to exports):
PROTEIN_TARGET({ayush:150,simran:120}),proteinTotal(events)->Number,calorieTotal(events)->Number(both sum, treating null as 0).- Step 1: Add failing tests
Append to health-frontend/test/schedule.test.js:
const { proteinTotal, calorieTotal, PROTEIN_TARGET } = require("../../health/js/schedule.js");
describe("macro totals", () => {
const events = [
{ protein: 34, calories: 740 },
{ protein: null, calories: 225 },
{ protein: 47, calories: null }
];
it("sums protein treating null as 0", () => {
expect(proteinTotal(events)).toBe(81);
});
it("sums calories treating null as 0", () => {
expect(calorieTotal(events)).toBe(965);
});
it("exposes protein targets", () => {
expect(PROTEIN_TARGET).toEqual({ ayush: 150, simran: 120 });
});
it("handles empty", () => {
expect(proteinTotal([])).toBe(0);
expect(calorieTotal([])).toBe(0);
});
});
- Step 2: Run to verify failure
Run: cd health-frontend && npx vitest run test/schedule.test.js Expected: FAIL — proteinTotal is not a function.
- Step 3: Implement
In health/js/schedule.js, add above the module.exports block:
var PROTEIN_TARGET = { ayush: 150, simran: 120 };
function proteinTotal(events) {
return (events || []).reduce(function (s, e) { return s + (e.protein || 0); }, 0);
}
function calorieTotal(events) {
return (events || []).reduce(function (s, e) { return s + (e.calories || 0); }, 0);
}
Update the export line to:
module.exports = { WEEKDAY_DAYTYPE, dayTypeForWeekday, nowNext, PROTEIN_TARGET, proteinTotal, calorieTotal };
- Step 4: Run to verify pass
Run: cd health-frontend && npx vitest run test/schedule.test.js Expected: PASS (all schedule cases).
- Step 5: Commit
git add health/js/schedule.js health-frontend/test/schedule.test.js
git commit -m "feat(health-fe): add macro totals and protein targets"
Task 4: Pure — meal-prep rollup + ingredients (mealprep.js)
Files:
- Create:
health/js/mealprep.js - Create:
health-frontend/test/mealprep.test.js
Interfaces:
- Consumes: nothing.
Produces (exported):
OFFICE_DAY_COUNT(3),mealPrepRollup(events, dayCount) -> {activity,details,count}[](meals only, one row per office-template meal),extractIngredients(details) -> string[],groceryList(schedule) -> string[](distinct ingredients across both people’s office meals).- Step 1: Write the failing test
health-frontend/test/mealprep.test.js:
const { OFFICE_DAY_COUNT, mealPrepRollup, extractIngredients, groceryList } = require("../../health/js/mealprep.js");
describe("mealPrepRollup", () => {
const events = [
{ kind: "wake", activity: "Wake Up", details: "water" },
{ kind: "meal", activity: "Lunch at Office", details: "Basmati rice 200g + Chickpeas curry (~740 cal)" },
{ kind: "workout", activity: "Gym", details: "Lower A" },
{ kind: "meal", activity: "Dinner", details: "Tofu + Sweet Potato" }
];
it("keeps only meals and stamps the weekly count", () => {
expect(mealPrepRollup(events, 3)).toEqual([
{ activity: "Lunch at Office", details: "Basmati rice 200g + Chickpeas curry (~740 cal)", count: 3 },
{ activity: "Dinner", details: "Tofu + Sweet Potato", count: 3 }
]);
});
it("exposes the office day count", () => { expect(OFFICE_DAY_COUNT).toBe(3); });
});
describe("extractIngredients", () => {
it("splits on + and , drops parentheticals and quantities", () => {
expect(extractIngredients("Basmati rice 200g + Chickpeas/Tofu curry + salad (~740 cal, 34g protein)"))
.toEqual(["Basmati rice", "Chickpeas/Tofu curry", "salad"]);
});
it("returns [] for blank", () => { expect(extractIngredients("")).toEqual([]); });
});
describe("groceryList", () => {
it("dedupes ingredients across both people's office meals", () => {
const schedule = {
office: {
ayush: [{ kind: "meal", details: "Basmati rice 200g + Chickpeas curry" }],
simran: [{ kind: "meal", details: "Dal + brown rice" }, { kind: "walk", details: "x" }]
},
wfh: { ayush: [], simran: [] }, saturday: { ayush: [], simran: [] }, sunday: { ayush: [], simran: [] }
};
expect(groceryList(schedule)).toEqual(["Basmati rice", "Chickpeas curry", "Dal", "brown rice"]);
});
});
- Step 2: Run to verify failure
Run: cd health-frontend && npx vitest run test/mealprep.test.js Expected: FAIL — module not found.
- Step 3: Implement
health/js/mealprep.js
var OFFICE_DAY_COUNT = 3;
function mealPrepRollup(events, dayCount) {
return (events || [])
.filter(function (e) { return e.kind === "meal"; })
.map(function (e) { return { activity: e.activity, details: e.details, count: dayCount }; });
}
function extractIngredients(details) {
var s = String(details || "").replace(/\([^)]*\)/g, " "); // drop parentheticals
return s.split(/[+,]/)
.map(function (part) {
return part
.replace(/\b\d+(\.\d+)?\s*(g|kg|ml|l|tsp|tbsp)\b/gi, " ") // drop "200g", "1 tsp"
.replace(/\s+/g, " ")
.trim();
})
.filter(function (x) { return x.length > 0; });
}
function groceryList(schedule) {
var out = [];
var seen = {};
["ayush", "simran"].forEach(function (person) {
var meals = ((schedule.office && schedule.office[person]) || [])
.filter(function (e) { return e.kind === "meal"; });
meals.forEach(function (m) {
extractIngredients(m.details).forEach(function (ing) {
var key = ing.toLowerCase();
if (!seen[key]) { seen[key] = true; out.push(ing); }
});
});
});
return out;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { OFFICE_DAY_COUNT, mealPrepRollup, extractIngredients, groceryList };
}
- Step 4: Run to verify pass
Run: cd health-frontend && npx vitest run test/mealprep.test.js Expected: PASS.
- Step 5: Commit
git add health/js/mealprep.js health-frontend/test/mealprep.test.js
git commit -m "feat(health-fe): add meal-prep rollup and ingredient extraction"
Task 5: API layer (api.js) — cache + fetch wrappers
Files:
- Create:
health/js/api.js - Create:
health-frontend/test/api.test.js
Interfaces:
- Consumes:
window.CONFIG(browser) — in tests, aglobal.CONFIGstub. Produces (exported for tests):
cacheGet(key),cacheSet(key,value)(pure over a storage object), and asyncfetchSchedule(),fetchLog(date),postCheckoff(payload).fetchSchedulereturns{data, stale:Boolean}— live data withstale:false, or the cached copy withstale:trueon network failure (or{data:null,stale:true}if no cache).- Step 1: Write the failing test
health-frontend/test/api.test.js:
// jsdom-free: provide minimal globals the module uses.
const store = {};
global.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; }
};
global.CONFIG = { EXEC_URL: "https://exec.test/x", API_TOKEN: "tok" };
const { cacheGet, cacheSet, fetchSchedule, postCheckoff } = require("../../health/js/api.js");
beforeEach(() => { for (const k in store) delete store[k]; });
describe("cache", () => {
it("round-trips JSON", () => {
cacheSet("k", { a: 1 });
expect(cacheGet("k")).toEqual({ a: 1 });
});
it("returns null for missing/corrupt", () => {
expect(cacheGet("nope")).toBeNull();
store["bad"] = "{not json";
expect(cacheGet("bad")).toBeNull();
});
});
describe("fetchSchedule", () => {
it("returns live data and caches it", async () => {
global.fetch = async () => ({ ok: true, json: async () => ({ schedule: {}, gym: [], principles: [] }) });
const r = await fetchSchedule();
expect(r.stale).toBe(false);
expect(r.data.gym).toEqual([]);
expect(cacheGet("health.schedule")).toEqual({ schedule: {}, gym: [], principles: [] });
});
it("falls back to cache when the network fails", async () => {
cacheSet("health.schedule", { schedule: {}, gym: [{ day: "Mon" }], principles: [] });
global.fetch = async () => { throw new Error("offline"); };
const r = await fetchSchedule();
expect(r.stale).toBe(true);
expect(r.data.gym[0].day).toBe("Mon");
});
});
describe("postCheckoff", () => {
it("posts text/plain with the token merged in", async () => {
let captured = null;
global.fetch = async (url, opts) => { captured = { url, opts }; return { ok: true, json: async () => ({ ok: true }) }; };
await postCheckoff({ date: "2026-07-12", person: "ayush", itemId: "x", itemLabel: "L", done: true });
expect(captured.url).toBe("https://exec.test/x");
expect(captured.opts.headers["Content-Type"]).toBe("text/plain");
const body = JSON.parse(captured.opts.body);
expect(body).toEqual({ token: "tok", date: "2026-07-12", person: "ayush", itemId: "x", itemLabel: "L", done: true });
});
});
- Step 2: Run to verify failure
Run: cd health-frontend && npx vitest run test/api.test.js Expected: FAIL — module not found.
- Step 3: Implement
health/js/api.js
var SCHEDULE_CACHE_KEY = "health.schedule";
function cacheGet(key) {
try { var raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : null; }
catch (e) { return null; }
}
function cacheSet(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota/full — ignore */ }
}
function fetchSchedule() {
var url = CONFIG.EXEC_URL + "?token=" + encodeURIComponent(CONFIG.API_TOKEN);
return fetch(url)
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) { cacheSet(SCHEDULE_CACHE_KEY, data); return { data: data, stale: false }; })
.catch(function () { return { data: cacheGet(SCHEDULE_CACHE_KEY), stale: true }; });
}
function fetchLog(date) {
var url = CONFIG.EXEC_URL + "?token=" + encodeURIComponent(CONFIG.API_TOKEN) +
"&action=log&date=" + encodeURIComponent(date);
return fetch(url)
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (j) { return (j && j.rows) || []; })
.catch(function () { return []; });
}
function postCheckoff(payload) {
var body = Object.assign({ token: CONFIG.API_TOKEN }, payload);
return fetch(CONFIG.EXEC_URL, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify(body)
})
.then(function (r) { return r.json(); })
.catch(function () { return { ok: false }; });
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { cacheGet, cacheSet, fetchSchedule, fetchLog, postCheckoff };
}
- Step 4: Run to verify pass
Run: cd health-frontend && npx vitest run test/api.test.js Expected: PASS.
- Step 5: Run the full front-end suite
Run: cd health-frontend && npm test Expected: all four test files pass (smoke, schedule, mealprep, api).
- Step 6: Commit
git add health/js/api.js health-frontend/test/api.test.js
git commit -m "feat(health-fe): add cache and backend fetch wrappers"
Task 6: App bootstrap + Home tab rendering (app.js)
Files:
- Create:
health/js/app.js
Interfaces:
- Consumes:
schedule.js,mealprep.js,api.jsglobals; the DOM inindex.html. Produces: a running app. On load: fetch schedule (+ today’s log), compute today’s day-type, render the Home tab for the selected person, wire tab + person switches. Home renders Now/Next for a single person, or a two-column side-by-side view when person=
both. This task is browser-only (no unit test); verify manually.- Step 1: Implement
health/js/app.js
(function () {
var state = {
schedule: null, gym: [], principles: [],
dayType: dayTypeForWeekday(new Date().getDay()),
person: "ayush", tab: "home", log: {} // log: { itemId: true }
};
function nowMinutes() { var d = new Date(); return d.getHours() * 60 + d.getMinutes(); }
function eventsFor(person) {
if (!state.schedule) return [];
return (state.schedule[state.dayType] && state.schedule[state.dayType][person]) || [];
}
function dayName() {
return ({ office: "Office day", wfh: "WFH day", saturday: "Saturday", sunday: "Sunday" })[state.dayType];
}
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return ({ "&": "&", "<": "<", ">": ">" })[c]; }); }
function renderNowCard(person) {
var events = eventsFor(person);
var nn = nowNext(events, nowMinutes());
var cls = person === "simran" ? "her" : "you";
var wrap = el("div");
var now = el("div", "card now " + cls);
if (nn.current) {
now.innerHTML = '<div class="lbl">Now · ' + esc(dayName()) + '</div>' +
'<div class="act">' + esc(nn.current.activity) + '</div>' +
'<div class="det">' + esc(nn.current.details) + '</div>' +
(nn.next ? '<div class="count">Next ' + esc(nn.next.timeLabel) + ' · ' + esc(nn.next.activity) + '</div>' : '');
} else {
now.innerHTML = '<div class="lbl">' + esc(dayName()) + '</div><div class="act">Not started yet</div>' +
(nn.next ? '<div class="count">First: ' + esc(nn.next.timeLabel) + ' · ' + esc(nn.next.activity) + '</div>' : '');
}
wrap.appendChild(now);
// Today's checkable timeline
var list = el("div", "card");
events.forEach(function (e) {
var done = !!state.log[e.id];
var item = el("div", "item" + (done ? " done" : ""));
item.innerHTML = '<button class="check' + (done ? " on" : "") + '" data-id="' + esc(e.id) +
'" data-label="' + esc(e.activity) + '" data-person="' + esc(person) + '"></button>' +
'<div class="t">' + esc(e.timeLabel) + '</div>' +
'<div class="col"><div class="a">' + esc(e.activity) + '</div>' +
(e.details ? '<div class="det">' + esc(e.details) + '</div>' : '') + '</div>';
list.appendChild(item);
});
wrap.appendChild(list);
return wrap;
}
function renderBoth() {
var row = el("div", "row");
["ayush", "simran"].forEach(function (p) {
var events = eventsFor(p);
var nn = nowNext(events, nowMinutes());
var col = el("div", "col");
col.appendChild(el("div", "lbl", CONFIG.PERSON_LABELS[p]));
var c = el("div", "card now " + (p === "simran" ? "her" : "you"));
c.innerHTML = '<div class="lbl">Now</div><div class="act" style="font-size:14px">' +
esc(nn.current ? nn.current.activity : "—") + '</div>' +
(nn.next ? '<div class="count">Next: ' + esc(nn.next.activity) + '</div>' : '');
col.appendChild(c);
row.appendChild(col);
});
return row;
}
function renderHome() {
var panel = document.getElementById("tab-home");
panel.innerHTML = "";
if (!state.schedule) { panel.appendChild(el("div", "card", "Loading…")); return; }
if (state.person === "both") panel.appendChild(renderBoth());
else panel.appendChild(renderNowCard(state.person));
}
// Placeholder renderers filled in Task 7.
function renderFood() { document.getElementById("tab-food").innerHTML = '<div class="card">Food (Task 7)</div>'; }
function renderGym() { document.getElementById("tab-gym").innerHTML = '<div class="card">Gym (Task 7)</div>'; }
function renderRules() { document.getElementById("tab-rules").innerHTML = '<div class="card">Rules (Task 7)</div>'; }
function renderActiveTab() {
({ home: renderHome, food: renderFood, gym: renderGym, rules: renderRules })[state.tab]();
}
function setTab(tab) {
state.tab = tab;
document.querySelectorAll(".tabbar button").forEach(function (b) { b.classList.toggle("on", b.dataset.tab === tab); });
document.querySelectorAll(".panel").forEach(function (p) { p.classList.toggle("on", p.id === "tab-" + tab); });
renderActiveTab();
}
function setPerson(person) {
state.person = person;
document.querySelectorAll("#personSwitch button").forEach(function (b) { b.classList.toggle("on", b.dataset.person === person); });
renderActiveTab();
}
function todayISO() {
var d = new Date(), p = function (n) { return String(n).padStart(2, "0"); };
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate());
}
function wire() {
document.getElementById("tabbar").addEventListener("click", function (e) {
var b = e.target.closest("button[data-tab]"); if (b) setTab(b.dataset.tab);
});
document.getElementById("personSwitch").addEventListener("click", function (e) {
var b = e.target.closest("button[data-person]"); if (b) setPerson(b.dataset.person);
});
document.getElementById("dayLabel").textContent = dayName();
}
function showBanner(msg) {
var b = document.getElementById("banner");
if (!msg) { b.hidden = true; return; }
b.textContent = msg; b.hidden = false;
}
function boot() {
wire();
fetchSchedule().then(function (res) {
if (!res.data) { showBanner("No data — check config / connection"); return; }
state.schedule = res.data.schedule; state.gym = res.data.gym || []; state.principles = res.data.principles || [];
if (res.stale) showBanner("Showing saved copy (offline)");
return fetchLog(todayISO()).then(function (rows) {
rows.forEach(function (r) { if (r.done) state.log[r.itemId] = true; });
renderActiveTab();
});
});
}
// expose for Task 7 & 8 to extend
window.HealthApp = { state: state, renderActiveTab: renderActiveTab, todayISO: todayISO, esc: esc, el: el, eventsFor: eventsFor, showBanner: showBanner };
document.addEventListener("DOMContentLoaded", boot);
})();
- Step 2: Manual verification
Temporarily point health/config.js EXEC_URL/API_TOKEN at your deployed backend (from the backend plan). Serve locally so fetch works (file:// blocks it):
cd health && python3 -m http.server 8080
Open http://localhost:8080/. Verify: day label shows today’s day-type; Home shows a “Now” card + a checkable timeline for You; the person switch toggles You/Simran and “Both” shows two columns; tab bar switches panels (Food/Gym/Rules show placeholders). If offline/misconfigured, the banner appears. (Revert any real token in config.js before committing — keep the placeholder.)
- Step 3: Commit
git add health/js/app.js
git commit -m "feat(health-fe): app bootstrap and Home tab (now/next + checkable timeline)"
Task 7: Food, Gym, Rules tab rendering
Files:
- Modify:
health/js/app.js(replace the three placeholder renderers)
Interfaces:
- Consumes:
mealPrepRollup,extractIngredients,groceryList,proteinTotal,calorieTotal,PROTEIN_TARGET,OFFICE_DAY_COUNT;state, helpers. Produces: full Food (Today meals + macros; Meal-prep rollup + grocery), Gym (weekly split + today’s workout per person), Rules (principles + reminder-subscribe buttons). Browser-only; verify manually.
- Step 1: Replace
renderFood
In health/js/app.js, replace the renderFood placeholder with:
var foodMode = "today";
function renderFood() {
var panel = document.getElementById("tab-food");
panel.innerHTML = "";
var seg = el("div", "seg");
seg.innerHTML = '<button data-mode="today" class="' + (foodMode === "today" ? "on" : "") + '">Today</button>' +
'<button data-mode="prep" class="' + (foodMode === "prep" ? "on" : "") + '">Meal prep</button>';
seg.addEventListener("click", function (e) { var b = e.target.closest("button"); if (b) { foodMode = b.dataset.mode; renderFood(); } });
panel.appendChild(seg);
if (foodMode === "today") {
var person = state.person === "both" ? "ayush" : state.person;
var meals = eventsFor(person).filter(function (e) { return e.kind === "meal"; });
var pt = proteinTotal(meals), ct = calorieTotal(meals), target = PROTEIN_TARGET[person] || 150;
var tot = el("div", "card");
tot.innerHTML = '<div class="lbl">' + esc(CONFIG.PERSON_LABELS[person]) + ' · protein ' + pt + ' / ' + target + ' g</div>' +
'<div class="prog"><div style="width:' + Math.min(100, Math.round(pt / target * 100)) + '%"></div></div>' +
'<div class="count">~' + ct + ' kcal today</div>';
panel.appendChild(tot);
var list = el("div", "card");
meals.forEach(function (m) {
list.appendChild(el("div", "item",
'<div class="t">' + esc(m.timeLabel) + '</div><div class="col"><div class="a">' + esc(m.activity) + '</div>' +
'<div class="det">' + esc(m.details) + '</div>' +
(m.calories ? '<div class="count" style="color:var(--ok)">' + m.calories + ' kcal' + (m.protein ? ' · ' + m.protein + 'g protein' : '') + '</div>' : '') +
'</div>'));
});
panel.appendChild(list);
} else {
var person2 = state.person === "both" ? "ayush" : state.person;
var rollup = mealPrepRollup(officeEvents(person2), OFFICE_DAY_COUNT); // office template, regardless of today
var cook = el("div", "card");
cook.innerHTML = '<h2>🍳 Batch cook (office week ×' + OFFICE_DAY_COUNT + ')</h2>';
rollup.forEach(function (r) {
cook.appendChild(el("div", "item", '<div class="col"><div class="a">' + esc(r.activity) + '</div><div class="det">' + esc(r.details) + '</div></div><div class="t">×' + r.count + '</div>'));
});
panel.appendChild(cook);
var groc = el("div", "card");
groc.innerHTML = '<h2>🛒 Grocery list</h2>';
groceryList(state.schedule).forEach(function (g) {
groc.appendChild(el("div", "item", '<div class="col"><div class="a">☐ ' + esc(g) + '</div></div>'));
});
panel.appendChild(groc);
}
}
function officeEvents(person) {
return (state.schedule.office && state.schedule.office[person]) || [];
}
- Step 2: Replace
renderGym
function renderGym() {
var panel = document.getElementById("tab-gym");
panel.innerHTML = "";
var today = ({ 1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday", 5: "Friday", 6: "Saturday", 0: "Sunday" })[new Date().getDay()];
var todays = state.gym.filter(function (g) { return g.day === today; });
if (todays.length) {
var t = el("div", "card now you");
t.innerHTML = '<div class="lbl">Today · ' + esc(today) + '</div><div class="act" style="font-size:15px">' +
esc(todays[0].focusAyush) + '</div><div class="det">Simran: ' + esc(todays[0].focusSimran) + ' · ' + esc(todays[0].type) + '</div>';
panel.appendChild(t);
}
var wk = el("div", "card");
wk.innerHTML = '<h2>Weekly split</h2>';
state.gym.forEach(function (g) {
wk.appendChild(el("div", "item", '<div class="t">' + esc(g.day) + '</div><div class="col"><div class="a">' +
esc(g.focusAyush) + '</div><div class="det">Simran: ' + esc(g.focusSimran) + '</div></div>'));
});
panel.appendChild(wk);
}
- Step 3: Replace
renderRules
function renderRules() {
var panel = document.getElementById("tab-rules");
panel.innerHTML = "";
var sub = el("div", "card");
sub.innerHTML = '<h2>🔔 Reminders</h2><div class="det">Add once per phone: Settings → Calendar → Add Subscribed Calendar.</div>';
["ayush", "simran"].forEach(function (p) {
var link = CONFIG.EXEC_URL + "?token=" + encodeURIComponent(CONFIG.API_TOKEN) + "&format=ics&person=" + p;
var a = el("a", "item");
a.href = link; a.textContent = "📅 Subscribe: " + CONFIG.PERSON_LABELS[p];
a.style.display = "block"; a.style.color = "var(--accent)"; a.style.textDecoration = "none";
sub.appendChild(a);
});
panel.appendChild(sub);
var pr = el("div", "card");
pr.innerHTML = '<h2>Principles</h2>';
state.principles.forEach(function (x) {
pr.appendChild(el("div", "item", '<div class="col"><div class="a">' + esc(x.topic) + '</div><div class="det">' + esc(x.guideline) + '</div></div>'));
});
panel.appendChild(pr);
}
- Step 4: Manual verification
Serve locally (as in Task 6) against the real backend. Verify: Food→Today shows meals + a protein progress bar; Food→Meal prep shows batch-cook rows (×3) + a grocery list; Gym shows today’s workout + the weekly split; Rules shows two calendar-subscribe links + the principles. Switch person and confirm Food/Today reflects the selected person (Both defaults to You for single-person views).
- Step 5: Commit
git add health/js/app.js
git commit -m "feat(health-fe): Food, Gym, and Rules tabs"
Task 8: Check-off interactions + sync
Files:
- Modify:
health/js/app.js(wire the check buttons to optimistic toggle +postCheckoff)
Interfaces:
- Consumes:
postCheckoff,state.log,todayISO. Produces: tapping a check button toggles it optimistically, updates
state.log, re-renders Home, and POSTs the change; on POST failure shows the offline banner but keeps the optimistic state. Browser-only; verify manually.- Step 1: Add the check handler in
wire()
In health/js/app.js, inside wire(), add a delegated listener on the Home panel:
document.getElementById("tab-home").addEventListener("click", function (e) {
var btn = e.target.closest(".check"); if (!btn) return;
var id = btn.dataset.id, label = btn.dataset.label, person = btn.dataset.person;
var nowOn = !state.log[id];
if (nowOn) state.log[id] = true; else delete state.log[id];
renderHome();
postCheckoff({ date: todayISO(), person: person, itemId: id, itemLabel: label, done: nowOn })
.then(function (r) { if (!r || r.ok !== true) showBanner("Check-off didn't sync (saved locally)"); });
});
- Step 2: Manual verification
Serve locally against the backend. Tap a check on Home → it fills green and the row strikes through immediately; reload the page → the check persists (it was written to the Log tab and re-read on boot). Confirm a new row appears in the Sheet’s hidden Log tab (backend DEPLOY.md step 12–13). Toggle off → re-tapping writes done:false; reload reflects the latest state per that day’s log.
- Step 3: Commit
git add health/js/app.js
git commit -m "feat(health-fe): check-off toggle with backend sync"
Task 9: PWA — manifest, icons, service worker (installable + offline)
Files:
- Create:
health/manifest.webmanifest,health/sw.js,health/icons/icon-192.png,health/icons/icon-512.png - Modify:
health/js/app.js(register the service worker)
Interfaces:
- Consumes: the served shell.
Produces: an installable, offline-capable PWA. Service-worker scope is
./(the/health/folder). Browser-only; verify manually.- Step 1: Create
health/manifest.webmanifest
{
"name": "Health",
"short_name": "Health",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#0e1116",
"theme_color": "#0e1116",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
- Step 2: Create the icons
Generate two solid-color PNG app icons (192×192 and 512×512). Run from repo root (requires ImageMagick convert; if unavailable, create any 192/512 PNGs by hand):
mkdir -p health/icons
convert -size 192x192 xc:'#3b82f6' -gravity center -pointsize 96 -fill white -annotate 0 '♥' health/icons/icon-192.png
convert -size 512x512 xc:'#3b82f6' -gravity center -pointsize 256 -fill white -annotate 0 '♥' health/icons/icon-512.png
Verify both files exist and are valid PNGs (file health/icons/icon-192.png → “PNG image data, 192 x 192”).
- Step 3: Create
health/sw.js
var CACHE = "health-v1";
var SHELL = [
"./", "./index.html", "./app.css",
"./config.js", "./js/schedule.js", "./js/mealprep.js", "./js/api.js", "./js/app.js",
"./manifest.webmanifest", "./icons/icon-192.png", "./icons/icon-512.png"
];
self.addEventListener("install", function (e) {
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(SHELL); }).then(function () { return self.skipWaiting(); }));
});
self.addEventListener("activate", function (e) {
e.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.map(function (k) { return k === CACHE ? null : caches.delete(k); }));
}).then(function () { return self.clients.claim(); }));
});
self.addEventListener("fetch", function (e) {
var req = e.request;
if (req.method !== "GET") return; // never cache POST check-offs
var url = new URL(req.url);
if (url.origin !== self.location.origin) return; // let backend calls hit the network
e.respondWith(
caches.match(req).then(function (cached) {
var network = fetch(req).then(function (res) {
if (res && res.ok) { var copy = res.clone(); caches.open(CACHE).then(function (c) { c.put(req, copy); }); }
return res;
}).catch(function () { return cached; });
return cached || network;
})
);
});
- Step 4: Register the service worker in
app.js
At the very end of the IIFE in health/js/app.js (just before the closing })();), add:
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () { navigator.serviceWorker.register("sw.js"); });
}
- Step 5: Manual verification
Serve locally over http://localhost:8080/ (service workers need http/https, not file://). In DevTools → Application: the manifest loads, the service worker activates, and the icons resolve. Reload once, then go offline (DevTools → Network → Offline) and reload — the shell still renders and the last schedule shows with the “saved copy” banner. On iPhone Safari (after deploy): Share → Add to Home Screen installs it; it opens standalone.
- Step 6: Commit
git add health/manifest.webmanifest health/sw.js health/icons/ health/js/app.js
git commit -m "feat(health-fe): installable PWA with offline shell"
Task 10: Config finalize + integration doc
Files:
- Create:
health/README.md(front-end setup + verification runbook)
Interfaces:
- Consumes: the deployed backend
EXEC_URL+API_TOKEN. Produces: the documented steps to point the app at the backend and verify the full stack, and the deploy note. No code; documentation + final end-to-end check.
- Step 1: Write
health/README.md
# Health companion — front-end (/health)
A build-free PWA served at `shahayush.com/health/`. Reads the Apps Script backend
(see `../health-backend/DEPLOY.md`).
## Configure
Edit `config.js` and set:
- `EXEC_URL` — the Apps Script web-app `…/exec` URL
- `API_TOKEN` — the same value as the backend `API_TOKEN` Script Property
`config.js` is committed so GitHub Pages can serve it. The token is public in the
served JS by design — use a dedicated, rotatable token; the backend only exposes
personal schedule data and an append-only check-off log.
## Run locally
```bash
cd health && python3 -m http.server 8080
# open http://localhost:8080/
Verify end-to-end
- Home shows today’s day-type, a Now/Next card, and a checkable timeline.
- Person switch (You / Simran / Both) works; Both shows side-by-side.
- Food → Today shows meals + protein bar; Meal prep shows batch-cook ×3 + grocery list.
- Gym shows today’s workout + the weekly split; Rules shows two calendar links + principles.
- Tap a check → persists across reload (written to the Sheet Log tab).
- Offline (after one online load): shell + last schedule render with the “saved copy” banner.
- Install: iPhone Safari → Share → Add to Home Screen → opens standalone.
Deploy
Commit under health/; it ships with the normal GitHub Pages build. health-frontend/ (tests) is excluded from the Jekyll build. After pushing, load https://shahayush.com/health/ and re-run the verify steps on both phones.
Tests (pure logic)
cd health-frontend && npm test
- [ ] **Step 2: Full front-end test suite (regression)**
Run: `cd health-frontend && npm test`
Expected: all four test files pass (`smoke`, `schedule`, `mealprep`, `api`).
- [ ] **Step 3: End-to-end verification against the live backend**
With the backend deployed and `config.js` pointed at it, run the `health/README.md`
"Verify end-to-end" steps 1–7 locally. All must pass before deploy.
- [ ] **Step 4: Commit**
```bash
git add health/README.md
git commit -m "docs(health-fe): add front-end setup and verification runbook"
Notes
- Meal-prep scope: the batch-cook rollup (each office-template meal ×3) and the ingredient/grocery extraction are best-effort over free-text
details. They read well on the current sheet; if the sheet’s meal phrasing changes a lot, revisitextractIngredients. - Token exposure:
config.jsships the token publicly (unavoidable on GitHub Pages). Use a dedicated rotatable token; blast radius is personal schedule data + append-only log. - Time handling: now/next uses device-local time; both users are in the sheet’s timezone, so no conversion is needed.
- Not in scope (YAGNI): history/charts, weight tracking, editing the schedule in the app, hash-based deep links. ```
