941 lines
39 KiB
JavaScript
941 lines
39 KiB
JavaScript
'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
|
||
};
|