agmission/server/controllers/dashboard.js

941 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 MonSun 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 MonSun 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 (MonSun). 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 MonSun 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 (MonSun).
* 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
};