radio: private radio tracker (/trackers/radio): frequencies, UBC125XLT banks + CSV, nets, ISS passes over Gouda, listening log, ntfy reminders
All checks were successful
Deploy production / deploy (push) Successful in 1m26s

This commit is contained in:
2026-10-26 16:58:29 +00:00
parent e24ee7f95d
commit 03574a95a7
37 changed files with 48839 additions and 3 deletions

368
src/lib/server/radio.ts Normal file
View File

@@ -0,0 +1,368 @@
// Radio tracker (/trackers/radio, owner-only): loads everything for the page and handles the
// owner's edits. Hand-written SQL on the shared postgres.js client; bigint Hz come back as
// float8 so they are plain numbers.
import { client } from '#lib/server/db/index.js';
import {
ALPHA_TAG_MAX,
BANDS,
CHANNELS,
MODES,
alphaTag,
fromZoned,
parseFreq,
type ChannelRow
} from '#lib/radio/index.js';
export type Frequency = {
id: number;
freqHz: number;
mode: string;
band: string;
name: string;
location: string | null;
notes: string | null;
marineCh: string | null;
tags: string[];
channels: number[];
logCount: number;
};
export type Channel = {
channel: number;
frequencyId: number;
alphaTag: string | null;
tag: string;
delay: boolean;
lockout: boolean;
priority: boolean;
};
export type Net = {
id: number;
name: string;
frequencyId: number | null;
days: number[];
times: string[];
durationMin: number | null;
kind: string;
notes: string | null;
remind: boolean;
active: boolean;
};
export type IssPass = {
startAt: string;
maxAt: string;
endAt: string;
maxEl: number;
startAz: number;
maxAz: number;
endAz: number;
sunlit: boolean;
};
export type LogEntry = {
id: number;
heardAt: string;
frequencyId: number | null;
freqHz: number | null;
mode: string | null;
what: string;
signal: number | null;
notes: string | null;
};
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
export async function loadRadio() {
const [freqs, chans, nets, passes, [tle], log, [logTotal]] = await Promise.all([
client`SELECT f.id, f.freq_hz::float8 AS freq_hz, f.mode, f.band, f.name, f.location, f.notes,
f.marine_ch, f.tags,
coalesce((SELECT array_agg(c.channel ORDER BY c.channel) FROM radio.channels c
WHERE c.frequency_id = f.id), '{}') AS channels,
(SELECT count(*)::int FROM radio.log l WHERE l.frequency_id = f.id) AS log_count
FROM radio.frequencies f ORDER BY f.freq_hz, f.name`,
client`SELECT channel, frequency_id, alpha_tag, delay, lockout, priority
FROM radio.channels ORDER BY channel`,
client`SELECT id, name, frequency_id, days, times, duration_min, kind, notes, remind, active
FROM radio.nets ORDER BY sort_order, id`,
client`SELECT start_at, max_at, end_at, max_el, start_az, max_az, end_az, sunlit
FROM radio.iss_passes WHERE end_at > now() ORDER BY start_at LIMIT 60`,
client`SELECT name, epoch, fetched_at FROM radio.iss_tle WHERE id = 1`,
client`SELECT id, heard_at, frequency_id, freq_hz::float8 AS freq_hz, mode, what, signal, notes
FROM radio.log ORDER BY heard_at DESC, id DESC LIMIT 300`,
client`SELECT count(*)::int AS n FROM radio.log`
]);
const frequencies: Frequency[] = freqs.map((f) => ({
id: f.id,
freqHz: f.freq_hz,
mode: f.mode,
band: f.band,
name: f.name,
location: f.location,
notes: f.notes,
marineCh: f.marine_ch,
tags: f.tags ?? [],
channels: (f.channels ?? []).map(Number),
logCount: f.log_count
}));
const byId = new Map(frequencies.map((f) => [f.id, f]));
const channels: Channel[] = chans.map((c) => {
const f = byId.get(c.frequency_id);
return {
channel: c.channel,
frequencyId: c.frequency_id,
alphaTag: c.alpha_tag,
tag: c.alpha_tag ?? (f ? alphaTag(f.name, f.marineCh) : ''),
delay: c.delay,
lockout: c.lockout,
priority: c.priority
};
});
return {
frequencies,
channels,
nets: nets.map((n): Net => ({
id: n.id,
name: n.name,
frequencyId: n.frequency_id,
days: (n.days ?? []).map(Number),
times: n.times ?? [],
durationMin: n.duration_min,
kind: n.kind,
notes: n.notes,
remind: n.remind,
active: n.active
})),
passes: passes.map((p): IssPass => ({
startAt: iso(p.start_at)!,
maxAt: iso(p.max_at)!,
endAt: iso(p.end_at)!,
maxEl: p.max_el,
startAz: p.start_az,
maxAz: p.max_az,
endAz: p.end_az,
sunlit: p.sunlit
})),
tle: tle ? { name: tle.name, epoch: iso(tle.epoch), fetchedAt: iso(tle.fetched_at) } : null,
log: log.map((l): LogEntry => ({
id: l.id,
heardAt: iso(l.heard_at)!,
frequencyId: l.frequency_id,
freqHz: l.freq_hz,
mode: l.mode,
what: l.what,
signal: l.signal,
notes: l.notes
})),
logTotal: logTotal.n as number
};
}
/** All channels with their frequency, for the CSV export. */
export async function channelRows(): Promise<ChannelRow[]> {
const rows = await client`SELECT c.channel, c.alpha_tag, c.delay, c.lockout, c.priority,
f.freq_hz::float8 AS freq_hz, f.mode, f.name, f.marine_ch
FROM radio.channels c JOIN radio.frequencies f ON f.id = c.frequency_id ORDER BY c.channel`;
return rows.map((r) => ({
channel: r.channel,
alphaTag: r.alpha_tag ?? alphaTag(r.name, r.marine_ch),
freqHz: r.freq_hz,
mode: r.mode,
delay: r.delay,
lockout: r.lockout,
priority: r.priority
}));
}
// ---- actions ----------------------------------------------------------------------------------
type Result = { ok: true; message?: string } | { ok: false; message: string };
const str = (form: FormData, k: string) => {
const v = form.get(k);
return typeof v === 'string' && v.trim() ? v.trim() : null;
};
const int = (form: FormData, k: string) => {
const v = Number(str(form, k));
return Number.isInteger(v) && v > 0 ? v : null;
};
const tagsOf = (s: string | null) =>
s
? [
...new Set(
s
.split(/[,\s]+/)
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
)
]
: [];
export async function saveFrequency(form: FormData): Promise<Result> {
const id = int(form, 'id');
const freqHz = parseFreq(str(form, 'freq'));
if (!freqHz) return { ok: false, message: 'Frequency not understood (try 145.450 or 3785 kHz).' };
const name = str(form, 'name');
if (!name) return { ok: false, message: 'A name is needed.' };
const mode = (str(form, 'mode') ?? 'FM').toUpperCase();
if (!(MODES as readonly string[]).includes(mode)) return { ok: false, message: 'Unknown mode.' };
const band = str(form, 'band') ?? 'other';
if (!(BANDS as readonly string[]).includes(band)) return { ok: false, message: 'Unknown band.' };
const v = {
freq_hz: freqHz,
mode,
band,
name,
location: str(form, 'location'),
notes: str(form, 'notes'),
marine_ch: str(form, 'marine_ch'),
tags: tagsOf(str(form, 'tags'))
};
try {
if (id) {
await client`UPDATE radio.frequencies SET ${client(v)}, updated_at = now() WHERE id = ${id}`;
} else {
await client`INSERT INTO radio.frequencies ${client({ ...v, source: 'Stan' })}`;
}
} catch (e) {
if ((e as { code?: string }).code === '23505')
return { ok: false, message: 'That frequency with that name already exists.' };
throw e;
}
return { ok: true };
}
export async function deleteFrequency(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`DELETE FROM radio.frequencies WHERE id = ${id}`;
return { ok: true };
}
export async function setChannel(form: FormData): Promise<Result> {
const channel = int(form, 'channel');
if (!channel || channel > CHANNELS) return { ok: false, message: 'Channel must be 1-500.' };
if (form.get('clear') === '1') {
await client`DELETE FROM radio.channels WHERE channel = ${channel}`;
return { ok: true };
}
const frequencyId = int(form, 'frequency_id');
if (!frequencyId) return { ok: false, message: 'Pick a frequency.' };
const tag = str(form, 'alpha_tag');
const v = {
channel,
frequency_id: frequencyId,
alpha_tag: tag ? tag.slice(0, ALPHA_TAG_MAX) : null,
delay: form.get('delay') === 'on',
lockout: form.get('lockout') === 'on',
priority: form.get('priority') === 'on'
};
await client`INSERT INTO radio.channels ${client(v)}
ON CONFLICT (channel) DO UPDATE SET frequency_id = EXCLUDED.frequency_id,
alpha_tag = EXCLUDED.alpha_tag, delay = EXCLUDED.delay, lockout = EXCLUDED.lockout,
priority = EXCLUDED.priority, updated_at = now()`;
return { ok: true };
}
const TIME = /^([01]?\d|2[0-3])[:.]?([0-5]\d)$/;
export async function saveNet(form: FormData): Promise<Result> {
const id = int(form, 'id');
const name = str(form, 'name');
if (!name) return { ok: false, message: 'A name is needed.' };
const days = form
.getAll('days')
.map(Number)
.filter((d) => d >= 1 && d <= 7);
const times: string[] = [];
for (const t of (str(form, 'times') ?? '').split(/[,\s]+/).filter(Boolean)) {
const m = t.match(TIME);
if (!m) return { ok: false, message: `Time "${t}" not understood (use 11:00).` };
times.push(`${m[1].padStart(2, '0')}:${m[2]}`);
}
const kind = str(form, 'kind') ?? 'net';
const v = {
name,
frequency_id: int(form, 'frequency_id'),
days: [...new Set(days)].sort(),
times: [...new Set(times)].sort(),
duration_min: int(form, 'duration_min'),
kind: ['net', 'broadcast', 'other'].includes(kind) ? kind : 'net',
notes: str(form, 'notes'),
remind: form.get('remind') === 'on',
active: form.get('active') !== 'off'
};
if (id) await client`UPDATE radio.nets SET ${client(v)}, updated_at = now() WHERE id = ${id}`;
else await client`INSERT INTO radio.nets ${client(v)}`;
return { ok: true };
}
export async function toggleRemind(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`UPDATE radio.nets SET remind = NOT remind, updated_at = now() WHERE id = ${id}`;
return { ok: true };
}
export async function deleteNet(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`DELETE FROM radio.nets WHERE id = ${id}`;
return { ok: true };
}
/**
* Quick log: `freq` may be a frequency ("145.45"), a scanner channel ("ch 353"), or empty when a
* frequency_id is picked. A typed frequency links to the known frequency with that value.
*/
export async function addLog(form: FormData): Promise<Result> {
const what = str(form, 'what');
if (!what) return { ok: false, message: 'What did you hear?' };
let frequencyId = int(form, 'frequency_id');
let freqHz: number | null = null;
let mode: string | null = str(form, 'mode');
const typed = str(form, 'freq');
if (typed && !frequencyId) {
const ch = typed.match(/^ch\s*(\d{1,3})$/i);
if (ch) {
const [r] =
await client`SELECT frequency_id FROM radio.channels WHERE channel = ${Number(ch[1])}`;
if (!r) return { ok: false, message: `Channel ${ch[1]} is empty.` };
frequencyId = r.frequency_id;
} else {
freqHz = parseFreq(typed);
if (!freqHz) return { ok: false, message: 'Frequency not understood.' };
const [r] = await client`SELECT id FROM radio.frequencies WHERE freq_hz = ${freqHz}
ORDER BY (SELECT count(*) FROM radio.channels c WHERE c.frequency_id = id) DESC, id LIMIT 1`;
if (r) frequencyId = r.id;
}
}
if (frequencyId) {
const [f] =
await client`SELECT freq_hz::float8 AS hz, mode FROM radio.frequencies WHERE id = ${frequencyId}`;
if (!f) return { ok: false, message: 'Unknown frequency.' };
freqHz = f.hz;
mode ??= f.mode;
}
const signal = int(form, 'signal');
// heard_at: "2026-10-26T18:05" from a datetime-local input is Amsterdam wall time.
const when = str(form, 'heard_at');
let heardAt = new Date();
if (when) {
const m = when.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return { ok: false, message: 'Time not understood.' };
heardAt = fromZoned(+m[1], +m[2], +m[3], +m[4], +m[5]);
}
await client`INSERT INTO radio.log ${client({
heard_at: heardAt,
frequency_id: frequencyId,
freq_hz: freqHz,
mode,
what,
signal: signal && signal <= 5 ? signal : null,
notes: str(form, 'notes')
})}`;
return { ok: true, message: 'Logged.' };
}
export async function deleteLog(form: FormData): Promise<Result> {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await client`DELETE FROM radio.log WHERE id = ${id}`;
return { ok: true };
}