All checks were successful
Deploy production / deploy (push) Successful in 1m20s
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
// Radio tracker (/trackers/radio, owner-only): pure helpers shared by the page and
|
||
// scripts/radio.ts (ISS refresh + reminders). No server imports here: the script runs inside the
|
||
// app container, which ships src/lib/radio (see Dockerfile).
|
||
|
||
export const TZ = 'Europe/Amsterdam';
|
||
|
||
export const BANDS = ['marine', 'air', 'ham', 'pmr', 'other'] as const;
|
||
export type Band = (typeof BANDS)[number];
|
||
export const BAND_LABEL: Record<Band, string> = {
|
||
marine: 'marine',
|
||
air: 'airband',
|
||
ham: 'ham',
|
||
pmr: 'PMR',
|
||
other: 'other'
|
||
};
|
||
/** Gradient per band: colourful icons on a black-and-white page. */
|
||
export const BAND_COLOR: Record<Band, [string, string]> = {
|
||
marine: ['#38bdf8', '#0369a1'],
|
||
air: ['#a78bfa', '#5b21b6'],
|
||
ham: ['#fb7185', '#be123c'],
|
||
pmr: ['#fbbf24', '#b45309'],
|
||
other: ['#a1a1aa', '#3f3f46']
|
||
};
|
||
|
||
export const MODES = ['FM', 'NFM', 'AM', 'USB', 'LSB', 'CW', 'DATA'] as const;
|
||
export type Mode = (typeof MODES)[number];
|
||
/** Modes the UBC125XLT can store (AUTO aside); others export as AUTO. */
|
||
export const SCANNER_MODES = new Set<string>(['FM', 'NFM', 'AM']);
|
||
|
||
/** Uniden UBC125XLT layout: 10 banks of 50 channels. */
|
||
export const BANKS = 10;
|
||
export const PER_BANK = 50;
|
||
export const CHANNELS = BANKS * PER_BANK;
|
||
export const bankOf = (channel: number) => Math.floor((channel - 1) / PER_BANK) + 1;
|
||
export const bankRange = (bank: number) => [(bank - 1) * PER_BANK + 1, bank * PER_BANK] as const;
|
||
/** The UBC125XLT shows 16-character alpha tags. */
|
||
export const ALPHA_TAG_MAX = 16;
|
||
|
||
/** Minimum max-elevation for an ISS pass to count (shown highlighted, reminded). */
|
||
export const ISS_MIN_EL = 10;
|
||
export const REMIND_MINUTES = 10;
|
||
export const ISS_DOWNLINK_HZ = 145_800_000;
|
||
/** No reminders are sent when the send time falls in this Amsterdam window (RADIO_QUIET_HOURS overrides). */
|
||
export const QUIET_HOURS = '00:00-08:00';
|
||
export type Quiet = { from: number; to: number; label: string } | null;
|
||
/** "00:00-08:00" -> minutes of day; "off" / "" -> null (no quiet hours). */
|
||
export function parseQuiet(spec: string | null | undefined): Quiet {
|
||
const m = /^\s*(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})\s*$/.exec(spec ?? '');
|
||
if (!m) return null;
|
||
const from = Number(m[1]) * 60 + Number(m[2]);
|
||
const to = Number(m[3]) * 60 + Number(m[4]);
|
||
if (from > 1440 || to > 1440 || from === to) return null;
|
||
const pad = (n: number) =>
|
||
`${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
|
||
return { from, to, label: `${pad(from)}–${pad(to)}` };
|
||
}
|
||
|
||
export const DAY_SHORT = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||
|
||
/** 145450000 -> "145.4500" (MHz, 4 decimals like the scanner); below 30 MHz -> "3785 kHz". */
|
||
export function formatFreq(hz: number | null | undefined, unit = true): string {
|
||
if (hz == null) return '';
|
||
if (hz < 30_000_000) {
|
||
const khz = hz / 1000;
|
||
return `${Number.isInteger(khz) ? khz : khz.toFixed(1)}${unit ? ' kHz' : ''}`;
|
||
}
|
||
return `${(hz / 1e6).toFixed(4)}${unit ? ' MHz' : ''}`;
|
||
}
|
||
|
||
/** MHz with 4 decimals, no unit (CSV/programming software). */
|
||
export const mhz = (hz: number) => (hz / 1e6).toFixed(4);
|
||
|
||
/**
|
||
* Parse a typed frequency: "145.45", "145.450 MHz", "3785 kHz", "3785k", "145450000".
|
||
* Bare numbers < 1000 are MHz, 1000..999999 kHz, larger Hz. Returns Hz or null.
|
||
*/
|
||
export function parseFreq(input: string | null | undefined): number | null {
|
||
if (!input) return null;
|
||
const s = input.trim().toLowerCase().replace(',', '.').replace(/\s+/g, '');
|
||
const m = s.match(/^(\d+(?:\.\d+)?)(mhz|m|khz|k|hz)?$/);
|
||
if (!m) return null;
|
||
const n = Number(m[1]);
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
const u = m[2];
|
||
let hz: number;
|
||
if (u === 'mhz' || u === 'm') hz = n * 1e6;
|
||
else if (u === 'khz' || u === 'k') hz = n * 1e3;
|
||
else if (u === 'hz') hz = n;
|
||
else if (n < 1000) hz = n * 1e6;
|
||
else if (n < 1_000_000) hz = n * 1e3;
|
||
else hz = n;
|
||
hz = Math.round(hz);
|
||
return hz >= 1000 && hz <= 10_000_000_000 ? hz : null;
|
||
}
|
||
|
||
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
|
||
export const compass = (az: number) => COMPASS[Math.round((((az % 360) + 360) % 360) / 45) % 8];
|
||
|
||
// ---- time zones -------------------------------------------------------------------------------
|
||
|
||
const partsFmt = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: TZ,
|
||
hourCycle: 'h23',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
weekday: 'short'
|
||
});
|
||
const WD: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
||
|
||
/** Wall-clock parts of an instant in Europe/Amsterdam (isoDay 1 = Monday). */
|
||
export function zoned(at: Date) {
|
||
const p = Object.fromEntries(partsFmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||
return {
|
||
y: Number(p.year),
|
||
m: Number(p.month),
|
||
d: Number(p.day),
|
||
h: Number(p.hour),
|
||
min: Number(p.minute),
|
||
s: Number(p.second),
|
||
isoDay: WD[p.weekday as string] ?? 1
|
||
};
|
||
}
|
||
|
||
/** The instant at which Europe/Amsterdam's wall clock reads y-m-d h:min. */
|
||
export function fromZoned(y: number, m: number, d: number, h: number, min: number): Date {
|
||
const guess = Date.UTC(y, m - 1, d, h, min);
|
||
// Offset at the guess, then correct once more for DST edges.
|
||
let t = guess;
|
||
for (let i = 0; i < 2; i++) {
|
||
const z = zoned(new Date(t));
|
||
const asUtc = Date.UTC(z.y, z.m - 1, z.d, z.h, z.min, z.s);
|
||
t = guess - (asUtc - t);
|
||
}
|
||
return new Date(t);
|
||
}
|
||
|
||
/** "11:00" / "11:00:00" -> [11, 0]. */
|
||
export const hm = (t: string) => {
|
||
const [h, m] = t.split(':').map(Number);
|
||
return [h || 0, m || 0] as const;
|
||
};
|
||
|
||
export type NetSchedule = { id: number; days: number[]; times: string[]; active?: boolean };
|
||
|
||
/** Occurrences of a weekly schedule (Amsterdam wall time) between from and to. */
|
||
export function occurrences(net: NetSchedule, from: Date, to: Date): Date[] {
|
||
if (net.active === false || !net.days.length || !net.times.length) return [];
|
||
const out: Date[] = [];
|
||
const start = zoned(new Date(from.getTime() - 86_400_000));
|
||
// Walk calendar days in Amsterdam (via noon UTC so DST never skips a day).
|
||
let cursor = Date.UTC(start.y, start.m - 1, start.d, 12);
|
||
while (cursor <= to.getTime() + 86_400_000) {
|
||
const dt = new Date(cursor);
|
||
const y = dt.getUTCFullYear();
|
||
const m = dt.getUTCMonth() + 1;
|
||
const d = dt.getUTCDate();
|
||
const isoDay = ((dt.getUTCDay() + 6) % 7) + 1;
|
||
if (net.days.includes(isoDay)) {
|
||
for (const t of net.times) {
|
||
const [h, mi] = hm(t);
|
||
const at = fromZoned(y, m, d, h, mi);
|
||
if (at > from && at <= to) out.push(at);
|
||
}
|
||
}
|
||
cursor += 86_400_000;
|
||
}
|
||
return out.sort((a, b) => a.getTime() - b.getTime());
|
||
}
|
||
|
||
export const nextOccurrence = (net: NetSchedule, now: Date) =>
|
||
occurrences(net, now, new Date(now.getTime() + 8 * 86_400_000))[0] ?? null;
|
||
|
||
const timeFmt = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: TZ,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hourCycle: 'h23'
|
||
});
|
||
const dayFmt = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: TZ,
|
||
weekday: 'short',
|
||
day: 'numeric',
|
||
month: 'short'
|
||
});
|
||
export const fmtTime = (at: Date | string | number) => timeFmt.format(new Date(at));
|
||
export const fmtDay = (at: Date | string | number) => dayFmt.format(new Date(at));
|
||
/** Amsterdam calendar date "2026-10-26" (for grouping). */
|
||
export const dayKey = (at: Date | string | number) => {
|
||
const z = zoned(new Date(at));
|
||
return `${z.y}-${String(z.m).padStart(2, '0')}-${String(z.d).padStart(2, '0')}`;
|
||
};
|
||
/** "today", "tomorrow" or "Wed 28 Oct". */
|
||
export function relDay(at: Date | string | number, now: Date | number = Date.now()) {
|
||
const k = dayKey(at);
|
||
if (k === dayKey(now)) return 'today';
|
||
if (k === dayKey(new Date(now).getTime() + 86_400_000)) return 'tomorrow';
|
||
return fmtDay(at);
|
||
}
|
||
/** "in 5 min", "in 2 h 10 min", "now". */
|
||
export function until(at: Date | string | number, now: Date | number = Date.now()) {
|
||
const mins = Math.round((new Date(at).getTime() - new Date(now).getTime()) / 60_000);
|
||
if (mins <= 0) return 'now';
|
||
if (mins < 60) return `in ${mins} min`;
|
||
const h = Math.floor(mins / 60);
|
||
if (h < 24) return `in ${h} h${mins % 60 ? ` ${mins % 60} min` : ''}`;
|
||
const d = Math.round(h / 24);
|
||
return `in ${d} day${d === 1 ? '' : 's'}`;
|
||
}
|
||
|
||
/** Days as text: [1,2,3,4,5] -> "weekdays", all 7 -> "daily", else "mon, wed". */
|
||
export function daysLabel(days: number[]) {
|
||
const s = [...new Set(days)].sort();
|
||
if (s.length === 7) return 'daily';
|
||
if (s.join() === '1,2,3,4,5') return 'weekdays';
|
||
if (s.join() === '6,7') return 'weekends';
|
||
return s.map((d) => DAY_SHORT[d - 1]).join(', ');
|
||
}
|
||
|
||
// ---- scanner CSV ------------------------------------------------------------------------------
|
||
|
||
export type ChannelRow = {
|
||
channel: number;
|
||
alphaTag: string | null;
|
||
freqHz: number | null;
|
||
mode: string | null;
|
||
delay: boolean;
|
||
lockout: boolean;
|
||
priority: boolean;
|
||
};
|
||
|
||
const csvCell = (v: string | number) => {
|
||
const s = String(v);
|
||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||
};
|
||
|
||
/**
|
||
* All 500 channels as CSV in the column order of Uniden's BC125AT/UBC125XLT programming software
|
||
* (BC125AT_SS: Channel, Name, Frequency, Modulation, CTCSS/DCS, Delay, Lockout, Priority) plus a
|
||
* leading Bank column. Empty channels are blank rows so the file always maps 1:1 to memories.
|
||
*/
|
||
export function scannerCsv(rows: ChannelRow[]): string {
|
||
const by = new Map(rows.map((r) => [r.channel, r]));
|
||
const lines = [
|
||
[
|
||
'Bank',
|
||
'Channel',
|
||
'Name',
|
||
'Frequency',
|
||
'Modulation',
|
||
'CTCSS/DCS',
|
||
'Delay',
|
||
'Lockout',
|
||
'Priority'
|
||
]
|
||
.map(csvCell)
|
||
.join(',')
|
||
];
|
||
for (let ch = 1; ch <= CHANNELS; ch++) {
|
||
const r = by.get(ch);
|
||
const has = r && r.freqHz != null;
|
||
lines.push(
|
||
[
|
||
bankOf(ch),
|
||
ch,
|
||
has ? (r.alphaTag ?? '').slice(0, ALPHA_TAG_MAX) : '',
|
||
has ? mhz(r.freqHz!) : '',
|
||
has ? (SCANNER_MODES.has(r.mode ?? '') ? r.mode! : 'AUTO') : 'AUTO',
|
||
'OFF',
|
||
has ? (r.delay ? 2 : 0) : 2,
|
||
has ? (r.lockout ? 'ON' : 'OFF') : 'OFF',
|
||
has ? (r.priority ? 'ON' : 'OFF') : 'OFF'
|
||
]
|
||
.map(csvCell)
|
||
.join(',')
|
||
);
|
||
}
|
||
return lines.join('\r\n') + '\r\n';
|
||
}
|
||
|
||
/** Short scanner tag from a station name (abbreviations, then cut to 16 characters). */
|
||
export function alphaTag(name: string, marineCh?: string | null): string {
|
||
const ABBR: [RegExp, string][] = [
|
||
[/\bSchiphol\b/gi, 'SPL'],
|
||
[/\bRotterdam\b/gi, 'RTM'],
|
||
[/\bScheveningen\b/gi, 'SCHEV'],
|
||
[/\bMaastricht\b/gi, 'MUAC'],
|
||
[/\bBrussel\b/gi, 'BRU'],
|
||
[/\bLondon\b/gi, 'LON'],
|
||
[/\bNieuw Milligen\b/gi, 'MIL'],
|
||
[/\bDordrecht\b/gi, 'DORD'],
|
||
[/\bKustwacht\b/gi, 'KWC'],
|
||
[/\bTower\b|\bToren\b/gi, 'TWR'],
|
||
[/\bApproach\b/gi, 'APP'],
|
||
[/\bArrival\b/gi, 'ARR'],
|
||
[/\bDeparture\b/gi, 'DEP'],
|
||
[/\bRadar\b/gi, 'RDR'],
|
||
[/\bOperations\b/gi, 'OPS'],
|
||
[/\bEurocontrol\b/gi, ''],
|
||
[/\bArea Control\b/gi, 'AC'],
|
||
[/\bSector\b/gi, ''],
|
||
[/\bBaan\b/gi, ''],
|
||
[/\bZweefvliegtuigen\b/gi, 'Gliders'],
|
||
[/\bInternational(e)?\b|\bInternationaal\b/gi, 'Intl'],
|
||
[/\bEmergency Frequency\b/gi, 'Emergency'],
|
||
[/\bReddings\s*Brigade\b/gi, 'RB'],
|
||
[/\bSluizen\b/gi, ''],
|
||
[/\bVolkeraksluizen\b/gi, 'Volkerak'],
|
||
[/\bSleep Dienst\b/gi, 'Tug'],
|
||
[/\bScheepvaartbedrijven\b|\bScheepvaart bedrijven\b/gi, 'Shipping'],
|
||
[/\bRijkswaterstaat\b/gi, 'RWS'],
|
||
[/\bLandelijk Scheepsportofoon\b/gi, 'Ship UHF'],
|
||
[/\bKanaal\b/gi, 'ch'],
|
||
[/\bSchip\b/gi, 'S'],
|
||
[/\bWal\b/gi, 'W']
|
||
];
|
||
let s = name;
|
||
for (const [re, to] of ABBR) s = s.replace(re, to);
|
||
s = s
|
||
.replace(/\([^)]*\)/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
if (marineCh) s = `${marineCh} ${s}`;
|
||
return s.slice(0, ALPHA_TAG_MAX).trim();
|
||
}
|
||
|
||
/** Whether an instant falls in the quiet window (Amsterdam wall clock; the window may wrap midnight). */
|
||
export function inQuiet(at: Date, quiet: Quiet): boolean {
|
||
if (!quiet) return false;
|
||
const z = zoned(at);
|
||
const t = z.h * 60 + z.min;
|
||
return quiet.from < quiet.to ? t >= quiet.from && t < quiet.to : t >= quiet.from || t < quiet.to;
|
||
}
|