radio: quiet hours for reminders (default 23:00-07:30 Amsterdam, RADIO_QUIET_HOURS overrides)
All checks were successful
Deploy production / deploy (push) Successful in 1m21s
All checks were successful
Deploy production / deploy (push) Successful in 1m21s
This commit is contained in:
@@ -21,3 +21,7 @@ Private (owner-only, 404 for everyone else) tracker for Opa Chiel's scanner hobb
|
||||
- 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.
|
||||
|
||||
## Quiet hours
|
||||
|
||||
Reminders whose send time (event minus 10 min) falls in 23:00-07:30 Europe/Amsterdam are skipped. Override with `RADIO_QUIET_HOURS=HH:MM-HH:MM` (or `off`) in `~/.config/radio.env`; the page shows the code default (`QUIET_HOURS` in `src/lib/radio/index.ts`). `bun scripts/radio.ts upcoming` marks skipped reminders as QUIET-SKIP.
|
||||
|
||||
@@ -9,22 +9,30 @@
|
||||
*
|
||||
* 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.
|
||||
* RADIO_QUIET_HOURS (default 23:00-07:30, "off" to disable) skips reminders sent in that window.
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
import * as sat from 'satellite.js';
|
||||
import {
|
||||
ISS_DOWNLINK_HZ,
|
||||
ISS_MIN_EL,
|
||||
QUIET_HOURS,
|
||||
REMIND_MINUTES,
|
||||
compass,
|
||||
fmtTime,
|
||||
formatFreq,
|
||||
occurrences
|
||||
inQuiet,
|
||||
occurrences,
|
||||
parseQuiet
|
||||
} 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';
|
||||
// Quiet hours (Amsterdam): reminders whose send time falls inside are skipped. "off" disables.
|
||||
const QUIET = parseQuiet(process.env.RADIO_QUIET_HOURS ?? QUIET_HOURS);
|
||||
const sendAt = (at: Date) => new Date(at.getTime() - REMIND_MINUTES * 60_000);
|
||||
const quiet = (at: Date) => inQuiet(sendAt(at), QUIET);
|
||||
const PAGE = 'https://stanrunge.dev/trackers/radio';
|
||||
|
||||
// Gouda (approx), 0 m.
|
||||
@@ -237,6 +245,7 @@ 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) {
|
||||
if (quiet(r.at)) continue;
|
||||
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;
|
||||
@@ -255,8 +264,11 @@ try {
|
||||
else if (mode === 'remind') await remind();
|
||||
else if (mode === 'upcoming') {
|
||||
const now = new Date();
|
||||
console.log(`quiet hours: ${QUIET?.label ?? 'off'} (Europe/Amsterdam, by send time)`);
|
||||
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}`);
|
||||
console.log(
|
||||
`${quiet(r.at) ? 'QUIET-SKIP' : 'SEND '} at ${fmtTime(sendAt(r.at))} ${r.kind}: ${r.title}`
|
||||
);
|
||||
} else throw new Error(`unknown mode ${mode} (iss | remind | upcoming)`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import {
|
||||
ISS_DOWNLINK_HZ,
|
||||
ISS_MIN_EL,
|
||||
QUIET_HOURS,
|
||||
REMIND_MINUTES,
|
||||
compass,
|
||||
dayKey,
|
||||
fmtTime,
|
||||
formatFreq,
|
||||
parseQuiet,
|
||||
relDay,
|
||||
until
|
||||
} from './index.js';
|
||||
@@ -100,7 +103,8 @@
|
||||
<p class="meta foot">
|
||||
{tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt
|
||||
? fmtAgo(tle.fetchedAt)
|
||||
: '?'} (refreshed daily).
|
||||
: '?'} (refreshed daily). Passes of {ISS_MIN_EL}° and up get a reminder {REMIND_MINUTES} min before,
|
||||
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
BANDS,
|
||||
BAND_LABEL,
|
||||
DAY_SHORT,
|
||||
QUIET_HOURS,
|
||||
REMIND_MINUTES,
|
||||
parseQuiet,
|
||||
daysLabel,
|
||||
fmtTime,
|
||||
formatFreq,
|
||||
@@ -118,7 +120,8 @@
|
||||
<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.
|
||||
Times are Amsterdam time. A bell sends an ntfy reminder {REMIND_MINUTES} minutes before each start,
|
||||
except during quiet hours ({parseQuiet(QUIET_HOURS)?.label}).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { alphaTag, bankOf, nextOccurrence, occurrences, parseFreq, scannerCsv } from './index';
|
||||
import {
|
||||
alphaTag,
|
||||
bankOf,
|
||||
fromZoned,
|
||||
inQuiet,
|
||||
parseQuiet,
|
||||
nextOccurrence,
|
||||
occurrences,
|
||||
parseFreq,
|
||||
scannerCsv
|
||||
} from './index';
|
||||
|
||||
describe('parseFreq', () => {
|
||||
it('reads MHz, kHz and Hz', () => {
|
||||
@@ -77,3 +87,17 @@ describe('scanner', () => {
|
||||
).toBeLessThanOrEqual(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quiet hours', () => {
|
||||
const q = parseQuiet('23:00-07:30');
|
||||
it('parses and wraps midnight (Amsterdam time)', () => {
|
||||
expect(q?.label).toBe('23:00–07:30');
|
||||
expect(parseQuiet('off')).toBeNull();
|
||||
expect(inQuiet(fromZoned(2026, 10, 27, 2, 11), q)).toBe(true);
|
||||
expect(inQuiet(fromZoned(2026, 10, 27, 7, 1), q)).toBe(true);
|
||||
expect(inQuiet(fromZoned(2026, 10, 27, 7, 30), q)).toBe(false);
|
||||
expect(inQuiet(fromZoned(2026, 10, 26, 18, 55), q)).toBe(false);
|
||||
expect(inQuiet(fromZoned(2026, 10, 26, 23, 0), q)).toBe(true);
|
||||
expect(inQuiet(fromZoned(2026, 10, 26, 18, 55), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,20 @@ export const ALPHA_TAG_MAX = 16;
|
||||
export const ISS_MIN_EL = 10;
|
||||
export const REMIND_MINUTES = 10;
|
||||
export const ISS_DOWNLINK_HZ = 145_800_000;
|
||||
/** No reminders are sent when the send time falls in this Amsterdam window (RADIO_QUIET_HOURS overrides). */
|
||||
export const QUIET_HOURS = '23:00-07:30';
|
||||
export type Quiet = { from: number; to: number; label: string } | null;
|
||||
/** "23:00-07:30" -> minutes of day; "off" / "" -> null (no quiet hours). */
|
||||
export function parseQuiet(spec: string | null | undefined): Quiet {
|
||||
const m = /^\s*(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})\s*$/.exec(spec ?? '');
|
||||
if (!m) return null;
|
||||
const from = Number(m[1]) * 60 + Number(m[2]);
|
||||
const to = Number(m[3]) * 60 + Number(m[4]);
|
||||
if (from > 1440 || to > 1440 || from === to) return null;
|
||||
const pad = (n: number) =>
|
||||
`${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
|
||||
return { from, to, label: `${pad(from)}–${pad(to)}` };
|
||||
}
|
||||
|
||||
export const DAY_SHORT = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||||
|
||||
@@ -312,3 +326,11 @@ export function alphaTag(name: string, marineCh?: string | null): string {
|
||||
if (marineCh) s = `${marineCh} ${s}`;
|
||||
return s.slice(0, ALPHA_TAG_MAX).trim();
|
||||
}
|
||||
|
||||
/** Whether an instant falls in the quiet window (Amsterdam wall clock; the window may wrap midnight). */
|
||||
export function inQuiet(at: Date, quiet: Quiet): boolean {
|
||||
if (!quiet) return false;
|
||||
const z = zoned(at);
|
||||
const t = z.h * 60 + z.min;
|
||||
return quiet.from < quiet.to ? t >= quiet.from && t < quiet.to : t >= quiet.from || t < quiet.to;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user