Reminders are planned and fired in-house instead of waiting for a provider
broadcast that some devices never send (#75), Settings → Calendars becomes the
one visibility model, calendars say what is different about them (#76, #78),
and Settings is sorted into places you'd look for a setting (#69).
The whole #69 entry was missing: the reorganised hub, the previewing pickers,
Backup & restore as its own entry, the recurrence date preview and the event
visibility explanations. Also corrects the agenda widget size path, which moved
to Settings → Widgets & tiles in the same rework.
- Restoring a backup left the calendar manager standing over the import
screen, so the target picker sat hidden behind it.
- The past-events preview consumed drags as well as taps, which stopped the
picker scrolling when the gesture started on the preview.
- The recurrence preview dropped DTSTART when the rule's weekday picks missed
the start's own weekday; RFC 5545 keeps it in the set.
- "This month" showed the month name in the range picker, reading as the whole
month next to options that name a concrete span.
The four files also carry their share of the comment trim from the previous
commit.
The v2.17.0 work left long prose comments explaining rationale that either
repeats docs/ARCHITECTURE.md or restates the line below it. Cut ~520 comment
lines across 54 files, keeping the short "why" notes for provider quirks and
non-obvious flow behaviour.
Fixes [#82](https://codeberg.org/jlmakiola/calendula/issues/82) — found while investigating [#67](https://codeberg.org/jlmakiola/calendula/issues/67).
Search formatted a result's date with `ZoneId.systemDefault()`, while the month,
week, day, agenda and detail surfaces all resolve an all-day event's dates in
UTC. All-day events are stored at UTC midnight with an exclusive end, so west of
UTC that difference is a whole day: an event on the 19th came back from search
dated the 18th, contradicting the grid it was filed in. East of UTC the offset
lands on the same date, which is why it went unnoticed.
### What changed
The rule was already written down three times — a private helper in
`AgendaUiState`, inline in `coversDay`, inline in `formatWhen` — so rather than
add a fourth copy, `dateZone` / `spanFirstDay` / `spanLastDay` /
`spansMultipleDays` move into `domain/Models.kt`, where they are pure date logic
rather than agenda UI state. Search reads its date through `spanFirstDay`; the
clock time stays in the device zone, as it is only ever rendered for timed events.
### Testing
New `domain/EventInstanceSpanTest` pins both directions: the west-of-UTC case
from this issue and the east-of-UTC leak from #65, plus multi-day spans, timed
events following the device zone, and zero-length events.
Local sweep green: `test` (541), `lint`, `assembleDebug`, `check_translations.py`.
On-device reviewed on the Pixel 10 with the device timezone set to New York
(fix confirmed) and back to Berlin (no regression in search, month, week, day,
agenda or the agenda widget).
---
_Recreated on Codeberg from Gitea PR #102 (same head `7c94425`, unchanged) as part of the forge migration. Already on-device signed off._
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/88
Follow-up to #97, on the same release line. After the visibility fix a calendar can be absent from the pickers for three different reasons the app knows and never said; this closes that gap.
**Settings → Calendars names the state** (#76, #78): read-only calendars are marked `Read-only`; ones whose account isn't syncing events to this device are marked `Not synced`, sorted to the bottom of their account, dimmed, and left **without a switch** — visibility can't reveal events that aren't on the device, so the control did nothing.
**Both pickers end in a "Missing a calendar?" row** (#76) opening the calendar manager, where those labels then say which reason applies. The manager overlay moved to the end of the host's overlay stack so it covers every surface that can open it (Settings, both event forms, the import picker).
Review notes:
- **Supporting text, not chips.** A row can carry several states at once next to a live switch; M3 supporting text composes there, static badges don't (and a non-interactive chip reads as a broken button).
- **`sync_events = 0` counts as "not synced" for account-backed calendars only.** Nothing syncs a device-local calendar by definition, and another app's local calendar can hold real events at 0 — the unsoundness that made the first #75 migration guard wrong. Covered by a test.
- **Excluded calendars stay out of the pickers** rather than being listed unpickable — a handful of read-only subscriptions would crowd out the ones you can actually choose.
541 JVM tests green (6 new), lint clean, check_translations clean. **On-device review owed**; the three new string keys need the Weblate backfill.
Reviewed-on: #101
The visibility section claimed the reminder side needed no per-calendar
handling. It does on the one path where VISIBLE was never written: an alert the
notifier silences keeps its SCHEDULED state while its event is still ahead, and
switching the calendar back on re-posts it, so the provider's own table is the
stash the deleted SuppressedReminderStore used to be.
Also rewraps the paragraph and separates it from the one that follows, which it
had been running into since the section was added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Excluding switched-off calendars from the event form's picker is right for
*targets*, but it also dropped the calendar an event already lives in. Editing
an event of a writable calendar switched off on this device (from a widget, a
deep link, another app's ACTION_EDIT) rendered the calendar row as the red "no
calendar" error, with the picker still enabled — so any pick turned the save
into a calendar move nobody asked for. The event's own calendar is added back
whenever it isn't among the targets, the way the managed special-dates case
already did; a calendar the app may not write to is still no target.
Settings → Notifications had missed the same predicate swap: it kept offering
per-calendar reminder overrides for switched-off calendars, where the provider
schedules no alarms and the setting could never fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reconciler writes VISIBLE=0 and then releases the id from the pending set,
but the ContentObserver that invalidates the repository's cached calendar
snapshot is dispatched through the main looper and arrives later. Until it did,
the released set was read against the snapshot from before the write: the
calendar reported as on again and instances re-admitted exactly the events the
migration was hiding — on a cold start, for as long as the busy main thread took.
The snapshot cache is keyed on the pending set as well as the tick now, so any
read that sees a changed set re-queries the provider; a change to the set also
re-runs the flows, and the pending ids are read in the same pass as the
calendars rather than combined in from a live flow. Both id sets are deduped at
the prefs seam (the store is shared with SettingsPrefs, so every unrelated write
re-emitted them) and calendars() collapses identical lists, which keeps those
re-queries as rare as they should be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two holes in the reconciler, both found by review.
The one-time notice armed on any device holding a calendar at VISIBLE=0, which
is the norm on a fresh install: a second account's calendars, "Holidays in X", a
subscribed calendar. A brand-new user got a changelog dialog about a migration
they never experienced, right after onboarding. It is gated on
firstInstallTime != lastUpdateTime now, and a fresh install retires the notice
unshown ahead of the permission check — so an update installed before the first
grant can't make it look like an upgrade afterwards.
The catch-up run hung off PermissionViewModel.onGranted, which only fires for the
in-app request. Granting from Android's app-settings screen comes back through
RootScreen's ON_RESUME, so an upgrading user who took that route kept their
inherited switch-offs unflushed — their events filtered app-side while the
provider went on scheduling the reminders they asked to stop. The trigger sits on
RootScreen showing the app instead, which covers both routes. To keep that cheap,
a settled run now returns after two DataStore reads instead of querying every
calendar first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"With VISIBLE=0 the provider creates no alert rows" justified deleting the
suppression stash, but it only holds where the flag was actually written. Where
the switch lives in pendingDisabledCalendarIds — a read-only install, or an
upgrade whose flush hasn't landed — the provider still holds VISIBLE=1 and keeps
creating and broadcasting rows. The receiver silenced those in
ReminderNotifier.post and then marked the whole due batch STATE_FIRED, and
dueAlerts only ever returns STATE_SCHEDULED, so switching the calendar back on
before the event could no longer surface the reminder: it was gone.
post() now reports whether it put a notification up, and the receiver marks what
it posted plus what it silenced for an event already over (handledAlertIds). A
silenced alert for an event still ahead stays scheduled, which makes the
provider's own table the stash SuppressedReminderStore used to be — no local
mirror, no serialization. ReminderRecovery re-posts those rows when the calendar
is switched back on, so recovery doesn't wait for the next unrelated broadcast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code review of the one-visibility-model fix (#75) found the reconciliation
reaching further than it should and the read-only case falling through it.
The migration switched calendars *on* to keep the upgrade invisible, but
"not disabled in Calendula" is the default for every calendar, including ones
the user deliberately hid in Google Calendar, Etar or DAVx5 — those would
reappear there and start firing reminders from a switch the user never touched.
Its sync_events guard didn't hold either: an ACCOUNT_TYPE_LOCAL calendar another
app created can sit at sync_events=0 while holding real device-local events. The
reconcile now only hides, and a one-time notice explains that visibility follows
the device and where to change it, instead of quietly rewriting other apps'
state.
Only READ_CALENDAR gates the app, so a read-only install could not write the
flag at all: every calendar it had switched off came back with its events and
its reminders, and the switch couldn't undo it. Those switch-offs are kept
app-side now (the retired disabled-set key, re-read under a new name), folded
into the visibility every consumer reads, and drained into the provider entry by
entry once WRITE_CALENDAR arrives — which also makes a part-applied run resumable
without re-applying a switch the user has since flipped by hand.
Also: restore the ReminderNotifier.post gate, the one path a snooze re-shown
from our own alarm passes; move the whole reconcile inside its try/catch, so a
damaged preferences file can't crash the process at launch; share one Calendars
query per provider tick across the flows that need it; and give the reworded
Settings hint new keys, so five locales stop rendering the retired app-only
wording.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reminders never fired for a calendar hidden at system level and nothing hinted
at it: the provider only schedules reminder alarms for Calendars.VISIBLE=1,
while Calendula filtered with its own disabledCalendarIds pref and parsed
isVisibleInSystem without ever using it — two models that could disagree
indefinitely.
Settings → Calendars now writes Calendars.VISIBLE, one calendar per update
(CalendarProvider2 skips its own checkNextAlarm() reschedule for any selection
that isn't _id=), and every display predicate reads isVisibleInSystem. The
drawer's filter sheet stays a purely in-app declutter and still leaves reminders
alone.
With VISIBLE=0 the provider creates no alert rows, so there is nothing left to
suppress: the disabled-calendar gates, SuppressedReminderStore and the re-enable
recovery are gone. A one-shot migration reconciles the retired set with the app's
state winning — enabled in-app and syncing gets shown, disabled gets hidden,
everything else untouched — so the upgrade changes nothing the user sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:44:56 +02:00
5 changed files with 32 additions and 56 deletions
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.