Skip to main content

Overview

Activity tracking is the core feature of Zen Nurture. Every activity—feeds, diapers, sleep, medicine, growth measurements, and notes—is stored as an event with detailed metadata.

Event Schema

All events share a common structure:
convex/schema.ts
events: defineTable({
  babyId: v.id("babyProfiles"),
  type: v.string(),
  timestamp: v.string(),
  caregiverId: v.optional(v.id("caregivers")),
  payload: v.optional(v.any()),
  source: v.optional(v.string()),
  loggedBy: v.optional(v.string()),
  loggedByName: v.optional(v.string()),
  photoIds: v.optional(v.array(v.string())),
  createdAt: v.string(),
  updatedAt: v.optional(v.string()),
})
  .index("by_babyId_timestamp", ["babyId", "timestamp"])
  .index("by_babyId_type_timestamp", ["babyId", "type", "timestamp"])
  .index("by_type_timestamp", ["type", "timestamp"])
  .index("by_babyId_type", ["babyId", "type"])

Event Types

Zen Nurture supports the following event types:
src/lib/constants.ts
export const EVENT_TYPES = {
  FEED_BOTTLE: "FEED_BOTTLE",
  FEED_BREAST: "FEED_BREAST",
  PUMP: "PUMP",
  DIAPER: "DIAPER",
  MED_DOSE: "MED_DOSE",
  SLEEP: "SLEEP",
  GROWTH: "GROWTH",
  NOTE: "NOTE",
  VACCINE_DOSE: "VACCINE_DOSE",
} as const;

Logging Events

Use the QuickLoggerDrawer component for rapid event logging:
src/components/QuickLoggerDrawer.tsx
export type QuickLogPrefill = {
  view: "feed" | "diaper" | "sleep" | "meds";
  feedSubType?: "bottle" | "breast";
  volume?: number;
  duration?: number;
  diaperKind?: "wet" | "dirty" | "dry" | "mixed";
  medName?: string;
  isSleepingNow?: boolean;
};

Create Event Mutation

convex/events.ts
export const createEvent = mutation({
  args: {
    babyId: v.id("babyProfiles"),
    type: v.string(),
    timestamp: v.string(),
    caregiverId: v.optional(v.id("caregivers")),
    payload: v.optional(v.any()),
    source: v.optional(v.string()),
    photoIds: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    await requireBabyAccess(ctx, args.babyId, user._id);
    const id = await ctx.db.insert("events", {
      ...args,
      source: args.source || "manual",
      createdAt: new Date().toISOString(),
      loggedBy: user._id,
      loggedByName: user.name,
    });
    return id;
  },
});

Feed Events

Bottle Feeds

type BottleFeedPayload = {
  amountMl: number;
  contentType: "formula" | "breast_milk" | "cow_milk";
  formulaName?: string;  // e.g., "Enfamil", "Similac"
  notes?: string;
};

await createEvent({
  babyId,
  type: "FEED_BOTTLE",
  timestamp: new Date().toISOString(),
  payload: {
    amountMl: 120,
    contentType: "formula",
    formulaName: "Enfamil",
  },
});

Breast Feeds

type BreastFeedPayload = {
  side: "left" | "right" | "both";
  durationMin: number;
  notes?: string;
};

await createEvent({
  babyId,
  type: "FEED_BREAST",
  timestamp: new Date().toISOString(),
  payload: {
    side: "left",
    durationMin: 15,
  },
});

Pumping

type PumpPayload = {
  amountMl: number;
  side?: "left" | "right" | "both";
  durationMin?: number;
  notes?: string;
};

await createEvent({
  babyId,
  type: "PUMP",
  timestamp: new Date().toISOString(),
  payload: {
    amountMl: 90,
    side: "both",
    durationMin: 20,
  },
});

Diaper Events

