diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index db61856..5af7c98 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -40,13 +40,24 @@ fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm { /** * The form moved [days] calendar days, keeping its time of day and its length. - * Both ends move, so a multi-day event keeps its span rather than collapsing to - * a single day; an all-day event's placeholder times ride along untouched. + * A multi-day event keeps its span rather than collapsing to a single day. + * + * An all-day event is pure date arithmetic — both ends move, and the placeholder + * times ride along untouched. A timed one re-derives its end from the preserved + * **instant** duration, for the same reason [shiftedTo] does: the length travels + * to a recurring row as `DURATION`, so a move onto a DST changeover that kept + * wall clock would silently rewrite the whole series' length. */ -fun EventForm.shiftedByDays(days: Int): EventForm { +fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm { if (days == 0) return this - return copy( - start = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time), - end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time), - ) + val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time) + if (isAllDay) { + return copy( + start = newStart, + end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time), + ) + } + val zone = resolvedZone(deviceZone) + val span = end.toInstant(zone) - start.toInstant(zone) + return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone)) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt index 5084c9e..cc06298 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.domain import kotlinx.datetime.LocalDate -import kotlinx.datetime.number /** * [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart]. @@ -9,41 +8,55 @@ import kotlinx.datetime.number * `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names * its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly * every weekly series — would keep pointing at Monday after the anchor became a - * Wednesday, and the series would not move at all. The day-selecting parts are - * therefore re-derived from [newStart]. + * Wednesday, and the series would not move at all. `BYDAY` is therefore + * re-derived from [newStart]. * - * Returns null when the rule picks days in a way one moved occurrence cannot - * resolve (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …). There is no safe - * guess there — dropping the extra days would delete occurrences — so the caller - * refuses to move anything but that single occurrence. + * **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule + * has to agree with the series *anchor*, which is not the occurrence being + * dragged: the anchor moves by the same wall-clock shift, and only weekday + * arithmetic survives that unchanged, because it is uniform mod 7 and every + * anchor time-of-day crosses the same number of midnights. Day-of-month does not + * — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an + * anchor of Jan 29 under a `BYMONTHDAY=1` rule, which is not an instance of its + * own rule and materialises a phantom occurrence on any client that trusts + * DTSTART. Those rules return null instead. The caller must also refuse when the + * shift is not a whole number of days, for the same reason (see + * `RescheduleViewModel`). + * + * Null also covers rules one moved occurrence cannot resolve at all + * (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …) — dropping the extra days + * would delete occurrences. The accepted parts are deliberately a subset of what + * [parseSimpleRecurrence] understands, so anything realignable is also a rule + * [problems] can check the `UNTIL` of. */ fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { if (oldStart == newStart) return rrule val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } if (parts.isEmpty()) return null + var weekly = false val rebuilt = parts.map { part -> val eq = part.indexOf('=') if (eq <= 0) return null val key = part.substring(0, eq).uppercase() val value = part.substring(eq + 1).trim() when (key) { + "FREQ" -> { + weekly = value.equals("WEEKLY", ignoreCase = true) + part + } "BYDAY" -> { val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null if (!value.equals(old, ignoreCase = true)) return null "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" } - "BYMONTHDAY" -> { - if (value != oldStart.day.toString()) return null - "BYMONTHDAY=${newStart.day}" - } - "BYMONTH" -> { - if (value != oldStart.month.number.toString()) return null - "BYMONTH=${newStart.month.number}" - } - "FREQ", "INTERVAL", "COUNT", "UNTIL", "WKST" -> part + "INTERVAL", "COUNT", "UNTIL", "WKST" -> part else -> return null } } + // BYDAY is only simple on a weekly rule (matching parseSimpleRecurrence); + // "every Monday of the month" is a shape this has not been reasoned about. + if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null + if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null return prefix + rebuilt.joinToString(";") } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt index ccb122e..88cf1ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -121,15 +121,25 @@ fun rememberDragSurface( if (!took) return@awaitEachGesture var dropped = false try { + // Once the block is lifted the gesture is ours, so it is + // driven on the initial pass and consumed there. That pass + // runs parent → child, which gets both halves right: an + // ancestor that outranks us (the pinch, which claims the + // moment a second finger lands anywhere in the timeline) has + // already consumed by the time we look, and everything below + // us — the month grid's full-bleed tap layer, which is a + // descendant and would otherwise open the day on lift — sees + // ours. Consumption persists across passes, so the scroll and + // the page swipe stand down on main as well. while (true) { - val event = awaitPointerEvent() + val event = awaitPointerEvent(PointerEventPass.Initial) val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (change.isConsumed) break if (!change.pressed) { + change.consume() dropped = true break } - // A second finger is the pinch taking over; it consumes on - // the initial pass, so the drag is already dead. if (event.changes.count { it.pressed } > 1) break change.consume() coordinates[0]?.takeIf { it.isAttached } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index d291527..d7b1a18 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -55,7 +55,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) val moved = outcome as? MoveOutcome.Moved val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) } - val message = when (val o = outcome) { + val message = when (outcome) { null -> null is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty()) MoveOutcome.Undone -> stringResource(R.string.event_move_undone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index cf6cf68..e022b6f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormProblem +import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.allowsEventMove +import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.realignRecurrence import de.jeanlucmakiola.calendula.domain.resolvedZone @@ -26,9 +29,11 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.coroutines.cancellation.CancellationException @@ -40,8 +45,14 @@ sealed interface MoveTarget { /** A new start instant — a timeline drag, which moves time and day at once. */ data class Start(val instant: Instant) : MoveTarget - /** A new first day, keeping the time of day — a month-grid or all-day drag. */ - data class Day(val date: LocalDate) : MoveTarget + /** + * A whole-day shift, keeping the time of day — a month-grid or all-day drag. + * A delta rather than a target date on purpose: the grid already knows how + * many columns the finger crossed, and re-deriving that from a date would + * mean the screen and this view model each resolving the event's first day + * in their own zone, which can disagree by a day. + */ + data class ByDays(val days: Int) : MoveTarget } /** @@ -122,6 +133,14 @@ class RescheduleViewModel @Inject constructor( private var pending: PreparedMove? = null + /** + * Set from the moment a drop is accepted until its write settles. Two drops + * of the same recurring event landing inside that window would each compute + * their shift from the same pre-move occurrence, and the data layer applies + * both to the re-read anchor — so the shifts would compound. + */ + private var busy = false + /** * The calendars whose events may be dragged. Nothing below the UI guards * this — the repository and data source attempt any write handed to them — @@ -149,15 +168,24 @@ class RescheduleViewModel @Inject constructor( * wider than the single occurrence would leave rule and anchor disagreeing. */ val canRealign: Boolean, + /** The series row's DTSTART date after the move — what its `UNTIL` must clear. */ + val newAnchorDate: LocalDate, ) fun move(request: MoveRequest) { - if (_scopePrompt.value != null) return + if (busy || _scopePrompt.value != null) return + busy = true viewModelScope.launch { - val prepared = prepare(request) ?: return@launch + val prepared = prepare(request) + if (prepared == null) { + busy = false + return@launch + } if (!prepared.isRecurring) { write(prepared, RecurringWriteScope.AllEvents) + busy = false } else { + // Still busy: the scope dialog is now the thing in flight. pending = prepared _scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) } @@ -167,19 +195,28 @@ class RescheduleViewModel @Inject constructor( /** Answer the scope dialog. */ fun moveWithScope(scope: RecurringWriteScope) { val prepared = pending ?: return + // Belt and braces against the dialog ever offering a scope the rule + // can't carry: writing it would leave anchor and rule disagreeing. + if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return pending = null _scopePrompt.value = null - viewModelScope.launch { write(prepared, scope) } + viewModelScope.launch { + write(prepared, scope) + busy = false + } } /** Dismiss the scope dialog without writing. */ fun cancelScope() { pending = null + busy = false _scopePrompt.value = null } /** Put a completed move back where it came from. */ fun undo(undo: MoveUndo) { + if (busy) return + busy = true _outcome.value = null viewModelScope.launch { _outcome.value = try { @@ -194,6 +231,7 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } + busy = false } } @@ -219,23 +257,16 @@ class RescheduleViewModel @Inject constructor( val original = detail.toEditForm(request.beginMillis, request.endMillis, zone) val shifted = when (val target = request.target) { is MoveTarget.Start -> original.shiftedTo(target.instant, zone) - is MoveTarget.Day -> original.shiftedByDays( - ( - target.date.toEpochDays() - - sourceDate(request.beginMillis, original.isAllDay, zone).toEpochDays() - ).toInt(), - ) + is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone) } // A zero-distance drop is not a write. Matches the edit form's own // pristine-form no-op, and keeps a mis-aimed long press harmless. if (shifted == original) return null - val problems = shifted.problems() - if (EventFormProblem.RecurrenceEndsBeforeStart in problems) { - _outcome.value = MoveOutcome.BlockedSeriesEnd - return null - } - if (problems.isNotEmpty()) { + // The UNTIL check is deferred to the write: how far the move reaches + // decides which date has to clear it, and "only this event" writes an + // exception row that no UNTIL constrains at all. + if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { _outcome.value = MoveOutcome.Failed return null } @@ -243,12 +274,23 @@ class RescheduleViewModel @Inject constructor( // An exception row stands alone whatever rule a sync adapter left on it. val isRecurring = original.rrule != null && !detail.isException val movedDay = shifted.start.date != original.start.date + // The series anchor moves by the same *wall-clock* shift as the dragged + // occurrence, so only a whole-day shift moves it by a predictable number + // of days — a drag that also changes the time of day would carry some + // anchor times of day across an extra midnight and leave the rule naming + // the wrong weekday. All-day forms shift as bare dates, so they always + // qualify. + val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time val realigned = if (isRecurring && movedDay) { - realignRecurrence( - requireNotNull(original.rrule), - original.start.date, - shifted.start.date, - ) + if (wholeDayShift) { + realignRecurrence( + requireNotNull(original.rrule), + original.start.date, + shifted.start.date, + ) + } else { + null + } } else { original.rrule } @@ -260,11 +302,20 @@ class RescheduleViewModel @Inject constructor( updated = shifted.copy(rrule = realigned ?: original.rrule), isRecurring = isRecurring, canRealign = !isRecurring || !movedDay || realigned != null, + // Where the series row's own DTSTART lands, given the anchor moves by + // the same shift. Only meaningful under [wholeDayShift], which is the + // only case a wider-than-one-occurrence write is offered in. + newAnchorDate = anchorDate(detail, original, zone) + .plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY), ) } private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) { val request = prepared.request + if (endsBeforeItStarts(prepared, scope)) { + _outcome.value = MoveOutcome.BlockedSeriesEnd + return + } _outcome.value = try { when { !prepared.isRecurring || scope == RecurringWriteScope.AllEvents -> @@ -300,6 +351,39 @@ class RescheduleViewModel @Inject constructor( } } + /** + * Whether this write would leave a rule whose `UNTIL` precedes the first day + * it now applies to — the provider then generates nothing and the event + * silently disappears from every view. + * + * Which date has to clear `UNTIL` depends on how far the write reaches: a + * whole-series move carries the series *anchor*, a split starts a new series + * at the moved occurrence, and a single occurrence becomes an exception row + * that no `UNTIL` constrains. Testing the occurrence in every case would + * refuse the perfectly ordinary drag of a bounded series' last occurrence. + */ + private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean { + if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false + val rule = prepared.updated.rrule ?: return false + val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false + val firstDay = if (scope == RecurringWriteScope.AllEvents) { + prepared.newAnchorDate + } else { + prepared.updated.start.date + } + return end.date < firstDay + } + + /** + * The series row's own start date — for a recurring master, `EventDetail` + * carries the row's DTSTART rather than the tapped occurrence's. Read in the + * same anchoring the write path uses: UTC for an all-day series. + */ + private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate = + detail.instance.start + .toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)) + .date + /** * Undo is offered only where the inverse is one symmetric write: a * non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the @@ -333,9 +417,4 @@ class RescheduleViewModel @Inject constructor( } } - /** The calendar day the dragged occurrence started on, as the grid shows it. */ - private fun sourceDate(beginMillis: Long, isAllDay: Boolean, zone: TimeZone): LocalDate = - Instant.fromEpochMilliseconds(beginMillis) - .toLocalDateTime(if (isAllDay) TimeZone.UTC else zone) - .date } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 99595d1..0d898e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -26,6 +26,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity @@ -95,6 +96,13 @@ class TimelineGeometry { var edgePx: Float = 0f var stepPx: Float = 0f var days: List = emptyList() + + /** + * Whether the columns are laid out right-to-left. Pointer coordinates are + * never mirrored, but the grid is, so the leftmost column is the *last* day + * in Arabic — the mapping has to flip with it. + */ + var isRtl: Boolean = false } /** @@ -123,6 +131,14 @@ class TimelineDragController { private var grab = Offset.Zero private var pointer = Offset.Zero + /** + * The slot the block already occupied when it was picked up. A long press + * that never moves must write nothing — and because the target snaps to the + * grid, "nothing moved" is not the same as "the start is unchanged": an + * event at 09:07 resolves to 09:00 the instant it lifts. + */ + private var originSlot: Pair? = null + val isDragging: Boolean get() = liftedInstanceId != null fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { @@ -130,7 +146,9 @@ class TimelineDragController { liftedInstanceId = block.event.instanceId grab = pointerInRoot - blockInRoot pointer = pointerInRoot + originSlot = null recompute() + originSlot = drag?.slot } fun move(pointerInRoot: Offset) { @@ -141,14 +159,20 @@ class TimelineDragController { fun cancel() { source = null liftedInstanceId = null + originSlot = null drag = null } - /** End the drag, handing back where it landed (null if it never resolved). */ + /** + * End the drag, handing back where it landed — null when it never resolved, + * or when it landed back on the slot it started from. + */ fun finish(): TimelineDrop? { val landed = drag + val origin = originSlot cancel() - return landed?.let { TimelineDrop(it.event, it.date, it.startMin) } + if (landed == null || landed.slot == origin) return null + return TimelineDrop(landed.event, landed.date, landed.startMin) } /** @@ -171,11 +195,13 @@ class TimelineDragController { // fine and stays visible on the next day. val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) val span = block.endMin - block.startMin + // The column the finger is over on screen, and the day that column shows. val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) + val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) drag = TimelineDrag( event = block.event, - date = days[column], + date = days[dayIndex], startMin = startMin, endMin = startMin + span, topLeftInRoot = Offset( @@ -270,6 +296,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Box( modifier = modifier .fillMaxSize() + // A copy of a block that is still in the tree behind it; announcing + // it again would just duplicate the event for the drag's duration. + .clearAndSetSemantics { } .onGloballyPositioned { origin = it.positionInRoot() }, ) { val drag = controller.drag ?: return@Box @@ -279,7 +308,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale) Box( modifier = Modifier - .offset { + // Absolute: these are root coordinates, and the direction-aware + // offset would mirror them across the screen in an RTL layout. + .absoluteOffset { IntOffset( (drag.topLeftInRoot.x - origin.x).roundToInt(), (drag.topLeftInRoot.y - origin.y).roundToInt(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index bbaa4d8..44a26f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -58,12 +58,14 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -536,6 +538,7 @@ private fun Timeline( val locale = currentLocale() val zoom = LocalTimelineZoom.current val density = LocalDensity.current + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top @@ -612,6 +615,7 @@ private fun Timeline( it.columnGapPx = 0f it.columnWidthPx = coords.size.width.toFloat() it.days = listOf(state.date) + it.isRtl = isRtl } }, ) @@ -727,15 +731,16 @@ private fun EventBlock( val moveAction = eventMoveAction(block.event) // A block clipped at the top continues from the previous day: its top edge is // midnight, not the event's start, so dragging it would invent a time. + val draggable = moveAction != null && block.beginsOn(date, zone) val dragModifier = rememberEventDragSource( - enabled = moveAction != null && block.beginsOn(date, zone), + enabled = draggable, key = block.event.instanceId, onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, ) - val lifted = dragController.liftedInstanceId == block.event.instanceId + val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId Box( modifier = modifier // The source stays put as a ghost while its floating copy travels. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt index 408fa05..6c735ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -20,6 +20,12 @@ class MonthRowGeometry( /** The row's day-column box — the space chip offsets are measured in. */ val cell: LayoutCoordinates, val columnWidthPx: Float, + /** + * Whether the columns are laid out right-to-left. Pointer coordinates are + * never mirrored, but the grid is, so the leftmost column is the *last* day + * in Arabic. + */ + val isRtl: Boolean, ) /** A chip in flight, in root coordinates so it can be drawn in an overlay. */ @@ -127,7 +133,7 @@ class MonthDragController { val column = ((pointer.x - bounds.left) / row.columnWidthPx) .toInt() .coerceIn(0, row.days.lastIndex) - row.days[column] + row.days[if (row.isRtl) row.days.lastIndex - column else column] } } drag = MonthChipDrag( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f34eb4d..85557d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -78,6 +78,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.geometry.Offset import kotlin.math.roundToInt import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface @@ -472,6 +476,9 @@ private fun MonthDragOverlay(controller: MonthDragController) { Box( modifier = Modifier .fillMaxSize() + // A copy of a chip that is still in the tree behind it; announcing it + // again would just duplicate the event for the drag's duration. + .clearAndSetSemantics { } .onGloballyPositioned { origin = it.positionInRoot() }, ) { val drag = controller.drag ?: return@Box @@ -481,7 +488,9 @@ private fun MonthDragOverlay(controller: MonthDragController) { continuesLeft = false, continuesRight = false, modifier = Modifier - .offset { + // Absolute: these are root coordinates, and the direction-aware + // offset would mirror them across the screen in an RTL layout. + .absoluteOffset { IntOffset( (drag.topLeftInRoot.x - origin.x).roundToInt(), (drag.topLeftInRoot.y - origin.y).roundToInt(), @@ -1802,7 +1811,7 @@ private fun MonthWeekRow( val bandCoordinates = remember { arrayOfNulls(1) } val density = LocalDensity.current val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() } - val zone = remember { TimeZone.currentSystemDefault() } + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val dragging = dragController?.isDragging == true DisposableEffect(rowToken, dragController) { onDispose { dragController?.removeRow(rowToken) } @@ -1831,6 +1840,7 @@ private fun MonthWeekRow( days = week.days, cell = coords, columnWidthPx = coords.size.width / 7f, + isRtl = isRtl, ), ) } @@ -1841,7 +1851,7 @@ private fun MonthWeekRow( controller = dragController, band = bandCoordinates, rowHeightPx = rowHeightPx, - zone = zone, + isRtl = isRtl, ), ), ) { @@ -2083,7 +2093,7 @@ private fun monthChipDragModifier( controller: MonthDragController?, band: Array, rowHeightPx: Float, - zone: TimeZone, + isRtl: Boolean, ): Modifier = rememberDragSurface( enabled = moveScope != null && controller != null, key = week.days.first(), @@ -2092,18 +2102,20 @@ private fun monthChipDragModifier( val columnPx = size.width / 7f val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y) val lane = (bandY / rowHeightPx).toInt() + // The column under the finger on screen, and the day that column shows. val column = (local.x / columnPx).toInt() + val dayIndex = if (isRtl) week.days.lastIndex - column else column val event = if (bandY < 0f || columnPx <= 0f) { null } else { - week.chipAt(column, lane, MAX_EVENT_ROWS) + week.chipAt(dayIndex, lane, MAX_EVENT_ROWS) } if (event == null || moveScope?.allows(event) != true) { false } else { controller?.begin( event = event, - grabDate = week.days[column], + grabDate = week.days[dayIndex], pointerInRoot = pointerInRoot, chipInRoot = Offset( x = nodeInRoot.x + column * columnPx, @@ -2117,19 +2129,17 @@ private fun monthChipDragModifier( onMove = { controller?.move(it) }, onDrop = { controller?.finish()?.let { drop -> - val delta = drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays() - if (delta != 0L) { + // How many columns the finger crossed — grabbing the middle of a + // multi-day bar shifts the event by what the finger travelled, not + // to where it landed. + val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt() + if (delta != 0) { moveScope?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(), - // The event's own first day plus the columns crossed — - // grabbing the middle of a multi-day bar shifts it by - // what the finger travelled, not to where it landed. - target = MoveTarget.Day( - drop.event.spanFirstDay(zone).plus(delta.toInt(), DateTimeUnit.DAY), - ), + target = MoveTarget.ByDays(delta), ), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 2c81ee9..0fd71cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions @@ -71,6 +72,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -672,6 +674,7 @@ private fun Timeline( val locale = currentLocale() val zoom = LocalTimelineZoom.current val density = LocalDensity.current + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top @@ -742,6 +745,7 @@ private fun Timeline( it.columnGapPx = gap it.columnWidthPx = (coords.size.width + gap) / state.days.size it.days = state.days + it.isRtl = isRtl } }, horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), @@ -895,15 +899,16 @@ private fun EventBlock( val moveAction = eventMoveAction(block.event) // A block clipped at the top continues from the previous day: its top edge is // midnight, not the event's start, so dragging it would invent a time. + val draggable = moveAction != null && block.beginsOn(date, zone) val dragModifier = rememberEventDragSource( - enabled = moveAction != null && block.beginsOn(date, zone), + enabled = draggable, key = block.event.instanceId, onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, ) - val lifted = dragController.liftedInstanceId == block.event.instanceId + val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) // The source stays put as a ghost while its floating copy travels. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index fc45e8f..4b2ee71 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -109,7 +109,7 @@ class EventShiftTest { assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay) - val moved = allDay.shiftedByDays(3) + val moved = allDay.shiftedByDays(3, berlin) assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14)) assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14)) // The placeholder times exist only for a switch back to timed; untouched. @@ -123,15 +123,34 @@ class EventShiftTest { end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)), ) - val moved = multiDay.shiftedByDays(-2) + val moved = multiDay.shiftedByDays(-2, berlin) assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0))) assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0))) } + @Test + fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() { + // 22:00 Sat -> 04:00 Sun is six real hours; the target Sunday is the one + // Berlin springs forward on. Keeping wall clock would write a five-hour + // DURATION for the whole series. + val overnight = form( + start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)), + end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)), + ) + + val moved = overnight.shiftedByDays(7, berlin) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0))) + assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin)) + .isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin)) + // The wall-clock end therefore lands an hour later than a naive +7 days. + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0))) + } + @Test fun `shifting by no days is a no-op`() { val original = form() - assertThat(original.shiftedByDays(0)).isEqualTo(original) + assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt index 0f6a1b6..6832bbd 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt @@ -23,21 +23,28 @@ class RecurrenceRealignTest { } @Test - fun `a monthly BYMONTHDAY rule follows the day of the month`() { + fun `a day-of-month rule is refused, because the anchor moves by days not dates`() { + // BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1: + // the rebuilt rule would say the 1st while the anchor became Jan 29 — a + // DTSTART that is not an instance of its own rule. Weekday arithmetic is + // uniform mod 7 and survives the same shift; day-of-month is not. + assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull() assertThat( - realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday), - ).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=10") + realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)), + ).isNull() } @Test - fun `a yearly rule realigns both month and day`() { - assertThat( - realignRecurrence( - "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", - monday, - LocalDate(2026, 7, 20), - ), - ).isEqualTo("FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=20") + fun `BYDAY on a non-weekly rule is refused`() { + // "every Monday of the month" is a shape this has not been reasoned about, + // and parseSimpleRecurrence can't read it either — so the UNTIL guard + // downstream would be blind to it. + assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull() + } + + @Test + fun `a rule with no FREQ is refused`() { + assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull() } @Test @@ -71,11 +78,28 @@ class RecurrenceRealignTest { @Test fun `parts we cannot reason about are refused rather than guessed`() { - assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1", monday, wednesday)) + assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday)) .isNull() assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull() } + @Test + fun `everything realignable is also a rule the UNTIL guard can read`() { + // problems() checks UNTIL through parseSimpleRecurrence; a rule this + // realigns but that parser rejects would move a series past its own end + // unchecked. Accepting only weekly BYDAY keeps the two in step. + val realignable = listOf( + "FREQ=WEEKLY;BYDAY=MO", + "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z", + "FREQ=DAILY;COUNT=5", + ) + realignable.forEach { rule -> + assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull() + assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!)) + .isNotNull() + } + } + @Test fun `a leading RRULE prefix is preserved`() { assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index 176f999..8675914 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -45,12 +45,14 @@ class RescheduleViewModelTest { @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) @AfterEach fun tearDown() = Dispatchers.resetMain() - // Monday 2026-06-08, midday UTC — midday so the device zone can't move the - // weekday out from under the BYDAY assertions. + // Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved + // in the device zone rather than UTC, because that is the zone the drop path + // reads dates back in, and at UTC+13 a UTC midday is already Tuesday. The + // BYDAY assertions below depend on this really being a Monday. private val monday = LocalDate(2026, 6, 8) - private val wednesday = LocalDate(2026, 6, 10) - private val beginMillis = - LocalDateTime(monday, LocalTime(12, 0)).toInstant(TimeZone.UTC).toEpochMilliseconds() + private val beginMillis = LocalDateTime(monday, LocalTime(12, 0)) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() private val endMillis = beginMillis + 3_600_000L private fun cal( @@ -103,7 +105,7 @@ class RescheduleViewModelTest { eventId = 42L, beginMillis = beginMillis, endMillis = endMillis, - target = MoveTarget.Day(wednesday), + target = MoveTarget.ByDays(2), ) /** A drop an hour later, expressed the way a timeline drag expresses one. */ @@ -262,13 +264,62 @@ class RescheduleViewModelTest { val vm = viewModel(tempDir, fake) vm.move(toWednesday()) + vm.moveWithScope(RecurringWriteScope.AllEvents) advanceUntilIdle() assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd) - assertThat(vm.scopePrompt.value).isNull() assertThat(fake.updatedEvents).isEmpty() } + @Test + fun `dragging a bounded series' last occurrence still allows moving just it`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") } + } + val vm = viewModel(tempDir, fake) + + vm.move(toWednesday()) + advanceUntilIdle() + // The block belongs to the write, not to the drop: an exception row is + // constrained by no UNTIL, so this scope must still be on offer. + assertThat(vm.scopePrompt.value).isNotNull() + + vm.moveWithScope(RecurringWriteScope.ThisEvent) + advanceUntilIdle() + + assertThat(fake.updatedOccurrences).hasSize(1) + assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java) + } + + @Test + fun `a drag that changes both the day and the time cannot move the series`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } + } + val vm = viewModel(tempDir, fake) + + // Onto the next day *and* two hours later: the anchor moves by the same + // wall-clock shift, so an anchor late enough in the day would cross two + // midnights and land on a weekday the rebuilt rule doesn't name. + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = beginMillis, + endMillis = endMillis, + target = MoveTarget.Start( + Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L), + ), + ), + ) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true)) + } + @Test fun `a zero-distance drop writes nothing and says nothing`( @TempDir tempDir: Path, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e73265..5e04249 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,21 +123,45 @@ Two things the drag has to get right that the edit screen sidesteps: - **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday - rule and the series does not move. `realignRecurrence` re-derives `BYDAY` / - `BYMONTHDAY` / `BYMONTH` from the new date, and returns **null** for rules one - moved occurrence cannot resolve (`BYDAY=MO,WE`, `2TH`, `BYSETPOS`) — the drop - then offers only *this event*, whose exception row carries no rule at all. The - same staleness is reachable from the edit screen; wiring it there too is a - separate change. + rule and the series does not move. `realignRecurrence` re-derives `BYDAY`, and + returns **null** for everything else — the drop then offers only *this event*, + whose exception row carries no rule at all. The same staleness is reachable + from the edit screen; wiring it there too is a separate change. + + What the rule has to agree with is the **series anchor**, not the occurrence + being dragged, and the anchor moves by the same *wall-clock* shift. Only two + things survive that intact, which is exactly the envelope the realigner accepts: + weekday (uniform mod 7, so every anchor time of day crosses the same number of + midnights) and a shift that is a whole number of days (otherwise a late-enough + anchor crosses one midnight more than the occurrence did). Day-of-month is not + uniform — `BYMONTHDAY=28` with a January anchor, occurrence Feb 28 dragged to + Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` rule, a DTSTART that + is not an instance of its own rule and a phantom occurrence on any client that + trusts it. Those drags may only move the one occurrence. + + The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence` + understands, so anything realignable is also a rule whose `UNTIL` the guard + below can actually read. - **The eligibility gate is load-bearing.** Nothing below the UI refuses a write, so `CalendarSource.allowsEventMove` is what keeps read-only and contact-managed events from being dragged. It is deliberately *not* `isEventTarget`: a managed event is editable (reminders, notes) yet must never move, while a switched-off calendar renders nothing to grab anyway. -`EventForm.problems()` runs before the write, so a drop that would push a series -past its own `UNTIL` — which makes the provider generate zero occurrences and the -event vanish — is refused rather than written. +A drop that would push a series past its own `UNTIL` — the provider then +generates zero occurrences and the event vanishes from every view — is refused +rather than written. The check happens at **write** time, not at drop time, +because which date has to clear `UNTIL` depends on how far the write reaches: a +whole-series move carries the *anchor*, a split starts a new series at the moved +occurrence, and a single occurrence becomes an exception row that no `UNTIL` +constrains. Testing the occurrence in every case would refuse the perfectly +ordinary drag of a bounded series' last occurrence. + +Two known limitations of a whole-series move, both shared with the edit screen's +own *All events* time save rather than introduced here — a drag just makes them +one gesture away: the series' `EXDATE` stamps and its exception rows are **not** +re-anchored, so previously deleted occurrences can reappear and previously +modified ones stay behind while the rest of the series moves. ### Event time zones @@ -186,7 +210,10 @@ shape (`DURATION` normalises to `PS`/`PD`, `EVENT_TIMEZONE` is stamped concrete), and it is offered only where the inverse is one symmetric write — a one-off event or a whole-series shift. *This event* leaves an exception row behind and *this and following* splits the series; neither is undone by shifting -back, so both get a plain confirmation. +back, so both get a plain confirmation. Two further gaps, both narrow and +accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall +clock (the −Δ normalises back to where it started), and an undo after a +concurrent remote time change overwrites it, exactly as the forward move would. ## Reminder delivery