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
All checks were successful
Deploy production / deploy (push) Successful in 1m26s
This commit is contained in:
368
src/lib/server/radio.ts
Normal file
368
src/lib/server/radio.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user