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

View File

@@ -26,6 +26,8 @@ COPY --from=builder /app/src/lib/music ./src/lib/music
COPY --from=builder /app/src/lib/games-ingest ./src/lib/games-ingest
# scripts/ai-*-import.ts (AI tracker news and bot status imports) import this.
COPY --from=builder /app/src/lib/server/ai-import.ts ./src/lib/server/ai-import.ts
# scripts/radio.ts (ISS passes and ntfy reminders) imports the radio helpers.
COPY --from=builder /app/src/lib/radio ./src/lib/radio
COPY --from=builder /app/server.js ./server.js
COPY package.json ./
EXPOSE 3000

View File

@@ -21,6 +21,7 @@
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9",
"satellite.js": "^7.1.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4",
@@ -939,6 +940,8 @@
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"satellite.js": ["satellite.js@7.1.0", "", {}, "sha512-U6nRml9Nb7dV9LJPiMNPyna7U7ry+1nXYkOeCEG7K/YbojQQLHHmcjPisp03VNY+HLcbQHqbt7t4t1vQMaKCLQ=="],
"semver": ["semver@7.8.5", "", { "bin": "bin/semver.js" }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Radio tracker: refresh ISS TLE and passes over Gouda
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo \"== $$(date -Is) iss\" >> %h/.local/state/radio.log; exec /usr/bin/sudo -n docker exec --env-file %h/.config/radio.env personal-website-app-1 bun scripts/radio.ts iss >> %h/.local/state/radio.log 2>&1"

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Daily ISS pass refresh for the radio tracker
[Timer]
OnCalendar=*-*-* 04:15:00 Europe/Amsterdam
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Radio tracker: ntfy reminders 10 min before nets and ISS passes
[Service]
Type=oneshot
ExecStart=/bin/sh -c "exec /usr/bin/sudo -n docker exec --env-file %h/.config/radio.env personal-website-app-1 bun scripts/radio.ts remind >> %h/.local/state/radio.log 2>&1"

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Radio tracker reminders, every minute
[Timer]
OnCalendar=*:*:00
AccuracySec=5s
[Install]
WantedBy=timers.target

23
docs/radio-tracker.md Normal file
View File

@@ -0,0 +1,23 @@
# Radio tracker (/trackers/radio)
Private (owner-only, 404 for everyone else) tracker for Opa Chiel's scanner hobby.
- **Frequencies** (`radio.frequencies`): freq in Hz, mode, band (marine/air/ham/pmr/other), name,
location, notes, marine channel, tags. Seeded from Opa's lists (airband banks 4-6, marine banks
7-10) and the extras in his notes (Haagse ronde, KLM ronde, ISS downlink, ADS-B).
- **Scanner banks** (`radio.channels`): Uniden UBC125XLT layout, 10 banks x 50 channels.
`/trackers/radio/banks.csv` exports all 500 channels in BC125AT_SS column order.
- **Nets** (`radio.nets`): days (1 = Mon .. 7 = Sun) and "HH:MM" times in Europe/Amsterdam.
- **ISS passes** (`radio.iss_passes`, `radio.iss_tle`): computed in `scripts/radio.ts iss` with
satellite.js from the Celestrak TLE (CATNR 25544) for Gouda (52.011 N, 4.711 E), 5 days ahead.
- **Log** (`radio.log`): what was heard, linked to a frequency.
## Jobs (systemd user timers on stan-ovh, copies in deploy/systemd)
- `radio-iss.timer`: daily 04:15 Amsterdam, `bun scripts/radio.ts iss` in the app container.
- `radio-remind.timer`: every minute, `bun scripts/radio.ts remind`. Sends an ntfy push
10 minutes before each net with `remind` on and each ISS pass with max elevation >= 10 deg.
`radio.reminders` makes each reminder fire once.
- Both read `~/.config/radio.env` (mode 600): `RADIO_NTFY_URL` (ntfy topic URL), optional
`RADIO_NTFY_TOKEN`. Log: `~/.local/state/radio.log`.
- `bun scripts/radio.ts upcoming` prints the next 24 h of reminders without sending.

View File

