Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions

Subscribe to a calendar published at a URL
This commit is contained in:
Coffey Labs
2026-09-02 00:54:55 -07:00
committed by GitHub
11 changed files with 885 additions and 25 deletions
+33 -1
View File
@@ -1,10 +1,11 @@
import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X } from "lucide-react";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays";
import { subscriptionCalendarId } from "@/store/calendar";
import { useContacts } from "@/store/contacts";
import { formatMonthYear } from "@/lib/format";
import { formatWeekday } from "@/lib/datetime";
@@ -65,6 +66,17 @@ export function CalendarSidebar() {
if (!cal.available) return null;
const birthdaysOn = useSettings((st) => st.settings.birthdayCalendar);
const subscriptions = useSettings((st) => st.settings.icalSubscriptions);
/*
* Refreshed when the calendar is opened, and not on a timer. ihasmail has
* nowhere to run a schedule -- no worker, no server-side state -- so the
* honest guarantee is that a subscription is as current as the last time
* somebody looked, which is also when it matters.
*/
useEffect(() => {
if (subscriptions.length) void useCalendar.getState().refreshSubscriptions();
}, [subscriptions]);
/*
* The cards have to be loaded for there to be any birthdays to derive, and
* the calendar is a view somebody can land on directly without ever opening
@@ -109,6 +121,26 @@ export function CalendarSidebar() {
<span className="cal-name">{t("Birthdays")}</span>
</div>
)}
{subscriptions.map((sub) => {
const id = subscriptionCalendarId(sub.id);
const failed = cal.subscriptionErrors[sub.id];
const count = cal.subscriptionEvents[sub.id]?.length ?? 0;
return (
<div
key={id}
className={`cal-list-item ${cal.hidden[id] ? "hidden-cal" : ""}`}
onClick={() => cal.toggleHidden(id)}
title={failed ? t("Could not read this calendar: {reason}", { reason: failed }) : t("Subscribed to {url}", { url: sub.url })}
>
<span className="cal-color" style={{ background: sub.color, borderColor: sub.color }} />
<span className="cal-name">{sub.name}</span>
{/* A subscription that cannot be read says so here rather than
drawing an empty calendar, which looks like a calendar with
nothing in it. */}
{failed ? <AlertTriangle size={12} className="faint" aria-label={t("Could not be read")} /> : count === 0 ? null : null}
</div>
);
})}
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
@@ -61,6 +61,49 @@ export function CalendarSettings() {
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button>
<h2>{t("Subscribed calendars")}</h2>
<p className="hint" style={{ marginTop: -8 }}>
{t("A calendar published at a URL — a timetable, a rota, a public holiday list. It is read-only, refreshed when you open the calendar, and never stored: the events are fetched and kept only for as long as this tab is open.")}
</p>
{s.icalSubscriptions.map((sub) => (
<div key={sub.id} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: sub.color, width: 14, height: 14 }} />
<h3>{sub.name}</h3>
<button
className="icon-btn sm danger"
aria-label={t("Remove subscription")}
onClick={() => update({ icalSubscriptions: s.icalSubscriptions.filter((x) => x.id !== sub.id) })}
>
<Trash2 size={16} />
</button>
</div>
<div className="hint truncate notranslate" translate="no">{sub.url}</div>
<div style={{ marginTop: 8 }}>
<ColorSwatches value={sub.color} onChange={(c) => update({ icalSubscriptions: s.icalSubscriptions.map((x) => (x.id === sub.id ? { ...x, color: c } : x)) })} />
</div>
</div>
))}
<button
className="btn"
onClick={async () => {
const url = await promptDialog({ title: t("Subscribe to a calendar"), placeholder: "https://example.com/calendar.ics" });
if (!url?.trim()) return;
const name = await promptDialog({ title: t("What is it called?"), defaultValue: t("Subscribed calendar"), placeholder: t("Name") });
if (!name?.trim()) return;
update({
icalSubscriptions: [
...s.icalSubscriptions,
// webcal: is how these are almost always published; it is an
// https URL wearing a different word, and the server treats it so.
{ id: `ics${Date.now()}`, url: url.trim(), name: name.trim(), color: CALENDAR_COLORS[s.icalSubscriptions.length % CALENDAR_COLORS.length]! },
],
});
}}
>
<Plus size={16} /> {t("Subscribe to a calendar")}
</button>
<h2>{t("Birthdays")}</h2>
<Switch
checked={s.birthdayCalendar}