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 && (
)}
{/* upcoming */}
{upcoming.length === 0 ? (
{/* import */}
);
}
β°
)}
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
{events.some((e) => e.fired || new Date(e.time).getTime() < now.getTime()) && (
)}
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)}
);
})
)}
{importState.msg && (
{/* manual add */}
{importState.msg}
)}
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."}