baby-free.openapps.xyz

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.
Owned app · rev c84ed8821371
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.
← All files
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
c84ed88213 · 5 hours ago
Bounded source sync
1ed137d95d · 16 hours ago
Bounded source sync
851999f793 · 17 hours ago
Bounded source sync
9623bc231c · 17 hours ago