diff --git a/src/app/(app)/growth/page.tsx b/src/app/(app)/growth/page.tsx
index e8b9d5a..b8b9cf3 100644
--- a/src/app/(app)/growth/page.tsx
+++ b/src/app/(app)/growth/page.tsx
@@ -7,6 +7,7 @@ import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting";
import { fmtDate } from "@/lib/date-ist";
+import { trackGrowthLogged } from "@/lib/analytics";
import type { GrowthRecord, Goal } from "@/types";
import {
Chart as ChartJS,
@@ -116,6 +117,7 @@ export default function GrowthPage() {
setSaveError(err.error || "Failed to save");
return;
}
+ trackGrowthLogged();
resetForm();
fetchGrowthData();
} catch (e) {
diff --git a/src/app/(app)/memories/page.tsx b/src/app/(app)/memories/page.tsx
index d24eac6..2e053ed 100644
--- a/src/app/(app)/memories/page.tsx
+++ b/src/app/(app)/memories/page.tsx
@@ -6,6 +6,7 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button, ConfirmDialog, Modal } from "@/components/ui";
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
import { formatBytes } from "@/lib/format-bytes";
+import { trackMemoryAdded } from "@/lib/analytics";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
@@ -213,6 +214,7 @@ export default function MemoriesPage() {
return;
}
+ trackMemoryAdded();
// Optimistic grid update
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
setMemories(prev => [{
diff --git a/src/app/(app)/wardrobe/add/page.tsx b/src/app/(app)/wardrobe/add/page.tsx
index 2edefa1..4914b01 100644
--- a/src/app/(app)/wardrobe/add/page.tsx
+++ b/src/app/(app)/wardrobe/add/page.tsx
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Image from "next/image";
import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui";
+import { trackGarmentAdded } from "@/lib/analytics";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
type Step = "capture" | "form";
@@ -224,6 +225,7 @@ export default function AddGarmentPage() {
}),
});
if (!res.ok) throw new Error((await res.json()).error);
+ trackGarmentAdded();
handleAddAnother();
} catch (err) {
setError(`Save failed: ${err}`);
diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx
index 16f8928..3a04c86 100644
--- a/src/app/(marketing)/layout.tsx
+++ b/src/app/(marketing)/layout.tsx
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { MarketingNav } from "@/components/marketing/MarketingNav";
-import Script from "next/script";
export const metadata: Metadata = {
title: {
@@ -32,15 +31,8 @@ export default function MarketingLayout({
}) {
return (
<>
- {/* Privacy-respecting analytics — no cookies, no tracking */}
-
-
{/* Scroll-reveal nav — appears after scrolling past hero */}
+ {/* Analytics now provided globally by Umami in root layout.tsx */}
{children}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ce5da65..74f8a13 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Caveat } from "next/font/google";
+import Script from "next/script";
import { SITE_URL, SITE_NAME } from "@/lib/seo";
import "./globals.css";
@@ -69,6 +70,12 @@ export default function RootLayout({
{children}
+ {/* Self-hosted Umami analytics — privacy-first, no cookies, GDPR-compliant */}
+
);
diff --git a/src/components/InstallPrompt.tsx b/src/components/InstallPrompt.tsx
index 4bc91f8..dc549ce 100644
--- a/src/components/InstallPrompt.tsx
+++ b/src/components/InstallPrompt.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import { trackPwaInstalled } from "@/lib/analytics";
// Extend the BeforeInstallPromptEvent type (not in standard lib)
interface BeforeInstallPromptEvent extends Event {
@@ -132,6 +133,7 @@ export function InstallPrompt() {
const { outcome } = await deferred.userChoice;
if (outcome === "accepted") {
localStorage.setItem(KEY_INSTALLED, "1");
+ trackPwaInstalled();
setDeferred(null);
setShow(false);
} else {
diff --git a/src/components/LogModal.tsx b/src/components/LogModal.tsx
index 0e32f70..b6ccd2d 100644
--- a/src/components/LogModal.tsx
+++ b/src/components/LogModal.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Button, Modal, Select, Input } from "@/components/ui";
import { addToOfflineQueue } from "@/lib/offline-queue";
+import { trackLogCreated } from "@/lib/analytics";
import type { LogType } from "@/types";
export type { LogType };
@@ -111,6 +112,7 @@ export function LogModal({ type, childId, onClose, onSaved, smartDefault }: Prop
if (!res.ok && !navigator.onLine) {
addToOfflineQueue({ type, data });
}
+ trackLogCreated(type as "feed" | "sleep" | "diaper");
onSaved?.();
onClose();
} catch {
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..d8dd18a
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,63 @@
+/**
+ * analytics.ts — thin wrapper around Umami's window.umami.track().
+ *
+ * Why a wrapper?
+ * - window.umami is undefined during SSR and before the script loads
+ * - gives us a typed, central place to see all tracked events
+ * - safe no-op in development if the script isn't loaded
+ *
+ * Usage:
+ * import { track } from "@/lib/analytics";
+ * track("log-created", { logType: "feed" });
+ *
+ * Umami dashboard → Events tab shows each event name + property breakdown.
+ */
+
+// Extend Window with Umami's runtime API
+declare global {
+ interface Window {
+ umami?: {
+ track: (event: string, data?: Record) => void;
+ };
+ }
+}
+
+export type EventData = Record;
+
+/**
+ * Fire a custom Umami analytics event.
+ * Safe to call anywhere — no-ops if Umami hasn't loaded yet (SSR, dev, ad-blockers).
+ */
+export function track(event: string, data?: EventData): void {
+ try {
+ window.umami?.track(event, data);
+ } catch {
+ // Swallow — analytics must never break the app
+ }
+}
+
+// ─── Typed event helpers ──────────────────────────────────────────────────────
+// These keep event names consistent across the codebase and show up as
+// named events in the Umami dashboard.
+
+/** Feed, diaper, or sleep log saved */
+export const trackLogCreated = (logType: "feed" | "sleep" | "diaper") =>
+ track("log-created", { logType });
+
+/** Garment added to wardrobe */
+export const trackGarmentAdded = () => track("garment-added");
+
+/** Memory photo uploaded */
+export const trackMemoryAdded = () => track("memory-added");
+
+/** Growth measurement recorded */
+export const trackGrowthLogged = () => track("growth-logged");
+
+/** PWA installed from browser prompt */
+export const trackPwaInstalled = () => track("pwa-installed");
+
+/** AI chat message sent */
+export const trackAiChat = () => track("ai-chat-sent");
+
+/** Vaccine marked as given */
+export const trackVaccineGiven = () => track("vaccine-given");