diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-17 22:22:37 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-17 22:22:37 +0000 |
| commit | b007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch) | |
| tree | a5b999b8f4c23a52ae9249675753a92a77993008 /android/app/src/main | |
| parent | 70e6dd75130e70f2db83096c23eaa75326b183a2 (diff) | |
Implement linear task chains and recurring maintenance buckets
Backend, web timeline, and Android widget wiring for the last two
unimplemented items from doot-future-task-scheduling-ideas.
Chains: task_chains table + chain_id/chain_position/chain_unlocked on
native_tasks (migration 026), WIP-limit-1 advancement hooked into
CompleteNativeTask, locked tasks excluded from all date-based queries,
5 new /api/widget/chains* endpoints, an N/M position badge on web and
Android widget rows.
Buckets: maintenance_buckets table + bucket_id/bucket_state/
bucket_last_active_at on native_tasks (migration 027),
staleness-then-priority selection scoring, a new RunBucketCycleCheck
scheduler loop, 5 new endpoints including the distinct Defer action, a
Defer button on web and Android widget rows.
Also corrected stale "not yet approved" status headers on the two
already-shipped specs this work depended on (labels/projects, budgets/
availability) -- their headers were never updated after implementation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'android/app/src/main')
5 files changed, 208 insertions, 1 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt index ed1bceb..dfaa8b6 100644 --- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt +++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt @@ -17,7 +17,10 @@ data class WidgetItem( val url: String = "", val completable: Boolean = false, @SerialName("recurring_event_id") val recurringEventId: String? = null, - @SerialName("project_color") val projectColor: String? = null + @SerialName("project_color") val projectColor: String? = null, + @SerialName("chain_position") val chainPosition: Int = 0, // 1-indexed; 0 = not a chain task + @SerialName("chain_total") val chainTotal: Int = 0, + @SerialName("bucket_state") val bucketState: String? = null // "active" when it's a maintenance-bucket item; null otherwise ) @Serializable @@ -45,3 +48,28 @@ data class TaskDetail( val description: String, val editable: Boolean ) + +@Serializable +data class Chain( + val id: String, + @SerialName("project_id") val projectId: String, + val status: String, // "active" | "paused" | "abandoned" | "completed" + @SerialName("created_at") val createdAt: String +) + +/** Matches models.Task's JSON shape (GET /api/widget/chains/{id}), not WidgetItem's. */ +@Serializable +data class ChainTask( + val id: String, + val content: String, + val completed: Boolean = false, + @SerialName("due_date") val dueDate: String? = null, + @SerialName("chain_position") val chainPosition: Int = 0, + @SerialName("chain_unlocked") val chainUnlocked: Boolean = false +) + +@Serializable +data class ChainDetail( + val chain: Chain, + val tasks: List<ChainTask> // locked + unlocked, ordered by chain_position +) diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt index 3283ea8..d630247 100644 --- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt +++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt @@ -133,6 +133,21 @@ class WidgetRepository( } } + /** POSTs a defer (return to bucket pool without crediting completion) to /api/widget/task/defer. */ + suspend fun defer(id: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id"}""".toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/task/defer") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + /** * POSTs a new task title to /api/widget/add and returns its id, so the * caller can follow up with the same project/labels/recurrence/due-date @@ -339,4 +354,65 @@ class WidgetRepository( check(response.isSuccessful) { "HTTP ${response.code}" } } } + + @Serializable + private data class ChainCreateRequest(val name: String, val tasks: List<String>) + + @Serializable + private data class ChainCreateResponse(val id: String) + + /** POSTs a new chain (name + ordered task titles) to /api/widget/chains and returns its id. */ + suspend fun createChain(name: String, taskTitles: List<String>): Result<String> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(ChainCreateRequest(name, taskTitles)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/chains") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val respBody = checkNotNull(response.body?.string()) { "Empty body" } + json.decodeFromString<ChainCreateResponse>(respBody).id + } + } + + /** GETs the full ordered checklist (locked + unlocked) for a chain. */ + suspend fun fetchChain(id: String): Result<ChainDetail> = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget/chains/$id") + .header("Authorization", "Bearer $token") + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = checkNotNull(response.body?.string()) { "Empty body" } + json.decodeFromString<ChainDetail>(body) + } + } + + private suspend fun setChainStatus(id: String, action: String): Result<Unit> = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget/chains/$id/$action") + .header("Authorization", "Bearer $token") + .post("".toRequestBody("application/json".toMediaType())) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** Pauses a chain: the unlocked task stays actionable but won't auto-advance on completion. */ + suspend fun pauseChain(id: String): Result<Unit> = setChainStatus(id, "pause") + + /** Resumes a paused chain, re-enabling auto-advance on the next completion. */ + suspend fun resumeChain(id: String): Result<Unit> = setChainStatus(id, "resume") + + /** Abandons a chain -- a terminal state distinct from "completed". */ + suspend fun abandonChain(id: String): Result<Unit> = setChainStatus(id, "abandon") } diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt index c6dcae9..78fb29c 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt @@ -10,6 +10,7 @@ import org.terst.doot.widget.data.Keys import org.terst.doot.widget.data.dataStore import org.terst.doot.widget.data.removeWidgetItemLocally import org.terst.doot.widget.work.CompleteWorker +import org.terst.doot.widget.work.DeferWorker import org.terst.doot.widget.work.RefreshWorker class CompleteTaskAction : ActionCallback { @@ -33,6 +34,27 @@ class CompleteTaskAction : ActionCallback { } } +// Defer is only ever shown for doot-native active bucket items (see +// TaskRow), so unlike CompleteTaskAction this doesn't need a source param. +class DeferTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val id = parameters[idKey] ?: return + // Optimistically drop it from the visible list right away, same as + // CompleteTaskAction -- DeferWorker does the real call + refresh. + removeWidgetItemLocally(context, id, "doot") + DootWidget().updateAll(context) + DeferWorker.enqueue(context, id) + } + + companion object { + val idKey = ActionParameters.Key<String>("item_id") + } +} + // Sets IS_REFRESHING synchronously (before the network round trip) so the // widget's icon flips to the loading state on the spot -- RefreshWorker // clears the flag when it finishes, regardless of outcome, so the icon diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt index 1c7e454..6515131 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt @@ -307,6 +307,37 @@ fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) { maxLines = 1 ) } + + if (task.chainTotal > 0) { + Text( + text = "${task.chainPosition}/${task.chainTotal}", + style = TextStyle( + color = ColorProvider(Color(0xFFC4B5FD)), + fontSize = textSize.scaledContentSize(11) + ), + modifier = GlanceModifier.padding(start = 4.dp) + ) + } + + if (task.bucketState == "active") { + Box( + modifier = GlanceModifier + .padding(start = 4.dp) + .clickable( + actionRunCallback<DeferTaskAction>( + actionParametersOf(DeferTaskAction.idKey to task.id) + ) + ) + ) { + Text( + text = "defer", + style = TextStyle( + color = ColorProvider(Color(0x99FFFFFF)), + fontSize = textSize.scaledContentSize(11) + ) + ) + } + } } } diff --git a/android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt new file mode 100644 index 0000000..03ed1ab --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt @@ -0,0 +1,50 @@ +package org.terst.doot.widget.work + +import android.content.Context +import androidx.glance.appwidget.updateAll +import androidx.work.* +import kotlinx.coroutines.flow.first +import org.terst.doot.widget.ui.DootWidget +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +// Mirrors CompleteWorker's shape -- see that file for the enqueueUniqueWork +// rationale (same rapid-double-tap race applies here). +class DeferWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val id = inputData.getString(KEY_ID) ?: return Result.failure() + + val prefs = applicationContext.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure() + val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure() + + val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token) + return repo.defer(id).fold( + onSuccess = { + repo.fetchAndPersist(applicationContext) + DootWidget().updateAll(applicationContext) + Result.success() + }, + onFailure = { Result.retry() } + ) + } + + companion object { + const val KEY_ID = "item_id" + + fun enqueue(context: Context, id: String) { + val data = workDataOf(KEY_ID to id) + val request = OneTimeWorkRequestBuilder<DeferWorker>() + .setInputData(data) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + "defer_$id", + ExistingWorkPolicy.KEEP, + request + ) + } + } +} |
