Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 381bd6356d4b794ac2a7c1b8
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Timeline.tsx
// One day at a time: a 24-hour strip of sleep and feeds, the day's totals, and every entry.
// The last week comes from the live log; older days load when you step back to them.
import { useEffect, useState } from "react";
import { useUnits } from "./lib/prefs";
import { loadRange } from "./lib/family";
import { explain } from "./lib/bounded";
import { Icon, useNow, dayLabel, localDayStart, addDays } from "./ui";
import { LogSheet } from "./LogSheets";
import { EntryRow } from "./Today";
import { totals, duration, isRunning, parseData, fmtShort, fmtVol, type BabyEvent, type EventType } from "../functions/lib/baby";
import type { FamilyCtx } from "./App";
export function Timeline({ ctx }: { ctx: FamilyCtx }) {
const [u] = useUnits();
const now = useNow(ctx.events.some(isRunning) ? 1000 : 30_000);
const today = localDayStart(now);
// ?date=YYYY-MM-DD opens a past day, so a day can be linked to and survives a reload.
const [day, setDayState] = useState(() => {
const q = new URLSearchParams(location.search).get("date");
const t = q && /^\d{4}-\d{2}-\d{2}$/.test(q) ? new Date(`${q}T00:00:00`).getTime() / 1000 : NaN;
return Number.isFinite(t) ? Math.min(localDayStart(t), today) : today;
});
const setDay = (d: number) => {
setDayState(d);
history.replaceState(null, "", d >= today ? "/app/timeline" : `/app/timeline?date=${new Date(d * 1000).toLocaleDateString("en-CA")}`);
};
const [older, setOlder] = useState<{ key: string; events: BabyEvent[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<{ type: EventType; event?: BabyEvent } | null>(null);
const end = addDays(day, 1);
const liveFrom = addDays(today, -7);
const babyId = ctx.baby?.id ?? "";
const key = `${babyId}:${day}`;
useEffect(() => {
if (day >= liveFrom || !babyId) return;
let cancelled = false;
setError(null);
loadRange(ctx.h, babyId, addDays(day, -1), end)
.then((events) => { if (!cancelled) setOlder({ key, events }); })
.catch((err) => { if (!cancelled) setError(explain(err)); });
return () => { cancelled = true; };
}, [key, day >= liveFrom]);
if (!ctx.baby) return null;
const source = day >= liveFrom ? ctx.events.filter((e) => e.baby === babyId) : older?.key === key ? older.events : null;
// A sleep that began the evening before still counts toward this day.
const list = source ? source.filter((e) => (e.start >= day && e.start < end) || (e.type === "sleep" && e.start < day && (e.end ?? now) > day)).sort((a, b) => b.start - a.start) : null;
const t = source ? totals(source, day, end, now) : null;
return (
<div className="timeline wrap-app enter">
<div className="day-nav">
<button type="button" className="icon-btn" onClick={() => setDay(addDays(day, -1))} aria-label="Previous day"><Icon name="back" size={22} /></button>
<label className="day-pick">
<span>{dayLabel(day + 43200)}</span>
<input type="date" aria-label="Pick a day" value={new Date(day * 1000).toLocaleDateString("en-CA")} max={new Date(today * 1000).toLocaleDateString("en-CA")}
onChange={(e) => { const d = new Date(`${e.target.value}T00:00:00`).getTime(); if (Number.isFinite(d)) setDay(localDayStart(d / 1000)); }} />
</label>
<button type="button" className="icon-btn" onClick={() => setDay(addDays(day, 1))} disabled={day >= today} aria-label="Next day"><Icon name="next" size={22} /></button>
</div>
{error && <p className="notice notice-error">{error}</p>}
{!list || !t ? (
<div className="stack" aria-busy="true" aria-label="Loading the day">
<span className="skeleton" style={{ height: 116, borderRadius: 16 }} />
<span className="skeleton" style={{ height: 34, width: "70%", borderRadius: 999 }} />
<span className="skeleton" style={{ height: 186, borderRadius: 16 }} />
</div>
) : (
<>
<DayStrip events={list} day={day} end={end} now={now} />
<div className="day-totals">
<span><strong>{t.feeds}</strong> {t.feeds === 1 ? "feed" : "feeds"}</span>
<span><strong>{t.sleepSec >= 60 ? fmtShort(t.sleepSec) : "0m"}</strong> sleep</span>
<span><strong>{t.diapers}</strong> {t.diapers === 1 ? "diaper" : "diapers"}</span>
{t.bottles > 0 && <span><strong>{fmtVol(t.bottleMl, u.vol)}</strong> bottle</span>}
{t.breastFeeds > 0 && <span><strong>{fmtShort(t.breastSec)}</strong> breast</span>}
{t.pumpMl > 0 && <span><strong>{fmtVol(t.pumpMl, u.vol)}</strong> pumped</span>}
</div>
{list.length ? (
<ul className="entries">
{list.map((e, i) => <EntryRow key={e.id} ctx={ctx} event={e} now={now} index={i} onOpen={() => setOpen({ type: e.type, event: e })} showDay={e.start < day} />)}
</ul>
) : (
<div className="empty-state">
<h3>Nothing logged {day >= today ? "today" : "this day"}</h3>
<p>{day >= today ? "Entries from everyone in the family show here as they are logged." : "Step to another day, or log something you forgot from Today with its time."}</p>
</div>
)}
</>
)}
{open && <LogSheet ctx={ctx} type={open.type} event={open.event} onClose={() => setOpen(null)} />}
</div>
);
}
/** The day as a 24-hour bar: sleep as blocks, feeds and diapers as ticks. */
function DayStrip({ events, day, end, now }: { events: BabyEvent[]; day: number; end: number; now: number }) {
const span = end - day;
const x = (t: number) => `${(Math.min(Math.max(t, day), end) - day) / span * 100}%`;
const w = (a: number, b: number) => `${Math.max(0.4, (Math.min(b, end) - Math.max(a, day)) / span * 100)}%`;
return (
<figure className="strip" aria-label="The day at a glance">
<div className="strip-row">
<span className="strip-name">Sleep</span>
<div className="strip-track">
{events.filter((e) => e.type === "sleep").map((e) => <i key={e.id} className="strip-sleep" style={{ left: x(e.start), width: w(e.start, e.end ?? now) }} title={fmtShort(duration(e, now))} />)}
</div>
</div>
<div className="strip-row">
<span className="strip-name">Feeds</span>
<div className="strip-track">
{events.filter((e) => e.type === "breast" || e.type === "bottle").map((e) => (
<i key={e.id} className="strip-feed" style={{ left: x(e.start), width: e.type === "breast" ? w(e.start, e.start + Math.max(300, duration(e, now))) : undefined }} />
))}
</div>
</div>
<div className="strip-row">
<span className="strip-name">Diapers</span>
<div className="strip-track">
{events.filter((e) => e.type === "diaper").map((e) => <i key={e.id} className={`strip-diaper ${parseData(e.data).kind === "wet" ? "" : "dirty"}`} style={{ left: x(e.start) }} />)}
</div>
</div>
<div className="strip-hours" aria-hidden="true"><span>12a</span><span>6a</span><span>12p</span><span>6p</span><span>12a</span></div>
</figure>
);
}
Change history
Quality pass: undo on timers, sleep by hour chart, diaper color, fixes
Bounded source sync
Bounded source sync
Bounded source sync