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

@@ -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