diff --git a/work/dashboard-frontend/src/components/AnnouncementChannelPicker.tsx b/work/dashboard-frontend/src/components/AnnouncementChannelPicker.tsx new file mode 100644 index 0000000..2d2b8c7 --- /dev/null +++ b/work/dashboard-frontend/src/components/AnnouncementChannelPicker.tsx @@ -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(null) + const [mcDetail, setMcDetail] = useState(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
Polling radios for current channels...
+ } + + // 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 ( +
+ {/* Meshtastic */} +
+ + {mtLive.length === 0 && ( +
+ + Meshtastic radio unreachable, or it isn't reporting any enabled channels right now. +
+ )} +
+ {mtLive.map((ch) => ( + + ))} + {mtStaleSelections.map((v) => ( + + ))} + {mtLive.length === 0 && mtStaleSelections.length === 0 && ( +
No Meshtastic channels available.
+ )} +
+
+ + {/* MeshCore */} +
+ + {!mcActive && ( +
+ + MeshCore companion unreachable right now. +
+ )} +
+ {mcLive.map((ch) => ( + + ))} + {mcStaleSelections.map((v) => ( + + ))} + {mcLive.length === 0 && mcStaleSelections.length === 0 && ( +
No MeshCore channels available.
+ )} +
+
+ + {value.length === 0 && ( +

Select at least one channel -- an announcement with nowhere to send can't be saved.

+ )} +
+ ) +} + +function CheckBox({ checked }: { checked: boolean }) { + return ( +
+ {checked && } +
+ ) +} diff --git a/work/dashboard-frontend/src/components/AnnouncementsPanel.tsx b/work/dashboard-frontend/src/components/AnnouncementsPanel.tsx new file mode 100644 index 0000000..930bbde --- /dev/null +++ b/work/dashboard-frontend/src/components/AnnouncementsPanel.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + + const [formOpen, setFormOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [draft, setDraft] = useState(EMPTY_DRAFT) + const [savedMessage, setSavedMessage] = useState('') // the last-persisted message, for the preview staleness note + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [notice, setNotice] = useState(null) + + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState(null) + + const [busyRowId, setBusyRowId] = useState(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 you’re 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 ( +
+
+ + + {rows.length} configured + {!formOpen && ( + + )} +
+

+ Free-text broadcasts you write and schedule yourself, separate from the automatic hazard/weather + notifications above. New announcements always save disabled — nothing transmits until you + switch one on below. +

+ + {notice && ( +
+ {notice} +
+ )} + + {/* ---- Create / edit form ---- */} + {formOpen && ( +
+
+

{editingId === null ? 'New announcement' : 'Edit announcement'}

+ +
+ + {saveError && ( +
{saveError}
+ )} + + 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." + /> + +
+ +