feat(hobbs-organizer): v1.4.0 per-TV morning routine (admin TVs tab) + photo app 1.2.1

- Admin TVs tab: wake time/days, YouTube Music playlist, Shield volume per TV,
  pushed via 5-min TV check-ins (/api/tv/config, /api/tv/report, LAN-only)
- com.dayline.photos 1.2.1: MorningActivity (screen on, volume, playlist),
  calendar alarm at wake time, Doze-safe chained check-ins
- Both TVs configured for 06:30 daily with the family playlist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 22:48:50 -06:00
parent 2d16ded0c1
commit 6e94a0c998
22 changed files with 1083 additions and 18 deletions
@@ -27,7 +27,8 @@ type DraftCalendar = {
test?: TestResult | "running";
};
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
type Tab = "calendars" | "photos" | "account";
type Tab = "calendars" | "photos" | "tvs" | "account";
const TAB_LABELS: Record<Tab, string> = { calendars: "Calendars", photos: "Photos", tvs: "TVs", account: "Account" };
async function api<T>(path: string, init?: RequestInit & { body?: string }) {
const response = await fetch(path, {
@@ -372,6 +373,10 @@ type UploadView = { key: string; thumb: string; src: string };
type PhotosView = { display: PhotoDisplay; albums: AlbumView[]; uploads: UploadView[] };
type AlbumDraft = { key: string; id?: string; name: string; enabled: boolean; shareUrl: string; view?: AlbumView; check?: string };
function minutesSince(timestamp: number) {
return (Date.now() - timestamp) / 60_000;
}
function ago(timestamp: number | null) {
if (!timestamp) return "never";
const minutes = Math.round((Date.now() - timestamp) / 60_000);
@@ -693,6 +698,255 @@ function PhotosTab() {
);
}
type TvStatusView = {
deviceName: string;
model: string;
ip: string;
appVersion: string;
lastSeenAt: number;
appliedVersion: string;
lastEvent: { at: number; kind: string; ok: boolean; detail: string } | null;
};
type TvView = {
id: string;
name: string;
wake: { enabled: boolean; time: string; days: number[] };
music: { enabled: boolean; playlistId: string };
volume: { enabled: boolean; level: number };
playlistUrl: string;
version: string;
status: (TvStatusView & { id: string }) | null;
};
type TvsView = { checkInMinutes: number; tvs: TvView[]; found: (TvStatusView & { id: string })[] };
type TvDraft = {
id: string;
name: string;
wake: { enabled: boolean; time: string; days: number[] };
music: { enabled: boolean; playlist: string };
volume: { enabled: boolean; level: number };
};
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function tvDraft(tv: TvView): TvDraft {
return { id: tv.id, name: tv.name, wake: { ...tv.wake }, music: { enabled: tv.music.enabled, playlist: tv.playlistUrl }, volume: { ...tv.volume } };
}
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
return (
<label className="flex cursor-pointer items-center gap-2 text-sm font-semibold">
<input type="checkbox" className="h-4 w-4 accent-cyan-300" checked={checked} onChange={(event) => onChange(event.target.checked)} />
{label}
</label>
);
}
function TvStatusLine({ tv, checkInMinutes }: { tv: TvView; checkInMinutes: number }) {
const status = tv.status;
if (!status) return <span className="text-amber-300">Never checked in</span>;
const applied = status.appliedVersion === tv.version;
const seenMinutes = minutesSince(status.lastSeenAt);
return (
<span className="space-x-2">
{applied ? (
<span className="text-emerald-300">✓ Settings are on the TV</span>
) : (
<span className="text-amber-300">Waiting for the TV to pick up changes (it checks every {checkInMinutes} min)</span>
)}
<span className={seenMinutes > checkInMinutes * 3 ? "text-rose-300" : "text-slate-500"}>· last check-in {ago(status.lastSeenAt)}</span>
</span>
);
}
function TvsTab() {
const [view, setView] = useState<TvsView | null>(null);
const [drafts, setDrafts] = useState<TvDraft[]>([]);
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const apply = useCallback((next: TvsView) => {
setView(next);
setDrafts(next.tvs.map(tvDraft));
}, []);
useEffect(() => {
void (async () => {
const result = await api<TvsView>("/api/admin/tvs");
if (result.ok) apply(result.data);
})();
// Keep check-in status fresh without touching unsaved edits.
const timer = window.setInterval(async () => {
const result = await api<TvsView>("/api/admin/tvs");
if (result.ok) setView(result.data);
}, 15000);
return () => window.clearInterval(timer);
}, [apply]);
const saved = JSON.stringify(view?.tvs.map(tvDraft) ?? []);
const dirty = view !== null && JSON.stringify(drafts) !== saved;
const update = (id: string, change: (draft: TvDraft) => TvDraft) => setDrafts((current) => current.map((draft) => (draft.id === id ? change(draft) : draft)));
const add = (found: TvsView["found"][number]) =>
setDrafts((current) => [
...current,
{
id: found.id,
name: found.deviceName || found.model || "TV",
wake: { enabled: true, time: "06:30", days: [0, 1, 2, 3, 4, 5, 6] },
music: { enabled: false, playlist: "" },
volume: { enabled: false, level: 40 },
},
]);
const save = async () => {
setBusy(true);
setMessage(null);
const result = await api<TvsView>("/api/admin/tvs", { method: "PUT", body: JSON.stringify({ tvs: drafts }) });
setBusy(false);
if (result.ok) {
apply(result.data);
setMessage({ tone: "ok", text: `Saved. Each TV picks up its settings within ${result.data.checkInMinutes} minutes; the status below turns green when it has.` });
} else setMessage({ tone: "error", text: result.data.error || "Save failed." });
};
if (!view) return <p className="text-sm text-slate-400">Loading…</p>;
const byId = new Map(view.tvs.map((tv) => [tv.id, tv]));
const draftIds = new Set(drafts.map((draft) => draft.id));
const found = view.found.filter((entry) => !draftIds.has(entry.id));
return (
<div className="space-y-5">
<p className="text-sm text-slate-400">
Morning routine for each TV, run by the <b>Hobbs Family Photos</b> app: wake the TV, set the volume, start the playlist, and bring up the
calendar at the wake time (music starts 30 seconds earlier). The TV turns on through HDMI-CEC.
</p>
{drafts.map((draft) => {
const tv = byId.get(draft.id);
const status = tv?.status ?? view.found.find((entry) => entry.id === draft.id) ?? null;
return (
<section key={draft.id} className="space-y-4 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-1">
<input className={inputClass} value={draft.name} maxLength={40} placeholder="TV name" onChange={(event) => update(draft.id, (d) => ({ ...d, name: event.target.value }))} />
<p className="text-xs text-slate-500">
{status ? `${status.deviceName || status.model} · ${status.ip} · photo app ${status.appVersion}` : draft.id}
</p>
<p className="text-xs">{tv ? <TvStatusLine tv={tv} checkInMinutes={view.checkInMinutes} /> : <span className="text-amber-300">New — save to send settings</span>}</p>
{status?.lastEvent && (
<p className={`text-xs ${status.lastEvent.ok ? "text-slate-400" : "text-rose-300"}`}>
Last morning routine {ago(status.lastEvent.at)}: {status.lastEvent.ok ? "✓ " : "✗ "}
{status.lastEvent.detail}
</p>
)}
</div>
<button type="button" className={dangerButton} onClick={() => setDrafts((current) => current.filter((entry) => entry.id !== draft.id))}>
Remove
</button>
</div>
<div className="space-y-2 border-t border-white/5 pt-3">
<Toggle label="Wake up and show the calendar" checked={draft.wake.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, wake: { ...d.wake, enabled: value } }))} />
<div className={`flex flex-wrap items-center gap-2 pl-6 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<input
type="time"
className={inputClass.replace("w-full", "w-36")}
value={draft.wake.time}
disabled={!draft.wake.enabled}
onChange={(event) => update(draft.id, (d) => ({ ...d, wake: { ...d.wake, time: event.target.value } }))}
/>
{DAY_LABELS.map((label, day) => {
const on = draft.wake.days.includes(day);
return (
<button
key={label}
type="button"
disabled={!draft.wake.enabled}
onClick={() =>
update(draft.id, (d) => ({
...d,
wake: { ...d.wake, days: on ? d.wake.days.filter((entry) => entry !== day) : [...d.wake.days, day].sort() },
}))
}
className={`rounded-md px-2 py-1 text-xs font-semibold ${on ? "bg-cyan-300 text-slate-950" : "bg-white/5 text-slate-400"}`}
>
{label}
</button>
);
})}
</div>
</div>
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<Toggle label="Play music" checked={draft.music.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, music: { ...d.music, enabled: value } }))} />
<div className="pl-6">
<input
className={inputClass}
placeholder="YouTube Music playlist link (Share → Copy link)"
value={draft.music.playlist}
onChange={(event) => update(draft.id, (d) => ({ ...d, music: { ...d.music, playlist: event.target.value } }))}
/>
</div>
</div>
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<Toggle label="Set the volume" checked={draft.volume.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, enabled: value } }))} />
<div className="flex items-center gap-3 pl-6">
<input
type="range"
min={0}
max={100}
step={5}
className="flex-1 accent-cyan-300"
value={draft.volume.level}
disabled={!draft.volume.enabled}
onChange={(event) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, level: Number(event.target.value) } }))}
/>
<span className="w-12 text-right text-sm tabular-nums">{draft.volume.level}%</span>
</div>
<p className="pl-6 text-xs text-slate-500">This is the Shield&apos;s own volume; the TV&apos;s volume still applies on top.</p>
</div>
</section>
);
})}
{!drafts.length && <p className="text-sm text-slate-400">No TVs set up yet.</p>}
{found.length > 0 && (
<section className="space-y-2 rounded-xl border border-dashed border-white/15 p-4">
<h2 className="text-sm font-bold">TVs found on the network</h2>
{found.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 text-sm">
<span className="flex-1">
{entry.deviceName || entry.model} <span className="text-slate-500">· {entry.ip} · checked in {ago(entry.lastSeenAt)}</span>
</span>
<button type="button" className={ghostButton} onClick={() => add(entry)}>
Add
</button>
</div>
))}
</section>
)}
{!found.length && !drafts.length && (
<p className="text-xs text-slate-500">TVs appear here once the Hobbs Family Photos app (version 1.2 or later) has checked in.</p>
)}
<div className="flex items-center gap-2">
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save and send to TVs"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => apply(view)}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
</div>
);
}
function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
@@ -752,7 +1006,7 @@ export default function AdminPage() {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
void check();
const fromHash = window.location.hash.replace("#", "") as Tab;
if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash);
if (fromHash in TAB_LABELS) setTab(fromHash);
}, [check]);
const selectTab = (next: Tab) => {
@@ -777,19 +1031,20 @@ export default function AdminPage() {
</div>
</header>
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
{(["calendars", "photos", "account"] as Tab[]).map((name) => (
{(Object.keys(TAB_LABELS) as Tab[]).map((name) => (
<button
key={name}
type="button"
onClick={() => selectTab(name)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold capitalize ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
>
{name}
{TAB_LABELS[name]}
</button>
))}
</nav>
{tab === "calendars" && <CalendarsTab />}
{tab === "photos" && <PhotosTab />}
{tab === "tvs" && <TvsTab />}
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
</>
)}