From 6f4ae7e2ecf276ef389138d9dfe93a18c934a84c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 12 Nov 2026 16:27:51 +0100 Subject: [PATCH] Move dated sub-tasks with their routine, mark tick copies (#160) --- CHANGELOG.md | 7 +- .../tasks/room/RoomTasksDataSourceTest.kt | 38 ++++ .../data/tasks/AndroidTasksDataSource.kt | 140 +++++++++------ .../agendula/data/tasks/SqlChunks.kt | 23 +++ .../agendula/data/tasks/SubtaskReset.kt | 165 +++++++++++++++--- .../agendula/data/tasks/TickedCopies.kt | 34 +++- .../data/tasks/room/RoomTasksDataSource.kt | 56 ++++-- .../agendula/data/tasks/SqlChunksTest.kt | 24 +++ .../agendula/data/tasks/SubtaskResetTest.kt | 97 +++++++++- .../agendula/data/tasks/TickedCopiesTest.kt | 33 ++++ 10 files changed, 515 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunks.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunksTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopiesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd6dfc..117d6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,10 @@ All notable changes to this project are documented here. The format follows ### Fixed - Sub-tasks of a repeating task start over when ticking off its current occurrence moves it on to its next date, as in tasks.org: completed and - half-done ones open again. Before, the next occurrence showed them already - ticked. Unticking gives them back their state, and repeating sub-tasks keep - their own schedule (#160). + half-done ones open again, and dated ones move along with the task. Before, + the next occurrence showed them already ticked. Unticking gives them back + their state and dates, and repeating sub-tasks keep their own schedule + (#160). ## [1.3.1] - 2026-11-09 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt index 0a61e93..31c0e66 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt @@ -14,6 +14,7 @@ import org.junit.Test import org.junit.runner.RunWith import kotlin.time.Clock import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours import kotlin.time.Instant /** @@ -763,6 +764,43 @@ class RoomTasksDataSourceTest { assertThat(db.tasks().entity(touched)!!.percentComplete).isEqualTo(30) } + @Test + fun aDatedSubtaskMovesWithItsRoutineAndBack() { + val at = now + val id = source.insertTask(form(title = "Routine")) + makeRecurring(id, at) + val step = source.insertTask(form(title = "Step", due = at + 1.days).copy(parentId = id)) + val undated = source.insertTask(form(title = "Undated").copy(parentId = id)) + source.setCompleted(step, completed = true) + val due = db.tasks().entity(step)!!.due!! + + source.setCompletedInstance(id, at, completed = true) + + // A week of wall-clock time: an hour either way across a DST change. + assertThat(db.tasks().entity(step)!!.due!! - due).isIn(com.google.common.collect.Range.closed(7.days - 1.hours, 7.days + 1.hours)) + assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(db.tasks().entity(undated)!!.due).isNull() + + source.setCompletedInstance(id, at, completed = false) + + assertThat(db.tasks().entity(step)!!.due).isEqualTo(due) + assertThat(db.tasks().entity(step)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + + @Test + fun aMarkedCopyUnderARoutineIsLeftAlone() { + val at = now + val id = source.insertTask(form(title = "Routine")) + makeRecurring(id, at) + val copy = source.insertTask(form(title = "Daily part").copy(parentId = id)) + source.setCompleted(copy, completed = true) + db.tasks().update(db.tasks().entity(copy)!!.copy(unknownProperties = "X-AGENDULA-TICK-COPY:1")) + + source.setCompletedInstance(id, at, completed = true) + + assertThat(db.tasks().entity(copy)!!.status).isEqualTo(TaskStatus.COMPLETED) + } + @Test fun aRoutineTickedOnItsLastOccurrenceLeavesItsSubtasksDone() { val at = now diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index c654450..606778c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -31,6 +31,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import java.time.ZoneId +import java.time.ZoneOffset import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Instant @@ -196,12 +197,7 @@ class AndroidTasksDataSource @Inject constructor( filterArgs: List = emptyList(), sortOrder: String? = null, read: (ColumnReader) -> T?, - ): List = ids.distinct().chunked(MAX_SQL_ARGS - filterArgs.size).flatMap { chunk -> - val selection = buildString { - append("$idColumn IN (${chunk.joinToString(",") { "?" }})") - if (filter != null) append(" AND ($filter)") - } - val args = (chunk.map(Long::toString) + filterArgs).toTypedArray() + ): List = inSelections(idColumn, ids, filter, filterArgs).flatMap { (selection, args) -> resolver.query(uri, projection, selection, args, sortOrder)?.use { c -> val reader = CursorColumnReader(c) buildList { while (c.moveToNext()) read(reader)?.let(::add) } @@ -743,7 +739,8 @@ class AndroidTasksDataSource @Inject constructor( * into a task of its own, remembers that copy, so the occurrence can still be * read and reopened, and how the series moved, so reopening can move it back. * When the series moved on, its sub-tasks start over for the next occurrence, - * and the tick remembers how they were (#160). + * and the tick remembers how they were (#160). Without a copy there is no + * tick to remember them in, so they are left as they are. */ private fun rememberingCopy(taskId: Long, occurrenceStart: Instant, closing: Boolean, write: () -> Int): Int { if (!closing) return write() @@ -757,13 +754,18 @@ class AndroidTasksDataSource @Inject constructor( .onFailure { Log.w(TAG, "Reading the series after the tick failed", it) } .getOrNull()?.takeIf { it != before } } - val reset = if (after != null && SubtaskReset.movedOn(before, after, runCatching { seriesSpec(taskId) }.getOrNull())) { - runCatching { resetSubtasks(authority, taskId) }.onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) }.getOrNull() - } else { - null - } runCatching { - detachedSince(authority, lastId)?.let { copy -> + val copy = detachedSince(authority, lastId) + val resets = SubtaskReset.resetsOnTick(copy, before, after) + copy?.let { + markCopy(authority, copy) + val reset = if (resets && before != null && after != null) { + runCatching { resetSubtasks(authority, taskId, SubtaskReset.shiftOf(before, after)) } + .onFailure { Log.w(TAG, "Resetting the sub-tasks failed", it) } + .getOrNull() + } else { + null + } val tick = Tick( copy, taskId.takeIf { after != null }, before.takeIf { after != null }, after, System.currentTimeMillis(), reset = reset?.let(SubtaskReset::encode), @@ -775,51 +777,83 @@ class AndroidTasksDataSource @Inject constructor( } } - /** Resets what [SubtaskReset] picks under [parentId]. */ - private fun resetSubtasks(authority: String, parentId: Long): List { - val copies = ticks.copies(authority) - val reset = SubtaskReset.toReset(parentId) { id -> + /** Marks [copyId] as a tick's copy where DAVx5 keeps and uploads unknown properties, so other installs know it too. */ + private fun markCopy(authority: String, copyId: Long) { + val values = mapOf( + Properties.TASK_ID to copyId, + Properties.MIMETYPE to SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE, + Properties.DATA0 to SubtaskReset.COPY_MARKER_JSON, + ) + runCatching { resolver.insert(TasksContract.propertiesUri(authority), values.toContentValues()) } + .onFailure { Log.w(TAG, "Marking the ticked copy failed", it) } + } + + /** The rows carrying Agendula's copy marker. */ + private fun markedCopies(authority: String): Set = runCatching { + resolver.query( + TasksContract.propertiesUri(authority), + arrayOf(Properties.TASK_ID, Properties.DATA0), + "${Properties.MIMETYPE} = ? AND ${Properties.DATA0} LIKE ?", + arrayOf(SubtaskReset.UNKNOWN_PROPERTY_MIMETYPE, "%${SubtaskReset.COPY_MARKER}%"), + null, + )?.use { c -> + val r = CursorColumnReader(c) + buildSet { while (c.moveToNext()) if (SubtaskReset.isCopyMarker(r.getString(Properties.DATA0))) r.getLong(Properties.TASK_ID)?.let(::add) } + }.orEmpty() + }.onFailure { Log.w(TAG, "Reading the copy markers failed", it) }.getOrDefault(emptySet()) + + /** Resets what [SubtaskReset] picks under [parentId], dated ones moved by [shift]. */ + private fun resetSubtasks(authority: String, parentId: Long, shift: SeriesShift?): List { + val copies = ticks.copies(authority) + markedCopies(authority) + val reset = SubtaskReset.toReset(parentId, shift) { id -> childRows(authority, id, SUBTASK_STATE) { r -> val rowId = r.getLong(Tasks.ID) ?: return@childRows null - SubtaskReset.providerNode(rowId, r.getString(Tasks.RRULE), r.getString(Tasks.RDATE), copies, subtaskState(r)) + val allDay = r.getBoolean(Tasks.IS_ALLDAY) + SubtaskReset.providerNode( + rowId, r.getString(Tasks.RRULE), r.getString(Tasks.RDATE), copies, subtaskState(r), + SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE)), allDay, zoneOf(allDay, r.getString(Tasks.TZ)), + ) } } - writeSubtaskStates(authority, reset.map { it.id to SubtaskState.FRESH }) + val ops = reset.map { subtaskOp(authority, it.id, SubtaskState.FRESH, it.dates.takeIf { _ -> it.dates != it.priorDates }) } + if (ops.isNotEmpty()) resolver.applyBatch(authority, ArrayList(ops)) return reset } + private fun zoneOf(allDay: Boolean, tz: String?): ZoneId = + if (allDay) ZoneOffset.UTC else tz?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault() + private fun subtaskState(r: ColumnReader) = SubtaskState(statusFromInt(r.getInt(Tasks.STATUS)), r.getInt(Tasks.PERCENT_COMPLETE), r.getLong(Tasks.COMPLETED)) - private fun subtaskOps(authority: String, states: List>): List = - states.map { (id, state) -> - val values = mapOf( - Tasks.STATUS to state.status.toInt(), - Tasks.PERCENT_COMPLETE to state.percent, - Tasks.COMPLETED to state.completedAt, - ) - ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build() + /** [state] onto sub-task [id], and [dates] when they change. */ + private fun subtaskOp(authority: String, id: Long, state: SubtaskState, dates: SubtaskDates?): ContentProviderOperation { + val values = buildMap { + put(Tasks.STATUS, state.status.toInt()) + put(Tasks.PERCENT_COMPLETE, state.percent) + put(Tasks.COMPLETED, state.completedAt) + if (dates != null) { + put(Tasks.DTSTART, dates.start) + put(Tasks.DUE, dates.due) + } } - - private fun writeSubtaskStates(authority: String, states: List>) { - if (states.isNotEmpty()) resolver.applyBatch(authority, ArrayList(subtaskOps(authority, states))) + return ContentProviderOperation.newUpdate(taskUri(authority, id)).withValues(values.toContentValues()).build() } - /** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state. */ + /** The reset sub-tasks of [reset] that [SubtaskReset.toRestore] gives back their state and dates. */ private fun subtaskRestore(authority: String, reset: String?): List { val entries = SubtaskReset.decode(reset) if (entries.isEmpty()) return emptyList() - val current = resolver.query( + val current = queryByIds( TasksContract.tasksUri(authority), SUBTASK_STATE, - "${Tasks.ID} IN (${entries.joinToString { "?" }}) AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)", - entries.map { it.id.toString() }.toTypedArray(), - null, - )?.use { c -> - val r = CursorColumnReader(c) - buildMap { while (c.moveToNext()) r.getLong(Tasks.ID)?.let { put(it, subtaskState(r)) } } - }.orEmpty() - return subtaskOps(authority, SubtaskReset.toRestore(entries, current::get).map { it.id to it.prior }) + Tasks.ID, + entries.map { it.id }, + filter = "${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0", + ) { r -> r.getLong(Tasks.ID)?.let { it to (subtaskState(r) to SubtaskDates(r.getLong(Tasks.DTSTART), r.getLong(Tasks.DUE))) } }.toMap() + return SubtaskReset.toRestore(entries, current::get).map { + subtaskOp(authority, it.id, it.prior, it.priorDates.takeIf { _ -> it.priorDates != it.dates }) + } } /** Walks [parentId]'s direct sub-task rows, overrides left out, with [read] on each. */ @@ -854,8 +888,11 @@ class AndroidTasksDataSource @Inject constructor( ?: return false val series = tick.seriesId ?: return false val before = tick.before ?: return false - if (seriesState(authority, series) != tick.after || subtaskIds(tick.copyId).isNotEmpty()) return false - val restore = before.filter { (column, value) -> tick.after?.get(column) != value } + val after = tick.after ?: return false + // Compared on what the tick recorded: one from before IS_ALLDAY and TZ were read has neither. + val now = seriesState(authority, series) ?: return false + if (after.any { (column, value) -> now[column] != value } || subtaskIds(tick.copyId).isNotEmpty()) return false + val restore = before.filter { (column, value) -> after[column] != value } val ops = arrayListOf( ContentProviderOperation.newUpdate(taskUri(authority, series)).withValues(restore.toContentValues()).build(), ContentProviderOperation.newDelete(taskUri(authority, tick.copyId)).build(), @@ -867,9 +904,8 @@ class AndroidTasksDataSource @Inject constructor( }.onFailure { Log.w(TAG, "Moving the series back failed", it) }.getOrDefault(false) /** The columns a tick moves a series by, or null when [taskId] is no series. */ - private fun seriesState(authority: String, taskId: Long): Map? { - if (seriesRules(listOf(taskId)).isEmpty()) return null - return resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c -> + private fun seriesState(authority: String, taskId: Long): Map? = + resolver.query(taskUri(authority, taskId), SERIES_STATE, null, null, null)?.use { c -> if (!c.moveToFirst()) return@use null SERIES_STATE.indices.associate { i -> SERIES_STATE[i] to when (c.getType(i)) { @@ -878,8 +914,7 @@ class AndroidTasksDataSource @Inject constructor( else -> c.getString(i) } } - } - } + }?.takeIf { state -> listOf(Tasks.RRULE, Tasks.RDATE).any { !(state[it] as? String).isNullOrBlank() } } private fun lastTaskId(authority: String): Long = resolver.query(TasksContract.tasksUri(authority), arrayOf(Tasks.ID), null, null, "${Tasks.ID} DESC") @@ -1011,18 +1046,19 @@ private const val MIGRATIONS = "provider_migrations" private const val TICKED_COPIES = "provider_ticked_copies" -/** Every copy a tick left, kept past [TickedCopies.HORIZON_MS]: nothing on the row itself marks it. */ +/** Every copy a tick on this install left, kept past [TickedCopies.HORIZON_MS] alongside the marker on the row. */ private const val KNOWN_COPIES = "provider_known_copies" -/** What a tick changes on a series, and an undo changes back. */ -/** What resetting a sub-task reads: whether it recurs, and its progress. */ +/** What resetting a sub-task reads: whether it recurs, its progress and its dates. */ private val SUBTASK_STATE = arrayOf( Tasks.ID, Tasks.ORIGINAL_INSTANCE_ID, Tasks.RRULE, Tasks.RDATE, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, + Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ, ) +/** What a tick changes on a series, and an undo changes back; with what its rule is read in. */ private val SERIES_STATE = arrayOf( Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, - Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, + Tasks.STATUS, Tasks.COMPLETED, Tasks.PERCENT_COMPLETE, Tasks.IS_ALLDAY, Tasks.TZ, ) private const val TAG = "AndroidTasksDataSource" @@ -1030,5 +1066,3 @@ private const val TAG = "AndroidTasksDataSource" /** No rule to look up: the provider says the row does not recur. */ private const val NO_RULE = -1L -/** SQLite's default bound-argument limit on older Android releases. */ -private const val MAX_SQL_ARGS = 999 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunks.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunks.kt new file mode 100644 index 0000000..ba23815 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunks.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.agendula.data.tasks + +/** SQLite's default bound-argument limit on older Android releases. */ +internal const val MAX_SQL_ARGS = 999 + +/** + * `idColumn IN (…)` selections over [ids], each with its arguments and + * [filterArgs] after them, in chunks that keep every query within [limit] + * bound arguments. + */ +internal fun inSelections( + idColumn: String, + ids: Collection, + filter: String? = null, + filterArgs: List = emptyList(), + limit: Int = MAX_SQL_ARGS, +): List>> = ids.distinct().chunked(limit - filterArgs.size).map { chunk -> + val selection = buildString { + append("$idColumn IN (${chunk.joinToString(",") { "?" }})") + if (filter != null) append(" AND ($filter)") + } + selection to (chunk.map(Long::toString) + filterArgs).toTypedArray() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskReset.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskReset.kt index eb55d0e..49b5909 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskReset.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskReset.kt @@ -7,6 +7,10 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import de.jeanlucmakiola.agendula.domain.statusFromInt +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset import kotlin.time.Duration.Companion.days import kotlin.time.Instant @@ -20,33 +24,102 @@ internal data class SubtaskState(val status: TaskStatus, val percent: Int?, val } } -/** A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of a recurring one left. */ -internal class SubtaskNode(val id: Long, val recurring: Boolean, val state: SubtaskState) +/** A sub-task's own start and due, as epoch millis. */ +internal data class SubtaskDates(val start: Long?, val due: Long?) { + val isEmpty: Boolean get() = start == null && due == null -/** A sub-task [SubtaskReset] reset, with the state it had. */ -internal data class ResetSubtask(val id: Long, val prior: SubtaskState) + companion object { + val NONE = SubtaskDates(null, null) + } +} + +/** + * A sub-task as [SubtaskReset] sees it; [recurring] also covers a copy a tick of + * a recurring one left. [zone] is the one its dates are read in. + */ +internal class SubtaskNode( + val id: Long, + val recurring: Boolean, + val state: SubtaskState, + val dates: SubtaskDates = SubtaskDates.NONE, + val isAllDay: Boolean = false, + val zone: ZoneId = ZoneOffset.UTC, +) + +/** A sub-task [SubtaskReset] reset: its state and dates before, and the dates the reset gave it. */ +internal data class ResetSubtask( + val id: Long, + val prior: SubtaskState, + val priorDates: SubtaskDates = SubtaskDates.NONE, + val dates: SubtaskDates = priorDates, +) + +/** + * How far a series moved, as wall-clock time in [seriesZone], so a sub-task + * keeps its place relative to the routine across a DST change. + */ +internal class SeriesShift(from: Long, to: Long, seriesZone: ZoneId) { + private val delta = Duration.between(local(from, seriesZone), local(to, seriesZone)) + + /** [at] moved along; an all-day date by whole days. */ + fun apply(at: Long, isAllDay: Boolean, zone: ZoneId): Long { + val z = if (isAllDay) ZoneOffset.UTC else zone + val by = if (isAllDay) Duration.ofDays(delta.toDays()) else delta + return local(at, z).plus(by).atZone(z).toInstant().toEpochMilli() + } + + private fun local(at: Long, zone: ZoneId) = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(at), zone) +} /** * What a routine's sub-tasks do when its series moves on to the next occurrence - * (#160): the ones completed or in progress start over, as in tasks.org, and - * moving the series back gives them their state again. + * (#160): the ones completed or in progress start over, as in tasks.org, dated + * ones move along with the routine, and moving the series back gives them their + * state and dates again. */ internal object SubtaskReset { /** Only bounds the search for a next occurrence. */ private val LOOKAHEAD = (366 * 1000).days + /** The `X-` property Agendula puts on the copy a provider tick left, so any device knows it for one. */ + const val COPY_MARKER = "X-AGENDULA-TICK-COPY" + + /** The marker as DAVx5 stores an unknown property: a JSON array of name and value. */ + const val COPY_MARKER_JSON = "[\"$COPY_MARKER\",\"1\"]" + + /** The provider `properties` mimetype DAVx5 keeps unknown properties under, and round-trips. */ + const val UNKNOWN_PROPERTY_MIMETYPE = "vnd.android.cursor.item/vnd.ical4android.unknown-property" + + /** Whether an unknown-property row's JSON is the copy marker. */ + fun isCopyMarker(json: String?): Boolean = + json?.trim()?.startsWith("[\"$COPY_MARKER\"", ignoreCase = true) == true + + /** Whether a stored residue carries the copy marker, as a copy synced in from a provider install does. */ + fun hasCopyMarker(residue: String?): Boolean = residue?.lines()?.any { + val line = it.trim() + line.startsWith(COPY_MARKER, ignoreCase = true) && line.getOrNull(COPY_MARKER.length).let { c -> c == ':' || c == ';' } + } == true + /** - * The sub-tasks under [parentId], all the way down, to reset. A recurring one - * has a series of its own and is left alone with everything under it; a - * cancelled one stays cancelled. + * The sub-tasks under [parentId], all the way down, to reset: started ones, + * and with a [shift], dated ones too. A recurring one has a series of its + * own and is left alone with everything under it; a cancelled one stays as + * it is. */ - fun toReset(parentId: Long, children: (Long) -> List): List { + fun toReset(parentId: Long, shift: SeriesShift? = null, children: (Long) -> List): List { val seen = mutableSetOf(parentId) val reset = mutableListOf() fun walk(id: Long) { children(id).filter { !it.recurring && seen.add(it.id) }.forEach { - if (it.state.status != TaskStatus.CANCELLED && !it.state.isFresh) reset += ResetSubtask(it.id, it.state) + val moves = shift != null && !it.dates.isEmpty + if (it.state.status != TaskStatus.CANCELLED && (!it.state.isFresh || moves)) { + val dates = if (shift == null) it.dates else SubtaskDates( + it.dates.start?.let { at -> shift.apply(at, it.isAllDay, it.zone) }, + it.dates.due?.let { at -> shift.apply(at, it.isAllDay, it.zone) }, + ) + reset += ResetSubtask(it.id, it.state, it.dates, dates) + } walk(it.id) } } @@ -55,27 +128,67 @@ internal object SubtaskReset { } /** - * A provider row as a node. Nothing on a copy the provider detached marks it - * as one, so only the [copies] a tick in Agendula left are known: they belong - * to a recurring sub-task and count as recurring. + * A provider row as a node. Nothing the provider sets on a detached copy marks + * it as one, so a copy is known by Agendula's marker or by the [copies] ticks + * on this install left: it belongs to a recurring sub-task and counts as recurring. */ - fun providerNode(id: Long, rrule: String?, rdate: String?, copies: Set, state: SubtaskState) = - SubtaskNode(id, !rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies, state) + fun providerNode( + id: Long, + rrule: String?, + rdate: String?, + copies: Set, + state: SubtaskState, + dates: SubtaskDates = SubtaskDates.NONE, + isAllDay: Boolean = false, + zone: ZoneId = ZoneOffset.UTC, + ) = SubtaskNode(id, !rrule.isNullOrBlank() || !rdate.isNullOrBlank() || id in copies, state, dates, isAllDay, zone) /** The reset sub-tasks still as the reset left them, so moving back may restore them. */ - fun toRestore(reset: List, current: (Long) -> SubtaskState?): List = - reset.filter { current(it.id)?.isFresh == true } + fun toRestore(reset: List, current: (Long) -> Pair?): List = + reset.filter { entry -> current(entry.id)?.let { (state, dates) -> state.isFresh && dates == entry.dates } == true } fun encode(reset: List): String = reset.joinToString(";") { - listOf(it.id, it.prior.status.name, it.prior.percent ?: "", it.prior.completedAt ?: "").joinToString(",") + listOf( + it.id, it.prior.status.name, it.prior.percent, it.prior.completedAt, + it.priorDates.start, it.priorDates.due, it.dates.start, it.dates.due, + ).joinToString(",") { value -> value?.toString().orEmpty() } } fun decode(raw: String?): List = raw.orEmpty().split(';').mapNotNull { entry -> val parts = entry.split(',') - if (parts.size != 4) return@mapNotNull null + if (parts.size != 4 && parts.size != 8) return@mapNotNull null val id = parts[0].toLongOrNull() ?: return@mapNotNull null val status = TaskStatus.entries.firstOrNull { it.name == parts[1] } ?: return@mapNotNull null - ResetSubtask(id, SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull())) + val prior = SubtaskDates(parts.getOrNull(4)?.toLongOrNull(), parts.getOrNull(5)?.toLongOrNull()) + val dates = if (parts.size == 8) SubtaskDates(parts[6].toLongOrNull(), parts[7].toLongOrNull()) else prior + ResetSubtask(id, SubtaskState(status, parts[2].toIntOrNull(), parts[3].toLongOrNull()), prior, dates) + } + + /** A provider series' rule set from its columns, or null when it does not recur. */ + fun specOf(state: Map): RecurrenceSpec? { + val rrule = (state[Tasks.RRULE] as? String)?.ifBlank { null } + val rdate = (state[Tasks.RDATE] as? String)?.ifBlank { null } + if (rrule == null && rdate == null) return null + val anchor = anchor(state) ?: return null + return RecurrenceSpec( + rrule = rrule, + rdate = rdate, + exdate = state[Tasks.EXDATE] as? String, + anchor = Instant.fromEpochMilliseconds(anchor), + isAllDay = isAllDay(state), + timeZone = state[Tasks.TZ] as? String, + ) + } + + fun isAllDay(state: Map): Boolean = (state[Tasks.IS_ALLDAY] as? Number)?.toInt()?.let { it != 0 } == true + + /** How the series moved from [before] to [after], read in the zone its rule is expanded in. */ + fun shiftOf(before: Map, after: Map, floating: ZoneId = ZoneId.systemDefault()): SeriesShift? { + val from = anchor(before) ?: return null + val to = anchor(after) ?: return null + val zone = if (isAllDay(after)) ZoneOffset.UTC + else (after[Tasks.TZ] as? String)?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: floating + return SeriesShift(from, to, zone) } /** @@ -85,7 +198,7 @@ internal object SubtaskReset { * there. An `EXDATE` or a status alone is no move; a series whose rule ran * out is finished. */ - fun movedOn(before: Map?, after: Map?, next: RecurrenceSpec?): Boolean { + fun movedOn(before: Map?, after: Map?, next: RecurrenceSpec? = after?.let(::specOf)): Boolean { val was = anchor(before ?: return false) ?: return false val now = anchor(after ?: return false) ?: return false if (now <= was) return false @@ -104,6 +217,14 @@ internal object SubtaskReset { return RecurrenceExpander.expand(probe, ExpansionWindow(from = from, until = from + LOOKAHEAD, maxOccurrences = 1)).isNotEmpty() } + /** + * Whether a provider tick resets the sub-tasks: the series moved on, and the + * provider left a [copy] whose tick can record what the reset changed. With + * no copy there is nothing to undo through, so they are left as they are. + */ + fun resetsOnTick(copy: Long?, before: Map?, after: Map?): Boolean = + copy != null && movedOn(before, after) + private fun anchor(state: Map): Long? = (state[Tasks.DTSTART] as? Number)?.toLong() ?: (state[Tasks.DUE] as? Number)?.toLong() } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt index 7298904..9ed7c06 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopies.kt @@ -39,31 +39,38 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private /** Every copy a tick in Agendula left in [authority]. */ fun copies(authority: String): Set { - val kept = known().getStringSet(authority, emptySet()).orEmpty().mapNotNullTo(HashSet()) { it.toLongOrNull() } + val kept = knownIds(authority).mapNotNullTo(HashSet()) { it.toLongOrNull() } prefs().all.forEach { (key, value) -> if (key.startsWith("$authority|")) (value as? String)?.let(::decode)?.let { kept += it.copyId } } return kept } + /** Records [tick] under [key]; ticks past [HORIZON_MS] go, their copies stay known. */ fun put(key: String, tick: Tick) { - val stale = prefs().all.filter { (_, value) -> - val at = (value as? String)?.let(::decode)?.at ?: 0L - tick.at - at > HORIZON_MS - }.keys + val (stale, keep) = expire(prefs().all.mapValues { (_, value) -> (value as? String)?.let(::decode) }, tick.at) + val known = keep + (key.substringBefore('|') to (keep[key.substringBefore('|')].orEmpty() + tick.copyId)) + known().edit().apply { + known.forEach { (authority, ids) -> putStringSet(authority, knownIds(authority) + ids.map(Long::toString)) } + }.apply() prefs().edit().apply { stale.forEach(::remove) putString(key, encode(tick)) }.apply() - val authority = key.substringBefore('|') - val kept = known().getStringSet(authority, emptySet()).orEmpty() - known().edit().putStringSet(authority, kept + tick.copyId.toString()).apply() } + /** Drops the tick under [key], and its copy from the known ones: moving back deleted it, and its id may be reused. */ fun remove(key: String) { + val copy = get(key)?.copyId prefs().edit().remove(key).apply() + if (copy != null) { + val authority = key.substringBefore('|') + known().edit().putStringSet(authority, knownIds(authority) - copy.toString()).apply() + } } + private fun knownIds(authority: String): Set = known().getStringSet(authority, emptySet()).orEmpty().toSet() + private fun encode(tick: Tick): String = JSONObject().apply { put("copy", tick.copyId) put("at", tick.at) @@ -100,5 +107,16 @@ internal class TickedCopies(private val prefs: () -> SharedPreferences, private companion object { /** As long as the own store keeps a tick undoable. */ const val HORIZON_MS = 30L * 24 * 60 * 60 * 1000 + + /** + * The keys of [ticks] past the horizon at [now], unreadable ones included, + * and per authority the copies they leave, which stay known after the tick goes. + */ + fun expire(ticks: Map, now: Long): Pair, Map>> { + val stale = ticks.filter { (_, tick) -> now - (tick?.at ?: 0L) > HORIZON_MS } + val keep = stale.entries.mapNotNull { (key, tick) -> tick?.let { key.substringBefore('|') to it.copyId } } + .groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() } + return stale.keys to keep + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt index 4b540ff..22f6ca1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -2,6 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room import androidx.room.InvalidationTracker import de.jeanlucmakiola.agendula.data.tasks.ResetSubtask +import de.jeanlucmakiola.agendula.data.tasks.SeriesShift +import de.jeanlucmakiola.agendula.data.tasks.SubtaskDates import de.jeanlucmakiola.agendula.data.tasks.SubtaskNode import de.jeanlucmakiola.agendula.data.tasks.SubtaskReset import de.jeanlucmakiola.agendula.data.tasks.SubtaskState @@ -22,6 +24,7 @@ import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import java.time.ZoneId +import java.time.ZoneOffset import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -642,7 +645,7 @@ class RoomTasksDataSource @Inject constructor( isDirty = true, ), ) - val reset = resetSubtasks(master.id, now) + val (reset, resetLists) = resetSubtasks(master, newAnchor, now) val copyId = if (override != null) { detach(override, now, close) } else { @@ -672,33 +675,62 @@ class RoomTasksDataSource @Inject constructor( ), ) advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) }) - writes.onWritten(setOf(master.listId) + reset.mapNotNull { tasks.entity(it.id)?.listId }) + writes.onWritten(setOf(master.listId) + resetLists) return true } - /** Resets what [SubtaskReset] picks under [parentId] for the series' next occurrence. */ - private fun resetSubtasks(parentId: Long, now: Instant): List { - val reset = SubtaskReset.toReset(parentId) { id -> - tasks.subtasks(id).map { SubtaskNode(it.task.id, it.task.isRecurring, it.task.progress) } + /** + * Resets what [SubtaskReset] picks under [master] for the series' next + * occurrence at [newAnchor], dated ones moved along; the lists it wrote to. + */ + private fun resetSubtasks(master: TaskEntity, newAnchor: Instant, now: Instant): Pair, Set> { + val shift = master.recurrenceAnchor?.let { + SeriesShift(it.toEpochMilliseconds(), newAnchor.toEpochMilliseconds(), master.zoneId()) } - reset.forEach { tasks.entity(it.id)?.let { sub -> tasks.update(sub.withProgress(SubtaskState.FRESH, now)) } } - return reset + val loaded = HashMap() + val reset = SubtaskReset.toReset(master.id, shift) { id -> + tasks.subtasks(id).map { row -> + val sub = row.task.also { loaded[it.id] = it } + SubtaskNode( + sub.id, + sub.isRecurring || SubtaskReset.hasCopyMarker(sub.unknownProperties), + sub.progress, + sub.dates, + sub.isAllDay, + sub.zoneId(), + ) + } + } + val lists = reset.mapNotNull { entry -> + loaded[entry.id]?.also { tasks.update(it.withProgress(SubtaskState.FRESH, entry.dates, now)) }?.listId + } + return reset to lists.toSet() } /** What [resetSubtasks] reset for [record], back as it was where nothing changed it since. */ private fun restoreSubtasks(record: AdvancedOccurrenceEntity, now: Instant): Set { - val restore = SubtaskReset.toRestore(SubtaskReset.decode(record.resetSubtasks)) { tasks.entity(it)?.takeUnless { e -> e.isDeleted }?.progress } - return restore.mapNotNull { reset -> - tasks.entity(reset.id)?.also { tasks.update(it.withProgress(reset.prior, now)) }?.listId + val entries = SubtaskReset.decode(record.resetSubtasks) + val current = entries.mapNotNull { entry -> tasks.entity(entry.id)?.takeUnless { it.isDeleted } }.associateBy { it.id } + val restore = SubtaskReset.toRestore(entries) { id -> current[id]?.let { it.progress to it.dates } } + return restore.mapNotNull { entry -> + current[entry.id]?.also { tasks.update(it.withProgress(entry.prior, entry.priorDates, now)) }?.listId }.toSet() } private val TaskEntity.progress get() = SubtaskState(status, percentComplete, completedAt?.toEpochMilliseconds()) - private fun TaskEntity.withProgress(state: SubtaskState, now: Instant) = copy( + private val TaskEntity.dates get() = SubtaskDates(dtstart?.toEpochMilliseconds(), due?.toEpochMilliseconds()) + + /** The zone a row's dates are read in, as [RecurrenceExpander] reads a series. */ + private fun TaskEntity.zoneId(): ZoneId = + if (isAllDay) ZoneOffset.UTC else timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault() + + private fun TaskEntity.withProgress(state: SubtaskState, dates: SubtaskDates, now: Instant) = copy( status = state.status, percentComplete = state.percent, completedAt = state.completedAt?.let(Instant::fromEpochMilliseconds), + dtstart = dates.start?.let(Instant::fromEpochMilliseconds), + due = dates.due?.let(Instant::fromEpochMilliseconds), lastModified = now, isDirty = true, ) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunksTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunksTest.kt new file mode 100644 index 0000000..ed81642 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SqlChunksTest.kt @@ -0,0 +1,24 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class SqlChunksTest { + + @Test + fun `ids are split so no query binds more than the limit`() { + val ids = (1L..2_100L).toList() + val chunks = inSelections("_id", ids, filter = "deleted = ?", filterArgs = listOf("0")) + + assertThat(chunks.map { it.second.size }).containsExactly(999, 999, 105).inOrder() + assertThat(chunks.flatMap { (_, args) -> args.dropLast(1) }).isEqualTo(ids.map(Long::toString)) + assertThat(chunks.all { (_, args) -> args.last() == "0" }).isTrue() + assertThat(chunks.first().first).endsWith("?) AND (deleted = ?)") + } + + @Test + fun `repeated ids are bound once, and none give no query`() { + assertThat(inSelections("_id", listOf(3L, 3L, 4L)).single().second).asList().containsExactly("3", "4") + assertThat(inSelections("_id", emptyList())).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskResetTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskResetTest.kt index ed3b487..b6050e4 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskResetTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/SubtaskResetTest.kt @@ -5,6 +5,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import org.junit.jupiter.api.Test +import java.time.ZoneId +import java.time.ZoneOffset import kotlin.time.Instant class SubtaskResetTest { @@ -79,8 +81,13 @@ class SubtaskResetTest { @Test fun `what was reset survives encoding`() { - val reset = listOf(ResetSubtask(2, done), ResetSubtask(3, halfway), ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null))) + val reset = listOf( + ResetSubtask(2, done), + ResetSubtask(3, halfway, SubtaskDates(1L, 2L), SubtaskDates(11L, 12L)), + ResetSubtask(4, SubtaskState(TaskStatus.NEEDS_ACTION, 10, null), SubtaskDates(null, 5L), SubtaskDates(null, 15L)), + ) assertThat(SubtaskReset.decode(SubtaskReset.encode(reset))).isEqualTo(reset) + assertThat(SubtaskReset.decode("2,COMPLETED,100,1000")).containsExactly(ResetSubtask(2, done)) assertThat(SubtaskReset.decode("")).isEmpty() assertThat(SubtaskReset.decode(null)).isEmpty() assertThat(SubtaskReset.decode("x,COMPLETED,,;2,NOPE,,;3,IN_PROCESS")).isEmpty() @@ -88,9 +95,91 @@ class SubtaskResetTest { @Test fun `moving back restores only the sub-tasks still as the reset left them`() { - val reset = listOf(ResetSubtask(2, done), ResetSubtask(3, halfway), ResetSubtask(4, done)) - val current = mapOf(2L to SubtaskState.FRESH, 3L to done) - assertThat(SubtaskReset.toRestore(reset, current::get)).containsExactly(ResetSubtask(2, done)) + val moved = SubtaskDates(20L, null) + val reset = listOf( + ResetSubtask(2, done), + ResetSubtask(3, halfway), + ResetSubtask(4, done), + ResetSubtask(5, done, SubtaskDates(10L, null), moved), + ResetSubtask(6, done, SubtaskDates(10L, null), moved), + ) + val current = mapOf( + 2L to (SubtaskState.FRESH to SubtaskDates.NONE), + 3L to (done to SubtaskDates.NONE), + 5L to (SubtaskState.FRESH to moved), + // Re-dated since: the user's dates win. + 6L to (SubtaskState.FRESH to SubtaskDates(30L, null)), + ) + assertThat(SubtaskReset.toRestore(reset, current::get).map { it.id }).containsExactly(2L, 5L) + } + + private val berlin = ZoneId.of("Europe/Berlin") + + private fun at(text: String) = java.time.Instant.parse(text).toEpochMilli() + + @Test + fun `a dated sub-task keeps its wall-clock place to the routine across a DST change`() { + // A weekly routine at 09:00 Berlin moves from the last Monday of summer time to the first of winter time. + val shift = SeriesShift(at("2026-10-19T07:00:00Z"), at("2026-10-26T08:00:00Z"), berlin) + assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, zone = berlin)).isEqualTo(at("2026-10-27T09:00:00Z")) + assertThat(shift.apply(at("2026-10-21T00:00:00Z"), isAllDay = true, zone = berlin)).isEqualTo(at("2026-10-28T00:00:00Z")) + } + + @Test + fun `moving on shifts dated sub-tasks, fresh ones included, and leaves undated fresh ones`() { + val shift = SeriesShift(at("2026-11-02T08:00:00Z"), at("2026-11-09T08:00:00Z"), ZoneOffset.UTC) + val nodes = listOf( + SubtaskNode(2, false, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null)), + SubtaskNode(3, false, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), isAllDay = true), + SubtaskNode(4, false, SubtaskState.FRESH), + SubtaskNode(5, false, cancelled, SubtaskDates(at("2026-11-02T10:00:00Z"), null)), + ) + val reset = SubtaskReset.toReset(1L, shift) { if (it == 1L) nodes else emptyList() } + assertThat(reset).containsExactly( + ResetSubtask(2, done, SubtaskDates(at("2026-11-02T10:00:00Z"), null), SubtaskDates(at("2026-11-09T10:00:00Z"), null)), + ResetSubtask(3, SubtaskState.FRESH, SubtaskDates(null, at("2026-11-03T00:00:00Z")), SubtaskDates(null, at("2026-11-10T00:00:00Z"))), + ) + } + + @Test + fun `a provider series' shift is read in its own zone`() { + val before = mapOf(Tasks.DTSTART to at("2026-10-19T07:00:00Z"), Tasks.TZ to "Europe/Berlin", Tasks.IS_ALLDAY to 0L) + val after = before + (Tasks.DTSTART to at("2026-10-26T08:00:00Z")) + val shift = SubtaskReset.shiftOf(before, after, floating = ZoneOffset.UTC)!! + assertThat(shift.apply(at("2026-10-20T08:00:00Z"), isAllDay = false, zone = berlin)).isEqualTo(at("2026-10-27T09:00:00Z")) + } + + @Test + fun `a provider series' rule set is read from its columns`() { + val state = mapOf( + Tasks.DTSTART to at("2026-11-02T08:00:00Z"), Tasks.RRULE to "FREQ=WEEKLY", Tasks.EXDATE to null, + Tasks.IS_ALLDAY to 1L, Tasks.TZ to null, + ) + val spec = SubtaskReset.specOf(state)!! + assertThat(spec.rrule).isEqualTo("FREQ=WEEKLY") + assertThat(spec.isAllDay).isTrue() + assertThat(SubtaskReset.specOf(state + (Tasks.RRULE to " "))).isNull() + } + + @Test + fun `the copy marker is recognised in a provider row and in a synced residue`() { + assertThat(SubtaskReset.isCopyMarker(SubtaskReset.COPY_MARKER_JSON)).isTrue() + assertThat(SubtaskReset.isCopyMarker(" [\"x-agendula-tick-copy\",\"1\"]")).isTrue() + assertThat(SubtaskReset.isCopyMarker("[\"X-AGENDULA-TICK-COPYRIGHT\",\"1\"]")).isFalse() + assertThat(SubtaskReset.isCopyMarker(null)).isFalse() + assertThat(SubtaskReset.hasCopyMarker("X-OTHER:1\r\nX-AGENDULA-TICK-COPY:1")).isTrue() + assertThat(SubtaskReset.hasCopyMarker("X-AGENDULA-TICK-COPY;X-P=1:1")).isTrue() + assertThat(SubtaskReset.hasCopyMarker("X-AGENDULA-TICK-COPYRIGHT:1")).isFalse() + assertThat(SubtaskReset.hasCopyMarker(null)).isFalse() + } + + @Test + fun `a provider tick resets only with a copy to record it in`() { + val before = state(start) + (Tasks.RRULE to "FREQ=WEEKLY") + val after = state(start + week) + (Tasks.RRULE to "FREQ=WEEKLY") + assertThat(SubtaskReset.resetsOnTick(9L, before, after)).isTrue() + assertThat(SubtaskReset.resetsOnTick(null, before, after)).isFalse() + assertThat(SubtaskReset.resetsOnTick(9L, before, before)).isFalse() } private val week = 7L * 24 * 60 * 60 * 1000 diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopiesTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopiesTest.kt new file mode 100644 index 0000000..cf56a51 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TickedCopiesTest.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TickedCopiesTest { + + private val now = 100L * 24 * 60 * 60 * 1000 + + private fun tick(copy: Long, daysAgo: Long) = Tick(copy, seriesId = 1, before = null, after = null, at = now - daysAgo * 24 * 60 * 60 * 1000) + + @Test + fun `a tick past the horizon goes, and its copy stays known`() { + val ticks = mapOf( + "org.dmfs.tasks|1|10" to tick(copy = 7, daysAgo = 31), + "org.dmfs.tasks|1|20" to tick(copy = 8, daysAgo = 2), + "org.tasks.opentasks|5|10" to tick(copy = 9, daysAgo = 40), + "garbage" to null, + ) + + val (stale, known) = TickedCopies.expire(ticks, now) + + assertThat(stale).containsExactly("org.dmfs.tasks|1|10", "org.tasks.opentasks|5|10", "garbage") + assertThat(known).containsExactly("org.dmfs.tasks", setOf(7L), "org.tasks.opentasks", setOf(9L)) + } + + @Test + fun `nothing expires inside the horizon`() { + val (stale, known) = TickedCopies.expire(mapOf("a|1|1" to tick(copy = 3, daysAgo = 29)), now) + assertThat(stale).isEmpty() + assertThat(known).isEmpty() + } +}