Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Core entities modeled after Baby Buddy:
- **Diaper Change** — time, type (wet/solid/both), color, notes
- **Sleep** — start/end time, nap vs. night, notes
- **Tummy Time** — start/end time, milestone, notes
- **Pumping** — start/end time, amount, notes
- **Pumping** — start/end time, breast side (left/right/both), amount, notes
- **Growth** — date, weight, height, head circumference
- **Temperature** — time, reading, notes
- **Note** — freeform note attached to a child and time
Expand Down
17 changes: 17 additions & 0 deletions client/src/components/QuickLogDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import { clearDraft, loadDraft, saveDraft } from "../utils/formDraft";
import { PUMPING_SIDES } from "../utils/pumping";
import NowButton from "./NowButton";

export type QuickLogCategory = "feed" | "diaper" | "sleep" | "pump" | "tummy" | "note";
Expand Down Expand Up @@ -78,6 +79,8 @@ interface FormState {
feedingType: string;
amount: string;
amountUnit: string;
// pumping
pumpSide: string;
// diaper
diaperType: string;
color: string;
Expand All @@ -101,6 +104,7 @@ function emptyForm(): FormState {
feedingType: "bottle_formula",
amount: "",
amountUnit: "oz",
pumpSide: "both",
diaperType: "wet",
color: "",
isNap: true,
Expand Down Expand Up @@ -198,6 +202,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
child_id: selectedChild.id,
start_time: startIso,
end_time: endIso,
side: form.pumpSide,
amount: form.amount ? parseFloat(form.amount) : null,
amount_unit: form.amount ? form.amountUnit : null,
notes: form.notes || null,
Expand Down Expand Up @@ -376,6 +381,18 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
)}
{category === "pump" && (
<>
<TextField
select
margin="dense"
label="Breast"
fullWidth
value={form.pumpSide}
onChange={(e) => setForm({ ...form, pumpSide: e.target.value })}
>
{PUMPING_SIDES.map((s) => (
<MenuItem key={s.value} value={s.value}>{s.label}</MenuItem>
))}
</TextField>
{timeField}
{endTimeField}
<Box sx={{ display: "flex", gap: 2 }}>
Expand Down
3 changes: 2 additions & 1 deletion client/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useNotification } from "../hooks/useNotification";
import NoChildPlaceholder from "../components/NoChildPlaceholder";
import QuickLogDialog, { type QuickLogCategory } from "../components/QuickLogDialog";
import { buildCategoryColors, type CategoryKey } from "../theme/categoryColors";
import { sideLabel } from "../utils/pumping";
import type {
Feeding,
DiaperChange,
Expand Down Expand Up @@ -291,7 +292,7 @@ export default function Dashboard() {
feedings.slice(0, 10).forEach((f) => allEvents.push({ cat: "feed", title: `${prettifyType(f.type)}${f.amount ? ` · ${f.amount}${f.amount_unit ? ` ${f.amount_unit}` : ""}` : ""}`, time: new Date(f.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(f.start_time, f.end_time) }));
diapers.slice(0, 10).forEach((d) => allEvents.push({ cat: "diaper", title: `Diaper · ${prettifyType(d.type)}`, time: new Date(d.time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: d.color || "" }));
sleeps.slice(0, 10).forEach((s) => allEvents.push({ cat: "sleep", title: s.is_nap ? "Nap" : "Sleep", time: new Date(s.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: s.end_time ? formatDuration(s.start_time, s.end_time) : `Active · ${formatDuration(s.start_time, null)}`, live: !s.end_time }));
pumpings.slice(0, 10).forEach((p) => allEvents.push({ cat: "pump", title: `Pump${p.amount ? ` · ${p.amount}${p.amount_unit ? ` ${p.amount_unit}` : ""}` : ""}`, time: new Date(p.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(p.start_time, p.end_time) }));
pumpings.slice(0, 10).forEach((p) => allEvents.push({ cat: "pump", title: `Pump${sideLabel(p.side) ? ` · ${sideLabel(p.side)}` : ""}${p.amount ? ` · ${p.amount}${p.amount_unit ? ` ${p.amount_unit}` : ""}` : ""}`, time: new Date(p.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(p.start_time, p.end_time) }));
tummyTimes.slice(0, 10).forEach((tt) => allEvents.push({ cat: "tummy", title: "Tummy time", time: new Date(tt.start_time).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), meta: formatDuration(tt.start_time, tt.end_time) }));

allEvents.sort((a, b) => {
Expand Down
29 changes: 24 additions & 5 deletions client/src/pages/PumpingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import NoChildPlaceholder from "../components/NoChildPlaceholder";

import type { Pumping } from "../types/models";
import { isoToLocal } from "../utils/dateTime";
import { PUMPING_SIDES, sideLabel } from "../utils/pumping";
import { buildCategoryColors } from "../theme/categoryColors";

function relativeTime(iso: string): string {
Expand Down Expand Up @@ -112,7 +113,7 @@ export default function PumpingPage() {
const [entries, setEntries] = useState<Pumping[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<Pumping | null>(null);
const [form, setForm] = useState({ start_time: "", end_time: "", amount: "", amount_unit: "oz", notes: "" });
const [form, setForm] = useState({ start_time: "", end_time: "", side: "both", amount: "", amount_unit: "oz", notes: "" });
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuEntry, setMenuEntry] = useState<Pumping | null>(null);

Expand All @@ -135,6 +136,7 @@ export default function PumpingPage() {
setForm({
start_time: isoToLocal(entry.start_time),
end_time: entry.end_time ? isoToLocal(entry.end_time) : "",
side: entry.side ?? "both",
amount: entry.amount != null ? String(entry.amount) : "",
amount_unit: entry.amount_unit || "oz",
notes: entry.notes || "",
Expand All @@ -144,7 +146,7 @@ export default function PumpingPage() {

const openAdd = () => {
setEditingEntry(null);
setForm({ start_time: "", end_time: "", amount: "", amount_unit: "oz", notes: "" });
setForm({ start_time: "", end_time: "", side: "both", amount: "", amount_unit: "oz", notes: "" });
setDialogOpen(true);
};

Expand All @@ -153,6 +155,7 @@ export default function PumpingPage() {
const payload = {
start_time: new Date(form.start_time).toISOString(),
end_time: form.end_time ? new Date(form.end_time).toISOString() : null,
side: form.side,
amount: form.amount ? parseFloat(form.amount) : null,
amount_unit: form.amount ? form.amount_unit : null,
notes: form.notes || null,
Expand All @@ -165,7 +168,7 @@ export default function PumpingPage() {
}
setDialogOpen(false);
setEditingEntry(null);
setForm({ start_time: "", end_time: "", amount: "", amount_unit: "oz", notes: "" });
setForm({ start_time: "", end_time: "", side: "both", amount: "", amount_unit: "oz", notes: "" });
await load();
} catch (err) {
notify(err instanceof Error ? err.message : "Failed to save pumping session.", "error");
Expand Down Expand Up @@ -290,10 +293,12 @@ export default function PumpingPage() {
</Box>
{items.map((p) => {
const duration = humanDuration(p.start_time, p.end_time);
const primary =
const side = sideLabel(p.side);
const summary =
p.amount != null
? `${p.amount} ${p.amount_unit ?? "oz"}`
: duration ?? "In progress";
const primary = side ? `${side} · ${summary}` : summary;
const meta = duration && p.amount != null ? duration : (p.notes || "—");
return (
<Box
Expand Down Expand Up @@ -348,6 +353,7 @@ export default function PumpingPage() {
<TableCell>Start</TableCell>
<TableCell>End</TableCell>
<TableCell>Duration</TableCell>
<TableCell>Breast</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Notes</TableCell>
<TableCell />
Expand All @@ -359,6 +365,7 @@ export default function PumpingPage() {
<TableCell>{new Date(p.start_time).toLocaleString()}</TableCell>
<TableCell>{p.end_time ? new Date(p.end_time).toLocaleString() : "In progress"}</TableCell>
<TableCell>{humanDuration(p.start_time, p.end_time) ?? "—"}</TableCell>
<TableCell>{sideLabel(p.side) ?? "—"}</TableCell>
<TableCell>{p.amount ? `${p.amount} ${p.amount_unit}` : "—"}</TableCell>
<TableCell>{p.notes || "—"}</TableCell>
<TableCell>
Expand All @@ -373,7 +380,7 @@ export default function PumpingPage() {
))}
{sortedEntries.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">
<TableCell colSpan={7} align="center">
<Typography color="text.secondary">No pumping sessions recorded.</Typography>
</TableCell>
</TableRow>
Expand Down Expand Up @@ -457,6 +464,18 @@ export default function PumpingPage() {
/>
<NowButton onSetNow={(v) => setForm({ ...form, end_time: v })} />
</Box>
<TextField
select
margin="dense"
label="Breast"
fullWidth
value={form.side}
onChange={(e) => setForm({ ...form, side: e.target.value })}
>
{PUMPING_SIDES.map((s) => (
<MenuItem key={s.value} value={s.value}>{s.label}</MenuItem>
))}
</TextField>
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
margin="dense"
Expand Down
3 changes: 3 additions & 0 deletions client/src/types/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ export interface TummyTime {
updated_at: string;
}

export type PumpingSide = "left" | "right" | "both";

export interface Pumping {
id: number;
child_id: number;
start_time: string;
end_time: string | null;
side: PumpingSide | null;
amount: number | null;
amount_unit: "ml" | "oz" | null;
notes: string | null;
Expand Down
12 changes: 12 additions & 0 deletions client/src/utils/pumping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { PumpingSide } from "../types/models";

export const PUMPING_SIDES: { value: PumpingSide; label: string }[] = [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "both", label: "Both" },
];

/** Human label for a stored side, or null when the session predates side tracking. */
export function sideLabel(side: string | null | undefined): string | null {
return PUMPING_SIDES.find((s) => s.value === side)?.label ?? null;
}
3 changes: 3 additions & 0 deletions server/migrations/0009_add_pumping_side.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Track which breast a pumping session was for.
-- NULL means the side wasn't recorded (entries logged before this migration).
ALTER TABLE pumping ADD COLUMN side TEXT CHECK(side IN ('left', 'right', 'both'));
12 changes: 6 additions & 6 deletions server/seed/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,12 @@ INSERT INTO tummy_time (child_id, start_time, end_time, milestone, notes) VALUES
(2, '2026-03-12T10:00:00Z', '2026-03-12T10:07:00Z', 'Looking around', NULL);

-- Pumping sessions
INSERT INTO pumping (child_id, start_time, end_time, amount, amount_unit, notes) VALUES
(1, '2026-03-13T07:00:00Z', '2026-03-13T07:20:00Z', 5, 'oz', 'Morning pump'),
(1, '2026-03-13T13:00:00Z', '2026-03-13T13:20:00Z', 4, 'oz', 'Afternoon pump'),
(1, '2026-03-13T21:00:00Z', '2026-03-13T21:15:00Z', 3, 'oz', 'Evening pump'),
(1, '2026-03-12T07:00:00Z', '2026-03-12T07:20:00Z', 5, 'oz', NULL),
(1, '2026-03-12T13:00:00Z', '2026-03-12T13:15:00Z', 4, 'oz', NULL);
INSERT INTO pumping (child_id, start_time, end_time, side, amount, amount_unit, notes) VALUES
(1, '2026-03-13T07:00:00Z', '2026-03-13T07:20:00Z', 'both', 5, 'oz', 'Morning pump'),
(1, '2026-03-13T13:00:00Z', '2026-03-13T13:20:00Z', 'left', 4, 'oz', 'Afternoon pump'),
(1, '2026-03-13T21:00:00Z', '2026-03-13T21:15:00Z', 'right', 3, 'oz', 'Evening pump'),
(1, '2026-03-12T07:00:00Z', '2026-03-12T07:20:00Z', 'both', 5, 'oz', NULL),
(1, '2026-03-12T13:00:00Z', '2026-03-12T13:15:00Z', 'both', 4, 'oz', NULL);

-- Growth for Liam
INSERT INTO growth (child_id, date, weight, weight_unit, height, height_unit, head_circumference, head_circumference_unit, notes) VALUES
Expand Down
3 changes: 2 additions & 1 deletion server/src/routes/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ activity.get("/", async (c) => {
`).bind(childId, fromDate, toDate).all<ActivityEntry>(),
c.env.DB.prepare(`
SELECT 'Pumping' AS activity_type, p.start_time AS event_time,
CASE WHEN p.amount IS NOT NULL THEN 'pumped ' || p.amount || ' ' || COALESCE(p.amount_unit, '') ELSE 'pumping' END AS detail,
CASE WHEN p.amount IS NOT NULL THEN 'pumped ' || p.amount || ' ' || COALESCE(p.amount_unit, '') ELSE 'pumping' END
|| CASE p.side WHEN 'left' THEN ' · left breast' WHEN 'right' THEN ' · right breast' WHEN 'both' THEN ' · both breasts' ELSE '' END AS detail,
${childNameExpr} AS child_name, ${loggedByExpr} AS logged_by
FROM pumping p
JOIN children c ON c.id = p.child_id
Expand Down
2 changes: 1 addition & 1 deletion server/src/routes/pumping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createChildScopedCrud } from "./crud.js";

export const pumping = createChildScopedCrud({
table: "pumping",
columns: ["start_time", "end_time", "amount", "amount_unit", "notes"],
columns: ["start_time", "end_time", "side", "amount", "amount_unit", "notes"],
requiredColumns: ["start_time"],
orderBy: "start_time",
});
17 changes: 13 additions & 4 deletions server/src/scheduled/dailySummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,17 @@ interface FeedingRow { type: string; start_time: string; end_time: string | null
interface DiaperRow { time: string; type: string; color: string | null }
interface SleepRow { start_time: string; end_time: string | null; is_nap: number }
interface TummyRow { start_time: string; end_time: string | null; milestone: string | null }
interface PumpingRow { start_time: string; end_time: string | null; amount: number | null; amount_unit: string | null }
interface PumpingRow { start_time: string; end_time: string | null; side: string | null; amount: number | null; amount_unit: string | null }
interface TemperatureRow { time: string; reading: number; reading_unit: string }
interface NoteRow { time: string; title: string | null; content: string }
interface HistoryEntryRow { activity_type: string; event_time: string; detail: string; child_name: string; logged_by: string }

const PUMPING_SIDE_LABELS: Record<string, string> = {
left: "left breast",
right: "right breast",
both: "both breasts",
};

// ── Data fetching ─────────────────────────────────────────────────────────────

async function fetchChildData(
Expand Down Expand Up @@ -88,7 +94,7 @@ async function fetchChildData(
"SELECT start_time, end_time, milestone FROM tummy_time WHERE child_id = ? AND start_time < ? AND (end_time IS NULL OR end_time > ?) ORDER BY start_time"
).bind(childId, end, start).all<TummyRow>(),
env.DB.prepare(
"SELECT start_time, end_time, amount, amount_unit FROM pumping WHERE child_id = ? AND start_time < ? AND (end_time IS NULL OR end_time > ?) ORDER BY start_time"
"SELECT start_time, end_time, side, amount, amount_unit FROM pumping WHERE child_id = ? AND start_time < ? AND (end_time IS NULL OR end_time > ?) ORDER BY start_time"
).bind(childId, end, start).all<PumpingRow>(),
env.DB.prepare(
"SELECT time, reading, reading_unit FROM temperature WHERE child_id = ? AND time >= ? AND time < ? ORDER BY time"
Expand Down Expand Up @@ -164,7 +170,8 @@ async function fetchActivityHistory(
`).bind(userId, windowStart, windowEnd).all<HistoryEntryRow>(),
env.DB.prepare(`
SELECT 'Pumping' AS activity_type, p.start_time AS event_time,
CASE WHEN p.amount IS NOT NULL THEN 'pumped ' || p.amount || ' ' || COALESCE(p.amount_unit, '') ELSE 'pumping' END AS detail,
CASE WHEN p.amount IS NOT NULL THEN 'pumped ' || p.amount || ' ' || COALESCE(p.amount_unit, '') ELSE 'pumping' END
|| CASE p.side WHEN 'left' THEN ' · left breast' WHEN 'right' THEN ' · right breast' WHEN 'both' THEN ' · both breasts' ELSE '' END AS detail,
${childNameExpr} AS child_name, ${loggedByExpr} AS logged_by
FROM pumping p
JOIN children c ON c.id = p.child_id
Expand Down Expand Up @@ -326,8 +333,10 @@ function buildChildSection(
const totalStr = totalAmount > 0 ? ` · ${totalAmount.toFixed(1)} ${esc(unit)} total` : "";
rows.push(sectionHeader("🍶", `Pumping (${pumping.length} session${pumping.length !== 1 ? "s" : ""}${totalStr})`));
for (const p of pumping) {
const sideLabel = PUMPING_SIDE_LABELS[p.side ?? ""];
const side = sideLabel ? ` · ${sideLabel}` : "";
const amount = p.amount != null ? ` · ${p.amount} ${esc(p.amount_unit ?? "")}` : "";
rows.push(row(`${esc(formatTime(p.start_time))} &mdash; ${esc(fmtDuration(durationMins(p.start_time, p.end_time)))}${amount}`));
rows.push(row(`${esc(formatTime(p.start_time))} &mdash; ${esc(fmtDuration(durationMins(p.start_time, p.end_time)))}${side}${amount}`));
}
}

Expand Down
64 changes: 64 additions & 0 deletions server/test/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,67 @@ describe("Sleep API", () => {
expect(entries).toHaveLength(1);
});
});

describe("Pumping API", () => {
let api: ReturnType<typeof testRequest>;
let childId: number;

beforeEach(async () => {
const app = createTestApp();
await applyMigrations(env.DB);
api = testRequest(app, env.DB);

const res = await api.post("/api/children", {
first_name: "Emma",
birth_date: "2024-06-15",
});
childId = ((await res.json()) as { id: number }).id;
});

it("POST /api/pumping stores the breast side", async () => {
const res = await api.post("/api/pumping", {
child_id: childId,
start_time: "2024-12-01T07:00:00Z",
end_time: "2024-12-01T07:20:00Z",
side: "left",
amount: 4,
amount_unit: "oz",
});
expect(res.status).toBe(201);
const entry = (await res.json()) as Record<string, unknown>;
expect(entry.side).toBe("left");
});

it("POST /api/pumping leaves side null when omitted", async () => {
const res = await api.post("/api/pumping", {
child_id: childId,
start_time: "2024-12-01T07:00:00Z",
});
expect(res.status).toBe(201);
const entry = (await res.json()) as Record<string, unknown>;
expect(entry.side).toBeNull();
});

it("PUT /api/pumping/:id updates the breast side", async () => {
const created = await api.post("/api/pumping", {
child_id: childId,
start_time: "2024-12-01T07:00:00Z",
side: "right",
});
const { id } = (await created.json()) as { id: number };

const res = await api.put(`/api/pumping/${id}`, { side: "both" });
expect(res.status).toBe(200);
const entry = (await res.json()) as Record<string, unknown>;
expect(entry.side).toBe("both");
});

it("POST /api/pumping rejects an unknown breast side", async () => {
const res = await api.post("/api/pumping", {
child_id: childId,
start_time: "2024-12-01T07:00:00Z",
side: "middle",
});
expect(res.status).toBe(500);
});
});
Loading