Health Companion — Backend Implementation Plan ()
Health Companion — Backend 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 Google Apps Script backend that turns the private diet/fitness Google Sheet into a JSON API, an .ics reminder feed, and a check-off log — the data source the /health/ PWA consumes.
Architecture: Pure, unit-tested JavaScript modules (parser, ics generator, log/auth helpers) live in a local Node project under health-backend/ and are tested with vitest. Thin Apps Script wrappers (Code.js, pushed to Google via clasp) read the live Sheet with SpreadsheetApp, call the pure functions, and expose doGet/doPost. Every pure file uses the dual-runtime pattern (if (typeof module !== "undefined") module.exports = …) so the exact same source runs under Node (for tests) and inside Apps Script’s global scope.
Tech Stack: Node.js (CommonJS), vitest, @google/clasp, Google Apps Script (V8 runtime), Google Sheets.
Global Constraints
These apply to every task. Copy names/values verbatim.
- Runtime style: CommonJS (no
"type": "module"). Pure modules end withif (typeof module !== "undefined" && module.exports) { module.exports = { … }; }so they load in both Node and Apps Script. Never useimport/exportkeywords. - Timezone:
America/Los_Angeles. Calendar times are floating local (noZ, noTZID) so iOS renders them in device-local time. - Person keys: exactly
"ayush"and"simran"(lowercase) everywhere. - Day-type keys: exactly
"office","wfh","saturday","sunday". - Day-type → weekdays (for
.icsBYDAY): office→MO,TU,WE, wfh→TH,FR, saturday→SA, sunday→SU. - Day-type → reference DTSTART date (Mon 2024-01-01 = a Monday): office→
20240101, wfh→20240104, saturday→20240106, sunday→20240107. - Stable event ID format:
`${dayType}:${person}:${time24}:${slug}`— e.g.office:ayush:07:00:gym-strength-together. IDs must be stable across sheet edits so check-offs stay attached. - Auth: all endpoints require a
tokenthat equals theAPI_TOKENScript Property. Requests without a matching token get{ "error": "unauthorized" }. - CORS: GET is consumed by the browser via normal
fetch(googleusercontent servesAccess-Control-Allow-Origin: *); POST is sent asContent-Type: text/plainto avoid a preflight.doGetalso supports acallbackparam (JSONP) as a fallback. (Browser-side CORS is verified in the front-end plan; here we verify withcurl.)
Data model contract (the front-end plan depends on these exact shapes)
// One timeline item
Event = {
id: string, // "office:ayush:07:00:gym-strength-together"
person: "ayush" | "simran",
dayType: "office" | "wfh" | "saturday" | "sunday",
time: string, // 24h "HH:MM", e.g. "07:00"
timeLabel: string, // original "7:00 AM"
minutes: number, // minutes since midnight, e.g. 420
activity: string, // "Gym Strength (Together)"
details: string, // "Lower A: RDL, Split Squat"
notes: string,
kind: "workout"|"walk"|"meal"|"wake"|"sleep"|"drive"|"rest"|"other",
together: boolean, // true if activity mentions "together"
calories: number | null,
protein: number | null
}
// parseSchedule(values) returns:
ParseResult = {
schedule: {
office: { ayush: Event[], simran: Event[] },
wfh: { ayush: Event[], simran: Event[] },
saturday: { ayush: Event[], simran: Event[] },
sunday: { ayush: Event[], simran: Event[] }
},
gym: { day: string, focusAyush: string, focusSimran: string, type: string }[],
principles: { topic: string, guideline: string }[]
}
Sheet layout the parser assumes (0-indexed columns of getDataRange().getValues()): 0=Time, 1=Activity, 2=Details/Food, 3=Notes, 4=blank spacer, 5=Time, 6=Activity, 7=Details/Food, 8=Notes. Columns 0–3 are Ayush, 5–8 are Simran. Section headers occupy a row with text like AYUSH — OFFICE DAY (Mon/Tue/Wed) in col 0 (and the Simran equivalent in col 5), followed by a Time | Activity | Details / Food | Notes header row, then data rows until a fully-blank row. The gym block starts at a row whose col 0 begins with WEEKLY GYM SCHEDULE; the principles block starts where col 5 begins with KEY NOTES & PRINCIPLES.
Task 1: Project scaffold + tooling
Files:
- Create:
health-backend/package.json - Create:
health-backend/vitest.config.mjs - Create:
health-backend/test/smoke.test.js - Create:
health-backend/.gitignore - Modify:
_config.yml(addhealth-backendto the top-levelexclude:list) - Modify:
.gitignore(repo root — ignorehealth-backend/node_modules)
Interfaces:
- Consumes: nothing.
- Produces: a working
npm test(vitest) inhealth-backend/, withglobals: trueso tests usedescribe/it/expectwithout importing them; the folder is excluded from the Jekyll build so it is never served.
Test convention (applies to every test file in this plan): the package is CommonJS, so test files require(...) the local src/*.js modules and rely on vitest globals for describe/it/expect — they never require("vitest") (vitest is ESM-only and would throw ERR_REQUIRE_ESM).
- Step 1: Create
health-backend/package.json
{
"name": "health-backend",
"version": "0.1.0",
"private": true,
"description": "Apps Script backend for the /health diet & fitness companion",
"engines": { "node": ">=18" },
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^2.1.0",
"@google/clasp": "^2.4.2"
}
}
- Step 2: Create
health-backend/.gitignore
node_modules/
.clasp.json
- Step 3: Add the ignore for node_modules at the repo root
In the repo-root .gitignore, add this line (append at the end):
health-backend/node_modules/
- Step 4: Exclude the backend folder from the Jekyll build
Open _config.yml, find the top-level exclude: list, and add health-backend as a new list item so Jekyll never copies it into _site. The result should include:
exclude:
- health-backend
(Keep every existing entry already under exclude:; just add this one line.)
- Step 5: Create the vitest config
Create health-backend/vitest.config.mjs (.mjs so Vite loads it as ESM regardless of the CommonJS package):
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { globals: true }
});
- Step 6: Write a smoke test
Create health-backend/test/smoke.test.js (no require("vitest") — globals):
describe("toolchain", () => {
it("runs vitest", () => {
expect(1 + 1).toBe(2);
});
});
- Step 7: Install deps and run the smoke test
Run:
cd health-backend && npm install && npm test
Expected: vitest reports 1 passed for test/smoke.test.js.
- Step 8: Commit
git add health-backend/package.json health-backend/vitest.config.mjs health-backend/.gitignore health-backend/test/smoke.test.js _config.yml .gitignore
git commit -m "chore(health): scaffold apps script backend project"
Task 2: Field-parsing helpers (time, macros, slug, kind)
Files:
- Create:
health-backend/src/parser.js - Create:
health-backend/test/parser-helpers.test.js
Interfaces:
- Consumes: nothing.
- Produces (all exported from
parser.js):slugify(str) -> stringparseTime(str) -> { time: "HH:MM", label: string, minutes: number } | nullextractMacros(str) -> { calories: number|null, protein: number|null }classifyKind(activity) -> string(one of thekindenum values)
- Step 1: Write the failing test
Create health-backend/test/parser-helpers.test.js:
const { slugify, parseTime, extractMacros, classifyKind } = require("../src/parser");
describe("slugify", () => {
it("lowercases and dashes non-alphanumerics", () => {
expect(slugify("Gym Strength (Together)")).toBe("gym-strength-together");
});
it("collapses and trims dashes", () => {
expect(slugify(" Lunch / Self ")).toBe("lunch-self");
});
});
describe("parseTime", () => {
it("parses AM", () => {
expect(parseTime("5:45 AM")).toEqual({ time: "05:45", label: "5:45 AM", minutes: 345 });
});
it("parses PM", () => {
expect(parseTime("1:00 PM")).toEqual({ time: "13:00", label: "1:00 PM", minutes: 780 });
});
it("parses 12 AM/PM correctly", () => {
expect(parseTime("12:00 AM").time).toBe("00:00");
expect(parseTime("12:30 PM").time).toBe("12:30");
});
it("returns null for non-times", () => {
expect(parseTime("Wake Up")).toBeNull();
expect(parseTime("")).toBeNull();
});
});
describe("extractMacros", () => {
it("pulls calories and protein", () => {
expect(extractMacros("Rice + curry (~740 cal, 34g protein)")).toEqual({ calories: 740, protein: 34 });
});
it("handles calories only", () => {
expect(extractMacros("Dal + rice (~440 cal)")).toEqual({ calories: 440, protein: null });
});
it("returns nulls when absent", () => {
expect(extractMacros("Drink 500ml water")).toEqual({ calories: null, protein: null });
});
});
describe("classifyKind", () => {
it("classifies known activities", () => {
expect(classifyKind("Gym Strength (Together)")).toBe("workout");
expect(classifyKind("Morning Walk (Together)")).toBe("walk");
expect(classifyKind("Light Breakfast")).toBe("meal");
expect(classifyKind("Wake Up")).toBe("wake");
expect(classifyKind("Sleep")).toBe("sleep");
expect(classifyKind("Drive to Office")).toBe("drive");
expect(classifyKind("Nap / Rest")).toBe("rest");
expect(classifyKind("Personal Time")).toBe("other");
});
});
- Step 2: Run test to verify it fails
Run: cd health-backend && npx vitest run test/parser-helpers.test.js Expected: FAIL — Cannot find module '../src/parser'.
- Step 3: Write minimal implementation
Create health-backend/src/parser.js:
function slugify(str) {
return String(str)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function parseTime(str) {
const m = String(str).trim().match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!m) return null;
let hour = parseInt(m[1], 10);
const min = parseInt(m[2], 10);
const mer = m[3].toUpperCase();
if (mer === "AM") { if (hour === 12) hour = 0; }
else { if (hour !== 12) hour += 12; }
const time = String(hour).padStart(2, "0") + ":" + String(min).padStart(2, "0");
return { time: time, label: str.trim().replace(/\s+/g, " "), minutes: hour * 60 + min };
}
function extractMacros(str) {
const s = String(str);
const cal = s.match(/(\d+)\s*cal/i);
const pro = s.match(/(\d+)\s*g\s*protein/i);
return {
calories: cal ? parseInt(cal[1], 10) : null,
protein: pro ? parseInt(pro[1], 10) : null
};
}
function classifyKind(activity) {
const a = String(activity);
if (/gym|strength|soccer|toning|yoga|workout|circuit|stretch/i.test(a)) return "workout";
if (/walk/i.test(a)) return "walk";
if (/breakfast|lunch|dinner|snack|meal|pre-sleep|eat|coffee/i.test(a)) return "meal";
if (/wake/i.test(a)) return "wake";
if (/sleep/i.test(a)) return "sleep";
if (/drive|leave|arrive|commute/i.test(a)) return "drive";
if (/rest|nap|relax|wind\s*down|freshen|recovery/i.test(a)) return "rest";
return "other";
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { slugify, parseTime, extractMacros, classifyKind };
}
- Step 4: Run test to verify it passes
Run: cd health-backend && npx vitest run test/parser-helpers.test.js Expected: PASS — all cases green.
- Step 5: Commit
git add health-backend/src/parser.js health-backend/test/parser-helpers.test.js
git commit -m "feat(health): add time/macro/kind parsing helpers"
Task 3: Parse day-type blocks into events
Files:
- Modify:
health-backend/src/parser.js(addparseSchedule) - Create:
health-backend/test/fixtures/sheet.js - Create:
health-backend/test/parse-schedule.test.js
Interfaces:
- Consumes:
slugify,parseTime,extractMacros,classifyKind(Task 2). Produces:
parseSchedule(values) -> ParseResult(see Global Constraints). This task implements thescheduleportion (per day-type, per person events);gymandprinciplesare filled in Task 4.- Step 1: Create the fixture
Create health-backend/test/fixtures/sheet.js (a representative slice — two day-types, both people, plus the gym+principles block used in Task 4):
// 9-column rows: 0-3 Ayush, 4 spacer, 5-8 Simran
const SHEET_VALUES = [
["AYUSH — OFFICE DAY (Mon/Tue/Wed)","","","","","SIMRAN — OFFICE DAY (Mon/Tue/Wed)","","",""],
["Time","Activity","Details / Food","Notes","","Time","Activity","Details / Food","Notes"],
["5:45 AM","Wake Up","Drink 500ml water","Hydration","","5:45 AM","Wake Up","Drink 500ml water","500ml water"],
["7:00 AM","Gym Strength (Together)","Lower A: RDL, Split Squat","Before breakfast","","7:00 AM","Gym Strength (Together)","Toning circuit","Weight-loss focus"],
["1:00 PM","Lunch at Office","Rice + Chickpeas curry (~740 cal, 34g protein)","From home","","1:00 PM","Lunch (Self)","Dal + brown rice (~440 cal)","Iron + fiber"],
["","","","","","","","",""],
["AYUSH — SUNDAY (Rest / Recovery Day)","","","","","SIMRAN — SUNDAY (Rest / Recovery Day)","","",""],
["Time","Activity","Details / Food","Notes","","Time","Activity","Details / Food","Notes"],
["7:00 AM","Wake Up (Relaxed)","Drink 500ml water","Lie-in","","7:00 AM","Wake Up (Relaxed)","Drink 500ml water",""],
["8:00 AM","Breakfast (Together)","Oats + Whey (~520 cal, 49g protein)","","","8:00 AM","Breakfast (Together)","2 Eggs + spinach (~290 cal)","Iron-rich"],
["","","","","","","","","",""],
["WEEKLY GYM SCHEDULE (3 days/week — Together)","","","","","KEY NOTES & PRINCIPLES","","",""],
["Day","Focus (Ayush)","Focus (Simran)","Type","","Topic","Guideline","",""],
["Monday","Lower A: RDL, Leg Press","Lower body equivalent","Strength","","Protein Target","Ayush 150g+; Simran 100-130g","",""],
["Tuesday","Upper Push: Bench","Upper push equivalent","Strength","","Hydration","3L water daily","",""]
];
if (typeof module !== "undefined" && module.exports) {
module.exports = { SHEET_VALUES };
}
- Step 2: Write the failing test
Create health-backend/test/parse-schedule.test.js:
const { parseSchedule } = require("../src/parser");
const { SHEET_VALUES } = require("./fixtures/sheet");
describe("parseSchedule — schedule blocks", () => {
const result = parseSchedule(SHEET_VALUES);
it("produces all four day-type keys", () => {
expect(Object.keys(result.schedule).sort())
.toEqual(["office", "saturday", "sunday", "wfh"]);
});
it("parses Ayush office events in order", () => {
const ev = result.schedule.office.ayush;
expect(ev.map(e => e.activity)).toEqual(["Wake Up", "Gym Strength (Together)", "Lunch at Office"]);
expect(ev[1].time).toBe("07:00");
expect(ev[1].minutes).toBe(420);
expect(ev[1].kind).toBe("workout");
expect(ev[1].together).toBe(true);
});
it("parses Simran office events independently", () => {
const ev = result.schedule.office.simran;
expect(ev[2].activity).toBe("Lunch (Self)");
expect(ev[2].calories).toBe(440);
});
it("extracts macros and assigns stable ids", () => {
const lunch = result.schedule.office.ayush[2];
expect(lunch.calories).toBe(740);
expect(lunch.protein).toBe(34);
expect(lunch.id).toBe("office:ayush:13:00:lunch-at-office");
});
it("leaves day-types absent from the sheet as empty", () => {
expect(result.schedule.wfh.ayush).toEqual([]);
expect(result.schedule.saturday.simran).toEqual([]);
});
it("parses the Sunday block", () => {
expect(result.schedule.sunday.ayush[1].activity).toBe("Breakfast (Together)");
expect(result.schedule.sunday.ayush[1].protein).toBe(49);
});
});
- Step 3: Run test to verify it fails
Run: cd health-backend && npx vitest run test/parse-schedule.test.js Expected: FAIL — parseSchedule is not a function.
- Step 4: Implement
parseSchedule(schedule portion)
Add to health-backend/src/parser.js, above the module.exports block:
const DAY_TYPE_FROM_HEADER = [
{ re: /office/i, key: "office" },
{ re: /wfh/i, key: "wfh" },
{ re: /saturday/i, key: "saturday" },
{ re: /sunday/i, key: "sunday" }
];
function emptySchedule() {
const blank = () => ({ ayush: [], simran: [] });
return { office: blank(), wfh: blank(), saturday: blank(), sunday: blank() };
}
function dayTypeFromHeader(text) {
const m = String(text).match(/^AYUSH\s*[—-]\s*(.+)$/i);
if (!m) return null;
const rest = m[1];
for (const d of DAY_TYPE_FROM_HEADER) if (d.re.test(rest)) return d.key;
return null;
}
function buildEvent(person, dayType, timeCell, activity, details, notes) {
const t = parseTime(timeCell);
if (!t) return null;
const macros = extractMacros(details);
return {
id: `${dayType}:${person}:${t.time}:${slugify(activity)}`,
person: person,
dayType: dayType,
time: t.time,
timeLabel: t.label,
minutes: t.minutes,
activity: String(activity).trim(),
details: String(details).trim(),
notes: String(notes).trim(),
kind: classifyKind(activity),
together: /together/i.test(String(activity)),
calories: macros.calories,
protein: macros.protein
};
}
function isBlankRow(row) {
return row.every(c => String(c == null ? "" : c).trim() === "");
}
function parseSchedule(values) {
const schedule = emptySchedule();
const rows = values || [];
for (let i = 0; i < rows.length; i++) {
const dayType = dayTypeFromHeader(rows[i][0]);
if (!dayType) continue;
// rows[i] = section header, rows[i+1] = column header, data starts at i+2
let r = i + 2;
for (; r < rows.length; r++) {
const row = rows[r];
if (isBlankRow(row)) break;
if (dayTypeFromHeader(row[0])) break; // next section with no blank between
const a = buildEvent("ayush", dayType, row[0], row[1], row[2], row[3]);
if (a) schedule[dayType].ayush.push(a);
const s = buildEvent("simran", dayType, row[5], row[6], row[7], row[8]);
if (s) schedule[dayType].simran.push(s);
}
i = r - 1;
}
return { schedule: schedule, gym: [], principles: [] };
}
Update the export line at the bottom of parser.js to include parseSchedule:
if (typeof module !== "undefined" && module.exports) {
module.exports = { slugify, parseTime, extractMacros, classifyKind, parseSchedule };
}
- Step 5: Run test to verify it passes
Run: cd health-backend && npx vitest run test/parse-schedule.test.js Expected: PASS — all cases green.
- Step 6: Commit
git add health-backend/src/parser.js health-backend/test/fixtures/sheet.js health-backend/test/parse-schedule.test.js
git commit -m "feat(health): parse day-type blocks into events"
Task 4: Parse gym split + principles
Files:
- Modify:
health-backend/src/parser.js(fillgymandprinciples) - Modify:
health-backend/test/parse-schedule.test.js(add cases)
Interfaces:
- Consumes:
parseSchedulescaffold (Task 3). Produces:
parseSchedule(...).gymand.principlespopulated per the contract.- Step 1: Add failing tests
Append to health-backend/test/parse-schedule.test.js:
describe("parseSchedule — gym & principles", () => {
const result = parseSchedule(SHEET_VALUES);
it("parses the gym split", () => {
expect(result.gym).toEqual([
{ day: "Monday", focusAyush: "Lower A: RDL, Leg Press", focusSimran: "Lower body equivalent", type: "Strength" },
{ day: "Tuesday", focusAyush: "Upper Push: Bench", focusSimran: "Upper push equivalent", type: "Strength" }
]);
});
it("parses the principles", () => {
expect(result.principles).toEqual([
{ topic: "Protein Target", guideline: "Ayush 150g+; Simran 100-130g" },
{ topic: "Hydration", guideline: "3L water daily" }
]);
});
});
- Step 2: Run to verify failure
Run: cd health-backend && npx vitest run test/parse-schedule.test.js Expected: FAIL — gym and principles are empty arrays.
- Step 3: Implement gym + principles parsing
In parser.js, add these two functions above the module.exports block:
function parseGym(rows) {
const out = [];
for (let i = 0; i < rows.length; i++) {
if (!/^WEEKLY GYM SCHEDULE/i.test(String(rows[i][0]))) continue;
for (let r = i + 2; r < rows.length; r++) {
const day = String(rows[r][0] == null ? "" : rows[r][0]).trim();
if (day === "") break;
out.push({
day: day,
focusAyush: String(rows[r][1] || "").trim(),
focusSimran: String(rows[r][2] || "").trim(),
type: String(rows[r][3] || "").trim()
});
}
break;
}
return out;
}
function parsePrinciples(rows) {
const out = [];
for (let i = 0; i < rows.length; i++) {
if (!/^KEY NOTES/i.test(String(rows[i][5]))) continue;
for (let r = i + 2; r < rows.length; r++) {
const topic = String(rows[r][5] == null ? "" : rows[r][5]).trim();
if (topic === "") break;
out.push({ topic: topic, guideline: String(rows[r][6] || "").trim() });
}
break;
}
return out;
}
Then, inside parseSchedule, replace the return line with:
return { schedule: schedule, gym: parseGym(rows), principles: parsePrinciples(rows) };
- Step 4: Run to verify pass
Run: cd health-backend && npx vitest run test/parse-schedule.test.js Expected: PASS — all cases green.
- Step 5: Commit
git add health-backend/src/parser.js health-backend/test/parse-schedule.test.js
git commit -m "feat(health): parse gym split and principles"
Task 5: .ics calendar feed generator
Files:
- Create:
health-backend/src/ics.js - Create:
health-backend/test/ics.test.js
Interfaces:
- Consumes:
ParseResultfromparseSchedule(Tasks 3–4). - Produces (exported from
ics.js):escapeIcs(str) -> stringbuildIcs(parseResult, person, options) -> stringwhereoptions = { calname: string }. Emits one weekly-recurringVEVENTwith aVALARMper event forperson, across all four day-types.
- Step 1: Write the failing test
Create health-backend/test/ics.test.js:
const { escapeIcs, buildIcs } = require("../src/ics");
const { parseSchedule } = require("../src/parser");
const { SHEET_VALUES } = require("./fixtures/sheet");
describe("escapeIcs", () => {
it("escapes commas, semicolons, backslashes, newlines", () => {
expect(escapeIcs("a, b; c\\d\ne")).toBe("a\\, b\\; c\\\\d\\ne");
});
});
describe("buildIcs", () => {
const parsed = parseSchedule(SHEET_VALUES);
const ics = buildIcs(parsed, "ayush", { calname: "Health · Ayush" });
it("wraps in a VCALENDAR with CRLF line endings", () => {
expect(ics.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true);
expect(ics.trimEnd().endsWith("END:VCALENDAR")).toBe(true);
expect(ics).toContain("X-WR-CALNAME:Health · Ayush");
});
it("emits a VEVENT per Ayush event with recurrence and alarm", () => {
// 3 office + 2 sunday = 5 events for ayush in the fixture
const count = (ics.match(/BEGIN:VEVENT/g) || []).length;
expect(count).toBe(5);
expect(ics).toContain("BEGIN:VALARM");
});
it("uses floating local time and correct BYDAY per day-type", () => {
expect(ics).toContain("DTSTART:20240101T070000"); // office gym, Mon ref date
expect(ics).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE"); // office
expect(ics).toContain("DTSTART:20240107T080000"); // sunday breakfast, Sun ref date
expect(ics).toContain("RRULE:FREQ=WEEKLY;BYDAY=SU"); // sunday
expect(ics).not.toContain("Z\r\n"); // no UTC 'Z' suffix on DTSTART/DTEND
});
it("only includes the requested person's events", () => {
expect(ics).toContain("SUMMARY:Lunch at Office"); // ayush
expect(ics).not.toContain("SUMMARY:Lunch (Self)"); // simran
});
});
- Step 2: Run test to verify it fails
Run: cd health-backend && npx vitest run test/ics.test.js Expected: FAIL — Cannot find module '../src/ics'.
- Step 3: Write minimal implementation
Create health-backend/src/ics.js:
const DAY_BYDAY = { office: "MO,TU,WE", wfh: "TH,FR", saturday: "SA", sunday: "SU" };
const DAY_REFDATE = { office: "20240101", wfh: "20240104", saturday: "20240106", sunday: "20240107" };
const DAY_ORDER = ["office", "wfh", "saturday", "sunday"];
function escapeIcs(str) {
return String(str)
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;");
}
function addMinutes(hhmm, add) {
const [h, m] = hhmm.split(":").map(n => parseInt(n, 10));
let total = h * 60 + m + add;
total = ((total % 1440) + 1440) % 1440;
const hh = String(Math.floor(total / 60)).padStart(2, "0");
const mm = String(total % 60).padStart(2, "0");
return hh + mm + "00";
}
function eventBlock(ev) {
const refDate = DAY_REFDATE[ev.dayType];
const start = ev.time.replace(":", "") + "00";
const end = addMinutes(ev.time, 15);
const descParts = [];
if (ev.details) descParts.push(ev.details);
if (ev.notes) descParts.push(ev.notes);
const desc = escapeIcs(descParts.join(" — "));
const lines = [
"BEGIN:VEVENT",
"UID:" + ev.id + "@health.shahayush.com",
"DTSTART:" + refDate + "T" + start,
"DTEND:" + refDate + "T" + end,
"RRULE:FREQ=WEEKLY;BYDAY=" + DAY_BYDAY[ev.dayType],
"SUMMARY:" + escapeIcs(ev.activity)
];
if (desc) lines.push("DESCRIPTION:" + desc);
lines.push(
"BEGIN:VALARM",
"ACTION:DISPLAY",
"DESCRIPTION:" + escapeIcs(ev.activity),
"TRIGGER:-PT0M",
"END:VALARM",
"END:VEVENT"
);
return lines;
}
function buildIcs(parseResult, person, options) {
const opts = options || {};
const calname = opts.calname || "Health";
const out = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//shahayush//health//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:" + calname
];
for (const dayType of DAY_ORDER) {
const events = parseResult.schedule[dayType][person] || [];
for (const ev of events) out.push.apply(out, eventBlock(ev));
}
out.push("END:VCALENDAR");
return out.join("\r\n") + "\r\n";
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { escapeIcs, buildIcs };
}
- Step 4: Run test to verify it passes
Run: cd health-backend && npx vitest run test/ics.test.js Expected: PASS — all cases green.
- Step 5: Commit
git add health-backend/src/ics.js health-backend/test/ics.test.js
git commit -m "feat(health): generate .ics reminder feed"
Task 6: Log parsing + token auth (pure)
Files:
- Create:
health-backend/src/log.js - Create:
health-backend/test/log.test.js
Interfaces:
- Consumes: nothing.
- Produces (exported from
log.js):parseLogRows(values) -> Row[]whereRow = { timestamp, date, person, itemId, itemLabel, done }(skips header row)filterLogByDate(rows, date) -> Row[]isAuthorized(provided, expected) -> booleanLOG_HEADER -> string[](the canonical Log-tab header, reused byCode.js)
- Step 1: Write the failing test
Create health-backend/test/log.test.js:
const { parseLogRows, filterLogByDate, isAuthorized, LOG_HEADER } = require("../src/log");
const VALUES = [
["timestamp", "date", "person", "itemId", "itemLabel", "done"],
["2026-07-11T14:00:00Z", "2026-07-11", "ayush", "office:ayush:07:00:gym-strength-together", "Gym Strength", true],
["2026-07-11T15:00:00Z", "2026-07-11", "simran", "office:simran:06:00:morning-walk-together", "Morning Walk", true],
["2026-07-10T15:00:00Z", "2026-07-10", "ayush", "office:ayush:06:00:morning-walk-together", "Morning Walk", true]
];
describe("parseLogRows", () => {
it("maps rows to objects and skips the header", () => {
const rows = parseLogRows(VALUES);
expect(rows).toHaveLength(3);
expect(rows[0]).toEqual({
timestamp: "2026-07-11T14:00:00Z", date: "2026-07-11", person: "ayush",
itemId: "office:ayush:07:00:gym-strength-together", itemLabel: "Gym Strength", done: true
});
});
it("returns [] for empty input", () => {
expect(parseLogRows([])).toEqual([]);
expect(parseLogRows([LOG_HEADER])).toEqual([]);
});
});
describe("filterLogByDate", () => {
it("keeps only the given date", () => {
const rows = parseLogRows(VALUES);
expect(filterLogByDate(rows, "2026-07-11")).toHaveLength(2);
expect(filterLogByDate(rows, "2026-07-10")).toHaveLength(1);
});
});
describe("isAuthorized", () => {
it("is true only on exact match with a non-empty expected", () => {
expect(isAuthorized("abc", "abc")).toBe(true);
expect(isAuthorized("abc", "xyz")).toBe(false);
expect(isAuthorized("", "")).toBe(false);
expect(isAuthorized(undefined, "abc")).toBe(false);
expect(isAuthorized("abc", null)).toBe(false);
});
});
- Step 2: Run test to verify it fails
Run: cd health-backend && npx vitest run test/log.test.js Expected: FAIL — Cannot find module '../src/log'.
- Step 3: Write minimal implementation
Create health-backend/src/log.js:
const LOG_HEADER = ["timestamp", "date", "person", "itemId", "itemLabel", "done"];
function parseLogRows(values) {
const rows = values || [];
const out = [];
for (let i = 1; i < rows.length; i++) { // skip header row
const r = rows[i];
if (!r || String(r[3] == null ? "" : r[3]).trim() === "") continue;
out.push({
timestamp: r[0], date: String(r[1]), person: String(r[2]),
itemId: String(r[3]), itemLabel: String(r[4] == null ? "" : r[4]),
done: r[5] === true || String(r[5]).toLowerCase() === "true"
});
}
return out;
}
function filterLogByDate(rows, date) {
return (rows || []).filter(r => r.date === date);
}
function isAuthorized(provided, expected) {
return typeof provided === "string" && typeof expected === "string" &&
expected.length > 0 && provided === expected;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { LOG_HEADER, parseLogRows, filterLogByDate, isAuthorized };
}
- Step 4: Run test to verify it passes
Run: cd health-backend && npx vitest run test/log.test.js Expected: PASS — all cases green.
- Step 5: Run the full suite
Run: cd health-backend && npm test Expected: all five test files pass (smoke, parser-helpers, parse-schedule, ics, log).
- Step 6: Commit
git add health-backend/src/log.js health-backend/test/log.test.js
git commit -m "feat(health): add log parsing and token auth helpers"
Task 7: Apps Script wrappers + deploy
Files:
- Create:
health-backend/src/Code.js(pushed to Apps Script asCode.gs) - Create:
health-backend/src/appsscript.json - Create:
health-backend/.clasp.json(git-ignored; created during deploy) - Create:
health-backend/DEPLOY.md(setup + verification runbook)
Interfaces:
- Consumes globals available in Apps Script scope after
clasp push:parseSchedule(parser.gs),buildIcs(ics.gs),parseLogRows,filterLogByDate,isAuthorized,LOG_HEADER(log.gs). - Produces the deployed web-app endpoints:
GET ?token=…→ fullParseResultJSONGET ?token=…&format=ics&person=ayush→.icsfeedGET ?token=…&action=log&date=YYYY-MM-DD→{ rows: Row[] }GETwith&callback=fn→ JSONP wrapper of the abovePOST(text/plain body){ token, date, person, itemId, itemLabel, done }→ appends to the Log tab, returns{ ok: true }
- Step 1: Write
appsscript.json
Create health-backend/src/appsscript.json:
{
"timeZone": "America/Los_Angeles",
"dependencies": {},
"webapp": { "access": "ANYONE_ANONYMOUS", "executeAs": "USER_DEPLOYING" },
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
- Step 2: Write
Code.js
Create health-backend/src/Code.js:
// Thin Apps Script wrappers. Runs ONLY inside Apps Script (uses SpreadsheetApp,
// PropertiesService). The pure functions come from parser.gs, ics.gs, log.gs.
var SCHEDULE_TAB = "Schedule"; // rename to the actual schedule tab name if different
var LOG_TAB = "Log";
function scriptToken_() {
return PropertiesService.getScriptProperties().getProperty("API_TOKEN");
}
function scheduleValues_() {
var sh = SpreadsheetApp.getActive().getSheetByName(SCHEDULE_TAB);
return sh.getDataRange().getValues();
}
function logSheet_() {
var ss = SpreadsheetApp.getActive();
var sh = ss.getSheetByName(LOG_TAB);
if (!sh) {
sh = ss.insertSheet(LOG_TAB);
sh.appendRow(LOG_HEADER);
sh.hideSheet();
}
return sh;
}
function readLog_() {
return parseLogRows(logSheet_().getDataRange().getValues());
}
function jsonOut_(obj, callback) {
var body = JSON.stringify(obj);
if (callback) {
return ContentService.createTextOutput(callback + "(" + body + ")")
.setMimeType(ContentService.MimeType.JAVASCRIPT);
}
return ContentService.createTextOutput(body)
.setMimeType(ContentService.MimeType.JSON);
}
function doGet(e) {
var p = (e && e.parameter) || {};
var cb = p.callback;
if (!isAuthorized(p.token, scriptToken_())) {
return jsonOut_({ error: "unauthorized" }, cb);
}
if (p.format === "ics") {
var person = p.person === "simran" ? "simran" : "ayush";
var ics = buildIcs(parseSchedule(scheduleValues_()), person,
{ calname: "Health · " + (person === "simran" ? "Simran" : "Ayush") });
return ContentService.createTextOutput(ics)
.setMimeType(ContentService.MimeType.ICAL);
}
if (p.action === "log") {
return jsonOut_({ rows: filterLogByDate(readLog_(), p.date) }, cb);
}
return jsonOut_(parseSchedule(scheduleValues_()), cb);
}
function doPost(e) {
var payload;
try {
payload = JSON.parse((e && e.postData && e.postData.contents) || "{}");
} catch (err) {
return jsonOut_({ error: "bad_json" });
}
if (!isAuthorized(payload.token, scriptToken_())) {
return jsonOut_({ error: "unauthorized" });
}
logSheet_().appendRow([
new Date().toISOString(),
String(payload.date || ""),
String(payload.person || ""),
String(payload.itemId || ""),
String(payload.itemLabel || ""),
payload.done === true
]);
return jsonOut_({ ok: true });
}
Note: Apps Script has no MimeType.ICAL in older runtimes — if clasp push or execution reports it as undefined, replace that line with .setMimeType(ContentService.MimeType.TEXT); iOS still accepts the subscription.
- Step 3: Write the deploy runbook
Create health-backend/DEPLOY.md:
# Deploying the Health backend (Apps Script via clasp)
## One-time setup
1. `cd health-backend && npm install`
2. `npx clasp login` (opens a browser; authorize the Google account that OWNS
the diet/fitness Sheet).
3. Enable the Apps Script API once at https://script.google.com/home/usersettings
4. Create a container-bound script tied to the Sheet:
`npx clasp create --type sheets --title "Health Backend" --rootDir src`
(or `npx clasp clone <scriptId>` if the Sheet already has a bound script).
This writes `.clasp.json` (git-ignored).
5. In `src/Code.js`, set `SCHEDULE_TAB` to the exact name of the schedule tab.
## Push + configure
6. `npx clasp push` — uploads `src/*.js` (as `.gs`) and `appsscript.json`.
7. Set the API token: open the script (`npx clasp open`), then
Project Settings → Script Properties → add `API_TOKEN` = a long random string.
Keep this value; the front-end and calendar URLs need it.
## Deploy the web app
8. `npx clasp deploy --description "v1"` → note the deployment's web-app URL
(`https://script.google.com/macros/s/<id>/exec`).
(Or Deploy → New deployment → Web app: execute as **me**, access **Anyone**.)
## Verify (curl — CORS is a browser concern, verified in the front-end plan)
9. Schedule JSON:
`curl -sL "<EXEC_URL>?token=<API_TOKEN>" | head -c 400`
Expect JSON starting with `{"schedule":{"office":{"ayush":[`.
10. Unauthorized is rejected:
`curl -sL "<EXEC_URL>?token=wrong"` → `{"error":"unauthorized"}`.
11. Calendar feed:
`curl -sL "<EXEC_URL>?token=<API_TOKEN>&format=ics&person=ayush" | head`
Expect `BEGIN:VCALENDAR` … with `BEGIN:VEVENT` / `RRULE` / `BEGIN:VALARM`.
12. Check-off write:
`curl -sL -X POST "<EXEC_URL>" -H "Content-Type: text/plain" \
--data '{"token":"<API_TOKEN>","date":"2026-07-11","person":"ayush","itemId":"office:ayush:07:00:gym-strength-together","itemLabel":"Gym","done":true}'`
Expect `{"ok":true}`; confirm a new row in the hidden **Log** tab.
13. Log read:
`curl -sL "<EXEC_URL>?token=<API_TOKEN>&action=log&date=2026-07-11"`
Expect `{"rows":[{...itemId...}]}`.
## Reminders (one-time, each phone)
14. On each iPhone: Settings → Calendar → Accounts → Add Account → Other →
Add Subscribed Calendar → URL:
`<EXEC_URL>?token=<API_TOKEN>&format=ics&person=ayush` (or `simran`).
- Step 4: Deploy and run the verification runbook
Follow health-backend/DEPLOY.md steps 1–13. All curl checks must return the expected output before considering the backend done. (Step 14 is per-phone setup.)
- Step 5: Commit
git add health-backend/src/Code.js health-backend/src/appsscript.json health-backend/DEPLOY.md
git commit -m "feat(health): add apps script web-app wrappers and deploy runbook"
Notes for the front-end plan (next)
- The front-end consumes the Data model contract above verbatim.
- Meal-prep aggregation (batch-cook + grocery rollup) and the day-of-week → day-type mapping are computed client-side from the JSON; they are intentionally not in the backend.
- The front-end will need the deployed exec URL + API_TOKEN as config (shipped in client JS — acceptable per the spec’s security section).
- CORS: front-end GET uses normal
fetch; check-off POST usesContent-Type: text/plain(no preflight). JSONPcallbackis the fallback. ```
