Share one date span across every view (#457) (#466)
Beta — Codeberg pre-release / detect (push) Successful in 8s
Beta — Codeberg pre-release / beta (push) Skipped

Switching back to month view after going before 1 Jan 1900 in day or week view crashed with `IndexOutOfBoundsException: index: -1, size: 12`. Each view had its own range: the month grid and the date picker covered 1900–2100, while the week and day pagers reached roughly 1808–2191. The month view took the date it was handed without checking it.

- A new `CalendarSpan` (1 Jan 1600 – 31 Dec 2399) is used by the month and dense-week grids, the week, multi-day and day pagers, and the date picker's `yearRange`. No view can hand another a date it can't show, and you can now go past 2100 and before 1900 everywhere.
- Every write to the month view's current month goes through one clamp, and `yearMonthForIndex` can't throw on any index. This is a safety net for dates arriving from outside, such as another app opening Calendula via the time intent.
- Tapping a greyed-out day past either end of the grid is ignored.

The timeline pagers now number pages from the span's first day rather than around a fixed middle page, so page numbers change once. `PageNumberingTest` pins the new numbering.

Closes #457

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/466
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-11-02 20:31:21 +01:00
1 parent b794c35d8e
commit 1807f228e7
8 files changed
+236 -94

No files matched your search

@@ -30,7 +30,9 @@ fun CalendarDatePickerDialog(
onDismiss: () -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
.toEpochDays() * MILLIS_PER_DAY,
yearRange = CalendarSpan.years,
)
DatePickerDialog(
onDismissRequest = onDismiss,
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.LocalDate
/**
* The dates every view can show and every date picker offers. One span for all
* of them, so no view can hand another a date it has no page for (#457).
*/
object CalendarSpan {
val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1)
val LAST_DAY: LocalDate = LocalDate(2399, 12, 31)
val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year
}
@@ -363,7 +363,7 @@ fun MonthScreen(
if (!scrolling) return@LaunchedEffect
snapshotFlow {
// Empty before the first layout pass — and reporting a default of 0
// for it would claim January 1900 is on screen and drag the whole
// for it would claim the span's first month is on screen and drag the whole
// window back there.
val visible = listState.layoutInfo.visibleItemsInfo
when {
@@ -448,7 +448,8 @@ fun MonthScreen(
}
}
// Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target ->
val jumpToDate: (LocalDate) -> Unit = { date ->
val target = coerceToMonthRange(date)
if (scrolling) {
val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth
@@ -551,8 +552,10 @@ fun MonthScreen(
// month, pager and all — straight away, or a swipe
// still settling would land and take the tap back.
onSelectDay = { date ->
viewModel.selectDate(date)
jumpToMonth(date.yearMonth)
if (isInMonthRange(date)) {
viewModel.selectDate(date)
jumpToMonth(date.yearMonth)
}
},
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
@@ -95,7 +96,7 @@ class MonthViewModel @Inject constructor(
val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
private val _month = MutableStateFlow(todayDate.yearMonth)
private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
val month: StateFlow<YearMonth> = _month
/**
@@ -149,7 +150,7 @@ class MonthViewModel @Inject constructor(
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page)
setMonth(yearMonthForIndex(page))
// Moving the grid to another month restores its full agenda.
_selectedDate.value = null
}
@@ -298,9 +299,11 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at.
*/
fun selectDate(date: LocalDate) {
// A greyed-out day past either end of the grid has no month to follow to.
if (!isInMonthRange(date)) return
val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target
if (target != _month.value) setMonth(target)
}
fun clearSelection() { _selectedDate.value = null }
@@ -309,16 +312,16 @@ class MonthViewModel @Inject constructor(
fun stepSplitPane(days: Int) {
val selected = _selectedDate.value
if (selected == null) {
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
} else {
val next = selected.plus(days, DateTimeUnit.DAY)
val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
_selectedDate.value = next
_month.value = next.yearMonth
setMonth(next.yearMonth)
}
}
fun goToToday() {
_month.value = todayDate.yearMonth
setMonth(todayDate.yearMonth)
_selectedDate.value = null
}
@@ -330,7 +333,7 @@ class MonthViewModel @Inject constructor(
private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return
if (_month.value != old.yearMonth) return
_month.value = new.yearMonth
setMonth(new.yearMonth)
_selectedDate.value = null
}
@@ -344,14 +347,24 @@ class MonthViewModel @Inject constructor(
*/
fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return
_month.value = ym
setMonth(ym)
_selectedDate.value = null
}
/** Jump to the month containing [date] (drawer jump-to-date). */
/**
* Jump to the month containing [date] (drawer jump-to-date, or the focus
* handed over by another view). The focus itself keeps a date outside the
* grid's span, so the day and week views return to it unchanged.
*/
fun goToDate(date: LocalDate) {
_month.value = date.yearMonth
_selectedDate.value = date
val target = coerceToMonthRange(date)
setMonth(target.yearMonth)
_selectedDate.value = target
}
/** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */
private fun setMonth(ym: YearMonth) {
_month.value = yearMonthForIndex(monthIndexOf(ym))
}
private fun buildState(
@@ -578,24 +591,39 @@ internal fun nextLoadWindow(
/**
* The continuous grid addresses months by an absolute index, so the list has one
* stable, gap-free coordinate space to scroll through and key its items by.
* Index 0 is January 1900; the list runs to [continuousMonthCount].
* Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount].
*
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
* the week-start preference — changing it reflows the rows *inside* a month
* block but never moves the block, so a scroll position stays put.
*/
private const val MONTH_INDEX_EPOCH_YEAR = 1900
private const val MONTH_INDEX_END_YEAR = 2100
private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year
private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year
/** The first and last day the month and Dense grids can show. */
private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1)
private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31)
internal fun monthIndexOf(ym: YearMonth): Int =
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
MONTH_INDEX_EPOCH_YEAR + index / 12,
Month.entries[index % 12],
)
/** The month at [index], held to the grid's span, so a stray index can't throw (#457). */
internal fun yearMonthForIndex(index: Int): YearMonth {
val held = clampMonthIndex(index)
return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12])
}
/** Total months the continuous grid scrolls through (1900 → 2100). */
internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1)
/**
* Hold [date] inside the span the month grid can show. Every view shares
* [CalendarSpan], but another app can open Calendula on any date (#457).
*/
internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY)
internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY
/** Total months the continuous grid scrolls through. */
internal fun continuousMonthCount(): Int =
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
@@ -611,25 +639,23 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
/**
* Hold a load window inside the months that actually exist. The pad
* [nextLoadWindow] adds can otherwise push an index past either end of the
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
* span, and [yearMonthForIndex] has no month to hand back for one.
*/
internal fun clampMonthWindow(window: IntRange): IntRange {
val last = continuousMonthCount() - 1
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
}
internal fun clampMonthWindow(window: IntRange): IntRange =
clampMonthIndex(window.first)..clampMonthIndex(window.last)
/**
* The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
* scrolling past. Index 0 is the span's first week under the active week-start,
* so — like the month space — every index is non-negative and the LazyColumn's
* item indices and week indices are the same number.
*
* Unlike month indices these *do* shift with the week-start preference, which is
* why the list state is keyed on it.
*/
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY
private val WEEK_INDEX_END = GRID_LAST_DAY
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
@@ -639,7 +665,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
/** Total weeks the Dense grid scrolls through. */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -7,16 +8,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
/** Pages the day pager spans — a little under four centuries. */
internal const val DAY_PAGE_COUNT: Int = 140_000
/** A fixed day every grid's pages are counted from, at its middle page. */
/** A fixed day every grid's pages are counted from. */
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. */
@@ -24,13 +16,19 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3)
/**
* 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.
* [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan].
* 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(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) {
data class DaysGrid(val dayCount: Int, val phase: Int) {
private val epochPage: Int get() = pageCount / 2
/** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1
private fun pagesFromEpoch(date: LocalDate): Int =
Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)
/** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate =
@@ -38,18 +36,16 @@ data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int =
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, pageCount - 1)
(epochPage + pagesFromEpoch(date)).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)
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0)
/** 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. */
@@ -10,6 +10,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.yearMonth
import org.junit.jupiter.api.Test
/**
@@ -34,22 +35,22 @@ class ContinuousMonthIndexTest {
@Test
fun `index round-trips back to its month`() {
listOf(
YearMonth(1900, Month.JANUARY),
YearMonth(1600, Month.JANUARY),
jun26,
YearMonth(2026, Month.DECEMBER),
YearMonth(2100, Month.DECEMBER),
YearMonth(2399, Month.DECEMBER),
).forEach { ym ->
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
}
}
@Test
fun `indices are non-negative and span 1900 through 2100`() {
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
fun `indices are non-negative and span 1600 through 2399`() {
assertThat(monthIndexOf(YearMonth(1600, Month.JANUARY))).isEqualTo(0)
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
assertThat(monthIndexOf(YearMonth(2399, Month.DECEMBER)))
.isEqualTo(continuousMonthCount() - 1)
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
assertThat(continuousMonthCount()).isEqualTo(800 * 12)
}
@Test
@@ -154,9 +155,9 @@ class ContinuousMonthIndexTest {
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws))
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(weekIndexOf(LocalDate(1600, 1, 1), ws)).isAtLeast(0)
assertThat(index).isLessThan(continuousWeekCount(ws))
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
assertThat(weekIndexOf(LocalDate(2399, 12, 31), ws))
.isLessThan(continuousWeekCount(ws))
}
}
@@ -236,4 +237,23 @@ class ContinuousMonthIndexTest {
assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
@Test
fun `a date before 1600 is held at the first day of the grid`() {
val held = coerceToMonthRange(LocalDate(1599, 12, 31))
assertThat(held).isEqualTo(LocalDate(1600, 1, 1))
assertThat(monthIndexOf(held.yearMonth)).isEqualTo(0)
}
@Test
fun `a date after 2399 is held at the last day of the grid`() {
val held = coerceToMonthRange(LocalDate(2400, 1, 1))
assertThat(held).isEqualTo(LocalDate(2399, 12, 31))
assertThat(monthIndexOf(held.yearMonth)).isEqualTo(continuousMonthCount() - 1)
}
@Test
fun `a date inside the span is left alone`() {
assertThat(coerceToMonthRange(LocalDate(2026, 6, 10))).isEqualTo(LocalDate(2026, 6, 10))
}
}
@@ -0,0 +1,75 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.YearMonth
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The month view never leaves the span its pager can show (#457). */
@OptIn(ExperimentalCoroutinesApi::class)
class MonthViewModelRangeTest {
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension()
private val dispatcher = mainDispatcher.dispatcher
private fun viewModel(tempDir: Path): MonthViewModel {
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
FakeCalendarDataSource(), tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
)
val today = object : TodaySource {
override val today = flowOf(LocalDate(2026, 11, 2))
}
return MonthViewModel(repo, settings, today, dispatcher)
}
@Test
fun `a focus before 1600 opens the first month`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(1599, 12, 31))
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1))
}
@Test
fun `a focus after 2399 opens the last month`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(2400, 1, 1))
assertThat(vm.month.value).isEqualTo(YearMonth(2399, Month.DECEMBER))
}
@Test
fun `a tap on a day past the span is ignored`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(1600, 1, 1))
vm.selectDate(LocalDate(1599, 12, 31))
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1))
}
@Test
fun `the split pane stops at the first month`(@TempDir tempDir: Path) = runTest(dispatcher) {
val vm = viewModel(tempDir)
vm.goToDate(LocalDate(1600, 1, 15))
vm.clearSelection()
vm.stepSplitPane(-1)
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
}
}
@@ -10,8 +10,9 @@ 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.
* Pins each grid's page numbering: a pager restores its page by number, so a
* change here moves the user to another date. Page 0 holds the first day of
* the shared span, and the last page holds its last day (#457).
*/
class PageNumberingTest {
@@ -26,6 +27,7 @@ class PageNumberingTest {
add(e)
e = e.plus(1, DateTimeUnit.DAY)
}
addAll(listOf(FIRST, FIRST.minus(1, DateTimeUnit.DAY), LAST, LAST.plus(1, DateTimeUnit.DAY)))
}
private fun pages(count: Int): List<Int> =
@@ -36,13 +38,14 @@ class PageNumberingTest {
fun `week pages keep their numbering for every first day`() {
DayOfWeek.entries.forEach { firstDay ->
val grid = DaysGrid.weeks(firstDay)
val phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.weekStart(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))
assertThat(grid.pageCount).isEqualTo(expectedCount(7, phase))
pages(grid.pageCount).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, 7, phase))
}
dates.forEach { date ->
assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay))
assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, 7, phase))
}
}
}
@@ -52,12 +55,12 @@ class PageNumberingTest {
(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))
assertThat(grid.pageCount).isEqualTo(expectedCount(count, phase))
pages(grid.pageCount).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, count, phase))
}
dates.forEach { date ->
assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase))
assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, count, phase))
}
}
dates.filter { it.year in 1950..2050 }.forEach { date ->
@@ -71,12 +74,21 @@ class PageNumberingTest {
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))
assertThat(grid.pageCount).isEqualTo(FIRST.daysUntil(LAST) + 1)
pages(grid.pageCount).forEach { page ->
assertThat(grid.startForPage(page)).isEqualTo(FIRST.plus(page, DateTimeUnit.DAY))
}
dates.forEach { date ->
assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date))
assertThat(grid.pageFor(date)).isEqualTo(FIRST.daysUntil(date).coerceIn(0, grid.pageCount - 1))
}
}
@Test
fun `every grid reaches both ends of the span`() {
(DayOfWeek.entries.map { DaysGrid.weeks(it) } + DaysGrid.days + (1..7).map { DaysGrid(it, 0) }).forEach { grid ->
assertThat(grid.startForPage(grid.pageFor(FIRST))).isAtMost(FIRST)
val lastStart = grid.startForPage(grid.pageFor(LAST))
assertThat(lastStart.plus(grid.dayCount - 1, DateTimeUnit.DAY)).isAtLeast(LAST)
}
}
@@ -104,34 +116,28 @@ class PageNumberingTest {
}
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 FIRST = LocalDate(1600, 1, 1)
val LAST = LocalDate(2399, 12, 31)
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)
/** Page 0 starts on the page boundary at or before [FIRST]. */
fun firstStart(count: Int, phase: Int): LocalDate {
val origin = DAYS_EPOCH.plus(phase, DateTimeUnit.DAY)
return origin.plus(Math.floorDiv(origin.daysUntil(FIRST), count) * count, DateTimeUnit.DAY)
}
fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate =
DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY)
fun expectedStart(page: Int, count: Int, phase: Int): LocalDate =
firstStart(count, phase).plus(page * 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 expectedCount(count: Int, phase: Int): Int =
Math.floorDiv(firstStart(count, phase).daysUntil(LAST), 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)
fun expectedPage(date: LocalDate, count: Int, phase: Int): Int =
Math.floorDiv(firstStart(count, phase).daysUntil(date), count)
.coerceIn(0, expectedCount(count, phase) - 1)
}
}