From 251b849500c204751cb884bece546e185a1795f6 Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Fri, 7 Nov 2025 10:45:13 +0100 Subject: [PATCH] feat(admin): add materialisation trigger/status UI + court management infrastructure Phase 1 continuation + Phase 2 preparation per Chief Cole request (Chief_Cole-20251107102650) Materialisation UI (Phase 1 continuation): - Created MaterialisationStatusPanel component with 5 states (never run, success, running, failed, rate limited) - Polling strategy: 3-second intervals while job running - Rate-limit countdown timer with live updates - Idempotency key generation (UUID v4) for manual triggers - Mock data for development with state cycling - Integrated into slot definitions page between header and table - TypeScript types: MaterialisationStatus, MaterialisationTriggerRequest/Response, helpers - API client with mock implementation (USE_MOCKS flag) - Professional slate theme consistent with existing UI Court Management Infrastructure (Phase 2 preparation): - Created TypeScript types for courts and club profile - Court types: Court, CourtRequest, CourtDependencies - Profile types: ClubProfile, ClubProfileUpdateRequest - Common timezones list (40+ IANA zones) - Validation helpers (email, URL) - API client with full CRUD mocks: - getClubProfile, updateClubProfile - getCourts, createCourt, updateCourt, deleteCourt - getCourtDependencies (cascade blocking) - Mock data: 3 courts, full profile, dependency simulation - In-memory mock state management Implementation ready for: - Next: Club detail tab navigation (Profile/Courts/Slot Definitions) - Next: Profile tab with edit form - Next: Courts tab with add/edit/delete modals Build tested and passed (npm run build successful) --- .../MaterialisationStatusPanel.tsx | 292 +++++++++ .../SlotDefinitionsComponent.tsx | 4 + src/lib/api/courts.ts | 567 ++++++++++++++++++ src/lib/api/materialisation.ts | 266 ++++++++ src/types/courts.ts | 151 +++++ src/types/materialisation.ts | 96 +++ 6 files changed, 1376 insertions(+) create mode 100644 src/app/[locale]/admin/clubs/[club_id]/slot-definitions/MaterialisationStatusPanel.tsx create mode 100644 src/lib/api/courts.ts create mode 100644 src/lib/api/materialisation.ts create mode 100644 src/types/courts.ts create mode 100644 src/types/materialisation.ts 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 new file mode 100644 index 0000000..d2a6b96 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/MaterialisationStatusPanel.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Calendar, Loader2, AlertCircle, CheckCircle, Clock, AlertTriangle } from 'lucide-react'; +import { getMaterialisationStatus, triggerMaterialisation } from '@/src/lib/api/materialisation'; +import type { MaterialisationStatus } from '@/src/types/materialisation'; +import { + generateIdempotencyKey, + calculateRemainingCooldown, + formatCountdown, + formatTimestamp, +} from '@/src/types/materialisation'; + +interface MaterialisationStatusPanelProps { + clubId: number; +} + +export default function MaterialisationStatusPanel({ clubId }: MaterialisationStatusPanelProps) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [triggering, setTriggering] = useState(false); + const [error, setError] = useState(null); + const [countdown, setCountdown] = useState(0); + + // Poll status on mount and when triggering completes + useEffect(() => { + loadStatus(); + }, [clubId]); + + // Poll status every 3 seconds while job is running + useEffect(() => { + if (!status || status.status !== 'running') return; + + const interval = setInterval(() => { + loadStatus(); + }, 3000); + + return () => clearInterval(interval); + }, [status?.status]); + + // Update countdown every second when rate limited + useEffect(() => { + if (!status?.rate_limit.next_available_at) { + setCountdown(0); + return; + } + + const remaining = calculateRemainingCooldown(status.rate_limit.next_available_at); + setCountdown(remaining); + + if (remaining <= 0) return; + + const interval = setInterval(() => { + const newRemaining = calculateRemainingCooldown(status.rate_limit.next_available_at!); + setCountdown(newRemaining); + + if (newRemaining <= 0) { + clearInterval(interval); + // Reload status to update rate limit state + loadStatus(); + } + }, 1000); + + return () => clearInterval(interval); + }, [status?.rate_limit.next_available_at]); + + async function loadStatus() { + const result = await getMaterialisationStatus(clubId); + + if (result.success) { + setStatus(result.data); + setError(null); + } else { + setError(result.error.detail); + } + + setLoading(false); + } + + async function handleTrigger() { + if (!status || triggering) return; + + setTriggering(true); + setError(null); + + const idempotencyKey = generateIdempotencyKey(); + + const result = await triggerMaterialisation(clubId, { idempotency_key: idempotencyKey }); + + if (result.success) { + // Start polling immediately + loadStatus(); + } else { + if (result.error.status === 429) { + // Rate limit error - reload status to show countdown + loadStatus(); + } else { + setError(result.error.detail); + } + } + + setTriggering(false); + } + + // Loading state + if (loading) { + return ( +
+
+ + Loading materialisation status... +
+
+ ); + } + + // Error state + if (error && !status) { + return ( +
+
+ +
+

