Overview
Weekly digests provide automated summaries of your baby’s week, comparing current patterns to previous weeks and highlighting trends, improvements, or concerns.Schema Structure
convex/schema.ts
weeklyDigests: defineTable({
babyId: v.id("babyProfiles"),
weekStart: v.string(),
weekEnd: v.string(),
thisWeek: v.any(),
lastWeek: v.any(),
summary: v.string(),
createdAt: v.string(),
})
.index("by_babyId", ["babyId"])
.index("by_babyId_weekStart", ["babyId", "weekStart"])
Digest Fields
ISO date string (YYYY-MM-DD) for Monday of the week
ISO date string (YYYY-MM-DD) for Sunday of the week
Aggregated statistics for the current week (feeds, diapers, sleep, etc.)
Aggregated statistics for the previous week for comparison
AI-generated narrative summary of the week’s activities and trends
Computing Weekly Comparison
The digest system compares this week to last week:convex/digest.ts
export const getWeeklyComparison = query({
args: { babyId: v.id("babyProfiles") },
handler: async (ctx, args) => {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) return null;
await requireBabyAccess(ctx, args.babyId, user._id);
const now = new Date();
const dayOfWeek = now.getDay();
const thisMonday = new Date(now);
thisMonday.setDate(now.getDate() - ((dayOfWeek + 6) % 7));
thisMonday.setHours(0, 0, 0, 0);
const lastMonday = new Date(thisMonday);
lastMonday.setDate(thisMonday.getDate() - 7);
const thisSunday = new Date(thisMonday);
thisSunday.setDate(thisMonday.getDate() + 6);
thisSunday.setHours(23, 59, 59, 999);
const lastSunday = new Date(thisMonday);
lastSunday.setTime(thisMonday.getTime() - 1);
const thisWeek = await computeWeek(from: thisMonday, to: now);
const lastWeek = await computeWeek(from: lastMonday, to: lastSunday);
return { thisWeek, lastWeek };
},
});
Week Data Structure
Each week contains comprehensive statistics:type WeekData = {
from: string; // ISO date
to: string; // ISO date
totalEvents: number;
feeds: {
count: number;
totalMl: number;
breastMin: number;
perDay: number; // Average feeds per day
};
diapers: {
count: number;
wet: number;
dirty: number;
perDay: number; // Average diapers per day
};
sleep: {
totalHours: number;
sessions: number;
avgPerDay: number; // Average hours per day
};
meds: {
taken: number;
skipped: number;
adherence: number; // Percentage (0-100)
};
growth: number; // Number of growth measurements
notes: number; // Number of notes logged
};
Computing Week Statistics
convex/digest.ts
const computeWeek = async (from: Date, to: Date) => {
const events = await ctx.db
.query("events")
.withIndex("by_babyId_timestamp", (q) => q.eq("babyId", args.babyId))
.filter((q) =>
q.and(
q.gte(q.field("timestamp"), from.toISOString()),
q.lte(q.field("timestamp"), to.toISOString())
)
)
.collect();
let feedCount = 0, feedMl = 0, breastMin = 0;
let diaperCount = 0, diaperWet = 0, diaperDirty = 0;
let sleepMin = 0, sleepSessions = 0;
let medsTaken = 0, medsSkipped = 0;
let growthEntries = 0;
let noteCount = 0;
for (const e of events) {
const p = (e.payload ?? {}) as any;
switch (e.type) {
case "FEED_BOTTLE":
feedCount++;
feedMl += p.amountMl ?? 0;
break;
case "FEED_BREAST":
feedCount++;
breastMin += p.durationMin ?? 0;
break;
case "PUMP":
feedMl += p.amountMl ?? 0;
break;
case "DIAPER":
diaperCount++;
if (p.kind === "wet") diaperWet++;
if (p.kind === "dirty") diaperDirty++;
break;
case "SLEEP":
sleepSessions++;
if (p.startTs && p.endTs) {
sleepMin += Math.floor(
(new Date(p.endTs).getTime() - new Date(p.startTs).getTime()) / 60000
);
}
break;
case "MED_DOSE":
if (p.outcome === "taken") medsTaken++;
else if (p.outcome === "skipped") medsSkipped++;
break;
case "GROWTH":
growthEntries++;
break;
case "NOTE":
noteCount++;
break;
}
}
const daysInRange = Math.max(1, Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)));
return {
from: from.toISOString().split("T")[0],
to: to.toISOString().split("T")[0],
totalEvents: events.length,
feeds: {
count: feedCount,
totalMl: feedMl,
breastMin,
perDay: Math.round((feedCount / daysInRange) * 10) / 10
},
diapers: {
count: diaperCount,
wet: diaperWet,
dirty: diaperDirty,
perDay: Math.round((diaperCount / daysInRange) * 10) / 10
},
sleep: {
totalHours: Math.round((sleepMin / 60) * 10) / 10,
sessions: sleepSessions,
avgPerDay: Math.round((sleepMin / 60 / daysInRange) * 10) / 10
},
meds: {
taken: medsTaken,
skipped: medsSkipped,
adherence: medsTaken + medsSkipped > 0
? Math.round((medsTaken / (medsTaken + medsSkipped)) * 100)
: 100
},
growth: growthEntries,
notes: noteCount,
};
};
Saving a Digest
Digests are saved with AI-generated summaries:convex/digest.ts
export const saveDigest = mutation({
args: {
babyId: v.id("babyProfiles"),
weekStart: v.string(),
weekEnd: v.string(),
thisWeek: v.any(),
lastWeek: v.any(),
summary: v.string(),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
await requireBabyAccess(ctx, args.babyId, user._id);
const existing = await ctx.db
.query("weeklyDigests")
.withIndex("by_babyId_weekStart", (q) =>
q.eq("babyId", args.babyId).eq("weekStart", args.weekStart)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
summary: args.summary,
thisWeek: args.thisWeek,
lastWeek: args.lastWeek
});
return existing._id;
}
return await ctx.db.insert("weeklyDigests", {
...args,
createdAt: new Date().toISOString(),
});
},
});
Listing Digests
convex/digest.ts
export const listDigests = query({
args: { babyId: v.id("babyProfiles"), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) return [];
await requireBabyAccess(ctx, args.babyId, user._id);
return await ctx.db
.query("weeklyDigests")
.withIndex("by_babyId", (q) => q.eq("babyId", args.babyId))
.order("desc")
.take(args.limit ?? 10);
},
});
Getting Latest Digest
convex/digest.ts
export const getLatestDigest = query({
args: { babyId: v.id("babyProfiles") },
handler: async (ctx, args) => {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) return null;
await requireBabyAccess(ctx, args.babyId, user._id);
const digests = await ctx.db
.query("weeklyDigests")
.withIndex("by_babyId", (q) => q.eq("babyId", args.babyId))
.order("desc")
.take(1);
return digests[0] ?? null;
},
});
Usage Example
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
// Get weekly comparison
const comparison = useQuery(api.digest.getWeeklyComparison, { babyId });
// Generate AI summary (pseudo-code)
const summary = await generateAISummary(comparison);
// Save the digest
const saveDigest = useMutation(api.digest.saveDigest);
await saveDigest({
babyId,
weekStart: comparison.thisWeek.from,
weekEnd: comparison.thisWeek.to,
thisWeek: comparison.thisWeek,
lastWeek: comparison.lastWeek,
summary,
});
// List past digests
const digests = useQuery(api.digest.listDigests, { babyId, limit: 10 });
AI Summary Generation
Summaries are generated using AI based on week-over-week changes:function generateDigestPrompt(thisWeek: WeekData, lastWeek: WeekData) {
return `
Generate a friendly, concise weekly summary for a baby's activities.
This Week:
- Feeds: ${thisWeek.feeds.count} total (${thisWeek.feeds.perDay}/day), ${thisWeek.feeds.totalMl}ml
- Diapers: ${thisWeek.diapers.count} total (${thisWeek.diapers.perDay}/day)
- Sleep: ${thisWeek.sleep.totalHours} hours total (${thisWeek.sleep.avgPerDay} hrs/day)
- Medicine adherence: ${thisWeek.meds.adherence}%
Last Week:
- Feeds: ${lastWeek.feeds.count} total (${lastWeek.feeds.perDay}/day), ${lastWeek.feeds.totalMl}ml
- Diapers: ${lastWeek.diapers.count} total (${lastWeek.diapers.perDay}/day)
- Sleep: ${lastWeek.sleep.totalHours} hours total (${lastWeek.sleep.avgPerDay} hrs/day)
- Medicine adherence: ${lastWeek.meds.adherence}%
Provide:
1. Overview of this week's patterns
2. Notable changes from last week
3. Any concerns or positive trends
4. Brief encouragement for parents
`;
}
The AI summary generation would typically use an LLM API (OpenAI, Anthropic, etc.) to create natural language summaries.
Example Digest Summary
Here’s what an AI-generated summary might look like:🍼 Week of March 4-10, 2024
Great week! Your baby had 56 feeds this week (8 per day), maintaining a
steady feeding schedule. Total intake was 4,200ml, up slightly from last
week's 4,050ml.
😴 Sleep improved noticeably—baby got 98 hours total this week (14 hrs/day)
compared to 91 hours last week. The extra sleep is a positive sign of
growing comfort with nighttime routines.
🧷 Diaper changes were consistent at 49 this week (7 per day). Everything
looks healthy and normal.
💊 Excellent medicine adherence at 100%—all scheduled doses were given on time.
✨ Keep up the great work! The improved sleep is a wonderful milestone.
Digest Display Components
function DigestCard({ digest }: { digest: WeeklyDigest }) {
const thisWeek = digest.thisWeek as WeekData;
const lastWeek = digest.lastWeek as WeekData;
const feedChange = thisWeek.feeds.perDay - lastWeek.feeds.perDay;
const sleepChange = thisWeek.sleep.avgPerDay - lastWeek.sleep.avgPerDay;
return (
<div className="bg-white rounded-3xl p-6 shadow-sm border border-muted/10">
<h3 className="text-xl font-bold mb-2">
Week of {new Date(digest.weekStart).toLocaleDateString()}
</h3>
<div className="prose prose-sm mb-4">
<p>{digest.summary}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Feeds/day"
value={thisWeek.feeds.perDay}
change={feedChange}
/>
<StatCard
label="Sleep hrs/day"
value={thisWeek.sleep.avgPerDay}
change={sleepChange}
/>
</div>
</div>
);
}
Email Delivery
Digests can be delivered via email:// Pseudo-code for email delivery
async function sendWeeklyDigestEmail(babyId: Id<"babyProfiles">) {
const digest = await getLatestDigest({ babyId });
const profile = await getBabyProfile({ id: babyId });
const family = await getFamily(profile.familyId);
await sendEmail({
to: family.ownerEmail,
subject: `${profile.name}'s Weekly Digest - Week of ${digest.weekStart}`,
html: generateDigestEmailHTML(digest),
});
}
Email delivery would typically be handled by a scheduled Convex action or cron job that runs weekly.
Comparison Metrics
The digest system automatically computes changes:function computeChanges(thisWeek: WeekData, lastWeek: WeekData) {
return {
feedsPerDayChange: thisWeek.feeds.perDay - lastWeek.feeds.perDay,
feedsPerDayPercent: ((thisWeek.feeds.perDay - lastWeek.feeds.perDay) / lastWeek.feeds.perDay) * 100,
sleepPerDayChange: thisWeek.sleep.avgPerDay - lastWeek.sleep.avgPerDay,
sleepPerDayPercent: ((thisWeek.sleep.avgPerDay - lastWeek.sleep.avgPerDay) / lastWeek.sleep.avgPerDay) * 100,
diapersPerDayChange: thisWeek.diapers.perDay - lastWeek.diapers.perDay,
diapersPerDayPercent: ((thisWeek.diapers.perDay - lastWeek.diapers.perDay) / lastWeek.diapers.perDay) * 100,
medsAdherenceChange: thisWeek.meds.adherence - lastWeek.meds.adherence,
};
}
Trend Indicators
Visual indicators for trends:function TrendIndicator({ value, label }: { value: number; label: string }) {
const isPositive = value > 0;
const isNeutral = value === 0;
if (isNeutral) {
return <span className="text-muted">→ {label}</span>;
}
return (
<span className={isPositive ? "text-green-600" : "text-red-600"}>
{isPositive ? "↑" : "↓"} {Math.abs(value).toFixed(1)} {label}
</span>
);
}
Scheduled Generation
Digests are typically generated on a schedule:// Convex cron job (pseudo-code)
export const generateWeeklyDigests = internalAction({
handler: async (ctx) => {
// Run every Monday at 9 AM
const allProfiles = await ctx.runQuery(internal.events.listAllProfiles);
for (const profile of allProfiles) {
const comparison = await ctx.runQuery(api.digest.getWeeklyComparison, {
babyId: profile._id,
});
const summary = await generateAISummary(comparison);
await ctx.runMutation(api.digest.saveDigest, {
babyId: profile._id,
weekStart: comparison.thisWeek.from,
weekEnd: comparison.thisWeek.to,
thisWeek: comparison.thisWeek,
lastWeek: comparison.lastWeek,
summary,
});
}
},
});
Related Features
Dashboard
Weekly digest link shown on dashboard
Activity Tracking
All digest data computed from events
Baby Profiles
Each profile gets its own digests
Reminders
Medicine adherence tracked in digests
