feat: add Umami self-hosted analytics with custom event tracking

- Root layout: load Umami script (afterInteractive) — covers all pages including
  SPA navigation auto-tracking
- Marketing layout: remove Plausible script (Umami now covers marketing pages too)
- src/lib/analytics.ts: type-safe track() wrapper + typed helpers for each event;
  window.umami declared globally; safe no-op on SSR/ad-block
- Custom events wired:
    log-created { logType }  — LogModal on successful save
    garment-added            — wardrobe/add after save
    memory-added             — memories after upload pipeline completes
    growth-logged            — growth page after measurement saved
    pwa-installed            — InstallPrompt when Android prompt accepted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-30 00:40:05 +05:30
parent cbbe8f24ac
commit deaa1810d7
8 changed files with 81 additions and 9 deletions

View file

@ -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 { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting"; import { formatAge } from "@/lib/formatting";
import { fmtDate } from "@/lib/date-ist"; import { fmtDate } from "@/lib/date-ist";
import { trackGrowthLogged } from "@/lib/analytics";
import type { GrowthRecord, Goal } from "@/types"; import type { GrowthRecord, Goal } from "@/types";
import { import {
Chart as ChartJS, Chart as ChartJS,
@ -116,6 +117,7 @@ export default function GrowthPage() {
setSaveError(err.error || "Failed to save"); setSaveError(err.error || "Failed to save");
return; return;
} }
trackGrowthLogged();
resetForm(); resetForm();
fetchGrowthData(); fetchGrowthData();
} catch (e) { } catch (e) {

View file

@ -6,6 +6,7 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button, ConfirmDialog, Modal } from "@/components/ui"; import { Button, ConfirmDialog, Modal } from "@/components/ui";
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter"; import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
import { formatBytes } from "@/lib/format-bytes"; import { formatBytes } from "@/lib/format-bytes";
import { trackMemoryAdded } from "@/lib/analytics";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */ /** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string { function resolveContentType(file: File): string {
@ -213,6 +214,7 @@ export default function MemoriesPage() {
return; return;
} }
trackMemoryAdded();
// Optimistic grid update // Optimistic grid update
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`; const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
setMemories(prev => [{ setMemories(prev => [{

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { useFamily } from "@/app/FamilyProvider"; import { useFamily } from "@/app/FamilyProvider";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { trackGarmentAdded } from "@/lib/analytics";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
type Step = "capture" | "form"; type Step = "capture" | "form";
@ -224,6 +225,7 @@ export default function AddGarmentPage() {
}), }),
}); });
if (!res.ok) throw new Error((await res.json()).error); if (!res.ok) throw new Error((await res.json()).error);
trackGarmentAdded();
handleAddAnother(); handleAddAnother();
} catch (err) { } catch (err) {
setError(`Save failed: ${err}`); setError(`Save failed: ${err}`);

View file

@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { MarketingNav } from "@/components/marketing/MarketingNav"; import { MarketingNav } from "@/components/marketing/MarketingNav";
import Script from "next/script";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@ -32,15 +31,8 @@ export default function MarketingLayout({
}) { }) {
return ( return (
<> <>
{/* Privacy-respecting analytics — no cookies, no tracking */}
<Script
defer
data-domain={process.env.NEXT_PUBLIC_APP_URL?.replace("https://", "").replace("http://", "")}
src="https://plausible.io/js/script.js"
strategy="afterInteractive"
/>
{/* Scroll-reveal nav — appears after scrolling past hero */} {/* Scroll-reveal nav — appears after scrolling past hero */}
{/* Analytics now provided globally by Umami in root layout.tsx */}
<MarketingNav /> <MarketingNav />
<main>{children}</main> <main>{children}</main>

View file

@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Caveat } from "next/font/google"; import { Geist, Geist_Mono, Caveat } from "next/font/google";
import Script from "next/script";
import { SITE_URL, SITE_NAME } from "@/lib/seo"; import { SITE_URL, SITE_NAME } from "@/lib/seo";
import "./globals.css"; import "./globals.css";
@ -69,6 +70,12 @@ export default function RootLayout({
<html lang="en-IN" suppressHydrationWarning> <html lang="en-IN" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
{children} {children}
{/* Self-hosted Umami analytics — privacy-first, no cookies, GDPR-compliant */}
<Script
src="https://analytics.manohargupta.com/script.js"
data-website-id="79444c19-ee31-4fab-baf5-f4e61098eeba"
strategy="afterInteractive"
/>
</body> </body>
</html> </html>
); );

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { trackPwaInstalled } from "@/lib/analytics";
// Extend the BeforeInstallPromptEvent type (not in standard lib) // Extend the BeforeInstallPromptEvent type (not in standard lib)
interface BeforeInstallPromptEvent extends Event { interface BeforeInstallPromptEvent extends Event {
@ -132,6 +133,7 @@ export function InstallPrompt() {
const { outcome } = await deferred.userChoice; const { outcome } = await deferred.userChoice;
if (outcome === "accepted") { if (outcome === "accepted") {
localStorage.setItem(KEY_INSTALLED, "1"); localStorage.setItem(KEY_INSTALLED, "1");
trackPwaInstalled();
setDeferred(null); setDeferred(null);
setShow(false); setShow(false);
} else { } else {

View file

@ -3,6 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button, Modal, Select, Input } from "@/components/ui"; import { Button, Modal, Select, Input } from "@/components/ui";
import { addToOfflineQueue } from "@/lib/offline-queue"; import { addToOfflineQueue } from "@/lib/offline-queue";
import { trackLogCreated } from "@/lib/analytics";
import type { LogType } from "@/types"; import type { LogType } from "@/types";
export type { LogType }; export type { LogType };
@ -111,6 +112,7 @@ export function LogModal({ type, childId, onClose, onSaved, smartDefault }: Prop
if (!res.ok && !navigator.onLine) { if (!res.ok && !navigator.onLine) {
addToOfflineQueue({ type, data }); addToOfflineQueue({ type, data });
} }
trackLogCreated(type as "feed" | "sleep" | "diaper");
onSaved?.(); onSaved?.();
onClose(); onClose();
} catch { } catch {

63
src/lib/analytics.ts Normal file
View file

@ -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<string, string | number | boolean>) => void;
};
}
}
export type EventData = Record<string, string | number | boolean>;
/**
* 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");