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:
76
src/lib/analytics/range.ts
Normal file
76
src/lib/analytics/range.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user