Calendar: preview a feed, expand repeating events, and a Plus reading

bc256e5 · gmorell · 2026-11-09 05:34

36 files +1117 -50
Message
{commit_body(@commit)}

Files changed

modified apps/room_calendar/lib/room_calendar/calendar_worker.ex
+133 −31
@@ -4,8 +4,12 @@ defmodule RoomCalendar.Worker do
4 4
5 5 require Logger
6 6
7 + import Ecto.Query, only: [from: 2]
8 +
9 + alias RoomCalendar.Occurrences
7 10 alias RoomSanctum.Configuration
8 11 alias RoomSanctum.Storage
12 + alias RoomSanctum.Storage.ICalendar
9 13 alias RoomSanctum.Repo
10 14
11 15 @registry :zeus
@@ -49,42 +53,23 @@ defmodule RoomCalendar.Worker do
49 53 val
50 54 end
51 55
56 + # How far either side of now a repeating event is written out. Back far
57 + # enough that this week's dates are there whatever day it is; ahead as far
58 + # as any board looks.
59 + @days_back 30
60 + @days_ahead 365
61 +
52 62 def handle_cast(:update_static, state) do
53 63 Logger.info("iCAL::#{state.id} updating")
54 − dt = NaiveDateTime.local_now()
55 64 cfg = Configuration.get_source!(state.id)
56 − url = cfg.config.url |> String.replace("webcal://", "http://")
57 −
58 − case HTTPoison.get(url) do
59 − {:ok, result} ->
60 − entries = ICalendar.from_ics(result.body)
61 −
62 − data =
63 − entries
64 − |> Enum.map(fn x ->
65 − %{
66 − blob: x |> Map.from_struct(),
67 − source_id: state.id |> intify,
68 − date_start: x.dtstart,
69 − date_end: x.dtend
70 − }
71 − end)
72 − |> Enum.map(fn x ->
73 − RoomSanctum.Storage.change_icalendar(%RoomSanctum.Storage.ICalendar{}, x).changes
74 − |> Map.put(:inserted_at, dt)
75 − |> Map.put(:updated_at, dt)
76 − end)
77 −
78 − Repo.insert_all(
79 − RoomSanctum.Storage.ICalendar,
80 − data,
81 − on_conflict: {:replace_all_except, [:id]},
82 − conflict_target: [:source_id, :blob]
83 − )
84 − |> IO.inspect()
65 +
66 + case fetch(cfg.config.url) do
67 + {:ok, events} ->
68 + count = store(intify(state.id), window(events))
69 + Logger.info("iCAL::#{state.id} stored #{count} occurrences of #{length(events)} events")
85 70
86 71 {:error, reason} ->
87 − IO.puts("error #{reason.reason}")
72 + Logger.warning("iCAL::#{state.id} #{reason}")
88 73 end
89 74
90 75 {:noreply, state}
@@ -94,5 +79,122 @@ defmodule RoomCalendar.Worker do
94 79 {:noreply, state}
95 80 end
96 81
82 + # The source's rows are the feed as it is now: replaced, not added to. Rows
83 + # were once upserted on their whole content, so a moved or retitled event
84 + # left its old self behind for ever.
85 + defp store(source_id, occurrences) do
86 + dt = NaiveDateTime.local_now()
87 +
88 + rows =
89 + Enum.map(occurrences, fn o ->
90 + %{
91 + source_id: source_id,
92 + date_start: o.start,
93 + date_end: o.end,
94 + blob: blob(o),
95 + inserted_at: dt,
96 + updated_at: dt
97 + }
98 + end)
99 +
100 + {:ok, count} =
101 + Repo.transaction(fn ->
102 + Repo.delete_all(from(ic in ICalendar, where: ic.source_id == ^source_id))
103 +
104 + rows
105 + |> Enum.chunk_every(1_000)
106 + |> Enum.reduce(0, fn chunk, n ->
107 + # A feed listing the same event twice is one row, not a failed update.
108 + {k, _} = Repo.insert_all(ICalendar, chunk, on_conflict: :nothing)
109 + n + k
110 + end)
111 + end)
112 +
113 + count
114 + end
115 +
116 + # What the condensers and pages read. The start goes in too: every date of a
117 + # series is otherwise the same blob, and a source's blobs are unique.
118 + defp blob(o) do
119 + %{
120 + "summary" => o.summary,
121 + "description" => o.description,
122 + "location" => o.location,
123 + "uid" => o.uid,
124 + "all_day" => o.all_day,
125 + "rrule" => o.rrule,
126 + "occurrence" => DateTime.to_iso8601(o.start)
127 + }
128 + end
129 +
130 + defp window(events) do
131 + now = DateTime.utc_now()
132 + Occurrences.expand(events, DateTime.add(now, -@days_back, :day), DateTime.add(now, @days_ahead, :day))
133 + end
134 +
135 + @doc """
136 + Fetch and parse a feed without storing anything.
137 +
138 + `{:ok, events}` or `{:error, reason}`, the reason a sentence a person can
139 + act on. A page that is not iCalendar at all -- a provider's "subscribe"
140 + widget is the usual one -- parses to no events rather than failing, so it is
141 + caught here before it can look like an empty calendar.
142 + """
143 + def fetch(url) do
144 + url = String.replace(url, "webcal://", "https://")
145 +
146 + case HTTPoison.get(url, [], follow_redirect: true, recv_timeout: 15_000) do
147 + {:ok, %{status_code: 200, body: body} = resp} ->
148 + if String.contains?(body, "BEGIN:VCALENDAR") do
149 + parse(body)
150 + else
151 + {:error, "not an iCalendar feed (got #{content_type(resp)}, no BEGIN:VCALENDAR)"}
152 + end
153 +
154 + {:ok, %{status_code: code}} ->
155 + {:error, "HTTP #{code}"}
156 +
157 + {:error, %HTTPoison.Error{reason: reason}} ->
158 + {:error, "request failed: #{inspect(reason)}"}
159 + end
160 + end
161 +
162 + defp parse(body) do
163 + {:ok, ICal.from_ics(body).events}
164 + rescue
165 + e -> {:error, "could not read the feed: #{Exception.message(e)}"}
166 + end
167 +
168 + @doc """
169 + What a feed holds that is not over yet: the next `limit` occurrences, with
170 + repeating events written out, and the counts around them -- for checking a
171 + URL before (or instead of) storing it.
172 + """
173 + def preview(url, limit \\ 15) do
174 + with {:ok, events} <- fetch(url) do
175 + now = DateTime.utc_now()
176 +
177 + upcoming =
178 + events
179 + |> window()
180 + # Not yet over, so today's all-day events -- midnight UTC -- stay in.
181 + |> Enum.filter(&(DateTime.compare(&1.end || &1.start, now) == :gt))
182 +
183 + {:ok,
184 + %{
185 + total: length(events),
186 + upcoming: length(upcoming),
187 + recurring: Enum.count(events, &(&1.rrule && is_nil(&1.recurrence_id))),
188 + events: Enum.take(upcoming, limit)
189 + }}
190 + end
191 + end
192 +
193 + defp content_type(%{headers: headers}) do
194 + Enum.find_value(headers, "unknown content", fn {k, v} ->
195 + if String.downcase(k) == "content-type", do: v
196 + end)
197 + end
198 +
97 199 defp via_tuple(name), do: {:via, Registry, {@registry, name}}
98 200 end
added apps/room_calendar/lib/room_calendar/occurrences.ex
+134 −0
@@ -0,0 +1,134 @@
1 +defmodule RoomCalendar.Occurrences do
2 + @moduledoc """
3 + A feed's events as the dates they actually happen on.
4 +
5 + A repeating event is one VEVENT with an RRULE, not one per date, so storing
6 + events as they come stores the first date and nothing after. This expands
7 + each rule over a window, in the event's own zone -- 7pm every third Monday
8 + stays 7pm across the clock change -- and settles the two ways a feed edits
9 + one date of a series:
10 +
11 + * EXDATE on the series drops a date; the rule expansion applies it.
12 + * A second VEVENT with the series' UID and a RECURRENCE-ID replaces the
13 + date it names, moved, retitled or cancelled. Google writes most edits
14 + this way, so the generated date it replaces is dropped here and the
15 + override stands in its place.
16 +
17 + Cancelled events, and cancelled overrides with them, are left out.
18 + """
19 +
20 + # A runaway rule -- one that matches nothing, or matches every second --
21 + # stops here rather than taking the worker with it.
22 + @max_per_rule 2_000
23 +
24 + @type t :: %{
25 + start: DateTime.t(),
26 + end: DateTime.t() | nil,
27 + all_day: boolean,
28 + summary: String.t() | nil,
29 + description: String.t() | nil,
30 + location: String.t() | nil,
31 + uid: String.t() | nil,
32 + rrule: map | nil
33 + }
34 +
35 + @doc """
36 + Every occurrence of `events` overlapping `from`..`to`, soonest first, with
37 + start and end in UTC. All-day occurrences start at midnight UTC on their
38 + date, which is how the rest of the app reads a whole day.
39 + """
40 + @spec expand([ICal.Event.t()], DateTime.t(), DateTime.t()) :: [t()]
41 + def expand(events, from, to) do
42 + replaced =
43 + events
44 + |> Enum.filter(& &1.recurrence_id)
45 + |> MapSet.new(&{&1.uid, instant(&1.recurrence_id)})
46 +
47 + events
48 + |> Enum.filter(& &1.dtstart)
49 + |> Enum.flat_map(fn e ->
50 + if e.rrule && is_nil(e.recurrence_id) do
51 + e
52 + |> ICal.Recurrence.stream()
53 + |> Stream.take(@max_per_rule)
54 + |> Stream.take_while(&(DateTime.compare(instant(&1), to) == :lt))
55 + |> Stream.reject(&MapSet.member?(replaced, {e.uid, instant(&1)}))
56 + |> Enum.map(&occurrence(e, &1))
57 + else
58 + [occurrence(e, e.dtstart)]
59 + end
60 + end)
61 + |> Enum.reject(&(&1.status == :cancelled))
62 + |> Enum.filter(&overlaps?(&1, from, to))
63 + |> Enum.sort_by(& &1.start, DateTime)
64 + |> Enum.map(&Map.delete(&1, :status))
65 + end
66 +
67 + defp occurrence(e, start) do
68 + {s, f, all_day} = span(e, start)
69 +
70 + %{
71 + start: s,
72 + end: f,
73 + all_day: all_day,
74 + summary: e.summary,
75 + description: e.description,
76 + location: e.location,
77 + uid: e.uid,
78 + status: e.status,
79 + # The series' rule, on every date it made. An override is a date of a
80 + # series too, though it carries no rule of its own.
81 + rrule: rule(e)
82 + }
83 + end
84 +
85 + # An occurrence lasts as long as the event it came from: DTEND less DTSTART,
86 + # or DURATION, or -- with neither -- a whole day for a date, and no length
87 + # at all for a time.
88 + defp span(e, %Date{} = day) do
89 + days =
90 + cond do
91 + match?(%Date{}, e.dtend) -> max(Date.diff(e.dtend, e.dtstart), 1)
92 + e.duration -> max(div(seconds(e.duration), 86_400), 1)
93 + true -> 1
94 + end
95 +
96 + s = DateTime.new!(day, ~T[00:00:00], "Etc/UTC")
97 + {s, DateTime.add(s, days, :day), true}
98 + end
99 +
100 + defp span(e, start) do
101 + s = instant(start)
102 +
103 + f =
104 + cond do
105 + e.dtend -> DateTime.add(s, DateTime.diff(instant(e.dtend), instant(e.dtstart)))
106 + e.duration -> DateTime.add(s, seconds(e.duration))
107 + true -> nil
108 + end
109 +
110 + {s, f, false}
111 + end
112 +
113 + defp overlaps?(%{start: s, end: f}, from, to) do
114 + DateTime.compare(s, to) == :lt and DateTime.compare(f || s, from) != :lt
115 + end
116 +
117 + # Floating times name no zone; read as UTC, as the old parser did.
118 + defp instant(%DateTime{} = dt), do: DateTime.shift_zone!(dt, "Etc/UTC")
119 + defp instant(%NaiveDateTime{} = dt), do: DateTime.from_naive!(dt, "Etc/UTC")
120 + defp instant(%Date{} = d), do: DateTime.new!(d, ~T[00:00:00], "Etc/UTC")
121 +
122 + defp seconds(%ICal.Duration{positive: pos, weeks: w, days: d, time: {h, m, s}}) do
123 + total = ((w * 7 + d) * 24 + h) * 3600 + m * 60 + s
124 + if pos, do: total, else: -total
125 + end
126 +
127 + # Enough to say that it repeats and how often; the rule itself has done its
128 + # work by the time anything is stored.
129 + defp rule(%{rrule: %ICal.Recurrence{frequency: freq, interval: interval}}),
130 + do: %{"freq" => Atom.to_string(freq), "interval" => interval}
131 +
132 + defp rule(%{recurrence_id: rid}) when not is_nil(rid), do: %{"override" => true}
133 + defp rule(_), do: nil
134 +end
modified apps/room_calendar/mix.exs
+1 −1
@@ -25,7 +25,7 @@ defmodule RoomCalendar.MixProject do
25 25 # Run "mix help deps" to learn about dependencies.
26 26 defp deps do
27 27 [
28 − {:icalendar, "~> 1.1.0"},
28 + {:ical, "~> 3.2"},
29 29 {:room_sanctum, in_umbrella: true}
30 30 # {:dep_from_hexpm, "~> 0.3.0"},
31 31 # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
added apps/room_calendar/test/room_calendar_occurrences_test.exs
+108 −0
@@ -0,0 +1,108 @@
1 +defmodule RoomCalendar.OccurrencesTest do
2 + @moduledoc """
3 + A feed in the shape Google writes one: a monthly series on an ordinal
4 + weekday, one date dropped by EXDATE, one moved by an override, one cancelled
5 + by an override, and an all-day event beside them.
6 + """
7 + use ExUnit.Case, async: true
8 +
9 + alias RoomCalendar.Occurrences
10 +
11 + @ics """
12 + BEGIN:VCALENDAR
13 + VERSION:2.0
14 + BEGIN:VEVENT
15 + UID:laser@example
16 + SUMMARY:CO2 Laser Training
17 + DTSTART;TZID=America/New_York:20260921T190000
18 + DTEND;TZID=America/New_York:20260921T210000
19 + RRULE:FREQ=MONTHLY;BYDAY=3MO
20 + EXDATE;TZID=America/New_York:20261221T190000
21 + END:VEVENT
22 + BEGIN:VEVENT
23 + UID:laser@example
24 + RECURRENCE-ID;TZID=America/New_York:20261116T190000
25 + SUMMARY:CO2 Laser Training (moved)
26 + DTSTART;TZID=America/New_York:20261117T180000
27 + DTEND;TZID=America/New_York:20261117T200000
28 + END:VEVENT
29 + BEGIN:VEVENT
30 + UID:laser@example
31 + RECURRENCE-ID;TZID=America/New_York:20270118T190000
32 + STATUS:CANCELLED
33 + SUMMARY:CO2 Laser Training
34 + DTSTART;TZID=America/New_York:20270118T190000
35 + DTEND;TZID=America/New_York:20270118T210000
36 + END:VEVENT
37 + BEGIN:VEVENT
38 + UID:hack@example
39 + SUMMARY:Hack the Space
40 + DTSTART;VALUE=DATE:20261031
41 + DTEND;VALUE=DATE:20261101
42 + RRULE:FREQ=MONTHLY;BYDAY=-1SA;COUNT=3
43 + END:VEVENT
44 + BEGIN:VEVENT
45 + UID:once@example
46 + SUMMARY:Long ago
47 + DTSTART:20200101T120000Z
48 + DTEND:20200101T130000Z
49 + END:VEVENT
50 + END:VCALENDAR
51 + """
52 +
53 + defp expand(from \\ ~U[2026-10-01 00:00:00Z], to \\ ~U[2027-03-01 00:00:00Z]) do
54 + @ics |> ICal.from_ics() |> Map.fetch!(:events) |> Occurrences.expand(from, to)
55 + end
56 +
57 + defp starts(summary_prefix) do
58 + expand()
59 + |> Enum.filter(&String.starts_with?(&1.summary, summary_prefix))
60 + |> Enum.map(& &1.start)
61 + end
62 +
63 + test "an ordinal-weekday series is written out, 7pm local across the clock change" do
64 + # Oct is EDT (UTC-4), Feb is EST (UTC-5): the same 19:00 in New York.
65 + assert ~U[2026-10-19 23:00:00Z] in starts("CO2")
66 + assert ~U[2027-02-16 00:00:00Z] in starts("CO2")
67 + end
68 +
69 + test "EXDATE drops its date, an override replaces its, a cancelled override removes its" do
70 + laser = expand() |> Enum.filter(&(&1.uid == "laser@example"))
71 +
72 + assert Enum.map(laser, &{&1.summary, &1.start}) == [
73 + {"CO2 Laser Training", ~U[2026-10-19 23:00:00Z]},
74 + # 16 Nov moved to the 17th at 6pm EST.
75 + {"CO2 Laser Training (moved)", ~U[2026-11-17 23:00:00Z]},
76 + # 21 Dec excluded; 18 Jan cancelled.
77 + {"CO2 Laser Training", ~U[2027-02-16 00:00:00Z]}
78 + ]
79 + end
80 +
81 + test "each date keeps the event's length, and says which series it came from" do
82 + [first | _] = expand()
83 +
84 + assert first.end == DateTime.add(first.start, 2, :hour)
85 + assert first.rrule == %{"freq" => "monthly", "interval" => 1}
86 + refute first.all_day
87 + end
88 +
89 + test "an override is a date of a series though it carries no rule" do
90 + assert %{rrule: %{"override" => true}} = Enum.find(expand(), &(&1.summary =~ "moved"))
91 + end
92 +
93 + test "all-day dates are midnight UTC to midnight UTC, flagged, and honour COUNT" do
94 + hack = expand() |> Enum.filter(&(&1.summary == "Hack the Space"))
95 +
96 + assert Enum.map(hack, & &1.start) ==
97 + [~U[2026-10-31 00:00:00Z], ~U[2026-11-28 00:00:00Z], ~U[2026-12-26 00:00:00Z]]
98 +
99 + assert Enum.all?(hack, &(&1.all_day and &1.end == DateTime.add(&1.start, 1, :day)))
100 + end
101 +
102 + test "only what overlaps the window, soonest first" do
103 + all = expand()
104 +
105 + refute Enum.any?(all, &(&1.summary == "Long ago"))
106 + assert all == Enum.sort_by(all, & &1.start, DateTime)
107 + end
108 +end
added apps/room_calendar/test/room_calendar_worker_test.exs
+69 −0
@@ -0,0 +1,69 @@
1 +defmodule RoomCalendar.WorkerTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias RoomCalendar.Worker
5 +
6 + # A one-shot HTTP server on a free port, answering every request with `body`.
7 + defp serve(content_type, body) do
8 + {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true])
9 + {:ok, port} = :inet.port(listen)
10 +
11 + spawn_link(fn ->
12 + {:ok, sock} = :gen_tcp.accept(listen)
13 + {:ok, _request} = :gen_tcp.recv(sock, 0)
14 +
15 + :gen_tcp.send(sock, [
16 + "HTTP/1.1 200 OK\r\ncontent-type: #{content_type}\r\n",
17 + "content-length: #{byte_size(body)}\r\nconnection: close\r\n\r\n",
18 + body
19 + ])
20 +
21 + :gen_tcp.close(sock)
22 + end)
23 +
24 + "http://127.0.0.1:#{port}/feed"
25 + end
26 +
27 + test "an HTML page is not an empty calendar" do
28 + url = serve("text/html; charset=utf-8", "<!doctype html><html></html>")
29 +
30 + assert {:error, reason} = Worker.fetch(url)
31 + assert reason =~ "not an iCalendar feed"
32 + assert reason =~ "text/html"
33 + end
34 +
35 + test "nobody answering is a failed request" do
36 + assert {:error, "request failed: " <> _} = Worker.fetch("http://127.0.0.1:9/cal.ics")
37 + end
38 +
39 + test "preview writes out repeating events and keeps what is still to come" do
40 + future = Date.utc_today() |> Date.add(10) |> Calendar.strftime("%Y%m%d")
41 + sooner = Date.utc_today() |> Date.add(2) |> Calendar.strftime("%Y%m%d")
42 +
43 + ics = """
44 + BEGIN:VCALENDAR
45 + VERSION:2.0
46 + BEGIN:VEVENT
47 + UID:later
48 + SUMMARY:Later
49 + DTSTART;VALUE=DATE:#{future}
50 + END:VEVENT
51 + BEGIN:VEVENT
52 + UID:past
53 + SUMMARY:Past
54 + DTSTART;VALUE=DATE:20010101
55 + END:VEVENT
56 + BEGIN:VEVENT
57 + UID:sooner
58 + SUMMARY:Sooner
59 + DTSTART;VALUE=DATE:#{sooner}
60 + RRULE:FREQ=WEEKLY;COUNT=3
61 + END:VEVENT
62 + END:VCALENDAR
63 + """
64 +
65 + assert {:ok, preview} = Worker.preview(serve("text/calendar", ics))
66 + assert %{total: 3, upcoming: 4, recurring: 1} = preview
67 + assert Enum.map(preview.events, & &1.summary) == ["Sooner", "Sooner", "Later", "Sooner"]
68 + end
69 +end
modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+3 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+36 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/storage.ex
+52 −9

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/storage/calendar.ex
+53 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+94 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+50 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+79 −6

Click to load diff…

added apps/room_sanctum/test/room_sanctum/calendar_condense_test.exs
+144 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/components/calendar_plus_test.exs
+79 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/live/source_live_calendar_preview_test.exs
+75 −0

Click to load diff…

modified config/config.exs
+6 −0

Click to load diff…

modified mix.lock
+1 −1

Click to load diff…

Parents: 7cea53b