diff --git a/shared/src/appleMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt b/shared/src/appleMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt index 7961f08..7789bbd 100644 --- a/shared/src/appleMain/kotlin/com/deadliner/bridge/IosOverviewStateBridge.kt +++ b/shared/src/appleMain/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/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..8a986fc 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 = completedInRange.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/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt b/shared/src/macosArm64Test/kotlin/com/deadliner/viewmodel/overview/OverviewViewModelTest.kt index 15534f3..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" @@ -103,6 +126,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"