diff --git a/CHANGELOG.md b/CHANGELOG.md index 579a67b..a1bf371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ All notable changes to this project are documented here. The format follows - Ticking off the current occurrence of a repeating task moves the series on to its next date and keeps a completed copy, as tasks.org and Apple Reminders do. Other apps and the web views of servers such as Infomaniak now - see it as done instead of still open. Unticking it moves the series back - (#137). + see it as done instead of still open. Unticking it moves the series back, + as long as the series hasn't moved on or changed its start since (#137). ## [1.3.0] - 2026-11-02 diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json index d36aa6d..dd13a3a 100644 --- a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/5.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 5, - "identityHash": "319fd369dabf77ad0743bec42d6aab1d", + "identityHash": "64dfff356aece57e8f1b81b82b84406b", "entities": [ { "tableName": "accounts", @@ -546,7 +546,7 @@ }, { "tableName": "advanced_occurrences", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `dropped_exdate` TEXT, `from_override` INTEGER NOT NULL, `detached` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `count_after` INTEGER, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "copyId", @@ -605,22 +605,22 @@ "notNull": true }, { - "fieldPath": "droppedExdate", - "columnName": "dropped_exdate", - "affinity": "TEXT" + "fieldPath": "countAfter", + "columnName": "count_after", + "affinity": "INTEGER" + }, + { + "fieldPath": "droppedExdates", + "columnName": "dropped_exdates", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" }, { "fieldPath": "fromOverride", "columnName": "from_override", "affinity": "INTEGER", "notNull": true - }, - { - "fieldPath": "detached", - "columnName": "detached", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" } ], "primaryKey": { @@ -665,11 +665,90 @@ ] } ] + }, + { + "tableName": "advanced_detached", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copyId", + "columnName": "copy_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anchor", + "columnName": "anchor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_advanced_detached_copy_id", + "unique": false, + "columnNames": [ + "copy_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `${TABLE_NAME}` (`copy_id`)" + }, + { + "name": "index_advanced_detached_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "advanced_occurrences", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "copy_id" + ], + "referencedColumns": [ + "copy_id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '319fd369dabf77ad0743bec42d6aab1d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '64dfff356aece57e8f1b81b82b84406b')" ] } } \ No newline at end of file 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 d28770d..533ce71 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 @@ -566,6 +566,8 @@ class RoomTasksDataSourceTest { source.setCompletedInstance(id, at + 14.days, completed = true) assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED) + // Nothing to move past: the closed occurrences stay the series' own. + assertThat(db.tasks().overrides(id)).hasSize(2) source.setCompletedInstance(id, at + 14.days, completed = false) assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) @@ -636,6 +638,97 @@ class RoomTasksDataSourceTest { assertThat(db.tasks().entity(override)!!.unknownProperties!!.lines()).contains("UID:alarm-1") } + @Test + fun reopeningTheCopyInTheEditorMovesTheSeriesBack() { + val at = now + val id = source.insertTask(form(title = "Weekly")) + makeRecurring(id, at) + source.setCompletedInstance(id, at, completed = true) + val copy = db.tasks().allIn(listId).single { it.id != id } + + source.updateTask( + copy.id, + TaskForm(title = copy.title.orEmpty(), listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 0), + reminders = null, + ) + + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at) + } + + @Test + fun anEditedCopyReopensAsATaskOfItsOwn() { + val at = now + val id = source.insertTask(form(title = "Weekly")) + makeRecurring(id, at) + source.setCompletedInstance(id, at, completed = true) + val copy = db.tasks().allIn(listId).single { it.id != id } + db.tasks().update(copy.copy(description = "notes")) + source.updateTask(copy.id, TaskForm(title = "Renamed", listId = listId, start = copy.dtstart, due = copy.due, percentComplete = 100), null) + + source.setCompleted(copy.id, completed = false) + + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days) + assertThat(db.tasks().entity(copy.id)!!.title).isEqualTo("Renamed") + assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + } + + @Test + fun aCopyWithASubtaskReopensOnItsOwn() { + val at = now + val id = source.insertTask(form(title = "Weekly")) + makeRecurring(id, at) + source.setCompletedInstance(id, at, completed = true) + val copy = db.tasks().allIn(listId).single { it.id != id } + source.insertTask(form(title = "Part").copy(parentId = copy.id)) + + source.setCompleted(copy.id, completed = false) + + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at + 7.days) + assertThat(db.tasks().entity(copy.id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION) + } + + @Test + fun aRecurringSubtaskKeepsTheOverrideModel() { + val at = now + val parent = source.insertTask(form(title = "Parent")) + val id = source.insertTask(form(title = "Daily part").copy(parentId = parent)) + makeRecurring(id, at, rule = "FREQ=DAILY") + + source.setCompletedInstance(id, at, completed = true) + + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at) + assertThat(db.tasks().overrides(id)).hasSize(1) + } + + @Test + fun aCountChangedSinceIsKept() { + val at = now + val id = source.insertTask(form(title = "Ten")) + makeRecurring(id, at, rule = "FREQ=WEEKLY;COUNT=10") + source.setCompletedInstance(id, at, completed = true) + db.tasks().update(db.tasks().entity(id)!!.copy(rrule = "FREQ=WEEKLY;COUNT=5")) + + source.setCompletedInstance(id, at, completed = false) + + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at) + assertThat(db.tasks().entity(id)!!.rrule).isEqualTo("FREQ=WEEKLY;COUNT=5") + } + + @Test + fun tickingTheSameOccurrenceAgainKeepsOneRecord() { + val at = now + val id = source.insertTask(form(title = "Weekly")) + makeRecurring(id, at) + source.setCompletedInstance(id, at, completed = true) + source.setCompletedInstance(id, at, completed = false) + + source.setCompletedInstance(id, at, completed = true) + + assertThat(db.advancedOccurrences().forMaster(id)).hasSize(1) + source.setCompletedInstance(id, at, completed = false) + assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(at) + } + @Test fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() { val id = source.insertTask(form(title = "Buy milk", due = now + 1.days)) diff --git a/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/RoomSyncStoreAdvancesTest.kt b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/RoomSyncStoreAdvancesTest.kt new file mode 100644 index 0000000..ea85e87 --- /dev/null +++ b/app/src/androidTestFull/java/de/jeanlucmakiola/agendula/data/sync/RoomSyncStoreAdvancesTest.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Clock +import kotlin.time.Instant + +/** A server version replacing a moved series takes the moves' copies with it (#137). */ +@RunWith(AndroidJUnit4::class) +class RoomSyncStoreAdvancesTest { + + private val db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), TasksDatabase::class.java) + .allowMainThreadQueries().build() + + @After + fun tearDown() = db.close() + + @Test + fun droppingAdvancesDeletesTheirCopies() { + val list = db.taskLists().insert(TaskListEntity(name = "Home", color = 0)) + val at = Instant.fromEpochSeconds(Clock.System.now().epochSeconds) + val series = db.tasks().insert(TaskEntity(listId = list, uid = "s", title = "Weekly", dtstart = at, rrule = "FREQ=WEEKLY")) + RoomTasksDataSource(db).setCompletedInstance(series, at, true) + val copy = db.tasks().allIn(list).single { it.id != series } + db.tasks().update(copy.copy(href = "copy.ics")) + + RoomSyncStore(db).dropAdvances(series) + + assertThat(db.advancedOccurrences().forMaster(series)).isEmpty() + assertThat(db.tasks().entity(copy.id)!!.isDeleted).isTrue() + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt index a54e999..f7d91df 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt @@ -825,6 +825,7 @@ class CollectionSyncer( } pendingDiscard.remove(href)?.let { cause -> + existing[null]?.let { store.dropAdvances(it.id) } report = report.copy( discardedEdits = report.discardedEdits + DiscardedEdit(uid, losing?.title, cause), diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt index 0cb0544..c95b8ab 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt @@ -41,6 +41,13 @@ interface SyncStore { fun deleteAll(taskIds: List) + /** + * Forgets how ticking occurrences moved [masterId]'s series, and deletes the + * copies that left: the server's version replaced the moved series, and its + * own overrides stand for those occurrences again. + */ + fun dropAdvances(masterId: Long) = Unit + /** * Records href and ETag on a resource's rows, clearing `is_dirty`. * @@ -97,6 +104,19 @@ class RoomSyncStore @Inject constructor( if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds) } + override fun dropAdvances(masterId: Long) = database.runInTransaction { + val advanced = database.advancedOccurrences() + advanced.forMaster(masterId).forEach { record -> + val rows = advanced.detachedFor(record.copyId).mapNotNull { it.taskId } + record.copyId + advanced.delete(record.copyId) + rows.mapNotNull { database.tasks().entity(it) }.forEach { row -> + // Never uploaded: nobody to tell. Uploaded: a tombstone the next run deletes. + if (row.href == null) database.tasks().delete(row.id) + else database.tasks().update(row.copy(isDeleted = true, isDirty = true)) + } + } + } + override fun markSynced(taskIds: List, href: String?, eTag: String?) { if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index e418e09..b578d0a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -68,9 +68,11 @@ class TasksRepositoryImpl @Inject constructor( override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow = observing { dataSource.task(taskId, occurrenceStart)?.let { task -> + // The row the read resolved: a past occurrence may have become a task of its own. + val owner = if (task.seriesId == null) task.taskId else taskId TaskDetail( task = task, - subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId }, + subtasks = SeriesCollapse.visible(dataSource.subtasks(owner)).filter { it.taskId != owner }, parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt index a5e99ef..c1b6c70 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/provider/ProviderWriter.kt @@ -12,7 +12,6 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource -import de.jeanlucmakiola.agendula.data.tasks.room.isClosed import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase @@ -108,7 +107,6 @@ class ProviderWriter internal constructor( } } - /** A read-only table is refused as such, whichever store is active. */ /** Refused as read-only whichever store is active, before the store is even looked at. */ private fun readOnly(table: ProviderTable): Nothing = throw UnsupportedOperationException("${table.path} is read-only") @@ -149,8 +147,6 @@ class ProviderWriter internal constructor( requireWritableList(current.listId) val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone()) if (edited == current) return true - // Reopening the copy of a ticked occurrence moves its series back, as in the app. - if (current.isClosed && !edited.isClosed && store.reopenAdvanced(taskId)) return true val moved = edited.listId != current.listId if (moved) { requireWritableList(edited.listId) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt index 21c09aa..81f950f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AdvancedOccurrence.kt @@ -39,35 +39,69 @@ data class AdvancedOccurrenceEntity( @ColumnInfo(name = "prior_completed_at") val priorCompletedAt: Instant?, /** Occurrences `COUNT` lost to the move. */ @ColumnInfo(name = "spent") val spent: Int, - /** `EXDATE` values the move dropped, as they were written. */ - @ColumnInfo(name = "dropped_exdate") val droppedExdate: String?, + /** The `COUNT` the move left; one changed since is the user's and stays. */ + @ColumnInfo(name = "count_after") val countAfter: Int?, + /** The `EXDATE` occurrences the move dropped, as epoch millis, comma-separated. */ + @ColumnInfo(name = "dropped_exdates", defaultValue = "") val droppedExdates: String = "", /** The copy was an override before, and goes back to being one. */ @ColumnInfo(name = "from_override") val fromOverride: Boolean, - /** Closed overrides detached with it: `anchor:taskId` pairs, comma-separated. */ - @ColumnInfo(name = "detached", defaultValue = "") val detached: String = "", ) { - val detachedRows: List> - get() = detached.split(',').filter { it.isNotEmpty() }.map { - Instant.fromEpochMilliseconds(it.substringBefore(':').toLong()) to it.substringAfter(':').toLong() - } + val dropped: List + get() = droppedExdates.split(',').mapNotNull { it.toLongOrNull()?.let(Instant::fromEpochMilliseconds) } companion object { - fun encodeDetached(rows: List>) = - rows.joinToString(",") { "${it.first.toEpochMilliseconds()}:${it.second}" } + fun encode(instants: List) = instants.joinToString(",") { it.toEpochMilliseconds().toString() } } } +/** A closed override detached along with an advance; [taskId] is null once that row is gone. */ +@Entity( + tableName = "advanced_detached", + foreignKeys = [ + ForeignKey( + entity = AdvancedOccurrenceEntity::class, + parentColumns = ["copy_id"], + childColumns = ["copy_id"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["task_id"], onDelete = ForeignKey.SET_NULL), + ], + indices = [Index(value = ["copy_id"]), Index(value = ["task_id"])], +) +data class AdvancedDetachedEntity( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "copy_id") val copyId: Long, + @ColumnInfo(name = "anchor") val anchor: Instant, + @ColumnInfo(name = "task_id") val taskId: Long?, +) + @Dao interface AdvancedOccurrenceDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(row: AdvancedOccurrenceEntity) + @Insert + fun insertDetached(rows: List) + @Query("SELECT * FROM advanced_occurrences WHERE copy_id = :copyId") fun forCopy(copyId: Long): AdvancedOccurrenceEntity? - @Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId AND anchor / 1000 = :anchorSecond") - fun at(masterId: Long, anchorSecond: Long): AdvancedOccurrenceEntity? + @Query("SELECT * FROM advanced_detached WHERE copy_id = :copyId") + fun detachedFor(copyId: Long): List + + @Query("SELECT * FROM advanced_occurrences WHERE master_id = :masterId") + fun forMaster(masterId: Long): List + + /** The record for [masterId]'s occurrence within the second starting at [secondStart]. */ + @Query( + "SELECT * FROM advanced_occurrences WHERE master_id = :masterId " + + "AND anchor >= :secondStart AND anchor < :secondStart + 1000", + ) + fun at(masterId: Long, secondStart: Instant): AdvancedOccurrenceEntity? @Query("DELETE FROM advanced_occurrences WHERE copy_id = :copyId") fun delete(copyId: Long) + + @Query("DELETE FROM advanced_occurrences WHERE master_id = :masterId") + fun deleteForMaster(masterId: Long) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index 44be52e..3bf4214 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -1,11 +1,10 @@ package de.jeanlucmakiola.agendula.data.tasks.room -import de.jeanlucmakiola.agendula.domain.TaskStatus - import de.jeanlucmakiola.agendula.domain.LocalAccount import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.isClosed import de.jeanlucmakiola.agendula.domain.priorityFromICal import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import kotlin.time.Instant @@ -104,7 +103,7 @@ val TaskEntity.isRecurring: Boolean /** Completed or cancelled. */ val TaskEntity.isClosed: Boolean - get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED + get() = status.isClosed /** * The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only 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 8502443..2e33011 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 @@ -29,7 +29,6 @@ private val WINDOW_FORWARD = 730.days /** Residue lines that describe the old series and must not follow the new one. */ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID") - /** Only bounds the search: the occurrence count is what stops expansion, so no real gap is cut off. */ private val SERIES_LOOKAHEAD = (366 * 1000).days @@ -265,7 +264,8 @@ class RoomTasksDataSource @Inject constructor( // in both (the task query skips non-null `master_id`, and the override // query finds no master in B) while still uploading as part of A's // resource. - tasks.update( + saveRow( + current, if (current.masterId == null) { TaskFormWriter.withRule(edited, form.rrule) } else { @@ -472,88 +472,90 @@ class RoomTasksDataSource @Inject constructor( /** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */ override fun setCancelled(taskId: Long, cancelled: Boolean) = tx { val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId") - if (cancelled || !reopenCopy(current)) { - tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now())) - writes.onWritten(setOf(current.listId)) - } + saveRow(current, TaskFormWriter.cancelled(current, cancelled, clock.now())) + writes.onWritten(setOf(current.listId)) } /** Reopening the copy a ticked occurrence left behind moves its series back, as in [setCompletedInstance]. */ override fun setCompleted(taskId: Long, completed: Boolean) = tx { val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId") - if (completed || !reopenCopy(current)) { - tasks.update(TaskFormWriter.completed(current, completed, clock.now())) - writes.onWritten(setOf(current.listId)) - } + saveRow(current, TaskFormWriter.completed(current, completed, clock.now())) + writes.onWritten(setOf(current.listId)) } /** * Ticking a series' first open occurrence moves the series on to its next * open one and keeps a completed copy, as tasks.org and Apple Reminders do — a * completed `RECURRENCE-ID` override alone leaves the series showing as open - * in most other clients (#137). Occurrences already closed as overrides on - * either side of it become copies too. Reopening the copy moves the series + * in most other clients (#137). Closed overrides between become copies too. + * Reopening the copy while the series is still where that left it moves it * back. Any other occurrence forks an override carrying the completion, the * same model [updateInstance] writes. Writing the status onto the master * instead would close the series. */ override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = tx { - closeOccurrence(taskId, occurrenceStart, completed, "complete") { TaskFormWriter.completed(it, completed, clock.now()) } + closeOccurrence(taskId, occurrenceStart, completed) { TaskFormWriter.completed(it, completed, clock.now()) } } /** Same model as [setCompletedInstance]. */ override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = tx { - closeOccurrence(taskId, occurrenceStart, cancelled, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) } + closeOccurrence(taskId, occurrenceStart, cancelled) { TaskFormWriter.cancelled(it, cancelled, clock.now()) } } - /** Moves the series behind the copy [taskId] back onto its occurrence; false when it is no such copy. */ - fun reopenAdvanced(taskId: Long): Boolean { - var reopened = false - tx { reopened = tasks.entity(taskId)?.let(::reopenCopy) == true } - return reopened - } - - private fun closeOccurrence( - taskId: Long, - occurrenceStart: Instant, - closing: Boolean, - what: String, - close: (TaskEntity) -> TaskEntity, - ) { - val row = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId") - val master = row.masterId?.let { tasks.entity(it) } ?: row + private fun closeOccurrence(taskId: Long, occurrenceStart: Instant, closing: Boolean, close: (TaskEntity) -> TaskEntity) { + val master = seriesMaster(taskId) + val row = tasks.entity(taskId)!! if (master.recurrenceSpec() == null) { // A plain task, or the copy a ticked occurrence left: this row is the occurrence. - if (closing || !reopenCopy(row)) writeRow(row, close) + saveRow(row, close(row)) + writes.onWritten(setOf(row.listId)) return } val override = if (row.masterId != null) row else tasks.override(master.id, occurrenceStart) val anchor = override?.recurrenceId ?: occurrenceStart val seriesStart = master.recurrenceAnchor ?: return if (anchor < seriesStart && !sameSecond(anchor, seriesStart)) { - // The series has moved past it: a stale tick does nothing, a reopen takes its copy back. + // The series has moved past it, so a tick has nothing left to close; + // a reopen goes to the copy that occurrence left. when { override != null -> writeInstance(master, override, anchor, close) - !closing -> copyAt(master, anchor)?.let { copy -> if (!reopenCopy(copy)) writeRow(copy, close) } + !closing -> copyAt(master, anchor)?.let { copy -> + saveRow(copy, close(copy)) + writes.onWritten(setOf(copy.listId)) + } } return } if (master.isClosed) { - // Only its last occurrence closes a series; reopening that one reopens it. - if (!closing && sameSecond(anchor, seriesStart)) closeSeries(master, override, close) - else writeInstance(master, override, anchor, close) + // A series ends with its last occurrence; reopening any of it reopens the series. + if (!closing) closeSeries(master, override, close) else writeInstance(master, override, anchor, close) return } if (!closing || !advance(master, anchor, override, close)) writeInstance(master, override, anchor, close) } - private fun writeRow(row: TaskEntity, close: (TaskEntity) -> TaskEntity) { - tasks.update(close(row)) - // Reopened on its own, it no longer stands for the series' occurrence. - if (!close(row).isClosed) advanced.delete(row.id) - writes.onWritten(setOf(row.listId)) + /** + * Writes [edited] over [current]. A copy whose only change is that it + * reopens moves its series back instead; any other edit makes it a task of + * its own, and it no longer stands for the series' occurrence. + */ + private fun saveRow(current: TaskEntity, edited: TaskEntity) { + val record = advanced.forCopy(current.id) + if (record != null) { + val reopenedOnly = current.isClosed && !edited.isClosed && sameApartFromState(current, edited) + if (reopenedOnly && reopenCopy(current, record)) return + if (!sameApartFromState(current, edited) || !edited.isClosed) advanced.delete(current.id) + } + tasks.update(edited) } + /** Nothing a user sees changed but its state — what saving a reopened copy from the editor amounts to. */ + private fun sameApartFromState(a: TaskEntity, b: TaskEntity) = + a.title == b.title && a.description == b.description && a.location == b.location && a.url == b.url && + a.priority == b.priority && a.dtstart == b.dtstart && a.due == b.due && a.isAllDay == b.isAllDay && + a.listId == b.listId && a.parentId == b.parentId && a.rrule == b.rrule && a.rdate == b.rdate && + a.categories == b.categories && a.color == b.color + /** [close] applied to the master — and to [override], its last occurrence — which ends the series. */ private fun closeSeries(master: TaskEntity, override: TaskEntity?, close: (TaskEntity) -> TaskEntity) { tasks.update(close(master)) @@ -563,11 +565,13 @@ class RoomTasksDataSource @Inject constructor( /** * Moves [master] past [anchor] when that is its first open occurrence; false - * when it is not. A series with `RDATE`s is left to the override model: - * anchoring on an `RDATE` would re-align its `RRULE`. + * when it is not, so the caller writes an override. Series with `RDATE`s keep + * the override model (anchoring on one would re-align the `RRULE`), and so do + * recurring subtasks, whose copies would pile up under the parent. */ private fun advance(master: TaskEntity, anchor: Instant, override: TaskEntity?, close: (TaskEntity) -> TaskEntity): Boolean { - if (master.rrule.isNullOrBlank() || !master.rdate.isNullOrBlank()) return false + val rule = master.rrule?.takeIf { it.isNotBlank() } ?: return false + if (!master.rdate.isNullOrBlank() || master.parentId != null) return false val spec = master.recurrenceSpec() ?: return false val overrides = tasks.overrides(master.id).associateBy { it.recurrenceId?.epochSeconds } fun closedAt(at: Instant) = overrides[at.epochSeconds]?.isClosed == true @@ -576,7 +580,7 @@ class RoomTasksDataSource @Inject constructor( if (index < 0 || leading.take(index).any { !closedAt(it) }) return false var nextIndex = index + 1 while (nextIndex < leading.size && closedAt(leading[nextIndex])) nextIndex++ - val next = leading.getOrNull(nextIndex) + val next = leading.getOrNull(nextIndex) ?: return true.also { closeSeries(master, override, close) } val now = clock.now() val zone = master.timezone ?: zone() @@ -584,23 +588,23 @@ class RoomTasksDataSource @Inject constructor( at to detach(overrides.getValue(at.epochSeconds), now) } // Expansion drops the anchor's sub-second part; the series keeps it. - val offset = spec.anchor - floorSecond(spec.anchor) - val newAnchor = (next ?: leading[index]) + offset - val spent = spentBefore(spec, next ?: leading[index]) + val newAnchor = next + (spec.anchor - floorSecond(spec.anchor)) + val spent = spentBefore(spec, next) val (start, due) = occurrenceTimes(master, newAnchor) - val moved = master.copy( - dtstart = start, - due = due, - rrule = master.rrule.let { TaskFormWriter.remainingCount(it, spent) }, - exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone), - lastModified = now, - isDirty = true, + val ruleAfter = TaskFormWriter.remainingCount(rule, spent) + tasks.update( + master.copy( + dtstart = start, + due = due, + rrule = ruleAfter, + exdate = TaskFormWriter.valuesFrom(master.exdate, newAnchor, zone), + status = TaskStatus.NEEDS_ACTION, + percentComplete = null, + completedAt = null, + lastModified = now, + isDirty = true, + ), ) - if (next == null) { - // Its last occurrence: the series ends there, and reopening it reopens the series. - closeSeries(moved, override, close) - return true - } val copyId = if (override != null) { detach(override, now, close) } else { @@ -609,7 +613,7 @@ class RoomTasksDataSource @Inject constructor( alarms.copyToTask(master.id, id, sameResource = false) id } - tasks.update(moved.copy(status = TaskStatus.NEEDS_ACTION, percentComplete = null, completedAt = null)) + advanced.at(master.id, floorSecond(anchor))?.let { advanced.delete(it.copyId) } advanced.insert( AdvancedOccurrenceEntity( copyId = copyId, @@ -622,15 +626,20 @@ class RoomTasksDataSource @Inject constructor( priorPercentComplete = master.percentComplete, priorCompletedAt = master.completedAt, spent = spent, - droppedExdate = TaskFormWriter.valuesBefore(master.exdate, newAnchor, zone), + countAfter = countOf(ruleAfter), + droppedExdates = AdvancedOccurrenceEntity.encode(TaskFormWriter.instantsBefore(master.exdate, newAnchor, zone)), fromOverride = override != null, - detached = AdvancedOccurrenceEntity.encodeDetached(detached), ), ) + advanced.insertDetached(detached.map { (at, id) -> AdvancedDetachedEntity(copyId = copyId, anchor = at, taskId = id) }) writes.onWritten(setOf(master.listId)) return true } + private fun countOf(rule: String?): Int? = rule?.split(';') + ?.firstOrNull { it.substringBefore('=').trim().equals("COUNT", ignoreCase = true) } + ?.substringAfter('=')?.trim()?.toIntOrNull() + /** Occurrences `COUNT` has spent before [cut]: counted before `EXDATE` (RFC 5545 §3.8.5.3). */ private fun spentBefore(spec: RecurrenceSpec, cut: Instant): Int = RecurrenceExpander.expand( spec.copy(exdate = null), @@ -674,47 +683,50 @@ class RoomTasksDataSource @Inject constructor( /** * Undoes [advance] for the copy [row] while the series still sits where it - * moved it: the anchor goes back, `COUNT` gets back what it spent and the - * dropped `EXDATE`s return; any other edit made since stays. The occurrences - * it moved past become overrides again — or, where their copy is gone, are - * excluded. False when [row] is no such copy, so the caller reopens it as a - * task of its own. + * moved it, and the copy is still just that — same list, nothing filed under + * it. The anchor goes back, `COUNT` gets back what it spent unless it was + * changed since, and the dropped `EXDATE`s return in the list's own shape; + * any other edit made since stays. The occurrences detached with it become + * overrides again, or are excluded where their copy is gone. False when it + * cannot, so the caller reopens the copy as a task of its own. */ - private fun reopenCopy(row: TaskEntity): Boolean { - val record = advanced.forCopy(row.id) ?: return false - val master = tasks.entity(record.masterId) - ?.takeIf { !it.isDeleted && !it.isClosed && it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true } - if (master == null) { + private fun reopenCopy(row: TaskEntity, record: AdvancedOccurrenceEntity): Boolean { + val master = tasks.entity(record.masterId)?.takeIf { + !it.isDeleted && !it.isClosed && row.listId == it.listId && + it.recurrenceAnchor?.let { a -> sameSecond(a, record.advancedTo) } == true + } + if (master == null || tasks.subtasks(row.id).isNotEmpty()) { advanced.delete(row.id) return false } val now = clock.now() val zone = master.timezone ?: zone() + val synced = lists.entity(master.listId)?.accountId != null val (start, due) = occurrenceTimes(master, record.priorDtstart ?: record.priorDue ?: record.anchor) + val rule = master.rrule?.let { if (countOf(it) == record.countAfter) TaskFormWriter.remainingCount(it, -record.spent) else it } var back = master.copy( dtstart = start, due = due, - rrule = master.rrule?.let { TaskFormWriter.remainingCount(it, -record.spent) }, - exdate = listOfNotNull(record.droppedExdate, master.exdate).joinToString(",").ifEmpty { null }, + rrule = rule, status = if (master.status == TaskStatus.NEEDS_ACTION) record.priorStatus else master.status, percentComplete = master.percentComplete ?: record.priorPercentComplete, completedAt = master.completedAt ?: record.priorCompletedAt, lastModified = now, isDirty = true, ) - val synced = lists.entity(master.listId)?.accountId != null - record.detachedRows.forEach { (anchor, id) -> - val detached = tasks.entity(id)?.takeIf { !it.isDeleted && it.listId == master.listId } - if (detached == null) back = TaskFormWriter.excepting(back, anchor, now, zone, dirty = synced) + record.dropped.forEach { back = TaskFormWriter.excepting(back, it, now, zone, dirty = synced) } + val detached = advanced.detachedFor(row.id).map { it.anchor to it.taskId?.let(tasks::entity) } + detached.forEach { (at, task) -> + if (task == null || task.isDeleted || task.listId != master.listId) back = TaskFormWriter.excepting(back, at, now, zone, dirty = synced) } tasks.update(back) - record.detachedRows.forEach { (anchor, id) -> - tasks.entity(id)?.takeIf { !it.isDeleted && it.listId == master.listId }?.let { toOverride(it, back, anchor, now) } + detached.forEach { (at, task) -> + if (task != null && !task.isDeleted && task.listId == master.listId) toOverride(task, back, at, now) } advanced.delete(row.id) if (record.fromOverride) toOverride(TaskFormWriter.completed(row, false, now), back, record.anchor, now) else deleteTask(row.id) - writes.onWritten(setOf(master.listId, row.listId)) + writes.onWritten(setOf(master.listId)) return true } @@ -724,7 +736,6 @@ class RoomTasksDataSource @Inject constructor( uid = master.uid, masterId = master.id, recurrenceId = anchor, - listId = master.listId, href = null, etag = null, isDirty = true, @@ -744,7 +755,7 @@ class RoomTasksDataSource @Inject constructor( /** The copy [advance] left for [master]'s occurrence at [anchor], if it is still there. */ private fun copyAt(master: TaskEntity, anchor: Instant): TaskEntity? = - advanced.at(master.id, anchor.epochSeconds)?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted } + advanced.at(master.id, floorSecond(anchor))?.let { tasks.entity(it.copyId) }?.takeUnless { it.isDeleted } /** The series' occurrences from its anchor on, at most [maxOccurrences]. */ private fun expandFrom(spec: RecurrenceSpec, maxOccurrences: Int): List { @@ -874,7 +885,7 @@ class RoomTasksDataSource @Inject constructor( if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}") move(current, edited) } - else -> tasks.update(edited) + else -> saveRow(current, edited) } } writes.onWritten(setOf(current.listId, edited.listId)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt index a73b2c0..e849b60 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -120,6 +120,10 @@ object TaskFormWriter { fun valuesBefore(values: String?, cut: Instant, zone: String): String? = mapValues(values, zone) { it.takeIf { at -> at < cut } } + /** The instants of an `EXDATE`/`RDATE` list before [cut]; values it cannot read are left out. */ + fun instantsBefore(values: String?, cut: Instant, zone: String): List = + values?.split(',')?.map(String::trim)?.mapNotNull { readValue(it, zone) }?.filter { it < cut }.orEmpty() + /** The values of an `EXDATE`/`RDATE` list at or after [cut]. */ fun valuesFrom(values: String?, cut: Instant, zone: String): String? = mapValues(values, zone) { it.takeIf { at -> at >= cut } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 5a3050f..d3dc1e8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -9,7 +9,7 @@ import java.util.UUID import kotlin.time.Instant /** - * Agendula's own task store. Five tables, designed from + * Agendula's own task store. Six tables, designed from * what the app actually reads and writes plus RFC 5545's `VTODO`. * * Schemas are exported to `app/schemas/` and committed, so a future version can @@ -22,6 +22,7 @@ import kotlin.time.Instant TaskEntity::class, TaskAlarmEntity::class, AdvancedOccurrenceEntity::class, + AdvancedDetachedEntity::class, ], version = 5, exportSchema = true, @@ -187,18 +188,19 @@ abstract class TasksDatabase : RoomDatabase() { val MIGRATION_4_5 = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( - "CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, " + - "`master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, " + - "`prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, " + - "`prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, " + - "`dropped_exdate` TEXT, `from_override` INTEGER NOT NULL, `detached` TEXT NOT NULL DEFAULT '', " + - "PRIMARY KEY(`copy_id`), " + - "FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , " + - "FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "CREATE TABLE IF NOT EXISTS `advanced_occurrences` (`copy_id` INTEGER NOT NULL, `master_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `advanced_to` INTEGER NOT NULL, `prior_dtstart` INTEGER, `prior_due` INTEGER, `prior_status` INTEGER NOT NULL, `prior_percent_complete` INTEGER, `prior_completed_at` INTEGER, `spent` INTEGER NOT NULL, `count_after` INTEGER, `dropped_exdates` TEXT NOT NULL DEFAULT '', `from_override` INTEGER NOT NULL, PRIMARY KEY(`copy_id`), FOREIGN KEY(`copy_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", ) db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` " + - "ON `advanced_occurrences` (`master_id`, `anchor`)", + "CREATE INDEX IF NOT EXISTS `index_advanced_occurrences_master_id_anchor` ON `advanced_occurrences` (`master_id`, `anchor`)", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `advanced_detached` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `copy_id` INTEGER NOT NULL, `anchor` INTEGER NOT NULL, `task_id` INTEGER, FOREIGN KEY(`copy_id`) REFERENCES `advanced_occurrences`(`copy_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_advanced_detached_copy_id` ON `advanced_detached` (`copy_id`)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_advanced_detached_task_id` ON `advanced_detached` (`task_id`)", ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 35de203..55943f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -37,6 +37,9 @@ data class TaskList( enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } +/** Completed or cancelled. */ +val TaskStatus.isClosed: Boolean get() = this == TaskStatus.COMPLETED || this == TaskStatus.CANCELLED + /** iCalendar priority, bucketed for the UI. */ enum class Priority { NONE, LOW, MEDIUM, HIGH } @@ -111,7 +114,7 @@ data class Task( val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null val isCompleted: Boolean get() = status == TaskStatus.COMPLETED - val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED + val isClosed: Boolean get() = status.isClosed val isSubtask: Boolean get() = parentId != null && parentId > 0 /** The task's own colour if set, else the list colour. */ val effectiveColor: Int get() = taskColor ?: listColor