Fix milestone sharing and test worker routing
This commit is contained in:
@@ -65,6 +65,15 @@ services:
|
||||
- test-internal
|
||||
- qrmaster-network
|
||||
|
||||
social-worker:
|
||||
container_name: qrmaster-test-social-worker
|
||||
environment:
|
||||
# Never resolve the ambiguous `web` alias on the shared production
|
||||
# network. The test container name is unique on this Docker daemon.
|
||||
QRMASTER_API_BASE: http://qrmaster-test-web:3000
|
||||
networks: !override
|
||||
- test-internal
|
||||
|
||||
adminer:
|
||||
container_name: qrmaster-test-adminer
|
||||
ports: !reset []
|
||||
|
||||
@@ -73,14 +73,15 @@ def render_card(card):
|
||||
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))
|
||||
ceiling = 5.0 if total <= 5 else float(trend.get("ceiling") or total * 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)
|
||||
# Exactly five levels: for 20 scans, 5 / 10 / 15 / 20 / 25.
|
||||
for index in range(1, 6):
|
||||
value = total * index / 4
|
||||
# Small milestones use whole scans (1..5); larger ones keep the
|
||||
# reached milestone on the fourth of five levels.
|
||||
axis_values = list(range(1, 6)) if total <= 5 else [total * index / 4 for index in range(1, 6)]
|
||||
for value in axis_values:
|
||||
y = top + height - round(value / ceiling * height)
|
||||
is_target = index == 4
|
||||
is_target = value == total
|
||||
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")
|
||||
@@ -146,6 +147,12 @@ if __name__ == "__main__":
|
||||
interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10")))
|
||||
if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}:
|
||||
raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker")
|
||||
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval}), flush=True)
|
||||
while True:
|
||||
run_once()
|
||||
try:
|
||||
run_once()
|
||||
except Exception as error:
|
||||
# Stay alive and make configuration/network errors visible in the
|
||||
# container logs instead of entering a silent restart loop.
|
||||
print(f"Worker cycle failed: {error}", flush=True)
|
||||
time.sleep(interval)
|
||||
|
||||
@@ -12,17 +12,30 @@ type Card = { language: 'en' | 'de'; qrTitle: string; label: string; title: stri
|
||||
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({ trend }: { trend: NonNullable<Card['trend']> }) {
|
||||
function Trend({ trend, locale }: { trend: NonNullable<Card['trend']>; locale: 'en' | 'de' }) {
|
||||
const ceiling = trend.target <= 5 ? 5 : trend.target * 1.25;
|
||||
const ticks = trend.target <= 5
|
||||
? [1, 2, 3, 4, 5]
|
||||
: Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
|
||||
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;
|
||||
const x = 52 + ((new Date(point.at).getTime() - first) / (last - first)) * 356;
|
||||
const y = 104 - (point.total / ceiling) * 88;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
const targetY = 88 - (trend.target / trend.ceiling) * 72;
|
||||
const ticks = Array.from({ length: 5 }, (_, index) => trend.target * (index + 1) / 4);
|
||||
return <div className="grid grid-cols-[2.5rem_1fr] gap-2"><div className="flex h-24 flex-col justify-between py-1 text-right text-[10px] font-medium tabular-nums text-[#45617f]">{ticks.slice().reverse().map(tick => <span key={tick} className={tick === trend.target ? 'text-[#0256ff]' : undefined}>{tick.toLocaleString()}</span>)}</div><div><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-24 w-full overflow-visible" aria-label="Cumulative unique scan trend">{ticks.map(tick => { const y = 88 - (tick / trend.ceiling) * 72; return <line key={tick} x1="5" x2="95" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? '1.4' : '0.8'} 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>{trend.endLabel}</span></div></div></div>;
|
||||
const targetY = 104 - (trend.target / ceiling) * 88;
|
||||
const number = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 2 });
|
||||
return <svg viewBox="0 0 420 132" className="w-full" role="img" aria-label="Cumulative unique scan trend">
|
||||
{ticks.map(tick => {
|
||||
const y = 104 - (tick / ceiling) * 88;
|
||||
return <g key={tick}><text x="42" y={y + 3} textAnchor="end" fontSize="9" fontWeight={tick === trend.target ? 700 : 500} fill={tick === trend.target ? '#0256ff' : '#64748b'}>{number.format(tick)}</text><line x1="52" x2="408" y1={y} y2={y} stroke={tick === trend.target ? '#bfdbfe' : '#e2e8f0'} strokeWidth={tick === trend.target ? 1.4 : 0.8} /></g>;
|
||||
})}
|
||||
<polyline points={points} fill="none" stroke="#0256ff" strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<circle cx="408" cy={targetY} r="3.5" fill="#ffffff" stroke="#0256ff" strokeWidth="2.5" />
|
||||
<text x="52" y="126" fontSize="9" fontWeight="600" fill="#45617f">{trend.startLabel}</text>
|
||||
<text x="408" y="126" textAnchor="end" fontSize="9" fontWeight="600" fill="#45617f">{trend.endLabel}</text>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
export function SocialMilestoneDialog() {
|
||||
@@ -73,16 +86,24 @@ export function SocialMilestoneDialog() {
|
||||
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');
|
||||
const shareWindow = window.open('about:blank', '_blank');
|
||||
if (shareWindow) shareWindow.opener = null;
|
||||
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') shareWindow?.location.replace(`https://x.com/intent/post?text=${encodeURIComponent(text)}`);
|
||||
const targetUrl = network === 'x'
|
||||
? `https://x.com/intent/post?text=${encodeURIComponent(text)}`
|
||||
: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||
if (network === 'x') {
|
||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||
else window.location.assign(targetUrl);
|
||||
}
|
||||
else {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
shareWindow?.location.replace(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`);
|
||||
if (shareWindow) shareWindow.location.href = targetUrl;
|
||||
else window.location.assign(targetUrl);
|
||||
}
|
||||
setBrand(result.milestone);
|
||||
showToast(network === 'linkedin' ? 'Share text copied and LinkedIn opened.' : 'X share composer opened.', 'success');
|
||||
@@ -107,13 +128,13 @@ export function SocialMilestoneDialog() {
|
||||
const count = card.totalUniqueScans || milestone.threshold;
|
||||
const status = brand?.brandStatus || milestone.brandStatus || 'pending';
|
||||
return <Dialog open onOpenChange={open => !open && setMilestone(null)}>
|
||||
<DialogContent className="max-w-xl overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
||||
<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">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] max-w-xl flex-col overflow-hidden border-slate-200 p-0 shadow-[0_30px_45px_-30px_rgba(50,50,93,0.45)]">
|
||||
<div className="shrink-0 px-6 pb-4 pt-5"><DialogHeader><div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-[#eaf1ff] text-[#0256ff]"><Sparkles className="h-4 w-4" /></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="min-h-0 space-y-4 overflow-y-auto border-y border-slate-100 px-6 py-5 overscroll-contain">
|
||||
<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="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><div className="mt-3 border-l border-slate-200 pl-3 text-xs text-[#4b5e76]"><span className="font-semibold tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</span> {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}</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 grid grid-cols-[0.72fr_1.28fr] items-end gap-4"><div><div className="text-[10px] font-semibold tracking-[0.1em] text-slate-400">UNIQUE SCANS</div><div className="mt-1 text-5xl font-normal tracking-[-0.04em] tabular-nums text-[#061b31]">{count.toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</div><div className="mt-2 text-xs text-[#4b5e76]"><span className="font-semibold tabular-nums text-[#061b31]">{(card.totalScans || count).toLocaleString(milestone.language === 'de' ? 'de-DE' : 'en-US')}</span> {milestone.language === 'de' ? 'Scans insgesamt' : 'total scans'}</div></div>{card.trend ? <Trend trend={card.trend} locale={milestone.language} /> : <div className="text-xs text-[#45617f]">{milestone.language === 'de' ? 'Noch keine Verlaufskurve verfügbar.' : 'No scan timeline available yet.'}</div>}</div>
|
||||
{card.trend && <div className="mt-3 flex items-center justify-end gap-2 text-[11px] text-[#45617f]"><LineChart className="h-3.5 w-3.5 text-[#0256ff]" />{milestone.language === 'de' ? 'Kumulierte eindeutige Scans' : 'Cumulative unique scans'}</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>
|
||||
@@ -122,7 +143,7 @@ export function SocialMilestoneDialog() {
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="mr-1 text-xs font-medium text-slate-500">{copy.self}</span><Button variant="outline" size="sm" onClick={() => shareSelf('x')} disabled={saving !== null}><X className="mr-1.5 h-3.5 w-3.5" />X</Button><Button variant="outline" size="sm" onClick={() => shareSelf('linkedin')} disabled={saving !== null}><Linkedin className="mr-1.5 h-3.5 w-3.5" />LinkedIn</Button></div>
|
||||
{status !== 'pending' && <div className={`flex items-center justify-between rounded-md px-3 py-2 text-sm ${status === 'posted' ? 'bg-emerald-50 text-emerald-800' : status === 'failed' ? 'bg-rose-50 text-rose-800' : 'bg-blue-50 text-blue-800'}`}><span>{status === 'posted' ? copy.posted : status === 'failed' ? copy.failed : copy.queued}</span>{brand?.brandPostUrl && <a href={brand.brandPostUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline"><ExternalLink className="h-3.5 w-3.5" />View</a>}</div>}
|
||||
</div>
|
||||
<DialogFooter className="bg-slate-50 px-7 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
|
||||
<DialogFooter className="shrink-0 bg-slate-50 px-6 py-4"><div className="flex w-full flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => dismiss('decline')} disabled={saving !== null}>{copy.decline}</Button><Button variant="primary" onClick={approveBrand} disabled={saving !== null || status !== 'pending'}><Send className="mr-1.5 h-4 w-4" />{status === 'approved' || status === 'processing' ? copy.queued : copy.approve}</Button><button type="button" className="w-full pt-1 text-xs text-slate-500 underline underline-offset-2 hover:text-slate-700" onClick={() => dismiss('opt_out')} disabled={saving !== null}>Do not show again</button></div></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user