From ecc3ece66b9063e2409d340da38aac50aa40aa7b Mon Sep 17 00:00:00 2001 From: Yuzhe Yan Date: Mon, 2 Nov 2026 15:16:28 +0100 Subject: [PATCH 1/2] feat(overview): load statistics for any local date range Platforms can offer custom ranges (for example Oct 12 to Dec 31) without reconstructing totals in UI. OverviewViewModel.loadRangeNow(start, endExclusive) returns OverviewRangeUiState: daily stats for every day, status distribution, completion periods, metrics compared with the preceding period of the same length, and completed task titles. It uses the same rules as the monthly projection (completions by completion date, other states by due date). The Apple bridge exposes loadRange with an error callback; ranges are limited to 1..3660 days. --- .../viewmodel/overview/OverviewModels.kt | 19 +++++ .../viewmodel/overview/OverviewViewModel.kt | 61 ++++++++++++++++ .../bridge/IosOverviewStateBridge.kt | 12 ++++ .../overview/OverviewViewModelTest.kt | 72 +++++++++++++++++++ 4 files changed, 164 insertions(+) diff --git a/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewModels.kt b/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewModels.kt index e359a41..7cbf813 100644 --- a/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewModels.kt +++ b/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewModels.kt @@ -161,3 +161,22 @@ data class OverviewMonthUiState( ) { val archivedCount: Int get() = archivedTaskCount + archivedHabitCount } + +/** + * A custom local date range `[startDate, endDateExclusive)`. `daily` covers every day of the range + * (days after `asOfDate` have no completions); `metrics` compare with the preceding period of the + * same length. + */ +@Serializable +data class OverviewRangeUiState( + val startDate: String, + val endDateExclusive: String, + val asOfDate: String, + val timeZoneId: String, + val completedCount: Int, + val daily: List, + val statusDistribution: OverviewHistory, + val completionBuckets: List, + val metrics: OverviewMonthlyMetrics, + val completedTaskTitles: List, +) diff --git a/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt b/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt index aa15416..8cba4da 100644 --- a/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt +++ b/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.DatePeriod +import kotlinx.datetime.daysUntil import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -83,6 +84,63 @@ class OverviewViewModel( habitRepository.list().filterNot(Habit::isDeleted), nowProvider(), timeZoneProvider(), ) + /** + * Shared projection for any local date range `[startDate, endDateExclusive)`, so platforms can + * offer custom ranges without reconstructing totals in UI. Completed activity follows the actual + * completion date; due-date states and metrics follow the due date, like the monthly projection. + * Metrics compare with the preceding period of the same length. + */ + fun loadRangeNow(startDate: String, endDateExclusive: String): OverviewRangeUiState = buildRangeState( + LocalDate.parse(startDate), LocalDate.parse(endDateExclusive), taskRepository.list().filterNot(Task::isDeleted), + nowProvider(), timeZoneProvider(), + ) + + internal fun buildRangeState( + start: LocalDate, end: LocalDate, tasks: List, now: Instant, timeZone: TimeZone, + ): OverviewRangeUiState { + val days = start.daysUntil(end) + require(days in 1..MAX_RANGE_DAYS) { "Range must contain 1..$MAX_RANGE_DAYS days" } + val today = now.toLocalDateTime(timeZone).date + val previousStart = start.minus(DatePeriod(days = days)) + val visibleTasks = tasks.filterNot(Task::isDeleted).filter { task -> + !isCompleted(task) || task.completedAt.instantOrNull()?.let { it <= now } != false + } + val times = taskTimes(visibleTasks, timeZone) + fun dueIn(task: Task, from: LocalDate, until: LocalDate) = + times.getValue(task.uid).dueDate?.let { it >= from && it < until } == true + val dueInRange = visibleTasks.filter { dueIn(it, start, end) } + val dueInPrevious = visibleTasks.filter { dueIn(it, previousStart, start) } + val daily = buildDaily(visibleTasks, start, days, times, now) + val completedInRange = visibleTasks.filter(::isCompleted).filter { task -> + times.getValue(task.uid).completedDate?.let { it >= start && it < end } == true + } + val buckets = completedInRange.groupingBy { times.getValue(it.uid).completionPeriod }.eachCount() + val unresolved = dueInRange.filterNot { isCompleted(it) || isAbandoned(it) } + return OverviewRangeUiState( + startDate = start.toString(), endDateExclusive = end.toString(), + asOfDate = today.toString(), timeZoneId = timeZone.id, + completedCount = daily.sumOf { it.completedCount }, + daily = daily, + statusDistribution = OverviewHistory( + completed = daily.sumOf { it.completedCount }, + abandoned = dueInRange.count(::isAbandoned), + overdue = unresolved.count { times.getValue(it.uid).dueAt?.let { due -> due < now } == true }, + pending = unresolved.count { times.getValue(it.uid).dueAt?.let { due -> due >= now } == true }, + ), + completionBuckets = overviewCompletionPeriods().map { OverviewCompletionBucket(it, buckets[it] ?: 0) }, + metrics = buildMetrics(dueInRange, dueInPrevious, times), + completedTaskTitles = dueInRange.filter(::isCompleted).map(Task::title), + ) + } + + private fun taskTimes(tasks: List, timeZone: TimeZone): Map = tasks.associate { task -> + val due = task.dueAt.instantOrNull() + val completed = task.completedAt.instantOrNull() + task.uid to OverviewTaskTimes(due, due?.toLocalDateTime(timeZone)?.date, + completed, completed?.toLocalDateTime(timeZone)?.date, + completed?.toOverviewCompletionPeriod(timeZone), task.startAt.instantOrNull()) + } + internal fun buildMonthState( start: LocalDate, tasks: List, habits: List, now: Instant, timeZone: TimeZone, ): OverviewMonthUiState { @@ -431,3 +489,6 @@ private fun Instant.toOverviewCompletionPeriod(timeZone: TimeZone): OverviewComp private fun overviewLog(message: String) { PlatformLogger().d("overview", message) } + +/** Upper bound for custom overview ranges (about ten years). */ +private const val MAX_RANGE_DAYS = 3660 diff --git a/shared/src/iosMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt b/shared/src/iosMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt index 7961f08..7789bbd 100644 --- a/shared/src/iosMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt +++ b/shared/src/iosMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt @@ -2,6 +2,7 @@ package com.deadliner.bridge import com.deadliner.db.DeadlinerDatabase import com.deadliner.viewmodel.overview.OverviewMonthUiState +import com.deadliner.viewmodel.overview.OverviewRangeUiState import com.deadliner.domain.focus.FocusSession import com.deadliner.domain.focus.FocusTimerOutcome import com.deadliner.domain.statistics.FocusStatisticsQuery @@ -85,6 +86,17 @@ class IosOverviewStateBridge( } } + /** Custom local date range `[startDate, endDateExclusive)`; invalid ranges report through `onError`. */ + fun loadRange(startDate: String, endDateExclusive: String, onState: (OverviewRangeUiState) -> Unit, onError: (String) -> Unit) { + workerScope.launch { + try { + val state = viewModel.loadRangeNow(startDate, endDateExclusive) + callbackScope.launch { onState(state) } + } catch (error: CancellationException) { throw error } + catch (error: Throwable) { callbackScope.launch { onError(error.message ?: "时间范围数据加载失败") } } + } + } + fun loadFocusSafely(onState: (FocusOverviewStatistics) -> Unit, onError: (String) -> Unit) { workerScope.launch { try { diff --git a/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt b/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt index 15534f3..36dc1ce 100644 --- a/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt +++ b/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt @@ -103,6 +103,78 @@ class OverviewViewModelTest { } finally { database.close(); remove(path) } } + @Test + fun wholeMonthRangeMatchesMonthlyProjection() { + val path = "${NSTemporaryDirectory()}overview-range-month-${NSUUID().UUIDString}.db" + val database = DeadlinerDatabase.create(MacosDatabaseDriverFactory(path, "range-month-test")) + try { + val now = Instant.parse("2026-10-12T12:00:00Z") + fun task(id: String, due: String, state: TaskState = TaskState.ACTIVE, completed: String? = null) = Task( + uid = id, title = id, state = state, dueAt = due, completedAt = completed, + createdAt = "2026-01-01T00:00:00Z", updatedAt = now.toString()) + val tasks = listOf( + task("pending", "2026-10-20T00:00:00Z"), + task("overdue", "2026-10-01T00:00:00Z"), + task("abandoned", "2026-10-01T00:00:00Z", TaskState.ABANDONED), + task("completed", "2026-09-01T00:00:00Z", TaskState.COMPLETED, "2026-10-01T00:00:00Z"), + task("archived", "2026-10-01T00:00:00Z", TaskState.ARCHIVED, "2026-10-02T00:00:00Z"), + task("future-completion", "2026-10-20T00:00:00Z", TaskState.COMPLETED, "2026-10-20T00:00:00Z"), + ) + val vm = OverviewViewModel(database.tasks, database.habits) + val month = vm.buildMonthState(kotlinx.datetime.LocalDate(2026, 10, 1), tasks, emptyList(), now, TimeZone.UTC) + val range = vm.buildRangeState(kotlinx.datetime.LocalDate(2026, 10, 1), kotlinx.datetime.LocalDate(2026, 11, 1), + tasks, now, TimeZone.UTC) + assertEquals(month.statusDistribution, range.statusDistribution) + assertEquals(month.completedCount, range.completedCount) + assertEquals(month.daily, range.daily) + assertEquals(range.completedCount, range.completionBuckets.sumOf { it.completedCount }) + } finally { database.close(); remove(path) } + } + + @Test + fun customRangeSpansMonthsIntoTheFutureAndComparesWithPrecedingPeriod() { + val path = "${NSTemporaryDirectory()}overview-range-${NSUUID().UUIDString}.db" + val database = DeadlinerDatabase.create(MacosDatabaseDriverFactory(path, "range-test")) + try { + val now = Instant.parse("2026-11-02T12:00:00Z") + fun task(id: String, due: String, state: TaskState = TaskState.ACTIVE, completed: String? = null) = Task( + uid = id, title = id, state = state, dueAt = due, completedAt = completed, + createdAt = "2026-01-01T00:00:00Z", updatedAt = now.toString()) + val tasks = listOf( + task("before-range", "2026-10-11T00:00:00Z", TaskState.COMPLETED, "2026-10-11T08:00:00Z"), + task("in-range-done", "2026-10-15T00:00:00Z", TaskState.COMPLETED, "2026-10-14T20:00:00Z"), + task("in-range-overdue", "2026-10-20T00:00:00Z"), + task("future-due", "2026-12-31T09:00:00Z"), + task("previous-period", "2026-08-01T00:00:00Z", TaskState.COMPLETED, "2026-08-01T00:00:00Z"), + ) + // 10 月 12 日到 12 月 31 日(含),共 81 天。 + val state = OverviewViewModel(database.tasks, database.habits).buildRangeState( + kotlinx.datetime.LocalDate(2026, 10, 12), kotlinx.datetime.LocalDate(2027, 1, 1), tasks, now, TimeZone.UTC) + assertEquals(81, state.daily.size) + assertEquals(1, state.completedCount) + assertEquals(OverviewHistory(completed = 1, pending = 1, abandoned = 0, overdue = 1), state.statusDistribution) + assertEquals(1, state.completionBuckets.single { it.period == OverviewCompletionPeriod.EVENING }.completedCount) + assertEquals(3, state.metrics.totalTasks) + // 前一个等长时段是 7 月 23 日到 10 月 11 日:含 8 月 1 日和 10 月 11 日到期的两项。 + assertEquals(2, state.metrics.previousTotalTasks) + assertEquals(listOf("in-range-done"), state.completedTaskTitles) + assertEquals("2026-11-02", state.asOfDate) + } finally { database.close(); remove(path) } + } + + @Test + fun emptyOrReversedRangesAreRejected() { + val path = "${NSTemporaryDirectory()}overview-range-invalid-${NSUUID().UUIDString}.db" + val database = DeadlinerDatabase.create(MacosDatabaseDriverFactory(path, "range-invalid-test")) + try { + val vm = OverviewViewModel(database.tasks, database.habits) + val day = kotlinx.datetime.LocalDate(2026, 10, 12) + kotlin.test.assertFailsWith { + vm.buildRangeState(day, day, emptyList(), Instant.parse("2026-11-02T12:00:00Z"), TimeZone.UTC) + } + } finally { database.close(); remove(path) } + } + @Test fun aggregatesOverviewMetricsFromSharedTaskAndHabitModels() { val path = "${NSTemporaryDirectory()}deadliner-overview-${NSUUID().UUIDString}.db" From de759d0303fa49bea6ad819ee191def9556ba349 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Tue, 3 Nov 2026 09:41:58 +0800 Subject: [PATCH 2/2] fix(overview): select range task titles by completion date --- .../viewmodel/overview/OverviewViewModel.kt | 2 +- .../overview/OverviewViewModelTest.kt | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt b/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt index 8cba4da..8a986fc 100644 --- a/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt +++ b/shared/src/commonMain/kotlin/com/deadliner/viewmodel/overview/OverviewViewModel.kt @@ -129,7 +129,7 @@ class OverviewViewModel( ), completionBuckets = overviewCompletionPeriods().map { OverviewCompletionBucket(it, buckets[it] ?: 0) }, metrics = buildMetrics(dueInRange, dueInPrevious, times), - completedTaskTitles = dueInRange.filter(::isCompleted).map(Task::title), + completedTaskTitles = completedInRange.map(Task::title), ) } diff --git a/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt b/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt index 36dc1ce..be9f8f4 100644 --- a/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt +++ b/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt @@ -23,6 +23,29 @@ import platform.Foundation.NSTemporaryDirectory import platform.posix.remove class OverviewViewModelTest { + @Test + fun rangeTitlesFollowCompletionDatesIncludingTasksWithoutDueDates() { + val path = "${NSTemporaryDirectory()}overview-range-titles-${NSUUID().UUIDString}.db" + val database = DeadlinerDatabase.create(MacosDatabaseDriverFactory(path, "range-titles-test")) + try { + val now = Instant.parse("2026-11-03T12:00:00Z") + fun done(id: String, due: String?, completed: String) = Task( + uid = id, title = id, state = TaskState.COMPLETED, dueAt = due, completedAt = completed, + createdAt = "2026-01-01T00:00:00Z", updatedAt = now.toString()) + val tasks = listOf( + done("late-completion", "2026-09-20T00:00:00Z", "2026-10-01T00:00:00Z"), + done("no-due-date", null, "2026-10-16T00:00:00Z"), + done("completed-before-range", "2026-10-20T00:00:00Z", "2026-09-25T00:00:00Z"), + done("exclusive-end", "2026-10-20T00:00:00Z", "2026-11-01T00:00:00Z"), + ) + val range = OverviewViewModel(database.tasks, database.habits).buildRangeState( + kotlinx.datetime.LocalDate(2026, 10, 1), kotlinx.datetime.LocalDate(2026, 11, 1), tasks, now, TimeZone.UTC) + assertEquals(2, range.completedCount) + assertEquals(listOf("late-completion", "no-due-date"), range.completedTaskTitles) + assertEquals(range.completedCount, range.completionBuckets.sumOf { it.completedCount }) + } finally { database.close(); remove(path) } + } + @Test fun taskOnlyMutationInvalidatesOverviewEvenWhenFocusStatisticsAreUnchanged() = runBlocking { val path = "${NSTemporaryDirectory()}overview-invalidation-${NSUUID().UUIDString}.db"