type DiaperPayload = {
  kind: "wet" | "dirty" | "dry" | "mixed";
  texture?: "runny" | "mucousy" | "mushy" | "solid" | "pebbles";
  color?: "black" | "green" | "yellow" | "brown" | "red" | "gray";
  blowout?: boolean;
  rash?: boolean;
  notes?: string;
};

await createEvent({
  babyId,
  type: "DIAPER",
  timestamp: new Date().toISOString(),
  payload: {
    kind: "dirty",
    texture: "mushy",
    color: "yellow",
    blowout: false,
    rash: false,
  },
});

Diaper Constants

src/lib/constants.ts
export const DIAPER_KINDS = {
  WET: "wet",
  DIRTY: "dirty",
  DRY: "dry",
  MIXED: "mixed",
} as const;

export const DIAPER_TEXTURES = {
  RUNNY: "runny",
  MUCOUSY: "mucousy",
  MUSHY: "mushy",
  SOLID: "solid",
  PEBBLES: "pebbles",
} as const;

export const DIAPER_COLORS = {
  BLACK: "black",
  GREEN: "green",
  YELLOW: "yellow",
  BROWN: "brown",
  RED: "red",
  GRAY: "gray",
} as const;

Sleep Events

type SleepPayload = {
  startTs: string;  // ISO timestamp
  endTs?: string;   // Optional - omit if still sleeping
  kind?: "nap" | "night";
  notes?: string;
};

// Log sleep start
await createEvent({
  babyId,
  type: "SLEEP",
  timestamp: new Date().toISOString(),
  payload: {
    startTs: new Date().toISOString(),
    kind: "nap",
  },
});

// Update when baby wakes
await updateEvent({
  id: eventId,
  payload: {
    startTs: startTime,
    endTs: new Date().toISOString(),
    kind: "nap",
  },
});
Active sleep sessions (without endTs) are detected and displayed with a pulsing indicator on the dashboard.

Medicine Events

type MedicinePayload = {
  medicineName: string;
  dose: number;
  doseUnit: string;  // "ml", "mg", "drops", etc.
  outcome: "taken" | "skipped" | "vomited";
  concentrationText?: string;  // e.g., "100mg/5ml"
  instructions?: string;
  notes?: string;
};

await createEvent({
  babyId,
  type: "MED_DOSE",
  timestamp: new Date().toISOString(),
  payload: {
    medicineName: "Paracetamol (Crocin)",
    dose: 2.5,
    doseUnit: "ml",
    outcome: "taken",
    concentrationText: "120mg/5ml",
  },
});

Medicine Database

Commonly used medicines are stored separately:
convex/schema.ts
medicines: defineTable({
  name: v.string(),
  defaultDoseUnit: v.optional(v.string()),
  concentrationText: v.optional(v.string()),
  instructions: v.optional(v.string()),
  createdAt: v.string(),
}).index("by_name", ["name"])

Growth Events

type GrowthPayload = {
  weightKg?: number;
  lengthCm?: number;
  headCircumferenceCm?: number;
  notes?: string;
};

await createEvent({
  babyId,
  type: "GROWTH",
  timestamp: new Date().toISOString(),
  payload: {
    weightKg: 5.2,
    lengthCm: 54.5,
    headCircumferenceCm: 37.0,
  },
});

Note Events

type NotePayload = {
  notes: string;
  category?: string;  // Optional categorization
};

await createEvent({
  babyId,
  type: "NOTE",
  timestamp: new Date().toISOString(),
  payload: {
    notes: "First tooth visible!",
  },
});

Photo Attachments

Any event can include photo attachments:
await createEvent({
  babyId,
  type: "FEED_BOTTLE",
  timestamp: new Date().toISOString(),
  payload: { amountMl: 120 },
  photoIds: ["file_abc123", "file_def456"],
});
Photos are stored in the files table:
convex/schema.ts
files: defineTable({
  babyId: v.id("babyProfiles"),
  filename: v.string(),
  mimeType: v.string(),
  storageId: v.string(),
  tags: v.optional(v.array(v.string())),
  capturedAt: v.string(),
  notes: v.optional(v.string()),
  createdAt: v.string(),
})

