Render milestone charts from real scan history

This commit is contained in:
2026-08-14 12:59:37 +02:00
parent 925540f3c6
commit aa3b4d02ab
4 changed files with 100 additions and 60 deletions

View File

@@ -8,14 +8,20 @@ import { useCsrf } from '@/hooks/useCsrf';
import { useTranslation } from '@/hooks/useTranslation';
import { showToast } from '@/components/ui/Toast';
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { periodDays: number; series: number[]; recentTotal: number } | null };
type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: string; totalUniqueScans: number; milestoneThreshold: number; trend: { points: Array<{ at: string; total: number }>; startLabel: string; endLabel: string; target: number; ceiling: number } | null };
type Milestone = { id: string; qrTitle: string; threshold: number; defaultXHandle: string | null; preview: string; language: 'en' | 'de'; card: Card; brandStatus: string; brandPostUrl: string | null; brandPostError: string | null };
type BrandState = { brandStatus: string; brandPostUrl: string | null; brandPostError: string | null; selfSharedAt: string | null };
function Trend({ series }: { series: number[] }) {
const max = Math.max(...series, 1);
const points = series.map((value, index) => `${(index / Math.max(series.length - 1, 1)) * 100},${92 - (value / max) * 70}`).join(' ');
return <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-20 w-full overflow-visible" aria-label="Real scan trend"><polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /></svg>;
function Trend({ trend }: { trend: NonNullable<Card['trend']> }) {
const first = new Date(trend.points[0].at).getTime();
const last = Math.max(new Date(trend.points[trend.points.length - 1].at).getTime(), first + 1);
const points = trend.points.map(point => {
const x = 5 + ((new Date(point.at).getTime() - first) / (last - first)) * 90;
const y = 88 - (point.total / trend.ceiling) * 72;
return `${x},${y}`;
}).join(' ');
const targetY = 88 - (trend.target / trend.ceiling) * 72;
return <div><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-24 w-full overflow-visible" aria-label="Cumulative unique scan trend"><line x1="5" x2="95" y1={targetY} y2={targetY} stroke="#bfdbfe" strokeWidth="1" vectorEffect="non-scaling-stroke" /><polyline points={points} fill="none" stroke="currentColor" strokeWidth="3" vectorEffect="non-scaling-stroke" /><circle cx="95" cy={targetY} r="2.7" fill="#0256ff" /></svg><div className="mt-1 flex justify-between text-[11px] font-medium tracking-wide text-[#45617f]"><span>{trend.startLabel}</span><span className="text-[#0256ff]">{trend.target.toLocaleString()}</span><span>{trend.endLabel}</span></div></div>;
}
export function SocialMilestoneDialog() {
@@ -101,9 +107,9 @@ export function SocialMilestoneDialog() {
<div className="px-7 pb-5 pt-7"><DialogHeader><div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-5 w-5" /></div><DialogTitle className="text-2xl font-semibold tracking-[-0.03em] text-[#061b31]">{copy.heading}</DialogTitle><DialogDescription className="pt-1 text-sm leading-6 text-[#4b5e76]"><strong className="font-medium text-[#061b31]">{milestone.qrTitle}</strong> {copy.subtitle}</DialogDescription></DialogHeader></div>
<div className="space-y-5 border-y border-slate-100 px-7 py-6">
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-[0_14px_28px_-22px_rgba(50,50,93,0.4)]">
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="font-semibold tracking-wide text-[#061b31]">QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
<div className="flex items-center justify-between border-b border-slate-100 pb-4 text-xs"><span className="flex items-center gap-2 font-semibold tracking-wide text-[#061b31]"><img src="/favicon.ico" alt="" className="h-5 w-5" />QR MASTER</span><span className="rounded bg-emerald-50 px-2 py-1 font-medium text-emerald-700"><Check className="mr-1 inline h-3 w-3" />Verified scan milestone</span></div>
<div className="mt-5 text-[11px] font-semibold tracking-[0.12em] text-slate-400">TOTAL UNIQUE SCANS</div><div className="mt-1 text-5xl font-semibold tracking-[-0.05em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-1 text-sm text-slate-500">{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}</div>
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend series={card.trend.series} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
{card.trend ? <div className="mt-5 text-[#0256ff]"><Trend trend={card.trend} /><div className="mt-2 flex items-center gap-2 text-xs text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}</div></div> : <div className="mt-5 inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-xs font-medium text-blue-700"><LineChart className="h-3.5 w-3.5" />{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}</div>}
<div className="mt-5 border-t border-slate-100 pt-3 text-sm font-medium text-[#061b31]">{card.qrTitle}</div>
</section>
<div><p className="text-sm leading-6 text-[#4b5e76]">{copy.consent}</p><blockquote className="mt-3 border-l-2 border-[#0256ff] pl-3 text-sm leading-6 text-[#273951]">{preview}</blockquote></div>

View File

@@ -45,30 +45,28 @@ export async function detectSocialMilestones(qrId?: string) {
async function createCardSnapshot(qr: { id: string; title: string; createdAt: Date; user: { primaryUseCase: string | null } }) {
const now = new Date();
const sevenDaysAgo = new Date(now);
sevenDaysAgo.setUTCHours(0, 0, 0, 0);
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6);
const scans = await db.qRScan.findMany({
where: { qrId: qr.id, isUnique: true },
select: { ts: true },
orderBy: { ts: 'asc' },
});
const daily = new Map<string, number>();
for (const scan of scans) {
if (scan.ts >= sevenDaysAgo) {
const key = scan.ts.toISOString().slice(0, 10);
daily.set(key, (daily.get(key) || 0) + 1);
}
}
const series = Array.from({ length: 7 }, (_, index) => {
const date = new Date(sevenDaysAgo);
date.setUTCDate(date.getUTCDate() + index);
return daily.get(date.toISOString().slice(0, 10)) || 0;
// Keep a representative, cumulative history in the immutable snapshot.
// It starts at QR creation and ends at the moment the milestone is detected.
const stride = Math.max(1, Math.ceil(scans.length / 24));
const points = [{ at: qr.createdAt.toISOString(), total: 0 }];
scans.forEach((scan, index) => {
const total = index + 1;
if (total % stride === 0 || total === scans.length) points.push({ at: scan.ts.toISOString(), total });
});
const activeDays = series.filter(Boolean).length;
const trend = activeDays >= 2 && scans.length >= 5
? { periodDays: 7, series, recentTotal: series.reduce((total, value) => total + value, 0) }
: null;
const month = new Intl.DateTimeFormat('en', { month: 'short', year: '2-digit', timeZone: 'UTC' });
const trend = {
points,
startLabel: month.format(qr.createdAt),
endLabel: month.format(now),
target: scans.length,
// This makes the reached total sit one grid level below the chart top.
ceiling: Math.max(1.25, scans.length * 1.25),
};
// The snapshot is made at detection time and never silently changes after consent.
return buildMilestoneCardSnapshot({

View File

@@ -48,7 +48,13 @@ export type SocialMilestoneCard = {
totalUniqueScans: number;
milestoneThreshold: number;
reachedAt: string;
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
trend: {
points: Array<{ at: string; total: number }>;
startLabel: string;
endLabel: string;
target: number;
ceiling: number;
} | null;
};
export function socialLocale(value?: string | null): SocialLocale {
@@ -90,7 +96,7 @@ export function buildMilestoneCardSnapshot(input: {
totalUniqueScans: number;
milestoneThreshold: number;
reachedAt: Date;
trend: { periodDays: number; series: number[]; recentTotal: number } | null;
trend: SocialMilestoneCard['trend'];
locale: SocialLocale;
}): SocialMilestoneCard {
return {