The realignment envelope is described as implemented: whole-day shifts, or
a time-only shift whose anchor keeps its day. Also documents the one-write-
at-a-time gate, and notes the drag as a third recurring-scope prompt.
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.
Every write sampled ZoneId.systemDefault() and stamped it into
EVENT_TIMEZONE, so the column was real but only ever held the device's
zone: an event synced from elsewhere could be read in its zone, never
authored in one.
Give EventForm a nullable `timezone`, where null keeps meaning "the
device zone at save time" — so every existing call site behaves exactly
as before — and a non-null value pins the event to a zone it then tracks
across DST. toWriteTimes resolves the form's zone ahead of the device's;
toEditForm pins only when the stored zone differs from the device's, and
prefills such an event in its own zone so the form shows the wall-clock
the event actually means.
Two provider-contract bugs fall out of this:
- Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the
device's. The instants stayed right, so nothing looked wrong, but the
event silently stopped tracking its zone and would drift an hour at the
next DST boundary. Only the timesChanged gate spared title-only edits.
- A zone change with an untouched wall-clock is still a time change (the
same 09:00 elsewhere is a different instant), so it now trips
timesChanged and rewrites DTSTART instead of being dropped.
All-day events keep carrying no zone at all: they're date-anchored, and
the UTC midnights they normalise to are an anchor rather than a location.
TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets
stay plain JUnit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documentation pass after the 2.0 milestone:
- docs/ARCHITECTURE.md — principles (provider as single source of truth,
observer-driven UI, JVM-first tests, no network), layer + reminder
mermaid diagrams, navigation (overlay/held-key, no nav lib), and the
provider lessons (recurring-write invariants, conflict snapshots)
- docs/README.md — map of what documentation lives where, incl. the
convention that superpowers/ plans are historical artifacts while
.planning/ stays current
- README.md — showcase layout (centered header, badges, screenshot
gallery from the fastlane assets, grouped features, install/build/
architecture/roadmap sections); renders on Gitea
- .planning/{PROJECT,REQUIREMENTS,STATE}.md unstaled: read-only-V1 talk
removed, V1/V2 checklists marked shipped, state points at v3 + the
Locations & People go/no-go
release.yaml gains a gitea-release job: on every tag push it extracts the
tag's CHANGELOG section and creates a Gitea release with it as the notes.
No APK assets — distribution stays with the F-Droid repo. Idempotent
(skips an existing release), gated on the test job only so notes appear
even when the F-Droid upload hiccups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>