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:
parent
cbbe8f24ac
commit
deaa1810d7
8 changed files with 81 additions and 9 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 => [{
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
63
src/lib/analytics.ts
Normal 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");
|
||||||
Loading…
Add table
Reference in a new issue