Adds the read-side Umami integration queued in last week's website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`): - Realtime panel polls Umami at 5s intervals; world map renders visitor origins via echarts + `public/world-map/echarts-world.json` topo. - Sessions list + session-detail-sheet drill-down (per-session event timeline pulled from `/api/v1/website-analytics`). - Weekly heatmap (day-of-week × hour-of-day) for engagement timing. - Metric-detail pages under `/[portSlug]/website-analytics/[metric]` for pageviews / referrers / events deep-dives. - Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF beacon backed by `email_open_tracking` (migration 0076); resolves inline on render in inbox. - Tracked-link redirect: `/q/[slug]` routes through `tracked_links` (migration 0077) and forwards to the canonical destination after logging the click. - Dashboard `website-glance-tile` now reads from the live Umami service instead of placeholder data. Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`, `@types/topojson-client`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
41 KiB
Website Analytics — flesh-out plan
Goal: rebuild /{portSlug}/website-analytics so it feels like a polished native CRM panel that mirrors Umami's idiom rather than reading as a stripped-down embed. Keep a "View in Umami →" deep-link in the header for power users; render most data in-app via the API. Also extend usage into adjacent CRM surfaces (dashboard tiles, inquiry detail, email open-tracking) so Umami stops being "the analytics page" and becomes a cross-cutting data layer.
Inputs to this plan:
- Live API capabilities reference —
docs/umami-api-capabilities.md(verified empirically against v3.1.0 on analytics.portnimara.com). - Live UI tour via Playwright — screenshots
umami-tour-1-overview.pngthroughumami-tour-9-compare.png(10 surfaces captured). - Pixel-tracking probe — confirmed the
/p/<slug>and/q/<slug>endpoints + their UI creation forms.
1. What Umami's UI actually does — design patterns to mirror
Tour findings (from 17 sub-pages + 4 team pages):
| Surface | Visual idiom | Adopt for CRM? |
|---|---|---|
| Overview | 5-tile KPI row (Visitors / Visits / Views / Bounce rate / Visit duration) — each tile shows headline number + colored arrow chip (green ↑ 58% / red ↓ 39%) + percentage delta. Single stacked bar chart below for traffic time-series (visitors stacked over visits, dual-shade blue). Filter pill + date-range nav top-right. | Yes — already mostly there, missing the bounce-rate + visit-duration tiles. |
| Events | List of custom event names with per-event count + time-series spark. | Yes — needs marketing-site event firing first (Phase 4). |
| Sessions | Dense table: avatar + per-session row showing Visits / Views / Events / Location (flag + city, country) / Browser icon / OS icon / Device icon / Last seen. Tabs for Activity vs Properties (custom session props). | Yes — high-leverage; lets reps see who is browsing right now. |
| Realtime | 4 stat tiles (Views/Visitors/Events/Countries) + auto-refreshing line chart of last 30 min. | Yes — already partial via the glance tile. |
| Performance | Likely page-speed / Core Web Vitals. | Skip — not relevant to marina sales. |
| Compare | Pick two date ranges side-by-side. | Partial — single compare=prev overlay on the existing trend chart suffices. |
| Breakdown | Pivot table view across dimensions. | Skip in v1; expose via Reports later. |
| Goals | Define event/page-view goals, see completion rate over time. | Yes — defer to Phase 5. |
| Funnels | Multi-step conversion funnel (e.g. /berths → /berths/A12 → /inquire → submit). | Yes — Phase 5; high-value for inquiry conversion. |
| Journeys | Most common navigation paths (Sankey-like). | Maybe — defer; nice-to-have. |
| Retention | Cohort retention grid. | Skip — wrong fit for one-and-done marina inquiry traffic. |
| Replays | Session replay (likely paid). | Skip — unavailable on our tier. |
| Segments / Cohorts | Saved filters / user groups. | Skip in v1. |
| UTM | Campaign attribution by UTM params. | Yes — Phase 5 for paid-campaign tracking. |
| Revenue | Revenue attribution. | Skip — would require firing purchase events from CRM after EOI close (consider Phase 6 if leadership wants funnel→revenue). |
| Attribution | First/last-click attribution model. | Maybe — defer. |
| Team-Boards / Websites / Links / Pixels | Account admin surfaces. | Pixels + Links: YES — see Phase 4. Boards/Websites stay in Umami. |
Visual specifics worth copying
- KPI tile design: large bold number, label above in muted-grey, arrow + percentage delta below in a colored chip (green-bg for positive, red-bg for negative, fixed-width for alignment). Our
KPITilealready does the right shape — we just need to add the missing two metrics. - Stacked bar chart for traffic: dual-shade single bar (visitors as light-blue base, views stacked dark-blue on top). Reads cleaner than two overlapping lines.
- Location rendering: flag emoji + "City, Country" inline. Use
getCountryName()+ a flag library (twemoji or unicode regional indicators). - Browser/OS/Device icons: small colored brand glyphs inline. Use
simple-iconsorlucideequivalents. - Filter chip + date nav:
<>arrows step through the date range; dropdown opens to preset list. Adopt the same pattern on our shell — currently we only have presets, no step-arrows.
2. Phased build plan
Phase 1 — Fill out the Overview tiles & chart (~3-4h)
Quick wins that close visual parity with Umami's Overview:
| Task | File | Effort |
|---|---|---|
| Add Bounce rate KPI tile | website-analytics-shell.tsx |
derive bounces / visits * 100; service field already there |
| Add Avg visit duration KPI tile | website-analytics-shell.tsx |
derive totaltime / visits formatted as Xm Ys; service field already there |
Add < > date-step arrows on the date-range chip |
date-range-picker.tsx |
step the current preset by one window (today→yesterday, 7d→prior-7d, etc.) |
| Convert pageviews trend to stacked bar (visitors vs views) | pageviews-chart.tsx |
recharts BarChart stacked, light/dark blue |
Add compare=prev overlay toggle on the trend chart |
pageviews-chart.tsx + service getPageviewsSeries |
optional "vs prior period" series rendered as dashed line |
| Add Top browsers / OS / devices ranked-list cards | new <TopList> consumers; service already exposes via getMetric(type) |
mirror Top Pages/Referrers/Countries layout |
| World choropleth heatmap card (already queued separately) | new visitor-world-map.tsx (Natural Earth topojson + react-simple-maps) |
~4-6h on its own |
Cumulative result: Overview surface reads at ~80% parity with Umami's Overview.
Phase 2 — Sessions surface (~4-5h)
New /website-analytics/sessions tab + supporting service wrappers:
| Task | File | Effort |
|---|---|---|
Service: getSessions(portId, range, opts) → /api/websites/:id/sessions (paged) |
umami.service.ts |
~30 min |
Service: getSession(portId, sessionId) → single-session detail |
umami.service.ts |
~15 min |
Service: getSessionActivity(portId, sessionId, range) → event timeline |
umami.service.ts |
~15 min |
Service: getSessionsWeekly(portId, range) → hour-of-week heatmap |
umami.service.ts |
~15 min |
API route: /api/v1/website-analytics?metric=sessions[&sessionId=...] |
route.ts | ~30 min |
UI: sessions-table.tsx — dense rows mirroring Umami (avatar + location flag + browser/OS/device icons + Last seen) |
new component | ~2h |
UI: session-detail-sheet.tsx — right-side Sheet drawer showing the session's full event timeline when a row is clicked |
new component | ~1h |
UI: weekly-heatmap-card.tsx — 7×24 grid colour-scaled by session count, hover for tooltip |
new component | ~1h |
Unlock: rep can see "who is currently browsing right now, where from, on what device, what they're looking at" — directly actionable for sales follow-up.
Phase 3 — Events surface (~3-4h, BLOCKED on Phase 4a)
| Task | File | Effort |
|---|---|---|
Service: getEvents(portId, range, opts) → /events paged list |
umami.service.ts |
~30 min |
Service: getEventsStats(portId, range) → totals |
umami.service.ts |
~15 min |
Service: getEventsSeries(portId, range, eventName, unit) → per-event time-series |
umami.service.ts |
~15 min |
| API route addition | route.ts | ~30 min |
UI: events-tab.tsx — list of event names with per-event count + spark + drill-in |
new component | ~1.5h |
UI: event-detail-sheet.tsx — single event's time-series chart + filter by payload property |
new component | ~1h |
Dependency: the marketing site must fire umami.track(name, payload) calls (Phase 4a). Without this, Events tab is empty.
Phase 4 — Pixel tracking + link tracking + marketing-site event push
Phase 4a — Marketing-site event tracking (~2-3h on marketing repo)
Add umami.track() calls in the marketing site:
inquiry-submittedwith{berth, source}payload — fires on EOI form submitbrochure-downloadwith{brochureId}— fires on brochure downloadberth-detail-viewedwith{berthId, mooring}— fires on/berths/[mooring]page viewphone-revealed/email-revealed— fires when contact details are exposed
These light up the Events tab + enable funnel analysis in Phase 5.
Phase 4b — Pixel-based email open tracking (~3-4h CRM-side)
Probe finding: Umami exposes pixel URLs at https://analytics.portnimara.com/p/<slug> — fetching the URL records an event. Use case: embed in HTML emails as a 1x1 image.
Two architecture options:
Option A — One Umami pixel per email type (simple, low fidelity)
- Create a pixel manually in Umami for each templated email type (
portal-invite,eoi-sent,reservation-reminder, etc.) - Embed the static pixel URL in each template
- Pro: zero CRM-side code beyond template HTML. Open rates roll up in Umami by pixel.
- Con: can't tell which recipient opened — only aggregate counts per template.
Option B — One Umami pixel + CRM-side per-send tracking endpoint (richer, recommended)
- Build
GET /api/public/email-pixel/:sendId.gifin our CRM that:- Returns a 1×1 transparent GIF
- Records the open in
document_sends.opened_at(already a table; per CLAUDE.md "send-from accounts" section) - Optionally proxies the hit to Umami via
POST /api/sendwith the email type + send id as event properties for cross-correlation
- Embed
<img src="https://crm.portnimara.com/api/public/email-pixel/{sendId}.gif" width="1" height="1" />in every templated email - Pro: per-recipient open tracking + open-time + CRM-attached. Funnels by email type via Umami too.
- Con: needs the public endpoint + a schema column (or reuse
document_sends.opened_at).
Recommendation: ship Option B. The CRM-side hook gives us per-deal attribution ("client X opened the EOI reminder twice but hasn't signed"), and Umami still gets the aggregate.
| Task | File | Effort |
|---|---|---|
New endpoint /api/public/email-pixel/[sendId]/route.ts returning a 1×1 GIF + recording open |
new route | ~1h |
Migration: add opened_at, open_count, last_opened_user_agent to document_sends if not present |
drizzle migration | ~30 min |
| Email template helper: inject the pixel HTML into every transactional template | src/lib/email/render.ts |
~30 min |
UI surface: on each document_sends row in the activity feed, show "Opened N times, last at X" badge |
email-activity-row.tsx |
~1h |
Cross-post to Umami via trackEvent('email-opened', {emailType, sendId}) so Umami funnel data includes opens |
new trackEvent wrapper in umami.service.ts |
~30 min |
Privacy: respect EMAIL_REDIRECT_TO dev gate; don't fire pixels for redirected dev emails |
ditto | ~15 min |
Phase 4c — Tracked redirect links (~1.5h)
Umami's /q/<slug> endpoint is a tracked redirect — records a click then 302s to the destination URL. Use for outbound CTAs:
- "View brochure" links in emails → wrap via Umami link → records click → opens brochure
- "Schedule a viewing" buttons → wrap via Umami link → click attribution
- Marketing-site CTAs → wrap → measure engagement
| Task | File | Effort |
|---|---|---|
Service: createTrackedLink(name, destinationUrl) → POST to Umami's links endpoint via authenticated API |
umami.service.ts |
~45 min |
Email template helper: <trackedLink href="..." name="..."> JSX wrapper that auto-creates the Umami link on first render + caches the slug |
src/lib/email/components/ |
~45 min |
Phase 5 — Reports surfaces (Funnels, UTM, Journeys) (~6-8h)
| Task | File | Effort |
|---|---|---|
Service: getReport(reportType, body) POST wrapper covering /funnel, /journey, /utm, /goals, /retention, /revenue, /attribution |
umami.service.ts |
~1h |
UI: /website-analytics/funnels page — admin-configurable funnel definitions (steps as event names or URL paths), per-step drop-off chart |
new page | ~3h |
UI: /website-analytics/utm page — UTM source/medium/campaign breakdown with click-through to attributed sessions |
new page | ~2h |
UI: /website-analytics/journeys page — top navigation paths rendered as ranked list (skip Sankey for v1) |
new page | ~1.5h |
| Defer: Goals / Retention / Revenue / Attribution to v2 (low signal for marina sales) |
High-leverage funnels to wire as defaults:
- Inquiry funnel:
/→/berths→/berths/[mooring]→inquiry-submittedevent → CRMeoi-signed(cross-system!) → CRMreservation-paid(cross-system!) - Email funnel:
email-sent→email-opened(pixel) → tracked-link click → CRM action
The cross-system funnels require Phase 4 to be live first.
Phase 6 — CRM → Umami event push for outcome attribution (~2-3h)
Close the funnel from "marketing site click" → "CRM closed deal" by firing CRM-side events back into Umami via POST /api/send:
| Event | Fired by | Payload |
|---|---|---|
crm-inquiry-created |
createInterest() in interests.service.ts |
{interestId, source, leadCategory} |
crm-eoi-sent |
generateAndSign() after EOI dispatch |
{interestId, berth, pathway} |
crm-eoi-signed |
Documenso DOCUMENT_COMPLETED webhook |
{interestId, berth} |
crm-reservation-paid |
manual stage advance to deposit_paid |
{interestId, berth, amount, currency} |
crm-contract-signed |
manual stage advance to contract |
{interestId, berth, amount, currency} |
| Task | File | Effort |
|---|---|---|
Service: trackEvent(name, payload, sessionId?) → POST /api/send on the Umami instance |
umami.service.ts |
~45 min |
| Hook into the 5 service entry points above (one event per outcome milestone) | each service file | ~1.5h |
| Audit log entry per event sent so we can verify Umami received it | audit_logs insert |
included |
Unlock: Umami's Revenue + Attribution reports start showing CRM outcomes attributed to marketing-site channels — closes the leadership question "which traffic sources actually generate signed deals, not just leads?"
Phase 7 — Cross-cutting CRM placements (~3-4h)
Beyond the dedicated /website-analytics page, surface Umami data inside CRM context:
| Placement | What | Effort |
|---|---|---|
| Dashboard rail tile (already shipped) — Pageviews + active now | already done in this session | — |
Inquiry detail page — "Source attribution" card showing the inquiry's UTM params, landing page, time-on-site, pages-viewed-before-submit. Pulls from getSession(sessionId) if the inquiry's create payload includes a session ID (requires marketing-site change to pass it). |
new inquiry-attribution-card.tsx |
~1.5h + marketing-site change |
Client detail page — "Website activity" card: total sessions, pageviews, last-seen, top pages visited. Requires umami.identify({email}) on marketing site to link sessions back to clients. |
new client-web-activity-card.tsx |
~1.5h + marketing-site identify call |
Berth detail page — "Marketing demand" card: pageviews to /berths/{mooring} over time + referrer breakdown. Drives "this berth is being viewed but not inquired-about — flag for outreach." |
new berth-demand-card.tsx |
~1h |
| Document send activity — pixel opens per recipient (from Phase 4b) | inline on existing document_sends rows |
included in 4b |
2b. Library adoptions (changes the plan materially)
Context7 lookup surfaced three official libraries that reshape the plan. Adopt all three.
@umami/api-client — official read-side client
Covers every read endpoint we need including all the report types. Built-in filter support, login/JWT auth handled internally, {ok, data} discriminated union for clean error handling.
Replaces: ~60-70% of our current umami.service.ts (drop umamiFetch, JWT cache, decrypt boilerplate; keep thin wrappers with existing signatures so consumers don't change).
One-time refactor (~2h):
const clientByPort = new Map<string, UmamiApiClient>();
async function getClient(portId: string): Promise<UmamiApiClient | null> {
if (clientByPort.has(portId)) return clientByPort.get(portId)!;
const cfg = await loadUmamiConfig(portId);
if (!cfg) return null;
const client = new UmamiApiClient({
apiEndpoint: `${cfg.apiUrl}/api`,
apiKey: cfg.apiToken ?? undefined,
});
if (!cfg.apiToken && cfg.username && cfg.password) await client.login(cfg.username, cfg.password);
clientByPort.set(portId, client);
return client;
}
export async function getStats(portId: string, range: DateRange) {
const client = await getClient(portId);
if (!client) return null;
const { from, to } = rangeToBounds(range);
const result = await client.getWebsiteStats(WEBSITE_ID, {
startAt: from.getTime(),
endAt: to.getTime(),
});
return result.ok ? result.data : null;
}
Same pattern for getPageviewsSeries, getMetric, getActiveVisitors, plus new ones from the SDK: getRealtime, getWebsiteSessionStats, runFunnelReport, runJourneyReport, etc.
@umami/node — official write-side SDK
For Phase 6 (CRM → Umami push) and Phase 4b cross-post:
const umami = new Umami({ websiteId, hostUrl });
await umami.track({
url: '/crm/eoi-signed',
name: 'crm-eoi-signed',
data: { interestId, berth, dealValue },
});
await umami.identify({ sessionId, email, interestId });
Replaces: the planned hand-rolled trackEvent() wrapper. Single line per outcome milestone.
react-simple-maps — for the world heatmap (Phase 1b)
Declarative SVG choropleth on d3-geo + topojson-client. SSR-safe. Use topojson/world-atlas (110m resolution ~30KB) cached in public/. Bundle ~30-50KB + topojson 30-100KB.
<ComposableMap projection="geoMercator">
<Geographies geography="/world-110m.json">
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill={scaleByVisitorCount(visitorsByCountry[geo.properties.iso_a2] ?? 0)}
onClick={() => onCountryClick(geo.properties.iso_a2)}
/>
))
}
</Geographies>
</ComposableMap>
Chose this over visx/Nivo/Chart.js Geo: visx is overkill for one map; Nivo + Chart.js force a different charting idiom (we use recharts everywhere); react-simple-maps' compose-primitives shape matches our recharts pattern.
Net effect on phase efforts
| Phase | Original estimate | Revised after library adoption |
|---|---|---|
| Service refactor (one-time) | — | +2h (one-time foundation; pays back across all phases) |
| Phase 1 — Overview parity | 3-4h | 3-4h (unchanged; api-client makes the filter additions trivial) |
| Phase 1b — World heatmap | 4-6h | 3-4h (library choice locked in) |
| Phase 2 — Sessions | 4-5h | 3-4h (api-client has session methods built-in) |
| Phase 3 — Events | 3-4h | 2-3h (api-client provides) |
| Phase 4b — Pixel hybrid | 3-4h | 2.5-3h (cross-post is one line) |
| Phase 5 — Reports (funnels/UTM/journeys) | 6-8h | 3-4h (every report method pre-wrapped) |
| Phase 6 — CRM → Umami push | 2-3h | 1.5h (@umami/node handles transport) |
Total scope drops from ~30-40h to ~22-28h with these adoptions.
3. Service-layer additions consolidated
Add to src/lib/services/umami.service.ts (each is a thin wrapper around existing umamiFetch / new umamiPost):
// Sessions (Phase 2)
getSessions(portId, range, { page?, pageSize?, query? }) → /sessions
getSession(portId, sessionId) → /sessions/:id
getSessionActivity(portId, sessionId, range) → /sessions/:id/activity
getSessionProperties(portId, sessionId) → /sessions/:id/properties
getSessionsWeekly(portId, range, timezone) → /sessions/weekly
// Events (Phase 3)
getEvents(portId, range, opts) → /events
getEventsStats(portId, range) → /events/stats
getEventsSeries(portId, range, eventName, unit) → /events/series
getEventDataProperties(portId, range) → /event-data/properties
// Realtime (Phase 1)
getRealtime(portId, range) → /api/realtime/:id (richer than /active)
// Reports (Phase 5)
getReport(portId, reportType, body) → POST /api/reports/:type (funnel/journey/utm/goals/retention/revenue/attribution)
// CRM → Umami (Phase 6)
trackEvent(portId, name, payload, sessionId?) → POST /api/send
// Links + Pixels admin (Phase 4)
createTrackedLink(portId, name, destinationUrl) → POST team-level /links
createTrackingPixel(portId, name) → POST team-level /pixels
Plus a new umamiPost(config, path, body) helper alongside the existing umamiFetch since GET-only doesn't cover reports + send.
4. Pixel-tracking answer (the user's specific question)
Q: Can we use Umami's pixel tracking for email opens?
A: Yes — and recommended in hybrid form. Direct verification on the live instance:
- Pixel UI at
/teams/[teamId]/pixelslets an admin create named pixels. Each gets an auto-generated slug. - The pixel URL is
https://analytics.portnimara.com/p/<slug>— fetching it records an event (no auth required from the email client side; the slug is the credential). - Embedded as
<img src="..." width="1" height="1" />in HTML emails, it fires when the email is rendered (Outlook/Apple Mail/etc.). - Standard caveats: Apple Mail privacy protection pre-fetches images server-side → opens may be over-counted for iOS users. Some recipients block images entirely → opens under-counted. Same caveats as every email tracking pixel ever.
Recommended hybrid (Phase 4b above): build a CRM-side pixel endpoint /api/public/email-pixel/[sendId].gif that:
- Returns the 1×1 GIF
- Records
opened_atindocument_sends - Cross-posts the hit to Umami via
POST /api/sendso the Umami Events tab + funnels include opens
This way: per-recipient attribution in the CRM, aggregate roll-ups in Umami, single source of truth for both.
5. Effort summary + prioritization
| Phase | Scope | Effort | Priority |
|---|---|---|---|
| 1 | Overview parity (KPI tiles, stacked-bar chart, date arrows, browser/OS/device cards) | ~3-4h | High — most visible polish, no dependencies |
| 1b | World choropleth heatmap (already queued separately) | ~4-6h | High if leadership wants the visual |
| 2 | Sessions surface (table + detail sheet + weekly heatmap) | ~4-5h | High — biggest "wow" + actionable |
| 3 | Events surface | ~3-4h | Medium — blocked on 4a |
| 4a | Marketing-site event tracking | ~2-3h (marketing repo) | High — unblocks 3 + 5 |
| 4b | Pixel-based email open tracking (hybrid) | ~3-4h | High — direct ask + immediate value |
| 4c | Tracked redirect links | ~1.5h | Medium |
| 5 | Reports (Funnels, UTM, Journeys) | ~6-8h | Medium — depends on 4a being live |
| 6 | CRM → Umami event push for outcome attribution | ~2-3h | Medium-high — needed to close marketing→outcome loop |
| 7 | Cross-cutting placements (inquiry / client / berth detail cards) | ~3-4h | Medium — depends on umami.identify() on marketing site |
Recommended build order (updated 2026-05-19 per user):
- ✅ Service refactor — Kept hand-rolled
umamiFetch(the official@umami/api-clienttransitively pullsnext-basicswhich requires React at module-import time, breaking SSR + tsx scripts). Adopted@umami/nodefor the write side. - ✅ Phase 1 — Overview parity (KPI tiles + browser/OS/device cards + date arrows + stacked-bar chart +
compare=prevoverlay) - ✅ Phase 1b — World heatmap. Switched from
react-simple-mapsto ECharts +public/world-map/echarts-world.json— theworld-atlas/110mtopojson has antimeridian-crossing polygons (Russia/Fiji/Antarctica) that render a horizontal line through the equator regardless of projection. ECharts' world.json is pre-cleaned. - ✅ Phase 4b — Pixel-based email open tracking.
document_send_openstable +/api/public/email-pixel/[sendId]endpoint +injectTrackingPixelhelper wired intoperformSend. Per-port kill switchemail_open_tracking_enabled(admin UI on/admin/website-analytics). Cross-posts to Umami asemail-opened. - ✅ Phase 2 — Sessions surface.
SessionsList(paginated, click-through to detail),SessionDetailSheet(full activity stream),WeeklyHeatmap(7×24 grid). API endpointssessions,session,session-activity,sessions-weekly. - ✅ Phase 6 — CRM → Umami event push.
trackEventcalls wired intocreateInterest(interest-created),updateInterestStage(interest-stage-changed),setInterestOutcome(interest-outcome-set). - ✅ Phase 7 — Cross-cutting placements.
email-sent(inperformSend),eoi-signed(inhandleDocumentCompleted). Remaining placements (inquiry / berth detail attribution cards) defer until UI surfaces them. - ✅ Phase 4c — Tracked redirect links.
tracked_links+tracked_link_clickstables +/q/[slug]redirect endpoint +createTrackedLink/buildTrackedUrlservice helpers. Email-composer integration deferred to UI follow-up. - Phase 3 + Phase 5 — DEFERRED to the end. Events tab is empty until marketing-site
umami.track()calls land (Phase 4a, deferred). Funnels save for the end per user direction — pageview-only marketing funnel is the v1; richer event-based funnels come later. - Phase 4a + cross-system funnels — when there's appetite for marketing-site repo changes, unlock Events tab + cross-system funnels.
Total scope: ~22-28h with library adoptions, of which ~13-15h is the high-priority Phases 1 + 1b + 4b + 2 + 6 that ship first.
Total scope: ~30-40h end-to-end for the full flesh-out.
6. What stays in Umami vs. mirrored in CRM
| In CRM (mirror) | In Umami only (deep-link) |
|---|---|
| Overview / KPI tiles / trend chart | Replays (paid) |
| Sessions list + detail | Retention (low signal) |
| Top pages / referrers / countries / browsers / OS / devices | Saved Boards (admin power-user) |
| Events + per-event drill | Pixels/Links admin CRUD (use Umami for setup; render data in CRM) |
| Funnels + UTM + Journeys | Performance / Web Vitals |
| World heatmap | Cohorts / Segments (defer until use case emerges) |
| Email open tracking | Multi-website CRUD |
Every page header in the CRM analytics surface gets a small "View in Umami →" outbound link in the corner for power users who want the full feature surface.
7. Open questions for the user before implementation
- Marketing site repo access: Phase 4a (umami.track calls), Phase 4 (umami.identify for client linkage), and Phase 7 (passing sessionId to inquiry intake) all require changes there. Confirm whoever owns the marketing site is in the loop.
- Pixel hybrid vs Umami-only: do you want per-recipient open tracking (hybrid) or just aggregate (Umami-only)? Recommended hybrid above; switch to Umami-only if the engineering cost isn't worth it.
- Funnel definitions: who defines the canonical funnels? Suggest admins set them up via a CRM-side admin page that POSTs to Umami's
/api/reports/funnel, with the most important funnels (inquiry, email-conversion) seeded as defaults at install time. - Privacy / GDPR: email pixel tracking +
umami.identify({email})linkage both touch PII. Confirm consent model — likely already handled by the marketing-site cookie banner, but the email pixel needs explicit opt-out handling (e.g. don't fire pixel if the recipient is in a do-not-track list).