From 8e34f97afb2760f6f8b8210b340c8a21081facc4 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 14 Aug 2026 13:04:09 +0200 Subject: [PATCH] Align milestone charts and self-share flow --- scripts/social-worker/worker.py | 16 +++++++++++----- .../(main)/api/social-milestones/[id]/route.ts | 6 ++++-- .../dashboard/SocialMilestoneDialog.tsx | 12 ++++++++---- src/lib/social-milestones-server.ts | 3 ++- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/scripts/social-worker/worker.py b/scripts/social-worker/worker.py index f13e1a2..cd10712 100644 --- a/scripts/social-worker/worker.py +++ b/scripts/social-worker/worker.py @@ -74,14 +74,16 @@ def render_card(card): 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) + # Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25. + for index in range(1, 6): + value = total * index / 4 + y = top + height - round(value / ceiling * height) + is_target = index == 4 + draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1) + draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra") 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) @@ -97,6 +99,10 @@ def _timestamp(value): return 0 +def _format_axis(value): + return f"{value:g}" if value < 1000 else f"{value:,.0f}" + + 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/app/(main)/api/social-milestones/[id]/route.ts b/src/app/(main)/api/social-milestones/[id]/route.ts index 76236e7..225bc33 100644 --- a/src/app/(main)/api/social-milestones/[id]/route.ts +++ b/src/app/(main)/api/social-milestones/[id]/route.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'crypto'; +import { randomBytes } from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { csrfProtection } from '@/lib/csrf'; @@ -78,7 +78,9 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st const now = new Date(); if (body.action === 'self_share') { - const token = milestone.shareToken || randomUUID().replace(/-/g, ''); + // 72 random bits keep public URLs unguessable while making the share URL + // much less disruptive in an X compose window than a full UUID. + const token = milestone.shareToken || randomBytes(9).toString('base64url'); const updated = await db.socialMilestone.update({ where: { id: milestone.id }, data: { selfSharedAt: now, publicShareApprovedAt: now, shareToken: token, cardData: card, language }, diff --git a/src/components/dashboard/SocialMilestoneDialog.tsx b/src/components/dashboard/SocialMilestoneDialog.tsx index c1886fa..fb1b755 100644 --- a/src/components/dashboard/SocialMilestoneDialog.tsx +++ b/src/components/dashboard/SocialMilestoneDialog.tsx @@ -21,7 +21,8 @@ function Trend({ trend }: { trend: NonNullable }) { return `${x},${y}`; }).join(' '); const targetY = 88 - (trend.target / trend.ceiling) * 72; - return
{trend.startLabel}{trend.target.toLocaleString()}{trend.endLabel}
; + const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4); + return
{ticks.slice().reverse().map(tick => {tick.toLocaleString()})}
{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return ; })}
{trend.startLabel}{trend.endLabel}
; } export function SocialMilestoneDialog() { @@ -70,19 +71,22 @@ export function SocialMilestoneDialog() { }; const shareSelf = async (network: 'x' | 'linkedin') => { if (!milestone) return; + // Open synchronously from the user gesture. Awaiting the API first can make + // LinkedIn treat the new window as a blocked popup. + const shareWindow = window.open('', '_blank', 'noopener,noreferrer'); setSaving('self'); try { const result = await update('self_share'); const shareUrl = `${window.location.origin}/s/m/${result.shareToken}`; const text = `${preview} ${shareUrl}`; - if (network === 'x') window.open(`https://x.com/intent/post?text=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer'); + if (network === 'x') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`); else { await navigator.clipboard?.writeText(text); - window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,noreferrer'); + shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`); } setBrand(result.milestone); showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success'); - } catch (error) { showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } + } catch (error) { shareWindow?.close(); showToast(error instanceof Error ? error.message : 'Could not prepare this share', 'error'); } finally { setSaving(null); } }; const approveBrand = async () => { diff --git a/src/lib/social-milestones-server.ts b/src/lib/social-milestones-server.ts index 181a26a..fdb0e92 100644 --- a/src/lib/social-milestones-server.ts +++ b/src/lib/social-milestones-server.ts @@ -64,7 +64,8 @@ async function createCardSnapshot(qr: { id: string; title: string; createdAt: Da 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. + // Five labelled grid lines: 1/4, 1/2, 3/4, target, then one level above. + // For 20 scans this is precisely 5, 10, 15, 20, 25. ceiling: Math.max(1.25, scans.length * 1.25), };