## What was wrong "Edit only this event" wrote a modified-occurrence exception unconditionally. That is the right shape for a synced series and the wrong one for everything else: an exception attaches to its parent through `ORIGINAL_SYNC_ID`, so on a series row with no `_sync_id` the link never forms — the insert fails or lands an orphan, the generic catch in `EventEditViewModel.performSave` turns it into a snackbar, and the scope dialog just closes again. To the reporter that read as "nothing happens, ever", with a stray copy of the event the one time the insert did land. This is the same constraint `deleteOccurrence` has documented since #47, and the same calendars: a local calendar, Calendula's own contact special-date calendars, and — the reporter's case — a Google calendar whose rows the sync adapter has not stamped yet, which is exactly why it "shows as on-device". `deleteOccurrence` got the `_sync_id` guard in 4fea176; `updateOccurrence` never did. ## What changed `updateOccurrence` now branches on `_sync_id` the way `deleteOccurrence` does. - **Synced series**: the exception path, untouched. Its write shape is load-bearing and on-device verified (#16, #47). - **No `_sync_id`**: the occurrence is excluded from the parent via EXDATE and the edited values are inserted as a standalone event on the same calendar — a detached instance, minus the `RECURRENCE-ID` the provider has no way to store here. Two things shape the write. The parent update reuses `buildOccurrenceExdateValues` unchanged, so it keeps carrying the whole time/recurrence set — an EXDATE-only update is not a recurrence change to the provider and leaves the expanded instances standing (#47's first quirk). And the form's RRULE is stripped before the insert: the exception path gets an inherited rule cleared for free by DTSTART + DURATION, but nothing clears one here, so leaving it would insert a second *series* overlapping the first. Ordering is chosen for the failure cases. The insert runs first, so a failure there leaves the series completely untouched — the discipline `updateEventFromOccurrence` already follows. If the EXDATE update then fails, the new row is a visible duplicate of an occurrence still in the series, so it is rolled back (best effort) before the failure surfaces. The reverse order could strand an occurrence excluded from its series with nothing standing in for it, turning an edit into a silent delete. A second detach of the same occurrence is refused rather than silently making a second copy (reachable from a stale detail screen: the EXDATE merge folds the repeat away and the update still reports a changed row). `NoSuchEventException` from a write now maps to the same "no longer exists" state the pre-check already gives. Smaller, in the same area: a failed save was invisible precisely because this bug was — the failure snackbar gets the long duration instead of a flash, and the catch logs the scope and event id (never the form's content) so a failure leaves something to report. ## What this costs The detached row has no stored link back to its series — that is the whole reason the path exists — and the KDoc now says so plainly. A whole-series delete leaves it standing where an exception row would have gone with the parent; a calendar move leaves it behind; a series-wide *time* edit moves the generated instances but not the absolute-instant EXDATE hole, so the occurrence returns alongside the copy; and building the row from the form rather than cloning the parent drops `ORGANIZER`, `STATUS` and the organizer/resource attendee rows, exactly as `moveEvent` does. The EXDATE staleness is not new — a #47 delete resurrects the same way after a series time edit — but a duplicate is a louder symptom than a resurrection. It wants fixing at the series-update end (re-stamping EXDATE alongside the DTSTART shift in `buildEventUpdateValues`, and carrying surviving stamps into the split series in `updateEventFromOccurrence`), which is a change to the "all events" path for *every* calendar type and does not belong in a targeted fix. Worth its own issue. ## How it was verified - `./gradlew :app:testDebugUnitTest` — BUILD SUCCESSFUL, 62 suites, 0 failures. - `./gradlew :app:lintDebug` — BUILD SUCCESSFUL, no new findings. - `./gradlew :app:assembleDebug` — BUILD SUCCESSFUL. - Independent adversarial code review, whose findings drove the second commit (the double-detach guard, the corrected rollback claim, and the cost documentation above). It confirmed no interleaving loses an occurrence, and cleared the drag-to-reschedule path: `RescheduleViewModel.undoFor` returns null for a recurring single-occurrence move, so the changed return value (a new event id rather than an exception id) never reaches the undo machinery. New JVM tests cover the pure halves: the rule is dropped and the row becomes a one-off with DTEND, every edited field survives onto the inserted columns, all-day stays on UTC midnights, the detached row's DTSTART agrees with the EXDATE stamp that removes it from the parent (timed and all-day), and `exdateContains` recognises an already-excluded occurrence without matching a neighbouring one. ## What still needs a device The provider behaviour itself cannot be confirmed on the JVM — `AndroidCalendarDataSource` has no fake-resolver harness, so `detachOccurrence`'s branch, its insert-then-EXDATE ordering and its rollback have no unit coverage. On a device, on a **local or unsynced** calendar: 1. The reported case end to end: recurring series, edit one occurrence's title, "Only this event" — the edit sticks, that occurrence alone changes, and the rest of the series survives (the #47 collapse must not reappear). 2. The same for an **all-day** yearly series (a contact birthday calendar is the natural subject) — the date-only EXDATE form excludes the right day, not the one before it. 3. A series pinned to a **non-device timezone**, and an occurrence across a **DST boundary** — the hole and the detached row must land on the same instant. 4. Editing the occurrence's **time**, not just its title, and editing the **first** occurrence of a series (DTSTART then points at an excluded instant — expected to be fine, same property the #47 delete path already has, but untested). 5. Reminders and guests on the detached row, and a colour from an account palette. 6. Regression on a **DAVx5 / Google synced** calendar: "Only this event" must still go down the exception path and behave exactly as before. 7. Drag-to-reschedule a single occurrence on an unsynced series — same path, different caller. Closes #234 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/245
Calendula
A modern Material 3 Expressive calendar for Android.
Reads, writes, and reminds — on top of the system calendar, with zero network access.
Calendula is named after the flower whose name — like the word calendar —
comes from the Latin kalendae, the first day of the month. It lives
entirely on top of Android's CalendarContract: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
✨ Features
Calendar
- Month, week, and day views with a one-tap view switcher
- Full event details — attendees and their responses, reminders, recurrence (humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
Editing
- Create, edit, and delete events — including recurring events with scoped writes: only this event, this and all following, or the whole series
- Recurrence picker with one-tap presets and custom rules (interval, weekday toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing, Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
Reminders
- Event reminders delivered by Calendula itself as notifications — essential when it's your only calendar app, since Android delegates reminder delivery to calendar apps
- Tap a reminder to land on the event
Design & privacy
- Real Material 3 Expressive throughout — dynamic color (Android 12+), expressive motion and shapes, light/dark theme
- English and German UI plus community translations (Spanish, French, Italian, Polish, and more in progress), per-app language setting — and open to more languages
- Zero telemetry, zero analytics, no internet permission — your data never leaves the device
📦 Install
Pick whichever channel you already use — they all install the same app:
| Channel | Updates | Notes |
|---|---|---|
| Official F-Droid | On F-Droid's build schedule | Recommended; no extra setup |
| Self-hosted F-Droid repo | Minutes after a release | Fastest; needs the repo added once |
| Codeberg release / Obtainium | Per release | Plain APK download, or automated by Obtainium |
| Google Play | Per release | Signed with Google's key — see below |
| Build from source | Whenever you build | Full control |
F-Droid (recommended)
Calendula is on the official F-Droid repository — just search for Calendula in any F-Droid client, or install it from f-droid.org.
F-Droid rebuilds from source on its own schedule, so a new version usually shows up there a few days after release.
Self-hosted F-Droid repo (fastest updates)
Every release is built, signed, and published to a self-hosted F-Droid repository as part of the release pipeline, so it lands there first. Add it once and your F-Droid client handles updates from then on:
-
In your F-Droid client, open Settings → Repositories → Add (or open the link below on your phone):
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425Repo:
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo· fingerprint (SHA-256):C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425 -
Refresh, search for Calendula, install.
Codeberg release / Obtainium
If you'd rather not use F-Droid at all, every release is also published on
Codeberg with the
signed APK (calendula_vX.Y.Z.apk) and a .sha256 checksum attached — download
and install it directly.
For automatic updates from that channel, use
Obtainium — on the phone,
add Calendula in one tap,
or do it by hand: Add App → paste https://codeberg.org/jlmakiola/calendula
→ Add. Either way, Obtainium tracks the releases and prompts you when a new
one appears.
Google Play
Calendula is live on Google Play: play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula.
Play builds are signed with Google's key rather than mine, so switching between Play and any other channel requires an uninstall (and with it, a fresh start for app settings — your events live in the system calendar and are unaffected).
Build from source
The build is a plain Gradle build with no proprietary dependencies — see
docs/BUILDING.md (note the floret-kit submodule).
Official F-Droid, the self-hosted repo, and the Codeberg releases all share the same signing key, so you can switch freely between them without reinstalling. Google Play is the exception — see above.
📚 Documentation
- Contributing — how to report, propose, and patch
- Building from source — requirements and Gradle tasks
- Architecture — the layered design and key pipelines
- Milestones — what's shipped and what's next
🤝 Contributing
Bug reports, ideas, and patches are all welcome on Codeberg.
The short version: start with an issue. Features get a yes-or-no before they
get code, and both features and bugs are assigned a milestone whose
release/vX.Y.Z branch your pull request then targets. Typo and docs fixes can
skip straight to a pull request. Translations don't go through pull requests at
all — Weblate owns them.
Read CONTRIBUTING.md before writing code: it covers the
workflow, the build (note the floret-kit submodule), and the architectural
rules a change is reviewed against.
🌍 Translations
Calendula ships in English and German, with community translations in Arabic, Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying degrees of completeness — partial is fine, untranslated strings simply fall back to English. You're warmly invited to add or finish your language. Translations are managed on a self-hosted Weblate:
No coding needed — register on the Weblate server, pick (or request) a language, and translate the strings in your browser. You can also reach this link in the app from the top of Settings → App language.
📜 License
MIT — Jean-Luc Makiola, 2026









