From aa3b4d02ab0ba2bc5ddb780763d2bcc1f1a8ae3b Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 12:59:37 +0200 Subject: [PATCH] Render milestone charts from real scan history --- scripts/social-worker/worker.py | 96 ++++++++++++------- .../dashboard/SocialMilestoneDialog.tsx | 20 ++-- src/lib/social-milestones-server.ts | 34 ++++--- src/lib/social-milestones.ts | 10 +- 4 files changed, 100 insertions(+), 60 deletions(-) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index 01f61a7..f13e1a2 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -1,4 +1,5 @@ """Always-on QR Master X milestone worker. The web app never receives X keys.""" +import io import json import os import tempfile @@ -24,49 +25,78 @@ def api(method, url, payload=None): def font(name, size): - return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size) + for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"): + if Path(candidate).exists(): + return ImageFont.truetype(candidate, size) + return ImageFont.load_default() + + +def logo(): + """Use the actual deployed QR Master favicon, not an invented icon.""" + try: + base = required("QRMASTER_API_BASE").rstrip("/") + response = requests.get(f"{base}/favicon.ico", timeout=10) + response.raise_for_status() + mark = Image.open(io.BytesIO(response.content)).convert("RGBA") + mark.thumbnail((56, 56)) + return mark + except Exception: + return None def render_card(card): - """Render the consented immutable scan snapshot; never invent trend data.""" - image = Image.new("RGB", (1200, 630), "#f8fafc") + """Render an immutable cumulative scan timeline from the stored snapshot.""" + image = Image.new("RGB", (1200, 630), "#f8f7f4") draw = ImageDraw.Draw(image) - navy, blue, slate, border, green = "#061b31", "#0256ff", "#64748b", "#e2e8f0", "#059669" - regular, medium, bold, display = font("DejaVuSans.ttf", 28), font("DejaVuSans-Bold.ttf", 28), font("DejaVuSans-Bold.ttf", 42), font("DejaVuSans-Bold.ttf", 116) - draw.rounded_rectangle((45, 42, 1155, 588), radius=24, fill="#ffffff", outline=border, width=2) - draw.rounded_rectangle((82, 79, 114, 111), radius=7, fill=blue) - draw.text((130, 81), "QR MASTER", font=medium, fill=navy) - draw.rounded_rectangle((932, 77, 1118, 114), radius=8, fill="#ecfdf5") - draw.text((954, 84), "Verified milestone", font=font("DejaVuSans-Bold.ttf", 17), fill=green) - draw.text((84, 158), "TOTAL UNIQUE SCANS", font=font("DejaVuSans-Bold.ttf", 18), fill="#94a3b8") - total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) - draw.text((78, 184), f"{total:,}", font=display, fill=navy) - label = "eindeutige Scans" if card.get("language") == "de" else "unique scans" - draw.text((86, 325), label, font=regular, fill=slate) - trend = card.get("trend") or None - if trend and len(trend.get("series", [])) > 1: - series = trend["series"] - left, top, width, height = 84, 390, 1030, 88 - max_value = max(series) or 1 - points = [(left + round(index * width / (len(series) - 1)), top + height - round(value / max_value * height)) for index, value in enumerate(series)] - for y in (top, top + height // 2, top + height): - draw.line((left, y, left + width, y), fill="#edf2f7", width=2) - draw.line(points, fill=blue, width=6, joint="curve") - for x, y in (points[0], points[-1]): - draw.ellipse((x - 7, y - 7, x + 7, y + 7), fill=blue) - draw.text((84, 495), f"Last {trend.get('periodDays', 7)} days ยท {trend.get('recentTotal', 0)} unique scans", font=font("DejaVuSans.ttf", 18), fill=slate) + navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669" + regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142) + mark = logo() + if mark: + image.paste(mark, (68, 58), mark) + logo_x = 140 else: - draw.rounded_rectangle((84, 398, 357, 452), radius=10, fill="#eff6ff") - copy = "Erste Dynamik" if card.get("language") == "de" else "Early momentum" - draw.text((106, 411), copy, font=font("DejaVuSans-Bold.ttf", 20), fill=blue) - draw.text((84, 495), "Trend appears once enough real scan history exists." if card.get("language") != "de" else "Der Trend erscheint mit ausreichend echten Scan-Daten.", font=font("DejaVuSans.ttf", 18), fill=slate) - draw.line((84, 532, 1116, 532), fill=border, width=2) - draw.text((84, 549), card.get("qrTitle") or card.get("title") or "QR code", font=medium, fill=navy) + logo_x = 68 + draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy) + + total = int(card.get("totalUniqueScans") or card.get("threshold") or 0) + draw.text((66, 212), f"{total:,}", font=display, fill=navy) + draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy) + draw.line((68, 548, 108, 548), fill=blue, width=4) + draw.text((126, 530), "QR code milestone", font=regular, fill=slate) + + trend = card.get("trend") or {} + raw_points = trend.get("points") or [] + left, top, width, height = 590, 140, 540, 330 + if raw_points: + start_ms = min(_timestamp(point.get("at")) for point in raw_points) + end_ms = max(_timestamp(point.get("at")) for point in raw_points) + span = max(1, end_ms - start_ms) + ceiling = float(trend.get("ceiling") or max(total * 1.25, 1.25)) + points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points] + target_y = top + height - round(float(trend.get("target") or total) / ceiling * height) + for fraction in (0, 0.25, 0.5, 0.75, 1): + y = top + round(height * fraction) + draw.line((left, y, left + width, y), fill="#dde5ef", width=1) + draw.line((left, target_y, left + width, target_y), fill="#bfdbfe", width=2) + draw.line(points, fill=blue, width=5, joint="curve") + x, y = points[-1] + draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5) + draw.text((left - 12, target_y - 34), f"{total:,}", font=font("DejaVuSans-Bold.ttf", 20), fill=blue, anchor="ra") + draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate) + draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra") + draw.text((870, 530), "VERIFIED SCAN DATA", font=font("DejaVuSans-Bold.ttf", 18), fill=mint) path = Path(tempfile.mkstemp(suffix=".png")[1]) image.save(path, "PNG", optimize=True) return path +def _timestamp(value): + try: + return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + except Exception: + return 0 + + def post_x(text, card): oauth = OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET")) path = render_card(card) if card else None diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index d65f4a3..c1886fa 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -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 ; +function Trend({ trend }: { trend: NonNullable }) { + 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
{trend.startLabel}{trend.target.toLocaleString()}{trend.endLabel}
; } export function SocialMilestoneDialog() { @@ -101,9 +107,9 @@ export function SocialMilestoneDialog() {
{copy.heading}{milestone.qrTitle} {copy.subtitle}
-
QR MASTERVerified scan milestone
+
QR MASTERVerified scan milestone
TOTAL UNIQUE SCANS
{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}
{milestone.language === 'de' ? 'eindeutige Scans' : 'unique scans'}
- {card.trend ?
{card.trend.recentTotal} {milestone.language === 'de' ? 'eindeutige Scans in den letzten' : 'unique scans in the last'} {card.trend.periodDays} days
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
} + {card.trend ?
{milestone.language === 'de' ? 'Kumulierte eindeutige Scans seit Erstellung' : 'Cumulative unique scans since creation'}
:
{milestone.language === 'de' ? 'Erste Dynamik' : 'Early momentum'}
}
{card.qrTitle}

{copy.consent}

{preview}
diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index 9ec4e70..181a26a 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -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(); - 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({ diff --git a/src/lib/social-milestones.ts b/src/lib/social-milestones.ts index 87a0921..e670788 100644 --- a/src/lib/social-milestones.ts +++ b/src/lib/social-milestones.ts @@ -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 {