@@ -37,7 +37,8 @@ export default defineConfig({
'worldtodo',
'notes',
'math',
'plant'
'plant',
'radio'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -117,6 +117,7 @@
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^6.4.299",
"postgres": "^3.4.9",
"satellite.js": "^7.1.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"zod": "^4.5.4"

View File

@@ -342,6 +342,32 @@ try {
throw new Error(`plant schema tables/grants are wrong: ${JSON.stringify(plant)}`);
const [{ n: plantEntries }] = await client`select count(*)::int as n from plant.entries`;
if (!plantEntries) throw new Error('plant.entries is empty, expected the seeded factory guide');
// Radio tracker (owner-only): 7 = frequencies, channels, nets, iss_passes, iss_tle, log,
// reminders; the migration seeds Opa's frequencies.
const RADIO_TABLES = 7;
const [radio] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'radio', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'radio' and c.relkind = 'r'
`;
if (
Number(radio.tables) !== RADIO_TABLES ||
Number(radio.app_rw) !== RADIO_TABLES ||
Number(radio.ro_read) !== 0 ||
radio.public_usage
)
throw new Error(`radio schema tables/grants are wrong: ${JSON.stringify(radio)}`);
const [{ n: radioFreqs }] = await client`select count(*)::int as n from radio.frequencies`;
if (!radioFreqs) throw new Error('radio.frequencies is empty, expected the seeded frequencies');
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -351,7 +377,7 @@ try {
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), radio: ${radio.tables} tables (${radioFreqs} frequencies), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select

263
scripts/radio.ts Normal file
View File

@@ -0,0 +1,263 @@
/**
* Radio tracker jobs (/trackers/radio). Runs inside the app container like cs-prices:
*
* bun scripts/radio.ts iss fetch the ISS TLE from Celestrak, compute the next 5 days of
* passes over Gouda into radio.iss_passes (daily timer)
* bun scripts/radio.ts remind ntfy reminder 10 minutes before each net (remind = true) and
* each ISS pass with max elevation >= 10 deg (every-minute timer)
* bun scripts/radio.ts upcoming print what the next 24 h of reminders would be (no sending)
*
* Reminders go to RADIO_NTFY_URL (an ntfy topic URL, optional RADIO_NTFY_TOKEN), passed in with
* `docker exec --env-file ~/.config/radio.env`. radio.reminders makes every reminder fire once.
*/
import postgres from 'postgres';
import * as sat from 'satellite.js';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
REMIND_MINUTES,
compass,
fmtTime,
formatFreq,
occurrences
} from '../src/lib/radio/index';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
const mode = process.argv[2] ?? 'remind';
const PAGE = 'https://stanrunge.dev/trackers/radio';
// Gouda (approx), 0 m.
const OBSERVER = {
latitude: sat.degreesToRadians(52.011),
longitude: sat.degreesToRadians(4.711),
height: 0
};
const TLE_URL = 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle';
const DAYS = 5;
const STEP_S = 10;
const KM_PER_AU = 149_597_870.7;
async function fetchTle() {
const res = await fetch(TLE_URL, {
headers: { 'User-Agent': 'stanrunge.dev radio-tracker/1.0 (daily ISS passes)' },
signal: AbortSignal.timeout(20_000)
});
if (!res.ok) throw new Error(`Celestrak ${res.status}`);
const lines = (await res.text())
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter(Boolean);
const i = lines.findIndex((l) => l.startsWith('1 25544'));
if (i < 0 || !lines[i + 1]?.startsWith('2 25544')) throw new Error('No ISS TLE in the response');
return {
name: (i > 0 ? lines[i - 1] : 'ISS (ZARYA)').trim(),
line1: lines[i],
line2: lines[i + 1]
};
}
function look(satrec: sat.SatRec, at: Date) {
const pv = sat.propagate(satrec, at);
if (!pv || typeof pv.position === 'boolean' || !pv.position) return null;
const gmst = sat.gstime(at);
const la = sat.ecfToLookAngles(OBSERVER, sat.eciToEcf(pv.position, gmst));
return {
el: sat.radiansToDegrees(la.elevation),
az: sat.radiansToDegrees(la.azimuth),
eci: pv.position,
gmst
};
}
/** ISS in sunlight while the sun is >= 6 deg below the observer's horizon (naked-eye visible). */
function sunlitInDarkSky(at: Date, eci: sat.EciVec3<number>, gmst: number) {
const jd = sat.jday(at);
const { rsun } = sat.sunPos(jd);
const sunKm = { x: rsun.x * KM_PER_AU, y: rsun.y * KM_PER_AU, z: rsun.z * KM_PER_AU };
const sunEl = sat.radiansToDegrees(
sat.ecfToLookAngles(OBSERVER, sat.eciToEcf(sunKm, gmst)).elevation
);
return sunEl < -6 && sat.shadowFraction(rsun, eci) < 0.5;
}
type Pass = {
start_at: Date;
max_at: Date;
end_at: Date;
max_el: number;
start_az: number;
max_az: number;
end_az: number;
sunlit: boolean;
};
function passes(satrec: sat.SatRec, from: Date, days: number): Pass[] {
const out: Pass[] = [];
const end = from.getTime() + days * 86_400_000;
let cur: Pass | null = null;
let prevEl = -90;
for (let t = from.getTime(); t <= end; t += STEP_S * 1000) {
const at = new Date(t);
const l = look(satrec, at);
if (!l) continue;
if (l.el >= 0 && prevEl < 0) {
cur = {
start_at: at,
max_at: at,
end_at: at,
max_el: l.el,
start_az: l.az,
max_az: l.az,
end_az: l.az,
sunlit: false
};
}
if (cur && l.el >= 0) {
if (l.el > cur.max_el) Object.assign(cur, { max_el: l.el, max_at: at, max_az: l.az });
if (!cur.sunlit && l.el >= ISS_MIN_EL && sunlitInDarkSky(at, l.eci, l.gmst))
cur.sunlit = true;
cur.end_at = at;
cur.end_az = l.az;
}
if (cur && l.el < 0) {
// Skip a pass already in progress at the start of the window.
if (cur.start_at.getTime() > from.getTime()) out.push(cur);
cur = null;
}
prevEl = l.el;
}
return out;
}
async function refreshIss() {
const tle = await fetchTle();
const satrec = sat.twoline2satrec(tle.line1, tle.line2);
const epoch = new Date((satrec.jdsatepoch - 2440587.5) * 86_400_000);
const now = new Date();
const list = passes(satrec, now, DAYS).filter((p) => p.max_el >= 1);
await sql.begin(async (tx) => {
await tx`INSERT INTO radio.iss_tle (id, name, line1, line2, epoch, fetched_at)
VALUES (1, ${tle.name}, ${tle.line1}, ${tle.line2}, ${epoch}, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, line1 = EXCLUDED.line1,
line2 = EXCLUDED.line2, epoch = EXCLUDED.epoch, fetched_at = now()`;
// Future passes are replaced by the fresh prediction; past ones are kept a week.
await tx`DELETE FROM radio.iss_passes WHERE start_at > now() OR end_at < now() - interval '7 days'`;
if (list.length)
await tx`INSERT INTO radio.iss_passes ${tx(list)} ON CONFLICT (start_at) DO NOTHING`;
});
const good = list.filter((p) => p.max_el >= ISS_MIN_EL).length;
console.log(
`${now.toISOString()} iss: TLE epoch ${epoch.toISOString()}, ${list.length} passes in ${DAYS} days (${good} >= ${ISS_MIN_EL} deg)`
);
}
async function ntfy(title: string, message: string, tags: string[]) {
const target = process.env.RADIO_NTFY_URL;
if (!target) {
console.log(`no RADIO_NTFY_URL; would send: ${title} | ${message}`);
return false;
}
const url = new URL(target);
const res = await fetch(url.origin, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(process.env.RADIO_NTFY_TOKEN
? { authorization: `Bearer ${process.env.RADIO_NTFY_TOKEN}` }
: {})
},
body: JSON.stringify({
topic: url.pathname.replace(/^\/+|\/+$/g, ''),
title,
message,
click: PAGE,
tags,
priority: 3
}),
signal: AbortSignal.timeout(10_000)
}).catch(() => null);
return Boolean(res?.ok);
}
type Due = {
kind: 'net' | 'iss';
refId: number;
at: Date;
title: string;
message: string;
tags: string[];
};
async function due(from: Date, to: Date): Promise<Due[]> {
const out: Due[] = [];
const nets = await sql`SELECT n.id, n.name, n.days, n.times, n.active, n.notes,
f.freq_hz::float8 AS hz, f.mode, f.marine_ch
FROM radio.nets n LEFT JOIN radio.frequencies f ON f.id = n.frequency_id
WHERE n.remind AND n.active`;
for (const n of nets) {
for (const at of occurrences(
{ id: n.id, days: n.days.map(Number), times: n.times },
from,
to
)) {
const freq = n.hz
? `${formatFreq(n.hz)} ${n.mode}${n.marine_ch ? ` (marine ch ${n.marine_ch})` : ''}`
: '';
out.push({
kind: 'net',
refId: n.id,
at,
title: `${n.name} at ${fmtTime(at)}`,
message: [`Starts in ${REMIND_MINUTES} min`, freq].filter(Boolean).join(' · '),
tags: ['radio']
});
}
}
const iss = await sql`SELECT start_at, max_at, end_at, max_el, start_az, max_az, end_az, sunlit
FROM radio.iss_passes WHERE start_at > ${from} AND start_at <= ${to} AND max_el >= ${ISS_MIN_EL}`;
for (const p of iss) {
const mins = Math.round((p.end_at.getTime() - p.start_at.getTime()) / 60_000);
out.push({
kind: 'iss',
refId: 0,
at: p.start_at,
title: `ISS pass at ${fmtTime(p.start_at)}, max ${Math.round(p.max_el)}°`,
message:
`${fmtTime(p.start_at)} ${compass(p.start_az)} → max ${Math.round(p.max_el)}° ${compass(p.max_az)} at ${fmtTime(p.max_at)} → ${compass(p.end_az)} ${fmtTime(p.end_at)} (${mins} min)` +
` · listen on ${formatFreq(ISS_DOWNLINK_HZ)} NFM` +
(p.sunlit ? ' · visible to the eye' : ''),
tags: ['satellite']
});
}
return out.sort((a, b) => a.at.getTime() - b.at.getTime());
}
async function remind() {
const now = new Date();
const list = await due(now, new Date(now.getTime() + REMIND_MINUTES * 60_000));
for (const r of list) {
const [claimed] = await sql`INSERT INTO radio.reminders (kind, ref_id, at)
VALUES (${r.kind}, ${r.refId}, ${r.at}) ON CONFLICT DO NOTHING RETURNING at`;
if (!claimed) continue;
const ok = await ntfy(r.title, r.message, r.tags);
if (!ok) {
// Let the next minute's run try again (it stops once the event has started).
await sql`DELETE FROM radio.reminders WHERE kind = ${r.kind} AND ref_id = ${r.refId} AND at = ${r.at}`;
console.log(`${now.toISOString()} remind FAILED: ${r.title}`);
} else console.log(`${now.toISOString()} reminded: ${r.title} | ${r.message}`);
}
await sql`DELETE FROM radio.reminders WHERE at < now() - interval '30 days'`;
}
try {
if (mode === 'iss') await refreshIss();
else if (mode === 'remind') await remind();
else if (mode === 'upcoming') {
const now = new Date();
for (const r of await due(now, new Date(now.getTime() + 86_400_000)))
console.log(`${r.at.toISOString()} ${r.kind}: ${r.title} | ${r.message}`);
} else throw new Error(`unknown mode ${mode} (iss | remind | upcoming)`);
} finally {
await sql.end();
}

View File

@@ -394,6 +394,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false,
private: true
},
{
name: 'radio',
subtitle: 'scanner frequencies, banks, nets, ISS passes and my listening log',
href: '/trackers/radio',
external: false,
private: true
},
{
name: 'math',
subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes",

View File

@@ -0,0 +1,40 @@
<script lang="ts">
// A small gradient tile per band (colourful icons on a black-and-white page).
import { BAND_COLOR, BAND_LABEL, type Band } from './index.js';
import Ship from 'lucide-svelte/icons/ship';
import Plane from 'lucide-svelte/icons/plane';
import RadioTower from 'lucide-svelte/icons/radio-tower';
import Radio from 'lucide-svelte/icons/radio';
import Antenna from 'lucide-svelte/icons/antenna';
let { band, size = 22 }: { band: string; size?: number } = $props();
const ICONS = { marine: Ship, air: Plane, ham: RadioTower, pmr: Radio, other: Antenna } as const;
const b = $derived((band in ICONS ? band : 'other') as Band);
const Icon = $derived(ICONS[b]);
const colors = $derived(BAND_COLOR[b]);
</script>
<span
class="band-icon"
style:--from={colors[0]}
style:--to={colors[1]}
style:--size="{size}px"
title={BAND_LABEL[b]}
aria-hidden="true"><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
>
<style>
.band-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--size);
height: var(--size);
border-radius: calc(var(--size) * 0.3);
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
}
</style>

285
src/lib/radio/Banks.svelte Normal file
View File

@@ -0,0 +1,285 @@
<script lang="ts">
// Uniden UBC125XLT memory: 10 banks x 50 channels. Pick a bank, tap a channel to edit it.
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Download from 'lucide-svelte/icons/download';
import X from 'lucide-svelte/icons/x';
import {
ALPHA_TAG_MAX,
BANDS,
BAND_LABEL,
BANKS,
PER_BANK,
bankOf,
bankRange,
formatFreq
} from './index.js';
import type { Channel, Frequency } from '#lib/server/radio.js';
let { channels, frequencies }: { channels: Channel[]; frequencies: Frequency[] } = $props();
const initialCh = Number(page.url.searchParams.get('ch')) || null;
let bank = $state(initialCh ? bankOf(initialCh) : Number(page.url.searchParams.get('bank')) || 4);
let selected = $state<number | null>(initialCh);
let error = $state<string | null>(null);
const byCh = $derived(new Map(channels.map((c) => [c.channel, c])));
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const perBank = $derived.by(() => {
const n = Array.from({ length: BANKS }, () => 0);
for (const c of channels) n[bankOf(c.channel) - 1]++;
return n;
});
const slots = $derived.by(() => {
const [a, b] = bankRange(bank);
return Array.from({ length: b - a + 1 }, (_, i) => a + i);
});
const sel = $derived(selected ? byCh.get(selected) : undefined);
const grouped = $derived(
BANDS.map((b) => ({ band: b, list: frequencies.filter((f) => f.band === b) })).filter(
(g) => g.list.length
)
);
</script>
<section class="card">
<div class="bar">
<div class="banks" role="tablist" aria-label="Scanner banks">
{#each perBank as n, i (i)}
<button
type="button"
role="tab"
aria-selected={bank === i + 1}
class={['bank', { active: bank === i + 1, empty: !n }]}
onclick={() => {
bank = i + 1;
selected = null;
}}><b>{i + 1}</b><span>{n}/{PER_BANK}</span></button
>
{/each}
</div>
<a class="ghost" href={resolve('/trackers/radio/banks.csv')} download data-sveltekit-reload
><Download size={14} />CSV</a
>
</div>
<p class="meta hint">
Bank {bank}: channels {bankRange(bank)[0]}–{bankRange(bank)[1]} · {channels.length} of 500 programmed
· Uniden UBC125XLT layout (model unconfirmed)
</p>
<div class="grid">
{#each slots as ch (ch)}
{@const c = byCh.get(ch)}
{@const f = c ? byId.get(c.frequencyId) : undefined}
<button
type="button"
class={['slot', { filled: !!f, sel: selected === ch, locked: c?.lockout }]}
onclick={() => (selected = selected === ch ? null : ch)}
aria-pressed={selected === ch}
>
<span class="no">{ch}</span>
{#if f && c}
<BandIcon band={f.band} size={16} />
<span class="tg">{c.tag}</span>
<span class="fq">{formatFreq(f.freqHz, false)} <i>{f.mode}</i></span>
{:else}
<span class="tg dim">empty</span>
{/if}
</button>
{/each}
</div>
{#if selected}
{@const f = sel ? byId.get(sel.frequencyId) : undefined}
<form
class="edit chedit"
method="POST"
action="?/channel"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update({ reset: false });
}
}}
>
<input type="hidden" name="channel" value={selected} />
<div class="full bar">
<strong>Channel {selected}</strong>
<button type="button" class="icon" aria-label="close" onclick={() => (selected = null)}
><X size={15} /></button
>
</div>
{#key selected}
<label class="wide"
>frequency<select name="frequency_id" value={sel?.frequencyId ?? ''} required>
<option value="" disabled>pick a frequency…</option>
{#each grouped as g (g.band)}
<optgroup label={BAND_LABEL[g.band]}>
{#each g.list as o (o.id)}<option value={o.id}
>{formatFreq(o.freqHz)} · {o.name}</option
>{/each}
</optgroup>
{/each}
</select></label
>
<label
>alpha tag<input
name="alpha_tag"
maxlength={ALPHA_TAG_MAX}
value={sel?.alphaTag ?? ''}
placeholder={sel?.tag ?? 'auto from name'}
/></label
>
<div class="checks">
<label><input type="checkbox" name="delay" checked={sel?.delay ?? true} />delay</label>
<label
><input type="checkbox" name="lockout" checked={sel?.lockout ?? false} />lockout</label
>
<label
><input
type="checkbox"
name="priority"
checked={sel?.priority ?? false}
/>priority</label
>
</div>
{/key}
{#if f?.notes}<p class="meta full">{f.notes}</p>{/if}
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if sel}<button class="ghost" name="clear" value="1" formnovalidate>clear channel</button
>{/if}
<button class="primary">save</button>
</div>
</form>
{/if}
</section>
<style>
.banks {
display: flex;
gap: 0.3rem;
overflow-x: auto;
scrollbar-width: none;
}
.bank {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 2.8rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
color: var(--site-text);
line-height: 1.1;
}
.bank b {
font-size: 0.95rem;
}
.bank span {
font-size: 0.62rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.bank.empty {
opacity: 0.55;
}
.bank.active {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
opacity: 1;
}
.bank.active span {
color: inherit;
opacity: 0.7;
}
.hint {
margin: 0.5rem 0 0.7rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.3rem;
}
.slot {
display: grid;
grid-template-columns: 1.9rem 16px 1fr;
grid-template-rows: auto auto;
column-gap: 0.4rem;
align-items: center;
padding: 0.35rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
text-align: left;
background: var(--site-bg);
min-width: 0;
font-family: ui-monospace, monospace;
}
.slot:hover {
border-color: var(--site-muted);
}
.slot.sel {
border-color: var(--site-text);
box-shadow: 0 0 0 1px var(--site-text);
}
.slot.locked {
opacity: 0.5;
}
.no {
grid-row: 1 / 3;
font-size: 0.7rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.slot :global(.band-icon) {
grid-row: 1 / 3;
}
.tg {
font-size: 0.74rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
}
.tg.dim {
grid-column: 2 / 4;
color: var(--site-muted);
opacity: 0.6;
text-transform: none;
}
.fq {
grid-column: 3;
font-size: 0.68rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.fq i {
font-style: normal;
opacity: 0.7;
}
.chedit {
margin-top: 0.8rem;
}
.checks {
display: flex;
gap: 0.8rem;
align-items: flex-end;
font-size: 0.8rem;
padding-bottom: 0.4rem;
}
.checks label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
// Add or edit one frequency.
import { enhance } from '$app/forms';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { BANDS, BAND_LABEL, MODES, formatFreq } from './index.js';
import type { Frequency } from '#lib/server/radio.js';
let { freq = null, ondone }: { freq?: Frequency | null; ondone?: () => void } = $props();
let error = $state<string | null>(null);
</script>
<form
class="edit"
method="POST"
action="?/saveFreq"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update({ reset: !freq });
ondone?.();
}
}}
>
{#if freq}<input type="hidden" name="id" value={freq.id} />{/if}
<label class="w-freq"
>frequency<input
name="freq"
required
inputmode="decimal"
value={freq ? formatFreq(freq.freqHz) : ''}
placeholder="145.450"
/></label
>
<label
>mode<select name="mode" value={freq?.mode ?? 'FM'}>
{#each MODES as m (m)}<option value={m}>{m}</option>{/each}
</select></label
>
<label
>band<select name="band" value={freq?.band ?? 'other'}>
{#each BANDS as b (b)}<option value={b}>{BAND_LABEL[b]}</option>{/each}
</select></label
>
<label class="w-ch">marine ch<input name="marine_ch" value={freq?.marineCh ?? ''} /></label>
<label class="wide">name<input name="name" required value={freq?.name ?? ''} /></label>
<label class="w-loc">location<input name="location" value={freq?.location ?? ''} /></label>
<label class="wide"
>tags<input
name="tags"
value={freq?.tags.join(', ') ?? ''}
placeholder="tower, emergency"
/></label
>
<label class="full">notes<textarea name="notes" rows="2">{freq?.notes ?? ''}</textarea></label>
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if freq}
<button
type="submit"
class="icon danger"
formaction="?/deleteFreq"
aria-label="delete frequency"
title="delete (also clears its scanner channels)"
onclick={(e) => {
if (!confirm(`Delete ${freq.name}? Its scanner channels are cleared too.`))
e.preventDefault();
}}><Trash2 size={15} /></button
>
{/if}
{#if ondone}<button type="button" class="ghost" onclick={ondone}>cancel</button>{/if}
<button class="primary">{freq ? 'save' : 'add frequency'}</button>
</div>
</form>

View File

@@ -0,0 +1,241 @@
<script lang="ts">
// Frequencies: search, band filter, inline edit.
import { page } from '$app/state';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import FreqForm from './FreqForm.svelte';
import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import { BANDS, BAND_LABEL, formatFreq, mhz } from './index.js';
import type { Frequency } from '#lib/server/radio.js';
let { frequencies }: { frequencies: Frequency[] } = $props();
let q = $state(page.url.searchParams.get('q') ?? '');
let band = $state<string | null>(page.url.searchParams.get('band'));
let editing = $state<number | null>(null);
let adding = $state(false);
const counts = $derived.by(() => {
const c: Record<string, number> = {};
for (const f of frequencies) c[f.band] = (c[f.band] ?? 0) + 1;
return c;
});
const shown = $derived.by(() => {
const needle = q.trim().toLowerCase();
const num = needle.replace(',', '.').replace(/\s*mhz$/, '');
return frequencies.filter((f) => {
if (band && f.band !== band) return false;
if (!needle) return true;
if (/^\d+(\.\d*)?$/.test(num))
return mhz(f.freqHz).startsWith(num) || String(f.freqHz / 1000).startsWith(num);
if (/^ch\s*\d+$/.test(needle)) {
const n = Number(needle.replace(/\D/g, ''));
return f.channels.includes(n) || f.marineCh === String(n).padStart(2, '0');
}
return [f.name, f.location, f.notes, f.marineCh && `ch ${f.marineCh}`, ...f.tags]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(needle);
});
});
</script>
<section class="card">
<div class="bar">
<label class="search">
<Search size={15} />
<input
type="search"
placeholder="search name, 145.45, ch 16, tag"
aria-label="Search frequencies"
bind:value={q}
/>
</label>
<button type="button" class="primary" onclick={() => (adding = !adding)}
><Plus size={15} />add</button
>
</div>
<div class="chips" role="group" aria-label="Filter by band">
<button type="button" class={['chip', { active: !band }]} onclick={() => (band = null)}
>all <span class="n">{frequencies.length}</span></button
>
{#each BANDS.filter((b) => counts[b]) as b (b)}
<button
type="button"
class={['chip', { active: band === b }]}
onclick={() => (band = band === b ? null : b)}
><BandIcon band={b} size={18} />{BAND_LABEL[b]} <span class="n">{counts[b]}</span></button
>
{/each}
</div>
{#if adding}<FreqForm ondone={() => (adding = false)} />{/if}
<ul class="freqs">
{#each shown as f (f.id)}
<li>
{#if editing === f.id}
<FreqForm freq={f} ondone={() => (editing = null)} />
{:else}
<div class="frow">
<BandIcon band={f.band} size={26} />
<span class="fq"
>{formatFreq(f.freqHz, false)}<small>{f.freqHz < 30e6 ? 'kHz' : 'MHz'}</small></span
>
<span class="md">{f.mode}</span>
<div class="nm">
<span class="name">{f.name}</span>
<span class="sub">
{#if f.marineCh}<span class="tag">ch {f.marineCh}</span>{/if}
{#if f.location}<span>{f.location}</span>{/if}
{#each f.tags as t (t)}<span class="tag">{t}</span>{/each}
{#if f.notes}<span class="note">{f.notes}</span>{/if}
</span>
</div>
<div class="chs">
{#each f.channels as c (c)}<a
class="chan"
href={resolve(`/trackers/radio?tab=banks&ch=${c}` as '/')}
title="scanner channel {c}">{c}</a
>{/each}
{#if f.logCount}<a
class="logged"
href={resolve(`/trackers/radio?tab=log&f=${f.id}` as '/')}
title="log entries">{f.logCount}×</a
>{/if}
</div>
<button
type="button"
class="icon"
aria-label="edit {f.name}"
onclick={() => (editing = f.id)}><Pencil size={14} /></button
>
</div>
{/if}
</li>
{:else}
<li class="empty">No frequencies match.</li>
{/each}
</ul>
</section>
<style>
.search {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 14rem;
max-width: 24rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
color: var(--site-muted);
}
.search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
color: var(--site-text);
}
.chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin: 0.7rem 0;
}
.freqs {
display: flex;
flex-direction: column;
}
.freqs > li + li {
border-top: 1px solid var(--site-border);
}
.frow {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0;
}
.fq {
width: 6.6rem;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-family: ui-monospace, monospace;
font-size: 0.86rem;
}
.fq small {
margin-left: 0.2rem;
font-size: 0.65rem;
color: var(--site-muted);
}
.md {
width: 2.6rem;
flex-shrink: 0;
font-size: 0.7rem;
color: var(--site-muted);
}
.nm {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.name {
font-size: 0.88rem;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
font-size: 0.74rem;
color: var(--site-muted);
}
.note {
flex-basis: 100%;
}
.chs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.chan,
.logged {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
padding: 0.05rem 0.4rem;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.logged {
background: color-mix(in srgb, #34d399 18%, transparent);
}
.chan:hover,
.logged:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.frow {
flex-wrap: wrap;
}
.md {
width: auto;
}
.nm {
flex-basis: calc(100% - 2.5rem);
order: 5;
padding-left: 2.1rem;
}
.chs {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,192 @@
<script lang="ts">
// ISS passes over Gouda: start / max / end (Amsterdam time), max elevation, direction.
import Eye from 'lucide-svelte/icons/eye';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
compass,
dayKey,
fmtTime,
formatFreq,
relDay,
until
} from './index.js';
import type { IssPass } from '#lib/server/radio.js';
let {
passes,
tle = null,
now,
limit = 0,
compact = false
}: {
passes: IssPass[];
tle?: { name: string; epoch: string | null; fetchedAt: string | null } | null;
now: number;
limit?: number;
compact?: boolean;
} = $props();
let showLow = $state(false);
const list = $derived.by(() => {
const l = passes.filter(
(p) => new Date(p.endAt).getTime() >= now && (showLow || p.maxEl >= ISS_MIN_EL)
);
return limit ? l.slice(0, limit) : l;
});
const days = $derived.by(() => {
const out: { key: string; label: string; passes: IssPass[] }[] = [];
for (const p of list) {
const k = dayKey(p.startAt);
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(p.startAt, now), passes: [] });
out.at(-1)!.passes.push(p);
}
return out;
});
const mins = (p: IssPass) =>
Math.round((new Date(p.endAt).getTime() - new Date(p.startAt).getTime()) / 60_000);
const fmtAgo = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
timeZone: 'Europe/Amsterdam',
dateStyle: 'medium',
timeStyle: 'short'
});
</script>
{#if !compact}
<div class="bar">
<p class="meta">
Gouda (52.011 N, 4.711 E) · listen on <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong>
(ARISS voice / SSTV) ·
<Eye size={12} /> = sunlit in a dark sky, visible to the eye
</p>
<label class="low"
><input type="checkbox" bind:checked={showLow} />passes below {ISS_MIN_EL}°</label
>
</div>
{/if}
{#if days.length}
{#each days as d (d.key)}
<h3 class="day">{d.label}</h3>
<ul class="passes">
{#each d.passes as p (p.startAt)}
{@const live = new Date(p.startAt).getTime() <= now}
<li class={{ low: p.maxEl < ISS_MIN_EL, live }}>
<span class="times">
<b>{fmtTime(p.startAt)}</b><span class="arrow">→</span>{fmtTime(p.maxAt)}<span
class="arrow">→</span
>{fmtTime(p.endAt)}
</span>
<span class="el" title="max elevation">
<span class="elbar"><i style:width="{Math.min(100, (p.maxEl / 90) * 100)}%"></i></span
>{Math.round(p.maxEl)}°
</span>
<span class="dir">{compass(p.startAz)} → {compass(p.maxAz)} → {compass(p.endAz)}</span>
<span class="dur">{mins(p)} min</span>
<span class="vis"
>{#if p.sunlit}<Eye size={14} aria-label="visible to the eye" />{/if}</span
>
<span class="rel">{live ? 'overhead now' : until(p.startAt, now)}</span>
</li>
{/each}
</ul>
{/each}
{:else}
<p class="empty">No passes computed yet (the daily refresh fills this).</p>
{/if}
{#if !compact && tle}
<p class="meta foot">
{tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt
? fmtAgo(tle.fetchedAt)
: '?'} (refreshed daily).
</p>
{/if}
<style>
.low {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.day {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
}
.passes {
display: flex;
flex-direction: column;
}
li {
display: grid;
grid-template-columns: 11.5rem 5.5rem 1fr 3.5rem 1.2rem 6.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.3rem;
border-radius: 0.45rem;
font-size: 0.84rem;
font-variant-numeric: tabular-nums;
}
li + li {
border-top: 1px solid var(--site-border);
}
li.low {
opacity: 0.5;
}
li.live {
background: color-mix(in srgb, #2dd4bf 14%, transparent);
}
.arrow {
margin: 0 0.25rem;
color: var(--site-muted);
}
.el {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.elbar {
width: 2.4rem;
height: 5px;
border-radius: 3px;
background: var(--site-border);
overflow: hidden;
}
.elbar i {
display: block;
height: 100%;
background: linear-gradient(90deg, #2dd4bf, #0f766e);
}
.dir,
.dur,
.rel {
color: var(--site-muted);
font-size: 0.78rem;
}
.rel {
text-align: right;
}
.vis {
color: #fbbf24;
display: inline-flex;
}
.foot {
margin-top: 0.8rem;
}
@media (max-width: 640px) {
li {
grid-template-columns: 1fr auto auto;
row-gap: 0.1rem;
}
.dir {
grid-column: 1;
}
.dur {
display: none;
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
// Listening log, newest first, grouped by day.
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Signal from './Signal.svelte';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { dayKey, fmtTime, formatFreq, mhz, relDay } from './index.js';
import type { Frequency, LogEntry } from '#lib/server/radio.js';
let {
log,
frequencies,
now,
limit = 0,
frequencyId = null
}: {
log: LogEntry[];
frequencies: Frequency[];
now: number;
limit?: number;
frequencyId?: number | null;
} = $props();
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const days = $derived.by(() => {
let l = frequencyId ? log.filter((e) => e.frequencyId === frequencyId) : log;
if (limit) l = l.slice(0, limit);
const out: { key: string; label: string; entries: LogEntry[] }[] = [];
for (const e of l) {
const k = dayKey(e.heardAt);
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(e.heardAt, now), entries: [] });
out.at(-1)!.entries.push(e);
}
return out;
});
</script>
{#each days as d (d.key)}
<h3 class="day">{d.label}</h3>
<ul class="log">
{#each d.entries as e (e.id)}
{@const f = e.frequencyId ? byId.get(e.frequencyId) : undefined}
<li>
<span class="time">{fmtTime(e.heardAt)}</span>
<BandIcon band={f?.band ?? 'other'} size={22} />
<div class="body">
<span class="what">{e.what}</span>
<span class="sub">
{#if e.freqHz}<a
href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(e.freqHz)}` as '/')}
class="fq">{formatFreq(e.freqHz)}{e.mode ? ` ${e.mode}` : ''}</a
>{/if}
{#if f}<span>{f.name}</span>{/if}
{#if e.notes}<span class="note">{e.notes}</span>{/if}
</span>
</div>
<Signal value={e.signal} />
<form method="POST" action="?/deleteLog" use:enhance>
<input type="hidden" name="id" value={e.id} />
<button
class="icon danger"
aria-label="delete log entry"
onclick={(ev) => {
if (!confirm('Delete this log entry?')) ev.preventDefault();
}}><Trash2 size={14} /></button
>
</form>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing logged yet. Heard something? Log it above.</p>
{/each}
<style>
.day {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin: 0.8rem 0 0.25rem;
}
.log {
display: flex;
flex-direction: column;
}
li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
}
li + li {
border-top: 1px solid var(--site-border);
}
.time {
width: 2.8rem;
flex-shrink: 0;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
color: var(--site-muted);
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.what {
font-size: 0.88rem;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.1rem 0.6rem;
font-size: 0.74rem;
color: var(--site-muted);
}
.fq {
font-variant-numeric: tabular-nums;
}
.fq:hover {
text-decoration: underline;
}
</style>

283
src/lib/radio/Nets.svelte Normal file
View File

@@ -0,0 +1,283 @@
<script lang="ts">
// Nets and broadcast schedules (Amsterdam time), with the ntfy reminder toggle per net.
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Bell from 'lucide-svelte/icons/bell';
import BellOff from 'lucide-svelte/icons/bell-off';
import Pencil from 'lucide-svelte/icons/pencil';
import Plus from 'lucide-svelte/icons/plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
import {
BANDS,
BAND_LABEL,
DAY_SHORT,
REMIND_MINUTES,
daysLabel,
fmtTime,
formatFreq,
mhz,
nextOccurrence,
relDay,
until
} from './index.js';
import type { Frequency, Net } from '#lib/server/radio.js';
let { nets, frequencies, now }: { nets: Net[]; frequencies: Frequency[]; now: number } = $props();
let editing = $state<number | 'new' | null>(null);
let error = $state<string | null>(null);
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
const grouped = $derived(
BANDS.map((b) => ({ band: b, list: frequencies.filter((f) => f.band === b) })).filter(
(g) => g.list.length
)
);
const KIND = { net: 'net', broadcast: 'broadcast', other: 'other' } as const;
</script>
{#snippet form(n: Net | null)}
<form
class="edit"
method="POST"
action="?/saveNet"
use:enhance={() =>
async ({ result, update }) => {
if (result.type === 'failure') error = String(result.data?.message ?? 'Could not save.');
else {
error = null;
await update();
editing = null;
}
}}
>
{#if n}<input type="hidden" name="id" value={n.id} />{/if}
<label class="wide">name<input name="name" required value={n?.name ?? ''} /></label>
<label
>kind<select name="kind" value={n?.kind ?? 'net'}>
{#each Object.keys(KIND) as k (k)}<option value={k}>{k}</option>{/each}
</select></label
>
<label class="wide"
>frequency<select name="frequency_id" value={n?.frequencyId ?? ''}>
<option value="">none</option>
{#each grouped as g (g.band)}
<optgroup label={BAND_LABEL[g.band]}>
{#each g.list as o (o.id)}<option value={o.id}>{formatFreq(o.freqHz)} · {o.name}</option
>{/each}
</optgroup>
{/each}
</select></label
>
<fieldset class="full days">
<legend>days</legend>
{#each DAY_SHORT as d, i (d)}
<label
><input
type="checkbox"
name="days"
value={i + 1}
checked={n?.days.includes(i + 1) ?? i < 5}
/>{d}</label
>
{/each}
</fieldset>
<label
>times<input
name="times"
value={n?.times.join(', ') ?? ''}
placeholder="11:00, 19:05"
/></label
>
<label
>minutes<input name="duration_min" inputmode="numeric" value={n?.durationMin ?? ''} /></label
>
<label class="inline"
><input type="checkbox" name="remind" checked={n?.remind ?? true} />remind {REMIND_MINUTES} min
before</label
>
<label class="full">notes<textarea name="notes" rows="2">{n?.notes ?? ''}</textarea></label>
<div class="actions full">
{#if error}<span class="err">{error}</span>{/if}
{#if n}
<button
class="icon danger"
formaction="?/deleteNet"
aria-label="delete net"
onclick={(e) => {
if (!confirm(`Delete ${n.name}?`)) e.preventDefault();
}}><Trash2 size={15} /></button
>
{/if}
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
<button class="primary">{n ? 'save' : 'add'}</button>
</div>
</form>
{/snippet}
<section class="card">
<div class="bar">
<p class="meta">
Times are Amsterdam time. A bell sends an ntfy reminder {REMIND_MINUTES} minutes before each start.
</p>
<button
type="button"
class="primary"
onclick={() => (editing = editing === 'new' ? null : 'new')}><Plus size={15} />add</button
>
</div>
{#if editing === 'new'}{@render form(null)}{/if}
<ul class="nets">
{#each nets as n (n.id)}
{@const f = n.frequencyId ? byId.get(n.frequencyId) : undefined}
{@const next = nextOccurrence(n, new Date(now))}
<li>
{#if editing === n.id}
{@render form(n)}
{:else}
<div class="net">
<BandIcon band={f?.band ?? 'other'} size={30} />
<div class="body">
<div class="top">
<strong>{n.name}</strong>
<span class="tag">{n.kind}</span>
</div>
<div class="sub">
{#if f}<a
href={resolve(`/trackers/radio?tab=frequencies&q=${mhz(f.freqHz)}` as '/')}
class="fq"
>{formatFreq(f.freqHz)}
{f.mode}{f.marineCh ? ` · ch ${f.marineCh}` : ''}{f.channels.length
? ` · scanner ${f.channels.join(', ')}`
: ''}</a
>{/if}
<span
>{daysLabel(n.days)}
{n.times.length ? n.times.join(' & ') : '· time unknown'}</span
>
</div>
{#if n.notes}<p class="meta">{n.notes}</p>{/if}
</div>
<div class="right">
{#if next}<span class="next">{relDay(next, now)} {fmtTime(next)}</span><span
class="meta">{until(next, now)}</span
>{/if}
</div>
<form method="POST" action="?/remind" use:enhance>
<input type="hidden" name="id" value={n.id} />
<button
class={['icon', 'bell', { on: n.remind && n.times.length }]}
aria-label={n.remind ? 'reminder on, turn off' : 'reminder off, turn on'}
title={n.times.length
? n.remind
? 'reminder on'
: 'reminder off'
: 'no time set, so no reminder'}
>{#if n.remind}<Bell size={15} />{:else}<BellOff size={15} />{/if}</button
>
</form>
<button
type="button"
class="icon"
aria-label="edit {n.name}"
onclick={() => (editing = n.id)}><Pencil size={14} /></button
>
</div>
{/if}
</li>
{:else}
<li class="empty">No nets yet.</li>
{/each}
</ul>
</section>
<style>
.nets {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
}
.nets > li + li {
border-top: 1px solid var(--site-border);
}
.net {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.top {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.sub {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.fq {
font-variant-numeric: tabular-nums;
}
.fq:hover {
text-decoration: underline;
}
.right {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
flex-shrink: 0;
}
.next {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.bell.on {
color: #fbbf24;
}
.days {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
border: 0;
padding: 0;
font-size: 0.8rem;
}
.days legend {
font-size: 0.72rem;
color: var(--site-muted);
margin-bottom: 0.2rem;
}
.days label,
.inline {
display: inline-flex !important;
flex-direction: row !important;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem !important;
color: var(--site-text) !important;
}
@media (max-width: 640px) {
.net {
flex-wrap: wrap;
}
.right {
order: 5;
flex-direction: row;
gap: 0.5rem;
align-items: baseline;
padding-left: 2.6rem;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<script lang="ts">
// Quick listening-log entry, built for a phone: frequency (or "ch 353"), what you heard,
// signal 1-5. Time defaults to now; "earlier" opens a time field.
import { enhance } from '$app/forms';
import Plus from 'lucide-svelte/icons/plus';
import { mhz } from './index.js';
import type { Frequency } from '#lib/server/radio.js';
let {
frequencies,
message = null,
error = false
}: { frequencies: Frequency[]; message?: string | null; error?: boolean } = $props();
let signal = $state<number | null>(null);
let earlier = $state(false);
let busy = $state(false);
// Datalist: one option per frequency value (the scanner ones first, then the rest).
const options = $derived.by(() => {
const seen: Record<number, true> = {};
const sorted = [...frequencies].sort(
(a, b) => Number(b.channels.length > 0) - Number(a.channels.length > 0) || a.freqHz - b.freqHz
);
const out: { value: string; label: string }[] = [];
for (const f of sorted) {
if (seen[f.freqHz]) continue;
seen[f.freqHz] = true;
out.push({ value: mhz(f.freqHz), label: f.name });
}
return out.sort((a, b) => Number(a.value) - Number(b.value));
});
</script>
<form
class="quick"
method="POST"
action="?/log"
use:enhance={() => {
busy = true;
return async ({ update, result }) => {
await update();
busy = false;
if (result.type === 'success') {
signal = null;
earlier = false;
}
};
}}
>
<div class="row">
<input
name="freq"
class="freq"
placeholder="145.450 or ch 353"
list="radio-freqs"
inputmode="decimal"
autocomplete="off"
aria-label="Frequency or scanner channel"
required
/>
<input
name="what"
class="what"
placeholder="what did you hear?"
aria-label="What you heard"
required
/>
</div>
<datalist id="radio-freqs">
{#each options as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</datalist>
<div class="row">
<div class="signal" role="radiogroup" aria-label="Signal quality">
{#each [1, 2, 3, 4, 5] as s (s)}
<label class={{ on: signal !== null && s <= signal }}
><input type="radio" name="signal" value={s} bind:group={signal} /><span>{s}</span></label
>
{/each}
</div>
<button type="button" class="ghost small" onclick={() => (earlier = !earlier)}
>{earlier ? 'now' : 'earlier?'}</button
>
<button class="primary" disabled={busy}><Plus size={15} />log</button>
</div>
{#if earlier}
<div class="row">
<input type="datetime-local" name="heard_at" aria-label="When (Amsterdam time)" />
<input name="notes" placeholder="notes" aria-label="Notes" class="what" />
</div>
{/if}
{#if message}<p class={['msg', { error }]} role="status">{message}</p>{/if}
</form>
<style>
.quick {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
input:not([type='radio']) {
height: 2.4rem;
padding: 0 0.7rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
background: var(--site-bg);
color: var(--site-text);
font: inherit;
font-size: 16px; /* no zoom on iOS */
min-width: 0;
}
input:focus {
outline: none;
border-color: var(--site-muted);
}
.freq {
flex: 0 1 11rem;
font-variant-numeric: tabular-nums;
}
.what {
flex: 1 1 12rem;
}
.signal {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
overflow: hidden;
}
.signal label {
position: relative;
cursor: pointer;
}
.signal input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.signal span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.3rem;
height: 2.3rem;
font-size: 0.85rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.signal label + label span {
border-left: 1px solid var(--site-border);
}
.signal label.on span {
background: color-mix(in srgb, #34d399 22%, transparent);
color: var(--site-text);
}
.signal label:focus-within span {
outline: 2px solid var(--site-muted);
outline-offset: -2px;
}
.primary {
margin-left: auto;
height: 2.4rem;
padding: 0 1rem !important;
}
.small {
height: 2.4rem;
}
.msg {
font-size: 0.8rem;
color: #34d399;
}
.msg.error {
color: #f87171;
}
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
// Signal quality 1..5 as five little bars.
let { value }: { value: number | null } = $props();
const LABEL = ['', 'barely', 'weak', 'fair', 'good', 'loud and clear'];
</script>
{#if value}
<span class="sig" title="signal {value}/5: {LABEL[value]}" aria-label="signal {value} of 5">
{#each [1, 2, 3, 4, 5] as i (i)}<i class={{ on: i <= value }} style:height="{3 + i * 2}px"
></i>{/each}
</span>
{/if}
<style>
.sig {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 13px;
}
i {
width: 3px;
border-radius: 1px;
background: var(--site-border);
}
i.on {
background: #34d399;
}
</style>

34
src/lib/radio/Tile.svelte Normal file
View File

@@ -0,0 +1,34 @@
<script lang="ts">
// Gradient icon tile for section headings and tabs.
import type { Component } from 'svelte';
let {
icon: Icon,
from,
to,
size = 22
}: {
icon: Component<{ size?: number; strokeWidth?: number }>;
from: string;
to: string;
size?: number;
} = $props();
</script>
<span class="tile" style:--from={from} style:--to={to} style:--size="{size}px" aria-hidden="true"
><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
>
<style>
.tile {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--size);
height: var(--size);
border-radius: 999px;
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
}
</style>

186
src/lib/radio/UpNext.svelte Normal file
View File

@@ -0,0 +1,186 @@
<script lang="ts">
// "Up next": the next net/broadcast starts and good ISS passes, soonest first.
import { resolve } from '$app/paths';
import BandIcon from './BandIcon.svelte';
import Satellite from 'lucide-svelte/icons/satellite';
import Bell from 'lucide-svelte/icons/bell';
import {
ISS_DOWNLINK_HZ,
ISS_MIN_EL,
compass,
fmtTime,
formatFreq,
occurrences,
relDay,
until
} from './index.js';
import type { Frequency, IssPass, Net } from '#lib/server/radio.js';
let {
nets,
passes,
frequencies,
now,
limit = 6
}: {
nets: Net[];
passes: IssPass[];
frequencies: Frequency[];
now: number;
limit?: number;
} = $props();
const byId = $derived(new Map(frequencies.map((f) => [f.id, f])));
type Item = {
key: string;
at: number;
end: number | null;
title: string;
sub: string;
band: string | null;
iss: boolean;
remind: boolean;
};
const items = $derived.by(() => {
const from = new Date(now - 60 * 60_000);
const to = new Date(now + 3 * 86_400_000);
const out: Item[] = [];
for (const n of nets) {
const f = n.frequencyId ? byId.get(n.frequencyId) : undefined;
for (const at of occurrences(n, from, to)) {
const end = n.durationMin
? at.getTime() + n.durationMin * 60_000
: at.getTime() + 15 * 60_000;
if (end < now) continue;
out.push({
key: `n${n.id}-${at.getTime()}`,
at: at.getTime(),
end,
title: n.name,
sub: f
? `${formatFreq(f.freqHz)} ${f.mode}${f.marineCh ? ` · ch ${f.marineCh}` : ''}`
: '',
band: f?.band ?? 'other',
iss: false,
remind: n.remind
});
}
}
for (const p of passes) {
if (p.maxEl < ISS_MIN_EL) continue;
const end = new Date(p.endAt).getTime();
if (end < now) continue;
out.push({
key: `iss-${p.startAt}`,
at: new Date(p.startAt).getTime(),
end,
title: `ISS pass, max ${Math.round(p.maxEl)}°`,
sub: `${compass(p.startAz)} → ${compass(p.endAz)} · ${formatFreq(ISS_DOWNLINK_HZ)} NFM${p.sunlit ? ' · visible' : ''}`,
band: null,
iss: true,
remind: true
});
}
return out.sort((a, b) => a.at - b.at).slice(0, limit);
});
</script>
{#if items.length}
<ol class="upnext">
{#each items as it (it.key)}
{@const live = it.at <= now && (it.end ?? it.at) >= now}
<li class={{ live }}>
{#if it.iss}<span class="iss-icon" aria-hidden="true"
><Satellite size={13} strokeWidth={2.25} /></span
>{:else}<BandIcon band={it.band ?? 'other'} size={26} />{/if}
<div class="what">
<a href={resolve(`/trackers/radio?tab=${it.iss ? 'iss' : 'nets'}` as '/')} class="t"
>{it.title}</a
>
<span class="s">{it.sub}</span>
</div>
<div class="when">
<span class="clock">{relDay(it.at, now)} {fmtTime(it.at)}</span>
<span class="rel"
>{live ? 'on now' : until(it.at, now)}{#if it.remind}<Bell
size={11}
aria-label="reminder on"
/>{/if}</span
>
</div>
</li>
{/each}
</ol>
{:else}
<p class="empty">Nothing scheduled in the next 3 days.</p>
{/if}
<style>
.upnext {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.35rem;
border-radius: 0.5rem;
}
li.live {
background: color-mix(in srgb, #34d399 12%, transparent);
}
.iss-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #2dd4bf, #0f766e);
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.t {
font-size: 0.88rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t:hover {
text-decoration: underline;
}
.s,
.rel {
font-size: 0.74rem;
color: var(--site-muted);
}
.when {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
flex-shrink: 0;
}
.clock {
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.rel {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
li.live .rel {
color: #34d399;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { alphaTag, bankOf, nextOccurrence, occurrences, parseFreq, scannerCsv } from './index';
describe('parseFreq', () => {
it('reads MHz, kHz and Hz', () => {
expect(parseFreq('145.450')).toBe(145_450_000);
expect(parseFreq('145,45 MHz')).toBe(145_450_000);
expect(parseFreq('3785 kHz')).toBe(3_785_000);
expect(parseFreq('3785')).toBe(3_785_000);
expect(parseFreq('145450000')).toBe(145_450_000);
expect(parseFreq('ch 16')).toBeNull();
expect(parseFreq('')).toBeNull();
});
});
describe('net schedule', () => {
const ronde = { id: 1, days: [1, 2, 3, 4, 5], times: ['11:00'] };
it('puts weekday nets on Amsterdam time across the DST switch', () => {
// Sat 24 Oct 2026 .. Sat 31 Oct 2026; DST ends Sun 25 Oct.
const occ = occurrences(
ronde,
new Date('2026-10-24T00:00:00Z'),
new Date('2026-10-31T00:00:00Z')
);
expect(occ.map((d) => d.toISOString())).toEqual([
'2026-10-26T10:00:00.000Z',
'2026-10-27T10:00:00.000Z',
'2026-10-28T10:00:00.000Z',
'2026-10-29T10:00:00.000Z',
'2026-10-30T10:00:00.000Z'
]);
const summer = occurrences(
ronde,
new Date('2026-07-01T00:00:00Z'),
new Date('2026-07-02T00:00:00Z')
);
expect(summer[0].toISOString()).toBe('2026-07-01T09:00:00.000Z');
});
it('finds the next occurrence and skips nets without a time', () => {
expect(nextOccurrence(ronde, new Date('2026-10-30T10:30:00Z'))?.toISOString()).toBe(
'2026-11-02T10:00:00.000Z'
);
expect(nextOccurrence({ id: 2, days: [7], times: [] }, new Date())).toBeNull();
});
});
describe('scanner', () => {
it('maps channels to 10 banks of 50', () => {
expect(bankOf(1)).toBe(1);
expect(bankOf(50)).toBe(1);
expect(bankOf(51)).toBe(2);
expect(bankOf(500)).toBe(10);
});
it('exports all 500 channels in BC125AT_SS order', () => {
const csv = scannerCsv([
{
channel: 301,
alphaTag: 'KUSTWACHT, 16',
freqHz: 156_800_000,
mode: 'FM',
delay: true,
lockout: false,
priority: true
}
]);
const lines = csv.trimEnd().split('\r\n');
expect(lines).toHaveLength(501);
expect(lines[0]).toBe(
'Bank,Channel,Name,Frequency,Modulation,CTCSS/DCS,Delay,Lockout,Priority'
);
expect(lines[301]).toBe('7,301,"KUSTWACHT, 16",156.8000,FM,OFF,2,OFF,ON');
expect(lines[1]).toBe('1,1,,,AUTO,OFF,2,OFF,OFF');
});
it('keeps alpha tags within 16 characters', () => {
expect(
alphaTag('Kustwacht Wal Scheveningen weerbericht en navigatie').length
).toBeLessThanOrEqual(16);
});
});

314
src/lib/radio/index.ts Normal file
View File

@@ -0,0 +1,314 @@
// 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;
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();
}

View File

@@ -35,3 +35,4 @@ export * from './osu-goals';
export * from './notes';
export * from './math';
export * from './plant';
export * from './radio';

View File

@@ -0,0 +1,186 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
check,
index,
integer,
primaryKey,
real,
smallint,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { radioSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Radio tracker (/trackers/radio, owner-only): frequencies, the scanner's memory banks (Uniden
// UBC125XLT, 10 x 50 channels), nets and broadcast schedules, ISS passes over Gouda and a
// listening log. scripts/radio.ts refreshes the ISS passes (daily timer) and sends the ntfy
// reminders (every-minute timer). Bands, modes and the bank layout live in #lib/radio.
export const radioFrequencies = radioSchema.table(
'frequencies',
{
id: id(),
freqHz: bigint('freq_hz', { mode: 'number' }).notNull(),
/** FM | NFM | AM | USB | LSB | CW | DATA */
mode: text('mode').notNull().default('FM'),
/** marine | air | ham | pmr | other */
band: text('band').notNull(),
name: text('name').notNull(),
location: text('location'),
notes: text('notes'),
/** Marine VHF channel number ("16", "83"), when it is one. */
marineCh: text('marine_ch'),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
source: text('source'),
...timestamps
},
(t) => [
uniqueIndex('frequencies_freq_name_key').on(t.freqHz, t.name),
index('frequencies_band_idx').on(t.band),
check('frequencies_mode_check', sql`${t.mode} IN ('FM','NFM','AM','USB','LSB','CW','DATA')`),
check('frequencies_band_check', sql`${t.band} IN ('marine','air','ham','pmr','other')`),
check('frequencies_freq_check', sql`${t.freqHz} > 0`)
]
);
/** Scanner memories: channel 1..500 (bank = (channel - 1) / 50 + 1). */
export const radioChannels = radioSchema.table(
'channels',
{
channel: smallint('channel').primaryKey(),
frequencyId: integer('frequency_id')
.notNull()
.references(() => radioFrequencies.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** Up to 16 characters on the UBC125XLT; null = derived from the frequency's name. */
alphaTag: text('alpha_tag'),
delay: boolean('delay').notNull().default(true),
lockout: boolean('lockout').notNull().default(false),
priority: boolean('priority').notNull().default(false),
updatedAt: timestamptz('updated_at').notNull().defaultNow()
},
(t) => [
index('channels_frequency_idx').on(t.frequencyId),
check('channels_channel_check', sql`${t.channel} BETWEEN 1 AND 500`),
check('channels_alpha_tag_check', sql`char_length(${t.alphaTag}) <= 16`)
]
);
/** Nets and broadcasts on a weekly schedule, in Europe/Amsterdam wall time. */
export const radioNets = radioSchema.table(
'nets',
{
id: id(),
name: text('name').notNull(),
frequencyId: integer('frequency_id').references(() => radioFrequencies.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** ISO weekdays, 1 = Monday .. 7 = Sunday. */
days: smallint('days')
.array()
.notNull()
.default(sql`'{}'::smallint[]`),
/** "HH:MM" start times; empty = time unknown (listed, never reminded). */
times: text('times')
.array()
.notNull()
.default(sql`'{}'::text[]`),
durationMin: integer('duration_min'),
kind: text('kind').notNull().default('net'),
notes: text('notes'),
/** ntfy reminder 10 minutes before each start. */
remind: boolean('remind').notNull().default(true),
active: boolean('active').notNull().default(true),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
check('nets_kind_check', sql`${t.kind} IN ('net','broadcast','other')`),
check('nets_days_check', sql`${t.days} <@ '{1,2,3,4,5,6,7}'::smallint[]`),
check(
'nets_times_check',
sql`array_to_string(${t.times}, ',') ~ '^(([01][0-9]|2[0-3]):[0-5][0-9](,|$))*$'`
)
]
);
/** ISS passes over Gouda (52.011 N, 4.711 E), from Celestrak's TLE, refreshed daily. */
export const radioIssPasses = radioSchema.table(
'iss_passes',
{
startAt: timestamptz('start_at').primaryKey(),
maxAt: timestamptz('max_at').notNull(),
endAt: timestamptz('end_at').notNull(),
/** Degrees above the horizon. */
maxEl: real('max_el').notNull(),
/** Azimuths in degrees from north. */
startAz: real('start_az').notNull(),
maxAz: real('max_az').notNull(),
endAz: real('end_az').notNull(),
/** The ISS is sunlit while the observer's sky is dark at max (naked-eye visible). */
sunlit: boolean('sunlit').notNull().default(false),
computedAt: timestamptz('computed_at').notNull().defaultNow()
},
(t) => [index('iss_passes_end_idx').on(t.endAt)]
);
/** The TLE used for the passes (one row). */
export const radioIssTle = radioSchema.table(
'iss_tle',
{
id: smallint('id').primaryKey().default(1),
name: text('name').notNull(),
line1: text('line1').notNull(),
line2: text('line2').notNull(),
epoch: timestamptz('epoch').notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
},
(t) => [check('iss_tle_single_check', sql`${t.id} = 1`)]
);
/** Listening log. */
export const radioLog = radioSchema.table(
'log',
{
id: id(),
heardAt: timestamptz('heard_at').notNull().defaultNow(),
frequencyId: integer('frequency_id').references(() => radioFrequencies.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
/** What was tuned (kept even if the frequency row is deleted, or for one-offs). */
freqHz: bigint('freq_hz', { mode: 'number' }),
mode: text('mode'),
what: text('what').notNull(),
/** 1 (barely) .. 5 (loud and clear). */
signal: smallint('signal'),
notes: text('notes'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
index('log_heard_idx').on(t.heardAt),
index('log_frequency_idx').on(t.frequencyId),
check('log_signal_check', sql`${t.signal} BETWEEN 1 AND 5`)
]
);
/** Reminders already sent (kind 'net' + net id, or 'iss' + 0), so each fires once. */
export const radioReminders = radioSchema.table(
'reminders',
{
kind: text('kind').notNull(),
refId: integer('ref_id').notNull(),
at: timestamptz('at').notNull(),
sentAt: timestamptz('sent_at').notNull().defaultNow()
},
(t) => [
primaryKey({ columns: [t.kind, t.refId, t.at] }),
check('reminders_kind_check', sql`${t.kind} IN ('net','iss')`)
]
);

View File

@@ -31,3 +31,4 @@ export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math');
export const plantSchema = pgSchema('plant');
export const radioSchema = pgSchema('radio');

View File

@@ -51,6 +51,13 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/cargill')).toBe(true);
expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true);
});
it('gates the whole radio tracker, reads, writes and the CSV export', () => {
expect(isOwnerOnlyPath('/trackers/radio')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radio/__data.json')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radio/banks.csv')).toBe(true);
expect(isOwnerOnlyPath('/trackers/radiox')).toBe(false);
expect(isOwnerWritePath('/trackers/radio')).toBe(true);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);

View File

@@ -42,6 +42,8 @@ export const ownerOnlyPrefixes = [
'/projects/worldwide-todo/api/queue',
// Cargill plant tracker: what the factory I work at does. Mine only.
'/trackers/cargill',
// Radio tracker: Opa's scanner frequencies, banks, nets, ISS passes and my log. Mine only.
'/trackers/radio',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const;
@@ -74,7 +76,8 @@ export const ownerWritePrefixes = [
'/trackers/ai',
'/trackers/movies',
'/trackers/math',
'/trackers/cargill'
'/trackers/cargill',
'/trackers/radio'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

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

View File

@@ -103,6 +103,21 @@ export const trackerIcons: Record<string, TrackerIcon> = {
// factory: saw-tooth roof, chimney and windows
paths: ['M3 20V10l5 3V10l5 3V10l5 3V4h3v16z', 'M7 17h1M11 17h1M15 17h1']
},
radio: {
from: '#f43f5e',
to: '#881337',
ink: WHITE,
// radio tower with waves
paths: [
'M4.9 16.1C1 12.2 1 5.8 4.9 1.9',
'M7.8 4.7a6.14 6.14 0 0 0-.8 7.5',
c(12, 9, 2),
'M16.2 4.8c2 2 2.26 5.11.8 7.47',
'M19.1 1.9a9.96 9.96 0 0 1 0 14.1',
'M9.5 18h5',
'm8 22 4-11 4 11'
]
},
math: {
from: '#60a5fa',
to: '#1e40af',

View File

@@ -0,0 +1,51 @@
import { requireOwner } from '#lib/server/owner.js';
import {
addLog,
deleteFrequency,
deleteLog,
deleteNet,
loadRadio,
saveFrequency,
saveNet,
setChannel,
toggleRemind
} from '#lib/server/radio.js';
import { fail, type RequestEvent } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// /trackers/radio: owner-only. Everyone else gets a 404 (here and in hooks.server.ts via
// ownerOnlyPrefixes); hooks.server.ts also sends cache-control: private, no-store.
export const load: PageServerLoad = async (event) => {
requireOwner(event);
return {
...(await loadRadio()),
now: Date.now(),
seo: {
title: 'radio',
description: 'Frequencies, scanner banks, nets and ISS passes.',
index: false
}
};
};
type Handler = (
form: FormData
) => Promise<{ ok: true; message?: string } | { ok: false; message: string }>;
// Actions don't run the load, so each one checks the owner again.
const action = (name: string, fn: Handler) => async (event: RequestEvent) => {
requireOwner(event);
const r = await fn(await event.request.formData());
if (!r.ok) return fail(400, { action: name, message: r.message });
return { action: name, ok: true as const, message: r.message ?? null };
};
export const actions: Actions = {
saveFreq: action('saveFreq', saveFrequency),
deleteFreq: action('deleteFreq', deleteFrequency),
channel: action('channel', setChannel),
saveNet: action('saveNet', saveNet),
deleteNet: action('deleteNet', deleteNet),
remind: action('remind', toggleRemind),
log: action('log', addLog),
deleteLog: action('deleteLog', deleteLog)
};

View File

@@ -0,0 +1,458 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Tile from '#lib/radio/Tile.svelte';
import UpNext from '#lib/radio/UpNext.svelte';
import QuickLog from '#lib/radio/QuickLog.svelte';
import FreqList from '#lib/radio/FreqList.svelte';
import Banks from '#lib/radio/Banks.svelte';
import Nets from '#lib/radio/Nets.svelte';
import IssPasses from '#lib/radio/IssPasses.svelte';
import LogList from '#lib/radio/LogList.svelte';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import RadioReceiver from 'lucide-svelte/icons/radio-receiver';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
import Satellite from 'lucide-svelte/icons/satellite';
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import { untrack, type Component } from 'svelte';
let { data, form } = $props();
type Tab = 'overview' | 'frequencies' | 'banks' | 'nets' | 'iss' | 'log';
type Icon = Component<{ size?: number; strokeWidth?: number }>;
const TABS: { id: Tab; label: string; icon: Icon; c: [string, string] }[] = [
{
id: 'overview',
label: 'overview',
icon: LayoutDashboard as unknown as Icon,
c: ['#f43f5e', '#881337']
},
{
id: 'frequencies',
label: 'frequencies',
icon: RadioReceiver as unknown as Icon,
c: ['#a78bfa', '#5b21b6']
},
{
id: 'banks',
label: 'scanner banks',
icon: LayoutGrid as unknown as Icon,
c: ['#38bdf8', '#0369a1']
},
{
id: 'nets',
label: 'nets',
icon: CalendarClock as unknown as Icon,
c: ['#fbbf24', '#b45309']
},
{
id: 'iss',
label: 'ISS passes',
icon: Satellite as unknown as Icon,
c: ['#2dd4bf', '#0f766e']
},
{ id: 'log', label: 'log', icon: NotebookPen as unknown as Icon, c: ['#34d399', '#047857'] }
];
const tab = $derived<Tab>(
(TABS.find((t) => t.id === page.url.searchParams.get('tab'))?.id ?? 'overview') as Tab
);
const count = (t: Tab) =>
t === 'frequencies'
? data.frequencies.length
: t === 'banks'
? data.channels.length
: t === 'nets'
? data.nets.length
: t === 'iss'
? data.passes.filter((p) => p.maxEl >= 10).length
: t === 'log'
? data.logTotal
: null;
// A clock for "in 5 min" and the live highlight (the server's time first, so SSR matches).
let now = $state(untrack(() => data.now));
$effect(() => {
now = Date.now();
const t = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(t);
});
const logMessage = $derived(form?.action === 'log' ? (form.message ?? null) : null);
const logError = $derived(form?.action === 'log' && !form.ok);
const logFreq = $derived(Number(page.url.searchParams.get('f')) || null);
const tabHref = (t: Tab) =>
resolve((t === 'overview' ? '/trackers/radio' : `/trackers/radio?tab=${t}`) as '/');
</script>
<div class="radio">
<header class="head">
<a href={resolve('/trackers/radio')} class="title-link"
><TrackerIcon name="radio" class="size-10" /></a
>
<div class="title">
<h1>radio<PrivateLock size={14} /></h1>
<p>Frequencies, scanner banks, nets, ISS passes over Gouda and what I heard.</p>
</div>
</header>
<nav class="tabs" aria-label="Radio tracker sections">
{#each TABS as t (t.id)}
{@const n = count(t.id)}
<a
href={tabHref(t.id)}
class={{ active: tab === t.id }}
aria-current={tab === t.id ? 'page' : undefined}
><Tile icon={t.icon} from={t.c[0]} to={t.c[1]} />{t.label}{#if n !== null}<span class="n"
>{n}</span
>{/if}</a
>
{/each}
</nav>
{#if tab === 'overview'}
<div class="overview">
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[3].icon} from="#fbbf24" to="#b45309" size={18} />up next
</h2>
<UpNext nets={data.nets} passes={data.passes} frequencies={data.frequencies} {now} />
</section>
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[5].icon} from="#34d399" to="#047857" size={18} />quick log
</h2>
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
<div class="recent">
<LogList log={data.log} frequencies={data.frequencies} {now} limit={5} />
</div>
</section>
<section class="card">
<h2 class="card-title">
<Tile icon={TABS[4].icon} from="#2dd4bf" to="#0f766e" size={18} />next ISS passes
<a class="more" href={tabHref('iss')}>all</a>
</h2>
<IssPasses passes={data.passes} {now} limit={4} compact />
</section>
<section class="card stats">
<h2 class="card-title">
<Tile icon={TABS[2].icon} from="#38bdf8" to="#0369a1" size={18} />scanner
</h2>
<p>
<b>{data.channels.length}</b> of 500 channels programmed, <b>{data.frequencies.length}</b> frequencies
known.
</p>
<p class="meta">
Airband: banks 4–6 (scan 4 5 6). Marine: banks 7–10 (press SCAN, then 7 8 9 0). Uniden
UBC125XLT layout; check the label on the scanner to confirm the model.
</p>
<a class="ghost" href={tabHref('banks')}>open banks</a>
</section>
</div>
{:else if tab === 'frequencies'}
<FreqList frequencies={data.frequencies} />
{:else if tab === 'banks'}
<Banks channels={data.channels} frequencies={data.frequencies} />
{:else if tab === 'nets'}
<Nets nets={data.nets} frequencies={data.frequencies} {now} />
{:else if tab === 'iss'}
<section class="card"><IssPasses passes={data.passes} tle={data.tle} {now} /></section>
{:else}
<section class="card">
<QuickLog frequencies={data.frequencies} message={logMessage} error={logError} />
</section>
<section class="card">
{#if logFreq}<p class="meta">
Showing one frequency. <a href={tabHref('log')} class="u">Show all</a>
</p>{/if}
<LogList log={data.log} frequencies={data.frequencies} {now} frequencyId={logFreq} />
</section>
{/if}
</div>
<style>
.radio {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.head {
display: flex;
align-items: center;
gap: 0.85rem;
}
.title-link {
display: inline-flex;
}
.title {
min-width: 0;
}
h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.title p {
color: var(--site-muted);
font-size: 0.9rem;
}
.tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.tabs a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.83rem;
color: var(--site-muted);
white-space: nowrap;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.n {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.overview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.recent {
margin-top: 0.4rem;
}
.more {
margin-left: auto;
text-transform: none;
letter-spacing: 0;
font-size: 0.78rem;
}
.more:hover,
.u {
text-decoration: underline;
}
.stats p + p {
margin-top: 0.4rem;
}
.stats .ghost {
margin-top: 0.7rem;
}
/* Shared building blocks for the section components. */
.radio :global(.card) {
padding: 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
min-width: 0;
}
.radio :global(.card-title) {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin-bottom: 0.6rem;
}
.radio :global(.bar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.radio :global(.meta),
.radio :global(.empty) {
color: var(--site-muted);
font-size: 0.82rem;
}
.radio :global(.empty) {
padding: 1rem 0;
}
.radio :global(button) {
cursor: pointer;
font: inherit;
}
.radio :global(.primary) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.8rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.83rem;
font-weight: 600;
transition: opacity 0.15s;
}
.radio :global(.primary:hover) {
opacity: 0.85;
}
.radio :global(.primary:disabled) {
opacity: 0.5;
}
.radio :global(.ghost) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
font-size: 0.83rem;
}
.radio :global(.ghost:hover) {
color: var(--site-text);
border-color: var(--site-muted);
}
.radio :global(.icon) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
border-radius: 0.4rem;
color: var(--site-muted);
transition:
color 0.15s,
background 0.15s;
}
.radio :global(.icon:hover) {
color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.radio :global(.icon.danger:hover) {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
.radio :global(.tag) {
font-size: 0.68rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
white-space: nowrap;
}
.radio :global(.chip) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
}
.radio :global(.chip:first-child) {
padding-left: 0.6rem;
}
.radio :global(.chip.active) {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.radio :global(.err) {
color: #f87171;
font-size: 0.8rem;
margin-right: auto;
}
/* Edit forms: a compact grid of labelled fields. */
.radio :global(.edit) {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
padding: 0.75rem;
margin: 0.5rem 0;
border: 1px solid var(--site-border);
border-radius: 0.65rem;
background: var(--site-bg);
}
.radio :global(.edit label) {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.72rem;
color: var(--site-muted);
min-width: 0;
}
.radio :global(.edit .wide) {
grid-column: span 2;
}
.radio :global(.edit .full) {
grid-column: 1 / -1;
}
.radio :global(.edit input:not([type='checkbox'])),
.radio :global(.edit select),
.radio :global(.edit textarea) {
height: 2.2rem;
padding: 0 0.55rem;
border: 1px solid var(--site-border);
border-radius: 0.45rem;
background: var(--site-surface);
color: var(--site-text);
font: inherit;
font-size: 0.86rem;
min-width: 0;
width: 100%;
}
.radio :global(.edit textarea) {
height: auto;
padding: 0.4rem 0.55rem;
resize: vertical;
}
.radio :global(.edit .actions) {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
@media (max-width: 860px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.radio {
padding-top: 1.25rem;
}
h1 {
font-size: 1.6rem;
}
.radio :global(.edit) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.radio :global(.edit input:not([type='checkbox'])),
.radio :global(.edit select),
.radio :global(.edit textarea) {
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,17 @@
import { requireOwner } from '#lib/server/owner.js';
import { channelRows } from '#lib/server/radio.js';
import { scannerCsv } from '#lib/radio/index.js';
import type { RequestHandler } from './$types';
// All 500 scanner memories as CSV (Uniden BC125AT/UBC125XLT programming software column order).
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const csv = scannerCsv(await channelRows());
return new Response(csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': 'attachment; filename="ubc125xlt-banks.csv"',
'cache-control': 'private, no-store'
}
});
};