import React, { useState, useEffect, useRef, useCallback } from "react"; // ── palette ──────────────────────────────────────────────────────────────── const C = { bg: "#141620", panel: "#1B1E2A", panelHi: "#20243252", line: "#2B2F3E", text: "#EAE7DE", muted: "#868CA0", amber: "#FFB02E", amberDim: "#B67B1E", teal: "#5CD6C0", danger: "#F26D6D", }; const LEAD_MS = 60 * 1000; // fire 1 minute before const pad = (n) => String(n).padStart(2, "0"); function relative(ms) { if (ms <= 0) return "starting"; const min = Math.round(ms / 60000); if (min < 1) return "under a minute"; if (min < 60) return `in ${min} min`; const h = Math.floor(min / 60); const m = min % 60; return m ? `in ${h} h ${m} min` : `in ${h} h`; } export default function CalendarAlarmClock() { const [now, setNow] = useState(new Date()); const [events, setEvents] = useState([]); const [title, setTitle] = useState(""); const [when, setWhen] = useState(() => { const d = new Date(Date.now() + 5 * 60000); d.setSeconds(0, 0); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }); const [armed, setArmed] = useState(false); // sound + notifications enabled const [notifPerm, setNotifPerm] = useState( typeof Notification !== "undefined" ? Notification.permission : "unsupported" ); const [alarm, setAlarm] = useState(null); // the event currently ringing const [importState, setImportState] = useState({ loading: false, msg: "" }); const audioRef = useRef(null); const loadedRef = useRef(false); // ── persistence via artifact storage ────────────────────────────────────── useEffect(() => { (async () => { try { if (typeof window !== "undefined" && window.storage) { const r = await window.storage.get("calendar_alarm_events"); if (r && r.value) setEvents(JSON.parse(r.value)); } } catch (_) { /* first run / no saved data */ } finally { loadedRef.current = true; } })(); }, []); useEffect(() => { if (!loadedRef.current) return; (async () => { try { if (typeof window !== "undefined" && window.storage) { await window.storage.set("calendar_alarm_events", JSON.stringify(events)); } } catch (_) {} })(); }, [events]); // ── clock tick ────────────────────────────────────────────────────────────── useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); // ── audio ─────────────────────────────────────────────────────────────────── const ensureAudio = useCallback(() => { try { if (!audioRef.current) { const AC = window.AudioContext || window.webkitAudioContext; if (AC) audioRef.current = new AC(); } if (audioRef.current && audioRef.current.state === "suspended") { audioRef.current.resume(); } } catch (_) {} }, []); const beep = useCallback((freq = 880) => { const ctx = audioRef.current; if (!ctx) return; const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.type = "square"; o.frequency.value = freq; const t = ctx.currentTime; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.14, t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.28); o.start(t); o.stop(t + 0.3); }, []); // ── enable button: unlock audio + ask notification permission ──────────────── const enable = useCallback(async () => { ensureAudio(); beep(880); setTimeout(() => beep(1180), 160); if (typeof Notification !== "undefined" && Notification.permission === "default") { try { const p = await Notification.requestPermission(); setNotifPerm(p); } catch (_) {} } setArmed(true); }, [ensureAudio, beep]); // ── fire detection ──────────────────────────────────────────────────────────── useEffect(() => { const t = now.getTime(); let toFire = null; let changed = false; const next = events.map((ev) => { if (ev.fired) return ev; const start = new Date(ev.time).getTime(); if (t >= start + 5000) { changed = true; // missed / already started β€” retire silently return { ...ev, fired: true }; } if (t >= start - LEAD_MS) { changed = true; toFire = ev; return { ...ev, fired: true }; } return ev; }); if (changed) setEvents(next); if (toFire) { setAlarm(toFire); if (armed && typeof Notification !== "undefined" && Notification.permission === "granted") { try { new Notification("Starting in 1 minute", { body: toFire.title }); } catch (_) {} } } }, [now, events, armed]); // ── ring while an alarm is active ──────────────────────────────────────────── useEffect(() => { if (!alarm || !armed) return; let n = 0; beep(880); const id = setInterval(() => { beep(n % 2 ? 660 : 880); n += 1; if (n > 40) clearInterval(id); // ~28s then stop on its own }, 700); return () => clearInterval(id); }, [alarm, armed, beep]); // ── actions ─────────────────────────────────────────────────────────────────── const addEvent = () => { if (!title.trim() || !when) return; const iso = new Date(when).toISOString(); setEvents((prev) => [...prev, { id: Date.now() + Math.random(), title: title.trim(), time: iso, fired: false }].sort( (a, b) => new Date(a.time) - new Date(b.time) ) ); setTitle(""); if (!armed) enable(); }; const removeEvent = (id) => setEvents((prev) => prev.filter((e) => e.id !== id)); const clearPast = () => setEvents((prev) => prev.filter((e) => new Date(e.time).getTime() > now.getTime() - 5000)); const dismiss = () => setAlarm(null); const importCalendar = async () => { setImportState({ loading: true, msg: "" }); if (!armed) enable(); try { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1000, messages: [ { role: "user", content: "Use the Google Calendar tool to list my events from right now through the end of today, local time. " + "Respond with ONLY a JSON array and nothing else (no prose, no markdown fences). " + 'Each item must be {"title": string, "start": ISO 8601 string with timezone offset}. ' + "If there are no upcoming events today, return [].", }, ], mcp_servers: [ { type: "url", url: "https://calendarmcp.googleapis.com/mcp/v1", name: "google-calendar" }, ], }), }); const data = await res.json(); const text = (data.content || []) .filter((b) => b.type === "text") .map((b) => b.text) .join("\n") .replace(/```json|```/g, "") .trim(); const start = text.indexOf("["); const end = text.lastIndexOf("]"); const arr = JSON.parse(text.slice(start, end + 1)); const mapped = arr .filter((e) => e && e.title && e.start) .map((e) => ({ id: Date.now() + Math.random(), title: String(e.title), time: new Date(e.start).toISOString(), fired: false, })); setEvents((prev) => { const seen = new Set(prev.map((p) => p.title + "|" + p.time)); const fresh = mapped.filter((m) => !seen.has(m.title + "|" + m.time)); return [...prev, ...fresh].sort((a, b) => new Date(a.time) - new Date(b.time)); }); setImportState({ loading: false, msg: mapped.length ? `Added ${mapped.length} event${mapped.length > 1 ? "s" : ""} from today.` : "No upcoming events today.", }); } catch (err) { setImportState({ loading: false, msg: "Couldn't reach your calendar. Check that Google Calendar is connected, or add events by hand below.", }); } }; // ── derived ─────────────────────────────────────────────────────────────────── const upcoming = events .filter((e) => !e.fired && new Date(e.time).getTime() > now.getTime() - 5000) .sort((a, b) => new Date(a.time) - new Date(b.time)); const nextEv = upcoming[0]; const nextMs = nextEv ? new Date(nextEv.time).getTime() - now.getTime() : Infinity; const proximity = Math.max(0, Math.min(1, 1 - nextMs / (5 * 60000))); // ramps in last 5 min const glow = 6 + proximity * 34; const glowColor = proximity > 0.05 ? C.amber : "#3a4a6a"; const hh = pad(now.getHours()); const mm = pad(now.getMinutes()); const ss = pad(now.getSeconds()); const dateStr = now.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" }); const S = { wrap: { background: C.bg, color: C.text, minHeight: 560, padding: "28px 22px 34px", fontFamily: "'Space Grotesk', system-ui, sans-serif", borderRadius: 16, }, eyebrow: { fontSize: 11, letterSpacing: "0.24em", textTransform: "uppercase", color: C.muted, display: "flex", justifyContent: "space-between", alignItems: "center", }, clock: { fontFamily: "'Share Tech Mono', monospace", fontSize: "clamp(52px, 15vw, 92px)", lineHeight: 1, textAlign: "center", marginTop: 18, color: proximity > 0.05 ? C.amber : C.text, textShadow: `0 0 ${glow}px ${glowColor}`, transition: "color .8s ease, text-shadow .8s ease", fontVariantNumeric: "tabular-nums", }, sec: { fontSize: "0.42em", color: C.muted, marginLeft: 4 }, date: { textAlign: "center", color: C.muted, letterSpacing: "0.14em", marginTop: 8, fontSize: 14 }, label: { fontSize: 11, letterSpacing: "0.2em", textTransform: "uppercase", color: C.muted, marginBottom: 10 }, input: { background: "#12141d", border: `1px solid ${C.line}`, color: C.text, borderRadius: 9, padding: "10px 12px", fontSize: 14, fontFamily: "inherit", outline: "none", }, row: { display: "flex", gap: 10, flexWrap: "wrap" }, }; return (
{alarm && (
⏰
Starting in 1 minute
{alarm.title}
)}
Alarm clock Β· calendar-linked {armed ? "● armed" : "β—‹ muted"}
{hh}:{mm} {ss}
{dateStr.toUpperCase()}
0.05 ? C.amber : C.text) : C.muted, fontSize: 15, }} > {nextEv ? ( <> Next: {nextEv.title} {relative(nextMs)} ) : ( "No alarms set" )}
{!armed && (
Browsers require one tap before an alarm can make sound.
)} {/* upcoming */}
Upcoming
{events.some((e) => e.fired || new Date(e.time).getTime() < now.getTime()) && ( )}
{upcoming.length === 0 ? (
Nothing armed yet. Import your day or add an alarm below.
) : ( upcoming.map((ev) => { const ms = new Date(ev.time).getTime() - now.getTime(); const near = ms < 5 * 60000; return (
{new Date(ev.time).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} {ev.title} {relative(ms)}
); }) )}
{/* import */}
{importState.msg && (
{importState.msg}
)}
{/* manual add */}
Add an alarm
setTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addEvent()} /> setWhen(e.target.value)} />
The alarm fires one minute before each activity. Keep this tab open and your computer awake for it to ring β€” if you close it, nothing sounds. {notifPerm === "denied" && " Notifications are blocked in your browser, so you'll get sound + the banner only."}
); }