feat(dashboard): cache snapshot data and add credits activity analytics
This commit is contained in:
136
lib/credits-activity.ts
Normal file
136
lib/credits-activity.ts
Normal 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)];
|
||||
}
|
||||
122
lib/dashboard-snapshot-cache.ts
Normal file
122
lib/dashboard-snapshot-cache.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user