better cron jobs. Better UI on walkthrough.

This commit is contained in:
Madison 2026-05-28 10:43:06 -05:00
parent 6c6c37f7a6
commit 91fc06188f
5 changed files with 242 additions and 107 deletions

View File

@ -8,9 +8,105 @@ const endpoints = [
"/api/latest/internal/external-db-sync/poller",
];
const previewEndpoints = [
"/api/latest/internal/preview/fill-pool",
];
const previewFillPoolEndpoint = "/api/latest/internal/preview/fill-pool";
const PREVIEW_FILL_POOL_ACTIVE_INTERVAL_MS = 5_000;
const PREVIEW_FILL_POOL_IDLE_INTERVAL_MS = 60_000;
const PREVIEW_FILL_POOL_ERROR_INTERVAL_MS = 10_000;
const BACKEND_HEALTH_POLL_INTERVAL_MS = 2_000;
type PreviewFillPoolResult = {
ready_count_before: number,
created_count: number,
target_ready_count: number,
deleted_expired_count: number,
};
async function waitUntilBackendReady(baseUrl: string): Promise<void> {
while (true) {
const healthResult = await Result.fromPromise(fetch(`${baseUrl}/health`));
if (healthResult.status === "ok" && healthResult.data.ok) {
return;
}
await wait(BACKEND_HEALTH_POLL_INTERVAL_MS);
}
}
function getPreviewFillPoolPollIntervalMs(result: PreviewFillPoolResult): number {
const poolNeedsFilling = result.ready_count_before < result.target_ready_count;
const didWork = result.created_count > 0 || result.deleted_expired_count > 0;
if (poolNeedsFilling || didWork) {
return PREVIEW_FILL_POOL_ACTIVE_INTERVAL_MS;
}
return PREVIEW_FILL_POOL_IDLE_INTERVAL_MS;
}
function logPreviewFillPoolActivity(result: PreviewFillPoolResult): void {
const parts: string[] = [];
if (result.created_count > 0) {
parts.push(`created ${result.created_count}`);
}
if (result.deleted_expired_count > 0) {
parts.push(`cleaned up ${result.deleted_expired_count} expired lease(s)`);
}
if (parts.length === 0) {
return;
}
const readyCount = result.ready_count_before + result.created_count;
console.log(`Preview pool: ${parts.join(", ")} (${readyCount}/${result.target_ready_count} ready)`);
}
async function runPreviewFillPool(baseUrl: string, cronSecret: string): Promise<PreviewFillPoolResult> {
const res = await fetch(`${baseUrl}${previewFillPoolEndpoint}`, {
headers: {
"Authorization": `Bearer ${cronSecret}`,
"x-stack-development-disable-extended-logging": "yes",
},
});
if (!res.ok) {
throw new HexclaveAssertionError(
`Failed to call ${previewFillPoolEndpoint}: ${res.status} ${res.statusText}\n${await res.text()}`,
{ res },
);
}
return await res.json() as PreviewFillPoolResult;
}
async function runPreviewFillPoolLoop(baseUrl: string, cronSecret: string): Promise<void> {
console.log("Preview pool cron started (fills every 5s while below target, every 60s when full).");
await waitUntilBackendReady(baseUrl);
let isIdle = false;
while (true) {
const runResult = await Result.fromPromise(runPreviewFillPool(baseUrl, cronSecret));
if (runResult.status === "error") {
captureError("run-cron-jobs-preview", runResult.error);
isIdle = false;
await wait(PREVIEW_FILL_POOL_ERROR_INTERVAL_MS);
continue;
}
const result = runResult.data;
const didWork = result.created_count > 0 || result.deleted_expired_count > 0;
const poolNeedsFilling = result.ready_count_before < result.target_ready_count;
if (didWork) {
logPreviewFillPoolActivity(result);
isIdle = false;
} else if (poolNeedsFilling) {
isIdle = false;
} else if (!isIdle) {
console.log(
`Preview pool full (${result.ready_count_before}/${result.target_ready_count} ready), `
+ `polling every ${PREVIEW_FILL_POOL_IDLE_INTERVAL_MS / 1000}s`,
);
isIdle = true;
}
await wait(getPreviewFillPoolPollIntervalMs(result));
}
}
async function main() {
const baseUrl = `http://localhost:${getEnvVariable('NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX', '81')}02`;
@ -25,27 +121,7 @@ async function main() {
return;
}
const run = async (endpoint: string) => {
console.log(`Running ${endpoint}...`);
const res = await fetch(`${baseUrl}${endpoint}`, {
headers: { 'Authorization': `Bearer ${cronSecret}` },
});
if (!res.ok) throw new HexclaveAssertionError(`Failed to call ${endpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, { res });
console.log(`${endpoint} completed.`);
};
for (const endpoint of previewEndpoints) {
runAsynchronously(async () => {
await wait(30_000); // Wait 30 seconds to make sure the server is fully started
while (true) {
const runResult = await Result.fromPromise(run(endpoint));
if (runResult.status === "error") {
captureError("run-cron-jobs-preview", runResult.error);
}
await wait(5000);
}
});
}
runAsynchronously(() => runPreviewFillPoolLoop(baseUrl, cronSecret));
return;
}
console.log("Starting cron jobs...");
@ -62,7 +138,7 @@ async function main() {
for (const endpoint of endpoints) {
runAsynchronously(async () => {
await wait(30_000); // Wait 30 seconds to make sure the server is fully started
await waitUntilBackendReady(baseUrl);
while (true) {
const runResult = await Result.fromPromise(run(endpoint));
if (runResult.status === "error") {

View File

@ -414,6 +414,17 @@ body:has(.show-site-loading-indicator) .site-loading-indicator {
}
}
@keyframes walkthrough-pulse-ring {
0% {
transform: scale(0.85);
opacity: 0.9;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
/* Pacifica styles */
[data-pacifica-surface] {

View File

@ -1,21 +1,42 @@
export function MockCursor({ className }: { className?: string }) {
import { cn } from '@/lib/utils';
import type { WalkthroughPhase } from './walkthrough-steps';
export function MockCursor({
className,
phase,
}: {
className?: string,
phase?: WalkthroughPhase,
}) {
return (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
>
<path
d="M5 3L19 12L12 13L9 20L5 3Z"
fill="white"
stroke="black"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
<div className="relative">
{phase === 'dwelling' && (
<span
className={cn(
"absolute -top-1 -left-1 h-8 w-8 rounded-full",
"border-2 border-blue-500",
"animate-[walkthrough-pulse-ring_1.4s_ease-out_infinite]",
)}
aria-hidden
/>
)}
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={{ filter: 'drop-shadow(2px 2px 0 rgba(0,0,0,0.85))' }}
>
<path
d="M5 3L19 12L12 13L9 20L5 3Z"
fill="white"
stroke="#171717"
strokeWidth="1.75"
strokeLinejoin="round"
/>
</svg>
</div>
);
}

View File

@ -4,9 +4,11 @@ import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { MockCursor } from './mock-cursor';
import { type SpotlightRect, type WalkthroughStep } from './walkthrough-steps';
import { type SpotlightRect, type WalkthroughPhase, type WalkthroughStep } from './walkthrough-steps';
export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing';
export type { WalkthroughPhase };
const SPOTLIGHT_BORDER = 'rgb(59 130 246)'; // blue-500
export function WalkthroughOverlay({
step,
@ -61,7 +63,7 @@ export function WalkthroughOverlay({
: null;
const padding = step?.spotlightPadding ?? 8;
const overlayOpacity = showSpotlight ? (animatedIn ? 0.55 : 0) : 0;
const overlayOpacity = showSpotlight ? (animatedIn ? 0.62 : 0) : 0;
return createPortal(
<>
@ -70,15 +72,18 @@ export function WalkthroughOverlay({
{showSpotlight && displayRect && step && (
<>
<div
className="fixed pointer-events-none rounded-xl"
className="fixed pointer-events-none"
style={{
top: displayRect.top - padding,
left: displayRect.left - padding,
width: displayRect.width + padding * 2,
height: displayRect.height + padding * 2,
boxShadow: `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity})`,
borderRadius: animatedIn ? '14px' : '0px',
border: animatedIn ? `2px solid ${SPOTLIGHT_BORDER}` : '0px solid transparent',
boxShadow: animatedIn
? `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity}), 0 0 14px 2px rgba(59, 130, 246, 0.35), 0 0 28px 6px rgba(59, 130, 246, 0.12)`
: `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity})`,
transition: 'all 400ms cubic-bezier(0.22, 1, 0.36, 1)',
borderRadius: animatedIn ? undefined : '0px',
}}
/>
@ -89,6 +94,7 @@ export function WalkthroughOverlay({
totalSteps={totalSteps}
phase={phase}
spotlightRect={spotlightRect!}
padding={padding}
/>
)}
</>
@ -103,10 +109,9 @@ export function WalkthroughOverlay({
transition: 'transform 500ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
>
<MockCursor />
<MockCursor phase={phase} />
</div>
{/* Floating tour progress card — bottom-center, bold and unmissable */}
{step && (
<TourProgressCard
stepIndex={stepIndex}
@ -116,12 +121,12 @@ export function WalkthroughOverlay({
/>
)}
{/* "Click to take control" hover overlay — above everything including CmdK */}
{/* "Click to take control" hover overlay */}
<div
className={cn(
"fixed inset-0 z-[60] flex items-center justify-center cursor-pointer",
"bg-black/50 backdrop-blur-[2px]",
"transition-opacity duration-200",
"transition-opacity duration-150",
isHovering ? "opacity-100" : "opacity-0 pointer-events-none",
)}
onClick={(e) => {
@ -135,7 +140,7 @@ export function WalkthroughOverlay({
</p>
</div>
{/* Invisible click catcher (active only when not hovering) — anywhere = stop */}
{/* Invisible click catcher (active only when not hovering) */}
{!isHovering && (
<div
className="fixed inset-0 z-40"
@ -162,8 +167,6 @@ function TourProgressCard({
phase: WalkthroughPhase,
dwellMs: number,
}) {
// Animate the bar's scaleX. During 'dwelling', grow from i/N to (i+1)/N over
// `dwellMs`. During 'navigating', hold at i/N. During 'finishing', show 100%.
const [progress, setProgress] = useState(stepIndex / totalSteps);
const [transitionMs, setTransitionMs] = useState(0);
@ -175,7 +178,6 @@ function TourProgressCard({
setTransitionMs(300);
setProgress(1);
} else if (phase === 'dwelling') {
// Snap to start with no transition, then next frame kick off the long animation.
setTransitionMs(0);
setProgress(stepIndex / totalSteps);
raf1 = requestAnimationFrame(() => {
@ -185,8 +187,6 @@ function TourProgressCard({
});
});
} else {
// 'navigating' — hold at start of step with a short transition so loop
// resets ease in rather than snap.
setTransitionMs(250);
setProgress(stepIndex / totalSteps);
}
@ -220,8 +220,7 @@ function TourProgressCard({
className={cn(
"inline-flex items-center px-2 py-0.5 rounded-full",
"text-[10px] font-bold uppercase tracking-wider",
"bg-gradient-to-r from-blue-500 to-indigo-500 text-white",
"shadow-[0_0_12px_rgba(59,130,246,0.6)]",
"bg-blue-500 text-white",
)}
>
Tour
@ -240,7 +239,6 @@ function TourProgressCard({
</span>
</div>
{/* The big bar */}
<div className="px-5 pb-4">
<div
className={cn(
@ -250,11 +248,7 @@ function TourProgressCard({
)}
>
<div
className={cn(
"absolute inset-y-0 left-0 origin-left rounded-full",
"bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-500",
"shadow-[0_0_18px_rgba(59,130,246,0.85),0_0_6px_rgba(99,102,241,1)]",
)}
className="absolute inset-y-0 left-0 origin-left rounded-full bg-blue-500"
style={{
width: '100%',
transform: `scaleX(${progress})`,
@ -262,12 +256,7 @@ function TourProgressCard({
? `transform ${transitionMs}ms linear`
: 'none',
}}
>
{/* Bright leading edge highlight */}
<div
className="absolute right-0 top-0 bottom-0 w-10 bg-gradient-to-l from-white/80 to-transparent pointer-events-none"
/>
</div>
/>
</div>
</div>
</div>
@ -281,53 +270,53 @@ function SpotlightTooltip({
totalSteps,
phase,
spotlightRect,
padding,
}: {
step: WalkthroughStep,
stepIndex: number,
totalSteps: number,
phase: WalkthroughPhase,
spotlightRect: SpotlightRect,
padding: number,
}) {
const tooltipWidth = 300;
const tooltipHeight = 96;
const tooltipGap = 16;
// Reserve room for the bottom progress card (~88px tall including margin).
const tooltipWidth = 280;
const tooltipGap = 14;
const viewportMarginTop = 16;
const viewportMarginBottom = 112;
const viewportMarginX = 16;
const padding = step.spotlightPadding ?? 8;
const spotlightTop = spotlightRect.top - padding;
const spotlightBottom = spotlightRect.top + spotlightRect.height + padding;
const spotlightCenterX = spotlightRect.left + spotlightRect.width / 2;
// Default: below the spotlight.
let placement: 'below' | 'above' = 'below';
let top = spotlightBottom + tooltipGap;
let left = spotlightCenterX - tooltipWidth / 2;
// If that would collide with the bottom progress card, flip to above the spotlight.
if (top + tooltipHeight > window.innerHeight - viewportMarginBottom) {
top = spotlightTop - tooltipGap - tooltipHeight;
const estimatedHeight = 96;
if (top + estimatedHeight > window.innerHeight - viewportMarginBottom) {
placement = 'above';
top = spotlightTop - tooltipGap - estimatedHeight;
}
top = Math.max(
viewportMarginTop,
Math.min(top, window.innerHeight - tooltipHeight - viewportMarginBottom),
Math.min(top, window.innerHeight - estimatedHeight - viewportMarginBottom),
);
let left = spotlightCenterX - tooltipWidth / 2;
left = Math.max(
viewportMarginX,
Math.min(left, window.innerWidth - tooltipWidth - viewportMarginX),
);
const pointerLeft = Math.max(
20,
Math.min(spotlightCenterX - left, tooltipWidth - 20),
);
return (
<div
className={cn(
"fixed pointer-events-none px-4 py-3 rounded-xl",
"bg-white/95 dark:bg-zinc-900/90",
"backdrop-blur-xl",
"shadow-2xl shadow-black/25",
"ring-1 ring-black/10 dark:ring-white/10",
)}
className="fixed pointer-events-none"
style={{
top,
left,
@ -335,16 +324,52 @@ function SpotlightTooltip({
transition: 'top 400ms cubic-bezier(0.22, 1, 0.36, 1), left 400ms cubic-bezier(0.22, 1, 0.36, 1)',
}}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">{step.title}</span>
<span className="text-[11px] tabular-nums font-medium text-zinc-500 dark:text-zinc-400">
{stepIndex + 1} / {totalSteps}
</span>
{/* Speech bubble pointer */}
<div
className={cn(
"absolute w-3 h-3",
"bg-white/95 dark:bg-zinc-900/90",
"border-black/10 dark:border-white/10",
)}
style={{
left: pointerLeft - 6,
...(placement === 'below'
? {
top: -6,
borderTopWidth: '1px',
borderLeftWidth: '1px',
transform: 'rotate(45deg)',
}
: {
bottom: -6,
borderBottomWidth: '1px',
borderRightWidth: '1px',
transform: 'rotate(45deg)',
}),
}}
/>
<div
className={cn(
"relative rounded-xl px-4 py-3",
"bg-white/95 dark:bg-zinc-900/90",
"backdrop-blur-xl",
"shadow-2xl shadow-black/25",
"ring-1 ring-black/10 dark:ring-white/10",
)}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">{step.title}</span>
<span className="text-[11px] tabular-nums font-medium text-zinc-500 dark:text-zinc-400">
{stepIndex + 1} / {totalSteps}
</span>
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{step.description}</p>
{phase === 'navigating' && (
<p className="mt-1.5 text-[11px] text-zinc-400 dark:text-zinc-500 italic">navigating</p>
)}
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{step.description}</p>
{phase === 'navigating' && (
<p className="mt-1.5 text-[11px] text-zinc-400 dark:text-zinc-500 italic">navigating</p>
)}
</div>
);
}

View File

@ -1,3 +1,5 @@
export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing';
export type WalkthroughStep = {
id: string,
path: string,
@ -21,14 +23,14 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [
path: '/',
sidebarNavLabel: 'Overview',
title: 'Global User Map',
description: 'See where your users are around the world.',
description: 'Pinch-zoom around the globe and see where sign-ups are landing.',
spotlightPadding: 12,
},
{
id: 'overview-metrics',
path: '/',
title: 'Usage Metrics',
description: 'Track daily active users and sign-ups at a glance.',
description: 'Daily actives, sign-ups, and retention — the numbers you actually check.',
spotlightPadding: 12,
},
{
@ -36,34 +38,34 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [
path: '/users',
cmdkSearch: 'Users',
title: 'User Management',
description: 'Manage all your users — search, export, or create new ones.',
description: 'Search, export, impersonate, or spin up test users in one place.',
},
{
id: 'teams-table',
path: '/teams',
cmdkSearch: 'Teams',
title: 'Teams',
description: 'Organize users into teams for multi-tenant apps.',
description: 'Multi-tenant apps live here. Teams, roles, invites — the whole stack.',
},
{
id: 'emails-sent',
path: '/email-sent',
cmdkSearch: 'Emails sent',
title: 'Email Logs',
description: 'Monitor sent emails, delivery status, and domain reputation.',
description: 'Every sent email, delivery status, and domain health in one feed.',
},
{
id: 'payments-products',
path: '/payments/products',
cmdkSearch: 'Products',
title: 'Products & Pricing',
description: 'Define products, pricing, and subscriptions.',
description: 'Products, prices, and subscriptions wired straight to Stripe.',
},
{
id: 'analytics-replays',
path: '/session-replays',
sidebarNavLabel: 'Replays',
title: 'Session Replays',
description: 'Watch real user sessions to understand how people use your app.',
description: 'Watch real sessions — clicks, rage taps, and dead ends included.',
},
];