'use strict'; const debug = require('debug')('agm:dashboard'); const ObjectId = require('mongodb').ObjectId; const { Job, App, AppFile, AppDetail, Setting } = require('../model'); const { JobStatus } = require('../helpers/job_constants'); const { Errors } = require('../helpers/constants'); const { AppAuthError, AppParamError, AppError } = require('../helpers/app_error'); // ─── Constants ──────────────────────────────────────────────────────────────── /** Job statuses shown in the Active Jobs panel. INVOICED (5) and ARCHIVED (9) are excluded. */ const ACTIVE_JOB_STATUSES = [ JobStatus.NEW, JobStatus.READY, JobStatus.DOWNLOADED, JobStatus.SPRAYED, JobStatus.COMPLETED ]; /** XT cross-track error thresholds (meters). */ const XT_GOOD = 1.0; const XT_MONITOR = 3.0; /** Altitude thresholds (meters). Target ~3.7 m. */ const ALT_TARGET = 3.7; const ALT_GOOD = 0.15; // ±0.15 m of target const ALT_MONITOR = 0.46; // ±0.46 m of target // ─── Timezone helpers ───────────────────────────────────────────────────────── /** * Validate an IANA timezone string. Returns 'UTC' if invalid or missing. */ function validateTz(tz) { if (!tz || typeof tz !== 'string') return 'UTC'; try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return tz; } catch { return 'UTC'; } } const APP_TIME_FIELD = 'startDateTimeUTC'; function appTimeMatch(startUTC, endExcl) { return { [APP_TIME_FIELD]: { $gte: startUTC, $lt: endExcl } }; } /** * Return a 'YYYY-MM-DD' date label for a Date in the given timezone. * Uses 'en-CA' locale which formats as YYYY-MM-DD natively. */ function toDateLabel(date, tz) { return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); } /** * Return the UTC Date corresponding to midnight 00:00:00 of dateLabel in tz. * * Strategy: format noon-UTC as local time in tz (via Intl), parse that back as * UTC to measure the timezone offset, then apply that offset to naive midnight. * This handles DST correctly because noon on most days is not a DST boundary. */ function midnightUTC(dateLabel, tz) { const noonUTC = new Date(`${dateLabel}T12:00:00.000Z`); const localStr = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(noonUTC); // en-CA produces "YYYY-MM-DD, HH:mm:ss" const [datePart, timePart] = localStr.split(', '); const localNoon = new Date(`${datePart}T${timePart}Z`); // parse as UTC to get naive value const offsetMs = noonUTC.getTime() - localNoon.getTime(); const naiveUTC = new Date(`${dateLabel}T00:00:00.000Z`); return new Date(naiveUTC.getTime() + offsetMs); } /** { start, end } UTC range for a day that is daysAgo before today in tz (0 = today). */ function dayWindow(daysAgo, tz) { const now = new Date(); const todayLabel = toDateLabel(now, tz); const pivot = new Date(`${todayLabel}T12:00:00.000Z`); pivot.setUTCDate(pivot.getUTCDate() - daysAgo); const targetLabel = toDateLabel(pivot, tz); const start = midnightUTC(targetLabel, tz); const end = new Date(start.getTime() + 86400000); return { start, end }; } /** { start, end } UTC range for the current Mon–Sun calendar week in tz. */ function weekWindow(tz) { const now = new Date(); const todayLabel = toDateLabel(now, tz); const pivot = new Date(`${todayLabel}T12:00:00.000Z`); const sinceMonday = (pivot.getUTCDay() + 6) % 7; // Mon=0 … Sun=6 const monPivot = new Date(pivot); monPivot.setUTCDate(monPivot.getUTCDate() - sinceMonday); const sunPivot = new Date(monPivot); sunPivot.setUTCDate(sunPivot.getUTCDate() + 6); const start = midnightUTC(toDateLabel(monPivot, tz), tz); const end = new Date(midnightUTC(toDateLabel(sunPivot, tz), tz).getTime() + 86400000); return { start, end }; } /** { start, end } UTC range for the current calendar month in tz. */ function monthWindow(tz) { const now = new Date(); const [year, month] = toDateLabel(now, tz).split('-'); const start = midnightUTC(`${year}-${month}-01`, tz); const end = new Date(start); end.setUTCMonth(end.getUTCMonth() + 1); return { start, end }; } /** { start, end } UTC range for the current calendar year in tz. */ function yearWindow(tz) { const now = new Date(); const [year] = toDateLabel(now, tz).split('-'); const start = midnightUTC(`${year}-01-01`, tz); const end = new Date(start); end.setUTCFullYear(end.getUTCFullYear() + 1); return { start, end }; } // ─── Shared helpers ─────────────────────────────────────────────────────────── function round2(n) { return Math.round((n || 0) * 100) / 100; } function safePct(current, previous) { if (!previous) return null; return Math.round(((current - previous) / previous) * 100); } /** Build Application base match for a set of jobIds (processed apps only). */ function appMatch(jobIds) { return { jobId: { $in: jobIds }, status: 3, markedDelete: { $ne: true } }; } /** Fetch all pilot job documents (light projection). */ async function fetchPilotJobs(pilotId) { return Job.find( { operator: ObjectId(pilotId), markedDelete: { $ne: true } }, { _id: 1, ttSprArea: 1, status: 1, byPuid: 1, name: 1, client: 1, vehicle: 1, endDate: 1, createdAt: 1 } ).lean(); } // ─── Internal computation helpers ───────────────────────────────────────────── /** * Parse and validate startDate/endDate from query params. * Defaults to the current Mon–Sun calendar week when either is absent. * Throws AppParamError if the format is invalid or the range exceeds 90 days. * @returns {{ startDate: string, endDate: string, startUTC: Date, endExcl: Date }} */ function parseDateRange(query, tz) { let startDate = query.startDate; let endDate = query.endDate; if (!startDate || !endDate) { const ww = weekWindow(tz); startDate = toDateLabel(ww.start, tz); // ww.end is exclusive (start of next Mon), so subtract 1 ms to get Sun endDate = toDateLabel(new Date(ww.end.getTime() - 1), tz); } const dateRe = /^\d{4}-\d{2}-\d{2}$/; if (!dateRe.test(startDate) || !dateRe.test(endDate)) AppParamError.throw(); // Count inclusive calendar days from the date strings directly — DST-immune and exact. // Using UTC midnight avoids the ±1-hour DST ambiguity that affects midnightUTC(). const startDay = new Date(`${startDate}T00:00:00Z`); const endDay = new Date(`${endDate}T00:00:00Z`); if (isNaN(startDay) || isNaN(endDay)) AppParamError.throw(); const diffDays = (endDay - startDay) / 86400000 + 1; if (diffDays < 1 || diffDays > 90) AppParamError.throw(); const startUTC = midnightUTC(startDate, tz); const endExcl = new Date(midnightUTC(endDate, tz).getTime() + 86400000); return { startDate, endDate, startUTC, endExcl }; } /** Compute KPI card data from pre-fetched jobs and the Application base match. */ async function computeKpi(jobs, base, tz) { const dayW = dayWindow(0, tz); const weekW = weekWindow(tz); const monthW = monthWindow(tz); const yearW = yearWindow(tz); const openStatuses = new Set([JobStatus.NEW, JobStatus.READY, JobStatus.DOWNLOADED, JobStatus.SPRAYED, JobStatus.COMPLETED]); function jobMetrics(windowStart, windowEnd) { const subset = windowStart ? jobs.filter(j => j.createdAt >= windowStart && j.createdAt < windowEnd) : jobs; return { assignedJobs: subset.filter(j => openStatuses.has(j.status)).length, assignedHectares: round2(subset.reduce((s, j) => s + (j.ttSprArea || 0), 0)) }; } function jobCounts(windowStart, windowEnd) { const subset = windowStart ? jobs.filter(j => j.createdAt >= windowStart && j.createdAt < windowEnd) : jobs; return { new: subset.filter(j => j.status === JobStatus.NEW).length, inProgress: subset.filter(j => j.status === JobStatus.READY || j.status === JobStatus.DOWNLOADED || j.status === JobStatus.SPRAYED).length, completed: subset.filter(j => j.status === JobStatus.COMPLETED).length }; } const emptyKpi = { assignedJobs: 0, assignedHectares: 0, sprayedHectares: 0, flightHours: 0, sprayEfficiencyPct: null, ferryTimePct: null, flowAccuracyPct: null, avgHdop: null }; const emptyOps = { missionsFlown: 0, distanceTravelledKm: 0, distanceSprayedKm: 0, sprayEfficiencyPct: null, ferryTimePct: null, flowAccuracyPct: null, avgHdop: null }; if (!jobs.length) { return { operations: emptyOps, periods: { day: { ...emptyKpi, jobCounts: jobCounts(dayW.start, dayW.end) }, week: { ...emptyKpi, jobCounts: jobCounts(weekW.start, weekW.end) }, month: { ...emptyKpi, jobCounts: jobCounts(monthW.start, monthW.end) }, year: { ...emptyKpi, jobCounts: jobCounts(yearW.start, yearW.end) }, all: { ...emptyKpi, jobCounts: jobCounts(null, null) } } }; } // flightTime sourced from totalFlightTime (all flight records, using totalFlightTime validity rules) const GROUP = { _id: null, count: { $sum: 1 }, sprayedHectares: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, sprayTime: { $sum: '$totalSprayTime' }, sprDist: { $sum: '$totalSprLength' }, travelDist: { $sum: '$totalFlightLength' }, volume: { $sum: '$totalSprayMat' }, hdopSum: { $sum: { $cond: [{ $and: [{ $gt: ['$avgHdop', null] }, { $gt: ['$avgHdop', 0] }] }, '$avgHdop', 0] } }, hdopCount: { $sum: { $cond: [{ $and: [{ $gt: ['$avgHdop', null] }, { $gt: ['$avgHdop', 0] }] }, 1, 0] } }, fcAccSum: { $sum: { $cond: [{ $and: [{ $gt: ['$flowAccuracyPct', null] }, { $gt: ['$flowAccuracyPct', 0] }] }, '$flowAccuracyPct', 0] } }, fcAccCnt: { $sum: { $cond: [{ $and: [{ $gt: ['$flowAccuracyPct', null] }, { $gt: ['$flowAccuracyPct', 0] }] }, 1, 0] } }, }; const [dayR, weekR, monthR, yearR, allR] = await Promise.all([ App.aggregate([{ $match: { ...base, ...appTimeMatch(dayW.start, dayW.end) } }, { $group: GROUP }]), App.aggregate([{ $match: { ...base, ...appTimeMatch(weekW.start, weekW.end) } }, { $group: GROUP }]), App.aggregate([{ $match: { ...base, ...appTimeMatch(monthW.start, monthW.end) } }, { $group: GROUP }]), App.aggregate([{ $match: { ...base, ...appTimeMatch(yearW.start, yearW.end) } }, { $group: GROUP }]), App.aggregate([{ $match: base }, { $group: GROUP }]) ]); function buildKpi(r, windowStart, windowEnd) { const d = r[0] || {}; const flightTime = d.flightTime || 0; return { ...jobMetrics(windowStart, windowEnd), sprayedHectares: round2(d.sprayedHectares || 0), flightHours: round2(flightTime / 3600), sprayEfficiencyPct: flightTime > 0 ? round2(((d.sprayTime || 0) / flightTime) * 100) : null, ferryTimePct: flightTime > 0 ? round2(((flightTime - (d.sprayTime || 0)) / flightTime) * 100) : null, flowAccuracyPct: (d.fcAccCnt || 0) > 0 ? round2((d.fcAccSum || 0) / d.fcAccCnt) : null, avgHdop: (d.hdopCount || 0) > 0 ? round2((d.hdopSum || 0) / d.hdopCount) : null }; } const day = dayR[0] || {}; return { operations: { missionsFlown: day.count || 0, distanceTravelledKm: round2((day.travelDist || 0) / 1000), distanceSprayedKm: round2((day.sprDist || 0) / 1000), sprayEfficiencyPct: (day.flightTime || 0) > 0 ? round2(((day.sprayTime || 0) / day.flightTime) * 100) : null, ferryTimePct: (day.flightTime || 0) > 0 ? round2(((day.flightTime - (day.sprayTime || 0)) / day.flightTime) * 100) : null, flowAccuracyPct: (day.fcAccCnt || 0) > 0 ? round2((day.fcAccSum || 0) / day.fcAccCnt) : null, avgHdop: (day.hdopCount || 0) > 0 ? round2((day.hdopSum || 0) / day.hdopCount) : null, }, periods: { day: { ...buildKpi(dayR, dayW.start, dayW.end), jobCounts: jobCounts(dayW.start, dayW.end) }, week: { ...buildKpi(weekR, weekW.start, weekW.end), jobCounts: jobCounts(weekW.start, weekW.end) }, month: { ...buildKpi(monthR, monthW.start, monthW.end), jobCounts: jobCounts(monthW.start, monthW.end) }, year: { ...buildKpi(yearR, yearW.start, yearW.end), jobCounts: jobCounts(yearW.start, yearW.end) }, all: { ...buildKpi(allR, null, null), jobCounts: jobCounts(null, null) } } }; } /** Compute today-vs-yesterday daily summary from Application aggregations. */ async function computeSummary(jobs, base, tz) { const todayW = dayWindow(0, tz); const yesterW = dayWindow(1, tz); const empty = { hectares: 0, flightHours: 0, haPerHour: 0, avgSpeedKmh: 0, sprayVolumeLiters: 0 }; const emptyDeltas = { hectaresPct: null, flightHoursPct: null, haPerHourPct: null, avgSpeedPct: null, sprayVolumePct: null }; if (!jobs.length) return { today: empty, yesterday: empty, deltas: emptyDeltas }; const [todayR, yesterR] = await Promise.all([ App.aggregate([ { $match: { ...base, ...appTimeMatch(todayW.start, todayW.end) } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } } ]), App.aggregate([ { $match: { ...base, ...appTimeMatch(yesterW.start, yesterW.end) } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } } ]) ]); function buildDay(r) { const raw = r[0] || {}; const hours = round2((raw.flightTime || 0) / 3600); const ha = round2(raw.sprayed || 0); return { hectares: ha, flightHours: hours, haPerHour: hours > 0 ? round2(ha / hours) : 0, avgSpeedKmh: round2((raw.avgSpeed || 0) * 3.6), sprayVolumeLiters: round2(raw.volume || 0) }; } const today = buildDay(todayR); const yesterday = buildDay(yesterR); const todayHasData = !!todayR[0]; return { today, yesterday, todayHasData, deltas: !todayHasData ? { hectaresPct: null, flightHoursPct: null, haPerHourPct: null, avgSpeedPct: null, sprayVolumePct: null } : { hectaresPct: safePct(today.hectares, yesterday.hectares), flightHoursPct: safePct(today.flightHours, yesterday.flightHours), haPerHourPct: safePct(today.haPerHour, yesterday.haPerHour), avgSpeedPct: safePct(today.avgSpeedKmh, yesterday.avgSpeedKmh), sprayVolumePct: safePct(today.sprayVolumeLiters, yesterday.sprayVolumeLiters) } }; } /** * Compute trend chart data for a date range. * @param {Object} parsedRange - Result of parseDateRange(). */ async function computeTrend(jobs, base, tz, parsedRange) { const { startDate, endDate, startUTC, endExcl } = parsedRange; // Generate all date labels using UTC noon-pivot to avoid DST edge-cases const labels = []; const pivot = new Date(`${startDate}T12:00:00.000Z`); while (true) { const label = toDateLabel(pivot, tz); labels.push(label); if (label === endDate) break; pivot.setUTCDate(pivot.getUTCDate() + 1); if (labels.length > 91) break; // safety cap } if (!jobs.length) { return { labels, hoursFlown: labels.map(() => 0), hectaresPerDay: labels.map(() => 0) }; } const grouped = await App.aggregate([ { $match: { ...base, ...appTimeMatch(startUTC, endExcl) } }, { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: `$${APP_TIME_FIELD}`, timezone: tz } }, hoursFlown: { $sum: '$totalFlightTime' }, hectaresPerDay: { $sum: '$totalSprayed' } } } ]); const dataMap = {}; for (const r of grouped) { dataMap[r._id] = { hoursFlown: round2(r.hoursFlown / 3600), hectaresPerDay: round2(r.hectaresPerDay) }; } return { labels, hoursFlown: labels.map(l => (dataMap[l] || {}).hoursFlown || 0), hectaresPerDay: labels.map(l => (dataMap[l] || {}).hectaresPerDay || 0) }; } /** * Compute active jobs panel data with client/vehicle name lookups. * @param {string} uid - Pilot user ID. * @param {?{start: Date, end: Date}} windowFilter - Optional time scope for job createdAt and app totals. */ async function computeActiveJobs(uid, windowFilter) { const jobs = await Job.aggregate([ { $match: { operator: ObjectId(uid), markedDelete: { $ne: true }, status: { $in: ACTIVE_JOB_STATUSES }, ...(windowFilter ? { createdAt: { $gte: windowFilter.start, $lt: windowFilter.end } } : {}) } }, { $sort: { createdAt: -1 } }, { $limit: 50 }, { $lookup: { from: 'users', localField: 'client', foreignField: '_id', as: 'clientDoc' } }, { $unwind: { path: '$clientDoc', preserveNullAndEmptyArrays: true } }, { $lookup: { from: 'users', localField: 'vehicle', foreignField: '_id', as: 'vehicleDoc' } }, { $unwind: { path: '$vehicleDoc', preserveNullAndEmptyArrays: true } }, { $lookup: { from: 'applications', let: { jobId: '$_id' }, pipeline: [ { $match: { $expr: { $and: [ { $eq: ['$jobId', '$$jobId'] }, { $eq: ['$status', 3] }, { $ne: ['$markedDelete', true] }, ...(windowFilter ? [{ $gte: [`$${APP_TIME_FIELD}`, windowFilter.start] }, { $lt: [`$${APP_TIME_FIELD}`, windowFilter.end] }] : []) ] } } }, { $group: { _id: null, haSprayed: { $sum: '$totalSprayed' }, volumeApplied: { $sum: '$totalSprayMat' } } } ], as: 'appTotals' } }, { $unwind: { path: '$appTotals', preserveNullAndEmptyArrays: true } }, { $project: { _id: 1, name: 1, status: 1, createdAt: 1, haTotal: '$ttSprArea', clientName: '$clientDoc.name', aircraftReg: { $ifNull: ['$vehicleDoc.tailNumber', '$vehicleDoc.unitId'] }, haSprayed: { $ifNull: ['$appTotals.haSprayed', 0] }, volumeAppliedLiters: { $ifNull: ['$appTotals.volumeApplied', 0] } } } ]); const result = jobs.map(j => { const haSprayed = round2(j.haSprayed); const haTotal = round2(j.haTotal || 0); const rawPct = haTotal > 0 ? Math.min(100, Math.max(0, (haSprayed / haTotal) * 100)) : 0; const progressPct = parseFloat(rawPct.toFixed(2)); return { jobId: j._id, name: j.name || '', clientName: j.clientName || '', aircraftReg: j.aircraftReg || '', status: j.status, displayStatus: toDisplayStatus(j.status), createdDate: j.createdAt || null, haTotal, haSprayed, progressPct, volumeAppliedLiters: round2(j.volumeAppliedLiters) }; }); return { jobs: result }; } /** * Compute performance gauge data for a date range. * @param {string} uid - Pilot user ID. * @param {ObjectId[]} jobIds - Pilot's job IDs. * @param {string} tz - Validated timezone. * @param {Object} parsedRange - Result of parseDateRange(). */ async function computePerformance(uid, jobIds, tz, parsedRange) { const { startDate, endDate, startUTC, endExcl } = parsedRange; const settingDoc = await Setting.findOne({ userId: ObjectId(uid) }, 'dashboard').lean(); const ds = settingDoc && settingDoc.dashboard; const xtGood = (ds && ds.xtGood != null) ? ds.xtGood : XT_GOOD; const xtMonitor = (ds && ds.xtMonitor != null) ? ds.xtMonitor : XT_MONITOR; const altTarget = (ds && ds.altTarget != null) ? ds.altTarget : ALT_TARGET; const altGoodBand = (ds && ds.altGoodBand != null) ? ds.altGoodBand : ALT_GOOD; const altMonitorBand = (ds && ds.altMonitorBand != null) ? ds.altMonitorBand : ALT_MONITOR; const noData = { startDate, endDate, avgXtError: null, xtThreshold: { good: xtGood, monitor: xtMonitor }, hasXtData: false, avgSprayAltitudeMeters: null, altitudeSource: null, altThreshold: { target: altTarget, goodBand: altGoodBand, monitorBand: altMonitorBand }, hasAltitudeData: false, sampleSize: 0 }; if (!jobIds.length) return noData; const base = appMatch(jobIds); const rangeApps = await App.find( { ...base, ...appTimeMatch(startUTC, endExcl) }, { _id: 1 } ).lean(); if (!rangeApps.length) return noData; const appIds = rangeApps.map(a => a._id); const appFiles = await AppFile.find( { appId: { $in: appIds }, markedDelete: { $ne: true } }, { _id: 1 } ).lean(); if (!appFiles.length) return noData; const fileIds = appFiles.map(f => f._id); const sprayOnCond = { $in: ['$sprayStat', [1, 3]] }; const agg = await AppDetail.aggregate([ { $match: { fileId: { $in: fileIds } } }, { $group: { _id: null, avgXtError: { $avg: { $cond: [{ $and: [{ $ne: ['$xTrack', 0] }, sprayOnCond] }, { $abs: '$xTrack' }, null] } }, xtCount: { $sum: { $cond: [{ $and: [{ $ne: ['$xTrack', 0] }, sprayOnCond] }, 1, 0 ] } }, avgSprayHeight: { $avg: { $cond: [{ $and: [{ $gt: ['$sprayHeight', 0] }, sprayOnCond] }, '$sprayHeight', null] } }, sprayHeightCount: { $sum: { $cond: [{ $and: [{ $gt: ['$sprayHeight', 0] }, sprayOnCond] }, 1, 0 ] } }, avgRadarAlt: { $avg: { $cond: [{ $and: [{ $gt: ['$radarAlt', 0] }, sprayOnCond] }, '$radarAlt', null] } }, radarAltCount: { $sum: { $cond: [{ $and: [{ $gt: ['$radarAlt', 0] }, sprayOnCond] }, 1, 0 ] } } } } ]); const r = agg[0]; if (!r) return { ...noData, sampleSize: fileIds.length }; let avgSprayAltitudeMeters = null; let altitudeSource = null; let hasAltitudeData = false; if (r.sprayHeightCount > 0 && r.avgSprayHeight != null) { avgSprayAltitudeMeters = round2(r.avgSprayHeight); altitudeSource = 'sprayHeight'; hasAltitudeData = true; } else if (r.radarAltCount > 0 && r.avgRadarAlt != null) { avgSprayAltitudeMeters = round2(r.avgRadarAlt); altitudeSource = 'radarAlt'; hasAltitudeData = true; } const hasXtData = r.xtCount > 0 && r.avgXtError != null; const avgXtError = hasXtData ? round2(r.avgXtError) : null; return { startDate, endDate, avgXtError, xtThreshold: { good: xtGood, monitor: xtMonitor }, hasXtData, avgSprayAltitudeMeters, altitudeSource, altThreshold: { target: altTarget, goodBand: altGoodBand, monitorBand: altMonitorBand }, hasAltitudeData, sampleSize: fileIds.length }; } // ─── GET /api/dashboard/pilot/kpi ──────────────────────────────────────────── /** * @api {get} /api/dashboard/pilot/kpi Pilot KPI Cards * @apiName GetPilotKpi * @apiGroup PilotDashboard * @apiDescription Returns KPI card data for the authenticated pilot. * All Application metrics use startDateTimeUTC (spray time) as the time axis. * tz still controls calendar-boundary calculations (day/week/month/year) and date bucketing. * * @apiQuery {String} [tz=UTC] IANA timezone string for period boundaries. * * @apiSuccess {Number} assignedJobs Open jobs (NEW / READY / DOWNLOADED / SPRAYED). * @apiSuccess {Number} assignedHectares Sum of ttSprArea across all assigned jobs. * @apiSuccess {Object} operations Today's operational totals: { missionsFlown, distanceTravelledKm, distanceSprayedKm }. * @apiSuccess {Object} periods Period-scoped metrics keyed by tab (week/month/year/all). * Every period has: { assignedJobs, assignedHectares, sprayedHectares, flightHours, jobCounts }. * jobCounts: { new, inProgress, completed } — present on all five periods. */ async function getKpi(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); const jobs = await fetchPilotJobs(req.uid); const base = appMatch(jobs.map(j => j._id)); res.json(await computeKpi(jobs, base, tz)); } // ─── GET /api/dashboard/pilot/summary ──────────────────────────────────────── /** * @api {get} /api/dashboard/pilot/summary Pilot Daily Summary * @apiName GetPilotSummary * @apiGroup PilotDashboard * @apiDescription Returns today vs yesterday operational metrics with percentage deltas. * * @apiQuery {String} [tz=UTC] IANA timezone string. */ async function getSummary(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); const jobs = await fetchPilotJobs(req.uid); const base = appMatch(jobs.map(j => j._id)); res.json(await computeSummary(jobs, base, tz)); } // ─── GET /api/dashboard/pilot/trend ────────────────────────────────────────── /** * @api {get} /api/dashboard/pilot/trend Pilot Trend Charts Data * @apiName GetPilotTrend * @apiGroup PilotDashboard * @apiDescription Returns daily hours flown and hectares sprayed for a date range. * Defaults to the current calendar week (Mon–Sun). Missing days are filled with 0. * * @apiQuery {String} [tz=UTC] IANA timezone string. * @apiQuery {String} [startDate] YYYY-MM-DD start date (inclusive). Defaults to Monday of current week. * @apiQuery {String} [endDate] YYYY-MM-DD end date (inclusive). Defaults to Sunday of current week. */ async function getTrend(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); const parsedRange = parseDateRange(req.query, tz); const jobs = await fetchPilotJobs(req.uid); const base = appMatch(jobs.map(j => j._id)); res.json(await computeTrend(jobs, base, tz, parsedRange)); } // ─── GET /api/dashboard/pilot/activeJobs ───────────────────────────────────── /** * Display status groupings used by the frontend color/badge system. * NEW (0) → 'NEW' * READY/DOWNLOADED/SPRAYED → 'IN_PROGRESS' * COMPLETED (4) → 'COMPLETED' */ function toDisplayStatus(status) { if (status === JobStatus.NEW) return 'NEW'; if (status === JobStatus.COMPLETED) return 'COMPLETED'; return 'IN_PROGRESS'; } /** * @api {get} /api/dashboard/pilot/activeJobs Pilot Active Jobs Panel * @apiName GetPilotActiveJobs * @apiGroup PilotDashboard * @apiDescription Returns the pilot's assigned jobs (active statuses only) with per-job * sprayed hectares and applied volume aggregated from uploaded Application files. * INVOICED (5) and ARCHIVED (9) jobs are excluded. * * The Application totals (haSprayed, volumeAppliedLiters) can be scoped to a time window: * - `period=day` → current calendar day * - `period=week` → current Mon–Sun calendar week * - `period=month` → current calendar month * - `period=year` → current calendar year * - (neither) → all-time totals (no date filter) * * @apiQuery {String} [period] Time window: day | week | month | year. * @apiQuery {String} [tz=UTC] IANA timezone string. */ async function getActiveJobs(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); let windowFilter = null; if (req.query.period) { switch (req.query.period) { case 'day': windowFilter = dayWindow(0, tz); break; case 'week': windowFilter = weekWindow(tz); break; case 'month': windowFilter = monthWindow(tz); break; case 'year': windowFilter = yearWindow(tz); break; default: AppParamError.throw(Errors.INVALID_PARAM); } } res.json(await computeActiveJobs(req.uid, windowFilter)); } // ─── GET /api/dashboard/pilot/performance ──────────────────────────────────── /** * @api {get} /api/dashboard/pilot/performance Pilot Performance Gauges * @apiName GetPilotPerformance * @apiGroup PilotDashboard * @apiDescription Returns average XT cross-track error and spray altitude gauges. * All values are calculated using spray-on records (sprayStat 1 = on-swath, 3 = swath entry), * which gives meaningful agronomic metrics free from transit/ferry-flight pollution. * Aggregates over ApplicationDetail records for all processed application files * within the requested date range. Defaults to the current calendar week (Mon–Sun). * Altitude source priority: sprayHeight (FM sensor) → radarAlt (AGL fallback). * Returns hasXtData / hasAltitudeData = false when no sensor data exists. * * NOTE: ApplicationDetail is a billion-document collection. * All queries are scoped by fileId to use the fileId index and avoid collection scans. * * @apiQuery {String} [tz=UTC] IANA timezone string. * @apiQuery {String} [startDate] YYYY-MM-DD start date (inclusive). Defaults to Monday of current week. * @apiQuery {String} [endDate] YYYY-MM-DD end date (inclusive). Defaults to Sunday of current week. */ async function getPerformance(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); const parsedRange = parseDateRange(req.query, tz); const jobs = await fetchPilotJobs(req.uid); const jobIds = jobs.map(j => j._id); res.json(await computePerformance(req.uid, jobIds, tz, parsedRange)); } // ─── PUT /api/dashboard/pilot/performance/thresholds ───────────────────────── /** * @api {put} /api/dashboard/pilot/performance/thresholds Save Performance Thresholds * @apiName SavePerformanceThresholds * @apiGroup Dashboard * @apiDescription Persists custom XT error and altitude thresholds for the authenticated user. * Values are validated (positive numbers, monitor > good, monitorBand > goodBand). * Pass null for any field to reset it to the system default. * * @apiBody {Number|null} xtGood XT ideal threshold in metres (e.g. 1.0) * @apiBody {Number|null} xtMonitor XT caution threshold in metres (e.g. 3.0) * @apiBody {Number|null} altTarget Altitude target in metres (e.g. 3.7) * @apiBody {Number|null} altGoodBand ±band from target for green zone (e.g. 0.15) * @apiBody {Number|null} altMonitorBand ±band from target for yellow zone (e.g. 0.46) * * @apiSuccess {Object} xtThreshold Saved {good, monitor} * @apiSuccess {Object} altThreshold Saved {target, goodBand, monitorBand} */ async function savePerformanceThresholds(req, res) { if (!req.uid) AppAuthError.throw(); const { xtGood, xtMonitor, altTarget, altGoodBand, altMonitorBand } = req.body; // Separate $set (new values) from $unset (null = reset to system default). // Using $set with undefined silently ignores the key — $unset is required to remove a stored field. const setFields = {}; const unsetFields = {}; if (xtGood !== undefined) { if (xtGood === null) { unsetFields['dashboard.xtGood'] = 1; } else { const v = Number(xtGood); if (!isFinite(v) || v <= 0) AppParamError.throw(); setFields['dashboard.xtGood'] = v; } } if (xtMonitor !== undefined) { if (xtMonitor === null) { unsetFields['dashboard.xtMonitor'] = 1; } else { const v = Number(xtMonitor); if (!isFinite(v) || v <= 0) AppParamError.throw(); setFields['dashboard.xtMonitor'] = v; } } if (altTarget !== undefined) { if (altTarget === null) { unsetFields['dashboard.altTarget'] = 1; } else { const v = Number(altTarget); if (!isFinite(v) || v <= 0) AppParamError.throw(); setFields['dashboard.altTarget'] = v; } } if (altGoodBand !== undefined) { if (altGoodBand === null) { unsetFields['dashboard.altGoodBand'] = 1; } else { const v = Number(altGoodBand); if (!isFinite(v) || v <= 0) AppParamError.throw(); setFields['dashboard.altGoodBand'] = v; } } if (altMonitorBand !== undefined) { if (altMonitorBand === null) { unsetFields['dashboard.altMonitorBand'] = 1; } else { const v = Number(altMonitorBand); if (!isFinite(v) || v <= 0) AppParamError.throw(); setFields['dashboard.altMonitorBand'] = v; } } // Load the user's currently stored thresholds so that partial updates are validated // against the real in-DB state rather than system defaults. Without this, sending only // {xtMonitor: 3} when xtGood is already stored as 5 would pass validation (3 > 1.0 default) // but leave an invalid combination (xtMonitor < xtGood) in the database. const currentSetting = await Setting.findOne({ userId: ObjectId(req.uid) }, 'dashboard').lean(); const storedDs = (currentSetting && currentSetting.dashboard) || {}; // Cross-field validation: prefer new value → if being reset use system default → use stored custom value → system default const resolvedXtGood = setFields['dashboard.xtGood'] ?? (('dashboard.xtGood' in unsetFields) ? XT_GOOD : (storedDs.xtGood ?? XT_GOOD)); const resolvedXtMonitor = setFields['dashboard.xtMonitor'] ?? (('dashboard.xtMonitor' in unsetFields) ? XT_MONITOR : (storedDs.xtMonitor ?? XT_MONITOR)); if (resolvedXtMonitor <= resolvedXtGood) AppParamError.throw(); const resolvedAltGood = setFields['dashboard.altGoodBand'] ?? (('dashboard.altGoodBand' in unsetFields) ? ALT_GOOD : (storedDs.altGoodBand ?? ALT_GOOD)); const resolvedAltMonitor = setFields['dashboard.altMonitorBand'] ?? (('dashboard.altMonitorBand' in unsetFields) ? ALT_MONITOR : (storedDs.altMonitorBand ?? ALT_MONITOR)); if (resolvedAltMonitor <= resolvedAltGood) AppParamError.throw(); const mongoUpdate = {}; if (Object.keys(setFields).length) mongoUpdate.$set = setFields; if (Object.keys(unsetFields).length) mongoUpdate.$unset = unsetFields; const updated = await Setting.findOneAndUpdate( { userId: ObjectId(req.uid) }, mongoUpdate, { new: true, lean: true, upsert: true, select: 'dashboard' } ); const ds = (updated && updated.dashboard) || {}; res.json({ xtThreshold: { good: ds.xtGood ?? XT_GOOD, monitor: ds.xtMonitor ?? XT_MONITOR }, altThreshold: { target: ds.altTarget ?? ALT_TARGET, goodBand: ds.altGoodBand ?? ALT_GOOD, monitorBand: ds.altMonitorBand ?? ALT_MONITOR } }); } // ─── GET /api/dashboard/pilot/snapshot ────────────────────────────────────── /** * @api {get} /api/dashboard/pilot/snapshot Pilot Dashboard Snapshot * @apiName GetPilotSnapshot * @apiGroup PilotDashboard * @apiDescription Returns a composite dashboard snapshot with selected modules in a single request. * Avoids N+1 API calls and redundant job/app lookups by composing internally. * All sub-modules use the same shared job/app data fetch. * * @apiQuery {String} [include=kpi,summary,activeJobs,performance,trend] Comma-separated list of modules to include. * Valid values: `kpi`, `summary`, `activeJobs`, `performance`, `trend`. * Omit to return all available modules (recommended for initial load). * @apiQuery {String} [tz=UTC] IANA timezone string for period and trend boundaries. * @apiQuery {String} [period] For `activeJobs` module: time window for haSprayed/volumeApplied sub-totals. * Values: `day` | `week` | `month` | `year`. Omit for all-time totals (default). * @apiQuery {String} [startDate] For `trend` and `performance` modules: YYYY-MM-DD start (defaults to Mon of current week). * @apiQuery {String} [endDate] For `trend` and `performance` modules: YYYY-MM-DD end (defaults to Sun of current week). Max range: 90 days. * * @apiSuccess {Object} [kpi] KPI card data when `include=kpi` (or default). * @apiSuccess {Object} [summary] Today vs yesterday summary when `include=summary`. * @apiSuccess {Object} [activeJobs] Active jobs panel when `include=activeJobs`. * @apiSuccess {Object} [performance] Performance gauges when `include=performance`. * @apiSuccess {Object} [trend] Trend chart data when `include=trend`. */ async function getSnapshot(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); const includeRaw = (req.query.include || 'kpi,summary,activeJobs,performance,trend') .split(',').map(s => s.trim()).filter(Boolean); const validModules = ['kpi', 'summary', 'activeJobs', 'performance', 'trend']; const include = new Set(includeRaw.filter(m => validModules.includes(m))); if (include.size === 0) { AppParamError.throw(Errors.INVALID_PARAM, 'include list is empty or contains no valid modules'); } // Fetch jobs once — shared by kpi, summary, trend, and performance modules const jobs = await fetchPilotJobs(req.uid); const jobIds = jobs.map(j => j._id); const base = appMatch(jobIds); // Parse date range once — shared by trend and performance modules const parsedRange = (include.has('trend') || include.has('performance')) ? parseDateRange(req.query, tz) : null; const snapshot = {}; const tasks = []; if (include.has('kpi')) tasks.push(computeKpi(jobs, base, tz).then(d => { snapshot.kpi = d; })); if (include.has('summary')) tasks.push(computeSummary(jobs, base, tz).then(d => { snapshot.summary = d; })); // Resolve optional period filter for activeJobs (same logic as standalone getActiveJobs) let activeJobsWindowFilter = null; if (include.has('activeJobs') && req.query.period) { switch (req.query.period) { case 'day': activeJobsWindowFilter = dayWindow(0, tz); break; case 'week': activeJobsWindowFilter = weekWindow(tz); break; case 'month': activeJobsWindowFilter = monthWindow(tz); break; case 'year': activeJobsWindowFilter = yearWindow(tz); break; default: AppParamError.throw(Errors.INVALID_PARAM, 'period must be day, week, month, or year'); } } if (include.has('activeJobs')) tasks.push(computeActiveJobs(req.uid, activeJobsWindowFilter).then(d => { snapshot.activeJobs = d; })); if (include.has('performance')) tasks.push(computePerformance(req.uid, jobIds, tz, parsedRange).then(d => { snapshot.performance = d; })); if (include.has('trend')) tasks.push(computeTrend(jobs, base, tz, parsedRange).then(d => { snapshot.trend = d; })); await Promise.all(tasks); res.json(snapshot); } module.exports = { getKpi, getSummary, getTrend, getActiveJobs, getPerformance, savePerformanceThresholds, getSnapshot };