Skip to main content

Overview

The milestones system helps parents track and celebrate their baby’s developmental achievements. Each milestone can include photos, videos, notes, and an achievement date.

Schema Structure

convex/schema.ts
milestones: defineTable({
  babyId: v.id("babyProfiles"),
  key: v.string(),
  title: v.string(),
  category: v.string(),
  achievedAt: v.optional(v.string()),
  note: v.optional(v.string()),
  photoIds: v.optional(v.array(v.string())),
  videoIds: v.optional(v.array(v.string())),
  isCustom: v.optional(v.boolean()),
  createdAt: v.string(),
})
  .index("by_babyId", ["babyId"])
  .index("by_babyId_key", ["babyId", "key"])

Milestone Fields

key
string
required
Unique identifier for the milestone. Predefined milestones use keys like "first_smile", custom milestones use generated keys like "custom_1234567890_abc123"
title
string
required
Display name of the milestone (e.g., “First Smile”, “Rolled Over”, “First Word”)
category
string
required
Category grouping: "motor", "language", "social", "cognitive", or custom categories
achievedAt
string
ISO timestamp when the milestone was achieved. Omit for unachieved milestones
note
string
Optional note about the milestone achievement
photoIds
string[]
Array of file IDs for attached photos
videoIds
string[]
Array of file IDs for attached videos
isCustom
boolean
true for user-created milestones, false or omitted for predefined milestones

Milestone Categories

Milestones are grouped into developmental categories:
Physical development milestones:
  • Rolling over
  • Sitting up
  • Crawling
  • Standing
  • First steps
  • Walking independently
Communication milestones:
  • First coos
  • Babbling
  • First word
  • Two-word phrases
  • Simple sentences
Social development milestones:
  • First smile
  • Recognizes parents
  • Stranger anxiety
  • Waving bye-bye
  • Playing with others
Mental development milestones:
  • Tracks objects with eyes
  • Responds to name
  • Object permanence
  • Points to objects
  • Follows simple instructions

Achieving a Milestone

Mark a predefined milestone as achieved:
convex/milestones.ts
export const achieve = mutation({
  args: {
    babyId: v.id("babyProfiles"),
    key: v.string(),
    title: v.string(),
    category: v.string(),
    achievedAt: v.optional(v.string()),
    note: v.optional(v.string()),
    photoIds: v.optional(v.array(v.string())),
    videoIds: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    await requireBabyAccess(ctx, args.babyId, user._id);
    const existing = await ctx.db
      .query("milestones")
      .withIndex("by_babyId_key", (q) =>
        q.eq("babyId", args.babyId).eq("key", args.key)
      )
      .first();

    const achievedAt = args.achievedAt ?? new Date().toISOString();

    if (existing) {
      await ctx.db.patch(existing._id, {
        achievedAt,
        note: args.note,
        photoIds: args.photoIds,
        videoIds: args.videoIds,
      });
      return existing._id;
    }

    return await ctx.db.insert("milestones", {
      babyId: args.babyId,
      key: args.key,
      title: args.title,
      category: args.category,
      achievedAt,
      note: args.note,
      photoIds: args.photoIds,
      videoIds: args.videoIds,
      createdAt: new Date().toISOString(),
    });
  },
});

Usage Example

import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";

const achieveMilestone = useMutation(api.milestones.achieve);

await achieveMilestone({
  babyId,
  key: "first_smile",
  title: "First Smile",
  category: "social",
  achievedAt: "2024-02-14T10:30:00.000Z",
  note: "Such a beautiful moment!",
  photoIds: ["file_abc123"],
});

Creating Custom Milestones

