Files
MultiRoombaRover/server/src/services/discordBotService/fleetDailyReports.js
T
2026-07-25 15:54:38 -04:00

150 lines
6.0 KiB
JavaScript

// Discord Fleet Daily Reports
// Purpose: Schedules and delivers completed-day fleet summaries to the existing admin alert channel.
// Scope: Discord owns timing/formatting/delivery; the fleet service owns evidence, analysis, and durable delivery state.
const { DateTime } = require('luxon');
const { EmbedBuilder } = require('discord.js');
function parseSendTime(value) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim());
if (!match) return { hour: 8, minute: 0 };
return {
hour: Math.max(0, Math.min(23, Number(match[1]))),
minute: Math.max(0, Math.min(59, Number(match[2]))),
};
}
function nextRunAt({ zone, hour, minute }) {
const now = DateTime.now().setZone(zone);
let next = now.set({ hour, minute, second: 0, millisecond: 0 });
if (next <= now) next = next.plus({ days: 1 });
return next;
}
function formatNumber(value, digits = 1) {
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: digits });
}
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
let timer = null;
const reportConfig = fleetConfig?.discord || {};
const enabled = fleetReportService?.enabled && reportConfig.enabled !== false;
const channelId = discordConfig?.channels?.adminAlerts;
const zone = String(reportConfig.timezone || 'America/New_York');
const { hour, minute } = parseSendTime(reportConfig.sendAt);
function publicRoverIds() {
// Discord's shared admin-alert channel does not provide a per-viewer socket
// against which private-rover grants can be checked. Excluding private
// rovers here preserves the existing privacy boundary instead of assuming
// every channel reader has every private grant.
return roverManager.getRoster()
.filter((rover) => !rover?.private?.enabled)
.map((rover) => String(rover.id));
}
function completedDayRange() {
const end = DateTime.now().setZone(zone).startOf('day');
const start = end.minus({ days: 1 });
return {
reportDate: start.toISODate(),
since: start.toMillis(),
until: end.toMillis(),
};
}
function buildEmbed(reportDate, report) {
const totals = report.totals;
const attention = report.attention
.filter((item) => item.severity !== 'notice')
.slice(0, 12)
.map((item) => `• ${item.roverId}: ${item.title}`)
.join('\n') || 'No material battery or efficiency changes need attention.';
const roverLines = report.rovers.map((rover) => {
const health = rover.batteryHealth || {};
const efficiency = rover.overallWhPerKm == null
? `efficiency pending (${formatNumber(rover.distanceMm / 1000, 0)} m)`
: `${formatNumber(rover.overallWhPerKm)} Wh/km`;
const capacity = health.measuredUsableMah == null
? `health collecting (${health.confidence || 'low'} confidence)`
: `${formatNumber(health.measuredUsableMah / 1000, 2)} Ah usable · ${formatNumber(health.capacityRetentionPercent)}% retained · ${health.confidence} confidence`;
return `• ${rover.name}: ${formatNumber(rover.distanceMm / 1e6, 2)} km · ${formatNumber(rover.dischargedWh, 2)} Wh · ${efficiency}\n Battery: ${capacity}`;
}
).join('\n') || 'No public rover telemetry.';
return new EmbedBuilder()
.setTitle(`Daily fleet report — ${reportDate}`)
.setColor(totals.attentionCount ? 0xf0b651 : 0x4caf50)
.addFields(
{
name: 'Fleet energy',
value: `${formatNumber(totals.distanceMm / 1e6, 2)} km · ${formatNumber(totals.dischargedWh, 2)} Wh · ${totals.overallWhPerKm == null ? 'efficiency pending' : `${formatNumber(totals.overallWhPerKm)} Wh/km`} · ${formatNumber(totals.stationaryDischargedWh, 2)} stationary Wh`,
},
{ name: 'Needs attention', value: attention.slice(0, 1024) },
{ name: 'Rovers', value: roverLines.slice(0, 1024) },
)
.setFooter({ text: 'The server reports page contains the complete all-rovers metric table.' });
}
async function deliverPreviousDay() {
if (!enabled || !channelId) return;
const range = completedDayRange();
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
if (existing?.discordDeliveredAt) return;
const report = fleetReportService.getDailyReport({
since: range.since,
until: range.until,
roverIds: publicRoverIds(),
});
if (!report) return;
/*
Daily storage retains the exact metric report used for delivery, but the
Discord message intentionally has no raw JSON attachment. Admins need
actionable fleet comparisons here; the complete read-only evidence stays
on the reports page without turning routine events into notification noise.
*/
fleetReportService.storage.saveDailyReport(range.reportDate, report);
const sent = await sendToChannel(
channelId,
`Daily fleet report for ${range.reportDate}`,
{ embeds: [buildEmbed(range.reportDate, report)] },
{ parse: [] },
);
if (sent) {
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { deliveredAt: Date.now(), error: null });
} else {
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { error: 'Discord delivery returned no message' });
}
}
function scheduleNext() {
if (!enabled || !channelId) return;
const next = nextRunAt({ zone, hour, minute });
const delay = Math.max(1000, next.toMillis() - Date.now());
timer = setTimeout(async () => {
try {
await deliverPreviousDay();
} catch (err) {
logger.warn('Daily fleet report delivery failed', { error: err.message });
} finally {
scheduleNext();
}
}, delay);
timer.unref?.();
logger.info('Scheduled daily fleet report', { nextRunAt: next.toISO(), channelId });
}
function start() {
scheduleNext();
}
function stop() {
if (timer) clearTimeout(timer);
timer = null;
}
return { start, stop, deliverPreviousDay };
}
module.exports = {
createFleetDailyReports,
};