diff --git a/apps/api/index.ts b/apps/api/index.ts index 200b88b..8c66495 100644 --- a/apps/api/index.ts +++ b/apps/api/index.ts @@ -47,8 +47,10 @@ app.use(errorHandler); const connectDB = async () => { await mongoose.connect(MONGO_URI); await ensureLocationIndexes(); + await ensureQueueReportIndexes(); logger.info('MongoDB connected'); logger.info('Location 2dsphere index ensured'); + logger.info('Queue report indexes ensured'); }; const server = app.listen(PORT, async () => { diff --git a/apps/api/routes/queues.ts b/apps/api/routes/queues.ts new file mode 100644 index 0000000..d1a8a75 --- /dev/null +++ b/apps/api/routes/queues.ts @@ -0,0 +1,29 @@ +import { Router } from 'express'; +import { ValidationError } from '../errors/AppError'; +import { getQueueHistoryForLocation } from '../services/queueHistory'; + +const router = Router(); + +router.get('/:locationId/history', async (req, res) => { + const locationId = String(req.params.locationId || ''); + if (!/^[a-fA-F0-9]{24}$/.test(locationId)) { + throw new ValidationError('locationId must be a valid location identifier'); + } + + const windowHours = req.query.windowHours === undefined ? undefined : Number(req.query.windowHours); + const limit = req.query.limit === undefined ? undefined : Number(req.query.limit); + + const history = await getQueueHistoryForLocation({ + locationId, + windowHours, + limit, + }); + + res.json({ + success: true, + data: history, + }); +}); + +export const queuesRouter = router; + diff --git a/apps/api/services/queueHistory.ts b/apps/api/services/queueHistory.ts new file mode 100644 index 0000000..b9e8a02 --- /dev/null +++ b/apps/api/services/queueHistory.ts @@ -0,0 +1,97 @@ +import { QueueReport, QueueReportDocument } from '../models/QueueReport'; + +export type QueueTrend = 'up' | 'down' | 'flat' | 'unknown'; + +export type QueueHistoryPoint = { + at: Date; + level: QueueReportDocument['level']; + waitTimeMinutes?: number; +}; + +export type QueueHistoryResult = { + locationId: string; + windowStart: Date; + windowEnd: Date; + points: QueueHistoryPoint[]; + trend: QueueTrend; +}; + +const LEVEL_WEIGHT: Record = { + none: 0, + low: 1, + medium: 2, + high: 3, + unknown: 1.5, +}; + +const computeAverageSignal = (points: QueueHistoryPoint[]) => { + if (points.length === 0) { + return null; + } + + const values = points.map((point) => { + if (typeof point.waitTimeMinutes === 'number' && Number.isFinite(point.waitTimeMinutes)) { + return point.waitTimeMinutes; + } + return LEVEL_WEIGHT[point.level] * 10; + }); + + return values.reduce((sum, value) => sum + value, 0) / values.length; +}; + +const computeTrend = (points: QueueHistoryPoint[]): QueueTrend => { + if (points.length < 2) { + return 'unknown'; + } + + const midpoint = Math.ceil(points.length / 2); + const firstHalf = computeAverageSignal(points.slice(0, midpoint)); + const secondHalf = computeAverageSignal(points.slice(midpoint)); + + if (firstHalf === null || secondHalf === null) { + return 'unknown'; + } + + const delta = secondHalf - firstHalf; + if (Math.abs(delta) < 3) { + return 'flat'; + } + + return delta > 0 ? 'up' : 'down'; +}; + +export const getQueueHistoryForLocation = async (params: { + locationId: string; + windowHours?: number; + limit?: number; +}): Promise => { + const windowHours = Number.isFinite(params.windowHours) ? Math.max(1, Math.floor(params.windowHours || 6)) : 6; + const limit = Number.isFinite(params.limit) ? Math.min(200, Math.max(1, Math.floor(params.limit || 24))) : 24; + + const windowEnd = new Date(); + const windowStart = new Date(windowEnd.getTime() - windowHours * 60 * 60 * 1000); + + const reports = (await QueueReport.find({ + locationId: params.locationId, + status: 'accepted', + reportedAt: { $gte: windowStart, $lte: windowEnd }, + }) + .sort({ reportedAt: 1 }) + .limit(limit) + .lean()) as QueueReportDocument[]; + + const points = reports.map((report) => ({ + at: report.reportedAt, + level: report.level, + waitTimeMinutes: report.waitTimeMinutes, + })); + + return { + locationId: params.locationId, + windowStart, + windowEnd, + points, + trend: computeTrend(points), + }; +}; +