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:
@@ -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 1–4).
|
||||
* - **Сегодня**: незавершённые, у которых 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package ru.forbion.f7cloud.feature.tasks
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Семантика умных списков Задач по веб-Tasks Nextcloud (mail/070 п.2).
|
||||
* Опорная дата фиксирована — тесты детерминированы, от сети/Android не зависят.
|
||||
*/
|
||||
class TasksSmartListsTest {
|
||||
|
||||
private val today: LocalDate = LocalDate.of(2026, 7, 12)
|
||||
|
||||
private fun stamp(date: LocalDate?): String =
|
||||
date?.format(DateTimeFormatter.BASIC_ISO_DATE).orEmpty()
|
||||
|
||||
private fun task(
|
||||
uid: String = "u",
|
||||
summary: String = "t",
|
||||
priority: Int = 0,
|
||||
status: String = "NEEDS-ACTION",
|
||||
percent: Int = 0,
|
||||
due: LocalDate? = null,
|
||||
start: LocalDate? = null,
|
||||
) = TaskItem(
|
||||
uid = uid,
|
||||
href = "/$uid.ics",
|
||||
etag = "",
|
||||
summary = summary,
|
||||
status = status,
|
||||
due = stamp(due),
|
||||
dueRaw = stamp(due),
|
||||
priority = priority,
|
||||
percentComplete = percent,
|
||||
description = "",
|
||||
listHref = "/list/",
|
||||
listName = "Список",
|
||||
startRaw = stamp(start),
|
||||
)
|
||||
|
||||
private fun matches(key: SmartListKey, task: TaskItem) =
|
||||
TasksSmartLists.matches(key, task, today)
|
||||
|
||||
// --- Важное (priority 1..4, незавершённые) ---
|
||||
|
||||
@Test
|
||||
fun important_highPriorityIncomplete_matches() {
|
||||
assertTrue(matches(SmartListKey.IMPORTANT, task(priority = 1)))
|
||||
assertTrue(matches(SmartListKey.IMPORTANT, task(priority = 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun important_mediumLowOrNone_excluded() {
|
||||
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 0)))
|
||||
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 5)))
|
||||
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 9)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun important_completedHighPriority_excluded() {
|
||||
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 1, status = "COMPLETED")))
|
||||
}
|
||||
|
||||
// --- Сегодня (start|due <= today, есть дата, незавершённые) ---
|
||||
|
||||
@Test
|
||||
fun today_dueTodayOrOverdue_matches() {
|
||||
assertTrue(matches(SmartListKey.TODAY, task(due = today)))
|
||||
assertTrue(matches(SmartListKey.TODAY, task(due = today.minusDays(1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun today_dueFuture_excluded() {
|
||||
assertFalse(matches(SmartListKey.TODAY, task(due = today.plusDays(1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun today_startTodayNoDue_matches() {
|
||||
assertTrue(matches(SmartListKey.TODAY, task(start = today)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun today_noDates_excluded() {
|
||||
assertFalse(matches(SmartListKey.TODAY, task()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun today_completedDueToday_excluded() {
|
||||
assertFalse(matches(SmartListKey.TODAY, task(due = today, status = "COMPLETED")))
|
||||
}
|
||||
|
||||
// --- На неделе (start|due <= today+6) ---
|
||||
|
||||
@Test
|
||||
fun week_withinHorizon_matches() {
|
||||
assertTrue(matches(SmartListKey.WEEK, task(due = today.plusDays(6))))
|
||||
assertTrue(matches(SmartListKey.WEEK, task(due = today.minusDays(3))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun week_beyondHorizon_excluded() {
|
||||
assertFalse(matches(SmartListKey.WEEK, task(due = today.plusDays(7))))
|
||||
}
|
||||
|
||||
// --- Все (все незавершённые, включая будущий start/due) ---
|
||||
|
||||
@Test
|
||||
fun all_incompleteRegardlessOfDates_matches() {
|
||||
assertTrue(matches(SmartListKey.ALL, task()))
|
||||
assertTrue(matches(SmartListKey.ALL, task(start = today.plusDays(30))))
|
||||
assertTrue(matches(SmartListKey.ALL, task(due = today.plusDays(30))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun all_completed_excluded() {
|
||||
assertFalse(matches(SmartListKey.ALL, task(status = "COMPLETED")))
|
||||
}
|
||||
|
||||
// --- Текущие (start не в будущем или без start; незавершённые) ---
|
||||
|
||||
@Test
|
||||
fun current_noStart_matches() {
|
||||
assertTrue(matches(SmartListKey.CURRENT, task()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun current_startPastOrToday_matches() {
|
||||
assertTrue(matches(SmartListKey.CURRENT, task(start = today)))
|
||||
assertTrue(matches(SmartListKey.CURRENT, task(start = today.minusDays(5))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun current_futureStart_excluded() {
|
||||
assertFalse(matches(SmartListKey.CURRENT, task(start = today.plusDays(1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun current_vs_all_futureStartTask() {
|
||||
// Ключевое отличие: задача с будущим start есть в «Все», но нет в «Текущие».
|
||||
val future = task(start = today.plusDays(3))
|
||||
assertTrue(matches(SmartListKey.ALL, future))
|
||||
assertFalse(matches(SmartListKey.CURRENT, future))
|
||||
}
|
||||
|
||||
// --- Завершенные ---
|
||||
|
||||
@Test
|
||||
fun completed_byStatus_matches() {
|
||||
assertTrue(matches(SmartListKey.COMPLETED, task(status = "COMPLETED")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completed_byPercent100_matches() {
|
||||
assertTrue(matches(SmartListKey.COMPLETED, task(percent = 100)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completed_incomplete_excluded() {
|
||||
assertFalse(matches(SmartListKey.COMPLETED, task()))
|
||||
}
|
||||
|
||||
// --- filter/count по набору ---
|
||||
|
||||
@Test
|
||||
fun filterAndCount_overMixedSet() {
|
||||
// a: Важное+Сегодня+Неделя+Все+Текущие. b: Все+Текущие (будущий due, старт не в будущем).
|
||||
// c: только Все (будущий start исключает из Текущие). d: Завершенные.
|
||||
val tasks = listOf(
|
||||
task(uid = "a", priority = 2, due = today),
|
||||
task(uid = "b", due = today.plusDays(10)),
|
||||
task(uid = "c", start = today.plusDays(2)),
|
||||
task(uid = "d", status = "COMPLETED", priority = 1),
|
||||
)
|
||||
assertEquals(listOf("a"), TasksSmartLists.filter(SmartListKey.IMPORTANT, tasks, today).map { it.uid })
|
||||
assertEquals(listOf("a"), TasksSmartLists.filter(SmartListKey.TODAY, tasks, today).map { it.uid })
|
||||
assertEquals(3, TasksSmartLists.count(SmartListKey.ALL, tasks, today))
|
||||
assertEquals(listOf("a", "b"), TasksSmartLists.filter(SmartListKey.CURRENT, tasks, today).map { it.uid })
|
||||
assertEquals(1, TasksSmartLists.count(SmartListKey.COMPLETED, tasks, today))
|
||||
}
|
||||
|
||||
// --- Видимость ---
|
||||
|
||||
@Test
|
||||
fun visibility_defaultAllVisible() {
|
||||
val v = SmartListVisibility.ALL_VISIBLE
|
||||
SmartListKey.entries.forEach { assertTrue(v.isVisible(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun visibility_hideAndRestore() {
|
||||
val hidden = SmartListVisibility.ALL_VISIBLE.withVisible(SmartListKey.COMPLETED, false)
|
||||
assertFalse(hidden.isVisible(SmartListKey.COMPLETED))
|
||||
assertTrue(hidden.isVisible(SmartListKey.ALL))
|
||||
assertEquals(setOf(SmartListKey.COMPLETED), hidden.hiddenKeys())
|
||||
|
||||
val restored = hidden.withVisible(SmartListKey.COMPLETED, true)
|
||||
assertTrue(restored.isVisible(SmartListKey.COMPLETED))
|
||||
assertTrue(restored.hiddenKeys().isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user