feat(slot-definitions): add real-time job tracking to materialization status panel
continuous-integration/drone/push Build is passing Details

- 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
master
Guillermo Pages 1 month ago
parent 2881721b18
commit 3b7f937505

@ -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<string | null>(null);
const [countdown, setCountdown] = useState(0);
const [selectedPolicy, setSelectedPolicy] = useState<MaterialisationPolicyProfile>('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
</div>
)}
{/* Active Job Status (queued/running) */}
{status.last_job && (status.last_job.status === 'queued' || status.last_job.status === 'running') && (
<div className="space-y-3">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Loader2 className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5 animate-spin" />
<div className="flex-1">
{status.last_job.status === 'queued' && (
<p className="text-sm text-blue-900 font-semibold">
Job queued, waiting to start...
</p>
)}
{status.last_job.status === 'running' && (
<>
<p className="text-sm text-blue-900 font-semibold">
Generating slots... ({formatElapsedTime(elapsedTime)})
</p>
<p className="text-xs text-blue-700 mt-1">
Policy: {status.last_job.policy_profile} · Horizon: {status.last_job.horizon_days} days
</p>
</>
)}
</div>
</div>
</div>
</div>
)}
{/* Completed Job Results */}
{status.last_job && status.last_job.status === 'completed' && status.last_job.result && !hasActiveJob && (
<div className="space-y-3">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-green-900 font-semibold mb-2">
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)`}
</p>
<div className="text-xs text-green-700 space-y-1">
{status.last_job.result.updated > 0 && <div> Updated: {status.last_job.result.updated}</div>}
{status.last_job.result.moved > 0 && <div> Moved: {status.last_job.result.moved}</div>}
{status.last_job.result.skipped > 0 && <div> Skipped: {status.last_job.result.skipped}</div>}
{status.last_job.completed_at && (
<div className="mt-2 text-slate-600">Completed: {formatTimestamp(status.last_job.completed_at)}</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Failed Job Status */}
{status.last_job && status.last_job.status === 'failed' && !hasActiveJob && (
<div className="space-y-3">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-900 font-semibold mb-1">
Job failed
</p>
{status.last_job.error && (
<p className="text-sm text-red-700">{status.last_job.error}</p>
)}
{status.last_job.failed_at && (
<div className="text-xs text-slate-600 mt-2">Failed: {formatTimestamp(status.last_job.failed_at)}</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Rate limited state */}
{isRateLimited && (
{isRateLimited && !hasActiveJob && (
<div className="space-y-3">
{status.last_success_at && (
<div className="text-sm text-slate-700">

@ -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`;
}

Loading…
Cancel
Save