Undo moves the event just as the drop did, so it now travels the same
way instead of the chip vanishing off the new day and reappearing on the
old one. The write signals when it starts, the controller puts a copy on
the chip's current seat, and the settle that follows is the drop's own —
glide onto the restored seat, ghost underneath, hand over there.
A week row now publishes what it draws along with where it draws it, so
the controller can find any moved event's seat itself rather than each
row scanning its own lanes.
The month grid seats a dropped chip in a lane, but the copy was left at
whatever height the finger was at for a flat 450ms hold and then
cross-faded across that gap — long enough that the event looked like it
took its time arriving. The week row now reports where the grid actually
seats the drop once the re-read lands, and the copy glides onto it and
hands over there. The flat wait is only the fallback for a drop with no
seat to find (a day's "+N" overflow, or a failed write).
Two causes, both leaving the block at the old slot or dimmed for a frame or
several. The source is now identified for the write's duration by event row
plus the slot it left, not by instance id — the provider renumbers Instances
rows, so a re-read still carrying the old time arrives under a new id and
the ghost stopped matching. And the ghost is dropped when the copy *starts*
dissolving rather than after: un-ghosting last left the block dim under the
fading copy and brightened it once the copy was gone.
The chip springs open from its corner on the expressive fast specs instead
of sliding in clipped, and its text is resolved in composition rather than
from an effect — a frame late meant it opened carrying the previous message
and grew into the new one mid-animation.
Undo now leaves the outcome standing until its write reports back, so the
one chip changes what it says instead of closing and reopening.
The chip goes back to the neutral fill and its motion is cut to a receipt's
length — the default visibility spring overshot and read as sluggish.
The landed copy is now handed back when the grid actually draws the drop,
not on a timer: releasing early put the source ghost back at full opacity in
its old slot for the rest of the re-read, which is the flicker.
The confirmation pill is now floret-kit's SnackChip, shared with Agendula's
swipe-delete undo, wearing the FAB's colours and crossfading when its
message is answered.
Timed blocks are keyed by event and their bounds tweened, so a landing drop,
the neighbour that has to make room for it, and an undo all slide instead of
popping. The source ghost travels with them rather than fading out, and the
floating copy dissolves into the block once the grid has caught up.
The floating copy used to vanish the moment the finger lifted, so the block
snapped back to its old slot until the provider notified and the query
re-read it. It now holds the slot it landed on until the write settles,
sinking its lift as it does, while the source ghost fades out.
The confirmation moves from a full-width snackbar to a pill on the FAB's
band, the shape agendula uses for its undoable delete.
While a block is lifted the gutter dims its hour labels and floats a badge
with the drop's start time, so the target reads off the same scale the
labels do. Gutter extracted into a shared composable — day and week had
identical copies.
Write path:
- realignRecurrence now handles weekly BYDAY only. What the rule must agree
with is the series *anchor*, which moves by the same wall-clock shift as the
dragged occurrence — weekday survives that (uniform mod 7), day-of-month does
not. BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1,
produced a Jan 29 anchor under a BYMONTHDAY=1 rule: a DTSTART that is not an
instance of its own rule. Also refuse when the shift is not a whole number of
days, which would carry a late-enough anchor across an extra midnight.
- The accepted parts are now a subset of parseSimpleRecurrence's, so anything
realignable is a rule the UNTIL guard can actually read. FREQ=MONTHLY;BYDAY=MO
previously slipped past it and could move a series past its own end.
- The UNTIL check moved to write time and picks the date the chosen scope
actually starts at: the anchor for a whole-series move, the occurrence for a
split, nothing for a single occurrence. It used to test the occurrence in
every case and refused the ordinary drag of a bounded series' last one.
- shiftedByDays preserves the instant duration for timed events, as shiftedTo
already did — a month drag onto a DST changeover rewrote the whole series'
DURATION.
- Guard against a second drop landing while one is in flight; the shifts would
compound on the re-read anchor.
Gestures:
- A long press that never moved wrote a reschedule: the target snaps to the
15-minute grid at pickup, so an event at 09:07 resolved to 09:00 the instant
it lifted. The drop is now refused when it lands on the slot it started from.
- After pickup the gesture is driven and consumed on the initial pass. Pinching
mid-drag used to zoom and drag at once, then write wherever the zoom left the
block; the month grid's tap layer opened the day on lift.
- RTL: pointer x was mapped to columns as if the grid were LTR, so a drop landed
6-n columns away, and both ghosts used the direction-aware offset with root
coordinates, mirroring them across the screen.
- The month drop passes the day delta instead of a date, so the grid and the
view model no longer each resolve the event's first day in their own zone.
Long-press an event block in the week or day timeline and drag it to another
time or day column; drag a chip in the month grid onto another date. The drop
runs through the edit screen's own write path, so recurring writes, reminders
and guests behave the same as a save.
Two provider traps the drag had to close:
- RRULE is written verbatim while DTSTART moves, so dragging a
FREQ=WEEKLY;BYDAY=MO occurrence to a Wednesday under "all events" left a
Wednesday anchor under a Monday rule and the series stayed put.
realignRecurrence re-derives BYDAY/BYMONTHDAY/BYMONTH, and refuses rules one
moved occurrence can't resolve (BYDAY=MO,WE, 2TH, BYSETPOS) — those may only
move the single occurrence.
- Nothing below the UI refuses a write, so allowsEventMove gates the gesture.
Not isEventTarget: a managed contact-mirror event is editable but must never
move.
The pickup is hand-rolled rather than detectDragGesturesAfterLongPress: during
the 500ms hold the block consumes nothing, so the scroll and page swipe claim at
touch slop and kill the press, and the stock detector also cancels as soon as the
finger leaves a block that may only be 10dp tall. Ours holds with a 6dp tolerance
and never cancels on leaving bounds.
Agenda is out of scope (list rows, no positioned blocks), as is
resize-by-edge-drag. Month drags don't auto-scroll or page yet.
- Back now closes the drawer instead of the app: a `BackHandler` inside the sheet, enabled while the drawer is open. The four host screens (Month/Week/Day/Agenda) pass their `drawerState` in.
- Header and section labels use `GroupedListInset` instead of a hardcoded 28.dp, so "Calendula", "View" and "Calendars" share the left edge of the rows below them — same as the Settings screens. The account labels and placeholders in `CalendarFilterList` follow.
- Plain leading icons now sit in a 40.dp box, matching `CalendarColorChip`'s footprint, so view rows, jump-to-date, Settings and the calendar rows all line up on one vertical axis.
Closes#114
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/121
CI already skips the Android build for docs-only pull requests, and that part works — a README-only PR reports green in ~11s. The skip-list was just too narrow to catch a realistic docs PR.
PR #96 is the example: a contributing guide, docs corrections and issue templates. It ran the full lint + test + assemble four times, over exactly two files Gradle never reads — `.forgejo/ISSUE_TEMPLATE/config.yml` and `.gitignore`. Added to the list: issue templates, `.planning/`, `licenses/`, `renovate.json5`, `.gitignore`, `.gitattributes`, `.editorconfig`.
It stays a skip-list rather than a build-list, so an unfamiliar path still builds by default; the workflows themselves, `.gitmodules` and `scripts/` are deliberately not skippable. The step now also prints which files forced the build, so the next "why did my docs PR build?" is answerable from the log.
One latent bug alongside it: `github.base_ref` is normalised against a `refs/heads/` prefix. Arriving in full-ref form would fail the merge-base lookup and quietly degrade the guard into "always build" — the failure mode this PR is fixing, but permanently and invisibly.
Verified by replaying the new pattern over real history: PR #96 and #97 now skip; the forge migration (workflows), the search fix (app code) and a Weblate translation merge still build.
No issue — reported directly.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/98
The screenshot strip linked `05-edit.png`, which doesn't exist — replaced with the real agenda screenshot, and the unused day-view shot is now shown too (six screenshots, all present).
Install is now one section per channel — official F-Droid, self-hosted F-Droid repo, Codeberg release / Obtainium, Google Play (coming soon), build from source — with an overview table up front and the previously missing "Get it on Obtainium" badge in the header. The badge links to the Codeberg releases page rather than an `obtainium://` deep link, because Forgejo's markdown sanitiser strips custom URL schemes.
The Play section notes that Play builds carry Google's signing key (so switching channels needs an uninstall) and asks for closed-testing testers.
Also corrects two stale claims: the UI is no longer German-and-English only (nine translations exist), and the language list now says so.
No issue — reported directly.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/97
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline.
**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.
### 1 · floret-kit moved
Mirrored to `jlmakiola/floret-kit` (6 branches, 3 tags, every SHA verified identical) and `.gitmodules` repointed. This is what actually unblocks contributors — a clone previously could not resolve its submodule without reaching the personal Gitea instance.
The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases (including F-Droid reproducible rebuilds) still resolve.
### 2 · Workflows split by directory
Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |
The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.
### 3 · Two release-pipeline safety changes
- `release.yaml`'s `detect` gets an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running the release pipeline on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
### 4 · Links repointed
In-app Source/License links, README badge, both F-Droid metadata files. **`Repo:` in `docs/fdroid-official/` deliberately stays on Gitea** — it keeps receiving `main` and every tag, so it remains a complete build source, and leaving it alone means no fdroiddata MR and no reproducible-build risk.
### Not in this PR
Renovate + Weblate repointing, and the Codeberg → Gitea push mirror (browser-side).
Supersedes Gitea PR #104.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/86
Adds a fourth link to the about card in settings, next to source and licence.
Play requires the privacy policy to be reachable from inside the app, not just from the store listing, for anything that touches calendar or contact data. Points at jeanlucmakiola.de/calendula/privacy.
Only the base string is added — Weblate owns the translations.
Reviewed-on: #103
Age came back `unknown` for every dependency after #98. That's the `internalChecksFilter: "none"` from that PR doing what it says on the tin — and rather more.
### Why
`lib/workers/repository/process/lookup/filter-checks.ts` short-circuits on `none`:
```js
if (internalChecksFilter === 'none') {
release = sortedReleases.pop(); // returns here
} else {
for (let candidateRelease of sortedReleases.reverse()) {
...
const updatedCandidateRelease = await postprocessRelease(...)
```
`postprocessRelease` is the only caller that fetches a Maven artifact's `Last-Modified` header (`lib/modules/datasource/maven/index.ts:222`), so skipping the loop leaves `releaseTimestamp` unset. That empties the Age column — but it also silently voids `minimumReleaseAge` and the `renovate/stability-days` check, since both compare against that same timestamp. The cooling-off tiers were decorative.
### Fix
`flexible` keeps the intent — still prefers a version that has cleared its window, but opens the PR at the newest candidate when none has — while running the loop that populates the timestamps.
Verified with a local `--platform=local --dry-run=full`:
| dependency | new version | age |
| --- | --- | --- |
| `androidx.compose:compose-bom` | 2026.06.01 | 24 d |
| `androidx.compose.material3:material3` | 1.5.0-alpha24 | 10 d |
| `androidx.work:work-runtime-ktx` | 2.11.2 | 122 d |
| `com.android.application` (AGP) | 9.3.1 | 2 d |
| `gradle` | 9.6.1 | 29 d |
Only `ghcr.io/renovatebot/renovate` stays unknown, because the docker registry serves no timestamps at all — the run marks all 176 of its tags pending for that reason, and `flexible` is what still lets that PR through.
`Pending` rejoins the table: under a flexible filter it names the newer version being held back, rather than leaving it invisible.
### Also
Migrates the Gitea workflow manager off the deprecated `fileMatch`. Renovate's config migration was rewriting it to `managerFilePatterns` (delimited regex) on every run and warning about it; the dry run confirms the manager still picks up `.gitea/workflows` afterwards.
Validated with `renovate-config-validator` against the pinned 43.232.0.
Reviewed-on: #100
Three gaps in the Renovate setup, all about having enough in front of you at review time.
### Age reads UNKNOWN
`config:recommended` already brings in `mergeConfidence:age-confidence-badges`, so the Age column existed — it just rendered grey. Fetching the badge SVGs from `developer.mend.io` directly shows why: Mend's index covers Maven Central but has nothing for Google's Maven repo, so every androidx/compose coordinate is blank. No token changes it; the JSON API behind the badges answers 401 for everyone.
| coordinate | registry | age badge |
| --- | --- | --- |
| `androidx.compose:compose-bom` | Google Maven | UNKNOWN |
| `androidx.core:core-ktx` | Google Maven | UNKNOWN |
| `com.google.truth:truth` | Maven Central | 2y |
| `org.jetbrains.kotlin:kotlin-stdlib` | Maven Central | 1y (confidence high, passing 99%) |
Age is now computed from `newVersionAgeInDays`, which Renovate derives itself to evaluate `minimumReleaseAge` — Google Maven serves `last-modified` on its POMs, so it's populated where Mend is blank, and the number agrees with the tiers it's read against. Mend keeps the Confidence column, which still resolves for the Maven Central half (Kotlin, Gradle, AGP, the test stack).
### Empty release notes
We run against Gitea, but the packages are *released* on GitHub, so changelog lookups were going out unauthenticated against a 60/h limit. `RENOVATE_GITHUB_COM_TOKEN` lifts that.
**Needs a secret before it does anything:** a github.com PAT with **no scopes ticked**, added as repo secret `GITHUB_COM_TOKEN`. Until then the var resolves empty, which is exactly today's behaviour.
### Nothing expressed how settled a release is
Cooling-off scaled by blast radius: 30 days major, 20 minor, 10 patch/digest.
Deliberately advisory. Renovate's default `internalChecksFilter: strict` would suppress the PR outright until the version aged in; `none` opens it at the highest version immediately, so merging ahead of the window stays a decision rather than a wait. A too-young release still gets a yellow `renovate/stability-days` check — `setStability` computes that from `minimumReleaseAge` + `releaseTimestamp` independently of the filter — and with `automerge: false` nothing acts on it.
### Notes
Validated with `renovate-config-validator` against the pinned 43.232.0. Config is read from the default branch, so the open bump PRs keep their current tables until the next run after this merges.
Reviewed-on: #98
The Codeberg mirror step pushes the tag, then immediately POSTs the release
for it — but Codeberg 500s when the release request outruns its indexing of
the just-pushed ref, and with only one attempt that single 500 skipped the
mirror every release (the same POST succeeds seconds later, as a manual retry
confirmed for v2.16.0). Wrap the create/update in a backoff retry loop that
PATCHes in place if a release already exists, so a transient 5xx no longer
loses the mirror. Step stays best-effort (continue-on-error).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate the release under a single 2.16.0 heading dated today and fold
in the work that had accumulated in Unreleased. Add the entries the log had
been missing as PRs merged: app-name toggle (#44), the Arabic/French/Italian
translations, the custom recurrence picker redesign + end-date fix (#42),
multi-day agenda events (#83), and dark-theme picker bar icons (#70).
Regenerate the fastlane per-version changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Weblate merge added a values-ar translation (~50% complete, RTL —
the app already declares supportsRtl). Add the matching <locale> line so
Arabic is selectable in both the in-app picker and the system per-app
language settings. Russian and Portuguese stay out for now: at 12% and
<1% they're below the usable bar (the lowest currently-offered language,
zh-CN, sits at ~24%).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>