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