From 3b7f937505def4bd1f73f7c1a9987622261d5bdc Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Wed, 12 Nov 2025 09:32:59 +0100 Subject: [PATCH] feat(slot-definitions): add real-time job tracking to materialization status panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MaterialisationJobInfo interface with job status, timing, and results - Add last_job field to MaterialisationStatus (BUILD 353 support) - Add calculateElapsedTime and formatElapsedTime helper functions - Implement job status polling (every 2s while queued/running) - Add elapsed time counter for running jobs with live updates - Display job progress states: - Queued: 'Job queued, waiting to start...' - Running: 'Generating slots... (45s)' with policy and horizon info - Completed: '✅ Generated X slots (Y cancelled, Z errors)' with detailed metrics - Failed: '❌ Job failed' with error message - Update rate limit display logic (only show when can_trigger=false AND no active job) - Disable trigger button when job is active (queued/running) - Resolves UX confusion where users saw rate limits while jobs were actually running - Implements Backend Brooke BUILD 353 job tracking feature --- .../MaterialisationStatusPanel.tsx | 118 +++++++++++++++++- src/types/materialisation.ts | 49 ++++++++ 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/MaterialisationStatusPanel.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/MaterialisationStatusPanel.tsx index 2f68920..2428ccb 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/MaterialisationStatusPanel.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/MaterialisationStatusPanel.tsx @@ -9,6 +9,8 @@ import { calculateRemainingCooldown, formatCountdown, formatTimestamp, + calculateElapsedTime, + formatElapsedTime, MATERIALISATION_POLICIES, } from '@/src/types/materialisation'; @@ -23,22 +25,49 @@ export default function MaterialisationStatusPanel({ clubId }: MaterialisationSt const [error, setError] = useState(null); const [countdown, setCountdown] = useState(0); const [selectedPolicy, setSelectedPolicy] = useState('SAFE'); + const [elapsedTime, setElapsedTime] = useState(0); // Poll status on mount and when triggering completes useEffect(() => { loadStatus(); }, [clubId]); - // Poll status every 3 seconds while job is running + // Poll status every 2 seconds while job is active (queued/running) useEffect(() => { - if (!status || status.status !== 'running') return; + if (!status) return; + + // Poll if legacy status is 'running' OR if last_job is queued/running + const isJobActive = + status.status === 'running' || + status.last_job?.status === 'queued' || + status.last_job?.status === 'running'; + + if (!isJobActive) return; const interval = setInterval(() => { loadStatus(); - }, 3000); + }, 2000); // 2 seconds as per spec + + return () => clearInterval(interval); + }, [status?.status, status?.last_job?.status]); + + // Update elapsed time every second for running jobs + useEffect(() => { + if (!status?.last_job?.started_at || status.last_job.status !== 'running') { + setElapsedTime(0); + return; + } + + const elapsed = calculateElapsedTime(status.last_job.started_at); + setElapsedTime(elapsed); + + const interval = setInterval(() => { + const newElapsed = calculateElapsedTime(status.last_job!.started_at!); + setElapsedTime(newElapsed); + }, 1000); return () => clearInterval(interval); - }, [status?.status]); + }, [status?.last_job?.started_at, status?.last_job?.status]); // Update countdown every second when rate limited useEffect(() => { @@ -139,8 +168,9 @@ export default function MaterialisationStatusPanel({ clubId }: MaterialisationSt if (!status) return null; // Determine panel state + const hasActiveJob = status.last_job?.status === 'queued' || status.last_job?.status === 'running'; const isRateLimited = !status.rate_limit.can_trigger && countdown > 0; - const canTrigger = status.rate_limit.can_trigger && status.status !== 'running' && !triggering; + const canTrigger = status.rate_limit.can_trigger && status.status !== 'running' && !hasActiveJob && !triggering; // Render status panel return ( @@ -244,8 +274,84 @@ export default function MaterialisationStatusPanel({ clubId }: MaterialisationSt )} + {/* Active Job Status (queued/running) */} + {status.last_job && (status.last_job.status === 'queued' || status.last_job.status === 'running') && ( +
+
+
+ +
+ {status.last_job.status === 'queued' && ( +

+ Job queued, waiting to start... +

+ )} + {status.last_job.status === 'running' && ( + <> +

+ Generating slots... ({formatElapsedTime(elapsedTime)}) +

+

+ Policy: {status.last_job.policy_profile} · Horizon: {status.last_job.horizon_days} days +

+ + )} +
+
+
+
+ )} + + {/* Completed Job Results */} + {status.last_job && status.last_job.status === 'completed' && status.last_job.result && !hasActiveJob && ( +
+
+
+ +
+

+ ✅ Generated {status.last_job.result.created} slots + {status.last_job.result.cancelled > 0 && ` (${status.last_job.result.cancelled} cancelled)`} + {status.last_job.result.errors.length > 0 && ` (${status.last_job.result.errors.length} errors)`} +

+
+ {status.last_job.result.updated > 0 &&
• Updated: {status.last_job.result.updated}
} + {status.last_job.result.moved > 0 &&
• Moved: {status.last_job.result.moved}
} + {status.last_job.result.skipped > 0 &&
• Skipped: {status.last_job.result.skipped}
} + {status.last_job.completed_at && ( +
Completed: {formatTimestamp(status.last_job.completed_at)}
+ )} +
+
+
+
+
+ )} + + {/* Failed Job Status */} + {status.last_job && status.last_job.status === 'failed' && !hasActiveJob && ( +
+
+
+ +
+

+ ❌ Job failed +

+ {status.last_job.error && ( +

{status.last_job.error}

+ )} + {status.last_job.failed_at && ( +
Failed: {formatTimestamp(status.last_job.failed_at)}
+ )} +
+
+
+
+ )} + {/* Rate limited state */} - {isRateLimited && ( + {isRateLimited && !hasActiveJob && (
{status.last_success_at && (
diff --git a/src/types/materialisation.ts b/src/types/materialisation.ts index 8902714..edae5d0 100644 --- a/src/types/materialisation.ts +++ b/src/types/materialisation.ts @@ -39,6 +39,28 @@ export interface MaterialisationRateLimit { cooldown_seconds: number; } +export interface MaterialisationJobInfo { + job_id: string; + status: 'queued' | 'running' | 'completed' | 'failed'; + accepted_at: string; // ISO 8601 timestamp + started_at?: string; // ISO 8601 timestamp + completed_at?: string; // ISO 8601 timestamp + failed_at?: string; // ISO 8601 timestamp + policy_profile: MaterialisationPolicyProfile; + horizon_days: number; + slots_generated?: number; + actor_user_id?: number; + error?: string; + result?: { + created: number; + updated: number; + moved: number; + cancelled: number; + skipped: number; + errors: string[]; + }; +} + export interface MaterialisationStatus { status: MaterialisationJobStatus; last_run_at: string | null; // ISO 8601 timestamp @@ -46,6 +68,7 @@ export interface MaterialisationStatus { last_error: string | null; slots_generated: number | null; rate_limit: MaterialisationRateLimit; + last_job?: MaterialisationJobInfo; // Real-time job tracking (BUILD 353) } export interface MaterialisationTriggerRequest { @@ -122,3 +145,29 @@ export function formatTimestamp(isoString: string): string { export function generateIdempotencyKey(): string { return crypto.randomUUID(); } + +/** + * Calculate elapsed time in seconds since job started + */ +export function calculateElapsedTime(startTime: string): number { + const now = new Date(); + const start = new Date(startTime); + const diffMs = now.getTime() - start.getTime(); + return Math.floor(diffMs / 1000); +} + +/** + * Format elapsed time for display (e.g., "2m 34s", "45s") + */ +export function formatElapsedTime(seconds: number): string { + if (seconds <= 0) return '0s'; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + + return `${seconds}s`; +}