dynamic-baby-schedular
A calm day planner for a baby's sleep. Enter a birth date and this morning's wake up time and the whole day appears: naps, feeds, wind down and bedtime, worked out from the baby's age rather than from one template with the times shifted. Edit any block and the rest of the day adapts around it while protecting bedtime. Nine age bands from newborn to eighteen months. It is a planner, not a tracker, so there is nothing to log and nothing to keep up with. Runs entirely in the browser with no database needed, and an optional weekly review can suggest a small bedtime shift. Gentle guidelines drawn from named published sources and shown on screen, not medical advice.
Read this first: this app gives advice about somebody's baby
Little Day tells a tired parent when their infant should sleep. Two things are therefore non negotiable in any version anyone builds from this document:
- Every number in the age band table comes from a named, reputable published source, and the app names those sources on screen. On screen, where the parent reading the advice can see them. Not in a README, not in a comment. If you change a number, change the source alongside it.
- A plain disclaimer sits next to those sources, in the UI. Guidelines, not medical advice, and every baby is different. Wording along these lines:
Gentle guidelines based on AAP/AASM, Cleveland Clinic, Sleep Foundation, Taking Cara Babies, and Huckleberry. Bedtime logic uses circadian-rhythm research. Every baby is different. Trust what you see in your baby.
Numbers you invented, or nudged until the schedule looked tidy, or took from a source you cannot name, are the one thing that turns this from a calm little app into a harmful one. Showing your work is the minimum, not decoration. This point returns in the fidelity section and again at the very end, deliberately.
Core brief
Read this section on its own first. If you built only from this section and nothing else, you would still build recognisably the right app.
Little Day is a calm, phone-first web app that tells one exhausted parent, at a glance, when their baby should nap, feed, and have wake time today, plus one small age-appropriate activity or soothing tip per block. The parent types in a birth date and this morning's wake-up time once, and the whole day appears, generated from the baby's age using published pediatric wake-window guidance. It is a planner, not a tracker: it never asks the parent to log anything.
Screens
- The day screen (
/), the only route. A greeting, the baby's age, and the day as a single column of cards in time order, one per block. - Settings sheet, an overlay. Birth date, today's wake-up, optional baby name, optional target bedtime. Opens by itself on first run.
- Edit time sheet, an overlay. Change one block's start time.
- Review sheet, an overlay, and optional. The week's review, with an accept or decline button.
- Empty state, on the day screen before any settings exist, normally seen behind the auto-opened settings sheet.
Nothing navigates. There is one route and everything else is an overlay on it.
The features that define it
- Three inputs, entered once: birth date, today's wake-up time, and optionally the baby's name. No account, no sign-up, no email, no password.
- The whole day is generated from two of those inputs, age and this morning's wake-up, with no other data and no server call.
- Nine age bands from newborn to 18 months and up, each carrying its own wake window, nap count, nap length, and bedtime behaviour.
- Six kinds of block: wake time, feed, nap, bedtime, settling, night cycle. A feed sits at wake-up and at the end of every nap.
- Three age-conditional endings to the day: no bedtime at all under about 12 weeks, a soft settling window from 12 to 16 weeks, a firm bedtime from 16 weeks on.
- One activity tip per wake block and one soothing tip per nap, bedtime, and settling block, rotating by the day of the year so today is stable on reload and tomorrow is different.
- A pencil on every card opens a sheet to nudge that block's start. The previous block stretches or shrinks to meet it and every later block shifts by the same amount, keeping its own duration.
- Bedtime protection on the cascade: rather than let bedtime slide late, the app shrinks the last nap first and then the wind-down. It never deletes a nap.
- Blocks the parent edited directly are marked with a small dot and are immutable afterwards. Blocks that merely moved are not marked. A "Reset today" pill appears in the header only when at least one edit exists.
- Edits are scoped to today's local date and disappear on calendar rollover. Changing this morning's wake-up time clears them too.
- An inline note on any wake window longer than the band's ceiling ("overtired risk"), and a plain-language note when the day would run past midnight.
- Fully offline. Everything lives in the browser. No spinner anywhere, no error toast, no stack trace ever shown to the parent.
- An optional weekly review: once a week an AI agent reads the week's snapshots and writes one short honest paragraph, with at most a bounded bedtime shift of 10 or 15 minutes that the parent accepts or declines.
- The guidance sources and a plain disclaimer live in the UI, not in a README.
The feel
A quiet nursery at dawn. Warm cream ground, soft pastels, big type, generous rounding, one soft warm-brown shadow, and almost no motion. The person reading this is holding a baby in one arm at 06:40 and has slept in fragments, so the screen answers the question before it is asked: one glance, one column, no tapping around, nothing flashing for attention. Type is 18px at the base and times are large, because this gets read at arm's length in a half-dark room. Nothing on the screen should feel urgent, and nothing should imply the parent is behind on something.
How to read the rest of this document
You are expected to make your own version of this app. Differing in wording, layout, colour, copy, component names, and small interactions is fine and expected. Everything after the fidelity section below is detail to draw on when you want it, not a specification to satisfy line by line.
Fidelity: what matters and what does not
Must match, or it is a different app
Four things. Everything else is negotiable.
- Enter a birth date and a wake-up time, get the whole day back. No other input, no waiting, no server round trip.
- The plan is derived from the baby's age, not one generic template with the times shifted. A three week old and a fourteen month old get structurally different days.
- Edit any block and the rest of the day adapts around it. The parent nudges one start time and the day rearranges itself sensibly.
- It is a planner, not a tracker. There is nothing to log, ever. No feeds, nappies, sleep, pumping, weight, temperature, or milestones. No charts, no statistics screen, no notifications, no streaks.
Must be right, or it breaks
These are not taste. Getting one of them wrong produces a broken or harmful app rather than a different one.
- The nine age band numbers. This one needs saying plainly, because it is not like the rest of the list: nothing in the code breaks if you change them. The output of this app is advice about somebody's baby. The wake windows, nap counts, nap lengths, and bedtime targets in the band table are midpoints of published pediatric guidance, and a reader who invents their own numbers is shipping bad advice to a tired parent about an infant's sleep. So treat the table as load bearing. If you want different numbers, that is completely fine, and you should take them from a reputable source and say which one in the app, rather than making them up or nudging them until the schedule looks tidier.
- Day generation stays deterministic. The same birth date plus the same
wake-up time must always produce the same plan. No
Math.random, noDatearithmetic inside the generator (work in minutes from midnight), tip selection keyed on the day of the year, and the two date details in the time handling section below: parse the birth date as local midnight, and divide by 30.4375 for age in months. Both of those shift band boundaries by a whole day if you get them wrong. - Bedtime protection when a block is edited. The cascade must not be allowed to push bedtime toward midnight because the parent nudged a nap by half an hour. Shrink the last nap, then the wind-down, and stop at the first block the parent edited explicitly. A cascade with no bedtime ceiling is not a rougher version of this app, it is an app that tells a parent to put a six month old to bed at 22:30.
- The three age-conditional endings. Under about 12 weeks there is no bedtime block at all, because the circadian rhythm has not formed yet. Telling the parent of a three week old to enforce a 19:00 bedtime is wrong, and the newborn bands must end in evening feeds plus a "night cycle begins" marker with a floor around 21:00 instead.
- The weekly review's verdict is computed in code, and the model is boxed in. The statistics and the verdict come from the trailing week's rows before any model runs. The model narrates that verdict and cannot override it. The only action it may propose is a bedtime shift of exactly 10 or 15 minutes in either direction, and citations must come from a fixed source whitelist, with the API rejecting bad shapes and demoting the verdict on an invalid citation. An unconstrained AI making free-form recommendations about an infant is a different and considerably worse product. If you do not want to build these guards, build the app without the weekly review at all. That is a legitimate choice; a review without the guards is not.
- The deployment contract. Every rule here carries its failure mode,
because each one fails silently or misleadingly. One deployable app per
repository, and never
subPath, which injects zero environment variables without warning, so the app boots with none of its secrets and presents as a dozen unrelated misconfigurations at once. Read the injectedPORTrather than hardcoding one, and bind to0.0.0.0, or the platform cannot reach the app and marks a working deploy unhealthy. Always ship abuildscript, even a no-opecho, because the platform runsnpm run buildregardless and a missing script fails the build. Answer/healthzwithok, unauthenticated, or the health probe 404s and a healthy app is reported down. - The Phase 2 secret handling.
DATABASE_URLandAUTH_TOKENcome from the platform parameter store at runtime and are never hardcoded or baked into a Dockerfile, because a value set in the image shadows the injected one and you spend the afternoon debugging the wrong database. The scheduled agent task gets its token inline in its prompt, because agent-task containers do not receive the app's environment variables, and an agent told to read the token from the environment spends its entire run hunting a variable that is not there and then fails. Compare the bearer token in constant time so it cannot be guessed a byte at a time, and use parameterised SQL only. - The sources and the disclaimer are visible in the UI. Guidelines, not medical advice, and every baby is different. This app tells tired parents what to do with their child, so showing your work is the minimum, not decoration.
- The last wake window of the day gets its own, much larger ceiling. The stretch between the end of the last nap and bedtime is the longest wake window of the day, and it is not the same quantity as the daytime wake window. Give each band a separate wind-down ceiling, roughly 75 minutes at newborn rising to about 7 hours at 18 months and up. Capping that final stretch at the band's daytime maximum instead false-flags nearly every ordinary schedule from 4 months onward as "overtired risk", so the app cries wolf at a parent on almost every day it is opened and the warning stops meaning anything.
Yours to change
Generously. All of this:
- The name, the branding, the palette, the fonts, the radii, the shadow, and whether there is a dark mode.
- Every word of copy: the greeting strings, the block labels, the empty state, the sheet titles, the button labels, the footnote wording, the tone.
- The activity and soothing tip pools. The text, the number of tips per pool, the voice. Write your own.
- The layout, the vertical order of the day screen, the card anatomy, the icon set, and the single-column width. 448px is the original's choice, not a rule.
- Whether the weekly review exists at all. Stopping after Phase 1 leaves a complete, useful, deployable app.
- Whether this is one deployable app or two. One app on one URL is the recommendation and the default in this document, and you should have a reason before splitting it.
- Component names, file layout, framework versions, and package choices.
- Every tuning number in the rest of this document that is not named above: the review thresholds, the 500 ms edit debounce, the tip rotation multipliers, the 30 minute compression floors, the type scale, the spacing, the breakpoint, the audit script's wake-time sweep, and the pool sizes.
The build is staged, and Stage 1 is small
You do not need the rest of this document to start, and you should not try to build everything at once. The build order near the end splits the work into four stages. Stage 1 is the settings sheet, the nine age bands, the generator, and a read-only day screen, deployed. No editing, no cascade, no review. Everything above this line is enough to build Stage 1 in full. Stage 2 adds editing and the cascade, Stage 3 adds the copy, polish, and feel, and Stage 4 is the optional weekly AI review. Deploy at the end of each stage rather than only at the end of the build, so a deployment problem surfaces while the app is still simple enough to debug.
The app in full, and what it is not
The same description again at more length, plus the deliberate omissions. From here on this document is reference material for the stages, not a checklist.
Build Little Day, a calm, mobile-first web app that tells a parent, at a glance, when their baby should nap, feed, and have wake time today, plus one age-appropriate activity or soothing tip per block. The parent enters three things once (birth date, today's wake-up time, optionally the baby's name) and the whole day is generated deterministically from the baby's age and this morning's wake-up, using published pediatric wake-window guidance. The parent can nudge any block's start time and the rest of the day cascades around it, with bedtime protected. Once a week, a scheduled AI agent looks at the week's snapshots and edits and writes one short, honest review with an optional bounded suggestion ("shift bedtime by 10 minutes"), which the parent accepts or declines in the app. It is a planner, not a tracker: it never asks the parent to log anything, and it deliberately does not track feeds, nappies, pumping, weight, or milestones. It is for one exhausted parent on a phone at 06:40 in the morning who wants to be told what to expect, not asked to fill in a form.
What it explicitly is not
Do not add these. Their absence is a design decision, not an omission.
- No logging or tracking of actual feeds, nappies, sleep, pumping, weight, temperature, or milestones. Nothing to fill in.
- No charts, graphs, or statistics screens in the app.
- No push notifications, alarms, or reminders of any kind.
- No user accounts, sign-up, password, or email.
- No multi-baby or twins support. One baby.
- No adjusted age for premature babies.
- No modelling of night wakes. One wake-up per day is assumed.
- No medical advice. The app says out loud that these are guidelines and that every baby is different.
How to use this prompt
Three paths. Pick one.
(a) Liivo MCP connector (no local setup, deploys itself).
Go to liivo.ai/connect and add the connector at address my.liivo.ai/mcp.
Then send as your first message:
Use setup-project for Little Day, a baby day-schedule web app
Then paste the rest of this prompt as your second message. Ask the platform for a managed PostgreSQL instance when you reach Phase 2. Build Phase 1 first and deploy it before you start Phase 2.
(b) Any AI in a local folder, then deploy to Liivo.
Create an empty folder, point Claude, ChatGPT, Cursor, or Copilot at it, paste
this prompt, and build. Run it locally with npm run dev. When it works, push
it to a Git repo and deploy that repo through the Liivo connector or the OSC
control panel. Phase 1 needs no services at all.
(c) Claude Code or Codex in a terminal.
mkdir little-day && cd little-day, start your agent, paste this prompt. Build
in the staged build order near the end of this brief. Deploy at the end of
Stage 1, and again at the end of each later stage.
Repo layout: build it as ONE deployable app
The original was two deployables: a static Next.js client and a separate Express API, on two public URLs, wired together with CORS and a hardcoded API base URL. That split had a reason at the time (the client existed first and was a pure static export). You should not repeat it. Build one Next.js app that serves both the UI and the API from the same origin. It is strictly simpler:
- One deployable, one build command, one public URL.
- No CORS configuration at all, since every fetch is same-origin.
- No "inject the API base URL into the client at build time" problem.
- The scheduled AI agent posts to the same origin the app is served from.
Be precise about what "one app" means here. It is a genuine merge into a
single package.json at the root of a single repository, with the API living as
Next.js route handlers under app/api/. It is not a monorepo with a client
workspace and a server workspace. The platform clones the repository and runs
npm install, then npm run build, then npm start, all from the repository
root, with no per-workspace configuration. There is a subPath setting for
deploying one workspace out of a monorepo and you should not use it: combined
with the parameter store it silently loads zero environment variables,
because the start command runs from inside the subdirectory and bypasses the
root-level config fetch, so the app boots with no secrets and fails in a way
that is very hard to diagnose. That is a known platform issue, not a
configuration mistake you can fix. Avoid the situation entirely by keeping one
app per repository.
(Verified against the platform's own deploy guide on 2026-08-19.)
Build it in two phases inside that one app:
- Phase 1, the app itself. Zero backing services. Everything lives in the
browser's
localStorage. Deployable and genuinely useful on its own. - Phase 2, the weekly AI review. Adds PostgreSQL, four API routes, and one scheduled AI agent. Entirely optional. If the reader stops after Phase 1 they have a complete, working app.
The two-repository split is documented at the end as an alternative, in case you are retrofitting an existing static site that is already deployed.
Tech stack
Pin these majors. They are what the original runs.
| Choice | Version | Why it matters |
|---|---|---|
| Next.js, App Router | 14.2.5 | One framework serves the UI and the API routes, which is what collapses two deployables into one. |
| React | 18.3.1 | Paired with Next 14. |
| TypeScript, strict: true | 5.5.3 | The schedule generator is a rules engine over a tagged union of block kinds. Strict typing is what keeps the nine age bands and six block kinds from drifting. |
| Tailwind CSS | 3.4.6 | The whole visual identity is six custom colours and two custom radii. Config, not stylesheets. |
| pg (node-postgres) | 8.x | Phase 2 only. Three tables. An ORM would be more code than the schema. Use raw parameterised SQL. |
| No ORM, no Prisma | | Deliberate. Three tables, six queries. Prisma would also drag in an openssl system dependency and a Dockerfile you otherwise do not need. |
| No auth library | | Single-user app. One shared bearer token. Sessions, OAuth, and NextAuth would all be theatre here. |
| npm | | Package manager. |
These versions are what the original runs, verified working together. They are a starting point, not a target. Later majors of Next, React, Tailwind, and TypeScript are all fine, and so is a different framework entirely if you prefer one. Nothing in this app's logic depends on a version number. The one structural constraint is the one above: the UI and the API want to come out of the same deployable, so a framework that can serve both is worth more here than any particular version of this one.
package.json:
{
"name": "little-day",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3030",
"build": "next build",
"start": "node scripts/migrate.mjs && next start -H 0.0.0.0 -p ${PORT:-3030}",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"audit:schedule": "sucrase-node scripts/audit.ts"
},
"dependencies": {
"next": "14.2.5",
"pg": "^8.13.1",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.12.7",
"@types/pg": "^8.11.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"autoprefixer": "10.4.19",
"postcss": "8.4.39",
"tailwindcss": "3.4.6",
"typescript": "5.5.3"
}
}
If you build Phase 1 only, drop pg, @types/pg, and the node scripts/migrate.mjs &&
prefix from start.
File layout:
app/
layout.tsx root layout, metadata, viewport
page.tsx the single screen, all client state
globals.css Tailwind directives plus 4 base rules
healthz/route.ts GET -> text "ok", no auth
api/
days/route.ts POST
days/[date]/route.ts PATCH
reports/route.ts POST (written by the AI agent)
latest-report/route.ts GET
reports/[id]/respond/route.ts POST
prepass/route.ts GET (read by the AI agent)
components/
Header.tsx AgeBadge.tsx EmptyState.tsx BlockCard.tsx
ScheduleView.tsx SettingsSheet.tsx EditTimeSheet.tsx
ReviewBanner.tsx ReviewSheet.tsx
lib/
types.ts every shared type
time.ts HH:mm and age maths, no Date arithmetic in the generator
storage.ts localStorage for Settings
overrides.ts localStorage for today's edits, with date rollover
sync.ts the four API calls, all best-effort
schedule/
bands.ts the nine age bands, the numbers
generate.ts the generator and the cascade
tips.ts the tip pools and the rotation
sources.ts the bibliography shown in the UI
server/
db.ts pg Pool
auth.ts bearer token check, constant time
prepass.ts stats, verdict, suggested delta
bands-context.ts slim band mirror for the AI prompt
bibliography.ts the citation whitelist and its validator
migrations/001_init.sql
scripts/migrate.mjs
scripts/audit.ts the band times wake-time sweep, your real test
public/healthz literal file containing "ok"
next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: { unoptimized: true },
reactStrictMode: true
};
export default nextConfig;
Do not set output: 'export'. The original did, and that is exactly what
forced the two-app split.
Page and screen inventory
There is one route, /. Everything else is an overlay on it. That is
deliberate: the parent should never navigate.
/ , the day screen
Container: <main className="mx-auto max-w-md min-h-screen">. Always centred,
never wider than 448px, even on a desktop monitor. This is a phone app that
happens to open in a browser.
Vertical order:
- Header (
px-6 pt-8 pb-4).- Small uppercase letter-spaced greeting, computed from the current hour:
< 05:00"Late night",< 12:00"Good morning",< 17:00"Good afternoon",< 21:00"Good evening", else "Good night". text-3xl font-semiboldtitle:"{name}'s day"if a name is set, otherwise"Today's plan".- Top right, a small "Reset today" pill, visible only when at least one manual edit exists for today. Tapping it discards every edit and returns to the generated schedule.
- Small uppercase letter-spaced greeting, computed from the current hour:
- Review banner, only when a weekly report exists and the parent has not
responded to it yet. Soft peach-tinted rounded card,
mx-6 mt-3. Two lines: a tiny uppercase eyebrow ("Week in review . smooth" / "Week in review . consider" / "Week in review") and the report headline clamped to two lines. A right-pointing arrow on the right. Tapping opens the Review sheet. No red dot, no badge, no urgency. - Age badge, a small white translucent pill: a peach dot, then
"{n} weeks old"if under 3 months or"{n.n} months old"otherwise, then a muted dash and the band's short label (for example "5 to 7 months"). - The block list, an ordered list of cards, 12px gap,
px-5 pb-32. One card per schedule block, in time order. See the card anatomy below. - Truncation note, only when the generated day ran past 23:59: "Schedule trimmed at end of day. Try setting an earlier wake-up time."
- Sources footnote, always: a small muted paragraph naming the guidance used and ending with "Every baby is different. Trust what you see in your baby."
- Floating settings button, fixed at
bottom-6 right-6, a 56px white circle with a peach gear glyph and a soft shadow,active:scale-95.
Block card anatomy
A white rounded-card (24px) card with a soft shadow, px-5 py-4.
- Left: a 40px
rounded-chip(16px) tinted square holding an emoji glyph. - Middle:
- tiny uppercase tracked label in the kind's accent colour, which is the block's own label text ("Wake time", "First nap", "Evening feed (cluster feeds are normal)", "Bedtime 19:00", and so on),
text-2xl font-semiboldtime, shown asHH:MM - HH:MMwhen the block has an end, or justHH:MMwhen it is a point in time (every feed, the bedtime, the night-cycle marker, the settling marker),- immediately after the time, a small filled dot in the kind's accent colour
only on blocks the parent edited directly, never on blocks that merely
moved as a consequence.
aria-label="Manually edited". - optional inline note in small muted text, at most one of:
- on an overlong wake block: "Wake window longer than usual, overtired risk."
- on a bedtime or settling block that was pulled earlier than target: "Earlier than usual, early wake-up, this protects against overtiredness."
- Right: a 32px pencil button, tinted like the icon square. Only the pencil opens the editor. Tapping the card body does nothing. That was a deliberate fix: a whole-card tap target made the list feel like it was full of accidental traps.
- Below, when the block carries a tip: a cream-tinted
rounded-chipstrip with a coloured bold prefix,Try:for activity tips (wake blocks) orSoothe:for soothing tips (naps, bedtime, settling, night cycle), then the tip text.
Kind to visual mapping:
| kind | tint | accent | glyph | has end time |
|---|---|---|---|---|
| wake | peach soft | peach | sun | yes |
| feed | sage soft | sage | bottle | no |
| nap | sky soft | sky | cloud | yes |
| bed | lavender soft | lavender | crescent moon | no |
| settling | lavender soft | lavender | crescent moon | no |
| night-cycle | lavender soft | lavender | crescent moon | no |
States of the day screen
- Pre-hydration: render only
<div className="min-h-screen" />. Nothing else.localStorageis not readable during server render, so anything else causes a visible flash or a hydration mismatch. - Empty (no settings saved): a centred welcome block filling 80vh. A 64px
peach-soft rounded square with a sun glyph,
text-2xl"Welcome", then "Tell us baby's birth date and today's wake-up time. We'll show a calm, age-appropriate plan for the day.", then a peach "Set up baby" pill button. The settings sheet also opens automatically on first load when no settings exist, so the empty state is normally seen behind the sheet. - Ready: header, optional banner, age badge, card list.
- Loading: there is none, and there should not be. The schedule is computed
synchronously in a
useMemo. Never show a spinner. - Error: there is none in the UI. Every network call is best-effort and
silently returns null on failure. A corrupt
localStoragevalue is dropped and treated as absent. The app must never show the parent a stack trace or a toast about a failed fetch. - Offline: fully functional. The generator, the editing, and the settings are all local. Only the weekly review needs the network, and its absence just means no banner.
Overlay 1: Settings sheet
Bottom sheet on mobile (rounded-t-card, full width, sitting on the bottom
edge), centred dialog on sm and up (rounded-card, max-w-md). Dimmed
bg-ink/30 backdrop that closes on tap. A small grab handle bar, hidden at
sm and up. role="dialog" aria-modal="true".
Title "Baby settings", subtitle "Just a few things. The schedule does the rest."
Fields, each a cream-filled rounded-chip input with a peach focus ring:
- Birth date,
<input type="date">,maxis today. Compute the default at runtime as "today minus 6 months", so a fresh install shows a full, interesting day. Never ship a fixed calendar date as the default: in a real codebase that is a real child's birth date sitting in a public repository, and it silently ages out of its band as time passes. - Today's wake-up,
<input type="time" lang="en-GB">(thelangforces 24-hour entry on browsers that honour it). Default07:00. - Baby's name (optional),
<input type="text">, placeholder "e.g. Mia". - Target bedtime, conditional and reactive to the birth date field:
- If the age band derived from the currently-typed birth date has
bedtimeMode: 'none'(under about 12 weeks), do not show an input. Show a cream info box instead: "Bedtime emerges around 12 to 16 weeks. Until then, newborns don't have a fixed bedtime, so this isn't shown." - Otherwise show a time input pre-filled with the parent's override if set, else the band's research default. Below it: "Default for this age is {HH:MM}. Adjust to match your family's rhythm."
- When an override differs from the band default, show a small peach text button on the same line as the label: "Reset to age default ({HH:MM})", which clears the override.
- If the age band derived from the currently-typed birth date has
Footer: "Cancel" (muted, bg-ink/5) and "Save" (peach, white text), equal width.
On save: persist to localStorage. If today's wake-up time changed, wipe
every manual edit for today before saving, because the edit indices are
positions in the generated array and the array is about to change shape.
Only persist the bedtime override when the band actually has a bedtime and the
value matches ^\d{2}:\d{2}$.
Overlay 2: Edit time sheet
Same sheet chrome. Title "Change start time", subtitle "{block label}, currently {HH:MM}".
One <input type="time" lang="en-GB">, autofocused, pre-filled with the block's
current start.
Validation, live, on every keystroke:
- not matching
^\d{2}:\d{2}$gives "Pick a valid time", - earlier than the previous block's start gives "Must be at or after the previous block ({HH:MM})".
When valid and a previous block exists, show a muted hint instead:
"Previous block starts at {HH:MM}." Errors render with role="alert". Save is
disabled (40 percent opacity, cursor-not-allowed) while invalid.
Footer: "Cancel" and "Save".
Overlay 3: Review sheet
Same sheet chrome, plus max-h-[90vh] overflow-y-auto because the content can
be long.
Contents top to bottom:
- Tiny uppercase muted eyebrow: "Week ending Sun 28 May", from the report's
week_ending, formatted weekday-day-month with no year. text-2xl font-semiboldheadline.- The summary paragraph,
whitespace-pre-lineso the model's line breaks survive. - A cream
rounded-cardbox: eyebrow "My honest take", then the honest recommendation. - Only when the verdict is
consider_tweakand a tweak object exists: a peach-bordered, peach-tinted box, eyebrow "Suggested tweak", then "Shift bedtime by +10 min to 19:10." with the sign always shown for positive deltas. - Only when citations exist: eyebrow "Based on", then a bulleted list, each
line the claim in near-black followed by a muted dash and a humanised
source name. The AI emits stable ids; map them to short readable labels on
the client (
cleveland-clinic-wake-windowsbecomes "Cleveland Clinic",pmc12371910becomes "PMC review (circadian)", and so on). Fall back to the raw id if unmapped. - Buttons, branching on verdict:
smooth_weekorstay_the_course: one full-width peach "Got it".consider_tweakwith a tweak: "Stick with current" (muted) and "Accept tweak" (peach), equal width.- While a response request is in flight, the pressed button's label becomes "Saving..." and all buttons disable, so a double tap cannot fire twice. The sheet closes only after the response resolves.
Accepting a tweak writes the new bedtime override to localStorage
before recording the response, so the schedule visibly changes the instant
the sheet closes. Declining records the response and changes nothing. Either
way the banner disappears and does not come back for that report.
Complete feature list
Setup and settings
- First-run auto-opens the settings sheet.
- Birth date, today's wake-up, optional baby name, optional target bedtime override.
- Birth date is capped at today.
- The target-bedtime field appears or disappears live as the typed birth date crosses the 12 week boundary, before saving.
- "Reset to age default" clears the bedtime override, shown only when it differs.
- All settings persist in
localStorageunder one key. - A corrupt or partial stored value is treated as absent, never crashes.
- An invalid stored bedtime override is silently dropped on read.
Schedule generation
- Whole day derived from two inputs only: age in months and today's wake-up time.
- Nine age bands from newborn to 18 months plus, selected by age in months.
- Wake window used is the midpoint of the band's min and max, rounded.
- Nap length is the band's typical nap length.
- A feed is placed at wake-up and again at the end of every nap.
- Three age-conditional endings to the day: no bedtime, soft settling, firm bedtime.
- Newborn evening stretches into a labelled cluster-feed window so the night cycle never starts absurdly early.
- Bedtime lands on the target when it is reachable, is pulled earlier when it is not, and falls back to a fixed offset after a late wake-up.
- Overtired warning on any wake block longer than the band's ceiling, with a separate and much larger ceiling for the final pre-bedtime stretch.
- Truncation flag and a plain-language note when the day would run past 23:59.
- One activity tip per wake block, one soothing tip per nap and per bedtime.
- Tips rotate deterministically by day of the year, so the day is stable if you reload it but different tomorrow.
- Dedicated tip pools for the newborn cluster-feed evening and the night-cycle marker, separate from nap soothing.
Editing today
- Every card has a pencil. Editing is always available, no edit mode toggle.
- Editing one block's start stretches or shrinks the previous block to meet it, then shifts every later block by the same delta, preserving their durations.
- Multiple edits apply in the order they were made; earlier edits stay locked.
- Bedtime protection: if the cascade pushes bedtime past the target, the app shrinks the last nap first (to a 30 minute floor), then the wind-down (to a 30 minute floor), to claw the time back. It never deletes a nap.
- An explicitly edited block is immutable: bedtime protection stops shifting at the first edited block it meets.
- If the parent edits the bedtime card itself, that becomes the anchor and bedtime protection is skipped entirely.
- Edited blocks are marked with a small coloured dot. Cascaded blocks are not.
- The overtired flags are recomputed after every cascade, not just on generation.
- "Reset today" appears in the header only when edits exist and clears them all.
- Edits are stored under today's local date and wiped automatically on calendar rollover.
- Changing today's wake-up time clears all edits.
- Validation prevents moving a block before the previous block's start.
Weekly AI review (Phase 2)
- Each day, the app snapshots the day's premise and generated blocks to the API on open, and again whenever the premise changes.
- Each edit sends a debounced update 500 ms later, coalescing rapid edits.
- "Reset today" sends its update immediately, without debouncing.
- Pending updates are flushed on tab hide and on unload, so the last edit is never stranded.
- The app fetches the latest report once per load.
- A banner appears only for a report with no recorded response.
- The review shows a headline, a summary, an honest recommendation, an optional bounded bedtime tweak, and the sources it cited.
- Accepting a tweak writes the new bedtime override locally and records the acceptance. Declining just records it.
- The verdict is computed in code from the week's statistics. The AI narrates it and cannot override it.
- The AI may only cite from a fixed whitelist of seven sources. The API validates every citation on write and demotes the verdict if any is invalid.
- The only action the AI can propose is a bedtime shift of exactly 10 or 15 minutes in either direction. Any other shape is rejected server-side.
- The review is skipped, cheaply and silently, when a report already exists for the week or when fewer than 5 days of data exist.
Small things that matter
- Everything is 18px base font size. This app is read by tired eyes at arm's length in a dark room.
-webkit-tap-highlight-color: transparentglobally, so taps do not flash blue.- All date and time inputs are forced to 18px so iOS Safari does not zoom on focus.
maximum-scale=1in the viewport so the layout cannot be pinch-broken.- Buttons use
active:scale-95for physical feedback, no hover-only affordances. - Every icon glyph is
aria-hidden; every icon button has anaria-label. - Both sheets are
role="dialog" aria-modal="true"with a labelled close backdrop. theme-coloris set to the cream background so the phone's status bar matches.- No sounds, no animations beyond the press scale and the sheet appearing.
- No keyboard shortcuts. This is a touch app.
- No dark mode. See the look and feel section for why.
Data model
Client-side types (lib/types.ts)
export type AgeBandId =
| 'newborn' | 'young-infant' | 'transition' | 'older-infant'
| 'half-year' | 'pre-crawler' | 'one-year' | 'toddler-2nap' | 'one-nap';
// How the day ends, conditional on circadian-rhythm development.
// 'none' 0 to ~12 weeks. No bedtime at all. Evening feeds plus a
// "night cycle begins" marker.
// 'soft' ~12 to ~16 weeks. Rhythm emerging. "Settling for the night".
// 'bedtime' ~16 weeks and up. A firm "Bedtime HH:MM" anchor.
export type BedtimeMode = 'none' | 'soft' | 'bedtime';
export interface AgeBand {
id: AgeBandId;
label: string; // "5 to 7 months"
shortLabel: string; // "5 to 7 months" or "18 mo +"
minMonths: number; // inclusive
maxMonths: number; // exclusive, Infinity on the last band
wakeWindowMin: number; // minutes
wakeWindowMax: number; // minutes, also the overtired ceiling for daytime wake
napCount: number;
typicalNapMin: number; // minutes
totalDaySleepHours: [number, number]; // reference only, used by the AI prompt
total24hSleepHours: [number, number]; // reference only, used by the AI prompt
feedIntervalMin: number; // reference only, the generator does not use it
bedtimeAfterLastWake: number; // minutes, fallback offset after a late wake-up
windDownMax: number; // minutes, the ceiling for the final pre-bedtime wake
bedtimeMode: BedtimeMode;
targetBedtime?: string; // "19:00", only for 'soft' and 'bedtime'
nightCycleEarliest?: string; // "21:00", soft floor, only for 'none'
}
export type BlockKind = 'wake' | 'feed' | 'nap' | 'bed' | 'night-cycle' | 'settling';
export interface ScheduleBlock {
kind: BlockKind;
start: string; // "07:00"
end?: string; // absent on feed, bed, settling, night-cycle
label: string;
activityTip?: string;
soothingTip?: string;
overtired?: boolean; // wake block longer than its ceiling
eveningCluster?: boolean; // the stretched newborn evening, exempt from the check
windDown?: boolean; // the post-last-nap wake, uses windDownMax
earlierThanTarget?: boolean; // bedtime was pulled earlier to protect sleep
}
export interface Settings {
birthDateISO: string; // "YYYY-MM-DD"
todayWakeTime: string; // "HH:mm"
babyName?: string;
userBedtime?: string; // "HH:mm", only honoured when the band has a bedtime
}
export interface DaySchedule {
ageBand: AgeBand;
ageWeeks: number;
ageMonths: number; // rounded to 1 decimal
blocks: ScheduleBlock[];
truncated: boolean;
}
// One edit pins one generated block's start. blockIndex is the position in
// DaySchedule.blocks at the moment the edit was made, which stays valid only
// while the day's premise is unchanged.
export interface DayOverrideEdit { blockIndex: number; newStart: string; }
export interface DayOverrides { date: string; edits: DayOverrideEdit[]; }
localStorage keys
| Key | Shape | Lifetime |
|---|---|---|
| baby-schedule:settings | Settings | Until cleared by the user |
| baby-schedule:overrides | DayOverrides | Wiped on calendar rollover and on wake-time change |
| baby-schedule:auth-token | raw token string | Phase 2 only, until cleared |
PostgreSQL schema (Phase 2 only)
Three tables. Every statement idempotent, because the migration runner replays every file on every boot.
CREATE TABLE IF NOT EXISTS days (
date DATE PRIMARY KEY,
age_weeks INTEGER NOT NULL,
age_band_id TEXT NOT NULL,
wake_time TEXT NOT NULL,
user_bedtime TEXT,
baseline_blocks JSONB NOT NULL,
final_blocks JSONB NOT NULL,
edits JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS weekly_reports (
id SERIAL PRIMARY KEY,
week_ending DATE NOT NULL UNIQUE,
verdict TEXT NOT NULL
CHECK (verdict IN ('smooth_week','stay_the_course','consider_tweak')),
headline TEXT NOT NULL,
summary TEXT NOT NULL,
honest_recommendation TEXT NOT NULL,
tweak JSONB,
citations JSONB NOT NULL DEFAULT '[]'::jsonb,
stats JSONB NOT NULL,
raw_llm_response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS report_responses (
report_id INTEGER PRIMARY KEY REFERENCES weekly_reports(id) ON DELETE CASCADE,
action TEXT NOT NULL CHECK (action IN ('accepted','declined','ignored')),
responded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS days_created_idx ON days(created_at DESC);
CREATE INDEX IF NOT EXISTS weekly_reports_week_idx ON weekly_reports(week_ending DESC);
Notes on the schema:
days.dateis the primary key. One row per calendar day. There is no user column, because there is one user.baseline_blocksis the generated day,final_blocksis the day after the parent's edits. The difference is the entire signal the weekly review reads.- Blocks are stored slimmed to four fields each (
kind,start,end,label). Do not store the UI-only flags. week_endingisUNIQUE, which is what makes a duplicate report write return a clean 409 instead of a second row.report_responses.actionallows'ignored', but nothing writes it. It exists so a future sweep can mark unanswered reports without a migration.
Data privacy, stated honestly
This app holds data about a child. Be straight with whoever uses it.
Phase 1 (no backend) stores nothing anywhere but the browser. The birth
date, the baby's name, today's wake-up, the bedtime override, and today's edits
all live in that one browser's localStorage. Nothing leaves the device.
Clearing site data deletes all of it. There is no export and no backup. If the
parent switches phones, they retype three fields.
Phase 2 sends a deliberately reduced record to the server. What is written to the database is exactly:
- the calendar date,
- the child's age in whole weeks and the age band id,
- today's wake-up time and the bedtime override,
- the generated blocks and the edited blocks, each block reduced to kind, start, end, and label,
- the list of edits.
What is not sent: the child's name and the child's birth date. Age in weeks is a coarser fact than a date of birth and is what the review needs. Keep it that way. The agent playbook also carries an explicit instruction not to put a name or a birth date in its output.
Who can read it. One shared bearer token guards every endpoint. Anyone holding that token can read and write every day and every report, because the schema has no notion of separate users. This is a single-family app and the auth model matches: there is no per-user isolation to be had, and adding accounts would not make the data more private, it would just add a password to lose. Be explicit with your reader:
- Treat the token like the key to the data. One token, full access.
- Generate it as at least 32 bytes of hex:
openssl rand -hex 32. - Comparison is constant-time on the server so the token cannot be guessed a byte at a time.
- The token reaches a new device once via
?setupToken=<token>in the URL, which is then removed from the address bar. That one request can still land in an access log or a referrer header. Acceptable for onboarding your own phone. Not acceptable if you ever make the app public or multi-family. - Rotate the token by changing the platform parameter and re-onboarding each device. There is no revocation list.
The AI agent sees the same reduced record. It reads seven days of blocks, edits, wake times, ages in weeks, and band ids. It never sees a name or a birth date. If you route it through a third-party model provider, that reduced record is what leaves your infrastructure. Say so out loud to whoever uses the app, and if that is not acceptable, build Phase 1 only. Phase 1 is a complete app.
No analytics, no tracking pixels, no third-party scripts. Keep it that way.
API contract (Phase 2)
Base path /api. Every endpoint requires Authorization: Bearer <token>
except GET /healthz. All bodies are JSON. Compare the token with a
constant-time comparison and return 401 {"error":"unauthorized"} on any
mismatch, including a length mismatch. If the server has no token configured at
all, return 500 {"error":"AUTH_TOKEN not configured"} rather than allowing the
request through.
Request body size limits: 64 KB on the day endpoints, 32 KB on report creation, 4 KB on the response endpoint.
GET /healthz
No auth. Returns 200 with the body ok as plain text. Also ship a literal
public/healthz file containing ok so the path answers even before the app's
routes are warm.
POST /api/days
Idempotent baseline snapshot for one date. Called by the app on first open each day and whenever the day's premise changes.
// request
{
"date": "2026-05-28", // required, ^\d{4}-\d{2}-\d{2}$
"ageWeeks": 12, // required, number
"ageBandId": "transition", // required, string
"wakeTime": "07:00",
"userBedtime": "19:30", // or null
"baselineBlocks": [ { "kind": "feed", "start": "07:00", "label": "Feed" } ],
"finalBlocks": [ { "kind": "feed", "start": "07:00", "label": "Feed" } ],
"edits": [ { "blockIndex": 5, "newStart": "13:10" } ]
}
// 200
{ "ok": true }
Upsert on date. On conflict, replace every column including edits. That
reset is intentional: it mirrors the client wiping its overrides when the
premise changes.
Errors: 400 {"error":"invalid date"}, 400 {"error":"missing age fields"},
500 {"error":"db error"}.
PATCH /api/days/:date
Debounced update after each edit. Replaces edits and final_blocks wholesale.
Last write wins.
// request
{
"edits": [ { "blockIndex": 5, "newStart": "13:10" } ],
"finalBlocks": [ /* slimmed blocks */ ],
"userBedtime": "19:30" // or null, null leaves the stored value alone
}
// 200
{ "ok": true }
Errors: 400 {"error":"invalid date"},
404 {"error":"no row for date, POST baseline first"}, 500 {"error":"db error"}.
user_bedtime uses COALESCE($3, user_bedtime) so a null in the request does
not erase a stored override.
GET /api/latest-report
Returns the newest report by week_ending, left-joined with its response.
// 200 with data
{ "report": {
"id": 3,
"week_ending": "2026-05-24",
"verdict": "consider_tweak",
"headline": "Bedtime crept later four nights running",
"summary": "You moved bedtime later on four of the five days...",
"honest_recommendation": "I would try the 10 minute shift for a week.",
"tweak": { "type": "shiftBedtime", "deltaMinutes": 10, "newUserBedtime": "19:10" },
"citations": [ { "claim": "Wake windows at this age run 120 to 150 minutes",
"source": "cleveland-clinic-wake-windows" } ],
"created_at": "2026-05-25T04:00:12.000Z",
"response_action": null, // or "accepted" | "declined" | "ignored"
"responded_at": null
} }
// 200 with nothing to show
{ "report": null }
POST /api/reports/:id/respond
Records the parent's answer.
// request
{ "action": "accepted" } // or "declined"
// 200
{ "ok": true }
Upsert on report_id, so re-answering overwrites. Errors:
400 {"error":"invalid id"},
400 {"error":"action must be accepted or declined"},
404 {"error":"report not found"} (raised from a foreign key violation).
GET /api/prepass
Read by the AI agent, not by the app. One call returns everything the agent
needs. Optional query parameter weekEnding=YYYY-MM-DD, defaulting to the most
recent Sunday in UTC. weekStart is weekEnding minus 6 days.
Short-circuits first:
{ "skip": "already_generated", "weekEnding": "2026-05-24" }
{ "skip": "insufficient_data", "weekEnding": "2026-05-24",
"daysObserved": 3, "minimumRequired": 5 }
Otherwise:
{
"skip": null,
"weekEnding": "2026-05-24",
"weekStart": "2026-05-18",
"stats": {
"daysObserved": 7,
"daysWithAnyEdit": 5,
"meanBedtimeShiftMin": 18,
"maxConsecutiveSameSignDriftDays": 4,
"driftDirection": "later", // "later" | "earlier" | "none"
"mixedBandsThisWeek": false,
"userBedtimeChangedMidWeek": false,
"bandsObserved": ["half-year"]
},
"verdict": "consider_tweak",
"suggestedDelta": 10,
"bandContext": "half-year (5 to 7 months):\n wakeWindow 120-150 min\n ...",
"bibliography": [ { "id": "...", "title": "...", "note": "..." } ],
"days": [ { "date": "...", "age_weeks": 24, "age_band_id": "half-year",
"wake_time": "07:00", "user_bedtime": null,
"baseline_blocks": [], "final_blocks": [], "edits": [] } ]
}
The verdict is computed here, in code, before any model is involved. That is the point of this endpoint.
POST /api/reports
Written by the AI agent, not by the app. This is the endpoint that enforces honesty, and it enforces it regardless of what the model produced.
// request
{
"weekEnding": "2026-05-24", // required, ^\d{4}-\d{2}-\d{2}$
"verdict": "consider_tweak", // required, one of the three
"headline": "...", // required string
"summary": "...", // required string
"honestRecommendation": "...", // required string
"tweak": { "type": "shiftBedtime", "deltaMinutes": 10, "newUserBedtime": "19:10" },
"citations": [ { "claim": "...", "source": "cleveland-clinic-wake-windows" } ],
"stats": { /* echo the stats object from prepass, required */ },
"rawLlmResponse": { } // optional, stored for debugging
}
// 200
{ "id": 3, "verdict": "consider_tweak" } // verdict is the POST-VALIDATION verdict
Server-side guards, applied in this order:
- Citation whitelist. If the verdict is not already
smooth_weekand anycitations[].sourceis not in the whitelist, or any entry is missing a stringclaimorsource, then demote: set verdict tosmooth_week, set tweak to null, empty the citations. Do not error. Ship the honest, unactionable version instead of the confident, unsupported one. - Tweak shape. If the verdict is not
consider_tweak, force tweak to null. If it isconsider_tweak, requiretype === 'shiftBedtime',deltaMinutesin exactly{-15, -10, 10, 15}, andnewUserBedtimematching^\d{2}:\d{2}$. Anything else demotes the verdict tostay_the_coursewith a null tweak. - Consistency. If the verdict ended up
consider_tweakwith no tweak, demote tostay_the_course.
Errors: 400 with a specific message per missing or invalid field,
409 {"error":"report already exists for that week"} on the unique violation
(PostgreSQL error code 23505), 500 {"error":"db error"}.
Note that the response echoes the post-validation verdict, which may differ from what was submitted. That is the agent's feedback channel: if it got demoted, it can see so.
The citation whitelist
Seven entries, each with a stable id that the model must reproduce verbatim.
Store the whitelist in one module and export both a prompt-formatting function
and a validator.
| id | title |
|---|---|
| cleveland-clinic-wake-windows | Cleveland Clinic, Wake Windows by Age |
| taking-cara-babies | Taking Cara Babies, Wake Windows and Baby Sleep |
| huckleberry-fyos | Huckleberry, First Year of Sleep Expectations |
| aap-aasm-consensus | AAP / American Academy of Sleep Medicine pediatric sleep consensus |
| sleep-foundation | Sleep Foundation, newborn sleep schedule and the 4 month regression |
| pmc12371910 | PMC narrative review on infant circadian-rhythm establishment |
| happiest-baby | Happiest Baby (Dr. Karp), the 5 S's |
Each entry also carries a note telling the model what the source may be used
for, for example that Taking Cara Babies is the source for the long
pre-bedtime wake window and that Happiest Baby is only a soothing-language
source and never a scheduling source. Inject id, title, and note into the
prompt.
const KNOWN_IDS = new Set(BIBLIOGRAPHY.map(b => b.id));
export function citationsAreValid(citations: unknown): boolean {
if (!Array.isArray(citations)) return false;
return citations.every(c =>
c && typeof c.claim === 'string' && typeof c.source === 'string'
&& KNOWN_IDS.has(c.source));
}
The AI agent integration path (read this part carefully)
This is the most transferable piece of the whole app, so build it exactly this way. No LLM is called from inside the app. The app has no model API key, no model SDK, and no outbound AI dependency. Instead, a scheduled external AI agent is a client of your API: it reads one endpoint, thinks, and writes one row.
Mondays 04:00 UTC
|
v
+-------------------+ GET /api/prepass +----------------------+
| scheduled AI | --------------------> | your app's API |
| agent task | | (verdict computed |
| (platform-run, | <-------------------- | here, in code) |
| any model) | one JSON bundle +----------------------+
+-------------------+ ^
| |
| POST /api/reports (strict shape) |
+----------------------------------------------+
|
app opens -> GET /api/latest-report
Why this shape
- The app never holds a model API key, so a compromised app cannot spend money.
- The model is swappable without touching the app. Change the agent, not the code.
- The agent has no database access at all. HTTP only, one read shape, one write shape. Its blast radius is one validated row per week.
- The judgement is deterministic and lives in your code. The model only writes prose. That means the feature cannot become confidently wrong about what happened, only about how to phrase it.
The single most important platform gotcha
An agent-task container does not receive the parameter-store environment
variables that a deployed web app receives. An agent instructed to read
AUTH_TOKEN from the environment will spend its entire run hunting for a
variable that is not there, then fail. The token must be pasted inline in the
agent's invocation prompt, and the playbook must say so explicitly, in those
words, or the agent will search the environment anyway out of habit.
This was learned by losing a scheduled run to it. Do not rediscover it.
Creating the agent task
Through the Liivo or OSC MCP connector, ask the platform to create a scheduled agent task:
- Schedule: weekly, Mondays 04:00 UTC.
- Instruction body: the playbook below, with the two placeholders filled in.
- No other tools or bindings needed. It only makes HTTP calls.
verify this: the exact tool name and argument shape for creating a scheduled
agent task belong to the platform and change independently of this app. Ask the
connector what it offers rather than assuming a tool name. On some setups
scheduled tasks must be created through the web control panel rather than
through the connector, and cron expressions may be interpreted in a different
timezone than you expect, so check the first run's timestamp.
If you would rather not use a platform agent at all, any scheduler works: a GitHub Actions cron job, a cloud scheduler hitting a small script, or a person running the two curl calls by hand once a week. The API does not care who calls it.
The agent playbook
Paste this as the agent's instruction body. Replace YOUR_API_BASE_URL_HERE and
YOUR_AUTH_TOKEN_HERE with real values. Keep the token inline.
# Weekly sleep-review agent task
You are the weekly sleep-review agent for a baby-schedule app. You run once per
week (Mondays 04:00 UTC) and produce one structured JSON report summarising the
parent's planning behaviour over the trailing 7 days.
You have two HTTP endpoints. Both require `Authorization: Bearer <token>`.
Use curl or any HTTP client your tools allow.
```
BASE_URL = YOUR_API_BASE_URL_HERE
AUTH_TOKEN = YOUR_AUTH_TOKEN_HERE
```
**Where the AUTH_TOKEN comes from:** the value is provided inline above, in this
prompt. Read it from here. Do NOT look in environment variables, files, or
session state. The platform's config binding injects environment variables into
deployed web apps only, not into agent-task containers.
## 1. Fetch the prepass bundle
GET $BASE_URL/api/prepass
Handle the two short-circuit cases first:
- `skip: "already_generated"` then exit successfully. Do nothing.
- `skip: "insufficient_data"` then exit successfully. Do nothing.
Otherwise you receive: skip, weekEnding, weekStart, stats, verdict,
suggestedDelta, bandContext, bibliography, days.
## 2. Apply the rules
These rules are not advisory. The backend re-validates them on POST and demotes
your verdict if you break them.
1. The verdict has been computed deterministically. You do not override it.
Narrate it in plain language. A `smooth_week` verdict means the week was
quiet. Note the consistency briefly and do not invent a problem.
2. Citations may only reference ids from the `bibliography` array. Use the id
verbatim in `citations[].source`. Never invent a study, a doctor, or an
organisation. If you cannot support a claim from the bibliography, drop it.
3. Numeric claims about wake windows, sleep totals, or nap counts must match
the values in `bandContext`. Do not pull ranges from memory.
4. Missing days are unknown, not good. `stats.daysObserved` may be under 7. Do
not infer that missing days went well. Say nothing about them.
5. Plain language. Short sentences. No jargon. You are speaking to a parent who
has slept four hours.
6. No moralising. No "great job". No "you should always". Observations,
evidence, one honest recommendation.
7. `summary` is 2 to 4 short sentences. `headline` is one short line.
8. `honestRecommendation` is required and always present. It is your real
opinion, even when the verdict is `consider_tweak`. You may recommend
sticking with the current plan while still proposing the tweak, if your
honest read is that the drift looks like a one-off.
9. If verdict is `smooth_week`: tweak MUST be null, citations may be empty,
tone is reassuring and brief.
10. If verdict is `stay_the_course`: tweak MUST be null, citations required,
explain what you observed and why staying is right.
11. If verdict is `consider_tweak`: tweak MUST be non-null,
`tweak.type = "shiftBedtime"`, `tweak.deltaMinutes` MUST equal
`suggestedDelta`, and `tweak.newUserBedtime` is the current `user_bedtime`
(or the band default if none) shifted by that amount. Citations required.
12. If `stats.mixedBandsThisWeek` is true, acknowledge the age-band transition
in the summary. Do not average numeric ranges across bands.
## 3. POST the result
POST $BASE_URL/api/reports with Content-Type: application/json and the bearer
token, body:
{
"weekEnding": "<from prepass>",
"verdict": "<from prepass>",
"headline": "...",
"summary": "...",
"honestRecommendation": "...",
"tweak": null | { "type": "shiftBedtime", "deltaMinutes": -15|-10|10|15,
"newUserBedtime": "HH:MM" },
"citations": [ { "claim": "...", "source": "<bibliography id>" } ],
"stats": <echo back the stats object from prepass>
}
200 with { "id": <number>, "verdict": "..." } confirms the report is saved.
409 means a report already exists for that week. Exit successfully.
400 means the body shape is wrong. Fix it and retry ONCE. Never retry beyond once.
## What you do NOT do
- You do not write to the database directly. No SQL.
- You do not modify the repository.
- You do not call any third-party APIs.
- You do not produce more than one report per invocation.
- You do not log the child's name or birth date in your output.
Testing the agent path without an agent
Do this before you schedule anything.
# 1. Is there anything to review?
curl -s -H "Authorization: Bearer YOUR_AUTH_TOKEN_HERE" \
https://YOUR_APP_URL_HERE/api/prepass | head -c 2000
# 2. Force the honesty backstop: submit a fake source and watch it demote.
curl -s -X POST https://YOUR_APP_URL_HERE/api/reports \
-H "Authorization: Bearer YOUR_AUTH_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"weekEnding":"2026-05-24","verdict":"consider_tweak",
"headline":"x","summary":"x","honestRecommendation":"x",
"tweak":{"type":"shiftBedtime","deltaMinutes":10,"newUserBedtime":"19:10"},
"citations":[{"claim":"x","source":"dr-invented-institute"}],
"stats":{"daysObserved":7}}'
# expect: {"id":<n>,"verdict":"smooth_week"} <- demoted, tweak stripped
# 3. Repeat the same call and confirm idempotency.
# expect: 409 {"error":"report already exists for that week"}
If step 2 returns consider_tweak, your whitelist validation is not wired up.
Stop and fix it before going further. That check is the whole safety story.
Business logic, with the real numbers
This is the whole value of the app, and it is the one part of this document that is not yours to freely tune. Not because the code breaks if you change a number, it does not, but because the output of this app is advice about somebody's baby. These figures are midpoints of published pediatric guidance, sourced below. Keep them, or replace them with figures from a reputable source you can name in the app. Do not invent your own and do not nudge them until the generated day looks tidier. See the "must be right" group in the fidelity section near the top of this document.
The nine age bands
minMonths is inclusive, maxMonths is exclusive, the last band is Infinity.
Band selection is a linear scan on age in months. All durations are minutes.
| id | label | months | wake window min/max | naps | nap len | day sleep h | 24h sleep h | feed interval | bedtime after last wake | windDownMax | bedtime mode | target | night floor |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| newborn | 0 to 6 weeks | 0 to 1.5 | 45 / 60 | 4 | 90 | 6 to 8 | 14 to 17 | 150 | 50 | 75 | none | | 21:00 |
| young-infant | 6 to 12 weeks | 1.5 to 2.75 | 60 / 90 | 4 | 75 | 4 to 6 | 14 to 16 | 150 | 70 | 120 | none | | 21:00 |
| transition | 12 to 16 weeks | 2.75 to 4 | 75 / 105 | 4 | 75 | 3.5 to 5 | 13 to 16 | 165 | 90 | 150 | soft | 19:30 | |
| older-infant | 4 to 5 months | 4 to 5 | 90 / 120 | 3 | 75 | 3 to 5 | 12 to 16 | 180 | 100 | 180 | bedtime | 19:00 | |
| half-year | 5 to 7 months | 5 to 7 | 120 / 150 | 3 | 60 | 3 to 4 | 12 to 15 | 210 | 120 | 240 | bedtime | 19:00 | |
| pre-crawler | 7 to 10 months | 7 to 10 | 150 / 180 | 2 | 75 | 2.5 to 3.5 | 12 to 15 | 210 | 150 | 300 | bedtime | 19:00 | |
| one-year | 10 to 14 months | 10 to 14 | 180 / 240 | 2 | 60 | 2 to 3 | 11 to 14 | 240 | 180 | 300 | bedtime | 19:30 | |
| toddler-2nap | 14 to 18 months | 14 to 18 | 240 / 300 | 1 | 90 | 2 to 2.5 | 11 to 14 | 240 | 240 | 360 | bedtime | 19:30 | |
| one-nap | 18 months and up | 18 to Infinity | 300 / 360 | 1 | 90 | 1 to 2.5 | 11 to 14 | 240 | 300 | 420 | bedtime | 19:30 | |
Short labels for the age badge: "Newborn", "Young infant", "12 to 16 weeks", "4 to 5 months", "5 to 7 months", "7 to 10 months", "10 to 14 months", "14 to 18 months", "18 mo +".
Two honest notes to carry over:
- The id
toddler-2napsays two naps butnapCountis 1. The id was chosen before the nap-transition numbers were corrected and is kept for storage stability, because it is written into stored rows. Keep the id, keep the 1. feedIntervalMinis present on every band and is read by nothing. Feeds are placed at wake-up and at every nap end, never on an interval. Keep the field (the AI band context can reference it) but do not wire it into the generator.
Where the numbers come from
Wake windows, nap counts, and sleep totals are midpoints of published guidance: Cleveland Clinic's wake windows by age, Taking Cara Babies, Huckleberry's first year of sleep expectations, and the AAP / AASM pediatric sleep consensus.
Bedtime mode is grounded in circadian development: the cortisol rhythm emerges around 8 weeks, melatonin around 9 weeks, and sleep and wake consolidate into a 24 hour rhythm at 3 to 4 months, which is 12 to 16 weeks. Before roughly 8 to 12 weeks an "early bedtime" does not form at all and the longest stretch often starts at 21:00 to 22:00 or later, so showing a bedtime then is actively misleading. From 4 months, typical bedtimes run 18:30 to 20:00; at 6 months 19:00 to 20:00; at 12 months 19:00 to 20:30.
windDownMax comes from Taking Cara Babies' observation that the longest wake
window of the day sits between the last nap and bedtime, with roughly 5 to 6
hours before the nap and 4 to 5 hours after it for 5 to 24 months. 240 minutes
is that 4 hour upper bound at half-year, and the number lengthens with age.
nightCycleEarliest of 21:00 comes from Huckleberry's 2 month sample showing
bedtime near 21:30 and the Sleep Foundation's position that there is no fixed
schedule under 2 months.
Surface all of this in the app footnote and in a sources list. The app must say that these are guidelines, not medical advice, and that every baby is different.
Time handling
Work in minutes from midnight, never in Date objects, inside the generator.
const DAY_END_MIN = 23 * 60 + 59; // 1439
toMinutes("07:30") // 450, clamps hours to 0..23 and minutes to 0..59
fromMinutes(450) // "07:30", clamps the result to 00:00..23:59
addMinutes("07:30", 45) // "08:15"
// Age uses local midnights on both ends so a timezone offset cannot shift a day.
ageInDays = max(0, floor((localMidnightToday - localMidnight(birthDate)) / 86400000))
ageInWeeks = floor(ageInDays / 7)
ageInMonths = ageInDays / 30.4375 // note: 30.4375, not 30 and not 30.44
dayOfYear = floor((now - Dec31OfLastYear) / 86400000)
Parse the birth date as new Date(birthDateISO + 'T00:00:00') so it is local
midnight, not UTC midnight. Getting this wrong shifts every band boundary by a
day for anyone west of Greenwich.
Two load-bearing numbers here, unlike most numbers in this document. The
30.4375 divisor and the local-midnight parse are both correctness, not taste.
30 or 30.44 will put a baby in the wrong band for a day or two around every
boundary, and a UTC parse does the same for half the planet. Everything else in
this section is free: work in Date objects if you insist, use a date library,
name the helpers whatever you like. Just keep the generator deterministic, which
is far easier with minutes-from-midnight than with timezone-aware dates.
Base schedule generation
generate(settings, now, overrides):
months = ageInMonths(settings.birthDateISO, now)
weeks = ageInWeeks(settings.birthDateISO, now)
band = first band where months >= minMonths and months < maxMonths, else last
doy = dayOfYear(now)
wakeWindow = round((band.wakeWindowMin + band.wakeWindowMax) / 2)
cursor = settings.todayWakeTime
truncated = false
blocks = []
push feed { start: cursor, label: "Feed" }
for i in 0 .. band.napCount - 1:
wakeEnd = cursor + wakeWindow
if wakeEnd >= 23:59: truncated = true; break
push wake { start: cursor, end: wakeEnd, label: "Wake time",
activityTip: pickActivity(band.id, doy, i) }
cursor = wakeEnd
napEnd = cursor + band.typicalNapMin
if cursor >= 23:59: truncated = true; break
push nap { start: cursor, end: min(napEnd, 23:59),
label: i == 0 ? "First nap"
: i == band.napCount - 1 ? "Last nap"
: "Nap " + (i + 1),
soothingTip: pickSoothing(band.id, doy, i) }
cursor = min(napEnd, 23:59)
if cursor < 23:49: push feed { start: cursor, label: "Feed" }
if not truncated:
END OF DAY, by band.bedtimeMode (see the three cases below)
mark overtired flags on every wake block
Note the loop's two guards. The >= 23:59 check on wakeEnd and the
< 23:49 check before a feed (10 minutes of headroom) exist so a very late
wake-up produces a short truncated day rather than a pile of blocks all
stamped 23:59.
End of day, bedtimeMode: 'none' (newborn, young-infant)
naturalEveningEnd = cursor + wakeWindow
floor = band.nightCycleEarliest # 21:00
useFloor = naturalEveningEnd < floor
eveningEnd = min(useFloor ? floor : naturalEveningEnd, 23:59)
push wake { start: cursor, end: eveningEnd,
label: useFloor ? "Evening (cluster feeds and fussy spells)" : "Evening",
activityTip: useFloor ? pickClusterEveningTip(doy)
: pickActivity(band.id, doy, band.napCount),
eveningCluster: useFloor }
cursor = eveningEnd
if cursor < 23:49:
push feed { start: cursor, label: "Evening feed (cluster feeds are normal)" }
push night-cycle { start: cursor, label: "Night cycle begins",
soothingTip: pickNightCycleTip(doy) }
else:
truncated = true
The floor is a "no earlier than", never a compression target. When the cascade naturally lands after 21:00, nothing moves. The stretched evening block is exempt from the overtired check, because a long fussy cluster-feed evening at this age is biology, not a scheduling error.
End of day, bedtimeMode: 'soft' (transition) and 'bedtime' (16 weeks and up)
target = settings.userBedtime or band.targetBedtime
{ sleepTime, earlierThanTarget } = resolveSleepTime(cursor, target,
band.windDownMax, band.bedtimeAfterLastWake)
if sleepTime >= 23:59:
push wake { start: cursor, end: "23:59", label: "Wind-down",
activityTip: pickActivity(band.id, doy, band.napCount),
windDown: true }
truncated = true
else:
push wake { start: cursor, end: sleepTime, label: "Wind-down",
activityTip: pickActivity(band.id, doy, band.napCount),
windDown: true }
if mode == 'soft':
push settling { start: sleepTime, label: "Settling for the night",
soothingTip: pickSoothing(band.id, doy, band.napCount),
earlierThanTarget }
else:
push bed { start: sleepTime, label: "Bedtime " + sleepTime,
soothingTip: pickSoothing(band.id, doy, band.napCount),
earlierThanTarget }
resolveSleepTime, the three regimes
resolveSleepTime(cursor, target, windDownMax, bedtimeAfterLastWake):
maxSleep = cursor + windDownMax
# 1. Target is unreachable without an overlong wind-down (early-wake day).
# Land at the ceiling and flag it so the card can explain the shift.
if target > maxSleep: return { maxSleep, earlierThanTarget: true }
# 2. Target is reachable, with a 30 minute comfort floor after the last nap.
if target > cursor + 30: return { target, earlierThanTarget: false }
# 3. Target is already past (late wake-up). Give a sane wind-down instead.
return { cursor + bedtimeAfterLastWake, earlierThanTarget: false }
The overtired check
for each block of kind 'wake' that has an end and is not eveningCluster:
duration = end - start
ceiling = block.windDown ? band.windDownMax : band.wakeWindowMax
block.overtired = duration > ceiling
Run this on the base schedule and again after every cascade, because an edit can push a previously-fine wake block past its ceiling.
Using wakeWindowMax for the final pre-bedtime stretch is the single easiest way
to get this app wrong. It false-flags nearly every early-wake schedule as
overtired from 4 months onward. That is what windDownMax exists to prevent.
The edit cascade
Edits are stored as { blockIndex, newStart } in the order they were made, and
replayed in that order on every generation.
applyEdit(blocks, index, newStart):
if index == 0:
# The first block is owned by todayWakeTime, but handle it anyway:
# shift the entire day by the delta.
return blocks.map(shift by newStart - blocks[0].start)
delta = newStart - blocks[index].start
if delta == 0: return blocks
blocks[index - 1].end = newStart # previous block stretches or shrinks
for i from index to end:
shift blocks[i] by delta # durations preserved
return blocks
shift(block, delta):
start = clamp(start + delta, 00:00, 23:59)
end = end ? clamp(end + delta, 00:00, 23:59) : undefined
# keep the bedtime label in sync with its time
if kind == 'bed' and label matches /^Bedtime \d{2}:\d{2}$/:
label = "Bedtime " + start
Bedtime protection
After all edits are applied, and only when the band has a bedtime mode other
than 'none', a target bedtime exists, and the parent did not edit the
bedtime anchor themselves:
compressTowardBedtime(blocks, editedIndices, target, bedtimeAfterLastWake):
bedIndex = first index of kind 'bed' or 'settling'
if bedIndex < 0 or editedIndices has bedIndex: return unchanged
overshoot = blocks[bedIndex].start - target
if overshoot <= 0: return unchanged # only compress when bedtime is LATE
# Step 1: shrink the LAST nap, down to a 30 minute floor.
lastNapIdx = last index of kind 'nap'
if lastNapIdx exists and not edited:
shrinkBy = min(napDuration - 30, overshoot)
if shrinkBy > 0:
nap.end -= shrinkBy
for i from lastNapIdx + 1 to end:
if editedIndices has i: BREAK # edited blocks are immutable
shift blocks[i] earlier by shrinkBy
overshoot -= shrinkBy
# Step 2: shrink the wind-down (the wake block just before bedtime).
if overshoot > 0:
wdIdx = bedIndex - 1
if wdIdx > 0 and blocks[wdIdx].kind == 'wake' and not edited:
floor = min(30, bedtimeAfterLastWake)
shrinkBy = min(wdDuration - floor, overshoot)
if shrinkBy > 0:
wd.end -= shrinkBy
for i from wdIdx + 1 to end:
if editedIndices has i: BREAK
shift blocks[i] earlier by shrinkBy
overshoot -= shrinkBy
# Any remaining overshoot is accepted. Never drop a nap to hit bedtime.
The BREAK on an edited block is the important part. The parent's explicit
choice always wins over the algorithm's preference.
What is load bearing and what is not. That this function exists at all is
load bearing: without a bedtime ceiling on the cascade, one nudged nap can put a
six month old's bedtime at 22:30, and the app is then giving a parent bad advice
rather than merely looking different. Its internals are yours. The two 30 minute
floors, the order (nap first, then wind-down), and accepting the leftover
overshoot rather than dropping a nap are all choices the original settled on
after real use. Change them if a different compression feels better to you. Keep
the ceiling, keep "never delete a nap", and keep the BREAK.
Tip rotation
Nine activity pools and nine soothing pools, one per band, five tips each. Plus
two extra pools used only by the 'none' bands: five cluster-evening tips and
seven night-cycle tips. All selection is deterministic on the day of the year,
so reloading gives the same day and tomorrow gives a different one.
pickActivity(bandId, doy, slot) = ACTIVITIES[bandId][ abs(doy + slot * 17) % 5 ]
pickSoothing(bandId, doy, slot) = SOOTHING[bandId][ abs(doy + slot * 23) % 5 ]
pickNightCycleTip(doy) = NIGHT_CYCLE[ abs(doy * 13) % 7 ]
pickClusterEveningTip(doy) = CLUSTER_EVENING[ abs(doy * 7) % 5 ]
The 17, 23, 13, and 7 multipliers, and the five-tips-per-pool size, are all
arbitrary and yours to change. The 17 and 23 are coprime-ish offsets that keep
consecutive slots on the same day from repeating the same tip, and any
similar pair does the same job. What must survive is the determinism: selection
keyed on the day of the year and nothing else, so a reload shows the same day
and tomorrow shows a different one. A Math.random() here quietly breaks the
promise that the plan is stable. slot is the nap index, and
band.napCount is passed as the slot for the wind-down and bedtime tips.
Tone for every tip: warm, practical, one sentence, never bossy, no exclamation marks. Examples to match in register, not to copy verbatim:
- newborn activity: "Tummy time, 1 to 3 minutes on your chest.", "Hold a high contrast card 20 to 30 cm from baby.", "Narrate what you are doing in a calm voice."
- newborn soothing: "Swaddle snug, arms tucked.", "Shush close to the ear, loud as the cry.", "Let baby suck, breast, bottle, or pacifier."
- half-year activity: "Sit-supported play with stacking cups.", "Texture basket, fabric, wood, silicone.", "Peekaboo with a scarf."
- pre-crawler soothing: "Boring is friendly. Do not start a new game.", "Trust the wake window, do not stretch too far."
- cluster evening: "Hold, sway, walk. The witching hour is biology, not your fault.", "Take turns with a partner if you can, this stretch is hard."
- night cycle: "Longest stretch often starts 21:00 to 22:00 at this age.", "Follow sleep cues, not the clock.", "Expect wake-ups every 2 to 3 hours after the first stretch."
Write five per pool for all nine bands, both kinds, in that voice. The cluster-evening and night-cycle pools must acknowledge that this stretch is long and hard, and must never imply the parent is doing something wrong.
The tip text itself is entirely yours. Every example above is a register sample, not content to reproduce. Write your own pools, in your own voice, as many or as few per pool as you like. This is one of the nicest parts of the app to make your own, and it is also the place where a warm sentence lands hardest, because the parent is reading it while doing the thing.
Weekly review statistics and thresholds
Computed in code from the trailing 7 days, before any model runs.
THRESHOLDS = {
smoothMaxDaysWithEdits: 2, // at most 2 days touched
smoothMaxMeanBedtimeShiftAbs: 10, // minutes
driftMinConsecutiveSameSign: 4, // days in a row drifting the same way
driftMinMeanBedtimeShiftAbs: 15 // minutes
}
MIN_DAYS_REQUIRED = 5
Per-day bedtime shift: find the last block of kind bed or settling in
baseline_blocks and in final_blocks, convert both starts to minutes, and
subtract. Positive means the parent pushed bedtime later. If either side has no
bedtime block (a newborn week), that day contributes nothing.
daysObserved = number of rows in the window
daysWithAnyEdit = rows whose edits array is non-empty
meanBedtimeShiftMin = round(mean of the per-day shifts)
maxConsecutiveSameSignDriftDays = longest run of same-sign non-zero shifts
driftDirection = (maxRun >= 2 and mean != 0)
? (mean > 0 ? 'later' : 'earlier') : 'none'
mixedBandsThisWeek = more than one distinct age_band_id in the window
userBedtimeChangedMidWeek = more than one distinct non-null user_bedtime,
with at least 2 present
bandsObserved = the distinct age_band_id values
Verdict, in this order:
if daysWithAnyEdit <= 2 and abs(meanBedtimeShiftMin) < 10 -> 'smooth_week'
if maxConsecutiveSameSignDriftDays >= 4
and abs(meanBedtimeShiftMin) >= 15 -> 'consider_tweak'
otherwise -> 'stay_the_course'
Suggested tweak magnitude, also deterministic:
abs(meanBedtimeShiftMin) >= 25 -> sign(mean) * 15
otherwise -> sign(mean) * 10
Every number in this subsection is a first-pass value, tuned on intuition rather than on a year of data. They are a starting point, not a target. Keep them in one exported constant so they are easy to change, say in a comment that they are provisional, and change any that feel wrong to you.
What is load bearing here is the shape, not the values: the verdict is computed in code from the week's rows before any model runs, and the model narrates that verdict rather than choosing it. Likewise the tweak is a bedtime shift and nothing else, of exactly 10 or 15 minutes and nothing else. You can move the thresholds anywhere. You cannot hand the choice to the model.
Look and feel
The mood is a quiet nursery at dawn. Warm cream ground, soft pastel accents, big type, generous rounding, one soft shadow, almost no motion. Nothing on the screen should feel urgent, because the person reading it is already stressed.
Design tokens
// tailwind.config.ts -> theme.extend
colors: {
cream: '#FFF7F0', // page background
peach: { DEFAULT: '#FFB088', soft: '#FFE4D2' }, // wake time, primary action
sage: { DEFAULT: '#7AB89A', soft: '#DDEEDF' }, // feeds
sky: { DEFAULT: '#7DB7D6', soft: '#DDECF6' }, // naps
lavender: { DEFAULT: '#9B8BC4', soft: '#E6E0F2' }, // bedtime, settling, night cycle
ink: '#2E2A26', // primary text
muted: '#7A7470' // secondary text
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif']
},
borderRadius: { chip: '16px', card: '24px' },
boxShadow: { soft: '0 6px 20px rgba(60, 40, 30, 0.06)' }
These are the values the original settled on after real use, and they are a good starting point rather than a target. The whole palette, the two radii, the font stack, and the shadow are yours to replace. Pick colours you would want to look at in a dark room, keep the six semantic roles distinct from each other, and change anything that feels wrong to you.
One observation worth keeping even if you throw out every hex code: the shadow colour is a warm brown at 6 percent, not black. A black shadow on cream reads as dirt. This detail matters more than it sounds.
/* app/globals.css, after the three @tailwind directives */
html, body {
background-color: #FFF7F0;
color: #2E2A26;
font-size: 18px; /* base, not 16. Tired eyes, arm's length. */
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
* { -webkit-tap-highlight-color: transparent; }
input[type="date"], input[type="time"], input[type="text"] { font-size: 18px; }
Type scale
| Use | Classes |
|---|---|
| Greeting eyebrow | text-sm uppercase tracking-widest text-muted |
| Page title | text-3xl font-semibold text-ink |
| Sheet title | text-2xl font-semibold text-ink |
| Block time | text-2xl font-semibold text-ink leading-tight |
| Block kind label | text-xs uppercase tracking-wider in the accent colour |
| Tip strip | text-[15px] text-ink/80 |
| Inline warning note | text-xs text-ink/55 leading-snug |
| Footnote | text-xs text-muted/80 leading-relaxed |
Spacing and layout
- Page container:
mx-auto max-w-md min-h-screen. 448px maximum, always. - Header
px-6 pt-8 pb-4. Card listpx-5 pb-32(the bottom padding clears the floating button). Card gapgap-3(12px). Card paddingpx-5 py-4. - Sheets:
p-6, full width androunded-t-cardon mobile,sm:max-w-mdandsm:rounded-cardcentred from thesmbreakpoint (640px) up. - Icon square 40px, pencil button 32px, floating settings button 56px.
All of these are tuned defaults, including the 448px cap. They are what felt right on a phone in the original, not measurements to hit. Widen the column, change the rhythm, use a different breakpoint. The only spacing constraint worth keeping is the 40px minimum touch target, because of who is tapping and in what state.
Motion
Three effects, total.
active:scale-95on buttons,active:scale-[0.98]on the review banner.- Sheets appear. No slide-up animation is implemented and none is needed.
transitionon the pencil'shover:opacity-80, which only fires on pointer devices.
No page transitions, no skeletons, no shimmer, no toasts, no confetti.
Responsive breakpoints
Only one matters, Tailwind's sm at 640px, and it does exactly one thing:
converts bottom sheets into centred dialogs. Everything else is identical at
375px and at 2560px, because the container is capped at 448px. Design at 375px
(iPhone SE and 12 mini) and check nothing overflows.
Dark mode
There is none, deliberately. A single warm cream surface is the identity, and a
dark inversion of a pastel nursery palette looks muddy. If you add one, invert
the ground to a warm near-black around #1A1614 and lift the pastels, and check
the tinted *-soft chips still separate from the card. Do not just add
dark: classes to the existing tokens.
Accessibility rules the original honours
- Every decorative glyph carries
aria-hidden. - Every icon-only button carries an
aria-label("Open settings", "Edit {block label} time", "Reset today's schedule", "Close settings"). - Both sheets are
role="dialog" aria-modal="true", and the dimmed backdrop is a real<button>witharia-label="Close ..."so it is reachable, not a div. - Validation errors carry
role="alert". - The edited-block dot has both
aria-label="Manually edited"and atitle, because it is the only information carried by colour alone. - The block list is an
<ol>of<li>, since the order is meaningful. - Base 18px type and a 40px minimum touch target throughout.
Known gap worth fixing if you care: neither sheet traps focus or closes on Escape. Add both. It is a small change and the original does not have it.
External services
Phase 1 needs nothing. No database, no auth provider, no email, no analytics, no CDN, no model API. That is worth saying out loud, because it means Phase 1 deploys anywhere that runs Node.
Phase 2 needs exactly two things.
| Service | What it is for | How to swap it |
|---|---|---|
| PostgreSQL (platform-managed) | The three tables. Roughly one small row per day plus one report per week, so this is a tiny database forever. | Any Postgres works: Neon, Supabase, Railway, RDS, or local. Only the DATABASE_URL changes. SQLite also works for local development, but see the warning below. |
| A scheduled AI agent | Runs the weekly review once a week. Reads /api/prepass, writes /api/reports. | Anything that can make two authenticated HTTP calls on a schedule: a platform agent task, a GitHub Actions cron, a cloud scheduler plus a small script, or a person running two curl commands on Mondays. The API does not know or care. |
To get the database on Liivo or OSC, ask the platform through the MCP connector rather than assuming an instance exists. Say what you need:
I need a managed PostgreSQL instance for this app. Please provision one and give
me the connection string so I can store it as DATABASE_URL in the parameter store.
Provisioned credentials are shown once. Save the connection string the moment it is issued. Platform vaults are typically write-only afterwards, so a lost connection string means provisioning again.
Do not use a local SQLite file or any local file for anything that must
survive a restart. Container storage is ephemeral and gets wiped on every
redeploy and every restart. SQLite is fine for npm run dev on your own laptop
and nowhere else.
Environment variables
Ship this as .env.example. Placeholders only. Never commit a real .env.
# ---------------------------------------------------------------------------
# Phase 1 needs NONE of these. The app works with an empty environment.
# ---------------------------------------------------------------------------
# Phase 2. PostgreSQL connection string, injected by the platform's parameter
# store at runtime. Never hardcode it and never put it in a Dockerfile, since
# that would override the injected value.
DATABASE_URL=postgres://YOUR_DB_USER_HERE:YOUR_DB_PASSWORD_HERE@YOUR_DB_HOST_HERE:5432/YOUR_DB_NAME_HERE
# Phase 2. The single shared bearer token guarding every /api route.
# Generate with: openssl rand -hex 32
AUTH_TOKEN=YOUR_AUTH_TOKEN_HERE
# ---------------------------------------------------------------------------
# Injected by the platform. Do not set these yourself in production.
# ---------------------------------------------------------------------------
# PORT the platform injects 8080. Read it, never hardcode it.
# APP_URL an address for the app, injected by the platform. Verified platform
# behaviour: this can be the INTERNAL web runner hostname rather than
# the public address. It is therefore fine for service to service
# callbacks and wrong for anything a person has to reach. Never print
# it in a page, put it in a link, or paste it into a message. Build
# user-facing URLs in the browser from window.location.origin, and
# take the app's public address from the platform after deploying.
# ---------------------------------------------------------------------------
# Only needed if you build the TWO-REPOSITORY alternative instead of one app.
# ---------------------------------------------------------------------------
# Comma-separated CORS allowlist for the API. Not needed in the one-app layout,
# because every request is same-origin.
# FRONTEND_ORIGIN=https://YOUR_CLIENT_URL_HERE,http://localhost:3030
#
# The API base URL the client should call. Not needed in the one-app layout,
# because the client uses relative paths.
# NEXT_PUBLIC_API_BASE_URL=https://YOUR_API_URL_HERE
Two notes on secrets:
NEXT_PUBLIC_variables are compiled into the browser bundle. Never put the auth token or the database URL behind that prefix. The auth token reaches the browser only through the one-time?setupToken=flow, never through the build.- In the one-app layout the client calls relative paths (
/api/days), so no public API URL variable is needed at all. That is one of the main reasons to prefer one app.
Liivo deployment
The recommended path: one app, one URL
Repo layout. One repository, one package.json at the root, nothing nested,
no workspaces. The platform clones the repository and then, all from the
repository root, runs npm install, then npm run build, then npm start.
Every file in the repository is part of the build context. Always ship a build
script even when nothing needs building, as a no-op such as
"build": "echo no build step"; this app has a real one.
Do not use the platform's subPath setting to deploy a subdirectory: with the
parameter store it silently injects zero environment variables and the app boots
with no secrets.
Build and start.
"build": "next build",
"start": "node scripts/migrate.mjs && next start -H 0.0.0.0 -p ${PORT:-3030}"
-H 0.0.0.0is required. Binding to localhost inside a container means the platform cannot reach the app and the deploy is marked unhealthy.-p ${PORT:-3030}reads the injected port. The platform injectsPORT=8080. Never hardcode 8080 in the start script.- Migrations run as part of
start, not by hand. Every statement must be idempotent, because this replays on every boot and every restart. - If you build Phase 1 only, drop the migrate prefix.
scripts/migrate.mjs:
// Applies every migrations/*.sql in filename order, then exits 0.
// No-ops cleanly when DATABASE_URL is absent, so Phase 1 can share the script.
import pg from 'pg';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
if (!process.env.DATABASE_URL) {
console.log('no DATABASE_URL, skipping migrations');
process.exit(0);
}
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
try {
const dir = path.resolve('migrations');
const files = (await readdir(dir)).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
await pool.query(await readFile(path.join(dir, f), 'utf8'));
console.log('migration applied:', f);
}
await pool.end();
} catch (err) {
console.error('migrations failed', err);
process.exit(1); // fail the boot loudly rather than serve a broken schema
}
Port. Injected as PORT=8080. Read it. Never hardcode it.
Health. The platform probes /healthz. Provide both a route handler at
app/healthz/route.ts returning the plain text ok and a literal public/healthz
file containing ok, so the path answers no matter which layer serves it first.
Also make sure / returns 200 quickly. It does, because the first render is an
empty shell and all the work happens after hydration.
Injected environment. Set DATABASE_URL and AUTH_TOKEN in the platform
parameter store, not in the repo, not in a Dockerfile.
Managed database. Ask the platform for PostgreSQL through the MCP connector. Save the connection string immediately; it is shown once.
Storage bucket. Not needed. This app stores no files.
Dockerfile. Not needed. This is a plain Node app with no system
dependencies. You would only need one if you added Prisma (which needs
openssl) or media processing (which needs ffmpeg). This app has neither. If
you do add one, do not set DATABASE_URL in it, because that would shadow the
injected value.
Public URL. Generated by the platform, of the form
https://<generated-prefix>.apps.liivo.io (or .apps.osaas.io on OSC). Never
hardcode it. The app does not need to know its own address, with one exception:
the agent playbook needs the URL pasted into it. Read it from the platform after
the first deploy and paste it in then.
Onboarding a device with the token (Phase 2). After deploying, visit
https://<your-app-url>/?setupToken=YOUR_AUTH_TOKEN_HERE once on each phone.
The token is stored in that browser and stripped from the address bar. Until you
do this, every sync call silently no-ops and the app behaves exactly like Phase 1.
When it fails, check in this order.
- Build failed. Read the build log. Almost always a TypeScript error, since
strictis on. Runnpm run typechecklocally first. - Deployed but unhealthy. You bound to localhost instead of
0.0.0.0, or hardcoded a port other than the injected one, or/healthzreturns 404. - Healthy but 500 on every
/apicall.AUTH_TOKENis not set, so the auth middleware returns 500 by design. Set it in the parameter store and restart. - 401 on every
/apicall from the browser. The device has no token. Do the?setupToken=visit. /api/daysreturns 500 with a db error.DATABASE_URLis wrong or the database is not reachable. Check the app logs for thepgerror.PATCH /api/days/:datereturns 404. No baseline row exists for that date. The POST must land first. Reload the app to trigger it.- Migrations crash the boot on the second deploy. You added a non-idempotent
statement. Make it
IF NOT EXISTSor add aschema_migrationsledger. - The weekly review never appears. Check
GET /api/prepassby hand. If it saysinsufficient_data, you have fewer than 5 days of snapshots and the feature is working correctly by staying silent. - The agent task fails looking for a token. It is reading the environment. Put the token inline in its prompt and say so explicitly in the playbook.
- Every environment variable is missing at once, not just one, and the app
reports both "AUTH_TOKEN not configured" and no database. You deployed with
a
subPathout of a monorepo. The parameter store injects nothing in that configuration. Move the app to its own repository root. Do not spend time checking your parameter values; they are fine, they are just never read.
The alternative: two repositories, two apps, two URLs
Only do this if you already have a static client deployed and are bolting an API onto it. It is strictly more work and there is no functional gain.
This means two separate repositories, not one repository with two folders.
The platform deploys one app per repository, from the repository root. Two
deployed apps means two repositories, each with its own root package.json, its
own build and start scripts, and its own generated public URL. Do not reach for
the subPath setting to deploy two folders out of one repository: combined with
the parameter store it silently loads zero environment variables, because the
start command runs from inside the subdirectory and bypasses the root-level
config fetch, so the app boots with none of its secrets and fails in a way that
is very hard to diagnose. It is a known platform issue, not a misconfiguration.
If you are somehow forced into it, fetch the config yourself at the top of the
start script using the injected APP_CONFIG_URL before the app process starts.
(Verified against the platform's own deploy guide on 2026-08-19.)
Shared code across the two repositories. This app has exactly one piece of duplication: the API needs a slim mirror of the nine age bands to build the AI prompt's numeric context. Publish it as a small versioned npm package, or just copy the file into both repositories with a comment at the top of each saying which one is the source of truth. Do not reach for a monorepo workspace to avoid the copy; the deploy pain costs more than the duplication. Whichever you choose, if a band number changes in the client and not in the server mirror, the AI will ground its claims on stale figures and nothing will warn you.
App 1, repository 1, the client. Next.js with output: 'export' in
next.config.mjs, which produces a fully static out/ directory.
"build": "next build",
"start": "serve out -l ${PORT:-3030}"
Add serve to dependencies. The original used npx --yes serve out -l 8080,
which fetches the package from the network at container start and hardcodes the
port. Both are avoidable. Keep public/healthz.
App 2, repository 2, the API. Plain Express, no build step, no TypeScript.
The platform still runs npm run build from the root, so ship a no-op build
script rather than omitting one.
{ "type": "module", "main": "index.js",
"scripts": { "build": "echo no build step", "start": "node index.js" },
"engines": { "node": ">=20" },
"dependencies": { "express": "^4.21.1", "pg": "^8.13.1" } }
const PORT = Number(process.env.PORT) || 8080; and app.listen(PORT, ...).
Run migrations before listen and process.exit(1) if they fail. Serve
GET /health unauthenticated and mount every router under /api behind the
bearer-token middleware.
Wiring the two. Three things have to line up, and every one of them is a thing that can only break in the two-repository layout.
- The client needs the API's public URL as an injected environment
variable. Do not hardcode it the way the original did. Set
NEXT_PUBLIC_API_BASE_URLin the client app's parameter store to the API app's generated public URL, and read it inlib/sync.tsasprocess.env.NEXT_PUBLIC_API_BASE_URL. Two consequences to plan for: a static export bakes the value into the bundle at build time, so changing the API URL means a rebuild and not a restart; and the value is visible in the browser bundle, which is fine for a URL and is exactly why the auth token must never travel through aNEXT_PUBLIC_variable. Two precisions on where that value comes from. Injecting it as configuration is right: one deployed app being told the address of another deployed app is exactly what injected config is for, and the client must not hardcode it. But set it explicitly to the API app's public URL, and do not derive it from an injectedAPP_URL.APP_URLcan be the internal web runner hostname, which one service can call and a browser cannot resolve, and this particular value is read in the browser even though the relationship it describes is app to app. - CORS on the API, as a comma-separated allowlist so local development and production both work from one variable:
const ALLOWED = (process.env.FRONTEND_ORIGIN || 'http://localhost:3030')
.split(',').map(o => o.trim()).filter(Boolean);
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && ALLOWED.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Vary', 'Origin');
}
res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Authorization, Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
Echo back the request's own origin only when it is on the list, and set
Vary: Origin. Never reflect an arbitrary origin, and never use * on an
endpoint that reads an Authorization header.
3. Two public URLs to keep straight. The agent playbook points at the API
URL. The ?setupToken= onboarding link points at the client URL. Mixing
them up produces a 404 that looks like an auth problem.
Deploy order, and it has to be this order because each app needs the other's generated URL:
- Create repository 2 and deploy the API. Note its generated public URL.
- Create repository 1, set
NEXT_PUBLIC_API_BASE_URLin its parameter store to the API's URL, then deploy the client. Note its generated public URL. - Set
FRONTEND_ORIGINon the API to the client's URL and restart the API. - Point the agent playbook at the API's URL, and do the
?setupToken=onboarding visit against the client's URL.
Any later change to either URL means step 2 is a rebuild of the client, not just a restart. That alone is a good enough reason to prefer the one-app layout.
Acceptance checklist
Walk this in order. Every item is observable.
The exact times below are what the band numbers in this document produce, so they double as a regression test against those numbers. If you changed a band value, or a nap length, or a compression floor, your times will differ and that is correct, not a failure. Recompute them from your own numbers and use those as your checklist instead. The items that check behaviour rather than arithmetic (the cascade adapting, bedtime not sliding late, edits surviving a reload, the newborn day ending without a bedtime) hold whatever numbers you chose.
Setup and first run
- Open the app with empty browser storage. You see a cream screen with a "Welcome" block and a "Set up baby" button, and the settings sheet has opened over it by itself.
- The settings sheet shows exactly four fields, and the fourth ("Target bedtime") is a time input when the birth date is more than 12 weeks ago and a grey explanatory note when it is less. Change the birth date between "10 weeks ago" and "20 weeks ago" without saving and watch that field switch between the two forms.
- Save a birth date of about 6 months ago and a wake-up of
07:00. The sheet closes and you land on the day screen. - The header greeting matches the current hour ("Good morning" before noon). The title reads "Today's plan". Type a name in settings and it becomes "{name}'s day".
- Reload the page. The schedule and settings are unchanged. Nothing was lost.
Schedule correctness, exact times
- With a 6 month old and a
07:00wake, the list is exactly 12 cards:
Feed 07:00
Wake time 07:00 - 09:15 (135 min, the 120/150 midpoint)
First nap 09:15 - 10:15 (60 min)
Feed 10:15
Wake time 10:15 - 12:30
Nap 2 12:30 - 13:30
Feed 13:30
Wake time 13:30 - 15:45
Last nap 15:45 - 16:45
Feed 16:45
Wind-down 16:45 - 19:00 (135 min, under windDownMax of 240)
Bedtime 19:00 19:00
No overtired note anywhere. The last card is lavender with a moon.
- With a newborn (about 3 weeks) and a
07:00wake, the list is exactly 16 cards, the wake windows are 53 minutes, the naps are 90 minutes, and the day ends:
Feed 16:32
Evening (cluster feeds and fussy spells) 16:32 - 21:00
Evening feed (cluster feeds are normal) 21:00
Night cycle begins 21:00
There is no bedtime card at all. The 4 hour 28 minute evening block shows no overtired note, because the cluster-feed evening is exempt.
-
Set the newborn's wake-up to
06:00. The night cycle still begins at21:00, not at 20:00. The evening block simply gets longer. The 21:00 floor is what prevents a newborn "bedtime" at 17:15. -
With a 4.5 month old and a
06:00wake, bedtime lands at18:00, not at the band target of 19:00, and that card carries the note "Earlier than usual, early wake-up, this protects against overtiredness." Change the wake-up to07:00and bedtime moves to exactly19:00with no note. -
With a 16 month old: a
08:00wake gives bedtime19:30clean, a07:00wake gives19:00with the earlier-than-usual note, and a06:00wake gives18:00with the note. One nap, 90 minutes, in all three. -
With a 14 week old (soft mode) the last card reads "Settling for the night", never "Bedtime". Known boundary quirk to expect and not to "fix" blindly: at a
08:00wake the last nap ends at exactly 19:00 and the target of 19:30 is exactly 30 minutes later, which fails the strict "more than 30 minutes" comfort check, so it falls through to thebedtimeAfterLastWakefallback and settles at20:30. -
Set the wake-up to
21:00on any band. The day truncates and the note "Schedule trimmed at end of day. Try setting an earlier wake-up time." appears. No card shows a nonsense time past 23:59. -
Reload the page five times. Every tip is identical each time. Change your system clock forward one day and reload: the tips change.
Editing and cascade
- Tap the card body of any nap. Nothing happens. Tap the pencil. The edit sheet opens. This distinction is intentional.
- In the edit sheet, type a time earlier than the previous block's start. The error "Must be at or after the previous block (HH:MM)" appears and Save greys out.
- Move the second nap 30 minutes later. The wake block before it stretches by 30 minutes. Every block after it moves 30 minutes later, keeping its own duration. A small coloured dot appears on the nap you edited and on no other card.
- After that edit, on a band with a firm bedtime, check bedtime. If the cascade pushed it past the target, the last nap has been shortened (never below 30 minutes) and then the wind-down shortened (never below 30 minutes) to claw the time back. No nap has disappeared.
- Edit a block to make a wake window longer than the band's
wakeWindowMax. The note "Wake window longer than usual, overtired risk." appears on that wake card immediately, and disappears when you undo the edit. - Edit the bedtime card itself. Bedtime protection stops entirely: your time stands and nothing recompresses around it.
- A "Reset today" pill appeared in the header the moment you made the first edit. Tap it. Every dot and every edit disappears and the generated schedule returns. The pill disappears too.
- Make an edit, then change today's wake-up time in settings. All edits are wiped, no dots remain.
- Make an edit, then change your system date to tomorrow and reload. The edits are gone automatically.
Look and feel
- At 375px wide nothing overflows horizontally and no text is clipped.
- At 1440px wide the content is still a 448px column centred on cream, and the sheets are centred dialogs rather than bottom sheets.
- Wake cards are peach, feeds sage green, naps sky blue, and the three end-of-day kinds lavender. Card shadows are warm brown, not grey.
- Tapping any button scales it down slightly. No blue tap flash on iOS.
- Focusing a date or time input on an iPhone does not zoom the page.
Phase 2, if you built it
GET /healthzreturns 200 with the bodyok.GET /api/latest-reportwith noAuthorizationheader returns401 {"error":"unauthorized"}.- Visit
/?setupToken=YOUR_AUTH_TOKEN_HEREonce. The address bar loses the parameter immediately. Nothing visible changes. - Open the app. In the network tab, one
POST /api/daysfires. Reload without changing anything: it fires again (it is idempotent), and no PATCH fires. - Edit three blocks quickly. Exactly one
PATCH /api/days/<today>fires, about 500 ms after the last edit, not three. - Edit a block and immediately switch browser tabs. The PATCH fires straight away rather than waiting out the debounce.
- Tap "Reset today". A PATCH fires immediately with an empty
editsarray. - Clear the token from storage and repeat steps 31 to 34. No network calls at all, and the app still works perfectly. This is the required degradation behaviour.
GET /api/prepassby hand. With fewer than 5 stored days it returns{"skip":"insufficient_data","daysObserved":n,"minimumRequired":5}.- POST a report with a citation source that is not in the whitelist. The
response verdict comes back as
smooth_weekregardless of what you sent, and the stored tweak is null. If this returns your original verdict, the honesty backstop is not wired up. Stop and fix it. - POST a report with
deltaMinutes: 45. The verdict comes back asstay_the_coursewith a null tweak. - POST the same
weekEndingtwice. The second returns409 {"error":"report already exists for that week"}. - With an unanswered report in the database, open the app. The peach review banner appears above the schedule. Tap it: the review sheet shows the headline, the summary, a "My honest take" box, and a "Based on" list with human-readable source names, not raw ids.
- On a
consider_tweakreport, tap "Accept tweak". The sheet closes, the banner is gone, and the bedtime on the schedule has moved by exactly the tweak'sdeltaMinutes. Reload: the new bedtime persists. - On another report, tap "Stick with current". The banner is gone and the schedule is unchanged.
- Reload after responding either way. The banner does not come back.
Build order, in stages
Build this in passes, not in one sitting. Stages are build passes. Phases are feature sets: Phase 1 is the app with zero backing services, Phase 2 is the optional weekly AI review. Stages 1 to 3 all live inside Phase 1, and Phase 2 becomes an optional Stage 4 at the end.
A practical note on the length of this document. You can paste the core brief at the top on its own, build Stage 1 from just that, get it deployed, and only then paste or refer back to the rest of this document for Stages 2 and 3. That works, and for a long prompt it works better than pasting everything at once.
Stage 1: the smallest thing that is recognisably the app, then deploy it
Goal: type in a birth date and a wake-up time, see a correct, age-appropriate day. Read-only. No editing yet.
-
Scaffold.
npx create-next-appwith TypeScript and Tailwind, App Router, nosrc/directory. Delete the starter page content. Put the design tokens intotailwind.config.tsand the four base rules intoglobals.css. Set the metadata and viewport inlayout.tsx, includingthemeColor: '#FFF7F0'andmaximumScale: 1. Confirmnpm run devserves a cream page. -
The domain layer, before any UI. In order:
lib/types.ts, thenlib/time.ts, thenlib/schedule/bands.tswith all nine bands and their numbers, thenlib/schedule/tips.tswith the pools, thenlib/schedule/generate.tsincluding the three age-conditional endings. This is the app. Get it right first. -
Write
scripts/audit.tsimmediately after the generator, before any component. It loops every band times a few wake times (the original swept06:00,07:00,08:00), prints the last four blocks of each day, and flags truncation, overtired blocks, and bedtime drift against the band target. Run it. Every bug in the original generator was found by this script and none by reading the code. Do not skip it and do not defer it. Run it withsucrase-node scripts/audit.tsortsx. -
Storage.
lib/storage.ts. Corrupt values are treated as absent, and it must be safe to call during server render (return null whenwindowis undefined). -
Minimum UI. The hydration guard,
BlockCard,ScheduleView,AgeBadge,Header,EmptyState,page.tsxwith theuseMemogeneration, andSettingsSheetwith the first three fields plus the auto-open on first run. The sources footnote and the disclaimer go in now, not later, because they are part of what the app is. -
Deploy it. One app, one URL, no environment variables. Confirm
/healthzreturnsokand/returns 200 on the deployed URL, on a real phone. Do this before writing another line. A parent could use what you have now.
Stage 2: the rest of the core brief
Goal: everything in the core brief's feature list, minus the weekly review.
-
The bedtime override field. The conditional fourth settings field, its live switching as the typed birth date crosses 12 weeks, the "Reset to age default" button, and the rule that changing today's wake-up wipes every edit for today.
-
Editing and the cascade.
lib/overrides.tswith today's-date scoping and the calendar rollover wipe,EditTimeSheetwith its live validation, the pencil handlers, the stretch-previous-and-shift-later cascade, and multiple edits applying in order. -
Bedtime protection. Shrink the last nap to its floor, then the wind-down to its floor, and stop at the first explicitly edited block. Skip the whole thing when the parent edited the bedtime anchor themselves. Re-run the audit script and walk the cascade items in the acceptance checklist.
-
Edit affordances. The edited-block dot, the "Reset today" pill appearing only when edits exist, and recomputing the overtired flags after every cascade rather than only on generation.
-
Redeploy. The core brief is now complete and shipped.
Stage 3: polish, feel, and the detail from the back of this document
-
The notes and the small things. The overtired inline note, the earlier-than-target note, the truncation note, the greeting by hour, the
active:scale-95presses, the tap-highlight suppression, the 18px input sizing that stops iOS zooming on focus. -
Accessibility. Every
aria-labelandaria-hidden,role="dialog"andaria-modalon both sheets, the backdrop as a real button,role="alert"on validation errors, the<ol>of<li>block list. Then fix the original's known gap: trap focus in both sheets and close them on Escape. -
Tune the feel. Re-read the look and feel section with the app in front of you on a phone at low brightness. Adjust the palette, the shadow, the type scale, and the spacing until it reads calm to you. This is the part worth spending time on and the part that is entirely yours.
-
Run
npm run typecheckand walk the whole acceptance checklist.
Stage 4, optional: the weekly AI review (Phase 2)
Skip this whole stage if you want. The app is complete without it. If you build it, build every guard in the "must be right" list, because the guards are what make it safe to put a model near infant sleep advice.
-
Ask the platform for PostgreSQL. Save the connection string the moment it is issued. Put it in the parameter store as
DATABASE_URL. Generate the token withopenssl rand -hex 32and store it asAUTH_TOKEN. -
Data layer.
migrations/001_init.sql,scripts/migrate.mjs, andlib/server/db.ts. Wire the migrate step intostart. Deploy and confirm the boot log shows the migration applied. -
Auth and the day routes.
lib/server/auth.tswith the constant-time comparison, thenPOST /api/daysandPATCH /api/days/:date. Test both with curl before touching the client. -
The client sync layer.
lib/sync.tswith the token read, the?setupToken=consumption, the debounced patcher, and the four calls. Wire it intopage.tsx: the premise-change POST, the debounced edit PATCH, the immediate reset PATCH, and the flush onvisibilitychangeandbeforeunload. Verify the no-token path first: with the token cleared, the app must make zero network calls and still work. -
The review pipeline.
lib/server/bibliography.ts,lib/server/bands-context.ts,lib/server/prepass.ts, thenGET /api/prepass, thenPOST /api/reportswith all three guards, thenGET /api/latest-reportandPOST /api/reports/:id/respond. Test the demotion path with curl before you trust it. -
The review UI.
ReviewBannerandReviewSheet, the accept and decline handlers, and the source-id to human-label map. -
The agent. Create the scheduled agent task with the playbook, the API base URL, and the token inline in the prompt. Trigger one run manually before trusting the schedule. Check the first run's timestamp to confirm the timezone is what you expected.
-
Redeploy and walk the whole acceptance checklist again.
What to drop if you are short on time
Drop Stage 4 entirely. Then, inside Stages 2 and 3: drop the bedtime override
field (hardcode the band default), drop the edited-block dot, drop the truncation
note. Do not drop windDownMax, the nightCycleEarliest floor, bedtime
protection, or the audit script. Those are what make the output correct rather
than merely plausible, and they are the parts a parent will notice are wrong.
Hard won facts and gotchas
Reference material for when something goes wrong or behaves oddly, not part of the build sequence. Skip it on the way through and come back to it. Each item cost real time to discover in the original build, and none of them is something you would guess.
A brute-force sweep is the right test for a rules engine
The generator's failure mode is "the numbers are wrong", not "the code throws", so unit tests catch less here than a script that prints the whole output space. Run every age band against three wake times (early, normal, late) and print the resulting times, the overtired flags, the truncation flag, and the drift between the produced bedtime and the band's target. Read the table with your own eyes.
That sweep is how the separate wind-down ceiling in the fidelity list was found. Six apparently unrelated bugs showed up across the grid, all of them the same root cause: the final wake window of the day was being measured against the daytime wake window maximum. One fix cleared all six. Without the sweep they would have looked like six problems.
The client works in local time and the server's week boundary does not
The client is entirely local-time: age comes from local midnights, "today" comes from local date getters. The weekly review, if you build it, derives its week boundary on the server, and the obvious implementation uses UTC. For a user far from UTC the client's idea of "today" and the server's idea of where the week ends can disagree by a day. Nothing breaks. The trailing window can just include or exclude one boundary day unexpectedly, which is confusing when you are debugging why a report covered six days instead of seven. Either accept it knowingly or carry the user's timezone offset into the week calculation.
Edits are keyed by array position, which is fragile on purpose
An edit is { blockIndex, newStart }, where blockIndex is the position in the
generated block array at the moment of the edit. That is only valid while the
day's premise is unchanged, which is exactly why changing the wake-up time wipes
every edit for the day. Never persist those indices across a change of premise
or a change of age band; they will point at a different block and the day will
rearrange itself for no visible reason. If you sync days to a server, its upsert
should reset the stored edits for the same reason, and that reset is intentional
rather than a lost-update bug.
Debounced writes need two flush listeners, not one
If you debounce sync writes, flush them on both visibilitychange and
beforeunload. On mobile browsers visibilitychange fires reliably where
beforeunload frequently does not, and on desktop the reverse case exists too.
With only one of them the parent's last edit can sit on a pending timer and never
leave the device.
Make the sync layer degrade to fully local, not to broken
Every sync helper should read the stored token first and return immediately when it is absent, before touching the network. That single rule is what makes the whole optional-backend design safe: with no token the app makes zero network calls and behaves exactly like the offline-only version, so you can build and ship the app first and add the server later without a rewrite, and a server outage is invisible to the parent.
No model is called from inside the app, and that is a deliberate reversal
An earlier version of this codebase held a model client in-process, with the provider API key in the app's environment and a scheduled job inside the server. That was removed in favour of the external scheduled agent described above, and you should not reintroduce it. The app having no model key means a compromised app cannot spend money on inference, the model can be changed without touching or redeploying the app, and the judgement stays in your code where it can be tested. If you inherit or generate code of this shape with an in-process model client and a provider key in the environment, delete it rather than wiring it up.
Give the scheduled agent exactly one endpoint to read
One prepass endpoint that returns everything at once, the statistics, the computed verdict, the suggested delta, the band reference numbers, the source whitelist, and the raw days, beats several tidy endpoints. One round trip means no multi-step data gathering, and every step you remove is a step where an agent can wander off, re-derive something itself, or decide it needs more context. Pair it with cheap explicit no-op answers, "already generated" and "not enough data", so the ordinary weekly outcome is the agent exiting successfully in one call without writing anything.
One last thing
Put the guidance sources in the UI, not just in a README, and put the disclaimer next to them:
Gentle guidelines based on AAP/AASM, Cleveland Clinic, Sleep Foundation, Taking Cara Babies, and Huckleberry. Bedtime logic uses circadian-rhythm research. Every baby is different. Trust what you see in your baby.
This app tells tired parents what to do with their child. Showing your work is not decoration, it is the minimum.