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 5d31e29..73c5db7 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 @@ -626,25 +626,8 @@ class TimelineDragController { */ private var originSlot: Triple? = null - /** The edge a resize is dragging, or null for a plain drag (#141). */ - private var resizeEdge: ResizeEdge? = null - - /** The day the resized event starts on; its edges are minutes from that midnight. */ - private var resizeDay: LocalDate? = null - private var resizeStartMin = 0 - private var resizeEndMin = 0 - - /** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */ - private var resizeGrabMin = 0f - - /** The column the resized edge is in, held until the finger is well into another. */ - private var resizeColumn: Int? = null - - /** The slot the resized edge sits on, held against a finger trembling between two. */ - private var resizeSlot: Int? = null - - /** The day, counted from the event's start day, the finger is resizing in. */ - private var resizeEdgeDay = 0 + /** The resize in progress, or null for a plain drag (#141). */ + private var resize: ResizeSession? = null fun begin( block: TimedBlock, @@ -691,10 +674,7 @@ class TimelineDragController { } fun cancel() { - resizeEdge = null - resizeDay = null - resizeColumn = null - resizeSlot = null + resize = null source = null clipOffsetMin = 0 dragZone = null @@ -800,11 +780,9 @@ class TimelineDragController { pointerInRoot: Offset, ) { val span = wallClockSpan(block.event, zone) + val session = ResizeSession(edge, span.day, span.startMin, span.endMin) source = block - resizeEdge = edge - resizeDay = span.day - resizeStartMin = span.startMin - resizeEndMin = span.endMin + resize = session clipOffsetMin = 0 this.titleLines = titleLines settling = null @@ -813,12 +791,12 @@ class TimelineDragController { liftedInstanceId = block.event.instanceId pointer = pointerInRoot // Measured from the edge's own column: the handle overhangs into the next. - val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0) + val edgeDay = if (edge == ResizeEdge.Top) 0 else (session.endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0) val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) - resizeColumn = edgeIndex.takeIf { it >= 0 } + session.column = edgeIndex.takeIf { it >= 0 } ?.let { column(it) } - resizeGrabMin = (pointerResizeMinutes() ?: 0f) - - if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin + session.grabMin = (pointerResizeMinutes(session) ?: 0f) - + if (edge == ResizeEdge.Top) session.startMin else session.endMin originSlot = null recompute() originSlot = drag?.slot @@ -834,7 +812,7 @@ class TimelineDragController { fun preview(zone: TimeZone): EventInstance? { val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null val event = source?.event ?: return null - val edge = resizeEdge + val edge = resize?.edge if (edge != null) { val drop = resizedDrop(event, edge, date, startMin, spanMin, zone) return event.copy(start = drop.start, end = drop.end) @@ -850,7 +828,7 @@ class TimelineDragController { fun finishResize(zone: TimeZone): TimelineResizeDrop? { val landed = drag val origin = originSlot - val edge = resizeEdge + val edge = resize?.edge cancel() if (landed == null || landed.slot == origin) return null val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) @@ -862,9 +840,8 @@ class TimelineDragController { * Where the finger is, in minutes from the resized event's start day — * past 1440 in a later column, negative in an earlier one. */ - private fun pointerResizeMinutes(): Float? { + private fun pointerResizeMinutes(session: ResizeSession): Float? { val grid = geometry.grid?.takeIf { it.isAttached } ?: return null - val day = resizeDay ?: return null val hourPx = geometry.hourPx val columnPx = geometry.columnWidthPx val days = geometry.days @@ -872,22 +849,23 @@ class TimelineDragController { val origin = grid.positionInRoot() val held = stickyColumn( position = (pointer.x - origin.x) / columnPx, - held = resizeColumn, + held = session.column, lastIndex = days.lastIndex, ) - resizeColumn = held - val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt() - resizeEdgeDay = dayOffset + session.column = held + val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt() + session.edgeDay = dayOffset return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f } - private fun recomputeResize(edge: ResizeEdge) { + private fun recomputeResize(session: ResizeSession) { val block = source ?: return - val day = resizeDay ?: return - val raw = pointerResizeMinutes() ?: return - val slot = stickySnap(raw - resizeGrabMin, resizeSlot) - resizeSlot = slot - val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay) + val edge = session.edge + val day = session.day + val raw = pointerResizeMinutes(session) ?: return + val slot = stickySnap(raw - session.grabMin, session.slot) + session.slot = slot + val (start, end) = resizedSpan(edge, session.startMin, session.endMin, slot.toFloat(), session.edgeDay) val days = geometry.days val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return // Which column the start day sits in, possibly off either end of the view. @@ -946,7 +924,7 @@ class TimelineDragController { * no move events while auto-scroll walks the content underneath it. */ fun recompute() { - resizeEdge?.let { + resize?.let { recomputeResize(it) return } @@ -1049,6 +1027,27 @@ class TimelineDragController { } } +/** One resize gesture (#141): the edge it drags, and the edges as minutes from [day]'s midnight. */ +private class ResizeSession( + val edge: ResizeEdge, + /** The day the resized event starts on. */ + val day: LocalDate, + val startMin: Int, + val endMin: Int, +) { + /** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */ + var grabMin = 0f + + /** The column the resized edge is in, held until the finger is well into another. */ + var column: Int? = null + + /** The slot the resized edge sits on, held against a finger trembling between two. */ + var slot: Int? = null + + /** The day, counted from the event's start day, the finger is resizing in. */ + var edgeDay = 0 +} + @Composable fun rememberTimelineDragController(): TimelineDragController { val controller = remember { TimelineDragController() } 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 ca58c5f..099d57f 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 @@ -841,7 +841,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp = /** Whether any day in this week holds more than [lanes] lanes can seat. */ private fun MonthWeek.overflowsAt(lanes: Int): Boolean = - days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() } + days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() } /** * Row height in the continuous grid. The paged grid divides the viewport between @@ -1627,11 +1627,11 @@ internal fun SplitMonthGrid( val inMonth = day.month == month.month && day.year == month.year // Seated by lane rather than gathered by colour, so each dot // is the event the expanded grid draws in that same lane. - val seated = week.laneEvents(col, day, SPLIT_DOT_LANES) + val seated = week.laneEvents(col, SPLIT_DOT_LANES) SplitDayCell( date = day, events = seated, - hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES), + hidden = week.overflowEvents(col, SPLIT_DOT_LANES), isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -2098,8 +2098,8 @@ private fun MonthWeekRow( val ripple = rememberTodayRipple(active = todayCol != null) val overflowRow = rememberOverflowRowHeight() val laneCap = week.laneCapFor(rowHeight, overflowRow) - val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 - val shownLanes = laneCount.coerceAtMost(laneCap) + // Once per row rather than per frame: a drag recomposes every frame. + val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } } val morphing = morphInFlight() // Every chip's start time for this row at once, and only when the row's own // inputs change: formatting is a parsed pattern per call, and the dim cutoff @@ -2283,7 +2283,7 @@ private fun MonthWeekRow( .then(if (morphing || dragging) Modifier else Modifier.clipToBounds()), ) { // Spanning bars on their shared lanes. - week.spans.filter { it.lane < shownLanes }.forEach { span -> + week.spans.filter { it.lane < laneCap }.forEach { span -> val cols = span.endCol - span.startCol + 1 MonthBar( event = span.event, @@ -2355,19 +2355,13 @@ private fun MonthWeekRow( ) } } - // Single-day timed pills + overflow, per column. Pills fill the - // lane slots no bar occupies on THIS day (top-most first), so a - // bar-free day isn't pushed down by a multi-day event that only - // sits on other days of the week. + // Single-day timed pills + overflow, per column, in the lanes + // [seating] gave them. week.days.forEachIndexed { col, d -> - val timed = week.timedByDay[d].orEmpty() - val occupied = week.spans - .filter { it.lane < shownLanes && col in it.startCol..it.endCol } - .map { it.lane } - .toSet() - val freeSlots = (0 until laneCap).filter { it !in occupied } - val pillsShown = timed.take(freeSlots.size) - pillsShown.forEachIndexed { i, ev -> + val seating = seatings[col] + seating.seated.forEach { seat -> + if (seat.bar) return@forEach + val ev = seat.event MonthBar( event = ev, dark = dark, @@ -2379,7 +2373,7 @@ private fun MonthWeekRow( modifier = Modifier .offset( x = colW * col, - y = EVENT_ROW_HEIGHT * freeSlots[i], + y = EVENT_ROW_HEIGHT * seat.lane, ) .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) .then( @@ -2395,18 +2389,10 @@ private fun MonthWeekRow( .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) } - val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size - if (hidden > 0) { - val hiddenEvents = buildList { - week.spans - .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } - .forEach { add(it.event) } - addAll(timed.drop(pillsShown.size)) - } + if (seating.hidden.isNotEmpty()) { OverflowDots( - events = hiddenEvents, + events = seating.hidden, day = d, - total = hidden, dark = dark, rowHeight = overflowRow, modifier = Modifier @@ -2786,7 +2772,6 @@ private fun MonthBar( private fun OverflowDots( events: List, day: LocalDate, - total: Int, dark: Boolean, rowHeight: Dp, modifier: Modifier = Modifier, @@ -2808,7 +2793,7 @@ private fun OverflowDots( .background(eventAccent(argb, dark, soften), CircleShape), ) } - val extra = total - dots.size + val extra = events.size - dots.size if (extra > 0) { Text( text = "+$extra", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index cfb3c83..a6cdfcc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,33 +39,55 @@ data class MonthWeek( val countByDay: Map, ) +/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */ +data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean) + /** - * The events occupying each lane of [day] — column [col] of this week — in lane - * order, capped at [laneCap] lanes. + * How one day of a week row is seated with [laneCap] lanes: what sits in each + * lane, top-most first with empty lanes left out, and what had no lane left. + */ +data class DaySeating(val seated: List, val hidden: List) + +/** + * The seating of column [col] with [laneCap] lanes — the one every reader of a + * row builds on: the bars and pills the paged grid draws, the split style's dots + * ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]). * - * This is the same seating [spans]/[timedByDay] get when the paged grid draws a - * week: bars keep the lane the row layout gave them, and the day's timed events - * fill whatever slots are left, top-most first. Reading it here means the split - * style's dots and the paged style's bars describe a day in the *same order*, so - * dot _i_ and lane _i_ are the same event and one can morph into the other (#53). + * Bars keep the lane the row layout gave them, and the day's timed events fill + * whatever slots are left, top-most first, so a bar-free day isn't pushed down by + * a multi-day event that only sits on other days of the week. Overflow is the + * exact complement, bars before pills. + */ +fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating { + val covering = spans.filter { col in it.startCol..it.endCol } + val byLane = arrayOfNulls(laneCap.coerceAtLeast(0)) + covering.forEach { span -> + if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true) + } + val timed = timedByDay[days[col]].orEmpty() + var pills = 0 + for (lane in byLane.indices) { + if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false) + } + return DaySeating( + seated = byLane.filterNotNull(), + hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills), + ) +} + +/** + * The events occupying each lane of column [col], in lane order, capped at + * [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading + * it here means the split style's dots and the paged style's bars describe a day + * in the *same order*, so dot _i_ and lane _i_ are the same event and one can + * morph into the other (#53). * * Deriving the dots independently — by distinct colour, as they first were — * left dot _i_ standing for no particular event, and quietly merged two events * that shared a calendar into a single dot. */ -fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { - val byLane = arrayOfNulls(laneCap) - spans.forEach { span -> - if (span.lane < laneCap && col in span.startCol..span.endCol) { - byLane[span.lane] = span.event - } - } - val free = (0 until laneCap).filter { byLane[it] == null } - timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> - byLane[free[i]] = event - } - return byLane.filterNotNull() -} +fun MonthWeek.laneEvents(col: Int, laneCap: Int): List = + seating(col, laneCap).seated.map { it.event } /** * The event drawn in lane [lane] of column [col], or null for an empty slot. @@ -74,14 +96,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List= 0 } ?: return null - return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index) + return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event } /** @@ -98,19 +113,11 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } /** - * The events on [day] that [laneEvents] had no lane left for — its exact + * The events in column [col] that [laneEvents] had no lane left for — its exact * complement, in the same bars-then-pills order. Returned as events rather than * a count because dimming is a per-event question (#79). */ -fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List { - val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol } - return buildList { - spans.forEach { span -> - if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event) - } - addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes)) - } -} +fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List = seating(col, laneCap).hidden /** * State for the continuous style (#38): a vertical stream of *self-contained* diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index 68c08ab..150b3f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -7,100 +7,63 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.daysUntil import kotlinx.datetime.plus -/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */ +/** Pages the week pager spans — a little under four centuries. */ internal const val WEEK_PAGE_COUNT: Int = 20_000 -private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2 - -/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */ -private val PAGE_EPOCH = LocalDate(2000, 1, 3) - -/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */ -internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate = - PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY) - -/** The pager page whose week contains [date], with weeks starting on [firstDay]. */ -internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int { - val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay)) - return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1) -} - -/** How a timeline pager cuts the calendar into pages of [dayCount] days. */ -sealed interface PageGrid { - val dayCount: Int - val pageCount: Int - - /** First day shown on pager [page]. */ - fun startForPage(page: Int): LocalDate - - /** The pager page showing [date]. */ - fun pageFor(date: LocalDate): Int -} - -/** Whole weeks, starting on [firstDay]. */ -data class WeekGrid(val firstDay: DayOfWeek) : PageGrid { - override val dayCount: Int get() = 7 - override val pageCount: Int get() = WEEK_PAGE_COUNT - override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay) - override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay) -} - /** Pages the multi-day pager spans — over three centuries at the widest page. */ internal const val DAYS_PAGE_COUNT: Int = 40_000 -private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2 +/** Pages the day pager spans — a little under four centuries. */ +internal const val DAY_PAGE_COUNT: Int = 140_000 -/** A fixed day the multi-day pages are counted from. */ +/** A fixed day every grid's pages are counted from, at its middle page. */ private val DAYS_EPOCH = LocalDate(2000, 1, 1) +/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */ +private val WEEK_EPOCH = LocalDate(2000, 1, 3) + /** - * Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any - * date can start a page. The phase stays put while paging and only changes when - * a jump moves another date to the first column. + * How a timeline pager cuts the calendar: runs of [dayCount] days, offset + * [phase] days from a fixed epoch, over [pageCount] pages centred on it. The + * multi-day view's phase (#193) stays put while paging and only changes when a + * jump moves another date to the first column. */ -data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid { - override val pageCount: Int get() = DAYS_PAGE_COUNT +data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) { - override fun startForPage(page: Int): LocalDate = - DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY) + private val epochPage: Int get() = pageCount / 2 - override fun pageFor(date: LocalDate): Int = - (DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) - .coerceIn(0, DAYS_PAGE_COUNT - 1) + /** First day shown on pager [page]. */ + fun startForPage(page: Int): LocalDate = + DAYS_EPOCH.plus(phase + (page - epochPage) * dayCount, DateTimeUnit.DAY) + + /** The pager page showing [date]. */ + fun pageFor(date: LocalDate): Int = + (epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) + .coerceIn(0, pageCount - 1) companion object { + /** Single days: every date starts a page of its own. */ + val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT) + + /** Whole weeks, starting on [firstDay]. */ + fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid( + dayCount = 7, + phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)), + pageCount = WEEK_PAGE_COUNT, + ) + /** The grid of [dayCount]-day pages on which [date] is a page's first day. */ fun startingAt(date: LocalDate, dayCount: Int): DaysGrid = DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount)) } } -/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */ -internal const val DAY_PAGE_COUNT: Int = 140_000 - -private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2 - -/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */ -private val DAY_EPOCH = LocalDate(2000, 1, 1) - -/** Single days: every date starts a page of its own. */ -data object DayGrid : PageGrid { - override val dayCount: Int get() = 1 - override val pageCount: Int get() = DAY_PAGE_COUNT - - override fun startForPage(page: Int): LocalDate = - DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY) - - override fun pageFor(date: LocalDate): Int = - (DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1) -} - /** * Where the focused date goes when the pager settles on [page] (#184): kept if * the page still shows it, otherwise moved by the pages travelled, so it keeps * its place in the page — the same weekday a week on. */ -internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate { +internal fun DaysGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate { val start = startForPage(page) val end = start.plus(dayCount - 1, DateTimeUnit.DAY) if (focus in start..end) return focus 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 6eaa28e..e19045b 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 @@ -464,11 +464,12 @@ private fun PagedTimelineScreen( // Straight to the pager: a tap mid-fling must still land, and the anchor // hasn't caught up with the fling yet to tell the difference. A date that - // would open mid-page on this grid re-frames the pages around it instead. + // would open mid-page on this grid, in a view that frames its pages from the + // date (multi-day), re-frames the pages around it instead. val jumpToDate: (LocalDate) -> Unit = { target -> focus?.date = target val page = grid.pageFor(target) - if (grid is DaysGrid && grid.startForPage(page) != target) { + if (grid.startForPage(page) != target && viewModel.rule.value(target) != grid) { viewModel.jumpTo(target) } else { scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) } @@ -531,7 +532,7 @@ private fun WeekContent( state: WeekUiState, today: LocalDate, pagerState: PagerState, - grid: PageGrid, + grid: DaysGrid, kind: TimelineKind, morph: TimelineMorph?, handoffStrip: Dp?, @@ -696,7 +697,7 @@ private fun WeekContent( @Composable private fun WeekPager( pagerState: PagerState, - grid: PageGrid, + grid: DaysGrid, kind: TimelineKind, span: (DaySpan) -> StateFlow, today: LocalDate, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 4128ef7..fe5992f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -62,10 +62,10 @@ private const val SPAN_CACHE_DAYS = 28 data class DaySpan(val start: LocalDate, val count: Int) /** How the pager cuts the calendar, and the page the anchor sits on. */ -data class PagerPosition(val grid: PageGrid, val page: Int) +data class PagerPosition(val grid: DaysGrid, val page: Int) /** Where [anchor] sits under [rule]. */ -fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition = +fun positionOf(anchor: LocalDate, rule: (LocalDate) -> DaysGrid): PagerPosition = rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } /** @@ -84,8 +84,8 @@ abstract class PagedTimelineViewModel( todaySource: TodaySource, zoneSource: ZoneSource, private val io: CoroutineDispatcher, - gridRule: Flow<(LocalDate) -> PageGrid>, - initialRule: (LocalDate) -> PageGrid, + gridRule: Flow<(LocalDate) -> DaysGrid>, + initialRule: (LocalDate) -> DaysGrid, ) : ViewModel() { /** @@ -121,7 +121,7 @@ abstract class PagedTimelineViewModel( * The grid for a given anchor, held eagerly so a screen can place its pager * on its very first frame rather than a frame after the anchor moved. */ - val rule: StateFlow<(LocalDate) -> PageGrid> = + val rule: StateFlow<(LocalDate) -> DaysGrid> = gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule) /** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */ @@ -233,11 +233,11 @@ class WeekViewModel @Inject constructor( zoneSource = zoneSource, io = io, gridRule = settingsPrefs.weekStart.map { pref -> - val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault())) - val rule: (LocalDate) -> PageGrid = { grid } + val grid = DaysGrid.weeks(pref.resolveFirstDay(Locale.getDefault())) + val rule: (LocalDate) -> DaysGrid = { grid } rule }, - initialRule = { WeekGrid(DayOfWeek.MONDAY) }, + initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) }, ) /** @@ -278,8 +278,8 @@ class DayViewModel @Inject constructor( todaySource = todaySource, zoneSource = zoneSource, io = io, - gridRule = flowOf { _: LocalDate -> DayGrid }, - initialRule = { DayGrid }, + gridRule = flowOf { _: LocalDate -> DaysGrid.days }, + initialRule = { DaysGrid.days }, ) /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt index 63d37e4..876f52a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -37,7 +37,7 @@ class LaneEventsTest { val week = rowOfJuly6(listOf(bar, meeting)) // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. - assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + assertThat(week.laneEvents(col = 1, laneCap = 3)) .containsExactly(bar, meeting) .inOrder() } @@ -48,7 +48,7 @@ class LaneEventsTest { val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) val week = rowOfJuly6(listOf(bar, monday)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + assertThat(week.laneEvents(col = 0, laneCap = 3)) .containsExactly(monday) } @@ -58,10 +58,9 @@ class LaneEventsTest { val week = rowOfJuly6(listOf(bar)) (1..3).forEach { col -> - val day = LocalDate(2026, 7, 6 + col) - assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + assertThat(week.laneEvents(col, laneCap = 3)).containsExactly(bar) } - assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() + assertThat(week.laneEvents(col = 4, laneCap = 3)).isEmpty() } /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ @@ -71,7 +70,7 @@ class LaneEventsTest { val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) val week = rowOfJuly6(listOf(first, second)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + assertThat(week.laneEvents(col = 0, laneCap = 3)) .containsExactly(first, second) .inOrder() } @@ -81,7 +80,7 @@ class LaneEventsTest { val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } val week = rowOfJuly6(events) - val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + val seated = week.laneEvents(col = 0, laneCap = 3) assertThat(seated).hasSize(3) assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) @@ -95,7 +94,7 @@ class LaneEventsTest { } val week = rowOfJuly6(bars) - val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + val seated = week.laneEvents(col = 0, laneCap = 3) assertThat(seated).hasSize(3) assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) .containsNoneIn(seated) @@ -105,14 +104,13 @@ class LaneEventsTest { fun `overflow is exactly what seating left behind`() { val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } val week = rowOfJuly6(events) - val day = LocalDate(2026, 7, 6) - val seated = week.laneEvents(col = 0, day = day, laneCap = 3) - val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3) + val seated = week.laneEvents(col = 0, laneCap = 3) + val overflow = week.overflowEvents(col = 0, laneCap = 3) assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder() assertThat(seated + overflow).containsExactlyElementsIn(events) - assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day]) + assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[LocalDate(2026, 7, 6)]) } @Test @@ -124,8 +122,7 @@ class LaneEventsTest { val parked = week.spans.filter { it.lane >= 3 }.map { it.event } (0..2).forEach { col -> - val day = LocalDate(2026, 7, 6 + col) - assertThat(week.overflowEvents(col, day, laneCap = 3)) + assertThat(week.overflowEvents(col, laneCap = 3)) .containsExactlyElementsIn(parked) } } @@ -134,7 +131,7 @@ class LaneEventsTest { fun `a day that fits has no overflow`() { val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L))) - assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty() + assertThat(week.overflowEvents(col = 0, laneCap = 3)).isEmpty() } private companion object { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt new file mode 100644 index 0000000..1aa3dc9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt @@ -0,0 +1,170 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertWithMessage +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse +import de.jeanlucmakiola.calendula.testEvent +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test +import kotlin.random.Random +import kotlin.time.Duration.Companion.minutes + +/** + * The month row's seating — which event sits in which lane of a day, and which + * fall to the overflow — is read in four places: the bars and pills a row draws, + * the split style's dots ([laneEvents]), the hit test ([chipAt]) and the overflow + * ([overflowEvents]). They must agree for dot _i_ to morph into lane _i_ (#53). + * + * The references below are the four copies as they were before they shared one + * implementation, the row's taken from its composable; this pins the shared one + * to all of them across a spread of generated weeks. + */ +class MonthSeatingTest { + + private val zone = TimeZone.of("Europe/Berlin") + private val firstMonday = LocalDate(2026, 3, 23) + + private var overflowed = 0 + private var stackedBars = 0 + + @Test + fun `every reader of a week sees the same seating`() { + val random = Random(53) + repeat(400) { round -> + val start = firstMonday.plus(round % 9, DateTimeUnit.WEEK) + val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } + val events = randomEvents(random, start, count = random.nextInt(0, 14)) + val laid = layoutCalendarWeek(days, events, zone) + val weeks = listOf(laid, clipWeekToMonth(laid, YearMonth(days[3].year, days[3].month))) + weeks.forEach { week -> + for (laneCap in 0..6) checkWeek(week, laneCap, "round $round, laneCap $laneCap") + if (week.spans.any { it.lane > 0 }) stackedBars++ + } + } + assertWithMessage("generated days with overflow").that(overflowed).isGreaterThan(500) + assertWithMessage("generated weeks with stacked bars").that(stackedBars).isGreaterThan(100) + } + + private fun checkWeek(week: MonthWeek, laneCap: Int, label: String) { + week.days.forEachIndexed { col, day -> + val where = "$label, col $col" + val row = rowSeating(week, col, day, laneCap) + val dots = refLaneEvents(week, col, day, laneCap) + val hits = (0 until laneCap).mapNotNull { lane -> refChipAt(week, col, lane, laneCap)?.let { lane to it } } + val overflow = refOverflowEvents(week, col, day, laneCap) + if (overflow.isNotEmpty()) overflowed++ + + assertWithMessage("$where: hit test vs row").that(hits).isEqualTo(row.seated) + assertWithMessage("$where: dots vs row").that(dots).isEqualTo(row.seated.map { it.second }) + assertWithMessage("$where: overflow vs row").that(overflow).isEqualTo(row.hiddenEvents) + assertWithMessage("$where: dot count vs hidden list") + .that(row.hiddenCount.coerceAtLeast(0)).isEqualTo(row.hiddenEvents.size) + + assertWithMessage("$where: laneEvents").that(week.laneEvents(col, laneCap)).isEqualTo(dots) + assertWithMessage("$where: overflowEvents").that(week.overflowEvents(col, laneCap)).isEqualTo(overflow) + (-1..laneCap).forEach { lane -> + assertWithMessage("$where: chipAt lane $lane") + .that(week.chipAt(col, lane, laneCap)) + .isEqualTo(refChipAt(week, col, lane, laneCap)) + } + val seating = week.seating(col, laneCap) + assertWithMessage("$where: seating lanes") + .that(seating.seated.map { it.lane to it.event }).isEqualTo(row.seated) + assertWithMessage("$where: seating hidden").that(seating.hidden).isEqualTo(row.hiddenEvents) + } + } + + private fun randomEvents(random: Random, weekStart: LocalDate, count: Int): List = + (1..count).map { n -> + val id = n.toLong() + val day = weekStart.plus(random.nextInt(-4, 10), DateTimeUnit.DAY) + val response = if (random.nextInt(6) == 0) EventResponse.Declined else EventResponse.Going + when (random.nextInt(5)) { + 0, 1 -> { + val from = day.atTime(0, 0).toInstant(TimeZone.UTC) + val to = day.plus(random.nextInt(1, 10), DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC) + testEvent(id = id, title = "A$id", start = from, end = to, isAllDay = true, response = response) + } + 2 -> { + // Crosses midnight, sometimes ending on it exactly. + val from = day.atTime(random.nextInt(18, 24), 0).toInstant(zone) + val to = from + (random.nextInt(1, 60) * 30).minutes + testEvent(id = id, title = "M$id", start = from, end = to, response = response) + } + else -> { + val from = day.atTime(random.nextInt(0, 23), random.nextInt(0, 4) * 15).toInstant(zone) + val length = if (random.nextInt(8) == 0) 0 else random.nextInt(1, 8) * 15 + testEvent(id = id, title = "T$id", start = from, end = from + length.minutes, response = response) + } + } + } + + private class RowSeating( + val seated: List>, + val hiddenEvents: List, + val hiddenCount: Int, + ) + + /** MonthWeekRow's bars, pills and overflow dots, as the composable worked them out. */ + private fun rowSeating(week: MonthWeek, col: Int, d: LocalDate, laneCap: Int): RowSeating { + val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 + val shownLanes = laneCount.coerceAtMost(laneCap) + val bars = week.spans + .filter { it.lane < shownLanes && col in it.startCol..it.endCol } + .map { it.lane to it.event } + val timed = week.timedByDay[d].orEmpty() + val occupied = week.spans + .filter { it.lane < shownLanes && col in it.startCol..it.endCol } + .map { it.lane } + .toSet() + val freeSlots = (0 until laneCap).filter { it !in occupied } + val pillsShown = timed.take(freeSlots.size) + val pills = pillsShown.mapIndexed { i, ev -> freeSlots[i] to ev } + val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size + val hiddenEvents = buildList { + week.spans + .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } + .forEach { add(it.event) } + addAll(timed.drop(pillsShown.size)) + } + return RowSeating((bars + pills).sortedBy { it.first }, hiddenEvents, hidden) + } + + private fun refLaneEvents(week: MonthWeek, col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) + week.spans.forEach { span -> + if (span.lane < laneCap && col in span.startCol..span.endCol) byLane[span.lane] = span.event + } + val free = (0 until laneCap).filter { byLane[it] == null } + week.timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> byLane[free[i]] = event } + return byLane.filterNotNull() + } + + private fun refChipAt(week: MonthWeek, col: Int, lane: Int, laneCap: Int): EventInstance? { + if (col !in week.days.indices || lane !in 0 until laneCap) return null + week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event } + val occupied = week.spans + .filter { it.lane < laneCap && col in it.startCol..it.endCol } + .map { it.lane } + .toSet() + val free = (0 until laneCap).filter { it !in occupied } + val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null + return week.timedByDay[week.days[col]].orEmpty().take(free.size).getOrNull(index) + } + + private fun refOverflowEvents(week: MonthWeek, col: Int, day: LocalDate, laneCap: Int): List { + val seatedLanes = week.spans.count { it.lane < laneCap && col in it.startCol..it.endCol } + return buildList { + week.spans.forEach { span -> + if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event) + } + addAll(week.timedByDay[day].orEmpty().drop(laneCap - seatedLanes)) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt index 96e5025..6683dd5 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt @@ -13,13 +13,13 @@ class FocusOnPageTest { @Test fun `a page that still shows the focus keeps it`() { - val grid = WeekGrid(DayOfWeek.MONDAY) + val grid = DaysGrid.weeks(DayOfWeek.MONDAY) assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday) } @Test fun `a week on keeps the weekday`() { - val grid = WeekGrid(DayOfWeek.MONDAY) + val grid = DaysGrid.weeks(DayOfWeek.MONDAY) val page = grid.pageFor(wednesday) assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7)) assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16)) @@ -34,7 +34,7 @@ class FocusOnPageTest { @Test fun `a day page is the focus`() { - val page = DayGrid.pageFor(wednesday) - assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5)) + val page = DaysGrid.days.pageFor(wednesday) + assertThat(DaysGrid.days.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5)) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt new file mode 100644 index 0000000..93a5814 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt @@ -0,0 +1,137 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +/** + * Pins each grid's page numbering to the formulas it shipped with: a pager + * restores its page by number, so a change here moves the user to another date. + */ +class PageNumberingTest { + + private val dates: List = buildList { + var d = LocalDate(1500, 1, 1) + while (d < LocalDate(2500, 1, 1)) { + add(d) + d = d.plus(37, DateTimeUnit.DAY) + } + var e = LocalDate(1999, 12, 1) + while (e < LocalDate(2000, 2, 1)) { + add(e) + e = e.plus(1, DateTimeUnit.DAY) + } + } + + private fun pages(count: Int): List = + listOf(-7, -1, 0, 1, count / 2 - 3, count / 2 - 1, count / 2, count / 2 + 1, count - 2, count - 1, count, count + 5) + + (0 until count step 997) + + @Test + fun `week pages keep their numbering for every first day`() { + DayOfWeek.entries.forEach { firstDay -> + val grid = DaysGrid.weeks(firstDay) + assertThat(grid.dayCount).isEqualTo(7) + assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT) + pages(LEGACY_WEEK_COUNT).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(legacyWeekStart(page, firstDay)) + } + dates.forEach { date -> + assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay)) + } + } + } + + @Test + fun `multi-day pages keep their numbering for every count and phase`() { + (1..7).forEach { count -> + (-count..count * 2).forEach { phase -> + val grid = DaysGrid(count, phase) + assertThat(grid.pageCount).isEqualTo(LEGACY_DAYS_COUNT) + pages(LEGACY_DAYS_COUNT).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(legacyDaysStart(page, count, phase)) + } + dates.forEach { date -> + assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase)) + } + } + dates.filter { it.year in 1950..2050 }.forEach { date -> + val grid = DaysGrid.startingAt(date, count) + assertThat(grid.startForPage(grid.pageFor(date))).isEqualTo(date) + } + } + } + + @Test + fun `day pages keep their numbering`() { + val grid = DaysGrid.days + assertThat(grid.dayCount).isEqualTo(1) + assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT) + pages(LEGACY_DAY_COUNT).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(legacyDayStart(page)) + } + dates.forEach { date -> + assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date)) + } + } + + @Test + fun `a multi-day jump re-frames exactly when the date would open mid-page`() { + val inRange = dates.filter { it.year in 1950..2050 } + (3..6).forEach { count -> + val grid = DaysGrid.startingAt(LocalDate(2026, 10, 16), count) + inRange.forEach { target -> + val midPage = grid.startForPage(grid.pageFor(target)) != target + assertThat(DaysGrid.startingAt(target, count) != grid).isEqualTo(midPage) + } + } + } + + @Test + fun `grids are equal exactly when they page the same way`() { + assertThat(DaysGrid.weeks(DayOfWeek.MONDAY)).isEqualTo(DaysGrid.weeks(DayOfWeek.MONDAY)) + assertThat(DaysGrid.weeks(DayOfWeek.MONDAY)).isNotEqualTo(DaysGrid.weeks(DayOfWeek.SUNDAY)) + assertThat(DaysGrid(3, 1)).isEqualTo(DaysGrid(3, 1)) + assertThat(DaysGrid(3, 1)).isNotEqualTo(DaysGrid(3, 2)) + assertThat(DaysGrid(3, 1)).isNotEqualTo(DaysGrid(4, 1)) + assertThat(DaysGrid.days).isEqualTo(DaysGrid.days) + assertThat(DaysGrid.days.hashCode()).isEqualTo(DaysGrid.days.hashCode()) + } + + private companion object { + const val LEGACY_WEEK_COUNT = 20_000 + const val LEGACY_DAYS_COUNT = 40_000 + const val LEGACY_DAY_COUNT = 140_000 + val WEEK_EPOCH = LocalDate(2000, 1, 3) + val DAYS_EPOCH = LocalDate(2000, 1, 1) + + fun LocalDate.weekStart(firstDay: DayOfWeek): LocalDate = + minus(((dayOfWeek.ordinal - firstDay.ordinal) + 7) % 7, DateTimeUnit.DAY) + + fun legacyWeekStart(page: Int, firstDay: DayOfWeek): LocalDate = + WEEK_EPOCH.weekStart(firstDay).plus((page - LEGACY_WEEK_COUNT / 2) * 7, DateTimeUnit.DAY) + + fun legacyWeekPage(date: LocalDate, firstDay: DayOfWeek): Int { + val days = WEEK_EPOCH.weekStart(firstDay).daysUntil(date.weekStart(firstDay)) + return (LEGACY_WEEK_COUNT / 2 + days / 7).coerceIn(0, LEGACY_WEEK_COUNT - 1) + } + + fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate = + DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY) + + fun legacyDaysPage(date: LocalDate, count: Int, phase: Int): Int = + (LEGACY_DAYS_COUNT / 2 + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, count)) + .coerceIn(0, LEGACY_DAYS_COUNT - 1) + + fun legacyDayStart(page: Int): LocalDate = + DAYS_EPOCH.plus(page - LEGACY_DAY_COUNT / 2, DateTimeUnit.DAY) + + fun legacyDayPage(date: LocalDate): Int = + (LEGACY_DAY_COUNT / 2 + DAYS_EPOCH.daysUntil(date)).coerceIn(0, LEGACY_DAY_COUNT - 1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt index 9199ec4..ef014da 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -16,34 +16,34 @@ class WeekPagingTest { @Test fun `a page and its week round-trip for every first day`() { DayOfWeek.entries.forEach { firstDay -> - val page = weekPageFor(sunday, firstDay) - val start = weekStartForPage(page, firstDay) + val page = DaysGrid.weeks(firstDay).pageFor(sunday) + val start = DaysGrid.weeks(firstDay).startForPage(page) assertThat(start).isEqualTo(sunday.startOfWeek(firstDay)) - assertThat(weekPageFor(start, firstDay)).isEqualTo(page) + assertThat(DaysGrid.weeks(firstDay).pageFor(start)).isEqualTo(page) } } @Test fun `every day of a week maps to the same page`() { val start = sunday.startOfWeek(DayOfWeek.MONDAY) - val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + val pages = (0..6).map { DaysGrid.weeks(DayOfWeek.MONDAY).pageFor(start.plus(it, DateTimeUnit.DAY)) } assertThat(pages.toSet()).hasSize(1) } @Test fun `adjacent pages are adjacent weeks`() { - val page = weekPageFor(sunday, DayOfWeek.SUNDAY) - assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY)) + val page = DaysGrid.weeks(DayOfWeek.SUNDAY).pageFor(sunday) + assertThat(DaysGrid.weeks(DayOfWeek.SUNDAY).startForPage(page + 1)) .isEqualTo(LocalDate(2026, 10, 4)) - assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY)) + assertThat(DaysGrid.weeks(DayOfWeek.SUNDAY).startForPage(page - 1)) .isEqualTo(LocalDate(2026, 9, 20)) } @Test fun `dates before the epoch page resolve too`() { val old = LocalDate(1990, 5, 16) - val page = weekPageFor(old, DayOfWeek.MONDAY) - assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14)) + val page = DaysGrid.weeks(DayOfWeek.MONDAY).pageFor(old) + assertThat(DaysGrid.weeks(DayOfWeek.MONDAY).startForPage(page)).isEqualTo(LocalDate(1990, 5, 14)) } @Test @@ -69,14 +69,14 @@ class WeekPagingTest { @Test fun `a day page and its day round-trip`() { listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> - assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day) + assertThat(DaysGrid.days.startForPage(DaysGrid.days.pageFor(day))).isEqualTo(day) } } @Test fun `adjacent day pages are adjacent days across a month end`() { - val page = DayGrid.pageFor(LocalDate(2026, 2, 28)) - assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) - assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + val page = DaysGrid.days.pageFor(LocalDate(2026, 2, 28)) + assertThat(DaysGrid.days.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(DaysGrid.days.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) } }