feat(dashboard): custom-announcements frontend (Announcements panel)

Extends the existing ScheduledBroadcasts page with an Announcements
section (list + create/edit form) over the announcement_routes.py API
from c0572b04. No new page/route/nav entry.

- AnnouncementsPanel: list (plain-English schedule summary, channel
  summary, enabled toggle, last sent, delete-with-confirm) + a
  create/edit form (message textarea with a live char count against
  the 140-char budget, schedule-kind-specific controls, Preview button
  wired to POST /announcements/{id}/preview -- never sends).
- AnnouncementChannelPicker: polls GET /api/channels (Meshtastic) and
  GET /api/meshcore/channels/detail (MeshCore) and renders both as
  checkbox groups, mixing transports freely. Degrades per radio: shows
  a plain "unreachable" note and still renders any already-selected
  channel by its stored name so an existing announcement stays
  editable even when a radio is down. Blocks save on zero channels.
- New announcements always save disabled (enabled is never sent from
  the create/edit form); the list-row toggle is the only place this
  form family arms one, matching the backend contract where PUT is the
  sole place `enabled` can change.
- lib/api.ts: fetchMeshtasticChannels + the Announcement CRUD/preview
  client functions, following the existing fetchJson/detail-surfacing
  error conventions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-08-16 18:29:26 +00:00
commit c2fd4d7131
4 changed files with 748 additions and 0 deletions

View file

