feat(dashboard): cache snapshot data and add credits activity analytics

This commit is contained in:
Matthias
2026-04-08 12:43:58 +02:00
parent 96d9c895ad
commit 22ec672f8e
15 changed files with 996 additions and 40 deletions

136
lib/credits-activity.ts Normal file
View File

@@ -0,0 +1,136 @@
type CreditTransactionType =
| "subscription"
| "topup"
| "usage"
| "reservation"
| "refund";
type CreditTransactionStatus = "committed" | "reserved" | "released" | "failed";
export type CreditTransactionLike = {
_creationTime: number;
type: CreditTransactionType;
status: CreditTransactionStatus;
amount: number;
};
export type CreditActivityPoint = {
day: string;
usage: number;
activity: number;
available: number;
};
const MIN_USAGE_DOMAIN_MAX = 8;
const EMPTY_USAGE_DOMAIN_MAX = 10;
const USAGE_HEADROOM_RATIO = 0.2;
const MIN_USAGE_HEADROOM = 2;
const TX_PRIORITY: Record<CreditTransactionType, number> = {
usage: 0,
reservation: 1,
refund: 2,
topup: 3,
subscription: 4,
};
const STATUS_PRIORITY: Record<CreditTransactionStatus, number> = {
committed: 0,
reserved: 1,
released: 2,
failed: 3,
};
export function formatCredits(value: number, locale: string) {
return `${new Intl.NumberFormat(locale).format(value)} Cr`;
}
export function prioritizeRecentCreditTransactions<T extends CreditTransactionLike>(
transactions: readonly T[],
limit: number,
) {
return [...transactions]
.sort((a, b) => {
const primary = TX_PRIORITY[a.type] - TX_PRIORITY[b.type];
if (primary !== 0) {
return primary;
}
const statusOrder = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status];
if (statusOrder !== 0) {
return statusOrder;
}
return b._creationTime - a._creationTime;
})
.slice(0, Math.max(0, limit));
}
export function buildCreditsActivitySeries<T extends CreditTransactionLike>(
transactions: readonly T[],
availableCredits: number,
locale: string,
maxPoints = 7,
): CreditActivityPoint[] {
const formatter = new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "short",
});
const byDay = new Map<
string,
{
date: Date;
usage: number;
activity: number;
}
>();
for (const tx of transactions) {
const date = new Date(tx._creationTime);
const key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).toISOString();
const current = byDay.get(key) ?? { date, usage: 0, activity: 0 };
const absAmount = Math.abs(tx.amount);
if (tx.type === "usage" && tx.status === "committed") {
current.usage += absAmount;
current.activity += absAmount;
} else if (
tx.type === "reservation" &&
(tx.status === "reserved" || tx.status === "released")
) {
current.activity += absAmount;
}
byDay.set(key, current);
}
return [...byDay.entries()]
.sort((a, b) => a[1].date.getTime() - b[1].date.getTime())
.slice(-Math.max(1, maxPoints))
.map(([, point]) => ({
day: formatter.format(point.date).replace(/\.$/, ""),
usage: point.usage,
activity: point.activity,
available: availableCredits,
}));
}
export function calculateUsageActivityDomain(
points: readonly Pick<CreditActivityPoint, "usage" | "activity">[],
): [number, number] {
const maxUsageOrActivity = points.reduce((maxValue, point) => {
return Math.max(maxValue, point.usage, point.activity);
}, 0);
if (maxUsageOrActivity <= 0) {
return [0, EMPTY_USAGE_DOMAIN_MAX];
}
const headroom = Math.max(
MIN_USAGE_HEADROOM,
Math.ceil(maxUsageOrActivity * USAGE_HEADROOM_RATIO),
);
return [0, Math.max(MIN_USAGE_DOMAIN_MAX, maxUsageOrActivity + headroom)];
}

View File

@@ -0,0 +1,122 @@
const STORAGE_NAMESPACE = "lemonspace.dashboard";
const CACHE_VERSION = 1;
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
type JsonRecord = Record<string, unknown>;
type DashboardSnapshotCachePayload<TSnapshot> = {
version: number;
cachedAt: number;
snapshot: TSnapshot;
};
function getLocalStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null;
}
function safeParse(raw: string | null): unknown {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function cacheKey(userId: string): string {
return `${STORAGE_NAMESPACE}:snapshot:v${CACHE_VERSION}:${userId}`;
}
function safeGet(storage: Storage, key: string): string | null {
try {
if (typeof storage.getItem === "function") {
return storage.getItem(key);
}
} catch {
// Ignore storage read failures in UX cache layer.
}
return null;
}
function safeSet(storage: Storage, key: string, value: string): void {
try {
if (typeof storage.setItem === "function") {
storage.setItem(key, value);
}
} catch {
// Ignore storage write failures in UX cache layer.
}
}
function safeRemove(storage: Storage, key: string): void {
try {
if (typeof storage.removeItem === "function") {
storage.removeItem(key);
}
} catch {
// Ignore storage remove failures in UX cache layer.
}
}
export function readDashboardSnapshotCache<TSnapshot>(
userId: string,
options?: { now?: number; ttlMs?: number },
): DashboardSnapshotCachePayload<TSnapshot> | null {
const storage = getLocalStorage();
if (!storage) return null;
const parsed = safeParse(safeGet(storage, cacheKey(userId)));
if (!isRecord(parsed)) return null;
if (parsed.version !== CACHE_VERSION) return null;
if (typeof parsed.cachedAt !== "number") return null;
if (!("snapshot" in parsed)) return null;
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
const now = options?.now ?? Date.now();
if (now - parsed.cachedAt > ttlMs) {
safeRemove(storage, cacheKey(userId));
return null;
}
return {
version: CACHE_VERSION,
cachedAt: parsed.cachedAt,
snapshot: parsed.snapshot as TSnapshot,
};
}
export function writeDashboardSnapshotCache<TSnapshot>(
userId: string,
snapshot: TSnapshot,
): void {
const storage = getLocalStorage();
if (!storage) return;
try {
safeSet(
storage,
cacheKey(userId),
JSON.stringify({
version: CACHE_VERSION,
cachedAt: Date.now(),
snapshot,
}),
);
} catch {
// Ignore quota/storage write errors in UX cache layer.
}
}
export function clearDashboardSnapshotCache(userId: string): void {
const storage = getLocalStorage();
if (!storage) return;
safeRemove(storage, cacheKey(userId));
}