+ Failed to load status +

+

{error}

+
+
+
+ ); + } + + if (!status) return null; + + // Determine panel state + const isRateLimited = !status.rate_limit.can_trigger && countdown > 0; + const canTrigger = status.rate_limit.can_trigger && status.status !== 'running' && !triggering; + + // Render status panel + return ( +
+ {/* Header */} +
+
+ +

Slot Materialisation

+
+ + {/* Status badge */} + {status.status === 'completed' && !isRateLimited && ( +
+ + Up to date +
+ )} + + {status.status === 'running' && ( +
+ + Processing... +
+ )} + + {status.status === 'failed' && ( +
+ + Failed +
+ )} + + {isRateLimited && ( +
+ + Rate limit +
+ )} + + {status.status === 'idle' && ( +
+ + Not yet run +
+ )} +
+ + {/* Never run state */} + {status.status === 'idle' && !status.last_run_at && ( +
+

+ Generate slot instances from your definitions to make them available for booking. +

+
+ )} + + {/* Success state */} + {status.status === 'completed' && status.last_success_at && !isRateLimited && ( +
+
+ Last generated:{' '} + {formatTimestamp(status.last_success_at)} +
+ {status.slots_generated !== null && ( +
+ Status:{' '} + {status.slots_generated} slots generated successfully +
+ )} +
+ )} + + {/* Running state */} + {status.status === 'running' && status.last_run_at && ( +
+
+ Started:{' '} + {formatTimestamp(status.last_run_at)} +
+
+ Status: Generating slots from definitions... +
+
+ )} + + {/* Failed state */} + {status.status === 'failed' && ( +
+ {status.last_run_at && ( +
+ Last attempt:{' '} + {formatTimestamp(status.last_run_at)} +
+ )} + {status.last_error && ( +
+ Error: {status.last_error} +
+ )} +
+ )} + + {/* Rate limited state */} + {isRateLimited && ( +
+ {status.last_success_at && ( +
+ Last generated:{' '} + {formatTimestamp(status.last_success_at)} +
+ )} + +
+
+ +
+

+ Job triggered too recently. Please wait 5 minutes between manual regenerations. +

+

+ Next available in: {formatCountdown(countdown)} +

+
+
+
+
+ )} + + {/* Error message */} + {error && ( +
+
+ +

{error}

+
+
+ )} + + {/* Trigger button */} +
+ +
+
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx index a36e38d..5ead518 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx @@ -8,6 +8,7 @@ import { getSlotDefinitions, getMockSlotDefinitions, deleteSlotDefinition } from import type { SlotDefinition, SlotDefinitionError } from '@/src/types/slot-definitions'; import { DAY_NAMES, formatTime, calculateEndTime } from '@/src/types/slot-definitions'; import SlotDefinitionForm from './SlotDefinitionForm'; +import MaterialisationStatusPanel from './MaterialisationStatusPanel'; interface SlotDefinitionsComponentProps { clubId: number; @@ -167,6 +168,9 @@ export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComp + {/* Materialisation Status Panel */} + + {/* Empty state */} {definitions.length === 0 ? (
diff --git a/src/lib/api/courts.ts b/src/lib/api/courts.ts new file mode 100644 index 0000000..399bfe7 --- /dev/null +++ b/src/lib/api/courts.ts @@ -0,0 +1,567 @@ +/** + * Court Management API Client + * + * Handles club profile and court inventory CRUD operations. + * Uses mock data until backend endpoints are ready. + */ + +import type { + Court, + CourtRequest, + CourtDependencies, + ClubProfile, + ClubProfileUpdateRequest, + CourtError, +} from '@/src/types/courts'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://staging.api.playchoo.com'; + +type ApiResult = + | { success: true; data: T } + | { success: false; error: CourtError }; + +/** + * Get club profile + */ +export async function getClubProfile(clubId: number): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 300)); + + const mockProfile = getMockClubProfile(clubId); + return { success: true, data: mockProfile }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: ClubProfile = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to fetch club profile. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Update club profile + */ +export async function updateClubProfile( + clubId: number, + request: ClubProfileUpdateRequest +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + + // Simple validation + if (!request.name || request.name.trim().length === 0) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Validation Error', + status: 400, + detail: 'One or more fields failed validation', + code: 'validation_error', + errors: [ + { + field: 'name', + message: 'Name is required', + }, + ], + }, + }; + } + + const mockProfile: ClubProfile = { + ...getMockClubProfile(clubId), + ...request, + updated_at: new Date().toISOString(), + }; + + return { success: true, data: mockProfile }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}`, { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: ClubProfile = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to update club profile. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Get courts for a club + */ +export async function getCourts(clubId: number): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 300)); + + const mockCourts = getMockCourts(clubId); + return { success: true, data: mockCourts }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: Court[] = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to fetch courts. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Create a new court + */ +export async function createCourt( + clubId: number, + request: CourtRequest +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + + // Simple validation + if (!request.name || request.name.trim().length === 0) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Validation Error', + status: 400, + detail: 'Court name is required', + code: 'validation_error', + errors: [ + { + field: 'name', + message: 'Court name is required', + }, + ], + }, + }; + } + + // Check for duplicate name (in mock data) + const existingCourts = getMockCourts(clubId); + if (existingCourts.some(c => c.name.toLowerCase() === request.name.trim().toLowerCase())) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Duplicate Court Name', + status: 409, + detail: `A court with the name '${request.name}' already exists for this club`, + code: 'duplicate_court_name', + }, + }; + } + + const newCourt: Court = { + court_id: Math.max(...existingCourts.map(c => c.court_id), 100) + 1, + name: request.name.trim(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + // Add to mock data (in-memory only) + mockCourtsData.push(newCourt); + + return { success: true, data: newCourt }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: Court = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to create court. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Update an existing court + */ +export async function updateCourt( + clubId: number, + courtId: number, + request: CourtRequest +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + + // Simple validation + if (!request.name || request.name.trim().length === 0) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Validation Error', + status: 400, + detail: 'Court name is required', + code: 'validation_error', + errors: [ + { + field: 'name', + message: 'Court name is required', + }, + ], + }, + }; + } + + // Check for duplicate name (excluding current court) + const existingCourts = getMockCourts(clubId); + if (existingCourts.some(c => c.court_id !== courtId && c.name.toLowerCase() === request.name.trim().toLowerCase())) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Duplicate Court Name', + status: 409, + detail: `A court with the name '${request.name}' already exists for this club`, + code: 'duplicate_court_name', + }, + }; + } + + const court = existingCourts.find(c => c.court_id === courtId); + if (!court) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Court Not Found', + status: 404, + detail: 'Court not found', + code: 'court_not_found', + }, + }; + } + + const updatedCourt: Court = { + ...court, + name: request.name.trim(), + updated_at: new Date().toISOString(), + }; + + // Update in mock data (in-memory only) + const index = mockCourtsData.findIndex(c => c.court_id === courtId); + if (index !== -1) { + mockCourtsData[index] = updatedCourt; + } + + return { success: true, data: updatedCourt }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}`, { + method: 'PATCH', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: Court = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to update court. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Get court dependencies (before deletion) + */ +export async function getCourtDependencies( + clubId: number, + courtId: number +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 300)); + + // Mock dependencies - first court has dependencies, others don't + const mockDeps: CourtDependencies = courtId === 101 ? { + can_delete: false, + dependencies: { + slot_definitions: 12, + upcoming_bookings: 45, + }, + } : { + can_delete: true, + dependencies: { + slot_definitions: 0, + upcoming_bookings: 0, + }, + }; + + return { success: true, data: mockDeps }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}/dependencies`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: CourtDependencies = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to check court dependencies. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Delete a court + */ +export async function deleteCourt( + clubId: number, + courtId: number +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check dependencies first + const depsResult = await getCourtDependencies(clubId, courtId); + if (depsResult.success && !depsResult.data.can_delete) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Court Has Dependencies', + status: 409, + detail: 'Cannot delete court because it is referenced by slot definitions or upcoming bookings', + code: 'court_has_dependencies', + dependencies: depsResult.data.dependencies, + }, + }; + } + + // Remove from mock data (in-memory only) + const index = mockCourtsData.findIndex(c => c.court_id === courtId); + if (index !== -1) { + mockCourtsData.splice(index, 1); + } + + return { success: true, data: undefined }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}`, { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + return { success: true, data: undefined }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to delete court. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Mock data for development + */ + +let mockCourtsData: Court[] = [ + { + court_id: 101, + name: 'Court 1', + created_at: '2024-06-15T10:30:00Z', + updated_at: '2024-06-15T10:30:00Z', + }, + { + court_id: 102, + name: 'Court 2', + created_at: '2024-06-15T10:30:00Z', + updated_at: '2024-06-15T10:30:00Z', + }, + { + court_id: 103, + name: 'Court 3', + created_at: '2024-06-15T10:30:00Z', + updated_at: '2024-06-15T10:30:00Z', + }, +]; + +export function getMockCourts(clubId: number): Court[] { + return [...mockCourtsData]; +} + +export function getMockClubProfile(clubId: number): ClubProfile { + return { + club_id: clubId, + name: 'Central Padel', + timezone: 'Europe/London', + address_line_1: '123 High Street', + address_line_2: 'Building A', + city: 'London', + postal_code: 'SW1A 1AA', + country: 'United Kingdom', + phone: '+44 20 1234 5678', + email: 'info@centralpadel.com', + website: 'https://www.centralpadel.com', + provider: 'local', + remote_club_id: null, + created_at: '2024-06-15T10:30:00Z', + updated_at: '2025-11-05T14:23:00Z', + }; +} diff --git a/src/lib/api/materialisation.ts b/src/lib/api/materialisation.ts new file mode 100644 index 0000000..74e7fea --- /dev/null +++ b/src/lib/api/materialisation.ts @@ -0,0 +1,266 @@ +/** + * Materialisation API Client + * + * Handles slot materialisation status polling and manual trigger requests. + * Uses mock data until backend endpoints are ready. + */ + +import type { + MaterialisationStatus, + MaterialisationTriggerRequest, + MaterialisationTriggerResponse, + MaterialisationError, +} from '@/src/types/materialisation'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://staging.api.playchoo.com'; + +type ApiResult = + | { success: true; data: T } + | { success: false; error: MaterialisationError }; + +/** + * Get materialisation status for a club + */ +export async function getMaterialisationStatus( + clubId: number +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 300)); + + const mockStatus = getMockMaterialisationStatus(); + return { success: true, data: mockStatus }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/materialisation-status`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: MaterialisationError = await response.json(); + return { success: false, error }; + } + + const data: MaterialisationStatus = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to fetch materialisation status. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Trigger materialisation job manually + */ +export async function triggerMaterialisation( + clubId: number, + request: MaterialisationTriggerRequest +): Promise> { + // Use mock data for now (until backend is ready) + const USE_MOCKS = true; + + if (USE_MOCKS) { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + + const mockResponse = getMockTriggerResponse(); + return { success: true, data: mockResponse }; + } + + try { + const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/materialisation-trigger`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error: MaterialisationError = await response.json(); + return { success: false, error }; + } + + const data: MaterialisationTriggerResponse = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to trigger materialisation. Please check your connection.', + code: 'network_error', + }, + }; + } +} + +/** + * Mock data for development + */ + +// Mock state to simulate different scenarios +let mockState: 'never_run' | 'success' | 'running' | 'failed' | 'rate_limited' = 'success'; +let mockTriggerCount = 0; + +export function getMockMaterialisationStatus(): MaterialisationStatus { + const now = new Date(); + const lastRun = new Date(now.getTime() - 15 * 60 * 1000); // 15 minutes ago + const nextAvailable = new Date(now.getTime() + 3 * 60 * 1000); // 3 minutes from now + + // Cycle through states for demo purposes + if (mockState === 'never_run') { + return { + status: 'idle', + last_run_at: null, + last_success_at: null, + last_error: null, + slots_generated: null, + rate_limit: { + can_trigger: true, + next_available_at: null, + cooldown_seconds: 300, + }, + }; + } + + if (mockState === 'success') { + return { + status: 'completed', + last_run_at: lastRun.toISOString(), + last_success_at: lastRun.toISOString(), + last_error: null, + slots_generated: 234, + rate_limit: { + can_trigger: true, + next_available_at: null, + cooldown_seconds: 300, + }, + }; + } + + if (mockState === 'running') { + return { + status: 'running', + last_run_at: new Date(now.getTime() - 30 * 1000).toISOString(), // 30 seconds ago + last_success_at: lastRun.toISOString(), + last_error: null, + slots_generated: null, + rate_limit: { + can_trigger: false, + next_available_at: null, + cooldown_seconds: 300, + }, + }; + } + + if (mockState === 'failed') { + return { + status: 'failed', + last_run_at: new Date(now.getTime() - 5 * 60 * 1000).toISOString(), // 5 minutes ago + last_success_at: lastRun.toISOString(), + last_error: 'Database connection timeout', + slots_generated: null, + rate_limit: { + can_trigger: true, + next_available_at: null, + cooldown_seconds: 300, + }, + }; + } + + if (mockState === 'rate_limited') { + return { + status: 'completed', + last_run_at: new Date(now.getTime() - 2 * 60 * 1000).toISOString(), // 2 minutes ago + last_success_at: new Date(now.getTime() - 2 * 60 * 1000).toISOString(), + last_error: null, + slots_generated: 234, + rate_limit: { + can_trigger: false, + next_available_at: nextAvailable.toISOString(), + cooldown_seconds: 300, + }, + }; + } + + // Default to success + return { + status: 'completed', + last_run_at: lastRun.toISOString(), + last_success_at: lastRun.toISOString(), + last_error: null, + slots_generated: 234, + rate_limit: { + can_trigger: true, + next_available_at: null, + cooldown_seconds: 300, + }, + }; +} + +export function getMockTriggerResponse(): MaterialisationTriggerResponse { + mockTriggerCount++; + + // First trigger: accept and start running + if (mockTriggerCount === 1) { + mockState = 'running'; + return { + status: 'accepted', + job_id: 'job-' + crypto.randomUUID(), + message: 'Materialisation job queued successfully', + }; + } + + // Second trigger: simulate rate limit (should not happen with proper UI, but handle it) + if (mockTriggerCount === 2) { + mockState = 'rate_limited'; + throw { + success: false, + error: { + type: 'about:blank', + title: 'Rate Limit Exceeded', + status: 429, + detail: 'Materialisation can only be triggered once every 5 minutes. Please wait before retrying.', + code: 'rate_limit_exceeded', + retry_after: 180, + next_available_at: new Date(Date.now() + 3 * 60 * 1000).toISOString(), + }, + }; + } + + // Subsequent triggers: accept + mockState = 'running'; + return { + status: 'accepted', + job_id: 'job-' + crypto.randomUUID(), + message: 'Materialisation job queued successfully', + }; +} + +/** + * Helper to set mock state for testing different scenarios + * (Only used in development/testing) + */ +export function setMockState(state: typeof mockState) { + mockState = state; +} diff --git a/src/types/courts.ts b/src/types/courts.ts new file mode 100644 index 0000000..6190936 --- /dev/null +++ b/src/types/courts.ts @@ -0,0 +1,151 @@ +/** + * Court Management Types + * + * Types for club profile and court inventory CRUD operations. + */ + +export interface Court { + court_id: number; + name: string; + created_at: string; // ISO 8601 timestamp + updated_at: string; // ISO 8601 timestamp +} + +export interface CourtRequest { + name: string; +} + +export interface CourtDependencies { + can_delete: boolean; + dependencies: { + slot_definitions: number; + upcoming_bookings: number; + }; +} + +export interface ClubProfile { + club_id: number; + name: string; + timezone: string; // IANA timezone + address_line_1?: string | null; + address_line_2?: string | null; + city?: string | null; + postal_code?: string | null; + country?: string | null; + phone?: string | null; + email?: string | null; + website?: string | null; + provider: 'local' | 'fairplay' | 'other'; + remote_club_id?: string | null; + created_at: string; // ISO 8601 timestamp + updated_at: string; // ISO 8601 timestamp +} + +export interface ClubProfileUpdateRequest { + name: string; + timezone: string; + address_line_1?: string; + address_line_2?: string; + city?: string; + postal_code?: string; + country?: string; + phone?: string; + email?: string; + website?: string; +} + +export interface CourtError { + type: string; + title: string; + status: number; + detail: string; + code: string; + errors?: ValidationError[]; + dependencies?: { + slot_definitions: number; + upcoming_bookings: number; + }; +} + +export interface ValidationError { + field: string; + message: string; +} + +/** + * IANA Timezone list (common timezones) + * Full list can be expanded as needed + */ +export const COMMON_TIMEZONES = [ + { value: 'Europe/London', label: 'London (GMT/BST)' }, + { value: 'Europe/Paris', label: 'Paris (CET/CEST)' }, + { value: 'Europe/Berlin', label: 'Berlin (CET/CEST)' }, + { value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' }, + { value: 'Europe/Rome', label: 'Rome (CET/CEST)' }, + { value: 'Europe/Amsterdam', label: 'Amsterdam (CET/CEST)' }, + { value: 'Europe/Brussels', label: 'Brussels (CET/CEST)' }, + { value: 'Europe/Vienna', label: 'Vienna (CET/CEST)' }, + { value: 'Europe/Zurich', label: 'Zurich (CET/CEST)' }, + { value: 'Europe/Stockholm', label: 'Stockholm (CET/CEST)' }, + { value: 'Europe/Copenhagen', label: 'Copenhagen (CET/CEST)' }, + { value: 'Europe/Oslo', label: 'Oslo (CET/CEST)' }, + { value: 'Europe/Helsinki', label: 'Helsinki (EET/EEST)' }, + { value: 'Europe/Athens', label: 'Athens (EET/EEST)' }, + { value: 'Europe/Istanbul', label: 'Istanbul (TRT)' }, + { value: 'Europe/Moscow', label: 'Moscow (MSK)' }, + { value: 'America/New_York', label: 'New York (EST/EDT)' }, + { value: 'America/Chicago', label: 'Chicago (CST/CDT)' }, + { value: 'America/Denver', label: 'Denver (MST/MDT)' }, + { value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)' }, + { value: 'America/Toronto', label: 'Toronto (EST/EDT)' }, + { value: 'America/Vancouver', label: 'Vancouver (PST/PDT)' }, + { value: 'America/Mexico_City', label: 'Mexico City (CST/CDT)' }, + { value: 'America/Sao_Paulo', label: 'São Paulo (BRT/BRST)' }, + { value: 'America/Buenos_Aires', label: 'Buenos Aires (ART)' }, + { value: 'Asia/Dubai', label: 'Dubai (GST)' }, + { value: 'Asia/Kolkata', label: 'Kolkata (IST)' }, + { value: 'Asia/Bangkok', label: 'Bangkok (ICT)' }, + { value: 'Asia/Singapore', label: 'Singapore (SGT)' }, + { value: 'Asia/Hong_Kong', label: 'Hong Kong (HKT)' }, + { value: 'Asia/Shanghai', label: 'Shanghai (CST)' }, + { value: 'Asia/Tokyo', label: 'Tokyo (JST)' }, + { value: 'Asia/Seoul', label: 'Seoul (KST)' }, + { value: 'Australia/Sydney', label: 'Sydney (AEDT/AEST)' }, + { value: 'Australia/Melbourne', label: 'Melbourne (AEDT/AEST)' }, + { value: 'Australia/Brisbane', label: 'Brisbane (AEST)' }, + { value: 'Pacific/Auckland', label: 'Auckland (NZDT/NZST)' }, +]; + +/** + * Helper to validate email format + */ +export function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +/** + * Helper to validate URL format + */ +export function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +/** + * Helper to format timestamp for display + */ +export function formatTimestamp(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/types/materialisation.ts b/src/types/materialisation.ts new file mode 100644 index 0000000..e8a7c81 --- /dev/null +++ b/src/types/materialisation.ts @@ -0,0 +1,96 @@ +/** + * Materialisation Status Types + * + * Types for slot materialisation job status and manual trigger endpoints. + * Supports status polling, rate limiting, and idempotency. + */ + +export type MaterialisationJobStatus = 'idle' | 'running' | 'completed' | 'failed'; + +export interface MaterialisationRateLimit { + can_trigger: boolean; + next_available_at: string | null; // ISO 8601 timestamp + cooldown_seconds: number; +} + +export interface MaterialisationStatus { + status: MaterialisationJobStatus; + last_run_at: string | null; // ISO 8601 timestamp + last_success_at: string | null; // ISO 8601 timestamp + last_error: string | null; + slots_generated: number | null; + rate_limit: MaterialisationRateLimit; +} + +export interface MaterialisationTriggerRequest { + idempotency_key: string; // UUID v4 +} + +export interface MaterialisationTriggerResponse { + status: 'accepted' | 'running' | 'completed' | 'failed'; + job_id: string; + message: string; + duplicate?: boolean; +} + +export interface MaterialisationError { + type: string; + title: string; + status: number; + detail: string; + code: string; + retry_after?: number; // seconds (for 429 responses) + next_available_at?: string; // ISO 8601 timestamp (for 429 responses) +} + +/** + * Helper to calculate remaining cooldown seconds + */ +export function calculateRemainingCooldown(nextAvailableAt: string | null): number { + if (!nextAvailableAt) return 0; + + const now = new Date(); + const next = new Date(nextAvailableAt); + const diffMs = next.getTime() - now.getTime(); + + return Math.max(0, Math.ceil(diffMs / 1000)); +} + +/** + * Helper to format countdown display (e.g., "2m 34s", "45s") + */ +export function formatCountdown(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`; +} + +/** + * Helper to format timestamp for display + */ +export function formatTimestamp(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +/** + * Generate UUID v4 for idempotency keys + */ +export function generateIdempotencyKey(): string { + return crypto.randomUUID(); +}