summaryrefslogtreecommitdiff
path: root/android/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
commitb007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch)
treea5b999b8f4c23a52ae9249675753a92a77993008 /android/app
parent70e6dd75130e70f2db83096c23eaa75326b183a2 (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')
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt30
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt76
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt22
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt31
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt50
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
+ )
+ }
+ }
+}