diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index 202e6c3..c6b7b44 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -9,6 +9,7 @@ import { ResponsiveContainer, Brush, } from 'recharts'; +import { Download } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; @@ -128,6 +129,108 @@ function cleanNumber(value: number): string { return `${Number(value.toFixed(4))}`; } +// --- CSV export --- + +/** Strip float representation noise without clamping small magnitudes the way + * a fixed decimal count would (`toFixed(4)` would flatten 0.000012 to 0). */ +function csvNumber(value: number): string { + if (Number.isInteger(value)) return `${value}`; + return `${Number(value.toPrecision(12))}`; +} + +function escapeCsvValue(value: string): string { + return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** Local-time ISO 8601 with offset, so a sample's wall-clock time survives the + * trip into a spreadsheet without the reader having to know our timezone. */ +export function toLocalIsoString(date: Date): string { + const offsetMin = -date.getTimezoneOffset(); + const sign = offsetMin >= 0 ? '+' : '-'; + const abs = Math.abs(offsetMin); + return ( + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` + + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}` + + `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}` + ); +} + +/** `_data_YYYYMMDD_HHMMSS.csv`, reduced to filesystem-safe + * characters. Falls back to "repeater" when a name sanitizes away entirely. */ +export function telemetryCsvFilename(repeaterName: string, at: Date): string { + const safeName = + repeaterName + .replace(/[^a-zA-Z0-9_-]+/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^_|_$/g, '') || 'repeater'; + const stamp = + `${at.getFullYear()}${pad2(at.getMonth() + 1)}${pad2(at.getDate())}` + + `_${pad2(at.getHours())}${pad2(at.getMinutes())}${pad2(at.getSeconds())}`; + return `${safeName}_data_${stamp}.csv`; +} + +interface CsvColumn { + key: string; + header: string; +} + +/** Columns mirror the flattened point shape built by `chartData` below, so every + * metric the pane can plot — builtins, their derived series, and each + * discovered LPP sensor — gets a column. Keep the two in step. */ +function buildCsvColumns(lppMetrics: { key: string; config: MetricConfig }[]): CsvColumn[] { + const withUnit = (label: string, unit: string) => (unit ? `${label} (${unit})` : label); + return [ + { key: 'timestamp_iso', header: 'Timestamp (ISO 8601)' }, + { key: 'timestamp', header: 'Unix Timestamp' }, + { key: 'battery_volts', header: withUnit('Voltage', BUILTIN_METRIC_CONFIG.battery_volts.unit) }, + { + key: 'noise_floor_dbm', + header: withUnit('Noise Floor', BUILTIN_METRIC_CONFIG.noise_floor_dbm.unit), + }, + { key: 'packets_received', header: 'Packets Received' }, + { key: 'packets_sent', header: 'Packets Sent' }, + { key: 'packets_received_delta', header: 'Packets Received Delta' }, + { key: 'packets_sent_delta', header: 'Packets Sent Delta' }, + { key: 'recv_errors', header: 'RX Errors' }, + { key: 'recv_error_pct', header: 'RX Error Rate (%)' }, + { + key: 'uptime_seconds', + header: withUnit('Uptime', BUILTIN_METRIC_CONFIG.uptime_seconds.unit), + }, + ...lppMetrics.map((m) => ({ + key: m.key, + // Unit already reflects the active distance-unit preference, as the chart does. + header: withUnit(m.config.label, m.config.unit), + })), + ]; +} + +/** Serialize chart points to CSV. Missing samples become empty cells rather + * than zeros, so gaps stay distinguishable from real readings. */ +export function buildTelemetryCsv( + rows: Array>, + columns: CsvColumn[] +): string { + const lines = [columns.map((c) => escapeCsvValue(c.header)).join(',')]; + for (const row of rows) { + lines.push( + columns + .map((c) => { + if (c.key === 'timestamp_iso') { + const ts = row.timestamp; + return ts == null ? '' : escapeCsvValue(toLocalIsoString(new Date(ts * 1000))); + } + const v = row[c.key]; + return typeof v === 'number' && Number.isFinite(v) ? csvNumber(v) : ''; + }) + .join(',') + ); + } + return lines.join('\r\n'); +} + interface TelemetryHistoryPaneProps { entries: TelemetryHistoryEntry[]; publicKey: string; @@ -427,6 +530,26 @@ export function TelemetryHistoryPane({ } }; + const repeaterName = useMemo( + () => contacts.find((c) => c.public_key === publicKey)?.name ?? publicKey.slice(0, 12), + [contacts, publicKey] + ); + + // Exports the full stored history, not the brushed viewport — the button is + // about archiving the data, while the brush is a chart-reading aid. + const handleDownloadCsv = () => { + const csv = buildTelemetryCsv(chartData, buildCsvColumns(lppMetrics)); + // Excel only detects UTF-8 in a CSV via the BOM, and LPP units include + // non-ASCII characters such as "°C". + const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = telemetryCsvFilename(repeaterName, new Date()); + a.click(); + URL.revokeObjectURL(url); + }; + const trackedNames = useMemo(() => { if (!slotsFull) return []; return trackedTelemetryRepeaters.map((key) => { @@ -444,6 +567,17 @@ export function TelemetryHistoryPane({ {entries.length} samples )} + {entries.length > 0 && ( + + )}
{/* Explanation + tracking toggle */} diff --git a/frontend/src/test/repeaterTelemetryCsv.test.ts b/frontend/src/test/repeaterTelemetryCsv.test.ts new file mode 100644 index 0000000..22952d5 --- /dev/null +++ b/frontend/src/test/repeaterTelemetryCsv.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTelemetryCsv, + telemetryCsvFilename, + toLocalIsoString, +} from '../components/repeater/RepeaterTelemetryHistoryPane'; + +const COLUMNS = [ + { key: 'timestamp_iso', header: 'Timestamp (ISO 8601)' }, + { key: 'timestamp', header: 'Unix Timestamp' }, + { key: 'battery_volts', header: 'Voltage (V)' }, + { key: 'lpp_temperature_ch1', header: 'Temperature Ch1 (°C)' }, +]; + +describe('telemetryCsvFilename', () => { + const at = new Date(2026, 6, 21, 9, 5, 3); // 2026-07-21 09:05:03 local + + it('formats as _data_YYYYMMDD_HHMMSS.csv with zero padding', () => { + expect(telemetryCsvFilename('BaseCamp', at)).toBe('BaseCamp_data_20260721_090503.csv'); + }); + + it('reduces unsafe characters to single underscores', () => { + expect(telemetryCsvFilename('Hill/Top Repeater #2!', at)).toBe( + 'Hill_Top_Repeater_2_data_20260721_090503.csv' + ); + }); + + it('falls back to "repeater" when the name sanitizes away entirely', () => { + expect(telemetryCsvFilename('///', at)).toBe('repeater_data_20260721_090503.csv'); + expect(telemetryCsvFilename('', at)).toBe('repeater_data_20260721_090503.csv'); + }); +}); + +describe('toLocalIsoString', () => { + it('emits local wall-clock time with an explicit UTC offset', () => { + const value = toLocalIsoString(new Date(2026, 0, 2, 3, 4, 5)); + // Offset varies with the runner's zone, so assert shape plus local fields. + expect(value).toMatch(/^2026-01-02T03:04:05[+-]\d{2}:\d{2}$/); + }); +}); + +describe('buildTelemetryCsv', () => { + it('writes a header row and one CRLF-terminated row per sample', () => { + const csv = buildTelemetryCsv( + [ + { timestamp: 1700000000, battery_volts: 4.05, lpp_temperature_ch1: 21.5 }, + { timestamp: 1700000600, battery_volts: 4.04, lpp_temperature_ch1: 21.6 }, + ], + COLUMNS + ); + + const lines = csv.split('\r\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('Timestamp (ISO 8601),Unix Timestamp,Voltage (V),Temperature Ch1 (°C)'); + expect(lines[1]).toContain('1700000000,4.05,21.5'); + expect(lines[2]).toContain('1700000600,4.04,21.6'); + }); + + it('leaves gaps empty rather than filling them with zeros', () => { + const csv = buildTelemetryCsv([{ timestamp: 1700000000, battery_volts: undefined }], COLUMNS); + + const cells = csv.split('\r\n')[1].split(','); + expect(cells[2]).toBe(''); // battery_volts + expect(cells[3]).toBe(''); // absent LPP sensor + }); + + it('strips floating-point noise without flattening small magnitudes', () => { + // 0.1 + 0.2 === 0.30000000000000004 — real IEEE-754 noise rather than a + // literal, which the linter rejects for losing precision at parse time. + const noisy = 0.1 + 0.2; + expect(`${noisy}`).toBe('0.30000000000000004'); + + const csv = buildTelemetryCsv( + [{ timestamp: 1, battery_volts: noisy, lpp_temperature_ch1: 0.000012 }], + COLUMNS + ); + + const cells = csv.split('\r\n')[1].split(','); + expect(cells[2]).toBe('0.3'); + expect(cells[3]).toBe('0.000012'); + }); + + it('quotes headers containing commas or quotes', () => { + const csv = buildTelemetryCsv( + [], + [{ key: 'x', header: 'Odd, "Name"' }] // sensor labels are user-influenced + ); + + expect(csv).toBe('"Odd, ""Name"""'); + }); + + it('renders the ISO column from the sample timestamp', () => { + const csv = buildTelemetryCsv([{ timestamp: 1700000000 }], COLUMNS); + + expect(csv.split('\r\n')[1].split(',')[0]).toBe(toLocalIsoString(new Date(1700000000 * 1000))); + }); +});