'use strict'; const debug = require('debug')('agm:dashboard'); const ObjectId = require('mongodb').ObjectId; const { Job, App, AppFile, AppDetail } = 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'; } } /** * 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(); } // ─── 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 are based on createdDate (upload time) since * Application.endDateTime is a raw device string with no guaranteed format. * * @apiQuery {String} [tz=UTC] IANA timezone string for today/week/month/year boundaries. * * @apiSuccess {Number} assignedJobs All-time total jobs assigned to this pilot. * @apiSuccess {Number} assignedHectares Sum of ttSprArea across all assigned jobs. * @apiSuccess {Number} sprayedToday Hectares sprayed today (by upload time). * @apiSuccess {Number} flightHoursToday Flight hours recorded today. * @apiSuccess {Object} operations distanceKm and sprayVolumeLiters for today. * @apiSuccess {Object} historical Breakdown of jobs/hectares/flightHours by period. */ async function getKpi(req, res) { if (!req.uid) AppAuthError.throw(); const tz = validateTz(req.query.tz); const todayW = dayWindow(0, tz); const weekW = weekWindow(tz); const monthW = monthWindow(tz); const yearW = yearWindow(tz); const jobs = await fetchPilotJobs(req.uid); const jobIds = jobs.map(j => j._id); const assignedJobs = jobs.length; const assignedHectares = round2(jobs.reduce((s, j) => s + (j.ttSprArea || 0), 0)); if (!jobIds.length) { return res.json({ assignedJobs, assignedHectares, sprayedToday: 0, flightHoursToday: 0, operations: { distanceKm: 0, sprayVolumeLiters: 0 }, historical: { jobs: { year: 0, month: 0, week: 0, day: 0 }, hectares: { year: 0, month: 0, week: 0, day: 0 }, flightHours: { year: 0, month: 0, week: 0, day: 0 } } }); } const base = appMatch(jobIds); // Run all aggregations in parallel const [todayR, weekR, monthR, yearR, wJobs, mJobs, yJobs] = await Promise.all([ App.aggregate([ { $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, distance: { $sum: '$totalSprLength' }, volume: { $sum: '$totalSprayMat' } } } ]), App.aggregate([ { $match: { ...base, createdDate: { $gte: weekW.start, $lt: weekW.end } } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } } ]), App.aggregate([ { $match: { ...base, createdDate: { $gte: monthW.start, $lt: monthW.end } } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } } ]), App.aggregate([ { $match: { ...base, createdDate: { $gte: yearW.start, $lt: yearW.end } } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' } } } ]), Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: weekW.start, $lt: weekW.end } }), Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: monthW.start, $lt: monthW.end } }), Job.countDocuments({ operator: ObjectId(req.uid), markedDelete: { $ne: true }, createdAt: { $gte: yearW.start, $lt: yearW.end } }) ]); const t = todayR[0] || {}, w = weekR[0] || {}, m = monthR[0] || {}, y = yearR[0] || {}; res.json({ assignedJobs, assignedHectares, sprayedToday: round2(t.sprayed || 0), flightHoursToday: round2((t.flightTime || 0) / 3600), operations: { distanceKm: round2((t.distance || 0) / 1000), sprayVolumeLiters: round2(t.volume || 0) }, historical: { jobs: { year: yJobs, month: mJobs, week: wJobs, day: assignedJobs }, hectares: { year: round2(y.sprayed || 0), month: round2(m.sprayed || 0), week: round2(w.sprayed || 0), day: round2(t.sprayed || 0) }, flightHours: { year: round2((y.flightTime || 0) / 3600), month: round2((m.flightTime || 0) / 3600), week: round2((w.flightTime || 0) / 3600), day: round2((t.flightTime || 0) / 3600) } } }); } // ─── 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 todayW = dayWindow(0, tz); const yesterW = dayWindow(1, tz); const jobs = await fetchPilotJobs(req.uid); const jobIds = jobs.map(j => j._id); const empty = { hectares: 0, flightHours: 0, haPerHour: 0, avgSpeedKmh: 0, sprayVolumeLiters: 0 }; if (!jobIds.length) { return res.json({ today: empty, yesterday: empty, deltas: { hectaresPct: null, flightHoursPct: null, haPerHourPct: null, avgSpeedPct: null, sprayVolumePct: null } }); } const base = appMatch(jobIds); const [todayR, yesterR] = await Promise.all([ App.aggregate([ { $match: { ...base, createdDate: { $gte: todayW.start, $lt: todayW.end } } }, { $group: { _id: null, sprayed: { $sum: '$totalSprayed' }, flightTime: { $sum: '$totalFlightTime' }, volume: { $sum: '$totalSprayMat' }, avgSpeed: { $avg: '$avgSpraySpeed' } } } ]), App.aggregate([ { $match: { ...base, createdDate: { $gte: yesterW.start, $lt: 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); const haPerHour = hours > 0 ? round2(ha / hours) : 0; return { hectares: ha, flightHours: hours, haPerHour, avgSpeedKmh: round2((raw.avgSpeed || 0) * 3.6), sprayVolumeLiters: round2(raw.volume || 0) }; } const today = buildDay(todayR); const yesterday = buildDay(yesterR); res.json({ today, yesterday, deltas: { 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) } }); } // ─── 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); let startDate = req.query.startDate; let endDate = req.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(); const startUTC = midnightUTC(startDate, tz); const endExcl = new Date(midnightUTC(endDate, tz).getTime() + 86400000); const diffDays = Math.round((endExcl - startUTC) / 86400000); if (diffDays < 1 || diffDays > 90) AppParamError.throw(); const jobs = await fetchPilotJobs(req.uid); const jobIds = jobs.map(j => j._id); // Generate all date labels for the range 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 (!jobIds.length) { return res.json({ labels, hoursFlown: labels.map(() => 0), hectaresPerDay: labels.map(() => 0) }); } const grouped = await App.aggregate([ { $match: { ...appMatch(jobIds), createdDate: { $gte: startUTC, $lt: endExcl } } }, { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdDate', 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) }; } const hoursFlown = labels.map(l => (dataMap[l] || {}).hoursFlown || 0); const hectaresPerDay = labels.map(l => (dataMap[l] || {}).hectaresPerDay || 0); res.json({ labels, hoursFlown, hectaresPerDay }); } // ─── 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. */ async function getActiveJobs(req, res) { if (!req.uid) AppAuthError.throw(); const jobs = await Job.aggregate([ { $match: { operator: ObjectId(req.uid), markedDelete: { $ne: true }, status: { $in: ACTIVE_JOB_STATUSES } } }, { $sort: { createdAt: -1 } }, { $limit: 50 }, // Client name (stored in users collection) { $lookup: { from: 'users', localField: 'client', foreignField: '_id', as: 'clientDoc' } }, { $unwind: { path: '$clientDoc', preserveNullAndEmptyArrays: true } }, // Aircraft registration (Vehicle is a User discriminator stored in users collection) { $lookup: { from: 'users', localField: 'vehicle', foreignField: '_id', as: 'vehicleDoc' } }, { $unwind: { path: '$vehicleDoc', preserveNullAndEmptyArrays: true } }, // Per-job aggregated Application totals { $lookup: { from: 'applications', let: { jobId: '$_id' }, pipeline: [ { $match: { $expr: { $and: [ { $eq: ['$jobId', '$$jobId'] }, { $eq: ['$status', 3] }, { $ne: ['$markedDelete', true] } ] } } }, { $group: { _id: null, haSprayed: { $sum: '$totalSprayed' }, volumeApplied: { $sum: '$totalSprayMat' } } } ], as: 'appTotals' } }, { $unwind: { path: '$appTotals', preserveNullAndEmptyArrays: true } }, { $project: { _id: 1, name: 1, status: 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 progressPct = haTotal > 0 ? Math.min(100, Math.max(0, Math.round((haSprayed / haTotal) * 100))) : 0; return { jobId: j._id, name: j.name || '', clientName: j.clientName || '', aircraftReg: j.aircraftReg || '', status: j.status, displayStatus: toDisplayStatus(j.status), haTotal, haSprayed, progressPct, volumeAppliedLiters: round2(j.volumeAppliedLiters) }; }); res.json({ jobs: result }); } // ─── 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. * Aggregates over ApplicationDetail records for the pilot's last 10 processed * application files. Altitude source priority: sprayHeight (FM) → radarAlt (AGL). * 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. */ async function getPerformance(req, res) { if (!req.uid) AppAuthError.throw(); const jobs = await fetchPilotJobs(req.uid); const jobIds = jobs.map(j => j._id); const noData = { avgXtErrorMeters: null, xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR }, hasXtData: false, avgSprayAltitudeMeters: null, altitudeSource: null, altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR }, hasAltitudeData: false, sampleSize: 0 }; if (!jobIds.length) return res.json(noData); // Step 1: Get the 10 most recently uploaded processed Application records const recentApps = await App.find(appMatch(jobIds)).sort({ createdDate: -1 }).limit(10).select('_id').lean(); if (!recentApps.length) return res.json(noData); // Step 2: Get all AppFiles for those applications const appIds = recentApps.map(a => a._id); const appFiles = await AppFile.find( { appId: { $in: appIds }, markedDelete: { $ne: true } }, { _id: 1 } ).lean(); if (!appFiles.length) return res.json(noData); const fileIds = appFiles.map(f => f._id); // Step 3: Aggregate ApplicationDetail — uses fileId index only (no collection scan) const agg = await AppDetail.aggregate([ { $match: { fileId: { $in: fileIds } } }, { $group: { _id: null, // XT error: average of abs(xTrack) excluding zero (zero means "no reading") avgXtError: { $avg: { $cond: [{ $ne: ['$xTrack', 0] }, { $abs: '$xTrack' }, null] } }, xtCount: { $sum: { $cond: [{ $ne: ['$xTrack', 0] }, 1, 0] } }, // sprayHeight: Flight Master dedicated sensor avgSprayHeight: { $avg: { $cond: [{ $gt: ['$sprayHeight', 0] }, '$sprayHeight', null] } }, sprayHeightCount: { $sum: { $cond: [{ $gt: ['$sprayHeight', 0] }, 1, 0] } }, // radarAlt: fallback AGL source avgRadarAlt: { $avg: { $cond: [{ $gt: ['$radarAlt', 0] }, '$radarAlt', null] } }, radarAltCount: { $sum: { $cond: [{ $gt: ['$radarAlt', 0] }, 1, 0] } } } } ]); const r = agg[0]; if (!r) return res.json({ ...noData, sampleSize: fileIds.length }); // Altitude: prefer sprayHeight over radarAlt 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 avgXtErrorMeters = hasXtData ? round2(r.avgXtError) : null; res.json({ avgXtErrorMeters, xtThreshold: { good: XT_GOOD, monitor: XT_MONITOR }, hasXtData, avgSprayAltitudeMeters, altitudeSource, altThreshold: { target: ALT_TARGET, goodBand: ALT_GOOD, monitorBand: ALT_MONITOR }, hasAltitudeData, sampleSize: fileIds.length }); } // ─── PATCH /api/jobs/:job_id/complete ──────────────────────────────────────── /** * @api {patch} /api/jobs/:job_id/complete Mark Job as Completed * @apiName CompleteJob * @apiGroup Jobs * @apiDescription Transitions a job from SPRAYED (3) to COMPLETED (4). * Only the Applicator who owns the job (Job.byPuid) may perform this action. * Returns 409 if the job is not in SPRAYED status. Returns 401 if the caller * is not the job owner. * * @apiParam {Number} job_id The numeric job ID. * * @apiSuccess {Object} job The updated job document. * * @apiError (409) {Object} error Status is not SPRAYED, or job does not exist. * @apiError (401) {Object} error Caller is not the job owner. */ async function completeJob(req, res) { const jobId = Number(req.params.job_id); if (!Number.isFinite(jobId) || jobId <= 0) AppParamError.throw(); const job = await Job.findById(jobId, { status: 1, byPuid: 1 }).lean(); if (!job) AppError.throw(Errors.JOB_NOT_FOUND); // Only the Applicator who owns the job may complete it if (!job.byPuid || job.byPuid.toString() !== req.uid) AppAuthError.throw(); // Transition is only valid from SPRAYED (3) if (job.status !== JobStatus.SPRAYED) AppParamError.throw(Errors.STATUS_JOB_INVALID); const updated = await Job.findByIdAndUpdate( jobId, { $set: { status: JobStatus.COMPLETED } }, { new: true, lean: true } ) .populate({ path: 'client', select: 'name' }) .populate({ path: 'operator', select: 'name' }) .populate({ path: 'vehicle', select: 'name tailNumber unitId' }); if (!updated) AppError.throw(Errors.JOB_NOT_FOUND); debug('Job %d marked COMPLETED by %s', jobId, req.uid); res.json(updated); } module.exports = { getKpi, getSummary, getTrend, getActiveJobs, getPerformance, completeJob };