diff options
41 files changed, 4573 insertions, 125 deletions
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3fc5603..4b58442 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,19 @@ android:theme="@style/Theme.TaskDetail" android:exported="false" /> + <!-- Quick-add bottom sheet (opened by widget "+" button) --> + <activity + android:name=".ui.QuickAddActivity" + android:theme="@style/Theme.TaskDetail" + android:exported="false" + android:windowSoftInputMode="adjustResize" /> + + <!-- Event detail bottom sheet (opened by widget calendar event taps) --> + <activity + android:name=".ui.EventDetailActivity" + android:theme="@style/Theme.TaskDetail" + android:exported="false" /> + <!-- Settings activity (also the widget config screen) --> <activity android:name=".ui.SettingsActivity" diff --git a/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt b/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt index 164e3a2..d2f2cbb 100644 --- a/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt +++ b/android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt @@ -3,6 +3,7 @@ package org.terst.doot.widget.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -15,4 +16,5 @@ object Keys { val ITEMS_JSON = stringPreferencesKey("items_json") val NOW = stringPreferencesKey("now") val LAST_UPDATED = longPreferencesKey("last_updated") + val IS_REFRESHING = booleanPreferencesKey("is_refreshing") } 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 4666dd9..fee15e1 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 @@ -12,8 +12,11 @@ data class WidgetItem( val start: String? = null, // ISO-8601 or null (floating task) val end: String? = null, // ISO-8601 or null @SerialName("is_all_day") val isAllDay: Boolean = false, + @SerialName("is_overdue") val isOverdue: Boolean = false, + @SerialName("due_date") val dueDate: String? = null, val url: String = "", - val completable: Boolean = false + val completable: Boolean = false, + @SerialName("recurring_event_id") val recurringEventId: String? = null ) @Serializable 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 b9f52ce..3574eb5 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 @@ -14,9 +14,14 @@ import okhttp3.RequestBody.Companion.toRequestBody private val json = Json { ignoreUnknownKeys = true } -@Serializable private data class UpdateDescriptionRequest(val id: String, val source: String, val description: String) +@Serializable +private data class WidgetAddRequest(val title: String) + +@Serializable +private data class RecurrenceResponse(val recurrence: String) + /** * Optimistically drops an item from the cached list so the widget can update instantly on * completion, ahead of the authoritative refresh a worker performs once the API call succeeds. @@ -121,4 +126,43 @@ class WidgetRepository( check(response.isSuccessful) { "HTTP ${response.code}" } } } + + /** + * POSTs a new task title to /api/widget/add. Uses proper JSON encoding + * (not manual string interpolation like reschedule/complete above) because + * the title is arbitrary user text that could contain characters that + * break hand-built JSON -- id/source above are safe because they're + * internal identifiers, never user-typed free text. + */ + suspend fun addTask(title: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(WidgetAddRequest(title)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/add") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** GETs the formatted recurrence schedule for a recurring event. */ + suspend fun getRecurrence(recurringEventId: String): Result<String> = + withContext(Dispatchers.IO) { + val encodedId = java.net.URLEncoder.encode(recurringEventId, "UTF-8") + val request = Request.Builder() + .url("$serverUrl/api/widget/recurrence?recurring_event_id=$encodedId") + .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" } + val parsed = json.decodeFromString<RecurrenceResponse>(body) + parsed.recurrence + } + } } 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 f81815f..c6dcae9 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 @@ -1,12 +1,16 @@ package org.terst.doot.widget.ui import android.content.Context +import androidx.datastore.preferences.core.edit import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.updateAll +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.RefreshWorker class CompleteTaskAction : ActionCallback { override suspend fun onAction( @@ -28,3 +32,19 @@ class CompleteTaskAction : ActionCallback { val sourceKey = ActionParameters.Key<String>("item_source") } } + +// 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 +// never gets stuck. +class RefreshTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + context.dataStore.edit { it[Keys.IS_REFRESHING] = true } + DootWidget().updateAll(context) + RefreshWorker.runOnce(context) + } +} diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt index 1575350..ae749fb 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt @@ -2,7 +2,6 @@ package org.terst.doot.widget.ui import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -48,9 +47,10 @@ class DootWidget : GlanceAppWidget() { val items = parseItems(prefs) val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.now() + val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false provideContent { - WidgetRoot(items, now) + WidgetRoot(items, now, isRefreshing) } } @@ -61,26 +61,47 @@ class DootWidget : GlanceAppWidget() { } @Composable -fun WidgetRoot(items: List<WidgetItem>, now: Instant) { +fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean) { val zone = ZoneId.systemDefault() val nowZoned: ZonedDateTime = now.atZone(zone) - val allScheduled = items.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } + val todayStart = nowZoned.toLocalDate().atStartOfDay(zone).toInstant() + val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() + val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) + + // All-day CALENDAR EVENTS (isAllDay && type == "event" -- see + // TimelineItemToWidgetItem's doc comment for why undated doot/gtask + // tasks, which are also flagged isAllDay, are deliberately excluded + // here) are pinned to the top of their day's section and never compete + // for hourly grid slots or floating-task packing. Previously they had + // no Start at all and fell into the same floating-task queue as + // ordinary untimed tasks, where enough tasks ahead of them in the queue + // could push their assigned slot past the visible grid range entirely + // -- not merely unpinned, actually invisible. + val allDayEvents = items.filter { it.isAllDay && it.type == "event" } + val rest = items.filter { !(it.isAllDay && it.type == "event") } + val todayAllDay = allDayEvents.filter { item -> + val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } + s == null || (s >= todayStart && s < tomorrowStart) + } + val tomorrowAllDay = allDayEvents.filter { item -> + val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } + s != null && s >= tomorrowStart && s < tomorrowEnd + } + + val allScheduled = rest.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } // Past tasks float at now (before untimed tasks); past events stay in the grid at 50% alpha val pastTasks = allScheduled.filter { it.type == "task" && Instant.parse(it.start!!) < now } val scheduledEvents = allScheduled.filter { it.type != "task" || Instant.parse(it.start!!) >= now } - val floating = items.filter { it.start == null } + val floating = rest.filter { it.start == null } val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now) val gridStart = calcGridStart(scheduledEvents, nowZoned.hour) val gridEnd = calcGridEnd(scheduledEvents, nowZoned.hour) - val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() - val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) val tomorrowItems = scheduledEvents .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd } val tomorrowFrags = fragments .filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd } - val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } - val tomorrowTaskSlots = tomorrowFrags.flatMap { it.slots } + val showTomorrow = tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty() // Glance's LazyColumn (backed by a RemoteViews ListView) is what actually scrolls in an // app widget — a plain Column clips its content to the widget's current height instead. @@ -91,38 +112,111 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant) { .padding(horizontal = 8.dp, vertical = 4.dp) ) { item { - Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) { + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( "TODAY", style = TextStyle( color = ColorProvider(Color(0x66FFFFFF)), fontSize = 11.sp, fontWeight = FontWeight.Bold - ) + ), + modifier = GlanceModifier.defaultWeight() ) + QuickAddButton() + Spacer(modifier = GlanceModifier.width(4.dp)) + RefreshButton(isRefreshing) } } + items(count = todayAllDay.size) { index -> + AllDayRow(todayAllDay[index]) + } + items(count = gridEnd - gridStart + 1) { index -> HourRow(gridStart + index, nowZoned, scheduledEvents, fragments, zone) } if (showTomorrow) { - item { TomorrowHeader() } - - items(count = tomorrowItems.size) { index -> - val item = tomorrowItems[index] - if (item.type == "event") TomorrowEventRow(item, zone) else TaskRow(item) - } - - items(count = tomorrowTaskSlots.size) { index -> - TaskRow(tomorrowTaskSlots[index].task) + item { + TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone) } } } } @Composable +fun AllDayRow(event: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} + Text( + text = event.title, + style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} + +@Composable +fun RefreshButton(isRefreshing: Boolean) { + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionRunCallback<RefreshTaskAction>()), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider( + if (isRefreshing) org.terst.doot.widget.R.drawable.ic_refresh_loading + else org.terst.doot.widget.R.drawable.ic_refresh + ), + contentDescription = if (isRefreshing) "Refreshing" else "Refresh", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} + +@Composable +fun QuickAddButton() { + val context = LocalContext.current + val intent = Intent(context, QuickAddActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionStartActivity(intent)), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_add), + contentDescription = "Add task", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} + +@Composable fun HourRow( hour: Int, nowZoned: ZonedDateTime, @@ -183,13 +277,21 @@ fun HourRow( @Composable fun EventBlock(event: WidgetItem, isPast: Boolean) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } val color = sourceColor(event.source) val alpha = if (isPast) 0.5f else 1f Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 4.dp) - .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box( @@ -240,6 +342,7 @@ fun TaskRow(task: WidgetItem) { putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source) putExtra(TaskDetailActivity.EXTRA_TITLE, task.title) putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable) + putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } // Split into two sibling clickable regions to avoid the Glance/RemoteViews limitation @@ -290,7 +393,9 @@ fun TaskRow(task: WidgetItem) { Text( text = task.title, style = TextStyle( - color = ColorProvider(Color(0xFFDDDDDD.toInt())), + color = ColorProvider( + if (task.isOverdue) Color(0xFFF87171) else Color(0xFFDDDDDD.toInt()) + ), fontSize = 14.sp ), maxLines = 1 @@ -300,7 +405,7 @@ fun TaskRow(task: WidgetItem) { } @Composable -fun TomorrowHeader() { +fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, allDayEvents: List<WidgetItem>, zone: ZoneId) { Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {} Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) { @@ -313,10 +418,33 @@ fun TomorrowHeader() { ) ) } + + allDayEvents.forEach { AllDayRow(it) } + + items.forEach { item -> + val isPast = false + if (item.type == "event") { + TomorrowEventRow(item, zone) + } else { + TaskRow(item) + } + } + + fragments.forEach { frag -> + frag.slots.forEach { slot -> TaskRow(slot.task) } + } } @Composable fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } val color = sourceColor(event.source) val timeLabel = event.start?.let { val t = Instant.parse(it).atZone(zone) @@ -326,7 +454,7 @@ fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) - .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Text( diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt new file mode 100644 index 0000000..b8423ce --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt @@ -0,0 +1,145 @@ +package org.terst.doot.widget.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class EventDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val source = intent.getStringExtra(EXTRA_SOURCE) ?: "calendar" + val url = intent.getStringExtra(EXTRA_URL) ?: "" + val recurringEventId = intent.getStringExtra(EXTRA_RECURRING_EVENT_ID) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + EventDetailSheet( + title = title, + source = source, + recurringEventId = recurringEventId, + onOpenCalendar = { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })) + ) + finish() + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_TITLE = "event_title" + const val EXTRA_SOURCE = "event_source" + const val EXTRA_URL = "event_url" + const val EXTRA_RECURRING_EVENT_ID = "event_recurring_id" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + title: String, + source: String, + recurringEventId: String?, + onOpenCalendar: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var recurrenceText by remember { mutableStateOf<String?>(null) } + + LaunchedEffect(recurringEventId) { + if (recurringEventId == null) return@LaunchedEffect + recurrenceText = "Loading…" + val prefs = context.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@LaunchedEffect + val token = prefs[Keys.TOKEN] ?: return@LaunchedEffect + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.getRecurrence(recurringEventId).onSuccess { text -> + recurrenceText = text + }.onFailure { + recurrenceText = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + recurrenceText?.let { text -> + Spacer(Modifier.height(8.dp)) + Text( + text = text, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + Spacer(Modifier.height(20.dp)) + OutlinedButton( + onClick = onOpenCalendar, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text("Open in Calendar", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt new file mode 100644 index 0000000..bb6bad0 --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt @@ -0,0 +1,115 @@ +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class QuickAddActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + QuickAddSheet( + onAdd = { title -> + lifecycleScope.launch { + val prefs = this@QuickAddActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.addTask(title).onSuccess { + repo.fetchAndPersist(this@QuickAddActivity) + DootWidget().updateAll(this@QuickAddActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickAddSheet( + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var title by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + .imePadding() + ) { + Text( + text = "Quick Add", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + placeholder = { Text("Task title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onAdd(title.trim()) }, + enabled = title.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Add", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt index d14386c..3aeafc5 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt @@ -24,6 +24,8 @@ import org.terst.doot.widget.data.WidgetRepository import org.terst.doot.widget.data.dataStore import org.terst.doot.widget.data.removeWidgetItemLocally import org.terst.doot.widget.work.CompleteWorker +import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.TimeZone @@ -36,6 +38,7 @@ class TaskDetailActivity : ComponentActivity() { val source = intent.getStringExtra(EXTRA_SOURCE) ?: return finish() val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val completable = intent.getBooleanExtra(EXTRA_COMPLETABLE, false) + val dueDate = intent.getStringExtra(EXTRA_DUE_DATE) setContent { MaterialTheme(colorScheme = darkColorScheme()) { @@ -56,6 +59,7 @@ class TaskDetailActivity : ComponentActivity() { source = source, completable = completable, detail = detail, + dueDate = dueDate, onComplete = { lifecycleScope.launch { removeWidgetItemLocally(this@TaskDetailActivity, id, source) @@ -101,6 +105,7 @@ class TaskDetailActivity : ComponentActivity() { const val EXTRA_SOURCE = "task_source" const val EXTRA_TITLE = "task_title" const val EXTRA_COMPLETABLE = "task_completable" + const val EXTRA_DUE_DATE = "task_due_date" } } @@ -114,6 +119,7 @@ fun TaskDetailSheet( source: String, completable: Boolean, detail: TaskDetail?, + dueDate: String?, onComplete: () -> Unit, onReschedule: (String) -> Unit, onSaveDescription: (String) -> Unit, @@ -238,10 +244,18 @@ fun TaskDetailSheet( colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) ) { - Text("Reschedule", fontSize = 15.sp) + Text(formatDueDateLabel(dueDate), fontSize = 15.sp) } } Spacer(Modifier.height(20.dp)) } } } + +private fun formatDueDateLabel(dueDate: String?): String { + if (dueDate == null) return "No due date · tap to schedule" + return runCatching { + val date = LocalDate.parse(dueDate.substring(0, 10)) + "Due " + date.format(DateTimeFormatter.ofPattern("MMM d")) + }.getOrDefault("No due date · tap to schedule") +} diff --git a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt index d3017ff..51ad727 100644 --- a/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt +++ b/android/app/src/main/java/org/terst/doot/widget/work/CompleteWorker.kt @@ -35,12 +35,31 @@ class CompleteWorker(context: Context, params: WorkerParameters) : const val KEY_ID = "item_id" const val KEY_SOURCE = "item_source" + // enqueueUniqueWork(id, KEEP, ...) instead of a plain enqueue(): a + // rapid double-tap on the same row (plausible since the checkbox + // doesn't visually update until this worker's full async round-trip + // -- complete() -> fetchAndPersist() -> updateAll() -- finishes) + // previously spawned two independent, unordered CompleteWorker runs + // for the same task. Each does its own fetchAndPersist(), so a + // second worker's fetch (started before the first worker's + // complete() call had actually landed server-side) could persist a + // stale snapshot after the first worker's correct one, leaving the + // widget showing outdated data. KEEP means a tap on a task that + // already has a completion in flight is simply dropped -- the first + // request's result (including its fetchAndPersist/updateAll) is what + // takes effect, with no race between two workers touching the same + // task. Different task ids still run independently/concurrently, + // which is fine since they don't share a row. fun enqueue(context: Context, id: String, source: String) { val data = workDataOf(KEY_ID to id, KEY_SOURCE to source) val request = OneTimeWorkRequestBuilder<CompleteWorker>() .setInputData(data) .build() - WorkManager.getInstance(context).enqueue(request) + WorkManager.getInstance(context).enqueueUniqueWork( + "complete_$id", + ExistingWorkPolicy.KEEP, + request + ) } } } diff --git a/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt index 5c0e89c..3bb8bdd 100644 --- a/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt +++ b/android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt @@ -1,6 +1,7 @@ package org.terst.doot.widget.work import android.content.Context +import androidx.datastore.preferences.core.edit import androidx.glance.appwidget.updateAll import androidx.work.* import kotlinx.coroutines.flow.first @@ -14,16 +15,20 @@ class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { + val result = performRefresh() + applicationContext.dataStore.edit { it[Keys.IS_REFRESHING] = false } + DootWidget().updateAll(applicationContext) + return result + } + + private suspend fun performRefresh(): Result { 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.fetchAndPersist(applicationContext).fold( - onSuccess = { - DootWidget().updateAll(applicationContext) - Result.success() - }, + onSuccess = { Result.success() }, onFailure = { Result.retry() } ) } diff --git a/android/app/src/main/res/drawable/ic_add.xml b/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..a96533f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M12,5 L12,19 M5,12 L19,12" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_refresh.xml b/android/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..4b67528 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4,12 A8,8 0 1,1 7,18" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + <path + android:pathData="M4,12 L4,7 L9,7" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_refresh_loading.xml b/android/app/src/main/res/drawable/ic_refresh_loading.xml new file mode 100644 index 0000000..7ad3b2d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_refresh_loading.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M5,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M12,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M19,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> +</vector> diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 4b67c2a..d04a60e 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -373,6 +373,8 @@ func main() { r.With(widgetAuth).Post("/api/widget/update", h.HandleWidgetUpdate) r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) + r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence) + r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd) } else { log.Println("WIDGET_TOKEN not set — /api/widget disabled") } diff --git a/docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md b/docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md new file mode 100644 index 0000000..7b2547c --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-clickable-reschedule.md @@ -0,0 +1,498 @@ +# Widget Clickable Date/Time Reschedule Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a doot task's actual due date in the detail popup, and make that date display itself the tap target that opens the reschedule date picker — replacing the current separate "Reschedule" button, which shows no date at all today. + +**Architecture:** Add a new `DueDate` field to the widget API (independent of the existing `Start`/`IsAllDay` fields, which are reserved for the client's floating-task positioning and must not change). Thread it through the Android data model, the `Intent` extras `TaskRow` builds when opening the detail popup, and into `TaskDetailSheet`'s UI, where it replaces the existing button. + +**Tech Stack:** Go (`internal/models`, `internal/handlers`), Kotlin/Jetpack Glance + Compose (Android widget + detail activity). + +## Global Constraints + +- Do not touch `WidgetItem.Start`/`End`/`IsAllDay` logic in `TimelineItemToWidgetItem` — those are protected by the existing `TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior` test and drive the client's `SlotPacker` positioning. `DueDate` is a new, separate field. +- `DueDate` is populated ONLY for doot tasks (`item.Type == TimelineItemTypeTask && item.Source == "doot"`) — never for calendar events, gtasks, or Trello cards. +- No time-of-day editing: the reschedule flow stays date-only, matching `HandleWidgetReschedule`'s existing `YYYY-MM-DD` contract. Do not add time-of-day UI or API fields. +- Date display format: `"Due " + "MMM d"` (e.g. "Due Jul 15") when a due date exists; `"No due date · tap to schedule"` when it doesn't. + +--- + +### Task 1: Server — add DueDate to the widget API + +**Files:** +- Modify: `internal/models/widget.go` +- Modify: `internal/handlers/widget.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `models.WidgetItem.DueDate *time.Time` (JSON field `due_date`, `omitempty`) + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/handlers/widget_test.go`, after the `TestTimelineItemToWidgetItem_NotOverdueByDefault` test added by the overdue-badge feature (if that test isn't present yet when you start, add these at the end of the `TestTimelineItemToWidgetItem_*` group instead — anywhere in that group is fine): + +```go +// TestTimelineItemToWidgetItem_DootTaskGetsDueDate proves the 2026-07-12 +// clickable-reschedule fix: a doot task's raw due date must reach the +// client via a NEW field (DueDate) that is independent of Start/IsAllDay -- +// Start is deliberately left nil for doot tasks (see +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior) so the +// client's floating-task SlotPacker can position it, and that must keep +// working unchanged. Before this fix there was no way for the Android +// detail popup to know a doot task's current due date at all. +func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { + due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "doot-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: due, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate == nil { + t.Fatal("expected DueDate to be set for a doot task with a real due date") + } + if !wi.DueDate.Equal(due) { + t.Errorf("DueDate = %v, want %v", *wi.DueDate, due) + } + // Start must stay nil -- this is the pre-existing floating-task + // behavior and this feature must not change it. + if wi.Start != nil { + t.Error("Start must remain nil for a doot task -- DueDate is a separate field, not a replacement") + } +} + +func TestTimelineItemToWidgetItem_UndatedDootTask_NilDueDate(t *testing.T) { + item := models.TimelineItem{ + ID: "doot-undated", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + // Zero Time simulates the "no real due date" case at the field level; + // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero(). + item.Time = time.Time{} + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to be nil when the source item has a zero Time") + } +} + +func TestTimelineItemToWidgetItem_CalendarEvent_NilDueDate(t *testing.T) { + start := time.Now() + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: start, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to stay nil for a non-doot item (calendar event)") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem_DootTaskGetsDueDate -v` +Expected: compile error — `wi.DueDate undefined (type models.WidgetItem has no field or method DueDate)`. + +- [ ] **Step 3: Add the field to `models.WidgetItem`** + +In `internal/models/widget.go`, add `DueDate` after `IsOverdue` (if the overdue-badge feature's field is already present) or after `IsAllDay` (if not yet landed), before `URL`: + +```go +type WidgetItem struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Type string `json:"type"` + Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) + End *time.Time `json:"end,omitempty"` + IsAllDay bool `json:"is_all_day"` + IsOverdue bool `json:"is_overdue"` + DueDate *time.Time `json:"due_date,omitempty"` // doot tasks only -- see TimelineItemToWidgetItem + URL string `json:"url,omitempty"` + Completable bool `json:"completable"` // true = doot task (checkbox shown) +} +``` + +If the `IsOverdue` line isn't present yet in your checkout (the overdue-badge task may not have landed), just add `DueDate` after `IsAllDay` instead — the exact position among the boolean/pointer flags doesn't matter, only that it's a real field on the struct. + +- [ ] **Step 4: Set the field in `TimelineItemToWidgetItem`** + +In `internal/handlers/widget.go`, find the end of the function (after the existing `Start`/`End` block, before `return wi`): + +```go + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { + t := item.Time + wi.Start = &t + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } + } + } + + return wi +} +``` + +Add the new `DueDate` block immediately before `return wi`, as a completely separate condition from the `Start`/`End` block above it (do not merge them): + +```go + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { + t := item.Time + wi.Start = &t + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } + } + } + + // DueDate is independent of Start/IsAllDay -- doot tasks deliberately + // keep Start nil (see the "floating task" doc comment above) so the + // client's SlotPacker positions them, but the Android detail popup + // still needs to know the real due date to display and reschedule it. + if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() { + due := item.Time + wi.DueDate = &due + } + + return wi +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v` +Expected: PASS — all tests in this group, including the three new ones. + +- [ ] **Step 6: Run the full Go test suite and gofmt check** + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure — confirm no new failures. + +Run: `gofmt -l internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go +git commit -m "feat(widget): add DueDate field for doot tasks to the widget API" +``` + +--- + +### Task 2: Android — clickable due-date row replaces the Reschedule button + +**Depends on:** Task 1 (for a meaningful on-device test — the field must exist server-side for the client to receive real data; not a compile dependency). + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt` + +**Interfaces:** +- Consumes: JSON field `due_date` (produced by Task 1) +- Produces: `WidgetItem.dueDate: String?` +- Produces: `TaskDetailActivity.EXTRA_DUE_DATE: String` (extra key constant) +- Modifies: `TaskDetailSheet(title, source, completable, onComplete, onReschedule, onDismiss)` → `TaskDetailSheet(title, source, completable, dueDate, onComplete, onReschedule, onDismiss)` (new `dueDate: String?` parameter inserted after `completable`) + +- [ ] **Step 1: Add `dueDate` to the Android `WidgetItem` data class** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`, add the field (exact position among the other fields doesn't matter — add it after `isAllDay`/`isOverdue`, before `url`): + +```kotlin +@Serializable +data class WidgetItem( + val id: String, + val title: String, + val source: String, + val type: String, + val start: String? = null, + val end: String? = null, + @SerialName("is_all_day") val isAllDay: Boolean = false, + @SerialName("is_overdue") val isOverdue: Boolean = false, + @SerialName("due_date") val dueDate: String? = null, + val url: String = "", + val completable: Boolean = false +) +``` + +(If `isOverdue` isn't present yet in your checkout, just add `dueDate` after `isAllDay` instead — same reasoning as Task 1 Step 3.) + +- [ ] **Step 2: Pass `dueDate` through `TaskRow`'s detail `Intent`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find (around line 290-297): + +```kotlin +fun TaskRow(task: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, TaskDetailActivity::class.java).apply { + putExtra(TaskDetailActivity.EXTRA_ID, task.id) + putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source) + putExtra(TaskDetailActivity.EXTRA_TITLE, task.title) + putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +``` + +Add one line for the due date: + +```kotlin +fun TaskRow(task: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, TaskDetailActivity::class.java).apply { + putExtra(TaskDetailActivity.EXTRA_ID, task.id) + putExtra(TaskDetailActivity.EXTRA_SOURCE, task.source) + putExtra(TaskDetailActivity.EXTRA_TITLE, task.title) + putExtra(TaskDetailActivity.EXTRA_COMPLETABLE, task.completable) + putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +``` + +- [ ] **Step 3: Rewrite `TaskDetailActivity.kt`** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.work.CompleteWorker +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.TimeZone + +class TaskDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = intent.getStringExtra(EXTRA_ID) ?: return finish() + val source = intent.getStringExtra(EXTRA_SOURCE) ?: return finish() + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val completable = intent.getBooleanExtra(EXTRA_COMPLETABLE, false) + val dueDate = intent.getStringExtra(EXTRA_DUE_DATE) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + TaskDetailSheet( + title = title, + source = source, + completable = completable, + dueDate = dueDate, + onComplete = { + CompleteWorker.enqueue(this, id, source) + finish() + }, + onReschedule = { dateISO -> + lifecycleScope.launch { + val prefs = this@TaskDetailActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.reschedule(id, source, dateISO).onSuccess { + repo.fetchAndPersist(this@TaskDetailActivity) + DootWidget().updateAll(this@TaskDetailActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_ID = "task_id" + const val EXTRA_SOURCE = "task_source" + const val EXTRA_TITLE = "task_title" + const val EXTRA_COMPLETABLE = "task_completable" + const val EXTRA_DUE_DATE = "task_due_date" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskDetailSheet( + title: String, + source: String, + completable: Boolean, + dueDate: String?, + onComplete: () -> Unit, + onReschedule: (String) -> Unit, + onDismiss: () -> Unit +) { + var showDatePicker by remember { mutableStateOf(false) } + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = System.currentTimeMillis() + ) + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = millis + val dateISO = "%04d-%02d-%02d".format( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH) + ) + onReschedule(dateISO) + } + showDatePicker = false + }) { Text("Set date") } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } + } + ) { + DatePicker(state = datePickerState) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(20.dp)) + if (completable) { + Button( + onClick = onComplete, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Mark Complete", fontSize = 15.sp) + } + Spacer(Modifier.height(8.dp)) + } + if (source == "doot") { + OutlinedButton( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text(formatDueDateLabel(dueDate), fontSize = 15.sp) + } + } + Spacer(Modifier.height(20.dp)) + } + } +} + +private fun formatDueDateLabel(dueDate: String?): String { + if (dueDate == null) return "No due date · tap to schedule" + return runCatching { + val date = LocalDate.parse(dueDate.substring(0, 10)) + "Due " + date.format(DateTimeFormatter.ofPattern("MMM d")) + }.getOrDefault("No due date · tap to schedule") +} +``` + +Note: only two things changed from the original file — `dueDate: String?` was added as a new parameter to `TaskDetailSheet` (and read from the intent in `onCreate`), and the button's `Text("Reschedule", ...)` became `Text(formatDueDateLabel(dueDate), ...)`. The button element itself (`OutlinedButton` with `onClick = { showDatePicker = true }`) is unchanged — it's still the same tap target opening the same picker, just relabeled with the actual date instead of the word "Reschedule". `formatDueDateLabel` takes the first 10 characters of the ISO date/time string (`YYYY-MM-DD`) before parsing, since the server sends a full RFC3339 timestamp (e.g. `2026-07-15T00:00:00-10:00`) but `LocalDate.parse` needs just the date portion. + +- [ ] **Step 4: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt +git commit -m "feat(widget): make the due-date display the reschedule tap target" +``` + +Manual on-device verification (release APK build, deploy, visual + interaction check) is deferred to the controller. + +--- + +## Out of scope + +Quick add and recurrence display — each gets its own spec. diff --git a/docs/superpowers/plans/2026-07-12-widget-overdue-badge.md b/docs/superpowers/plans/2026-07-12-widget-overdue-badge.md new file mode 100644 index 0000000..c67cf72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-overdue-badge.md @@ -0,0 +1,260 @@ +# Widget Overdue Badge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Forward the already-computed `IsOverdue` flag from the server's `TimelineItem` through the widget API to the Android client, and render overdue task titles in a distinct warning color. + +**Architecture:** Add `IsOverdue` to `models.WidgetItem` (Go) and forward it in `TimelineItemToWidgetItem`. Add the matching `isOverdue` field to the Android `WidgetItem` data class. `TaskRow` (the single shared rendering path for every task row in the widget) reads the flag and colors the title text accordingly. + +**Tech Stack:** Go (`internal/models`, `internal/handlers`), Kotlin/Jetpack Glance (Android widget). + +## Global Constraints + +- Visual signal is a title-text color change only — no new icon, badge, or label, and no sort-order change. This matches every other "this is special" signal in `DootWidget.kt`, which is color-only (`sourceColor`, `AllDayRow`'s bar, `EventBlock`'s past-event dimming). +- The overdue color is `Color(0xFFF87171)` (soft red) — distinct from the default title color `Color(0xFFDDDDDD)` and from every value in `sourceColor()`. +- Server-side test follows the existing pattern in `internal/handlers/widget_test.go` (see `TestTimelineItemToWidgetItem_AllDayEvent` for the style: table-free, one behavior per test function, doc comment explaining the "why"). + +--- + +### Task 1: Server — forward IsOverdue through the widget API + +**Files:** +- Modify: `internal/models/widget.go` +- Modify: `internal/handlers/widget.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `models.WidgetItem.IsOverdue bool` (JSON field `is_overdue`) +- Consumes: `models.TimelineItem.IsOverdue` (already exists, already correctly computed — see `internal/models/timeline.go:62`) + +- [ ] **Step 1: Write the failing test** + +Add to `internal/handlers/widget_test.go`, directly after `TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior`: + +```go +// TestTimelineItemToWidgetItem_ForwardsIsOverdue proves the 2026-07-12 +// overdue-badge fix: TimelineItem.IsOverdue (already computed correctly by +// ComputeDaySection, confirmed by the earlier fix that made overdue tasks +// appear in the timeline at all) must be forwarded onto WidgetItem so the +// Android client can render it distinctly -- previously it was silently +// dropped, so an overdue task looked identical to a normal one on the +// widget. +func TestTimelineItemToWidgetItem_ForwardsIsOverdue(t *testing.T) { + item := models.TimelineItem{ + ID: "overdue-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsOverdue: true, + } + + wi := TimelineItemToWidgetItem(item) + + if !wi.IsOverdue { + t.Error("expected IsOverdue to be forwarded as true") + } +} + +func TestTimelineItemToWidgetItem_NotOverdueByDefault(t *testing.T) { + item := models.TimelineItem{ + ID: "today-1", + Title: "Water the plants", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.IsOverdue { + t.Error("expected IsOverdue to be false when the source item isn't overdue") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem_ForwardsIsOverdue -v` +Expected: FAIL — `wi.IsOverdue` is always `false` (the zero value), because `models.WidgetItem` has no such field yet (this will actually be a compile error first: `unknown field IsOverdue in struct literal` is not applicable here since the test only reads `wi.IsOverdue` — the compile error will be `wi.IsOverdue undefined (type models.WidgetItem has no field or method IsOverdue)`). + +- [ ] **Step 3: Add the field to `models.WidgetItem`** + +In `internal/models/widget.go`, add `IsOverdue` to the `WidgetItem` struct (after `IsAllDay`, before `URL`, to keep related boolean flags grouped): + +```go +type WidgetItem struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Type string `json:"type"` + Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) + End *time.Time `json:"end,omitempty"` + IsAllDay bool `json:"is_all_day"` + IsOverdue bool `json:"is_overdue"` + URL string `json:"url,omitempty"` + Completable bool `json:"completable"` // true = doot task (checkbox shown) +} +``` + +- [ ] **Step 4: Forward the field in `TimelineItemToWidgetItem`** + +In `internal/handlers/widget.go`, find: + +```go + wi := models.WidgetItem{ + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + URL: item.URL, + } +``` + +Replace with: + +```go + wi := models.WidgetItem{ + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + IsOverdue: item.IsOverdue, + URL: item.URL, + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v` +Expected: PASS — all `TestTimelineItemToWidgetItem_*` tests pass, including the two new ones. + +- [ ] **Step 6: Run the full Go test suite and gofmt check** + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing failures unrelated to this change (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations` in `internal/handlers`, and the `internal/models` vet failure for `undefined: MealToAtom`) — confirm no new failures. + +Run: `gofmt -l internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go` +Expected: no output (clean). + +- [ ] **Step 7: Commit** + +```bash +git add internal/models/widget.go internal/handlers/widget.go internal/handlers/widget_test.go +git commit -m "feat(widget): forward IsOverdue from TimelineItem to WidgetItem API" +``` + +--- + +### Task 2: Android — render overdue tasks with a distinct title color + +**Depends on:** Task 1 must be deployed (or at least merged) first — the Android build needs `is_overdue` in the JSON response to have somewhere to come from, though the field itself is optional/defaults false so the build will compile fine either way. Sequencing is for a meaningful on-device test, not a compile dependency. + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` + +**Interfaces:** +- Consumes: JSON field `is_overdue` (produced by Task 1) +- Produces: `WidgetItem.isOverdue: Boolean` (default `false`) + +- [ ] **Step 1: Add the field to the Android `WidgetItem` data class** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`: + +```kotlin +package org.terst.doot.widget.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WidgetItem( + val id: String, + val title: String, + val source: String, // "todoist" | "trello" | "calendar" | "plantoeat" | "gtasks" + val type: String, // "task" | "event" + val start: String? = null, // ISO-8601 or null (floating task) + val end: String? = null, // ISO-8601 or null + @SerialName("is_all_day") val isAllDay: Boolean = false, + @SerialName("is_overdue") val isOverdue: Boolean = false, + val url: String = "", + val completable: Boolean = false +) + +@Serializable +data class WidgetResponse( + val now: String, + val items: List<WidgetItem> +) +``` + +- [ ] **Step 2: Color the title text for overdue tasks in `TaskRow`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find the `TaskRow` composable's title `Text` (currently around line 344-351): + +```kotlin + Box( + modifier = GlanceModifier + .defaultWeight() + .clickable(actionStartActivity(detailIntent)) + .padding(start = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = task.title, + style = TextStyle( + color = ColorProvider(Color(0xFFDDDDDD.toInt())), + fontSize = 14.sp + ), + maxLines = 1 + ) + } +``` + +Replace with: + +```kotlin + Box( + modifier = GlanceModifier + .defaultWeight() + .clickable(actionStartActivity(detailIntent)) + .padding(start = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = task.title, + style = TextStyle( + color = ColorProvider( + if (task.isOverdue) Color(0xFFF87171) else Color(0xFFDDDDDD.toInt()) + ), + fontSize = 14.sp + ), + maxLines = 1 + ) + } +``` + +- [ ] **Step 3: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +git commit -m "feat(widget): color overdue task titles distinctly" +``` + +Manual on-device verification (release APK build, deploy, visual check) is deferred to the controller, same as the refresh-button feature's Step 8. + +--- + +## Out of scope + +Clickable-date reschedule, quick add, and recurrence display — each gets its own spec and plan. diff --git a/docs/superpowers/plans/2026-07-12-widget-quick-add.md b/docs/superpowers/plans/2026-07-12-widget-quick-add.md new file mode 100644 index 0000000..07a1552 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-quick-add.md @@ -0,0 +1,562 @@ +# Widget Quick Add Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the user create a new doot task directly from the widget, via a "+" button that opens a small text-entry sheet. + +**Architecture:** A new bearer-token-protected JSON endpoint (`POST /api/widget/add`) mirrors the existing `/api/widget/complete`/`/api/widget/reschedule` handlers and creates an undated native task via the existing `store.CreateNativeTask`. The Android side follows the same "tap widget element → launch a full Activity with a Compose bottom sheet → do the work → close" pattern already used for reschedule, via a new `QuickAddActivity`. + +**Tech Stack:** Go (`internal/handlers`, `cmd/dashboard`), Kotlin/Jetpack Glance + Compose (Android widget). + +## Global Constraints + +- No in-widget text input — Glance/RemoteViews can't reliably support it. The button launches a full Activity, same pattern as the existing detail popup. +- The new task is created undated (no due date) — quick add is for capture, not scheduling; the user can reschedule it afterward via the existing clickable-date flow. +- The Android `addTask` request body must use proper JSON encoding (`kotlinx.serialization`), NOT manual string interpolation like `reschedule`/`complete` use — those two are safe because `id`/`source` are internal identifiers that can't contain `"` or `\`, but a task title is arbitrary user text and must be safely escaped. +- Server validates title non-empty independent of the client-side disabled-button check (defense at the trust boundary, not just the UI). + +--- + +### Task 1: Server — POST /api/widget/add endpoint + +**Files:** +- Modify: `internal/handlers/widget.go` +- Modify: `cmd/dashboard/main.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `Handler.HandleWidgetAdd(w http.ResponseWriter, r *http.Request)` +- Consumes: `h.store.CreateNativeTask(task models.Task) error` (existing), `newID() string` (existing, same package, `internal/handlers/handlers.go:24`) + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/handlers/widget_test.go`, after `TestHandleWidgetComplete_UnknownID_Returns404`: + +```go +// TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a +// title to /api/widget/add creates an undated native task the same way the +// web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON +// API instead of a session-authenticated HTML form. +func TestHandleWidgetAdd_CreatesTask(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":"Buy milk"}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + tasks, err := s.GetUndatedNativeTasks() + if err != nil { + t.Fatalf("failed to read back tasks: %v", err) + } + found := false + for _, task := range tasks { + if task.Content == "Buy milk" { + found = true + } + } + if !found { + t.Error("expected a task with content 'Buy milk' to have been created") + } +} + +func TestHandleWidgetAdd_EmptyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":""}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleWidgetAdd_WhitespaceOnlyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":" "}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetAdd -v` +Expected: compile error — `h.HandleWidgetAdd undefined (type *Handler has no field or method HandleWidgetAdd)`. + +- [ ] **Step 3: Implement `HandleWidgetAdd`** + +In `internal/handlers/widget.go`, add this type near the other request types (`widgetCompleteRequest`, `widgetRescheduleRequest`): + +```go +type widgetAddRequest struct { + Title string `json:"title"` +} +``` + +Add the handler after `HandleWidgetComplete` (at the end of the file, or directly after `HandleWidgetComplete`'s closing brace): + +```go +// HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet. +func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { + var req widgetAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + http.Error(w, "title is required", http.StatusBadRequest) + return + } + + task := models.Task{ + ID: newID(), + Content: title, + Priority: 1, + } + if err := h.store.CreateNativeTask(task); err != nil { + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} +``` + +`strings` is already imported in this file (used by `WidgetAuthMiddleware`'s `strings.HasPrefix`/`strings.TrimPrefix`) — no new import needed. `models` is already imported too. + +- [ ] **Step 4: Register the route** + +In `cmd/dashboard/main.go`, find: + +```go + r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) + r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) +``` + +Replace with: + +```go + r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) + r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) + r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetAdd -v` +Expected: PASS — all three new tests. + +- [ ] **Step 6: Run the full Go test suite, build, and gofmt check** + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure — confirm no new failures. The `go build ./...` must succeed cleanly since `cmd/dashboard/main.go` was touched. + +Run: `gofmt -l internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go +git commit -m "feat(widget): add POST /api/widget/add for quick-add" +``` + +--- + +### Task 2: Android — QuickAddActivity and widget button + +**Depends on:** Task 1 (for a meaningful on-device test; not a compile dependency). + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt` +- Create: `android/app/src/main/res/drawable/ic_add.xml` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` +- Create: `android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt` +- Modify: `android/app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `WidgetRepository.addTask(title: String): Result<Unit>` +- Produces: `QuickAddButton()` composable in `DootWidget.kt` +- Produces: `QuickAddActivity` (new Activity, must be declared in the manifest to be launchable) + +- [ ] **Step 1: Add `addTask` to `WidgetRepository`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add the `@Serializable` import and a small request data class at file scope (after the `private val json = ...` line), then the new method at the end of the `WidgetRepository` class body (after `complete`): + +```kotlin +package org.terst.doot.widget.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class WidgetAddRequest(val title: String) + +class WidgetRepository( + private val client: OkHttpClient, + private val serverUrl: String, + private val token: String +) { + /** Fetches /api/widget and returns the parsed response. Does NOT persist. */ + suspend fun fetchRaw(): Result<WidgetResponse> = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget") + .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<WidgetResponse>(body) + } + } + + /** Fetches and persists to DataStore. Call this from workers. */ + suspend fun fetchAndPersist(context: Context): Result<WidgetResponse> { + return fetchRaw().onSuccess { resp -> + context.dataStore.edit { prefs -> + prefs[Keys.ITEMS_JSON] = json.encodeToString(resp.items) + prefs[Keys.NOW] = resp.now + prefs[Keys.LAST_UPDATED] = System.currentTimeMillis() + } + } + } + + /** POSTs a due-date update to /api/widget/reschedule. */ + suspend fun reschedule(id: String, source: String, dateISO: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id","source":"$source","date":"$dateISO"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/reschedule") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** POSTs a task completion to /api/widget/complete. */ + suspend fun complete(context: Context, id: String, source: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id","source":"$source"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/complete") + .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. Uses proper JSON encoding + * (not manual string interpolation like reschedule/complete above) because + * the title is arbitrary user text that could contain characters that + * break hand-built JSON -- id/source above are safe because they're + * internal identifiers, never user-typed free text. + */ + suspend fun addTask(title: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(WidgetAddRequest(title)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/add") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } +} +``` + +- [ ] **Step 2: Add the `+` drawable** + +Create `android/app/src/main/res/drawable/ic_add.xml`: + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M12,5 L12,19 M5,12 L19,12" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" /> +</vector> +``` + +- [ ] **Step 3: Add `QuickAddButton` and wire it into the TODAY row** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find the "TODAY" header `Row` (added by the refresh-button feature): + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + RefreshButton(isRefreshing) + } +``` + +Replace with (adds `QuickAddButton()` before the refresh button): + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + QuickAddButton() + Spacer(modifier = GlanceModifier.width(4.dp)) + RefreshButton(isRefreshing) + } +``` + +Add the new composable directly after `RefreshButton` (find it — it was added by the refresh-button feature, right after `AllDayRow`): + +```kotlin +@Composable +fun QuickAddButton() { + val context = LocalContext.current + val intent = Intent(context, QuickAddActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionStartActivity(intent)), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_add), + contentDescription = "Add task", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} +``` + +No new imports needed — `Intent`, `LocalContext`, `actionStartActivity` are all already imported/used in this file (see `TaskRow`'s `detailIntent`). + +- [ ] **Step 4: Create `QuickAddActivity.kt`** + +Create `android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class QuickAddActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + QuickAddSheet( + onAdd = { title -> + lifecycleScope.launch { + val prefs = this@QuickAddActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.addTask(title).onSuccess { + repo.fetchAndPersist(this@QuickAddActivity) + DootWidget().updateAll(this@QuickAddActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickAddSheet( + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var title by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Text( + text = "Quick Add", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + placeholder = { Text("Task title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onAdd(title.trim()) }, + enabled = title.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Add", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} +``` + +- [ ] **Step 5: Declare `QuickAddActivity` in the manifest** + +In `android/app/src/main/AndroidManifest.xml`, find the existing `<activity android:name=".widget.ui.TaskDetailActivity" ...>` declaration and add a sibling entry for `QuickAddActivity` immediately after it, copying its exact attributes (theme, exported state, etc.) — read the existing `TaskDetailActivity` entry first to match its attributes exactly, then add: + +```xml + <activity + android:name=".widget.ui.QuickAddActivity" + android:exported="false" + android:theme="@style/Theme.Doot.Transparent" /> +``` + +(Use whatever exact `android:theme` and other attributes `TaskDetailActivity`'s entry uses — copy them verbatim rather than guessing, since this plan was written without reading the manifest directly. If `TaskDetailActivity`'s entry has additional attributes not shown above, e.g. `android:launchMode` or `android:windowSoftInputMode`, include those too — a text-entry sheet in particular likely wants `android:windowSoftInputMode="adjustResize"` if `TaskDetailActivity` doesn't already set it, so the keyboard doesn't cover the input field.) + +- [ ] **Step 6: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt \ + android/app/src/main/res/drawable/ic_add.xml \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt \ + android/app/src/main/AndroidManifest.xml +git commit -m "feat(widget): add quick-add button and entry sheet" +``` + +Manual on-device verification (release APK build, deploy, visual + interaction check, including keyboard behavior) is deferred to the controller. + +--- + +## Out of scope + +Recurrence display gets its own spec (the fifth and last feature). diff --git a/docs/superpowers/plans/2026-07-12-widget-recurrence.md b/docs/superpowers/plans/2026-07-12-widget-recurrence.md new file mode 100644 index 0000000..6c306a5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-recurrence.md @@ -0,0 +1,1103 @@ +# Widget Recurrence Display Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a human-readable recurrence schedule (e.g. "Repeats weekly on Monday") in a new event-detail popup, replacing calendar events' current behavior of jumping straight out to the Google Calendar app on tap. + +**Architecture:** Capture `RecurringEventId` from Google's API (available on event instances even though the RRULE itself is master-event-only) and thread it from `CalendarEvent` through `TimelineItem`/`WidgetItem` to the Android client. A new endpoint does the actual RRULE lookup+formatting lazily, on-demand, when a user opens a recurring event's new detail popup — not during the bulk timeline fetch. + +**Tech Stack:** Go (`internal/api`, `internal/models`, `internal/store`, `internal/handlers`, SQL migration), Kotlin/Jetpack Glance + Compose (Android widget). + +## Global Constraints + +- `RecurringEventID` is empty string for non-recurring events — never nil-vs-empty ambiguity in Go (it's a plain `string`, not `*string`); the Android side treats an empty/missing JSON value as "not recurring" via a nullable `String?`. +- `formatRecurrence` is intentionally NOT a full RFC 5545 parser — cover `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL`, `BYDAY` only; anything else falls back to the literal string `"Recurring event"`. +- The existing "open in Google Calendar" behavior must still be reachable (as a button in the new popup), not removed. +- `EventDetailActivity` is a new, separate Activity class — do not fold this into `TaskDetailActivity` or `QuickAddActivity`. + +--- + +### Task 1: Server — capture and forward RecurringEventID + +**Files:** +- Create: `migrations/022_calendar_events_recurring_id.sql` +- Modify: `internal/models/types.go` +- Modify: `internal/models/timeline.go` +- Modify: `internal/models/widget.go` +- Modify: `internal/store/sqlite.go` +- Modify: `internal/api/google_calendar.go` +- Modify: `internal/handlers/timeline_logic.go` +- Modify: `internal/handlers/widget.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `models.CalendarEvent.RecurringEventID string` +- Produces: `models.TimelineItem.RecurringEventID string` +- Produces: `models.WidgetItem.RecurringEventID string `json:"recurring_event_id,omitempty"`` + +- [ ] **Step 1: Create the migration** + +Create `migrations/022_calendar_events_recurring_id.sql`: + +```sql +ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT ''; +``` + +- [ ] **Step 2: Add `RecurringEventID` to `models.CalendarEvent`** + +In `internal/models/types.go`, find: + +```go +type CalendarEvent struct { + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + HTMLLink string `json:"html_link"` +} +``` + +Replace with: + +```go +type CalendarEvent struct { + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + HTMLLink string `json:"html_link"` + RecurringEventID string `json:"recurring_event_id,omitempty"` // empty = not a recurring instance +} +``` + +- [ ] **Step 3: Capture `RecurringEventId` in `GoogleCalendarClient`** + +In `internal/api/google_calendar.go`, find (in `GetUpcomingEvents`): + +```go + for _, item := range events.Items { + start, end := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + HTMLLink: item.HtmlLink, + }) + } +``` + +Replace with: + +```go + for _, item := range events.Items { + start, end := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, + }) + } +``` + +Then find the near-identical block in `GetEventsByDateRange`: + +```go + for _, item := range events.Items { + evtStart, evtEnd := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: evtStart, + End: evtEnd, + HTMLLink: item.HtmlLink, + }) + } +``` + +Replace with: + +```go + for _, item := range events.Items { + evtStart, evtEnd := c.parseEventTime(item) + allEvents = append(allEvents, models.CalendarEvent{ + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: evtStart, + End: evtEnd, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, + }) + } +``` + +Run `gofmt -w internal/api/google_calendar.go` after this step — the alignment in the two blocks above is deliberately loose (gofmt will fix column alignment automatically; don't hand-align it yourself). + +- [ ] **Step 4: Thread the column through the store** + +In `internal/store/sqlite.go`, find `SaveCalendarEvents`'s insert: + +```go + stmt, err := tx.Prepare(` + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link) + VALUES (?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + + for _, e := range events { + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink) + if err != nil { + return err + } + } +``` + +Replace with: + +```go + stmt, err := tx.Prepare(` + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + + for _, e := range events { + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID) + if err != nil { + return err + } + } +``` + +Then find `GetCalendarEventsByDateRange`: + +```go +func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) { + rows, err := s.db.Query(` + SELECT id, summary, description, start_time, end_time, html_link + FROM calendar_events + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time ASC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var events []models.CalendarEvent + for rows.Next() { + var e models.CalendarEvent + if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink); err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} +``` + +Replace with: + +```go +func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) { + rows, err := s.db.Query(` + SELECT id, summary, description, start_time, end_time, html_link, recurring_event_id + FROM calendar_events + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time ASC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var events []models.CalendarEvent + for rows.Next() { + var e models.CalendarEvent + if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink, &e.RecurringEventID); err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} +``` + +- [ ] **Step 5: Add `RecurringEventID` to `TimelineItem` and forward it in `BuildTimeline`** + +In `internal/models/timeline.go`, find: + +```go + // Source-specific metadata + ListID string `json:"list_id,omitempty"` // For Google Tasks +} +``` + +Replace with: + +```go + // Source-specific metadata + ListID string `json:"list_id,omitempty"` // For Google Tasks + RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events +} +``` + +In `internal/handlers/timeline_logic.go`, find the calendar-events block inside `BuildTimeline`: + +```go + for _, event := range events { + endTime := event.End + item := models.TimelineItem{ + ID: event.ID, + Type: models.TimelineItemTypeEvent, + Title: event.Summary, + Time: event.Start, + EndTime: &endTime, + Description: event.Description, + URL: event.HTMLLink, + OriginalItem: event, + IsCompleted: false, + Source: "calendar", + } + item.ComputeDaySection(now) + items = append(items, item) + } +``` + +Replace with: + +```go + for _, event := range events { + endTime := event.End + item := models.TimelineItem{ + ID: event.ID, + Type: models.TimelineItemTypeEvent, + Title: event.Summary, + Time: event.Start, + EndTime: &endTime, + Description: event.Description, + URL: event.HTMLLink, + OriginalItem: event, + IsCompleted: false, + Source: "calendar", + RecurringEventID: event.RecurringEventID, + } + item.ComputeDaySection(now) + items = append(items, item) + } +``` + +- [ ] **Step 6: Add `RecurringEventID` to `WidgetItem` and forward it** + +In `internal/models/widget.go`, add `RecurringEventID` to the struct (position among the other fields doesn't matter — add it after `DueDate`, before `URL`, or wherever the struct currently ends up after prior tasks land): + +```go + RecurringEventID string `json:"recurring_event_id,omitempty"` +``` + +In `internal/handlers/widget.go`'s `TimelineItemToWidgetItem`, add this forwarding line to the initial `wi := models.WidgetItem{...}` struct literal (alongside `IsAllDay`, `URL`, etc. — wherever that literal currently is after prior tasks land): + +```go + RecurringEventID: item.RecurringEventID, +``` + +(This is a straight passthrough regardless of item type — a task's `RecurringEventID` is always empty since only the calendar-event branch in `BuildTimeline` ever sets it, so there's no need for a type check here.) + +- [ ] **Step 7: Write and run tests** + +Add to `internal/handlers/widget_test.go`: + +```go +// TestTimelineItemToWidgetItem_ForwardsRecurringEventID proves the +// 2026-07-12 recurrence-display fix's data plumbing: a calendar event's +// RecurringEventId (captured from Google's API, which only puts the RRULE +// itself on the master event, not on expanded instances) must reach the +// client so it can look up the human-readable schedule on demand. +func TestTimelineItemToWidgetItem_ForwardsRecurringEventID(t *testing.T) { + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: time.Now(), + RecurringEventID: "master-123", + } + + wi := TimelineItemToWidgetItem(item) + + if wi.RecurringEventID != "master-123" { + t.Errorf("RecurringEventID = %q, want %q", wi.RecurringEventID, "master-123") + } +} + +func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing.T) { + item := models.TimelineItem{ + ID: "cal-2", + Title: "One-off meeting", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.RecurringEventID != "" { + t.Errorf("expected empty RecurringEventID for a non-recurring event, got %q", wi.RecurringEventID) + } +} +``` + +Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v` +Expected: PASS — all tests in this group, including the two new ones. + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure. + +Run: `gofmt -l internal/models/types.go internal/models/timeline.go internal/models/widget.go internal/store/sqlite.go internal/api/google_calendar.go internal/handlers/timeline_logic.go internal/handlers/widget.go internal/handlers/widget_test.go` +Expected: no output. + +- [ ] **Step 8: Commit** + +```bash +git add migrations/022_calendar_events_recurring_id.sql \ + internal/models/types.go internal/models/timeline.go internal/models/widget.go \ + internal/store/sqlite.go internal/api/google_calendar.go \ + internal/handlers/timeline_logic.go internal/handlers/widget.go internal/handlers/widget_test.go +git commit -m "feat(widget): capture and forward RecurringEventID for calendar events" +``` + +--- + +### Task 2: Server — recurrence lookup endpoint + +**Depends on:** Task 1 (uses `RecurringEventID`, which Task 1 introduces). + +**Files:** +- Modify: `internal/api/google_calendar.go` +- Modify: `internal/api/interfaces.go` +- Modify: `internal/handlers/widget.go` +- Modify: `cmd/dashboard/main.go` +- Test: `internal/api/google_calendar_test.go` (create if it doesn't exist) +- Test: `internal/handlers/widget_test.go` +- Test: `internal/handlers/timeline_logic_test.go` (adds a mock method) + +**Interfaces:** +- Produces: `formatRecurrence(rrules []string) string` (unexported, `internal/api` package) +- Produces: `GoogleCalendarClient.GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)` +- Produces: `GoogleCalendarAPI.GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)` (interface method) +- Produces: `Handler.HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request)` + +- [ ] **Step 1: Write the failing tests for `formatRecurrence`** + +Check whether `internal/api/google_calendar_test.go` already exists (`ls internal/api/`). If it doesn't, create it with this content; if it does, add the test function to it: + +```go +package api + +import "testing" + +func TestFormatRecurrence(t *testing.T) { + tests := []struct { + name string + rules []string + want string + }{ + {"weekly single day", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO"}, "Repeats weekly on Monday"}, + {"weekly multiple days", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"}, "Repeats weekly on Monday, Wednesday, Friday"}, + {"daily", []string{"RRULE:FREQ=DAILY"}, "Repeats daily"}, + {"monthly", []string{"RRULE:FREQ=MONTHLY"}, "Repeats monthly"}, + {"yearly", []string{"RRULE:FREQ=YEARLY"}, "Repeats yearly"}, + {"interval weekly", []string{"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU"}, "Repeats every 2 weeks on Sunday"}, + {"interval daily", []string{"RRULE:FREQ=DAILY;INTERVAL=3"}, "Repeats every 3 days"}, + {"unrecognized frequency", []string{"RRULE:FREQ=HOURLY"}, "Recurring event"}, + {"empty", []string{}, "Recurring event"}, + {"unparseable", []string{"not a valid rule"}, "Recurring event"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatRecurrence(tc.rules) + if got != tc.want { + t.Errorf("formatRecurrence(%v) = %q, want %q", tc.rules, got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/api/ -run TestFormatRecurrence -v` +Expected: compile error — `undefined: formatRecurrence`. + +- [ ] **Step 3: Implement `formatRecurrence`** + +Add to `internal/api/google_calendar.go` (anywhere at package level, e.g. after `deduplicateEvents`): + +```go +var recurrenceFreqNames = map[string]string{ + "DAILY": "daily", + "WEEKLY": "weekly", + "MONTHLY": "monthly", + "YEARLY": "yearly", +} + +var recurrenceFreqUnits = map[string]string{ + "DAILY": "day", + "WEEKLY": "week", + "MONTHLY": "month", + "YEARLY": "year", +} + +var recurrenceWeekdayNames = map[string]string{ + "SU": "Sunday", + "MO": "Monday", + "TU": "Tuesday", + "WE": "Wednesday", + "TH": "Thursday", + "FR": "Friday", + "SA": "Saturday", +} + +// formatRecurrence turns Google Calendar RRULE strings into short English. +// This intentionally covers only the common cases (FREQ, INTERVAL, BYDAY) -- +// not a full RFC 5545 parser. Anything it can't confidently describe falls +// back to "Recurring event" rather than showing nothing or an error. +func formatRecurrence(rrules []string) string { + for _, rule := range rrules { + rule = strings.TrimPrefix(rule, "RRULE:") + parts := make(map[string]string) + for _, kv := range strings.Split(rule, ";") { + pieces := strings.SplitN(kv, "=", 2) + if len(pieces) == 2 { + parts[pieces[0]] = pieces[1] + } + } + + freqKey := parts["FREQ"] + unit, ok := recurrenceFreqUnits[freqKey] + if !ok { + continue + } + + interval := 1 + if iv := parts["INTERVAL"]; iv != "" { + if n, err := strconv.Atoi(iv); err == nil && n > 0 { + interval = n + } + } + + var phrase string + if interval == 1 { + phrase = "Repeats " + recurrenceFreqNames[freqKey] + } else { + phrase = fmt.Sprintf("Repeats every %d %ss", interval, unit) + } + + if byday := parts["BYDAY"]; byday != "" { + var days []string + for _, code := range strings.Split(byday, ",") { + code = strings.TrimSpace(code) + if len(code) >= 2 { + code = code[len(code)-2:] + } + if name, ok := recurrenceWeekdayNames[code]; ok { + days = append(days, name) + } + } + if len(days) > 0 { + phrase += " on " + strings.Join(days, ", ") + } + } + + return phrase + } + return "Recurring event" +} +``` + +Add `"strconv"` to this file's import block (it currently imports `"context"`, `"fmt"`, `"log"`, `"sort"`, `"strings"`, `"time"`, plus the two `google.golang.org/api/...` packages — `strconv` is new, `fmt`/`strings` already exist). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/api/ -run TestFormatRecurrence -v` +Expected: PASS — all 10 subtests. + +- [ ] **Step 5: Implement `GetRecurrenceRule` and add it to the interface + mock** + +In `internal/api/google_calendar.go`, add this method after `GetCalendarList`: + +```go +// GetRecurrenceRule looks up a recurring event's master record and returns +// its formatted recurrence schedule. Google's API only puts the RRULE on +// the master event, not on expanded instances (see parseEventTime's +// SingleEvents(true) callers), so this does a live lookup by the instance's +// RecurringEventId. There's no per-event calendar attribution stored today +// (events from all configured calendars are merged without recording which +// one they came from), so this tries each configured calendar in turn. +func (c *GoogleCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + for _, calendarID := range c.calendarIDs { + event, err := c.srv.Events.Get(calendarID, recurringEventID).Do() + if err != nil { + continue + } + return formatRecurrence(event.Recurrence), nil + } + return "", fmt.Errorf("recurring event %s not found on any configured calendar", recurringEventID) +} +``` + +In `internal/api/interfaces.go`, find: + +```go +type GoogleCalendarAPI interface { + GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) + GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) + GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) + SetCalendarIDs(ids []string) +} +``` + +Replace with: + +```go +type GoogleCalendarAPI interface { + GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) + GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) + GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) + SetCalendarIDs(ids []string) + GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) +} +``` + +In `internal/handlers/timeline_logic_test.go`, find `MockCalendarClient`'s method set (`GetUpcomingEvents`, `GetEventsByDateRange`, `GetCalendarList`, `SetCalendarIDs`) and add a new field plus mock method so the mock still satisfies the interface: + +```go +// MockCalendarClient implements GoogleCalendarAPI interface for testing +type MockCalendarClient struct { + Events []models.CalendarEvent + Err error + // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called + // with, for tests that need to assert on how the caller resolved its + // calendar ID list (e.g. fetchCalendarEvents' comma-split fallback). + SetCalendarIDsCalls [][]string + // RecurrenceRule is returned by GetRecurrenceRule for any id when RecurrenceErr is nil. + RecurrenceRule string + RecurrenceErr error +} +``` + +Add the method (anywhere among the other `MockCalendarClient` methods): + +```go +func (m *MockCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + return m.RecurrenceRule, m.RecurrenceErr +} +``` + +- [ ] **Step 6: Write the failing handler test** + +Add to `internal/handlers/widget_test.go`: + +```go +// TestHandleWidgetRecurrence_ReturnsFormattedSchedule proves the recurrence +// lookup endpoint: given a recurring_event_id query param, it calls the +// calendar client's GetRecurrenceRule and returns the formatted text. +func TestHandleWidgetRecurrence_ReturnsFormattedSchedule(t *testing.T) { + mock := &MockCalendarClient{RecurrenceRule: "Repeats weekly on Monday"} + h := &Handler{googleCalendarClient: mock} + req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=master-1", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp struct { + Recurrence string `json:"recurrence"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Recurrence != "Repeats weekly on Monday" { + t.Errorf("recurrence = %q, want %q", resp.Recurrence, "Repeats weekly on Monday") + } +} + +func TestHandleWidgetRecurrence_NotFound_Returns404(t *testing.T) { + mock := &MockCalendarClient{RecurrenceErr: fmt.Errorf("not found")} + h := &Handler{googleCalendarClient: mock} + req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=missing", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestHandleWidgetRecurrence_MissingParam_Returns400(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest("GET", "/api/widget/recurrence", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} +``` + +Add `"fmt"` to `widget_test.go`'s imports if not already present (check the existing import block first — if `fmt` is already imported, don't add a duplicate). + +- [ ] **Step 7: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetRecurrence -v` +Expected: compile error — `h.HandleWidgetRecurrence undefined`. + +- [ ] **Step 8: Implement `HandleWidgetRecurrence`** + +Add to `internal/handlers/widget.go`, after `HandleWidgetComplete`: + +```go +// HandleWidgetRecurrence looks up and formats a recurring calendar event's schedule. +func (h *Handler) HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request) { + recurringEventID := r.URL.Query().Get("recurring_event_id") + if recurringEventID == "" { + http.Error(w, "recurring_event_id is required", http.StatusBadRequest) + return + } + + recurrence, err := h.googleCalendarClient.GetRecurrenceRule(r.Context(), recurringEventID) + if err != nil { + http.Error(w, "recurring event not found", http.StatusNotFound) + return + } + + resp := struct { + Recurrence string `json:"recurrence"` + }{Recurrence: recurrence} + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} +``` + +Check `internal/handlers/handlers.go` (or wherever `Handler` is defined) for the exact field name of the calendar client on `Handler` — the code above assumes `h.googleCalendarClient` based on the existing `googleCalendarClient` field name already used in `timeline_logic_test.go`'s `Handler{googleCalendarClient: failingCal}` construction (see `TestFetchCalendarEvents_CacheFallbackOnAPIError`). If the actual field name differs, use the real one instead. + +- [ ] **Step 9: Register the route** + +In `cmd/dashboard/main.go`, find (after Task 1 of the quick-add feature, if that's landed, the block will have 4 lines instead of 3 — either way, find the `/api/widget/reschedule` line and add after it): + +```go + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) +``` + +Add immediately after it (order relative to `/api/widget/add`, if present, doesn't matter): + +```go + r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence) +``` + +- [ ] **Step 10: Run tests to verify they pass, then full suite + gofmt** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetRecurrence -v` +Expected: PASS — all three new tests. + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures plus the pre-existing vet failure. + +Run: `gofmt -l internal/api/google_calendar.go internal/api/interfaces.go internal/api/google_calendar_test.go internal/handlers/widget.go internal/handlers/widget_test.go internal/handlers/timeline_logic_test.go cmd/dashboard/main.go` +Expected: no output. + +- [ ] **Step 11: Commit** + +```bash +git add internal/api/google_calendar.go internal/api/interfaces.go internal/api/google_calendar_test.go \ + internal/handlers/widget.go internal/handlers/widget_test.go internal/handlers/timeline_logic_test.go \ + cmd/dashboard/main.go +git commit -m "feat(widget): add recurrence lookup endpoint (GET /api/widget/recurrence)" +``` + +--- + +### Task 3: Android — event detail popup with recurrence + +**Depends on:** Task 2 (for a meaningful on-device test; not a compile dependency). + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt` +- Create: `android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` +- Modify: `android/app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `WidgetItem.recurringEventId: String?` +- Produces: `WidgetRepository.getRecurrence(recurringEventId: String): Result<String>` +- Produces: `EventDetailActivity` (new Activity) + +- [ ] **Step 1: Add `recurringEventId` to the Android `WidgetItem`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`, add the field (position among the other fields doesn't matter): + +```kotlin + @SerialName("recurring_event_id") val recurringEventId: String? = null, +``` + +- [ ] **Step 2: Add `getRecurrence` to `WidgetRepository`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add this method to the class (after whichever methods prior features already added — `addTask` if quick-add landed first, or after `complete` otherwise): + +```kotlin + /** GETs the formatted recurrence schedule for a recurring event. */ + suspend fun getRecurrence(recurringEventId: String): Result<String> = + withContext(Dispatchers.IO) { + val encodedId = java.net.URLEncoder.encode(recurringEventId, "UTF-8") + val request = Request.Builder() + .url("$serverUrl/api/widget/recurrence?recurring_event_id=$encodedId") + .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" } + val parsed = json.decodeFromString<RecurrenceResponse>(body) + parsed.recurrence + } + } +``` + +Add this small response data class at file scope (alongside any existing file-scope classes like `WidgetAddRequest`, if quick-add landed first — otherwise just above the `WidgetRepository` class): + +```kotlin +@Serializable +private data class RecurrenceResponse(val recurrence: String) +``` + +- [ ] **Step 3: Create `EventDetailActivity.kt`** + +Create `android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class EventDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val source = intent.getStringExtra(EXTRA_SOURCE) ?: "calendar" + val url = intent.getStringExtra(EXTRA_URL) ?: "" + val recurringEventId = intent.getStringExtra(EXTRA_RECURRING_EVENT_ID) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + EventDetailSheet( + title = title, + source = source, + recurringEventId = recurringEventId, + onOpenCalendar = { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })) + ) + finish() + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_TITLE = "event_title" + const val EXTRA_SOURCE = "event_source" + const val EXTRA_URL = "event_url" + const val EXTRA_RECURRING_EVENT_ID = "event_recurring_id" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + title: String, + source: String, + recurringEventId: String?, + onOpenCalendar: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var recurrenceText by remember { mutableStateOf<String?>(null) } + + LaunchedEffect(recurringEventId) { + if (recurringEventId == null) return@LaunchedEffect + recurrenceText = "Loading…" + val prefs = context.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@LaunchedEffect + val token = prefs[Keys.TOKEN] ?: return@LaunchedEffect + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.getRecurrence(recurringEventId).onSuccess { text -> + recurrenceText = text + }.onFailure { + recurrenceText = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + recurrenceText?.let { text -> + Spacer(Modifier.height(8.dp)) + Text( + text = text, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + Spacer(Modifier.height(20.dp)) + OutlinedButton( + onClick = onOpenCalendar, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text("Open in Calendar", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} +``` + +Note: `recurrenceText` starts `null` (renders no recurrence line at all) and only becomes non-null if `recurringEventId != null` — a non-recurring event never shows "Loading…" or any recurrence text, per the design's requirement that a non-recurring event show no recurrence line, not an empty one. If the fetch fails, it falls back to `null` (silently hides the line) rather than showing an error state, since recurrence info is supplementary, not the primary purpose of the popup — the title and "Open in Calendar" button remain fully functional either way. + +- [ ] **Step 4: Wire `AllDayRow`, `EventBlock`, and `TomorrowEventRow` to open `EventDetailActivity`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, all three composables currently build their `clickable` modifier inline as `.clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" }))))`. Replace each with a two-step: build an `Intent` for `EventDetailActivity` carrying the event's data, then use that in `clickable`. + +For `AllDayRow` (find the composable, note it currently takes `event: WidgetItem` as its only parameter): + +```kotlin +@Composable +fun AllDayRow(event: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} + Text( + text = event.title, + style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} +``` + +For `EventBlock` (takes `event: WidgetItem, isPast: Boolean`): + +```kotlin +@Composable +fun EventBlock(event: WidgetItem, isPast: Boolean) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + val alpha = if (isPast) 0.5f else 1f + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = GlanceModifier + .width(3.dp) + .height(20.dp) + .background(color.copy(alpha = alpha)) + ) {} + Text( + text = event.title, + style = TextStyle( + color = ColorProvider(Color.White.copy(alpha = alpha)), + fontSize = 14.sp + ), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} +``` + +For `TomorrowEventRow` (takes `event: WidgetItem, zone: ZoneId`): + +```kotlin +@Composable +fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val color = sourceColor(event.source) + val timeLabel = event.start?.let { + val t = Instant.parse(it).atZone(zone) + hourLabel(t.hour) + if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" + } ?: "" + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(detailIntent)), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = timeLabel, + style = TextStyle(color = ColorProvider(Color(0x4DFFFFFF)), fontSize = 10.sp), + modifier = GlanceModifier.width(32.dp) + ) + Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} + Text( + text = event.title, + style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.75f)), fontSize = 13.sp), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} +``` + +`Intent` and `Uri` imports already exist in this file (used by the code being replaced); `LocalContext` is already imported (used by `TaskRow`). + +- [ ] **Step 5: Declare `EventDetailActivity` in the manifest** + +In `android/app/src/main/AndroidManifest.xml`, add an entry for `EventDetailActivity` copying `TaskDetailActivity`'s (or `QuickAddActivity`'s, if quick-add landed first) exact attributes — same approach as the quick-add feature's Step 5: + +```xml + <activity + android:name=".widget.ui.EventDetailActivity" + android:exported="false" + android:theme="@style/Theme.Doot.Transparent" /> +``` + +- [ ] **Step 6: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt \ + android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \ + android/app/src/main/AndroidManifest.xml +git commit -m "feat(widget): add event detail popup showing recurrence schedule" +``` + +Manual on-device verification (release APK build, deploy, tap a recurring event and a one-off event, confirm the recurrence line appears only for the recurring one and the wording is sensible, confirm "Open in Calendar" still works) is deferred to the controller. + +--- + +## Out of scope + +This is the last of the five widget features in this batch. diff --git a/docs/superpowers/plans/2026-07-12-widget-refresh-button.md b/docs/superpowers/plans/2026-07-12-widget-refresh-button.md new file mode 100644 index 0000000..9cd5e59 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-refresh-button.md @@ -0,0 +1,362 @@ +# Widget Refresh Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a tappable refresh icon to the doot Android widget that immediately triggers a fetch, with a static "loading" icon shown while it's in flight. + +**Architecture:** A new `RefreshTaskAction` (Glance `ActionCallback`) sets a `IS_REFRESHING` DataStore flag and redraws the widget synchronously (so the icon flips instantly), then enqueues the existing `RefreshWorker.runOnce()`. `RefreshWorker` is restructured so that, regardless of outcome (success, failure, or the early "no url/token configured" return), it always clears the flag and redraws the widget once at the end — so the icon never gets stuck in the loading state. + +**Tech Stack:** Kotlin, Jetpack Glance (`GlanceAppWidget`, `RemoteViews`-backed), AndroidX `WorkManager`, AndroidX `DataStore<Preferences>`. + +## Global Constraints + +- RemoteViews cannot animate a rotating icon — the "spinner" is a static icon swap (`ic_refresh` ↔ `ic_refresh_loading`), confirmed acceptable in the design spec. +- Follow the existing fire-and-forget pattern used by `CompleteTaskAction`/`CompleteWorker`: actions enqueue `WorkManager` work and return immediately; workers call `DootWidget().updateAll()` when done. +- No new automated tests: this is Glance composition + `WorkManager` wiring, the same category of code as the rest of `DootWidget.kt` and `*Worker.kt`, none of which have unit tests today. Verification is a manual build-install-tap cycle, described exactly in Task 1 Step 8. + +--- + +### Task 1: Refresh button end-to-end (data flag, drawables, action, worker, UI) + +**Why one task:** None of these pieces are independently testable in isolation — the action needs the flag and drawables to mean anything, the worker's flag-clearing has no observable effect without the UI reading the flag, and the UI has nothing to tap without the action. This is a single vertical slice with one real deliverable: tap the icon, see it load, see it revert. + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt` +- Create: `android/app/src/main/res/drawable/ic_refresh.xml` +- Create: `android/app/src/main/res/drawable/ic_refresh_loading.xml` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` + +**Interfaces:** +- Produces: `Keys.IS_REFRESHING: Preferences.Key<Boolean>` +- Produces: `RefreshTaskAction : ActionCallback` (no parameters, `actionRunCallback<RefreshTaskAction>()`) +- Produces: `RefreshButton(isRefreshing: Boolean)` composable in `DootWidget.kt` +- Modifies: `WidgetRoot(items: List<WidgetItem>, now: Instant)` → `WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean)` — the sole caller, `DootWidget.provideGlance()`, is updated in the same step. + +- [ ] **Step 1: Add the `IS_REFRESHING` DataStore key** + +Edit `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt`. Add the `booleanPreferencesKey` import and the new key: + +```kotlin +package org.terst.doot.widget.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore + +val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "doot_widget") + +object Keys { + val SERVER_URL = stringPreferencesKey("server_url") + val TOKEN = stringPreferencesKey("token") + val ITEMS_JSON = stringPreferencesKey("items_json") + val NOW = stringPreferencesKey("now") + val LAST_UPDATED = longPreferencesKey("last_updated") + val IS_REFRESHING = booleanPreferencesKey("is_refreshing") +} +``` + +- [ ] **Step 2: Add the two drawables** + +Create `android/app/src/main/res/drawable/ic_refresh.xml` (idle state — circular arrow, same 24dp/stroke conventions as the existing `ic_checkbox_empty.xml`): + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4,12 A8,8 0 1,1 7,18" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + <path + android:pathData="M4,12 L4,7 L9,7" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> +``` + +Create `android/app/src/main/res/drawable/ic_refresh_loading.xml` (loading state — three dots, visually distinct from the idle icon): + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M5,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M12,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M19,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> +</vector> +``` + +- [ ] **Step 3: Add `RefreshTaskAction` to `Actions.kt`** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.updateAll +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.work.CompleteWorker +import org.terst.doot.widget.work.RefreshWorker + +class CompleteTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val id = parameters[idKey] ?: return + val source = parameters[sourceKey] ?: return + CompleteWorker.enqueue(context, id, source) + } + + companion object { + val idKey = ActionParameters.Key<String>("item_id") + val sourceKey = ActionParameters.Key<String>("item_source") + } +} + +// 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 +// never gets stuck. +class RefreshTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + context.dataStore.edit { it[Keys.IS_REFRESHING] = true } + DootWidget().updateAll(context) + RefreshWorker.runOnce(context) + } +} +``` + +- [ ] **Step 4: Restructure `RefreshWorker.doWork()` to always clear the flag** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt`: + +```kotlin +package org.terst.doot.widget.work + +import android.content.Context +import androidx.datastore.preferences.core.edit +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 +import java.util.concurrent.TimeUnit + +class RefreshWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val result = performRefresh() + applicationContext.dataStore.edit { it[Keys.IS_REFRESHING] = false } + DootWidget().updateAll(applicationContext) + return result + } + + private suspend fun performRefresh(): Result { + 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.fetchAndPersist(applicationContext).fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() } + ) + } + + companion object { + const val WORK_NAME = "doot_widget_refresh" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder<RefreshWorker>(15, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + fun runOnce(context: Context) { + val request = OneTimeWorkRequestBuilder<RefreshWorker>().build() + WorkManager.getInstance(context).enqueue(request) + } + } +} +``` + +Note: this drops the old success-branch's own `DootWidget().updateAll()` call, since `doWork()` now always calls it exactly once after `performRefresh()` returns, on every path (success, retry, or the early-return failures from missing url/token). + +- [ ] **Step 5: Wire `isRefreshing` through `DootWidget.provideGlance()` and `WidgetRoot`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, change `provideGlance`: + +```kotlin + override suspend fun provideGlance(context: Context, id: GlanceId) { + val prefs = context.dataStore.data.first() + val items = parseItems(prefs) + val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: Instant.now() + val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false + + provideContent { + WidgetRoot(items, now, isRefreshing) + } + } +``` + +Change the `WidgetRoot` signature and its "TODAY" header `Row` (this is the top of the function body — the `allDayEvents`/`rest`/etc. computation below is unchanged): + +```kotlin +@Composable +fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean) { +``` + +Then find this block inside `WidgetRoot`: + +```kotlin + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + ) + } +``` + +Replace it with: + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + RefreshButton(isRefreshing) + } +``` + +- [ ] **Step 6: Add the `RefreshButton` composable** + +In the same file, add this new composable directly after the existing `AllDayRow` composable (so it sits near the other small icon/row composables): + +```kotlin +@Composable +fun RefreshButton(isRefreshing: Boolean) { + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionRunCallback<RefreshTaskAction>()), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider( + if (isRefreshing) org.terst.doot.widget.R.drawable.ic_refresh_loading + else org.terst.doot.widget.R.drawable.ic_refresh + ), + contentDescription = if (isRefreshing) "Refreshing" else "Refresh", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} +``` + +No new imports needed — `actionRunCallback`, `Box`, `Image`, `ImageProvider`, `ColorFilter`, `ColorProvider`, `GlanceModifier`, `Color`, `Alignment` are all already imported in this file (used by `TaskRow`'s checkbox, which this mirrors). + +- [ ] **Step 7: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. If it fails, the error output will name the file/line — common mistakes here are a missing import or a typo in the drawable resource names (`R.drawable.ic_refresh` / `R.drawable.ic_refresh_loading` must exactly match the two file names from Step 2, minus `.xml`). + +- [ ] **Step 8: Manual verification on device** + +Build and install the release APK the same way prior widget fixes in this session were verified: + +```bash +cd android && ./gradlew assembleRelease +cp app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk +chown www-data:www-data /site/static.terst.org/public/files/doot-widget.apk +``` + +Then on the device: reinstall the widget's APK, open the widget, and confirm: +1. The refresh icon is visible in the top-right of the "TODAY" row. +2. Tapping it immediately swaps the icon to the loading (three-dot) glyph — no delay, no network wait. +3. Within a few seconds (once the fetch completes), the icon swaps back to the idle refresh icon. +4. The widget's item list reflects freshly-fetched data (e.g. toggle airplane mode off/on between taps if you want to force a visible content change, or just confirm no crash and the icon cycle completes). +5. Turn off wifi/data entirely, tap refresh: confirm the icon still reverts to idle after `WorkManager`'s constraint check fails to satisfy `NetworkType.CONNECTED` and the retry backs off — it must not get stuck on the loading icon indefinitely. (`RefreshWorker` has no explicit network constraint on `runOnce()`'s one-time request, only `schedule()`'s periodic request does — so this checks the underlying `fetchAndPersist()` call's own failure path via `Result.retry()`, not a WorkManager constraint block.) + +- [ ] **Step 9: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt \ + android/app/src/main/res/drawable/ic_refresh.xml \ + android/app/src/main/res/drawable/ic_refresh_loading.xml \ + android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt \ + android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +git commit -m "feat(widget): add manual refresh button with loading-state icon" +``` + +--- + +## Out of scope + +The other four widget features (quick add, clickable-date reschedule, recurrence display, overdue badge) — each gets its own spec and plan, per the design doc's sequencing (refresh → overdue badge → clickable reschedule → quick add → recurrence). diff --git a/docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md b/docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md new file mode 100644 index 0000000..c456908 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-clickable-reschedule-design.md @@ -0,0 +1,41 @@ +# Widget Clickable Date/Time Reschedule — Design + +## Context + +Third of five widget features (refresh → overdue badge → **clickable reschedule** → quick add → recurrence). Today `TaskDetailSheet` (the bottom-sheet popup opened by tapping a task row) shows a separate "Reschedule" `OutlinedButton` for doot tasks, which opens a `DatePickerDialog` — but the popup never displays the task's *current* due date anywhere. The user wants the due-date value itself to be the clickable element, replacing the standalone button. + +Decided without a user Q&A round (per explicit instruction to proceed autonomously). + +## Key finding from grounding + +`WidgetItem.start` is deliberately `null` for every doot task regardless of whether it has a real due date — this is intentional, existing behavior from the 2026-07-12 all-day-pinning fix (`TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior`): doot tasks are "floating" items positioned by the client's `SlotPacker`, and `start` is reserved for that scheduling/positioning role. There is currently **no field carrying a doot task's raw due date to the Android client at all** — `TaskRow`'s `Intent` extras don't include it either. This must be added; it doesn't already exist under a different name. + +## Design + +**Server (Go):** add a new field, independent of `Start`/`IsAllDay` semantics, so this change cannot regress the floating-task positioning behavior the prior fix protects: +- `models.WidgetItem` gains `DueDate *time.Time `json:"due_date,omitempty"``. +- `TimelineItemToWidgetItem`: when `item.Type == TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero()`, set `wi.DueDate = &item.Time`. This is the *only* new condition — it does not touch the existing `Start`/`End` logic at all. + +**Android — data layer:** +- `WidgetItem.kt` gains `@SerialName("due_date") val dueDate: String? = null`. + +**Android — passing the value into the popup:** +- `TaskRow`'s `detailIntent` (`DootWidget.kt`) gains `putExtra(TaskDetailActivity.EXTRA_DUE_DATE, task.dueDate)` (nullable string extra). +- `TaskDetailActivity.onCreate` reads it: `val dueDate = intent.getStringExtra(EXTRA_DUE_DATE)`, passes to `TaskDetailSheet`. + +**Android — UI:** +- `TaskDetailSheet` gains a `dueDate: String?` parameter. +- The existing "Reschedule" `OutlinedButton` (only ever shown for `source == "doot"`) is replaced by a tappable row: an outlined `Row` styled like the current button (same border/shape/padding) containing formatted text — + - If `dueDate != null`: parse and format as `"Due " + MMM d` (e.g. "Due Jul 15"), using `java.time.LocalDate`/`DateTimeFormatter` (already available; `TaskDetailActivity.kt` already imports `java.util.Calendar`/`TimeZone` for the existing picker, this adds the modern `java.time` formatter alongside it). + - If `dueDate == null`: show `"No due date · tap to schedule"`. + - Tapping the row opens the same `DatePickerDialog` that exists today (unchanged picker logic) — only the trigger element changes from a button labeled "Reschedule" to this date-display row. +- No time-of-day editing: the server's reschedule endpoint (`HandleWidgetReschedule`) only accepts a `YYYY-MM-DD` date and sets the task to midnight — this was already true before this feature and stays true. "date/time" in the request is read as "the current due-date value that's displayed," not a request for new time-granularity editing, since doot tasks don't carry time-of-day today (confirmed: every existing `due_date` in the production DB is midnight-valued). + +## Testing + +- Server: unit test for `TimelineItemToWidgetItem` confirming `DueDate` is set for a doot task with a real due date, and confirming it's still `nil` for (a) a doot task with no due date and (b) a non-doot item (e.g. a calendar event), so this can't leak into other item types. +- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check. + +## Out of scope + +Quick add and recurrence display — each gets its own spec. diff --git a/docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md b/docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md new file mode 100644 index 0000000..db8cdce --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-overdue-badge-design.md @@ -0,0 +1,30 @@ +# Widget Overdue Badge — Design + +## Context + +Second of five widget features (refresh → **overdue badge** → clickable reschedule → quick add → recurrence). Direct follow-on from the 2026-07-12 fix (commit `f01b292`) that made overdue native tasks appear in the widget at all: `TimelineItem.IsOverdue` is computed server-side but never reaches the client. Today all 10 previously-invisible overdue tasks render identically to a normal task due today — same color, same style, no way to tell "this is 12 days late" from "this is due today." + +Decided without a user Q&A round (per explicit instruction to proceed autonomously); the choices below are the minimal, lowest-risk options consistent with existing patterns in the codebase. + +## Goal + +Visually distinguish overdue tasks from normal tasks in the widget. + +## Design + +**Server (Go):** +- `models.WidgetItem` (`internal/models/widget.go`) gains `IsOverdue bool `json:"is_overdue"``. +- `TimelineItemToWidgetItem` (`internal/handlers/widget.go`) sets `wi.IsOverdue = item.IsOverdue` — `TimelineItem.IsOverdue` is already computed correctly by `ComputeDaySection` (confirmed by the prior fix), this just forwards the existing field. + +**Android (Kotlin):** +- `WidgetItem.kt` gains `@SerialName("is_overdue") val isOverdue: Boolean = false`. +- `TaskRow` (`DootWidget.kt`) is the single rendering path for every task (today's floating queue, tomorrow's section, and now overdue tasks all flow through it — confirmed by grounding: `TomorrowSection` and the floating-task fragments both call `TaskRow`). When `task.isOverdue == true`, the title `Text` uses a warning color (`Color(0xFFF87171)` — a soft red, distinct from the existing grey `0xFFDDDDDD` and from every `sourceColor` value) instead of the default grey. No other layout change — same row height, same checkbox, same tap targets. + +**Why title-color instead of a new badge/icon/label:** the widget is already dense (hour grid + floating queue + tomorrow section); every existing "this is special" signal in this file is done via color (see `sourceColor`, `AllDayRow`'s colored bar, `EventBlock`'s past-event alpha dimming) rather than added text or icons. A color change is the lowest-risk, most consistent option and needs no new layout space. + +**Out of scope:** No "N days overdue" text, no sort-order change (overdue tasks already interleave into the floating queue via `SlotPacker` same as any other floating task — leaving that alone, this feature is purely visual). + +## Testing + +- Server: unit test for `TimelineItemToWidgetItem` asserting `IsOverdue` is forwarded (mirrors the existing `TestTimelineItemToWidgetItem_AllDayEvent` pattern in `widget_test.go`). +- Android: no unit-test surface (consistent with all other `DootWidget.kt` changes this session) — verified by build + manual on-device check, deferred to the controller same as Feature 1's Step 8. diff --git a/docs/superpowers/specs/2026-07-12-widget-quick-add-design.md b/docs/superpowers/specs/2026-07-12-widget-quick-add-design.md new file mode 100644 index 0000000..a025554 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-quick-add-design.md @@ -0,0 +1,35 @@ +# Widget Quick Add — Design + +## Context + +Fourth of five widget features (refresh → overdue badge → clickable reschedule → **quick add** → recurrence). Decided without a user Q&A round (per explicit instruction to proceed autonomously). + +## Key constraint from grounding + +Jetpack Glance / RemoteViews widgets do not support a reliable in-widget text-entry field across launchers and Android versions. Every existing interactive flow in this widget that needs more than a tap (reschedule's date picker) already follows the same pattern: tap a widget element → launch a full `ComponentActivity` with a Compose `ModalBottomSheet` → do the real work there → close. Quick add follows the identical pattern rather than attempting in-widget text input. + +A reusable server-side creation path already exists for the web UI: `HandleUnifiedAdd` (`internal/handlers/handlers.go:608`) creates a `models.Task` via `h.store.CreateNativeTask`. It's form-encoded and behind session/cookie auth, not the widget's bearer-token JSON API, so it isn't directly reusable from the widget client — but the underlying `CreateNativeTask` call is the same one this feature will use, just from a new bearer-token-protected JSON endpoint that mirrors the existing `/api/widget/complete` and `/api/widget/reschedule` handlers. + +## Design + +**Server (Go):** +- New handler `HandleWidgetAdd` in `internal/handlers/widget.go`, matching the shape of `HandleWidgetComplete`: decode a JSON body `{"title": "..."}`, reject empty/whitespace-only titles with 400, create an undated doot task (`models.Task{ID: newID(), Content: title, Priority: 1}` — no due date; a quick-add task starts in the same "undated/floating" bucket as any other undated doot task), call `h.store.CreateNativeTask`, return 200 on success or 500 on a store error. +- New route in `cmd/dashboard/main.go`, alongside the other widget routes: `r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd)`. + +**Android — data layer:** +- `WidgetRepository` gains `suspend fun addTask(title: String): Result<Unit>`, POSTing to `/api/widget/add`. +- Unlike `reschedule`/`complete` (which manually string-interpolate their JSON bodies from internal IDs that can't contain special characters), `addTask` takes arbitrary free-text user input, so it must not use manual string interpolation — a title containing a `"` or `\` would produce invalid or unsafe JSON. It uses a small `@Serializable data class WidgetAddRequest(val title: String)` encoded via the file's existing `json` (`kotlinx.serialization.json.Json`) instance instead. + +**Android — UI:** +- New composable `QuickAddButton()` in `DootWidget.kt`, same 24dp tap-target pattern as `RefreshButton`, placed in the "TODAY" header row next to the refresh button (order: `Text("TODAY")` — `defaultWeight()` — `QuickAddButton()` — `RefreshButton(isRefreshing)`). Uses a new `ic_add.xml` drawable (24dp plus-sign vector, same stroke style as the other icons). +- Tapping it launches a new `QuickAddActivity : ComponentActivity`, structurally a near-twin of `TaskDetailActivity`: a `ModalBottomSheet` containing a `TextField` (title input, autofocus) and an "Add" `Button`. Submitting: calls `WidgetRepository.addTask(title)`, and on success, `fetchAndPersist` + `DootWidget().updateAll()` + `finish()`, matching the existing reschedule success flow exactly. Empty/blank titles disable the "Add" button (no server round-trip for obviously-invalid input — the server still validates independently as the source of truth). +- `QuickAddActivity` is a separate class from `TaskDetailActivity` (not a mode flag on the existing one) — they have different triggers (widget button vs. task row tap), different required inputs (no id/source/completable for creation), and keeping them separate avoids a sprawling "does five different things" activity, consistent with this codebase's existing per-purpose-Activity pattern. + +## Testing + +- Server: unit test for `HandleWidgetAdd` covering (a) success — valid title creates a task, verified via the store, (b) empty title → 400, matching the existing `TestHandleWidgetComplete_*` test style in `widget_test.go` (uses `setupTestDB`). +- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check. + +## Out of scope + +Recurrence display gets its own spec (the fifth and last feature). diff --git a/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md b/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md new file mode 100644 index 0000000..0f241cf --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md @@ -0,0 +1,51 @@ +# Widget Recurrence Display — Design + +## Context + +Fifth and last of five widget features. Decided without a user Q&A round (per explicit instruction to proceed autonomously) — this one has more judgment calls than the other four, documented below with reasoning. + +## Key findings from grounding + +1. **No recurrence data exists anywhere in this codebase today.** `models.CalendarEvent` has no recurrence field, `GoogleCalendarClient` never reads `item.Recurrence` or `item.RecurringEventId` from the Google API response, and there's no DB column for it. This is new plumbing end-to-end, not a forward of an existing value (unlike the overdue-badge and due-date features). + +2. **`GoogleCalendarClient` calls `.SingleEvents(true)`** (`internal/api/google_calendar.go:124,157`), which makes Google's API expand recurring series into individual instances. Each instance has `RecurringEventId` (pointing at the master event) but does **not** carry the `Recurrence` field (the RRULE strings) — that only lives on the master event. Getting the actual schedule therefore requires a second API call: `Events.Get(calendarID, recurringEventId)`. + +3. **No per-event calendar attribution exists.** Events from all 3 configured calendars are merged into one deduplicated list with no record of which calendar each came from (`calendar_events` table has no `calendar_id` column). Looking up a master event by ID needs to know which calendar to query. Rather than add calendar attribution (a bigger change touching the DB schema, the dedup logic, and every caller), this design tries each configured calendar ID in turn — there are only ~3, and this only happens on-demand (see point 5), not during the bulk fetch. + +4. **Calendar events currently never open an in-app popup at all.** Tapping a calendar event (`EventBlock`, `TomorrowEventRow`, or `AllDayRow` in `DootWidget.kt`) launches `ACTION_VIEW` directly to the Google Calendar app/website. Only tasks (`TaskRow`) open the in-app `TaskDetailActivity` bottom sheet. Since the user asked for recurrence to show "in the popup," and there is no existing popup for events, this design adds one — a new `EventDetailActivity`, structurally a sibling of `TaskDetailActivity`/`QuickAddActivity` (same one-Activity-per-purpose pattern used by quick add). The existing "jump straight to Google Calendar" behavior is preserved as a button inside the new popup, not removed. + +5. **Recurrence lookup is lazy (on-demand), not part of the bulk `/api/widget` fetch.** Pre-fetching every recurring event's master record during each timeline build would mean N extra Google API calls per fetch cycle (every 15 minutes, or on-demand) even when nobody looks at any of them. Instead, a new endpoint is queried only when the user actually opens a recurring event's popup — matching how reschedule already does its own live network call from `TaskDetailActivity` rather than being pre-computed into the widget JSON blob. + +## Design + +**Server — data layer (Go):** +- `models.CalendarEvent` gains `RecurringEventID string` (empty = not a recurring instance). +- Migration `022_calendar_events_recurring_id.sql`: `ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT ''`. +- `internal/store/sqlite.go`'s `SaveCalendarEvents`/`GetCalendarEventsByDateRange` thread the new column through (the table is fully replaced on every save — `DELETE FROM calendar_events` then re-insert — so no backfill logic is needed, the next fetch cycle repopulates it). +- `GoogleCalendarClient.parseEventTime`'s callers (`GetUpcomingEvents`, `GetEventsByDateRange`) capture `item.RecurringEventId` into `models.CalendarEvent.RecurringEventID`. +- `TimelineItem` gains `RecurringEventID string`; `BuildTimeline`'s event-mapping section forwards it from `models.CalendarEvent`. +- `models.WidgetItem` gains `RecurringEventID string `json:"recurring_event_id,omitempty"``; `TimelineItemToWidgetItem` forwards it only for `Type == "event"`. + +**Server — recurrence lookup (Go):** +- `GoogleCalendarAPI` interface gains `GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)`. +- `GoogleCalendarClient.GetRecurrenceRule` tries `Events.Get(calendarID, recurringEventID)` against each configured calendar ID in turn, returns the first success's formatted recurrence text; returns an error if the event isn't found on any configured calendar. +- New pure function `formatRecurrence(rrules []string) string` turns Google's RRULE strings into short English (e.g. `RRULE:FREQ=WEEKLY;BYDAY=MO` → `"Repeats weekly on Monday"`). Covers `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL` (e.g. "every 2 weeks"), and `BYDAY` (weekday names, comma-joined for multiple days). Anything it can't parse falls back to the generic `"Recurring event"` rather than showing nothing or crashing — this is intentionally NOT a full RFC 5545 parser, just common-case coverage (every recurring event actually seen in this calendar during grounding was a simple weekly repeat). +- New handler `HandleWidgetRecurrence` (`GET /api/widget/recurrence?recurring_event_id=X`), bearer-token protected like the other widget endpoints: calls `GetRecurrenceRule`, returns `{"recurrence": "..."}` on success or 404 if not found on any calendar. +- New route: `r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence)`. + +**Android — data layer:** +- `WidgetItem.kt` gains `@SerialName("recurring_event_id") val recurringEventId: String? = null`. +- `WidgetRepository` gains `suspend fun getRecurrence(recurringEventId: String): Result<String>` — a GET request with the id as a query parameter, parsing `{"recurrence": "..."}` from the response. + +**Android — UI:** +- New `EventDetailActivity.kt` (separate class, not a mode flag on `TaskDetailActivity` — different trigger, different data, same reasoning as keeping `QuickAddActivity` separate): a `ModalBottomSheet` showing the event title, formatted start time, a recurrence line that shows "Loading…" then the fetched text (only rendered at all if `recurringEventId != null` — a non-recurring event shows no recurrence line, not an empty one), and an "Open in Calendar" button that does the existing `ACTION_VIEW` behavior. +- `EventBlock`, `TomorrowEventRow`, and `AllDayRow` (`DootWidget.kt`) change their `clickable` action from directly launching `ACTION_VIEW` to instead launching `EventDetailActivity` with the event's id/title/start/url/recurringEventId as extras. + +## Testing + +- Server: unit tests for `formatRecurrence` (the pure function) covering the cases in the design above — this is the highest-value test in this feature since it's the one piece of real logic; table-driven, matching this codebase's existing style where a table is natural (see `TestCalcCalendarBounds` in `timeline_logic_test.go` for the established table-driven pattern in this repo). Unit test for `TimelineItemToWidgetItem` confirming `RecurringEventID` forwards only for events. `HandleWidgetRecurrence` tested via the mock calendar client (`MockCalendarClient` in `timeline_logic_test.go`, which needs a new `GetRecurrenceRule` mock method added alongside its existing mocked methods). +- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check. + +## Out of scope + +Editing/creating recurrence rules (this is read-only display). Showing recurrence for doot tasks (doot tasks have no recurrence concept at all — confirmed during earlier grounding that `models.Task.IsRecurring` exists but is never set or used anywhere in the Go codebase; wiring that up is a different, unscoped feature). diff --git a/docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md b/docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md new file mode 100644 index 0000000..4a3ad34 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-widget-refresh-button-design.md @@ -0,0 +1,43 @@ +# Widget Refresh Button — Design + +## Context + +First of five widget feature requests (refresh button, quick add, clickable-date reschedule, recurrence in the popup, overdue badge), being designed and shipped as separate small cycles rather than one batch. This spec covers only the refresh button. + +`RefreshWorker` already exists and does the right thing on completion: `fetchAndPersist()` then `DootWidget().updateAll()`. Today it only runs on a 15-minute periodic schedule (`RefreshWorker.schedule()`) or via `RefreshWorker.runOnce()`, which nothing currently calls. There is no manual trigger in the widget UI. + +## Goal + +A tappable refresh icon on the widget that triggers an immediate fetch, with visual feedback while the fetch is in flight. + +## Constraint + +Android AppWidgets render via `RemoteViews`, which does not support arbitrary View animation (no rotating-icon spinner). The realistic version of "spinner" is a static icon swap: refresh glyph ↔ a distinct "loading" glyph, swapped synchronously on tap and swapped back when the worker finishes. Confirmed acceptable with the user. + +## Design + +**Flow:** +1. User taps the refresh icon. +2. `RefreshTaskAction.onAction()` (new `ActionCallback`, mirrors the existing `CompleteTaskAction`) sets `Keys.IS_REFRESHING = true` in the widget's DataStore and calls `DootWidget().updateAll(context)` immediately — the icon flips to the loading glyph before any network call happens. +3. The same action enqueues `RefreshWorker.runOnce(context)`. +4. `RefreshWorker.doWork()` is extended so that, on **both** the success and failure branches, it clears `Keys.IS_REFRESHING` and calls `DootWidget().updateAll(context)` again — the icon always reverts, even if the fetch errors out and `Result.retry()` is returned (a retry is still "not actively refreshing" from the user's point of view between attempts). + +**Components:** + +- `data/DataStore.kt` — add `val IS_REFRESHING = booleanPreferencesKey("is_refreshing")` to `Keys`. +- `res/drawable/ic_refresh.xml` — new 24dp vector icon, same style/stroke convention as the existing `ic_checkbox_empty.xml`. +- `res/drawable/ic_refresh_loading.xml` — new 24dp vector icon, visually distinct (e.g. hourglass or three dots) from the idle refresh icon. +- `widget/ui/Actions.kt` — new `RefreshTaskAction : ActionCallback`, no parameters needed. +- `widget/ui/DootWidget.kt`: + - `provideGlance()` reads `prefs[Keys.IS_REFRESHING] ?: false` and passes it into `WidgetRoot(items, now, isRefreshing)`. + - `WidgetRoot`'s existing "TODAY" header `Row` gains a second child: a new `RefreshButton(isRefreshing: Boolean)` composable, right-aligned (the `Row` needs `horizontalArrangement`/a spacer or `defaultWeight()` on the "TODAY" text so the icon lands on the right edge). + - `RefreshButton` renders `ic_refresh_loading` if `isRefreshing`, else `ic_refresh`; both wrapped in a `Box` with `clickable(actionRunCallback<RefreshTaskAction>())`, sized to match the existing checkbox tap target (24dp box, matching `TaskRow`'s checkbox pattern). +- `widget/work/RefreshWorker.kt` — `doWork()`'s `fold` branches both gain the clear-flag-and-update step before returning their `Result`. + +**Error handling:** No new error states. `RefreshWorker.doWork()` already returns `Result.retry()` on failure; WorkManager's existing backoff handles the retry timing. The icon simply reverts to idle between attempts rather than staying in a stuck loading state. + +**Testing:** No new unit-test surface — this is Glance/RemoteViews composition and WorkManager wiring, consistent with how the rest of `DootWidget.kt` and the `*Worker.kt` classes are (not) unit tested today. Verified by building the APK, installing on device, and confirming the tap → loading-icon → fetch → idle-icon cycle live, the same verification approach used for every other widget fix this session. + +## Out of scope + +The other four widget features (quick add, clickable-date reschedule, recurrence display, overdue badge) — each gets its own spec. diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go index 8bb3143..bb17812 100644 --- a/internal/api/google_calendar.go +++ b/internal/api/google_calendar.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "sort" + "strconv" "strings" "time" @@ -83,6 +84,86 @@ func deduplicateEvents(events []models.CalendarEvent) []models.CalendarEvent { return unique } +var recurrenceFreqNames = map[string]string{ + "DAILY": "daily", + "WEEKLY": "weekly", + "MONTHLY": "monthly", + "YEARLY": "yearly", +} + +var recurrenceFreqUnits = map[string]string{ + "DAILY": "day", + "WEEKLY": "week", + "MONTHLY": "month", + "YEARLY": "year", +} + +var recurrenceWeekdayNames = map[string]string{ + "SU": "Sunday", + "MO": "Monday", + "TU": "Tuesday", + "WE": "Wednesday", + "TH": "Thursday", + "FR": "Friday", + "SA": "Saturday", +} + +// formatRecurrence turns Google Calendar RRULE strings into short English. +// This intentionally covers only the common cases (FREQ, INTERVAL, BYDAY) -- +// not a full RFC 5545 parser. Anything it can't confidently describe falls +// back to "Recurring event" rather than showing nothing or an error. +func formatRecurrence(rrules []string) string { + for _, rule := range rrules { + rule = strings.TrimPrefix(rule, "RRULE:") + parts := make(map[string]string) + for _, kv := range strings.Split(rule, ";") { + pieces := strings.SplitN(kv, "=", 2) + if len(pieces) == 2 { + parts[pieces[0]] = pieces[1] + } + } + + freqKey := parts["FREQ"] + unit, ok := recurrenceFreqUnits[freqKey] + if !ok { + continue + } + + interval := 1 + if iv := parts["INTERVAL"]; iv != "" { + if n, err := strconv.Atoi(iv); err == nil && n > 0 { + interval = n + } + } + + var phrase string + if interval == 1 { + phrase = "Repeats " + recurrenceFreqNames[freqKey] + } else { + phrase = fmt.Sprintf("Repeats every %d %ss", interval, unit) + } + + if byday := parts["BYDAY"]; byday != "" { + var days []string + for _, code := range strings.Split(byday, ",") { + code = strings.TrimSpace(code) + if len(code) >= 2 { + code = code[len(code)-2:] + } + if name, ok := recurrenceWeekdayNames[code]; ok { + days = append(days, name) + } + } + if len(days) > 0 { + phrase += " on " + strings.Join(days, ", ") + } + } + + return phrase + } + return "Recurring event" +} + // NewGoogleCalendarClient creates a client that fetches from multiple calendars. // calendarIDs can be comma-separated (e.g., "cal1@group.calendar.google.com,cal2@group.calendar.google.com") // timezone is the IANA timezone name for display (e.g., "Pacific/Honolulu") @@ -130,12 +211,13 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults for _, item := range events.Items { start, end := c.parseEventTime(item) allEvents = append(allEvents, models.CalendarEvent{ - ID: item.Id, - Summary: item.Summary, - Description: item.Description, - Start: start, - End: end, - HTMLLink: item.HtmlLink, + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, }) } } @@ -163,12 +245,13 @@ func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start, for _, item := range events.Items { evtStart, evtEnd := c.parseEventTime(item) allEvents = append(allEvents, models.CalendarEvent{ - ID: item.Id, - Summary: item.Summary, - Description: item.Description, - Start: evtStart, - End: evtEnd, - HTMLLink: item.HtmlLink, + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: evtStart, + End: evtEnd, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, }) } } @@ -201,3 +284,21 @@ func (c *GoogleCalendarClient) GetCalendarList(ctx context.Context) ([]models.Ca } return calendars, nil } + +// GetRecurrenceRule looks up a recurring event's master record and returns +// its formatted recurrence schedule. Google's API only puts the RRULE on +// the master event, not on expanded instances (see parseEventTime's +// SingleEvents(true) callers), so this does a live lookup by the instance's +// RecurringEventId. There's no per-event calendar attribution stored today +// (events from all configured calendars are merged without recording which +// one they came from), so this tries each configured calendar in turn. +func (c *GoogleCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + for _, calendarID := range c.calendarIDs { + event, err := c.srv.Events.Get(calendarID, recurringEventID).Do() + if err != nil { + continue + } + return formatRecurrence(event.Recurrence), nil + } + return "", fmt.Errorf("recurring event %s not found on any configured calendar", recurringEventID) +} diff --git a/internal/api/google_calendar_test.go b/internal/api/google_calendar_test.go index 3cf0dbd..efbab0d 100644 --- a/internal/api/google_calendar_test.go +++ b/internal/api/google_calendar_test.go @@ -272,3 +272,33 @@ func TestGetUpcomingEvents_APIError_ReturnsEmptyNotError(t *testing.T) { t.Errorf("expected 0 events on API error, got %d", len(events)) } } + +// --- formatRecurrence --- + +func TestFormatRecurrence(t *testing.T) { + tests := []struct { + name string + rules []string + want string + }{ + {"weekly single day", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO"}, "Repeats weekly on Monday"}, + {"weekly multiple days", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"}, "Repeats weekly on Monday, Wednesday, Friday"}, + {"daily", []string{"RRULE:FREQ=DAILY"}, "Repeats daily"}, + {"monthly", []string{"RRULE:FREQ=MONTHLY"}, "Repeats monthly"}, + {"yearly", []string{"RRULE:FREQ=YEARLY"}, "Repeats yearly"}, + {"interval weekly", []string{"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU"}, "Repeats every 2 weeks on Sunday"}, + {"interval daily", []string{"RRULE:FREQ=DAILY;INTERVAL=3"}, "Repeats every 3 days"}, + {"unrecognized frequency", []string{"RRULE:FREQ=HOURLY"}, "Recurring event"}, + {"empty", []string{}, "Recurring event"}, + {"unparseable", []string{"not a valid rule"}, "Recurring event"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatRecurrence(tc.rules) + if got != tc.want { + t.Errorf("formatRecurrence(%v) = %q, want %q", tc.rules, got, tc.want) + } + }) + } +} diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 183f3f0..3c1e4e1 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -30,6 +30,7 @@ type GoogleCalendarAPI interface { GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) SetCalendarIDs(ids []string) + GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) } // GoogleTasksAPI defines the interface for Google Tasks operations diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index aaf1d0d..408006d 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -314,9 +314,20 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ } if len(enabledIDs) == 0 { - // No source_configs synced yet — fall back to the configured calendar ID + // No source_configs synced yet — fall back to the configured calendar + // ID(s). GoogleCalendarID is a single env var that itself holds a + // comma-separated list (see GOOGLE_CALENDAR_ID in .env) -- it must be + // split before use. Passing the raw joined string straight through + // as a single calendar ID (the previous behavior) sends Google's API + // a calendarId that matches nothing, failing every fetch with a 404 — + // this is a real production incident, not a hypothetical: it silently + // broke all calendar events (web and widget both) until fixed. if len(configs) == 0 && h.config.GoogleCalendarID != "" { - enabledIDs = []string{h.config.GoogleCalendarID} + for _, id := range strings.Split(h.config.GoogleCalendarID, ",") { + if trimmed := strings.TrimSpace(id); trimmed != "" { + enabledIDs = append(enabledIDs, trimmed) + } + } } else { // Configs exist but all disabled — respect that return nil, nil @@ -326,10 +337,12 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ h.googleCalendarClient.SetCalendarIDs(enabledIDs) fetcher := &CacheFetcher[models.CalendarEvent]{ - Store: h.store, - CacheKey: store.CacheKeyGoogleCalendar, - TTLMinutes: h.config.CacheTTLMinutes, - Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) }, + Store: h.store, + CacheKey: store.CacheKeyGoogleCalendar, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { + return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + }, GetFromCache: h.store.GetCalendarEvents, SaveToCache: h.store.SaveCalendarEvents, } diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 67ba09d..6fddd1d 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -81,16 +81,17 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ for _, event := range events { endTime := event.End item := models.TimelineItem{ - ID: event.ID, - Type: models.TimelineItemTypeEvent, - Title: event.Summary, - Time: event.Start, - EndTime: &endTime, - Description: event.Description, - URL: event.HTMLLink, - OriginalItem: event, - IsCompleted: false, - Source: "calendar", + ID: event.ID, + Type: models.TimelineItemTypeEvent, + Title: event.Summary, + Time: event.Start, + EndTime: &endTime, + Description: event.Description, + URL: event.HTMLLink, + OriginalItem: event, + IsCompleted: false, + Source: "calendar", + RecurringEventID: event.RecurringEventID, } item.ComputeDaySection(now) items = append(items, item) @@ -125,7 +126,10 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ } } - // 6. Fetch native (doot-owned) tasks + // 6. Fetch native (doot-owned) tasks due within the range, plus any + // overdue tasks (due before the range's start) so they still surface -- + // GetNativeTasksByDateRange's lower bound would otherwise drop them + // before ComputeDaySection ever gets a chance to mark them IsOverdue. nativeDated, err := s.GetNativeTasksByDateRange(start, end) if err != nil { log.Printf("Warning: failed to read native tasks: %v", err) @@ -149,6 +153,29 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ } } + nativeOverdue, err := s.GetOverdueNativeTasks(start) + if err != nil { + log.Printf("Warning: failed to read overdue native tasks: %v", err) + } else { + for _, task := range nativeOverdue { + if task.DueDate == nil { + continue + } + item := models.TimelineItem{ + ID: task.ID, + Type: models.TimelineItemTypeTask, + Title: task.Content, + Time: *task.DueDate, + Description: task.Description, + OriginalItem: task, + IsCompleted: task.Completed, + Source: "doot", + } + item.ComputeDaySection(now) + items = append(items, item) + } + } + nativeUndated, err := s.GetUndatedNativeTasks() if err != nil { log.Printf("Warning: failed to read undated native tasks: %v", err) diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 02dd5fb..1da2d8a 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -19,6 +19,13 @@ import ( type MockCalendarClient struct { Events []models.CalendarEvent Err error + // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called + // with, for tests that need to assert on how the caller resolved its + // calendar ID list (e.g. fetchCalendarEvents' comma-split fallback). + SetCalendarIDsCalls [][]string + // RecurrenceRule is returned by GetRecurrenceRule for any id when RecurrenceErr is nil. + RecurrenceRule string + RecurrenceErr error } func (m *MockCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) { @@ -33,7 +40,13 @@ func (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.Cale return nil, m.Err } -func (m *MockCalendarClient) SetCalendarIDs(ids []string) {} +func (m *MockCalendarClient) SetCalendarIDs(ids []string) { + m.SetCalendarIDsCalls = append(m.SetCalendarIDsCalls, ids) +} + +func (m *MockCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + return m.RecurrenceRule, m.RecurrenceErr +} func setupTestStore(t *testing.T) *store.Store { t.Helper() @@ -77,6 +90,7 @@ func setupTestStore(t *testing.T) *store.Store { start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, html_link TEXT, + recurring_event_id TEXT DEFAULT '', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS google_tasks ( @@ -288,6 +302,56 @@ func TestBuildTimeline_ReadsCalendarEventsFromStore(t *testing.T) { } } +// TestBuildTimeline_IncludesOverdueNativeTasks proves the 2026-07-12 fix: a +// native task whose due_date is BEFORE the requested range's start must +// still appear in the timeline, marked IsOverdue, instead of being silently +// dropped. Root cause: GetNativeTasksByDateRange's SQL bound (due_date >= +// start) excluded overdue tasks from ever being fetched, so ComputeDaySection +// never got a chance to set IsOverdue -- both the web Timeline view and the +// widget API (which both call BuildTimeline with a start of "today") showed +// zero overdue tasks even though the Tasks tab (which calls the unbounded +// GetNativeTasks) showed them fine. +func TestBuildTimeline_IncludesOverdueNativeTasks(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + overdueDate := today.AddDate(0, 0, -12) // 12 days in the past + + if err := db.CreateNativeTask(models.Task{ + ID: "overdue-1", + Content: "Pay the water bill", + DueDate: &overdueDate, + }); err != nil { + t.Fatalf("Failed to create native task: %v", err) + } + + start := today + end := today.AddDate(0, 0, 2) + + items, err := BuildTimeline(context.Background(), db, start, end) + if err != nil { + t.Fatalf("BuildTimeline failed: %v", err) + } + + var found *models.TimelineItem + for i := range items { + if items[i].ID == "overdue-1" { + found = &items[i] + } + } + if found == nil { + t.Fatal("expected overdue native task to appear in timeline, but it was missing") + } + if !found.IsOverdue { + t.Error("expected overdue native task to have IsOverdue = true") + } + if found.Title != "Pay the water bill" { + t.Errorf("Title: got %q, want %q", found.Title, "Pay the water bill") + } +} + func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -336,6 +400,50 @@ func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { } } +// TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs proves the +// 2026-07-12 fix: when no source_configs rows exist yet for "gcal" (the +// normal state before any calendar-discovery sync has run), fetchCalendarEvents +// falls back to config.GoogleCalendarID -- but that's a single env var that +// itself holds a comma-separated list of calendar IDs (see GOOGLE_CALENDAR_ID +// in .env). Previously the whole joined string was passed to SetCalendarIDs +// as a single one-element slice, which Google's API rejected with a 404 +// ("Not Found") on every single fetch -- a real production incident that +// broke all calendar events, in both the web dashboard and the widget, until +// this fix. It must now be split into separate IDs. +func TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + mock := &MockCalendarClient{Events: []models.CalendarEvent{{ID: "e1"}}} + h := &Handler{ + store: db, + googleCalendarClient: mock, + config: &config.Config{ + CacheTTLMinutes: 5, + GoogleCalendarID: "cal-a@group.calendar.google.com, cal-b@gmail.com,cal-c@group.calendar.google.com", + }, + renderer: newTestRenderer(), + } + + if _, err := h.fetchCalendarEvents(context.Background(), true); err != nil { + t.Fatalf("fetchCalendarEvents: %v", err) + } + + if len(mock.SetCalendarIDsCalls) == 0 { + t.Fatal("expected SetCalendarIDs to be called") + } + got := mock.SetCalendarIDsCalls[len(mock.SetCalendarIDsCalls)-1] + want := []string{"cal-a@group.calendar.google.com", "cal-b@gmail.com", "cal-c@group.calendar.google.com"} + if len(got) != len(want) { + t.Fatalf("SetCalendarIDs called with %d ids, want %d: got %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("id[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + func TestSaveAndGetCalendarEvents(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 3b94bf1..f1f7452 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "net/http" "strings" "time" @@ -31,11 +32,13 @@ func WidgetAuthMiddleware(token string, next http.Handler) http.Handler { // Exported for testability. func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi := models.WidgetItem{ - ID: item.ID, - Title: item.Title, - Source: item.Source, - IsAllDay: item.IsAllDay, - URL: item.URL, + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + IsOverdue: item.IsOverdue, + URL: item.URL, + RecurringEventID: item.RecurringEventID, } switch item.Type { @@ -54,18 +57,38 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi.Completable = item.Source == "doot" } - // Only populate Start/End for items with a real time (non-all-day, non-zero) - if !item.IsAllDay && !item.Time.IsZero() { + // Only populate Start/End for items with a real time. All-day CALENDAR + // EVENTS (not undated doot/gtask tasks, which are also flagged IsAllDay + // as a "no specific time" fallback -- see TimelineItem.ComputeDaySection) + // get Start populated too, using their real event date, so the widget + // client can pin them to the top of the correct day's section instead of + // losing them in the floating-task hourly-slot packer, which has no + // concept of "all day" and can push a slot past the visible grid range + // entirely. Tasks keep the existing nil-Start "floating" treatment + // regardless of IsAllDay -- only Start is set for them (never End), and + // only when they have a real time. + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t - if item.EndTime != nil { - wi.End = item.EndTime - } else { - end := item.Time.Add(time.Hour) - wi.End = &end + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } } } + // DueDate is independent of Start/IsAllDay -- doot tasks deliberately + // keep Start nil (see the "floating task" doc comment above) so the + // client's SlotPacker positions them, but the Android detail popup + // still needs to know the real due date to display and reschedule it. + if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() { + due := item.Time + wi.DueDate = &due + } + return wi } @@ -105,6 +128,10 @@ type widgetCompleteRequest struct { Source string `json:"source"` } +type widgetAddRequest struct { + Title string `json:"title"` +} + type widgetRescheduleRequest struct { ID string `json:"id"` Source string `json:"source"` @@ -130,6 +157,10 @@ func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request) tz := config.GetDisplayTimezone() dueDate := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, tz) if err := h.store.RescheduleNativeTask(req.ID, dueDate); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } http.Error(w, "failed to reschedule", http.StatusInternalServerError) return } @@ -276,6 +307,14 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { switch req.Source { case "doot": if err := h.store.CompleteNativeTask(req.ID); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + // Surfaced as 404 (not a silent 200) so the caller -- the + // Android widget -- can tell "nothing changed" apart from + // "it worked", instead of the previous behavior where a + // stale/wrong id looked identical to a real completion. + http.Error(w, "task not found", http.StatusNotFound) + return + } http.Error(w, "failed to complete task", http.StatusInternalServerError) return } @@ -319,3 +358,52 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +// HandleWidgetRecurrence looks up and formats a recurring calendar event's schedule. +func (h *Handler) HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request) { + recurringEventID := r.URL.Query().Get("recurring_event_id") + if recurringEventID == "" { + http.Error(w, "recurring_event_id is required", http.StatusBadRequest) + return + } + + recurrence, err := h.googleCalendarClient.GetRecurrenceRule(r.Context(), recurringEventID) + if err != nil { + http.Error(w, "recurring event not found", http.StatusNotFound) + return + } + + resp := struct { + Recurrence string `json:"recurrence"` + }{Recurrence: recurrence} + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet. +func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { + var req widgetAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + http.Error(w, "title is required", http.StatusBadRequest) + return + } + + task := models.Task{ + ID: newID(), + Content: title, + Priority: 1, + } + if err := h.store.CreateNativeTask(task); err != nil { + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 1d8dba9..3116918 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -168,6 +169,178 @@ func TestTimelineItemToWidgetItem_Event(t *testing.T) { } } +// TestTimelineItemToWidgetItem_AllDayEvent proves the 2026-07-12 fix: an +// all-day CALENDAR EVENT (Type == event, IsAllDay == true) must get Start +// populated with its real date -- previously Start was nil for every +// IsAllDay item regardless of type, which meant the widget client's +// hourly-slot packer had no date information to pin all-day events to the +// top of the correct day and they could silently fall outside the visible +// grid range instead. End stays nil since there's no meaningful end time to +// show for an all-day item. +func TestTimelineItemToWidgetItem_AllDayEvent(t *testing.T) { + day := time.Date(2026, 7, 12, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "cal-holiday", + Title: "Company Holiday", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: day, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Type != "event" { + t.Errorf("Type: got %q, want %q", wi.Type, "event") + } + if !wi.IsAllDay { + t.Error("expected IsAllDay to be true") + } + if wi.Start == nil { + t.Fatal("all-day event should have non-nil Start (needed to pin it to the correct day)") + } + if !wi.Start.Equal(day) { + t.Errorf("Start = %v, want %v", *wi.Start, day) + } + if wi.End != nil { + t.Error("all-day event should have nil End") + } +} + +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior proves the +// same fix does NOT change behavior for undated doot/gtask tasks, which are +// also flagged IsAllDay as a "no specific time" fallback (see +// TimelineItem.ComputeDaySection) but are a different concept from a real +// all-day calendar event -- they must keep the existing nil-Start +// "floating" treatment so the hourly-slot packer still places them. +func TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior(t *testing.T) { + item := models.TimelineItem{ + ID: "undated-task", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Start != nil { + t.Error("an undated task (IsAllDay as a fallback, not a real all-day event) should still have nil Start") + } +} + +// TestTimelineItemToWidgetItem_ForwardsIsOverdue proves the 2026-07-12 +// overdue-badge fix: TimelineItem.IsOverdue (already computed correctly by +// ComputeDaySection, confirmed by the earlier fix that made overdue tasks +// appear in the timeline at all) must be forwarded onto WidgetItem so the +// Android client can render it distinctly -- previously it was silently +// dropped, so an overdue task looked identical to a normal one on the +// widget. +func TestTimelineItemToWidgetItem_ForwardsIsOverdue(t *testing.T) { + item := models.TimelineItem{ + ID: "overdue-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsOverdue: true, + } + + wi := TimelineItemToWidgetItem(item) + + if !wi.IsOverdue { + t.Error("expected IsOverdue to be forwarded as true") + } +} + +func TestTimelineItemToWidgetItem_NotOverdueByDefault(t *testing.T) { + item := models.TimelineItem{ + ID: "today-1", + Title: "Water the plants", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.IsOverdue { + t.Error("expected IsOverdue to be false when the source item isn't overdue") + } +} + +// TestTimelineItemToWidgetItem_DootTaskGetsDueDate proves the 2026-07-12 +// clickable-reschedule fix: a doot task's raw due date must reach the +// client via a NEW field (DueDate) that is independent of Start/IsAllDay -- +// Start is deliberately left nil for doot tasks (see +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior) so the +// client's floating-task SlotPacker can position it, and that must keep +// working unchanged. Before this fix there was no way for the Android +// detail popup to know a doot task's current due date at all. +func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { + due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "doot-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: due, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate == nil { + t.Fatal("expected DueDate to be set for a doot task with a real due date") + } + if !wi.DueDate.Equal(due) { + t.Errorf("DueDate = %v, want %v", *wi.DueDate, due) + } + // Start must stay nil -- this is the pre-existing floating-task + // behavior and this feature must not change it. + if wi.Start != nil { + t.Error("Start must remain nil for a doot task -- DueDate is a separate field, not a replacement") + } +} + +func TestTimelineItemToWidgetItem_UndatedDootTask_NilDueDate(t *testing.T) { + item := models.TimelineItem{ + ID: "doot-undated", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + // Zero Time simulates the "no real due date" case at the field level; + // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero(). + item.Time = time.Time{} + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to be nil when the source item has a zero Time") + } +} + +func TestTimelineItemToWidgetItem_CalendarEvent_NilDueDate(t *testing.T) { + start := time.Now() + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: start, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to stay nil for a non-doot item (calendar event)") + } +} + func TestHandleWidgetComplete_NonCompletable(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar"}` @@ -323,6 +496,26 @@ func TestHandleWidgetComplete_GoogleTask(t *testing.T) { } } +// TestHandleWidgetComplete_UnknownID_Returns404 proves the 2026-07-12 fix at +// the handler layer: a "doot" completion for an id that doesn't exist must +// surface as 404, not the previous silent 200 (see +// store.ErrNativeTaskNotFound's doc comment for the underlying bug this +// closes -- a real production incident where the widget's completeTask tap +// intermittently looked like it worked but changed nothing). +func TestHandleWidgetComplete_UnknownID_Returns404(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"id":"does-not-exist","source":"doot"}` + req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + func TestHandleWidgetComplete_GoogleTask_NotConfigured(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -483,6 +676,66 @@ func TestHandleWidgetComplete_TrelloCard_Archives(t *testing.T) { } } +// TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a +// title to /api/widget/add creates an undated native task the same way the +// web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON +// API instead of a session-authenticated HTML form. +func TestHandleWidgetAdd_CreatesTask(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":"Buy milk"}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + tasks, err := s.GetUndatedNativeTasks() + if err != nil { + t.Fatalf("failed to read back tasks: %v", err) + } + found := false + for _, task := range tasks { + if task.Content == "Buy milk" { + found = true + } + } + if !found { + t.Error("expected a task with content 'Buy milk' to have been created") + } +} + +func TestHandleWidgetAdd_EmptyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":""}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleWidgetAdd_WhitespaceOnlyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":" "}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) h := WidgetAuthMiddleware("", inner) @@ -496,3 +749,88 @@ func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) { t.Fatalf("expected 401 with empty token, got %d", w.Code) } } + +// TestTimelineItemToWidgetItem_ForwardsRecurringEventID proves the +// 2026-07-12 recurrence-display fix's data plumbing: a calendar event's +// RecurringEventId (captured from Google's API, which only puts the RRULE +// itself on the master event, not on expanded instances) must reach the +// client so it can look up the human-readable schedule on demand. +func TestTimelineItemToWidgetItem_ForwardsRecurringEventID(t *testing.T) { + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: time.Now(), + RecurringEventID: "master-123", + } + + wi := TimelineItemToWidgetItem(item) + + if wi.RecurringEventID != "master-123" { + t.Errorf("RecurringEventID = %q, want %q", wi.RecurringEventID, "master-123") + } +} + +func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing.T) { + item := models.TimelineItem{ + ID: "cal-2", + Title: "One-off meeting", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.RecurringEventID != "" { + t.Errorf("expected empty RecurringEventID for a non-recurring event, got %q", wi.RecurringEventID) + } +} + +// TestHandleWidgetRecurrence_ReturnsFormattedSchedule proves the recurrence +// lookup endpoint: given a recurring_event_id query param, it calls the +// calendar client's GetRecurrenceRule and returns the formatted text. +func TestHandleWidgetRecurrence_ReturnsFormattedSchedule(t *testing.T) { + mock := &MockCalendarClient{RecurrenceRule: "Repeats weekly on Monday"} + h := &Handler{googleCalendarClient: mock} + req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=master-1", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp struct { + Recurrence string `json:"recurrence"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Recurrence != "Repeats weekly on Monday" { + t.Errorf("recurrence = %q, want %q", resp.Recurrence, "Repeats weekly on Monday") + } +} + +func TestHandleWidgetRecurrence_NotFound_Returns404(t *testing.T) { + mock := &MockCalendarClient{RecurrenceErr: fmt.Errorf("not found")} + h := &Handler{googleCalendarClient: mock} + req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=missing", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestHandleWidgetRecurrence_MissingParam_Returns400(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest("GET", "/api/widget/recurrence", nil) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} diff --git a/internal/models/timeline.go b/internal/models/timeline.go index ab486a8..6e48f91 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -9,11 +9,11 @@ import ( type TimelineItemType string const ( - TimelineItemTypeTask TimelineItemType = "task" - TimelineItemTypeMeal TimelineItemType = "meal" - TimelineItemTypeCard TimelineItemType = "card" - TimelineItemTypeEvent TimelineItemType = "event" - TimelineItemTypeGTask TimelineItemType = "gtask" // Google Tasks + TimelineItemTypeTask TimelineItemType = "task" + TimelineItemTypeMeal TimelineItemType = "meal" + TimelineItemTypeCard TimelineItemType = "card" + TimelineItemTypeEvent TimelineItemType = "event" + TimelineItemTypeGTask TimelineItemType = "gtask" // Google Tasks ) type DaySection string @@ -42,7 +42,8 @@ type TimelineItem struct { Source string `json:"source"` // "trello", "plantoeat", "calendar", "gtasks" // Source-specific metadata - ListID string `json:"list_id,omitempty"` // For Google Tasks + ListID string `json:"list_id,omitempty"` // For Google Tasks + RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events } // ComputeDaySection sets the DaySection, IsOverdue, and IsAllDay based on the item's time diff --git a/internal/models/types.go b/internal/models/types.go index 58d3888..6f8c405 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -127,12 +127,13 @@ type Project struct { // CalendarEvent represents a Google Calendar event type CalendarEvent struct { - ID string `json:"id"` - Summary string `json:"summary"` - Description string `json:"description"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - HTMLLink string `json:"html_link"` + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + HTMLLink string `json:"html_link"` + RecurringEventID string `json:"recurring_event_id,omitempty"` // empty = not a recurring instance } // GoogleTask represents a task from Google Tasks @@ -249,12 +250,12 @@ type CompletedTask struct { // SourceConfig represents a configurable item from a data source type SourceConfig struct { - ID int64 `json:"id"` - Source string `json:"source"` // trello, gcal, gtasks - ItemType string `json:"item_type"` // board, project, calendar, tasklist - ItemID string `json:"item_id"` - ItemName string `json:"item_name"` - Enabled bool `json:"enabled"` + ID int64 `json:"id"` + Source string `json:"source"` // trello, gcal, gtasks + ItemType string `json:"item_type"` // board, project, calendar, tasklist + ItemID string `json:"item_id"` + ItemName string `json:"item_name"` + Enabled bool `json:"enabled"` } // FeatureToggle represents a feature flag diff --git a/internal/models/widget.go b/internal/models/widget.go index 8d0bdd6..cbaf19c 100644 --- a/internal/models/widget.go +++ b/internal/models/widget.go @@ -6,15 +6,18 @@ import "time" // Source values: "doot", "trello", "plantoeat", "calendar", "gtasks" // Type values: "task", "event" type WidgetItem struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - Type string `json:"type"` - Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) - End *time.Time `json:"end,omitempty"` - IsAllDay bool `json:"is_all_day"` - URL string `json:"url,omitempty"` - Completable bool `json:"completable"` // true = doot task (checkbox shown) + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Type string `json:"type"` + Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) + End *time.Time `json:"end,omitempty"` + IsAllDay bool `json:"is_all_day"` + IsOverdue bool `json:"is_overdue"` + DueDate *time.Time `json:"due_date,omitempty"` // doot tasks only -- see TimelineItemToWidgetItem + URL string `json:"url,omitempty"` + Completable bool `json:"completable"` // true = doot task (checkbox shown) + RecurringEventID string `json:"recurring_event_id,omitempty"` } // WidgetResponse is the full /api/widget response body. diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 218675e..7789a0d 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -1,12 +1,21 @@ package store import ( + "database/sql" "encoding/json" + "errors" "time" "task-dashboard/internal/models" ) +// ErrNativeTaskNotFound is returned by CompleteNativeTask, UncompleteNativeTask, +// and RescheduleNativeTask when no row matches the given id -- previously these +// three silently reported success on a 0-row UPDATE (Exec's err is nil even when +// no rows match), so a stale or wrong id from a caller looked identical to a real +// completion: the HTTP response was 200, but nothing in the database changed. +var ErrNativeTaskNotFound = errors.New("native task not found") + // GetNativeTasks returns all non-completed native tasks. func (s *Store) GetNativeTasks() ([]models.Task, error) { rows, err := s.db.Query(` @@ -22,15 +31,37 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) { return scanNativeTasks(rows) } -// GetNativeTasksByDateRange returns non-completed native tasks due within the given range, -// including overdue tasks (due before start) so they keep appearing until completed. +// GetNativeTasksByDateRange returns non-completed native tasks due within the given range. +// Overdue tasks (due before start) are deliberately excluded here -- BuildTimeline fetches +// those separately via GetOverdueNativeTasks so callers that only want "in range" can use this +// without double-counting against that separate fetch. func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at FROM native_tasks + WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? + ORDER BY due_date ASC, priority DESC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// GetOverdueNativeTasks returns non-completed native tasks whose due date is +// before the given time. BuildTimeline calls this alongside +// GetNativeTasksByDateRange, whose lower bound excludes anything due before +// the requested range's start -- without this, a task overdue from a +// previous day never gets fetched at all, so it never reaches +// ComputeDaySection to be marked IsOverdue. +func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at + FROM native_tasks WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ? ORDER BY due_date ASC, priority DESC - `, end) + `, before) if err != nil { return nil, err } @@ -81,31 +112,62 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error { return err } -// CompleteNativeTask marks a task as completed. +// CompleteNativeTask marks a task as completed. Returns ErrNativeTaskNotFound +// if id doesn't match any row. func (s *Store) CompleteNativeTask(id string) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) } -// RescheduleNativeTask sets a new due date on a task. +// RescheduleNativeTask sets a new due date on a task. Returns +// ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) RescheduleNativeTask(id string, dueDate time.Time) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, dueDate, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) } -// UncompleteNativeTask marks a task as not completed. +// UncompleteNativeTask marks a task as not completed. Returns +// ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) UncompleteNativeTask(id string) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) +} + +// checkRowsAffected returns ErrNativeTaskNotFound if the update matched no +// rows -- mirrors the RowsAffected() check already used in sqlite.go's +// ApproveAgentSession/DenyAgentSession for the same "silent 0-row update" +// class of bug. +func checkRowsAffected(result sql.Result) error { + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNativeTaskNotFound + } + return nil } -func scanNativeTasks(rows interface{ Next() bool; Scan(...interface{}) error; Err() error }) ([]models.Task, error) { +func scanNativeTasks(rows interface { + Next() bool + Scan(...interface{}) error + Err() error +}) ([]models.Task, error) { var tasks []models.Task for rows.Next() { var t models.Task diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go new file mode 100644 index 0000000..5c11d3a --- /dev/null +++ b/internal/store/native_tasks_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// newNativeTasksTestStore creates a Store backed by a fresh temp sqlite DB +// with just the native_tasks table -- enough to exercise +// CompleteNativeTask/UncompleteNativeTask/RescheduleNativeTask without +// running the full migration set. +func newNativeTasksTestStore(t *testing.T) *Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if _, err := db.Exec(` + CREATE TABLE native_tasks ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + description TEXT DEFAULT '', + project_name TEXT DEFAULT '', + due_date DATETIME, + priority INTEGER DEFAULT 1, + completed BOOLEAN DEFAULT 0, + labels TEXT DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO native_tasks (id, content) VALUES ('real-1', 'Real task')`); err != nil { + t.Fatal(err) + } + return &Store{db: db} +} + +// TestCompleteNativeTask_UnknownID_ReturnsErrNotFound proves the 2026-07-12 +// fix: a plain UPDATE ... WHERE id = ? silently "succeeds" with a nil error +// when 0 rows match (this is how database/sql's Exec behaves for an UPDATE +// that matches nothing -- no error, just RowsAffected() == 0). Before this +// fix, CompleteNativeTask returned that nil error straight through, so a +// stale/wrong id from a caller (the Android widget, in the real incident +// this was found from) looked identical to a real completion: HTTP 200, +// nothing changed in the database. +func TestCompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.CompleteNativeTask("does-not-exist") + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} + +func TestCompleteNativeTask_RealID_Succeeds(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.CompleteNativeTask("real-1"); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + + var completed bool + if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'real-1'`).Scan(&completed); err != nil { + t.Fatal(err) + } + if !completed { + t.Error("expected task to be marked completed") + } +} + +func TestUncompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.UncompleteNativeTask("does-not-exist") + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} + +func TestRescheduleNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.RescheduleNativeTask("does-not-exist", time.Now()) + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index f955f71..ad88166 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -600,8 +600,8 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error { } stmt, err := tx.Prepare(` - INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -609,7 +609,7 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error { defer func() { _ = stmt.Close() }() for _, e := range events { - _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink) + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID) if err != nil { return err } @@ -644,7 +644,7 @@ func (s *Store) GetCalendarEvents() ([]models.CalendarEvent, error) { // GetCalendarEventsByDateRange retrieves cached calendar events within a date range func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) { rows, err := s.db.Query(` - SELECT id, summary, description, start_time, end_time, html_link + SELECT id, summary, description, start_time, end_time, html_link, recurring_event_id FROM calendar_events WHERE start_time >= ? AND start_time <= ? ORDER BY start_time ASC @@ -657,7 +657,7 @@ func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.Cal var events []models.CalendarEvent for rows.Next() { var e models.CalendarEvent - if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink); err != nil { + if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink, &e.RecurringEventID); err != nil { return nil, err } events = append(events, e) diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 4d3c8f8..e8af436 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -188,10 +188,12 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store { return store } -// TestGetNativeTasksByDateRange_IncludesOverdue guards against a regression where a native task -// due before the window's start (e.g. yesterday, still incomplete) silently dropped out of the -// widget/timeline the moment the day rolled over, because the query required due_date >= start. -func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { +// TestGetNativeTasksByDateRange_ExcludesOverdue documents the deliberate contract after +// 2026-07-13's reconciliation: GetNativeTasksByDateRange is scoped to [start, end) only. +// Overdue tasks (due before start) are BuildTimeline's job to fetch separately via +// GetOverdueNativeTasks -- see that test below and timeline_logic.go's "6." section -- +// so this function must NOT also return them, or BuildTimeline would double them up. +func TestGetNativeTasksByDateRange_ExcludesOverdue(t *testing.T) { store := setupTestStoreWithNativeTasks(t) now := time.Now() @@ -221,8 +223,8 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { for _, r := range results { ids[r.ID] = true } - if !ids["t-overdue"] { - t.Error("expected overdue task to be included, but it was excluded") + if ids["t-overdue"] { + t.Error("expected overdue task to be excluded from the ranged fetch") } if !ids["t-today"] { t.Error("expected today's task to be included") @@ -232,6 +234,37 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { } } +// TestGetOverdueNativeTasks_IncludesOnlyPastDue is the store-level counterpart to +// TestGetNativeTasksByDateRange_ExcludesOverdue: this is the function BuildTimeline relies on +// to actually surface overdue tasks (see timeline_logic.go's "6." section and +// TestBuildTimeline_IncludesOverdueNativeTasks for the integration-level proof). +func TestGetOverdueNativeTasks_IncludesOnlyPastDue(t *testing.T) { + store := setupTestStoreWithNativeTasks(t) + + now := time.Now() + overdue := now.Add(-48 * time.Hour) + today := now + + for _, task := range []models.Task{ + {ID: "t-overdue", Content: "Overdue task", DueDate: &overdue}, + {ID: "t-today", Content: "Today task", DueDate: &today}, + } { + if err := store.CreateNativeTask(task); err != nil { + t.Fatalf("CreateNativeTask(%s) failed: %v", task.ID, err) + } + } + + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + results, err := store.GetOverdueNativeTasks(start) + if err != nil { + t.Fatalf("GetOverdueNativeTasks failed: %v", err) + } + if len(results) != 1 || results[0].ID != "t-overdue" { + t.Errorf("expected only the overdue task, got %+v", results) + } +} + // TestSaveAndGetGoogleTasks_RoundTripsTimestamps guards against a regression where // due_date/updated_at (TEXT columns, not DATETIME) failed to scan back into time.Time // via sql.NullTime whenever a row had a non-null timestamp. diff --git a/migrations/023_calendar_events_recurring_id.sql b/migrations/023_calendar_events_recurring_id.sql new file mode 100644 index 0000000..e77ddb2 --- /dev/null +++ b/migrations/023_calendar_events_recurring_id.sql @@ -0,0 +1 @@ +ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT ''; |