Querying Events

By Date Range

convex/events.ts
export const listEvents = query({
  args: {
    babyId: v.id("babyProfiles"),
    from: v.optional(v.string()),
    to: v.optional(v.string()),
    type: v.optional(v.string()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let q = ctx.db
      .query("events")
      .withIndex("by_babyId_timestamp", (q) => q.eq("babyId", args.babyId));

    if (args.from) {
      q = q.filter((q) => q.gte(q.field("timestamp"), args.from!));
    }
    if (args.to) {
      q = q.filter((q) => q.lte(q.field("timestamp"), args.to!));
    }
    if (args.type) {
      q = q.filter((q) => q.eq(q.field("type"), args.type!));
    }

    return await q.order("desc").take(args.limit || 100);
  },
});

Get Last Event by Type

const lastFeed = useQuery(api.events.getLastEventByType, {
  babyId,
  eventType: "FEED_BOTTLE",
});

Get Multiple Last Events

const lastEvents = useQuery(api.events.getLastEventsByTypes, {
  babyId,
  eventTypes: ["FEED_BOTTLE", "FEED_BREAST", "DIAPER", "SLEEP"],
});
// Returns: { "FEED_BOTTLE": {...}, "FEED_BREAST": {...}, ... }

Daily Aggregates

Get computed statistics for a specific day:
const aggregates = useQuery(api.events.getDailyAggregates, {
  babyId,
  date: "2024-03-05",
});

// Returns:
// {
//   date: "2024-03-05",
//   feeds: {
//     totalMl: 720,
//     totalBreastMin: 45,
//     count: 8,
//     bottleSizeAvg: 120,
//     avgGapMin: 180  // 3 hours
//   },
//   diapers: {
//     wet: 4,
//     dirty: 2,
//     dry: 0,
//     mixed: 1,
//     total: 7,
//     byTexture: { mushy: 2, solid: 1 },
//     byColor: { yellow: 3 },
//     blowoutCount: 0,
//     rashCount: 0
//   },
//   sleeps: {
//     totalMinutes: 840,  // 14 hours
//     sessions: 6
//   },
//   meds: {
//     taken: 2,
//     skipped: 0,
//     adherence: 100
//   }
// }

Range Aggregates

Get day-by-day breakdowns for a date range:
const rangeData = useQuery(api.events.getRangeAggregates, {
  babyId,
  from: "2024-03-01T00:00:00.000Z",
  to: "2024-03-07T23:59:59.999Z",
});

// Returns:
// {
//   "2024-03-01": { feeds: {...}, diapers: {...}, ... },
//   "2024-03-02": { feeds: {...}, diapers: {...}, ... },
//   ...
// }

Updating and Deleting Events

Update Event

convex/events.ts
export const updateEvent = mutation({
  args: {
    id: v.id("events"),
    timestamp: v.optional(v.string()),
    caregiverId: v.optional(v.id("caregivers")),
    payload: v.optional(v.any()),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const event = await ctx.db.get(args.id);
    if (!event) throw new Error("Event not found");
    await requireBabyAccess(ctx, event.babyId, user._id);
    const { id, ...updates } = args;
    await ctx.db.patch(id, {
      ...updates,
      updatedAt: new Date().toISOString(),
    });
    return id;
  },
});

Delete Event

const deleteEvent = useMutation(api.events.deleteEvent);
await deleteEvent({ id: eventId });

Event Sources

The source field tracks how events were created:
  • "manual": Logged by user via UI (default)
  • "quick_action": Via quick action buttons
  • "suggestion": From AI suggestions
  • "import": Imported from external source
  • "api": Created via API

Dashboard

View live activity timers and summaries

Reminders

Set reminders based on event patterns

Weekly Digests

AI-generated summaries of activity data

Baby Profiles

All events are linked to a baby profile

Build docs developers (and LLMs) love