feat(analytics): Umami integration with per-port admin settings

Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:53:06 +02:00
parent 49d34e00c8
commit f5772ce318
13 changed files with 1198 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
/**
* Pure date-range types and helpers shared by client components and the
* server-side analytics service.
*
* Lives outside `src/lib/services/analytics.service.ts` because that file
* imports the DB driver (`postgres`) which can't be bundled into client
* components - see Next.js "Module not found: net" build error.
*
* No DB / no IO / no React.
*/
/**
* Preset date ranges used by the dashboard's quick-pick tabs.
*/
export type PresetDateRange = '7d' | '30d' | '90d' | 'today';
/**
* A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD).
* The lower bound is inclusive at 00:00; the upper bound is inclusive at
* 23:59:59.999 (resolved inside `rangeToBounds`).
*/
export interface CustomDateRange {
kind: 'custom';
from: string; // ISO YYYY-MM-DD
to: string; // ISO YYYY-MM-DD
}
export type DateRange = PresetDateRange | CustomDateRange;
export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const;
export function isCustomRange(range: DateRange): range is CustomDateRange {
return typeof range === 'object' && range.kind === 'custom';
}
/**
* Resolve any DateRange (preset or custom) to a concrete {from, to} pair.
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
* - Custom ranges use the operator-supplied dates verbatim, with `to`
* normalized to end-of-day so a same-day range still includes that day.
*/
export function rangeToBounds(range: DateRange): { from: Date; to: Date } {
if (isCustomRange(range)) {
return {
from: new Date(`${range.from}T00:00:00.000Z`),
to: new Date(`${range.to}T23:59:59.999Z`),
};
}
const now = Date.now();
const days = rangeToDays(range);
return { from: new Date(now - days * 86_400_000), to: new Date(now) };
}
export function rangeToDays(range: PresetDateRange): number {
switch (range) {
case 'today':
return 1;
case '7d':
return 7;
case '30d':
return 30;
case '90d':
return 90;
}
}
/**
* Number of days a range spans (rounded up). Useful for sizing chart axes.
*/
export function rangeSpanDays(range: DateRange): number {
if (isCustomRange(range)) {
const { from, to } = rangeToBounds(range);
return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000));
}
return rangeToDays(range);
}