@ -0,0 +1,163 @@
// Checkbox channel picker for custom announcements. Polls both radios for
// their CURRENT channels and lets the owner check any number of them,
// mixing Meshtastic and MeshCore freely. This is deliberately NOT the
// single-transport ChannelPicker.tsx (that one is Meshtastic-only and
// stores plain channel indices) -- announcement channels are a mixed-
// transport list of {transport, channel, name}.
import { useState, useEffect, useCallback } from 'react'
import { Check, AlertTriangle } from 'lucide-react'
import {
fetchMeshtasticChannels, getMeshcoreChannelsDetail,
type MeshtasticChannel, type MeshcoreChannelsDetail, type AnnouncementChannelRef,
} from '@/lib/api'
interface Props {
value: AnnouncementChannelRef[]
onChange: (value: AnnouncementChannelRef[]) => void
}
export default function AnnouncementChannelPicker({ value, onChange }: Props) {
const [mtChannels, setMtChannels] = useState<MeshtasticChannel[] | null>(null)
const [mcDetail, setMcDetail] = useState<MeshcoreChannelsDetail | null>(null)
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
Promise.allSettled([fetchMeshtasticChannels(), getMeshcoreChannelsDetail()]).then(
([mt, mc]) => {
setMtChannels(mt.status === 'fulfilled' ? mt.value : [])
setMcDetail(mc.status === 'fulfilled' ? mc.value : { active: false, channels: [] })
setLoading(false)
}
)
}, [])
useEffect(() => { load() }, [load])
const isSelected = (transport: 'meshtastic' | 'meshcore', channel: number | string) =>
value.some((v) => v.transport === transport && v.channel === channel)
const toggle = (transport: 'meshtastic' | 'meshcore', channel: number | string, name: string) => {
if (isSelected(transport, channel)) {
onChange(value.filter((v) => !(v.transport === transport && v.channel === channel)))
} else {
onChange([...value, { transport, channel, name }])
}
}
if (loading) {
return <div className="text-sm text-slate-500">Polling radios for current channels...</div>
}
// Meshtastic: only channels the radio currently reports as enabled are
// offerable. Empty list (radio unreachable or nothing enabled) still
// needs to surface any channel this announcement already had selected,
// by its stored name, so an existing announcement stays editable.
const mtLive = (mtChannels ?? []).filter((c) => c.enabled)
const mtStaleSelections = value.filter(
(v) => v.transport === 'meshtastic' && !mtLive.some((c) => c.index === v.channel)
)
const mcActive = mcDetail?.active ?? false
const mcLive = mcDetail?.channels ?? []
const mcStaleSelections = value.filter(
(v) => v.transport === 'meshcore' && !mcLive.some((c) => c.name === v.channel)
)
return (
<div className="space-y-4">
{/* Meshtastic */}
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Meshtastic channels</label>
{mtLive.length === 0 && (
<div className="flex items-center gap-2 text-xs text-amber-400 mb-1">
<AlertTriangle size={13} />
Meshtastic radio unreachable, or it isn&apos;t reporting any enabled channels right now.
</div>
)}
<div className="border border-[#1e2a3a] p-2 space-y-1">
{mtLive.map((ch) => (
<label
key={ch.index}
onClick={() => toggle('meshtastic', ch.index, ch.name || `Channel ${ch.index}`)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<CheckBox checked={isSelected('meshtastic', ch.index)} />
<span className="text-sm text-slate-200">
{ch.index}: {ch.name || `Channel ${ch.index}`}
{ch.role === 'PRIMARY' && <span className="text-slate-500"> (Primary)</span>}
</span>
</label>
))}
{mtStaleSelections.map((v) => (
<label
key={`stale-mt-${v.channel}`}
onClick={() => toggle('meshtastic', v.channel, v.name || `Channel ${v.channel}`)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<CheckBox checked />
<span className="text-sm text-slate-400">
{v.name || `Channel ${v.channel}`} <span className="text-slate-600">(not currently reported by the radio)</span>
</span>
</label>
))}
{mtLive.length === 0 && mtStaleSelections.length === 0 && (
<div className="text-sm text-slate-500 p-2">No Meshtastic channels available.</div>
)}
</div>
</div>
{/* MeshCore */}
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">MeshCore channels</label>
{!mcActive && (
<div className="flex items-center gap-2 text-xs text-amber-400 mb-1">
<AlertTriangle size={13} />
MeshCore companion unreachable right now.
</div>
)}
<div className="border border-[#1e2a3a] p-2 space-y-1">
{mcLive.map((ch) => (
<label
key={ch.name}
onClick={() => toggle('meshcore', ch.name, ch.name)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<CheckBox checked={isSelected('meshcore', ch.name)} />
<span className="text-sm text-slate-200">{ch.name}</span>
</label>
))}
{mcStaleSelections.map((v) => (
<label
key={`stale-mc-${v.channel}`}
onClick={() => toggle('meshcore', v.channel, v.name || String(v.channel))}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<CheckBox checked />
<span className="text-sm text-slate-400">
{v.name || v.channel} <span className="text-slate-600">(not currently reported by the companion)</span>
</span>
</label>
))}
{mcLive.length === 0 && mcStaleSelections.length === 0 && (
<div className="text-sm text-slate-500 p-2">No MeshCore channels available.</div>
)}
</div>
</div>
{value.length === 0 && (
<p className="text-xs text-red-400">Select at least one channel -- an announcement with nowhere to send can&apos;t be saved.</p>
)}
</div>
)
}
function CheckBox({ checked }: { checked: boolean }) {
return (
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
checked ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{checked && <Check size={12} className="text-white" />}
</div>
)
}

View file

@ -0,0 +1,463 @@
// Custom scheduled announcements (owner-authored free-text broadcasts on a
// clock-slot schedule). CRUD over /api/announcements -- see
// work/meshai/dashboard/api/announcement_routes.py for the backend
// contract this follows exactly (new rows always start disabled; PUT is
// the only place `enabled` can change; preview never sends).
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Check, X, Loader2, Eye, Megaphone } from 'lucide-react'
import {
fetchAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement,
previewAnnouncement,
type Announcement, type AnnouncementDraft, type AnnouncementChannelRef, type AnnouncementPreview,
} from '@/lib/api'
import { TextInput, NumberInput, TimeInput } from '@/pages/Notifications'
import { SelectInput } from '@/pages/Config'
import AnnouncementChannelPicker from '@/components/AnnouncementChannelPicker'
const DOW_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const MESSAGE_BUDGET_HINT = 140 // typical mesh packet budget; Preview after saving confirms the exact figure
function ordinal(n: number): string {
const rem100 = n % 100
if (rem100 >= 11 && rem100 <= 13) return `${n}th`
switch (n % 10) {
case 1: return `${n}st`
case 2: return `${n}nd`
case 3: return `${n}rd`
default: return `${n}th`
}
}
function describeSchedule(a: {
schedule_kind: string
time_of_day: string
interval_days: number | null
dow_mask: boolean[] | null
day_of_month: number | null
}): string {
const t = a.time_of_day
switch (a.schedule_kind) {
case 'daily':
return `Every day at ${t}`
case 'interval_days': {
const n = a.interval_days ?? 1
return `Every ${n} day${n === 1 ? '' : 's'} at ${t}`
}
case 'weekly': {
const days = (a.dow_mask ?? []).map((on, i) => (on ? DOW_LABELS[i] : null)).filter(Boolean)
return days.length ? `${days.join('/')} at ${t}` : `No days selected, at ${t}`
}
case 'monthly':
return `The ${ordinal(a.day_of_month ?? 1)} of each month at ${t}`
default:
return t
}
}
function channelSummary(channels: AnnouncementChannelRef[]): string {
if (channels.length === 0) return 'No channels'
const names = channels.map((c) => c.name || String(c.channel))
return `${channels.length} channel${channels.length === 1 ? '' : 's'}: ${names.join(', ')}`
}
function formatLastSent(v: number | null): string {
if (!v) return 'Never'
return new Date(v * 1000).toLocaleString()
}
const EMPTY_DRAFT: AnnouncementDraft = {
name: '',
message: '',
schedule_kind: 'daily',
time_of_day: '08:00',
interval_days: 1,
dow_mask: [false, false, false, false, false, false, false],
day_of_month: 1,
timezone: 'America/Boise',
channels: [],
}
export default function AnnouncementsPanel() {
const [rows, setRows] = useState<Announcement[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [formOpen, setFormOpen] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [draft, setDraft] = useState<AnnouncementDraft>(EMPTY_DRAFT)
const [savedMessage, setSavedMessage] = useState<string>('') // the last-persisted message, for the preview staleness note
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [preview, setPreview] = useState<AnnouncementPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [busyRowId, setBusyRowId] = useState<number | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
setRows(await fetchAnnouncements())
} catch (e) {
setLoadError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refresh() }, [refresh])
const openNew = () => {
setEditingId(null)
setDraft({ ...EMPTY_DRAFT, dow_mask: [...EMPTY_DRAFT.dow_mask!] })
setSavedMessage('')
setPreview(null)
setPreviewError(null)
setSaveError(null)
setFormOpen(true)
}
const openEdit = (a: Announcement) => {
setEditingId(a.announcement_id)
setDraft({
name: a.name,
message: a.message,
schedule_kind: a.schedule_kind,
time_of_day: a.time_of_day,
interval_days: a.interval_days ?? 1,
dow_mask: a.dow_mask ?? [false, false, false, false, false, false, false],
day_of_month: a.day_of_month ?? 1,
timezone: a.timezone,
channels: a.channels,
})
setSavedMessage(a.message)
setPreview(null)
setPreviewError(null)
setSaveError(null)
setFormOpen(true)
}
const closeForm = () => {
setFormOpen(false)
setEditingId(null)
setPreview(null)
setPreviewError(null)
setSaveError(null)
}
const save = async () => {
setSaveError(null)
if (!draft.name.trim()) { setSaveError('Name is required.'); return }
if (!draft.message.trim()) { setSaveError('Message is required.'); return }
if (draft.channels.length === 0) { setSaveError('Select at least one channel.'); return }
if (draft.schedule_kind === 'interval_days' && (!draft.interval_days || draft.interval_days < 1)) {
setSaveError('Interval must be at least 1 day.'); return
}
if (draft.schedule_kind === 'weekly' && !(draft.dow_mask ?? []).some(Boolean)) {
setSaveError('Select at least one day of the week.'); return
}
if (draft.schedule_kind === 'monthly' && (!draft.day_of_month || draft.day_of_month < 1 || draft.day_of_month > 31)) {
setSaveError('Day of month must be between 1 and 31.'); return
}
setSaving(true)
try {
if (editingId === null) {
await createAnnouncement(draft)
setNotice('Saved. Enable it when youre ready — new announcements always start disabled.')
} else {
// Never send `enabled` from this form -- the list view's toggle is
// the only thing allowed to arm/disarm an announcement.
await updateAnnouncement(editingId, draft)
setNotice('Saved.')
}
setFormOpen(false)
setEditingId(null)
await refresh()
setTimeout(() => setNotice(null), 5000)
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const runPreview = async () => {
if (editingId === null) return
setPreviewLoading(true)
setPreviewError(null)
try {
setPreview(await previewAnnouncement(editingId))
} catch (e) {
setPreviewError(e instanceof Error ? e.message : String(e))
} finally {
setPreviewLoading(false)
}
}
const toggleEnabled = async (a: Announcement) => {
setBusyRowId(a.announcement_id)
try {
await updateAnnouncement(a.announcement_id, { enabled: !a.enabled })
await refresh()
} catch (e) {
alert(`Could not change enabled state: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusyRowId(null)
}
}
const remove = async (a: Announcement) => {
if (!confirm(`Delete announcement "${a.name}"? This cannot be undone.`)) return
setBusyRowId(a.announcement_id)
try {
await deleteAnnouncement(a.announcement_id)
await refresh()
} catch (e) {
alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`)
setBusyRowId(null)
}
}
const messageLen = draft.message.length
const overBudget = messageLen > MESSAGE_BUDGET_HINT
const messageDirty = editingId !== null && draft.message !== savedMessage
return (
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center gap-2">
<Megaphone size={16} className="text-accent" />
<label className="text-xs text-slate-500 uppercase tracking-wide">Announcements</label>
<span className="text-xs text-slate-600">{rows.length} configured</span>
{!formOpen && (
<button
onClick={openNew}
className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-accent hover:bg-accent/80 text-white text-sm transition-colors"
>
<Plus size={14} /> New announcement
</button>
)}
</div>
<p className="text-xs text-slate-600">
Free-text broadcasts you write and schedule yourself, separate from the automatic hazard/weather
notifications above. New announcements always save disabled &mdash; nothing transmits until you
switch one on below.
</p>
{notice && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />{notice}
</div>
)}
{/* ---- Create / edit form ---- */}
{formOpen && (
<div className="border border-[#2a3a4a] p-4 space-y-4 bg-[#0d1420]">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-200">{editingId === null ? 'New announcement' : 'Edit announcement'}</h3>
<button onClick={closeForm} className="text-slate-500 hover:text-slate-300"><X size={16} /></button>
</div>
{saveError && (
<div className="p-2 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{saveError}</div>
)}
<TextInput
label="Name"
value={draft.name}
onChange={(v) => setDraft({ ...draft, name: v })}
placeholder="Short label, e.g. Range closure notice"
helper="For your own reference in the list below -- not sent on the air."
/>
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Message</label>
<textarea
value={draft.message}
onChange={(e) => setDraft({ ...draft, message: e.target.value })}
rows={4}
placeholder="The exact text to broadcast"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600"
/>
<p className={`text-xs ${overBudget ? 'text-amber-400' : 'text-slate-600'}`}>
{messageLen} / {MESSAGE_BUDGET_HINT} characters
{overBudget && ' — this will be truncated to fit the packet budget. Save, then use Preview below to see exactly what gets sent.'}
</p>
</div>
<SelectInput
label="Schedule"
value={draft.schedule_kind}
onChange={(v) => setDraft({ ...draft, schedule_kind: v as AnnouncementDraft['schedule_kind'] })}
options={[
{ value: 'daily', label: 'Every day' },
{ value: 'interval_days', label: 'Every N days' },
{ value: 'weekly', label: 'Specific days of the week' },
{ value: 'monthly', label: 'Specific day of the month' },
]}
/>
{draft.schedule_kind === 'interval_days' && (
<NumberInput
label="Interval (days)"
value={draft.interval_days ?? 1}
onChange={(v) => setDraft({ ...draft, interval_days: v })}
min={1}
helper="Sends every N days, counted from the first day it fires."
/>
)}
{draft.schedule_kind === 'weekly' && (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Days of the week</label>
<div className="flex flex-wrap gap-2">
{DOW_LABELS.map((label, i) => {
const checked = (draft.dow_mask ?? [])[i] ?? false
return (
<label
key={label}
onClick={() => {
const mask = [...(draft.dow_mask ?? [false, false, false, false, false, false, false])]
mask[i] = !mask[i]
setDraft({ ...draft, dow_mask: mask })
}}
className={`px-3 py-1.5 text-sm cursor-pointer border ${
checked ? 'bg-accent border-accent text-white' : 'border-[#1e2a3a] text-slate-300 hover:bg-[#0a0e17]'
}`}
>
{label}
</label>
)
})}
</div>
</div>
)}
{draft.schedule_kind === 'monthly' && (
<NumberInput
label="Day of month"
value={draft.day_of_month ?? 1}
onChange={(v) => setDraft({ ...draft, day_of_month: v })}
min={1}
max={31}
helper="If the month is shorter than this day (e.g. day 31 in April), it fires on that month's last day instead."
/>
)}
<div className="grid grid-cols-2 gap-3">
<TimeInput
label="Time of day"
value={draft.time_of_day}
onChange={(v) => setDraft({ ...draft, time_of_day: v })}
/>
<TextInput
label="Timezone"
value={draft.timezone}
onChange={(v) => setDraft({ ...draft, timezone: v })}
helper="IANA timezone name, e.g. America/Boise."
/>
</div>
<AnnouncementChannelPicker
value={draft.channels}
onChange={(v) => setDraft({ ...draft, channels: v })}
/>
{/* Preview -- requires a saved row, since the backend reads the
stored message by id. Never sends. */}
<div className="space-y-2 pt-2 border-t border-[#1e2a3a]">
<div className="flex items-center gap-2">
<button
onClick={runPreview}
disabled={editingId === null || previewLoading}
className="flex items-center gap-1 px-3 py-1.5 border border-[#1e2a3a] text-slate-300 hover:bg-[#0a0e17] disabled:opacity-40 disabled:cursor-not-allowed text-sm transition-colors"
>
<Eye size={14} /> {previewLoading ? 'Loading preview...' : 'Preview'}
</button>
<span className="text-xs text-slate-600">
{editingId === null
? 'Save the announcement first, then Preview shows the exact wire text. This never sends anything.'
: 'Shows the exact wire text and size. This never sends anything.'}
</span>
</div>
{messageDirty && editingId !== null && (
<p className="text-xs text-amber-400">You&apos;ve edited the message since it was last saved -- Preview still shows the saved version until you save again.</p>
)}
{previewError && <div className="p-2 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{previewError}</div>}
{preview && (
<div className="p-3 bg-[#0a0e17] border border-[#1e2a3a] space-y-1">
<p className="text-sm text-slate-200 font-mono whitespace-pre-wrap break-words">{preview.wire_text}</p>
<p className={`text-xs ${preview.truncated ? 'text-amber-400' : 'text-slate-500'}`}>
{preview.char_count} characters / {preview.byte_count} bytes (budget {preview.budget})
{preview.truncated ? ' — truncated to fit the budget' : ' — fits, not truncated'}
</p>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button onClick={closeForm} className="px-3 py-1.5 text-slate-400 hover:text-slate-200 text-sm">Cancel</button>
<button
onClick={save}
disabled={saving}
className="flex items-center gap-1 px-4 py-1.5 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed text-white text-sm transition-colors"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
)}
{/* ---- List ---- */}
{loading ? (
<div className="text-sm text-slate-500 flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Loading announcements...</div>
) : loadError ? (
<div className="text-sm text-red-400">Failed to load announcements: {loadError}</div>
) : rows.length === 0 ? (
<div className="text-sm text-slate-500">No announcements yet.</div>
) : (
<div className="border border-border divide-y divide-border">
{rows.map((a) => (
<div key={a.announcement_id} className="p-3 flex items-start gap-3">
<button
onClick={() => toggleEnabled(a)}
disabled={busyRowId === a.announcement_id}
title={a.enabled ? 'Enabled -- click to disable' : 'Disabled -- click to enable'}
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 mt-0.5 disabled:opacity-50 ${
a.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
}`}
>
<span className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${a.enabled ? 'translate-x-5' : ''}`} />
</button>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-100 font-medium">{a.name}</span>
<span className={`text-xs ${a.enabled ? 'text-green-400' : 'text-slate-500'}`}>
{a.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<p className="text-xs text-slate-400">{describeSchedule(a)}</p>
<p className="text-xs text-slate-500 truncate">{channelSummary(a.channels)}</p>
<p className="text-xs text-slate-600">Last sent: {formatLastSent(a.last_sent_at)}</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<button onClick={() => openEdit(a)} className="text-accent hover:text-accent text-xs">Edit</button>
<button
onClick={() => remove(a)}
disabled={busyRowId === a.announcement_id}
className="text-red-400 hover:text-red-300 disabled:opacity-50"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -925,3 +925,120 @@ export async function sendTestMessage(body: {
}
return response.json()
}
// ---- Meshtastic channel list (radio-polled, for channel pickers) ------
export interface MeshtasticChannel {
index: number
name: string
role: 'DISABLED' | 'PRIMARY' | 'SECONDARY' | 'UNKNOWN' | string
enabled: boolean
}
// Returns [] when the radio is unreachable or reports nothing -- there is
// no separate "active" flag on this endpoint (unlike the MeshCore one).
export async function fetchMeshtasticChannels(): Promise<MeshtasticChannel[]> {
return fetchJson<MeshtasticChannel[]>('/api/channels')
}
// ---- Custom scheduled announcements (owner-authored broadcasts) -------
//
// Backend: work/meshai/dashboard/api/announcement_routes.py. New rows
// always start disabled (create never accepts `enabled`); PUT is the only
// place that can arm one. There is deliberately no send-now endpoint.
// A single delivery target on an announcement. `channel` is a Meshtastic
// channel index (int) or a MeshCore channel name (str); `name` is a
// display label captured at save time so the target stays readable even
// if the radio is later unreachable or the channel is renamed/removed.
export interface AnnouncementChannelRef {
transport: 'meshtastic' | 'meshcore'
channel: number | string
name?: string
}
export interface Announcement {
announcement_id: number
name: string
message: string
schedule_kind: 'daily' | 'interval_days' | 'weekly' | 'monthly'
time_of_day: string
interval_days: number | null
dow_mask: boolean[] | null
day_of_month: number | null
timezone: string
channels: AnnouncementChannelRef[]
enabled: boolean
last_sent_at: number | null
created_at: number
updated_at: number
}
// Fields the create/edit form fills in. Shared by POST (as-is) and PUT
// (merged as a partial update by the backend).
export interface AnnouncementDraft {
name: string
message: string
schedule_kind: 'daily' | 'interval_days' | 'weekly' | 'monthly'
time_of_day: string
interval_days?: number | null
dow_mask?: boolean[] | null
day_of_month?: number | null
timezone: string
channels: AnnouncementChannelRef[]
}
export interface AnnouncementPreview {
wire_text: string
char_count: number
byte_count: number
budget: number
truncated: boolean
}
async function announcementError(response: Response): Promise<never> {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
export async function fetchAnnouncements(): Promise<Announcement[]> {
return fetchJson<Announcement[]>('/api/announcements')
}
// Always creates disabled -- there is no `enabled` field in the request body.
export async function createAnnouncement(draft: AnnouncementDraft): Promise<Announcement> {
const response = await fetch('/api/announcements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
})
if (!response.ok) return announcementError(response)
return response.json()
}
// Partial update. Pass only the fields that changed; include `enabled` to
// arm/disarm -- this is the only endpoint that can flip it.
export async function updateAnnouncement(
id: number,
changes: Partial<AnnouncementDraft> & { enabled?: boolean }
): Promise<Announcement> {
const response = await fetch(`/api/announcements/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
if (!response.ok) return announcementError(response)
return response.json()
}
export async function deleteAnnouncement(id: number): Promise<void> {
const response = await fetch(`/api/announcements/${id}`, { method: 'DELETE' })
if (!response.ok) return announcementError(response)
}
// Returns the exact wire text plus size-vs-budget info. Never sends.
export async function previewAnnouncement(id: number): Promise<AnnouncementPreview> {
const response = await fetch(`/api/announcements/${id}/preview`, { method: 'POST' })
if (!response.ok) return announcementError(response)
return response.json()
}

View file

@ -7,6 +7,7 @@ import {
Toggle, NumberInput, TimeInput,
type NotificationsConfig,
} from '@/pages/Notifications'
import AnnouncementsPanel from '@/components/AnnouncementsPanel'
interface Props {
family?: 'meshtastic' | 'meshcore'
@ -214,6 +215,10 @@ export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
)}
<p className="text-xs text-slate-600">All times are Mountain Time (America/Boise). DST handled automatically.</p>
</div>
{/* Announcements -- owner-authored free-text broadcasts, separate from
the automatic band-conditions/hazard notifications above. */}
<AnnouncementsPanel />
</div>
)
}