feat(tasks): умные списки этап 1 — движок семантики + 22 юнита

Чистый TasksSmartLists (6 виртуальных списков по веб-Tasks NC: Важное/Сегодня/
На неделе/Все/Текущие/Завершенные) + SmartListVisibility. TaskItem.startRaw +
startLocalDate() (пусто до DTSTART из core — запрошено у лида). testImplementation
junit + TasksSmartListsTest (22 зелёных). Detekt-долг чужого пакета v0.5.133-137
унесён в baseline, код лида не тронут.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
b-mob-tasks
2026-07-12 22:20:29 +00:00
parent 34a67cc89c
commit cec5f2c4b9
7 changed files with 324 additions and 4 deletions
@@ -24,8 +24,10 @@ object TasksGrouping {
val sorted = if (sortAscending) {
tasks.sortedWith(compareBy({ it.dueLocalDate() ?: LocalDate.MAX }, { it.summary.lowercase() }))
} else {
tasks.sortedWith(compareByDescending<TaskItem> { it.dueLocalDate() ?: LocalDate.MIN }
.thenBy { it.summary.lowercase() })
tasks.sortedWith(
compareByDescending<TaskItem> { it.dueLocalDate() ?: LocalDate.MIN }
.thenBy { it.summary.lowercase() }
)
}
val grouped = linkedMapOf<String, MutableList<TaskItem>>()
val colors = linkedMapOf<String, Color>()
@@ -65,6 +67,13 @@ fun TaskItem.dueLocalDate(): LocalDate? {
}.getOrNull()
}
fun TaskItem.startLocalDate(): LocalDate? {
if (startRaw.isBlank()) return null
return runCatching {
LocalDate.parse(startRaw.take(8), DateTimeFormatter.BASIC_ISO_DATE)
}.getOrNull()
}
fun TaskItem.isOverdue(): Boolean {
val date = dueLocalDate() ?: return false
return !isCompleted && date.isBefore(LocalDate.now())
@@ -30,6 +30,12 @@ data class TaskItem(
val listHref: String,
val listName: String,
val parentUid: String = "",
/**
* Сырой DTSTART в формате YYYYMMDD[THHMMSS[Z]] (как dueRaw). Нужен умным спискам
* «Текущие»/«Сегодня»/«На неделе» (веб-Tasks учитывает и start, и due). Пусто, пока
* core (`DavTask`) не отдаёт DTSTART — см. письмо лиду об этой правке core.
*/
val startRaw: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -0,0 +1,94 @@
package ru.forbion.f7cloud.feature.tasks
import java.time.LocalDate
/**
* Умные (виртуальные) списки Задач — агрегируют задачи ПО ВСЕМ реальным спискам.
* Семантика строго по веб-Tasks Nextcloud (согласовано с лидом, mail/070 п.2):
*
* - **Важное** (starred): незавершённые с высоким приоритетом (VTODO PRIORITY 14).
* - **Сегодня**: незавершённые, у которых start ИЛИ due не в будущем (today или раньше,
* т.е. включая просроченные). Задачи вовсе без дат сюда НЕ попадают.
* - **На неделе**: то же, но горизонт — конец текущей недели (today .. today+6).
* - **Все**: все незавершённые (включая будущий start).
* - **Текущие** (current): незавершённые, у которых start НЕ в будущем (уже стартовали
* или без start). Отличие от «Все» — исключает задачи с будущим start.
* - **Завершенные**: завершённые.
*
* Чистая логика без Android/сети — покрыта юнит-тестами (`TasksSmartListsTest`).
*
* NB: критерии «Сегодня»/«На неделе» учитывают start; в бою start появится, когда core
* (`DavTask`) начнёт отдавать DTSTART (см. письмо лиду) — до этого start пуст и в расчёте
* участвует только due. Границу окна «неделя» и участие start сверить на веб-версии glb.
*/
enum class SmartListKey(val title: String, val icon: String) {
IMPORTANT("Важное", "important-gray.svg"),
TODAY("Сегодня", "calendar-today-gray.svg"),
WEEK("На неделе", "calendar-nextweek-gray.svg"),
ALL("Все", "calendar-icon-gray.svg"),
CURRENT("Текущие", "current-gray.svg"),
COMPLETED("Завершенные", "check-done-gray.svg"),
}
/** Какие умные списки показывать в панели (шит «Параметры задач»). По умолчанию — все. */
data class SmartListVisibility(
private val hidden: Set<SmartListKey> = emptySet(),
) {
fun isVisible(key: SmartListKey): Boolean = key !in hidden
fun withVisible(key: SmartListKey, visible: Boolean): SmartListVisibility =
SmartListVisibility(if (visible) hidden - key else hidden + key)
/** Ключи скрытых списков — для сериализации в SharedPreferences. */
fun hiddenKeys(): Set<SmartListKey> = hidden
companion object {
val ALL_VISIBLE = SmartListVisibility()
fun of(hidden: Collection<SmartListKey>): SmartListVisibility =
SmartListVisibility(hidden.toSet())
}
}
object TasksSmartLists {
/** Высокий приоритет VTODO (1 — наивысший, 4 — ещё «важный»); 0 = не задан. */
private val HIGH_PRIORITY = 1..4
/** Горизонт списка «На неделе»: сегодня плюс 6 дней (окно текущей недели). */
private const val WEEK_HORIZON_DAYS = 6L
/** Попадает ли [task] в умный список [key] при опорной дате [today]. */
fun matches(key: SmartListKey, task: TaskItem, today: LocalDate): Boolean = when (key) {
SmartListKey.IMPORTANT -> !task.isCompleted && task.priority in HIGH_PRIORITY
SmartListKey.TODAY -> !task.isCompleted && task.dueOrStartOnOrBefore(today)
SmartListKey.WEEK -> !task.isCompleted && task.dueOrStartOnOrBefore(today.plusDays(WEEK_HORIZON_DAYS))
SmartListKey.ALL -> !task.isCompleted
SmartListKey.CURRENT -> !task.isCompleted && task.hasStarted(today)
SmartListKey.COMPLETED -> task.isCompleted
}
/** Задачи умного списка [key], сохраняя исходный порядок [tasks]. */
fun filter(key: SmartListKey, tasks: List<TaskItem>, today: LocalDate): List<TaskItem> =
tasks.filter { matches(key, it, today) }
/** Число задач в умном списке (для бейджа в панели). */
fun count(key: SmartListKey, tasks: List<TaskItem>, today: LocalDate): Int =
tasks.count { matches(key, it, today) }
/** Уже стартовала: нет start, либо start сегодня/в прошлом. */
private fun TaskItem.hasStarted(today: LocalDate): Boolean {
val start = startLocalDate() ?: return true
return !start.isAfter(today)
}
/**
* Есть start ИЛИ due, и хотя бы один из них не позже [limit].
* Задача без обеих дат не попадает в «Сегодня»/«На неделе».
*/
private fun TaskItem.dueOrStartOnOrBefore(limit: LocalDate): Boolean {
val start = startLocalDate()
val due = dueLocalDate()
if (start == null && due == null) return false
return (start != null && !start.isAfter(limit)) || (due != null && !due.isAfter(limit))
}
}