Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OverviewDailyStat>,
val statusDistribution: OverviewHistory,
val completionBuckets: List<OverviewCompletionBucket>,
val metrics: OverviewMonthlyMetrics,
val completedTaskTitles: List<String>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Task>, 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<Task>, timeZone: TimeZone): Map<String, OverviewTaskTimes> = 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<Task>, habits: List<Habit>, now: Instant, timeZone: TimeZone,
): OverviewMonthUiState {
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<IllegalArgumentException> {
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"
Expand Down
Loading