Parents can add their own milestones:
convex/milestones.ts
export const createCustom = mutation({
  args: {
    babyId: v.id("babyProfiles"),
    title: v.string(),
    category: v.string(),
    achievedAt: v.optional(v.string()),
    note: v.optional(v.string()),
    photoIds: v.optional(v.array(v.string())),
    videoIds: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    await requireBabyAccess(ctx, args.babyId, user._id);

    const key = `custom_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
    const achievedAt = args.achievedAt ?? new Date().toISOString();

    return await ctx.db.insert("milestones", {
      babyId: args.babyId,
      key,
      title: args.title.trim(),
      category: args.category,
      achievedAt,
      note: args.note,
      photoIds: args.photoIds,
      videoIds: args.videoIds,
      isCustom: true,
      createdAt: new Date().toISOString(),
    });
  },
});

Custom Milestone Example

const createCustomMilestone = useMutation(api.milestones.createCustom);

await createCustomMilestone({
  babyId,
  title: "Tried Solid Food",
  category: "feeding",
  achievedAt: "2024-03-05T12:00:00.000Z",
  note: "First taste of mashed banana - loved it!",
  photoIds: ["file_xyz789"],
});
Custom milestones automatically receive a unique key prefixed with custom_ to distinguish them from predefined milestones.

Listing Milestones

Retrieve all milestones for a baby:
convex/milestones.ts
export const list = query({
  args: { babyId: v.id("babyProfiles") },
  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("milestones")
      .withIndex("by_babyId", (q) => q.eq("babyId", args.babyId))
      .collect();
  },
});

Usage

const milestones = useQuery(api.milestones.list, { babyId });

// Filter achieved vs. unachieved
const achieved = milestones?.filter(m => m.achievedAt) || [];
const upcoming = milestones?.filter(m => !m.achievedAt) || [];

// Group by category
const byCategory = milestones?.reduce((acc, m) => {
  if (!acc[m.category]) acc[m.category] = [];
  acc[m.category].push(m);
  return acc;
}, {} as Record<string, typeof milestones>);

Updating Milestones

Modify notes or attachments:
convex/milestones.ts
export const updateMilestone = mutation({
  args: {
    id: v.id("milestones"),
    note: v.optional(v.string()),
    photoIds: v.optional(v.array(v.string())),
    videoIds: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const milestone = await ctx.db.get(args.id);
    if (!milestone) throw new Error("Milestone not found");
    await requireBabyAccess(ctx, milestone.babyId, user._id);

    const updates: Record<string, unknown> = {};
    if (args.note !== undefined) updates.note = args.note;
    if (args.photoIds !== undefined) updates.photoIds = args.photoIds;
    if (args.videoIds !== undefined) updates.videoIds = args.videoIds;

    if (Object.keys(updates).length > 0) {
      await ctx.db.patch(args.id, updates);
    }
    return args.id;
  },
});

Unachieving a Milestone

Revert a milestone to unachieved state:
convex/milestones.ts
export const unachieve = mutation({
  args: { id: v.id("milestones") },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const milestone = await ctx.db.get(args.id);
    if (!milestone) throw new Error("Milestone not found");
    await requireBabyAccess(ctx, milestone.babyId, user._id);
    await ctx.db.patch(args.id, {
      achievedAt: undefined,
      note: undefined,
      photoIds: undefined,
      videoIds: undefined,
    });
  },
});

Deleting Milestones

Remove a milestone (typically for custom milestones):
convex/milestones.ts
export const remove = mutation({
  args: { id: v.id("milestones") },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const milestone = await ctx.db.get(args.id);
    if (!milestone) throw new Error("Milestone not found");
    await requireBabyAccess(ctx, milestone.babyId, user._id);
    await ctx.db.delete(args.id);
  },
});

Media Attachments

Milestones support both photos and videos:

Photo Attachments

// Upload photo first
const photoId = await uploadFile(file);

// Attach to milestone
await achieveMilestone({
  babyId,
  key: "first_steps",
  title: "First Steps",
  category: "motor",
  photoIds: [photoId],
});

Video Attachments

// Upload video
const videoId = await uploadFile(videoFile);

// Attach to milestone
await updateMilestone({
  id: milestoneId,
  videoIds: [videoId],
});
Video files can be large. Ensure proper upload handling and progress indicators for a good user experience.

Age-Appropriate Suggestions

Milestones can be filtered by baby’s age to show relevant achievements:
const MILESTONE_AGE_RANGES = {
  "first_smile": { minWeeks: 4, maxWeeks: 12 },
  "rolled_over": { minWeeks: 12, maxWeeks: 24 },
  "sat_up": { minWeeks: 20, maxWeeks: 32 },
  "crawled": { minWeeks: 24, maxWeeks: 40 },
  "first_steps": { minWeeks: 36, maxWeeks: 72 },
};

function getRelevantMilestones(babyAgeWeeks: number) {
  return Object.entries(MILESTONE_AGE_RANGES)
    .filter(([key, range]) => 
      babyAgeWeeks >= range.minWeeks && 
      babyAgeWeeks <= range.maxWeeks
    )
    .map(([key]) => key);
}

Milestone Timeline

Display milestones in chronological order:
const timeline = milestones
  ?.filter(m => m.achievedAt)
  .sort((a, b) => 
    new Date(a.achievedAt!).getTime() - 
    new Date(b.achievedAt!).getTime()
  );

Export and Sharing

Milestones are included in data exports:
// Export includes milestones
const exportData = await exportBabyData({ babyId });
// exportData.milestones contains all milestone records

Dashboard

Recent milestones shown on dashboard

Baby Profiles

Age-appropriate milestone suggestions

Weekly Digests

Milestones included in weekly summaries

Activity Tracking

Link milestones to specific activities

Build docs developers (and LLMs) love