// 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, };