From 157a113c397cedbba31b5e56862bad6297c29889 Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Tue, 27 Oct 2026 18:52:54 +0300 Subject: [PATCH] fix timers --- .../kadence/android/data/KadenceRepository.kt | 35 +- .../tech/kadence/android/data/dto/Dtos.kt | 20 +- .../android/data/store/KadencePrefs.kt | 25 + .../tech/kadence/android/timer/TimerEngine.kt | 46 +- .../tech/kadence/android/ui/KadenceApp.kt | 21 + .../android/ui/calendar/FilterPanel.kt | 132 +++++ .../kadence/android/ui/calendar/MonthGrid.kt | 258 ++++++++++ .../tech/kadence/android/ui/tasks/TaskRow.kt | 134 +++++ .../kadence/android/ui/tasks/TasksScreen.kt | 456 +++++++++++------- .../tech/kadence/android/TimerEngineTest.kt | 26 + .../main/kotlin/tech/kadence/core/Calendar.kt | 401 +++++++++++++++ .../main/kotlin/tech/kadence/core/Timer.kt | 5 +- .../tech/kadence/core/CalendarParityTest.kt | 67 +++ .../kotlin/tech/kadence/core/CalendarTest.kt | 325 +++++++++++++ backend/app/api/routes_timer.py | 11 +- backend/app/api/schemas.py | 10 + backend/app/services/pomodoro.py | 26 +- backend/tests/test_api.py | 22 + backend/tests/test_pomodoro.py | 34 ++ desktop/kadence_desktop/api.py | 14 +- desktop/kadence_desktop/controller.py | 37 +- desktop/packaging/kadence.desktop | 2 +- .../src/components/calendar/TaskCalendar.tsx | 387 +++++++++++++++ .../src/components/tasks/AddTaskDialog.tsx | 75 +++ .../src/components/tasks/SourceFilterTree.tsx | 156 ++++++ frontend/src/components/tasks/TaskList.tsx | 88 ++++ frontend/src/components/tasks/TaskRow.tsx | 120 +++++ frontend/src/lib/calendar.ts | 443 +++++++++++++++++ frontend/src/pages/TasksPage.tsx | 348 ++++++------- frontend/src/pages/TimerPage.tsx | 26 +- 30 files changed, 3331 insertions(+), 419 deletions(-) create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/calendar/FilterPanel.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/calendar/MonthGrid.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TaskRow.kt create mode 100644 android/core/src/main/kotlin/tech/kadence/core/Calendar.kt create mode 100644 android/core/src/test/kotlin/tech/kadence/core/CalendarParityTest.kt create mode 100644 android/core/src/test/kotlin/tech/kadence/core/CalendarTest.kt create mode 100644 frontend/src/components/calendar/TaskCalendar.tsx create mode 100644 frontend/src/components/tasks/AddTaskDialog.tsx create mode 100644 frontend/src/components/tasks/SourceFilterTree.tsx create mode 100644 frontend/src/components/tasks/TaskList.tsx create mode 100644 frontend/src/components/tasks/TaskRow.tsx create mode 100644 frontend/src/lib/calendar.ts diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt b/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt index 9774d2f..1a41b49 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt @@ -82,8 +82,19 @@ class KadenceRepository(val api: KadenceApi) { suspend fun resume(): ApiResult = api.post("/api/timer/resume") - suspend fun complete(autoNext: Boolean = true): ApiResult = - api.post("/api/timer/complete", body("auto_next" to autoNext)) + /** + * @param sessionId the interval the caller believes it is finishing. The server + * ignores a call that names an interval which is no longer running — which is + * what stops two clients reaching zero in the same second from completing the + * break the first one's `auto_next` has just begun. Null means unconditional. + */ + suspend fun complete( + autoNext: Boolean = true, + sessionId: Long? = null, + ): ApiResult = api.post( + "/api/timer/complete", + body("auto_next" to autoNext, "session_id" to sessionId), + ) suspend fun abandon(reason: String? = null): ApiResult = api.post("/api/timer/abandon", body("reason" to reason)) @@ -98,7 +109,25 @@ class KadenceRepository(val api: KadenceApi) { // ---- tasks -------------------------------------------------------------- - suspend fun tasks(): ApiResult = api.get("/api/tasks") + /** + * The task list. + * + * The calendar needs history — a past month is only worth looking at if the + * work that finished is on it, and laundry slots go `stale` rather than done + * — so it asks for the archive too. The defaults keep every existing caller + * byte-identical, and both views filter defensively anyway, so whichever + * payload was cached last is still rendered correctly. + */ + suspend fun tasks( + includeDone: Boolean = false, + includeStale: Boolean = false, + ): ApiResult = api.get( + if (includeDone || includeStale) { + "/api/tasks?include_done=$includeDone&include_stale=$includeStale" + } else { + "/api/tasks" + }, + ) /** Fans out to Jira, Todoist, Google and the laundry site; slow by nature. */ suspend fun refreshTasks(): ApiResult = api.post("/api/tasks/refresh") diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt index e6e3891..e5027fe 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt @@ -20,6 +20,7 @@ package tech.kadence.android.data.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import tech.kadence.core.CalendarItem @Serializable data class TaskRefDto( @@ -63,20 +64,25 @@ data class TimerStateDto( @SerialName("timer_enabled") val timerEnabled: Boolean = true, ) +/** + * A task, and — via [CalendarItem] — something the month grid can lay out + * directly, the way [KadenceConfig] is an `AuthConfig`. The alternative was a + * mapping layer between the wire and the grid that would only ever copy fields. + */ @Serializable data class TaskDto( - val id: Long = 0, - val source: String = "", - @SerialName("source_label") val sourceLabel: String = "", + override val id: Long = 0, + override val source: String = "", + @SerialName("source_label") override val sourceLabel: String = "", @SerialName("external_id") val externalId: String = "", val title: String = "", val description: String? = null, val url: String? = null, - val project: String? = null, + override val project: String? = null, val status: String? = null, val priority: String? = null, - @SerialName("due_date") val dueDate: String? = null, - @SerialName("start_date") val startDate: String? = null, + @SerialName("due_date") override val dueDate: String? = null, + @SerialName("start_date") override val startDate: String? = null, val labels: List = emptyList(), @SerialName("is_done") val isDone: Boolean = false, val pinned: Boolean = false, @@ -84,7 +90,7 @@ data class TaskDto( @SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null, @SerialName("days_overdue") val daysOverdue: Int = 0, val bucket: String = "someday", -) +) : CalendarItem @Serializable data class TaskListDto( diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt b/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt index 0e82aaf..ccc9bef 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt @@ -75,6 +75,28 @@ class KadencePrefs(context: Context) { get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L) set(value) = prefs.edit().putLong(KEY_UPDATE_CHECKED_AT, value).apply() + /** + * How the Tasks screen was last left, deliberately outside [KadenceConfig] + * for the same reason as the update bookkeeping above: a view toggle or a + * filter checkbox must not push the config StateFlow and recompose every + * screen in the app. Nothing outside that one screen reads them, and + * `TimerService` certainly does not. + */ + var tasksCalendarView: Boolean + get() = prefs.getBoolean(KEY_TASKS_CALENDAR, false) + set(value) = prefs.edit().putBoolean(KEY_TASKS_CALENDAR, value).apply() + + var tasksShowLong: Boolean + get() = prefs.getBoolean(KEY_TASKS_SHOW_LONG, false) + set(value) = prefs.edit().putBoolean(KEY_TASKS_SHOW_LONG, value).apply() + + /** Hidden source/project keys — never the visible ones. See `isHidden`. */ + var tasksHiddenKeys: Set + // The returned set is owned by SharedPreferences and must not be handed + // out or mutated; copy it on the way through. + get() = prefs.getStringSet(KEY_TASKS_HIDDEN, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(KEY_TASKS_HIDDEN, value.toSet()).apply() + fun update(transform: (KadenceConfig) -> KadenceConfig) { val next = transform(_config.value) prefs.edit().apply { @@ -105,6 +127,9 @@ class KadencePrefs(context: Context) { private const val KEY_ONBOARDED = "onboarded" private const val KEY_UPDATE_SKIPPED = "update_skipped_version" private const val KEY_UPDATE_CHECKED_AT = "update_checked_at" + private const val KEY_TASKS_CALENDAR = "tasks_calendar_view" + private const val KEY_TASKS_SHOW_LONG = "tasks_show_long" + private const val KEY_TASKS_HIDDEN = "tasks_hidden_keys" fun normalizeBaseUrl(raw: String): String { val trimmed = raw.trim().trimEnd('/') diff --git a/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt b/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt index 3eb2788..7c65ce2 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt @@ -22,7 +22,10 @@ * whose time is up — it just reports `remaining_seconds = 0`. A client has to call * `complete`. `desktop/kadence_desktop/controller.py` does the same, with the same * re-entry guard, because without one a 1 Hz tick at zero fires `complete` every second - * until the reply lands. + * until the reply lands. The guard is per interval id and the call names the interval + * it means to finish: the countdown runs out on every client at once, and a complete + * that arrives after someone else's has already rolled the timer on would otherwise + * finish the interval that rolling on created. */ package tech.kadence.android.timer @@ -147,8 +150,17 @@ class TimerEngine( */ private val mutex = Mutex() - /** The `complete` re-entry guard. See the file header. */ - private var completing = false + /** + * The interval we have already asked the server to finish. See the file header. + * + * An id, not a bare flag: a poll issued just before the countdown ran out answers + * with that interval still running at 00:00, and a flag cleared by that answer + * sends `complete` a second time — onto whatever `auto_next` has since started, + * which is how a break could end the instant it began. Ids are never reused, so + * the second attempt is never made; a call that *failed* clears it so the zero + * crossing is still retried. + */ + private var completedId: Long? = null private var pollJob: Job? = null private var tickJob: Job? = null @@ -257,10 +269,12 @@ class TimerEngine( if (remaining != _state.value.remaining) { _state.value = _state.value.copy(remaining = remaining) } - if (remaining == 0 && _state.value.isActive && !current.paused && !completing) { + if (remaining == 0 && _state.value.isActive && !current.paused && + completedId != current.sessionId + ) { // The countdown has run out. The server books the interval and its // reply decides what follows — an auto-started break, or nothing. - completing = true + completedId = current.sessionId complete() } } @@ -369,7 +383,6 @@ class TimerEngine( val current = server.current if (current == null) { anchor = null - completing = false } else { val changed = anchor?.sessionId != current.id || anchor?.paused != current.isPaused || @@ -386,7 +399,6 @@ class TimerEngine( paused = current.isPaused, ) } - if (current.id != _state.value.sessionId) completing = false } _state.value = _state.value.copy( @@ -467,12 +479,15 @@ class TimerEngine( _state.value = before.ended() anchor = null enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before) - completing = false return@launch } + // Naming the interval is what makes this safe to send at all: by the time it + // arrives, another client's countdown may already have finished it and started + // the next one, and an unconditional complete would end *that* instead. + val expected = before.sessionId?.takeIf { it != LOCAL_SESSION_ID } mutex.withLock { _state.value = _state.value.copy(busy = true) - when (val result = repo.complete(autoNext)) { + when (val result = repo.complete(autoNext, expected)) { is ApiResult.Ok -> { val dto = result.value applyServer(dto, fromCommand = true) @@ -493,10 +508,14 @@ class TimerEngine( anchor = null enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before) } - else -> handleFailure(result) + else -> { + // Nothing was booked, so the interval sitting at 00:00 is still + // owed a complete; let the next tick try again. + if (completedId == before.sessionId) completedId = null + handleFailure(result) + } } _state.value = _state.value.copy(busy = false) - completing = false } } @@ -704,7 +723,10 @@ class TimerEngine( ) OutboxOp.PAUSE -> repo.pause() OutboxOp.RESUME -> repo.resume() - OutboxOp.COMPLETE -> repo.complete(autoNext = false) + OutboxOp.COMPLETE -> repo.complete( + autoNext = false, + sessionId = entry.anchorSessionId?.takeIf { it != LOCAL_SESSION_ID }, + ) OutboxOp.ABANDON -> repo.abandon(entry.reason) OutboxOp.EXTEND -> repo.extend(entry.minutes) OutboxOp.INTERRUPT -> repo.interrupt() diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt index 84bd1df..e27d234 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt @@ -69,6 +69,12 @@ fun KadenceApp(app: KadenceApplication) { val updateState by app.updates.state.collectAsStateWithLifecycle() var showOnboarding by remember { mutableStateOf(!config.onboarded) } + // How the Tasks screen was last left. Held here and written straight back to + // prefs rather than kept in `KadenceConfig`, so a filter checkbox does not + // push the config StateFlow and recompose every screen in the app. + var tasksCalendarView by remember { mutableStateOf(app.prefs.tasksCalendarView) } + var tasksShowLong by remember { mutableStateOf(app.prefs.tasksShowLong) } + var tasksHiddenKeys by remember { mutableStateOf(app.prefs.tasksHiddenKeys) } var showUpdateDialog by remember { mutableStateOf(false) } var destination by remember { mutableStateOf(Destination.Timer) } val scope = rememberCoroutineScope() @@ -157,6 +163,21 @@ fun KadenceApp(app: KadenceApplication) { cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()), runningTaskTitle = ui.taskTitle, intervalRunning = ui.isActive, + calendarView = tasksCalendarView, + onSetCalendarView = { next -> + tasksCalendarView = next + app.prefs.tasksCalendarView = next + }, + showLong = tasksShowLong, + onSetShowLong = { next -> + tasksShowLong = next + app.prefs.tasksShowLong = next + }, + hiddenKeys = tasksHiddenKeys, + onSetHiddenKeys = { next -> + tasksHiddenKeys = next + app.prefs.tasksHiddenKeys = next + }, onAttach = { taskId -> // `attach` needs something to attach to: with nothing // running the server answers "No interval is running." diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/calendar/FilterPanel.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/calendar/FilterPanel.kt new file mode 100644 index 0000000..3624e84 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/calendar/FilterPanel.kt @@ -0,0 +1,132 @@ +/** + * Hide whole categories of work: a source, or one project inside it. + * + * The set it edits holds **hidden** keys, never visible ones, so a Jira project + * that first appears after a refresh shows up rather than being filtered out by a + * set written last week. See `isHidden` in `:core`'s `Calendar.kt`. + * + * Switching a source off disables its projects rather than silently unticking + * them: the parent governs, and switching it back on restores the whole subtree + * instead of leaving ghosts from whatever was unticked before. + * + * `ToggleRow` from the shared primitives is deliberately not used here — its + * explanatory `detail` line is mandatory by design, and twenty Jira projects would + * each demand a sentence nobody wants to read. + */ +package tech.kadence.android.ui.calendar + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import tech.kadence.android.ui.components.HorizontalRule +import tech.kadence.android.ui.theme.KadenceTheme +import tech.kadence.core.CalendarItem +import tech.kadence.core.buildFilterTree + +@Composable +fun FilterPanel( + /** The *unfiltered* list — the counts have to describe what is there. */ + items: List, + hidden: Set, + onChange: (Set) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = KadenceTheme.colors + val tree = buildFilterTree(items) + + Column(modifier.fillMaxWidth()) { + if (tree.isEmpty()) { + Text( + text = "Nothing to filter yet.", + style = KadenceTheme.type.bodySmall, + color = colors.inkMuted, + modifier = Modifier.padding(14.dp), + ) + return@Column + } + + tree.forEachIndexed { index, node -> + if (index > 0) HorizontalRule() + val sourceHidden = node.key in hidden + FilterRow( + label = node.label, + count = node.count, + checked = !sourceHidden, + bold = true, + onChange = { visible -> + val next = hidden.toMutableSet() + if (visible) { + // Turning a source back on restores its projects with it. + next.remove(node.key) + node.children.forEach { next.remove(it.key) } + } else { + next.add(node.key) + } + onChange(next) + }, + ) + node.children.forEach { child -> + FilterRow( + label = child.label, + count = child.count, + checked = !sourceHidden && child.key !in hidden, + enabled = !sourceHidden, + indent = true, + onChange = { visible -> + val next = hidden.toMutableSet() + if (visible) next.remove(child.key) else next.add(child.key) + onChange(next) + }, + ) + } + } + } +} + +@Composable +private fun FilterRow( + label: String, + count: Int, + checked: Boolean, + onChange: (Boolean) -> Unit, + bold: Boolean = false, + indent: Boolean = false, + enabled: Boolean = true, +) { + val colors = KadenceTheme.colors + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onChange(!checked) } + .padding(start = if (indent) 32.dp else 8.dp, end = 14.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox(checked = checked, onCheckedChange = onChange, enabled = enabled) + Text( + text = label, + style = KadenceTheme.type.body, + fontWeight = if (bold) FontWeight.SemiBold else null, + color = if (enabled) colors.ink else colors.inkFaint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "$count", + style = KadenceTheme.type.caption, + color = colors.inkFaint, + ) + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/calendar/MonthGrid.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/calendar/MonthGrid.kt new file mode 100644 index 0000000..245f289 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/calendar/MonthGrid.kt @@ -0,0 +1,258 @@ +/** + * The month, as bars from start date to deadline. + * + * Built like a printed table rather than a grid of cards: a hairline between the + * cells, tabular day figures, and exactly one accent — today. + * + * Bars are coloured by **state, not by source**. Five saturated fills across a + * month grid would read as Material rather than Kadence, and five categorical + * hues cannot clear the data-viz palette validator's all-pairs normal-vision + * floor anyway. Identity comes from the title and from the filter; colour is kept + * for what changes how you act on a task — done, overdue, or still waiting. + * + * A lane is a `Row` of weighted cells rather than an absolutely positioned strip: + * the bars are already clipped to the week by `layoutMonth`, so a bar is simply a + * cell of `weight(span)` with `Spacer`s either side. No offset arithmetic, and it + * reflows for free at any width. + */ +package tech.kadence.android.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import tech.kadence.android.data.dto.TaskDto +import tech.kadence.android.ui.components.HorizontalRule +import tech.kadence.android.ui.theme.KadenceTheme +import tech.kadence.core.WeekLayout +import tech.kadence.core.weekdayLabels + +private val BAR_HEIGHT = 18.dp +private val BAR_GAP = 3.dp +private val ROW_MIN = 74.dp + +@Composable +fun MonthGrid( + weeks: List, + /** 1-12 — days outside it are drawn faint. */ + month: Int, + today: String, + tasksById: Map, + selectedDay: String?, + weekStartsMonday: Boolean, + onSelectDay: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = KadenceTheme.colors + Column(modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .background(colors.surfaceSunken) + .padding(vertical = 5.dp), + ) { + weekdayLabels(weekStartsMonday).forEach { name -> + Text( + text = name.take(2), + style = KadenceTheme.type.caption, + color = colors.inkMuted, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + HorizontalRule() + + weeks.forEachIndexed { index, week -> + if (index > 0) HorizontalRule() + WeekRow( + week = week, + month = month, + today = today, + tasksById = tasksById, + selectedDay = selectedDay, + onSelectDay = onSelectDay, + ) + } + } +} + +@Composable +private fun WeekRow( + week: WeekLayout, + month: Int, + today: String, + tasksById: Map, + selectedDay: String?, + onSelectDay: (String) -> Unit, +) { + val colors = KadenceTheme.colors + + Box(Modifier.fillMaxWidth().heightIn(min = ROW_MIN)) { + // The cells themselves: the tap targets and the selection tint, behind + // everything. The bars drawn over them carry no pointer modifiers, so + // they never swallow a tap meant for the day. + Row(Modifier.matchParentSize()) { + week.dates.forEach { date -> + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .background(if (date == selectedDay) colors.accentSoft else Color.Transparent) + .clickable { onSelectDay(date) }, + ) + } + } + + Column(Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { + week.dates.forEach { date -> + val outside = date.substring(5, 7).toInt() != month + Text( + text = date.substring(8, 10).trimStart('0'), + style = KadenceTheme.type.caption, + color = when { + date == today -> colors.accent + outside -> colors.inkFaint + else -> colors.inkMuted + }, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + + repeat(week.lanes) { lane -> + Row(Modifier.fillMaxWidth().height(BAR_HEIGHT)) { + var column = 0 + week.bars + .filter { it.lane == lane } + .sortedBy { it.startCol } + .forEach { bar -> + if (bar.startCol > column) { + Spacer(Modifier.weight((bar.startCol - column).toFloat())) + } + val task = tasksById[bar.id] + val span = (bar.endCol - bar.startCol + 1).toFloat() + if (task == null) { + Spacer(Modifier.weight(span)) + } else { + TaskBar( + task = task, + today = today, + continuesLeft = bar.continuesLeft, + continuesRight = bar.continuesRight, + modifier = Modifier.weight(span), + ) + } + column = bar.endCol + 1 + } + if (column < 7) Spacer(Modifier.weight((7 - column).toFloat())) + } + Spacer(Modifier.height(BAR_GAP)) + } + + if (week.hasOverflow) { + Row(Modifier.fillMaxWidth()) { + week.overflow.forEach { count -> + Text( + text = if (count > 0) "+$count" else "", + style = KadenceTheme.type.caption, + color = colors.inkMuted, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +/** + * One bar. + * + * `daysOverdue` and `bucket` are computed by the backend against *today*, so a + * stale laundry slot from March claims to be two hundred days overdue forever. + * Neither is used here: lateness is decided from the task's own due date, and only + * for something still open and still offered by its source. + */ +@Composable +private fun TaskBar( + task: TaskDto, + today: String, + continuesLeft: Boolean, + continuesRight: Boolean, + modifier: Modifier = Modifier, +) { + val colors = KadenceTheme.colors + val late = !task.isDone && !task.stale && (task.dueDate?.let { it < today } == true) + val muted = task.isDone || task.stale + val square = 0.dp + val round = 4.dp + + Box( + modifier + .fillMaxHeight() + .padding(horizontal = 1.dp) + .clip( + RoundedCornerShape( + topStart = if (continuesLeft) square else round, + bottomStart = if (continuesLeft) square else round, + topEnd = if (continuesRight) square else round, + bottomEnd = if (continuesRight) square else round, + ), + ) + .background(colors.surfaceSunken), + contentAlignment = Alignment.CenterStart, + ) { + Row( + Modifier.fillMaxSize().padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!continuesLeft) { + Box( + Modifier + .width(2.dp) + .fillMaxHeight(0.6f) + .clip(RoundedCornerShape(1.dp)) + .background(if (late) colors.clay else if (muted) colors.inkFaint else colors.inkMuted), + ) + } + Text( + text = task.title, + style = KadenceTheme.type.caption, + color = when { + muted -> colors.inkFaint + late -> colors.clay + else -> colors.ink + }, + textDecoration = if (task.isDone) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TaskRow.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TaskRow.kt new file mode 100644 index 0000000..81c6a1e --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TaskRow.kt @@ -0,0 +1,134 @@ +/** + * One task, as a row. + * + * Moved out of `TasksScreen.kt` unchanged when that screen grew a calendar beside + * the list: the calendar's day detail renders the same row, so a task reads + * identically wherever you meet it. + * + * Read-only, and that is the product decision rather than a shortcut: tapping it + * **opens the task where it lives**, which is where closing it happens; "Track" + * attaches it to the running interval, which is the one thing that belongs to + * Kadence. + */ +package tech.kadence.android.ui.tasks + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import tech.kadence.android.data.dto.TaskDto +import tech.kadence.android.ui.components.Eyebrow +import tech.kadence.android.ui.theme.KadenceTheme +import tech.kadence.core.dueLabel + +@Composable +internal fun TaskRow( + task: TaskDto, + isRunning: Boolean, + anyIntervalRunning: Boolean, + onOpen: () -> Unit, + onTrack: () -> Unit, +) { + val colors = KadenceTheme.colors + val due = dueLabel(task.dueDate, task.daysOverdue) + val linkable = !task.url.isNullOrBlank() + + Row( + Modifier + .fillMaxWidth() + // A local task has no URL and nothing to open; making it look tappable + // and then doing nothing is worse than it being plainly inert. + .then(if (linkable) Modifier.clickable(onClick = onOpen) else Modifier) + .padding(start = 14.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = task.title, + style = KadenceTheme.type.body, + color = if (task.stale || task.isDone) colors.inkMuted else colors.ink, + textDecoration = if (task.isDone) TextDecoration.LineThrough else null, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Eyebrow(task.sourceLabel.ifBlank { task.source }) + if (due.isNotBlank()) { + Text( + text = due, + style = KadenceTheme.type.caption, + // Overdue is the one case worth colouring; everything else is + // a date and a date is not an alarm. `daysOverdue` is computed + // against *today*, so it only means anything for something + // still open — a stale laundry slot carries one forever. + color = if (task.daysOverdue > 0 && !task.isDone && !task.stale) { + colors.clay + } else { + colors.inkFaint + }, + ) + } + task.project?.takeIf { it.isNotBlank() }?.let { project -> + Text( + text = project, + style = KadenceTheme.type.caption, + color = colors.inkFaint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (isRunning) { + Text( + text = "running", + style = KadenceTheme.type.caption, + color = colors.accent, + modifier = Modifier.padding(end = 10.dp), + ) + } else { + TextButton(onClick = onTrack, contentPadding = PaddingValues(horizontal = 10.dp)) { + // "Focus" when the tap will begin an interval on this task, "Track" + // when it only re-points the one already running. + Text( + text = if (anyIntervalRunning) "Track" else "Focus", + style = KadenceTheme.type.button, + color = colors.accent, + ) + } + } + } +} + +/** + * Open the task in whatever app or browser owns it. + * + * `url` is absent for local tasks, and for a source that did not supply one; the row + * is not clickable in that case, so this is only ever reached with something to open. + * Still guarded, because an unparseable URL from a third-party API must not crash the + * list. + */ +internal fun openTask(context: android.content.Context, task: TaskDto) { + val url = task.url?.takeIf { it.isNotBlank() } ?: return + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } + .onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt index 9783b77..e03265a 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt @@ -1,85 +1,119 @@ /** - * Every todo you own, grouped the way the other clients group them. + * Every todo you own — as a list, or as a month. * - * Read-only, and that is the product decision rather than a shortcut: **you cannot tick - * a task off here.** Completion belongs to the tool the task lives in — close it in - * Jira, Todoist or Google Tasks and the next refresh notices. Two places to mark - * something done is two places to be wrong. + * Read-only, and that is the product decision rather than a shortcut: **you cannot + * tick a task off here.** Completion belongs to the tool the task lives in — close + * it in Jira, Todoist or Google Tasks and the next refresh notices. Two places to + * mark something done is two places to be wrong. * - * So a row has exactly two actions and they follow from that. Tapping it **opens the - * task where it lives**, which is where closing it happens; "Track" attaches it to the - * running interval, which is the one thing that belongs to Kadence. A task with no - * `url` — a local one, or a source that supplied none — is simply not tappable rather - * than tappable-and-inert. + * The screen asks the backend for **done and stale rows too**, because a past + * month is only worth looking at if the work that finished is on it, and laundry + * slots go `stale` rather than done. The list therefore filters those out itself + * rather than trusting the URL the payload came from — which also means the single + * `Cached.TASKS` slot stays correct whichever view wrote it last. * - * Bucket order and labels come from `:core`'s `BUCKET_ORDER` / `BUCKET_LABEL`, so the - * phone cannot drift from the web app's wording — including the deliberate choice of - * "Would be nice to start" over the desktop's "In progress". + * Bucket order and labels come from `:core`, and so does the whole calendar + * layout, so the phone cannot drift from the web app. + * + * One Compose constraint worth not re-learning: this is a single `LazyColumn`, and + * both bodies contribute `item`s to *it*. The month grid is a plain `Column` of + * `Row`s inside one item — never a nested `LazyVerticalGrid`, which throws on + * same-axis nested scrolling. */ package tech.kadence.android.ui.tasks -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.dto.TaskDto +import tech.kadence.android.data.dto.TaskListDto import tech.kadence.android.data.net.ApiResult +import tech.kadence.android.data.store.CacheSlot import tech.kadence.android.ui.Loadable +import tech.kadence.android.ui.calendar.FilterPanel +import tech.kadence.android.ui.calendar.MonthGrid import tech.kadence.android.ui.components.Banner -import tech.kadence.android.ui.components.Eyebrow import tech.kadence.android.ui.components.GhostButton import tech.kadence.android.ui.components.HorizontalRule import tech.kadence.android.ui.components.PageHeader import tech.kadence.android.ui.components.Panel +import tech.kadence.android.ui.components.PresetChip import tech.kadence.android.ui.components.SectionTitle -import tech.kadence.android.data.store.CacheSlot import tech.kadence.android.ui.rememberLoad import tech.kadence.android.ui.theme.KadenceTheme import tech.kadence.core.BUCKET_LABEL import tech.kadence.core.BUCKET_ORDER -import tech.kadence.core.dueLabel +import tech.kadence.core.buildModel +import tech.kadence.core.itemsOnDay +import tech.kadence.core.layoutMonth +import tech.kadence.core.monthGrid +import tech.kadence.core.shiftMonth +import tech.kadence.core.todayISO +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +/** + * Monday-first, hardcoded on both clients for now. + * + * `week_starts_monday` is in the settings document, but `AppSettingsDto` does not + * carry it and this screen loads no settings — so honouring it on the web alone is + * how the two clients start disagreeing about what a week is. The backend default + * is Monday anyway. + */ +private const val WEEK_STARTS_MONDAY = true @Composable fun TasksScreen( repo: KadenceRepository, /** Yesterday's list, so a phone with no signal is not an empty screen. */ - cache: CacheSlot? = null, + cache: CacheSlot? = null, runningTaskTitle: String?, /** An interval is running — with or without a task attached to it. */ intervalRunning: Boolean, + calendarView: Boolean, + onSetCalendarView: (Boolean) -> Unit, + showLong: Boolean, + onSetShowLong: (Boolean) -> Unit, + hiddenKeys: Set, + onSetHiddenKeys: (Set) -> Unit, onAttach: (Long) -> Unit, ) { val colors = KadenceTheme.colors val context = LocalContext.current val scope = rememberCoroutineScope() - val loader = rememberLoad(cache = cache) { repo.tasks() } + val loader = rememberLoad(cache = cache) { + repo.tasks(includeDone = true, includeStale = true) + } var refreshing by remember { mutableStateOf(false) } var refreshNote by remember { mutableStateOf(null) } + val today = remember { todayISO() } + var cursorYear by rememberSaveable { mutableStateOf(LocalDate.now().year) } + var cursorMonth by rememberSaveable { mutableStateOf(LocalDate.now().monthValue) } + var selectedDay by rememberSaveable { mutableStateOf(null) } + var filterOpen by rememberSaveable { mutableStateOf(false) } + fun refresh() { refreshing = true refreshNote = null @@ -106,23 +140,21 @@ fun TasksScreen( } val state = loader.value + val all = (state as? Loadable.Loaded)?.value?.tasks.orEmpty() + // Always, regardless of which URL answered: that is what keeps one cache slot + // correct for two views. + val open = all.filter { !it.isDone && !it.stale } LazyColumn( Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - top = 24.dp, - bottom = 32.dp, - ), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { - val total = (state as? Loadable.Loaded)?.value?.tasks?.size PageHeader( eyebrow = "Everything open", title = "Tasks", - caption = total?.let { "$it across your sources" }, + caption = if (state is Loadable.Loaded) "${open.size} across your sources" else null, ) { GhostButton( text = if (refreshing) "Refreshing…" else "Refresh sources", @@ -132,6 +164,13 @@ fun TasksScreen( } } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PresetChip(text = "List", selected = !calendarView, onClick = { onSetCalendarView(false) }) + PresetChip(text = "Calendar", selected = calendarView, onClick = { onSetCalendarView(true) }) + } + } + refreshNote?.let { note -> item { Banner(text = note, actionLabel = "Retry", onAction = ::refresh) } } @@ -155,148 +194,239 @@ fun TasksScreen( item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) } } - val byBucket = state.value.tasks.groupBy { it.bucket } - // BUCKET_ORDER first, then anything the backend invented later — an - // unknown bucket must still be visible rather than silently dropped. - val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } + - byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted() - - if (state.value.tasks.isEmpty()) { - item { - Panel { - Text( - text = "Nothing open. Either you are done, or no source " + - "is configured yet — the backend's Settings page " + - "is where they go.", - style = KadenceTheme.type.bodySmall, - color = colors.inkMuted, - ) - } - } - } - - buckets.forEach { bucket -> - val tasks = byBucket[bucket].orEmpty() - item(key = "head-$bucket") { - SectionTitle( - label = BUCKET_LABEL[bucket] ?: bucket, - trailing = "${tasks.size}", - ) - } - item(key = "body-$bucket") { - Panel(padding = 0.dp) { - tasks.forEachIndexed { index, task -> - if (index > 0) HorizontalRule() - TaskRow( - task = task, - isRunning = task.title == runningTaskTitle, - anyIntervalRunning = intervalRunning, - // Tapping the row opens the task where it lives — - // Jira, Todoist, Google Tasks. That is where it - // gets *closed*, and this app deliberately cannot - // close it, so sending you there is the whole of - // what a tap should do. - onOpen = { openTask(context, task) }, - // Attaching is the separate, explicit action. - onTrack = { onAttach(task.id) }, - ) - } - } - } + if (calendarView) { + calendarBody( + all = all, + hiddenKeys = hiddenKeys, + onSetHiddenKeys = onSetHiddenKeys, + showLong = showLong, + onSetShowLong = onSetShowLong, + today = today, + year = cursorYear, + month = cursorMonth, + onShiftMonth = { delta -> + val (y, m) = shiftMonth(cursorYear, cursorMonth, delta) + cursorYear = y + cursorMonth = m + selectedDay = null + }, + onToday = { + cursorYear = LocalDate.now().year + cursorMonth = LocalDate.now().monthValue + selectedDay = null + }, + selectedDay = selectedDay, + onSelectDay = { selectedDay = if (it == selectedDay) null else it }, + filterOpen = filterOpen, + onToggleFilter = { filterOpen = !filterOpen }, + runningTaskTitle = runningTaskTitle, + intervalRunning = intervalRunning, + onOpenTask = { openTask(context, it) }, + onAttach = onAttach, + ) + } else { + listBody( + tasks = open, + runningTaskTitle = runningTaskTitle, + intervalRunning = intervalRunning, + onOpenTask = { openTask(context, it) }, + onAttach = onAttach, + ) } } } } } -@Composable -private fun TaskRow( - task: TaskDto, - isRunning: Boolean, - anyIntervalRunning: Boolean, - onOpen: () -> Unit, - onTrack: () -> Unit, +/** The original view: grouped by deadline bucket. */ +private fun LazyListScope.listBody( + tasks: List, + runningTaskTitle: String?, + intervalRunning: Boolean, + onOpenTask: (TaskDto) -> Unit, + onAttach: (Long) -> Unit, ) { - val colors = KadenceTheme.colors - val due = dueLabel(task.dueDate, task.daysOverdue) - val linkable = !task.url.isNullOrBlank() + val byBucket = tasks.groupBy { it.bucket } + // BUCKET_ORDER first, then anything the backend invented later — an unknown + // bucket must still be visible rather than silently dropped. + val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } + + byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted() - Row( - Modifier - .fillMaxWidth() - // A local task has no URL and nothing to open; making it look tappable - // and then doing nothing is worse than it being plainly inert. - .then(if (linkable) Modifier.clickable(onClick = onOpen) else Modifier) - .padding(start = 14.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Column(Modifier.weight(1f)) { - Text( - text = task.title, - style = KadenceTheme.type.body, - color = if (task.stale) colors.inkMuted else colors.ink, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(2.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Eyebrow(task.sourceLabel.ifBlank { task.source }) - if (due.isNotBlank()) { - Text( - text = due, - style = KadenceTheme.type.caption, - // Overdue is the one case worth colouring; everything else is - // a date and a date is not an alarm. - color = if (task.daysOverdue > 0) colors.clay else colors.inkFaint, - ) - } - task.project?.takeIf { it.isNotBlank() }?.let { project -> - Text( - text = project, - style = KadenceTheme.type.caption, - color = colors.inkFaint, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - if (isRunning) { - Text( - text = "running", - style = KadenceTheme.type.caption, - color = colors.accent, - modifier = Modifier.padding(end = 10.dp), - ) - } else { - TextButton(onClick = onTrack, contentPadding = PaddingValues(horizontal = 10.dp)) { - // "Focus" when the tap will begin an interval on this task, "Track" - // when it only re-points the one already running. + if (tasks.isEmpty()) { + item { + Panel { Text( - text = if (anyIntervalRunning) "Track" else "Focus", - style = KadenceTheme.type.button, - color = colors.accent, + text = "Nothing open. Either you are done, or no source is " + + "configured yet — the backend's Settings page is where they go.", + style = KadenceTheme.type.bodySmall, + color = KadenceTheme.colors.inkMuted, ) } } } + + buckets.forEach { bucket -> + val inBucket = byBucket[bucket].orEmpty() + item(key = "head-$bucket") { + SectionTitle(label = BUCKET_LABEL[bucket] ?: bucket, trailing = "${inBucket.size}") + } + item(key = "body-$bucket") { + Panel(padding = 0.dp) { + inBucket.forEachIndexed { index, task -> + if (index > 0) HorizontalRule() + TaskRow( + task = task, + isRunning = task.title == runningTaskTitle, + anyIntervalRunning = intervalRunning, + onOpen = { onOpenTask(task) }, + onTrack = { onAttach(task.id) }, + ) + } + } + } + } } -/** - * Open the task in whatever app or browser owns it. - * - * `url` is absent for local tasks, and for a source that did not supply one; the row - * is not clickable in that case, so this is only ever reached with something to open. - * Still guarded, because an unparseable URL from a third-party API must not crash the - * list. - */ -private fun openTask(context: android.content.Context, task: TaskDto) { - val url = task.url?.takeIf { it.isNotBlank() } ?: return - val intent = android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(url), - ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - runCatching { context.startActivity(intent) } - .onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") } +private val MONTH_LABEL = DateTimeFormatter.ofPattern("LLLL yyyy") + +private fun LazyListScope.calendarBody( + all: List, + hiddenKeys: Set, + onSetHiddenKeys: (Set) -> Unit, + showLong: Boolean, + onSetShowLong: (Boolean) -> Unit, + today: String, + year: Int, + month: Int, + onShiftMonth: (Int) -> Unit, + onToday: () -> Unit, + selectedDay: String?, + onSelectDay: (String) -> Unit, + filterOpen: Boolean, + onToggleFilter: () -> Unit, + runningTaskTitle: String?, + intervalRunning: Boolean, + onOpenTask: (TaskDto) -> Unit, + onAttach: (Long) -> Unit, +) { + val model = buildModel(all, hiddenKeys, showLong) + val weeks = monthGrid(year, month, WEEK_STARTS_MONDAY) + // Showing long bars needs the room they take, or they simply push the day's + // real work into "+N" and the month gets less informative, not more. + val layouts = layoutMonth(weeks, model.visible, maxLanes = if (showLong) 4 else 3) + val byId = model.visible.associateBy { it.id } + + item(key = "cal-toolbar") { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + GhostButton(text = "‹", onClick = { onShiftMonth(-1) }) + Text( + text = YearMonth.of(year, month).format(MONTH_LABEL), + style = KadenceTheme.type.statSmall, + color = KadenceTheme.colors.ink, + modifier = Modifier.weight(1f), + ) + GhostButton(text = "›", onClick = { onShiftMonth(1) }) + GhostButton(text = "Today", onClick = onToday) + } + } + + item(key = "cal-filters") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PresetChip( + text = if (hiddenKeys.isEmpty()) "Filter" else "Filter (${hiddenKeys.size})", + selected = filterOpen, + onClick = onToggleFilter, + ) + PresetChip( + text = "Long tasks (${model.longCount})", + selected = showLong, + onClick = { onSetShowLong(!showLong) }, + ) + } + } + + if (filterOpen) { + item(key = "cal-filter-panel") { + Panel(padding = 0.dp) { + FilterPanel(items = all, hidden = hiddenKeys, onChange = onSetHiddenKeys) + } + } + } + + item(key = "cal-grid") { + Panel(padding = 0.dp) { + MonthGrid( + weeks = layouts, + month = month, + today = today, + tasksById = byId, + selectedDay = selectedDay, + weekStartsMonday = WEEK_STARTS_MONDAY, + onSelectDay = onSelectDay, + ) + } + } + + // The tapped day's work, under the grid. Items of the *outer* LazyColumn, not + // a nested lazy list. + selectedDay?.let { day -> + val onDay = itemsOnDay(model.visible, day) + item(key = "cal-day-head") { + SectionTitle( + label = LocalDate.parse(day).format(DAY_LABEL), + trailing = "${onDay.size}", + ) + } + item(key = "cal-day-body") { + Panel(padding = 0.dp) { + if (onDay.isEmpty()) { + Text( + text = "Nothing on this day.", + style = KadenceTheme.type.bodySmall, + color = KadenceTheme.colors.inkMuted, + modifier = Modifier.padding(14.dp), + ) + } else { + onDay.forEachIndexed { index, task -> + if (index > 0) HorizontalRule() + TaskRow( + task = task, + isRunning = task.title == runningTaskTitle, + anyIntervalRunning = intervalRunning, + onOpen = { onOpenTask(task) }, + onTrack = { onAttach(task.id) }, + ) + } + } + } + } + } + + // The tasks a calendar cannot place: no due date, so no column to sit in. + // Jira keeps the same affordance and calls it the backlog. + if (model.undated.isNotEmpty()) { + item(key = "cal-undated-head") { + SectionTitle(label = "No due date", trailing = "${model.undated.size}") + } + item(key = "cal-undated-body") { + Panel(padding = 0.dp) { + model.undated.forEachIndexed { index, task -> + if (index > 0) HorizontalRule() + TaskRow( + task = task, + isRunning = task.title == runningTaskTitle, + anyIntervalRunning = intervalRunning, + onOpen = { onOpenTask(task) }, + onTrack = { onAttach(task.id) }, + ) + } + } + } + } } + +private val DAY_LABEL = DateTimeFormatter.ofPattern("EEEE, d MMMM") diff --git a/android/app/src/test/kotlin/tech/kadence/android/TimerEngineTest.kt b/android/app/src/test/kotlin/tech/kadence/android/TimerEngineTest.kt index 33ddeb8..002fd27 100644 --- a/android/app/src/test/kotlin/tech/kadence/android/TimerEngineTest.kt +++ b/android/app/src/test/kotlin/tech/kadence/android/TimerEngineTest.kt @@ -79,6 +79,8 @@ class TimerEngineTest { path.endsWith("/api/timer/pause") -> running(pauseReplyStatus, remaining = 900) path.endsWith("/api/timer/resume") -> running("running") path.endsWith("/api/timer/extend") -> running("running", 1404, 1800) + path.endsWith("/api/timer/complete") -> + fixture("timer_complete_reply.json") else -> fixture("timer_state_running.json") } return MockResponse.Builder() @@ -140,6 +142,30 @@ class TimerEngineTest { assertTrue(engine.state.value.interruptions >= 1) } + @Test + fun `complete names the interval it is finishing`() { + // Every client runs its own countdown, so they all reach zero together. An + // unconditional complete from the slowest of them lands on the break the + // fastest one's auto-start has just created and ends it on the spot — which + // is what made breaks look like they turned straight into focus. Naming the + // interval is what lets the server ignore the late call. + val engine = engine() + + runBlocking { engine.complete().join() } + + val sent = server.takeRequest().let { first -> + // The first recorded request is the fixture poll from `engine()`. + if (first.url.encodedPath.endsWith("/api/timer/complete")) first + else server.takeRequest() + } + assertEquals("/api/timer/complete", sent.url.encodedPath) + assertTrue( + sent.body?.utf8()?.contains("\"session_id\":412") == true, + "the complete must name interval 412, not just say 'finish whatever is running'", + ) + assertEquals(413L, engine.state.value.sessionId, "the auto-started break is adopted") + } + @Test fun `toggle reads the state it just changed`() { val engine = engine() diff --git a/android/core/src/main/kotlin/tech/kadence/core/Calendar.kt b/android/core/src/main/kotlin/tech/kadence/core/Calendar.kt new file mode 100644 index 0000000..74a94b1 --- /dev/null +++ b/android/core/src/main/kotlin/tech/kadence/core/Calendar.kt @@ -0,0 +1,401 @@ +/** + * The month grid, and the two filters that make it readable. + * + * A literal port of `frontend/src/lib/calendar.ts`, the same way `Format.kt` is a + * literal port of `format.ts`. `CalendarTest` asserts the outputs the TypeScript + * produces; `CalendarParityTest` reads `LONG_TASK_DAYS` and the exported names + * straight out of the web source and fails the build when the two drift. + * + * A calendar of *tasks* is not a calendar of events: nothing here has a time of + * day, only a start date and a due date, so every item is a whole-day bar and the + * only interesting layout question is which lane it sits in. + * + * Three rules exist because of what the sources actually return rather than for + * their own sake: + * + * * **Long tasks are measured on their true span, before clipping.** Clipping to + * the week first would make a fifteen-day bar look like seven days in each of + * three rows, so it would never trip the filter — the entire point of it. + * * **The filter stores hidden keys, never visible ones**, so a Jira project that + * appears for the first time after a refresh shows up instead of being filtered + * out by a set written last week. + * * **A due date is what makes a task plottable** — see [spanOf]. + * + * Nothing here may key off `bucket` or `days_overdue`: the backend computes both + * against *today*, so a stale laundry slot from March reports `overdue` with a + * `days_overdue` that grows forever. In a cell you are looking at in a past month + * that is noise — style from the cell's own date against `is_done`/`stale`. + */ +package tech.kadence.core + +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** Longer than this many days and a bar is "long". Mirrored in `calendar.ts`. */ +const val LONG_TASK_DAYS = 7 + +/** + * What the layout needs off a task. + * + * An interface rather than a data class so `TaskDto` can simply be one, the way + * `KadenceConfig` is an `AuthConfig` — no mapping layer between the wire and the + * grid. + */ +interface CalendarItem { + val id: Long + val source: String + val sourceLabel: String + val project: String? + val startDate: String? + val dueDate: String? +} + +/** Inclusive, so a one-day task is `days == 1`. */ +data class Span(val start: String, val end: String, val days: Int) + +// -------------------------------------------------------------------------- +// Dates +// -------------------------------------------------------------------------- +// ISO day strings compare lexicographically in calendar order, so ordering and +// overlap tests never need a date object at all. Only arithmetic does. + +/** Whole days from [from] to [to]. */ +fun diffDays(from: String, to: String): Int = + ChronoUnit.DAYS.between(LocalDate.parse(from), LocalDate.parse(to)).toInt() + +fun addDays(iso: String, days: Int): String = LocalDate.parse(iso).plusDays(days.toLong()).toString() + +fun todayISO(): String = LocalDate.now().toString() + +private fun span(start: String, end: String): Span = Span(start, end, diffDays(start, end) + 1) + +/** + * Where a task sits on the calendar, or null if it cannot be plotted. + * + * **A due date is what makes a task plottable.** A task with only a start date is + * deliberately *not* drawn, which is the one rule here that costs something and is + * worth the cost: the Jira JQL in `backend/app/sources/jira.py` selects issues + * whose start date is today-or-earlier with no lower bound, and Jira issues very + * often carry a start date and no due date. Drawing those on their start day puts + * work the user thinks of as "now" into a cell four months back — a confident + * wrong answer, which is worse than an honest absence. They come back as undated + * instead and get their own list. + * + * A due date *before* the start date is bad data — Jira permits it — and collapses + * to the deadline, because the deadline is the half that matters. + */ +fun spanOf(item: CalendarItem): Span? { + val due = item.dueDate?.takeIf { it.isNotBlank() } ?: return null + val start = item.startDate?.takeIf { it.isNotBlank() } + if (start == null || due < start) return span(due, due) + return span(start, due) +} + +fun isLong(value: Span): Boolean = value.days > LONG_TASK_DAYS + +// -------------------------------------------------------------------------- +// The grid +// -------------------------------------------------------------------------- + +/** + * The weeks a month needs — four to six, never a fixed six. + * + * October 2026 starts on a Thursday and ends on a Saturday, so Monday-first it + * runs 28 September to 1 November in five rows. February 2027 begins on a Monday + * and needs only four. Padding either to six hangs an empty week off the bottom + * of the page, which reads as a bug. + * + * [month] is 1-12. + */ +fun monthGrid(year: Int, month: Int, weekStartsMonday: Boolean): List> { + val first = LocalDate.of(year, month, 1) + // `DayOfWeek.value` is Monday=1..Sunday=7; the TypeScript's `getDay()` is + // Sunday=0..Saturday=6. This is that conversion, and it is the one line in + // the port where the two languages genuinely disagree. + val weekday = first.dayOfWeek.value % 7 + val offset = if (weekStartsMonday) (weekday + 6) % 7 else weekday + val lastDay = first.lengthOfMonth() + + val weeks = mutableListOf>() + var cursor = 1 - offset + while (cursor <= lastDay) { + val row = (0 until 7).map { first.plusDays((cursor - 1 + it).toLong()).toString() } + weeks.add(row) + cursor += 7 + } + return weeks +} + +private val WEEKDAYS_FROM_MONDAY = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + +fun weekdayLabels(weekStartsMonday: Boolean): List = + if (weekStartsMonday) WEEKDAYS_FROM_MONDAY + else listOf(WEEKDAYS_FROM_MONDAY[6]) + WEEKDAYS_FROM_MONDAY.subList(0, 6) + +/** `shiftMonth(2026, 1, -1)` is December 2025. [month] is 1-12 throughout. */ +fun shiftMonth(year: Int, month: Int, delta: Int): Pair { + val zero = year * 12 + (month - 1) + delta + return Pair(Math.floorDiv(zero, 12), Math.floorMod(zero, 12) + 1) +} + +// -------------------------------------------------------------------------- +// Lane packing +// -------------------------------------------------------------------------- + +data class Bar( + val id: Long, + val lane: Int, + /** Inclusive column indices into the week, 0-6. */ + val startCol: Int, + val endCol: Int, + /** The task began before this week / ends after it — square that edge off. */ + val continuesLeft: Boolean, + val continuesRight: Boolean, +) + +data class WeekLayout( + val dates: List, + val bars: List, + /** Per column, how many tasks this week could not draw — the "+N" counts. */ + val overflow: List, + /** Lanes actually drawn, so a row can size itself to its content. */ + val lanes: Int, + /** Whether any day in this week needs the "+N" strip under the bars. */ + val hasOverflow: Boolean, +) + +private class Placed(val item: CalendarItem, val span: Span) + +/** + * Lay out a whole month, keeping a multi-week bar in one lane throughout. + * + * This has to be a month-at-a-time function rather than a week-at-a-time one, + * because the lane a continuing bar occupies is information that flows from one + * week to the next. Packing each week independently — however the segments are + * sorted — lets a bar step between lanes halfway along. With A spanning 2 to 18 + * March and B spanning 9 March to 2 April, A owns lane 0 in the first week; in the + * second both clip to Monday, and *any* tie-break that prefers B (longest-first + * does) moves A down to lane 1 mid-bar. + * + * So, FullCalendar's rule, in two phases per week: + * + * 1. **Continuations keep the lane they already hold.** This can never collide: + * every bar continuing into this week covers its first day, so every pair of + * them also overlapped on the previous week's last day, and they were given + * distinct lanes there. + * 2. **Everything else competes** for the lowest free lane, longest first. + * + * Note that `continuesLeft` is geometry (the span starts before this week) while + * "is a continuation" is bookkeeping (we already gave it a lane). They are not the + * same, and conflating them breaks the first visible week: a bar running in from + * before the grid has no lane yet, so it competes as a newcomer while still + * drawing with an open left edge. + * + * A task that finds no free lane is not drawn and is counted against **every** day + * it covers, because each cell's "+2" has to describe that cell — it is what opens + * when you tap it. It is deliberately given no lane, so next week it competes + * again and may well win one. + * + * [maxLanes] is a per-client layout budget, not a shared rule: it is a required + * argument with no default so that neither client can drift into its own idea of + * what "the" value is. + */ +fun layoutMonth( + weeks: List>, + items: List, + maxLanes: Int, +): List { + val placed = items.mapNotNull { item -> spanOf(item)?.let { Placed(item, it) } } + val laneOf = mutableMapOf() + val layouts = mutableListOf() + + for (week in weeks) { + val weekStart = week[0] + val weekEnd = week[6] + val inWeek = placed.filter { it.span.start <= weekEnd && it.span.end >= weekStart } + + val occupied = Array(maxLanes) { BooleanArray(7) } + val bars = mutableListOf() + val overflow = IntArray(7) + + fun columns(entry: Placed): Pair = Pair( + if (entry.span.start < weekStart) 0 else diffDays(weekStart, entry.span.start), + if (entry.span.end > weekEnd) 6 else diffDays(weekStart, entry.span.end), + ) + + fun claim(lane: Int, from: Int, to: Int) { + for (column in from..to) occupied[lane][column] = true + } + + fun free(lane: Int, from: Int, to: Int): Boolean { + for (column in from..to) if (occupied[lane][column]) return false + return true + } + + fun barFor(entry: Placed, lane: Int, from: Int, to: Int) = Bar( + id = entry.item.id, + lane = lane, + startCol = from, + endCol = to, + continuesLeft = entry.span.start < weekStart, + continuesRight = entry.span.end > weekEnd, + ) + + // Phase 1 — continuations reclaim their lane, in lane order. + val continuing = inWeek + .filter { it.span.start < weekStart && laneOf.containsKey(it.item.id) } + .sortedBy { laneOf.getValue(it.item.id) } + for (entry in continuing) { + val lane = laneOf.getValue(entry.item.id) + val (from, to) = columns(entry) + claim(lane, from, to) + bars.add(barFor(entry, lane, from, to)) + } + + // Phase 2 — the rest take the lowest lane that fits. + val seen = continuing.map { it.item.id }.toSet() + val starting = inWeek + .filter { it.item.id !in seen } + .sortedWith( + compareByDescending { it.span.days } + .thenBy { it.span.start } + .thenBy { it.item.id }, + ) + for (entry in starting) { + val (from, to) = columns(entry) + val lane = (0 until maxLanes).firstOrNull { free(it, from, to) } + if (lane == null) { + for (column in from..to) overflow[column] += 1 + continue + } + claim(lane, from, to) + laneOf[entry.item.id] = lane + bars.add(barFor(entry, lane, from, to)) + } + + layouts.add( + WeekLayout( + dates = week, + bars = bars, + overflow = overflow.toList(), + lanes = bars.maxOfOrNull { it.lane + 1 } ?: 0, + hasOverflow = overflow.any { it > 0 }, + ), + ) + } + + return layouts +} + +// -------------------------------------------------------------------------- +// The type filter +// -------------------------------------------------------------------------- + +data class FilterNode( + /** `"jira"` for a source, `"jira/MIPT"` for one of its projects. */ + val key: String, + val label: String, + val count: Int, + val children: List, +) + +private fun projectKey(source: String, project: String): String = "$source/$project" + +/** + * Sources, each expanded into the projects seen under it. + * + * Only tasks that *name* a project get a child node, so a source's children can + * sum to less than its own count; those tasks are reachable by hiding the whole + * source, which is the honest thing a "(none)" bucket would only dress up. + */ +fun buildFilterTree(items: List): List { + class Entry(var label: String) { + var count = 0 + val projects = linkedMapOf() + } + + val sources = linkedMapOf() + for (item in items) { + val entry = sources.getOrPut(item.source) { + Entry(item.sourceLabel.ifBlank { item.source }) + } + entry.count += 1 + val project = item.project?.trim().orEmpty() + if (project.isNotEmpty()) entry.projects[project] = (entry.projects[project] ?: 0) + 1 + } + + return sources.entries + .sortedBy { it.value.label } + .map { (source, entry) -> + FilterNode( + key = source, + label = entry.label, + count = entry.count, + children = entry.projects.entries + .sortedBy { it.key } + .map { (project, count) -> + FilterNode(projectKey(source, project), project, count, emptyList()) + }, + ) + } +} + +/** Hiding a source hides its projects with it. */ +fun isHidden(item: CalendarItem, hidden: Set): Boolean { + if (item.source in hidden) return true + val project = item.project?.trim().orEmpty() + return project.isNotEmpty() && projectKey(item.source, project) in hidden +} + +data class CalendarModel( + /** What the grid draws. */ + val visible: List, + /** Passing the type filter but carrying no due date, so nowhere to draw them. */ + val undated: List, + /** Spanning more than a week — the toggle's count, whether or not it is on. */ + val longCount: Int, +) + +/** + * One pass over the list for everything the toolbar and the grid need. + * + * [longCount] deliberately ignores [showLong], so the toggle's own label does not + * change the moment you press it. + */ +fun buildModel( + items: List, + hidden: Set, + showLong: Boolean, +): CalendarModel { + val visible = mutableListOf() + val undated = mutableListOf() + var longCount = 0 + + for (item in items) { + if (isHidden(item, hidden)) continue + val value = spanOf(item) + if (value == null) { + undated.add(item) + continue + } + if (isLong(value)) { + longCount += 1 + if (!showLong) continue + } + visible.add(item) + } + + return CalendarModel(visible, undated, longCount) +} + +/** The tasks touching one day, in the order the grid stacks them. */ +fun itemsOnDay(items: List, day: String): List = + items + .mapNotNull { item -> spanOf(item)?.let { Pair(item, it) } } + .filter { (_, span) -> span.start <= day && span.end >= day } + .sortedWith( + compareBy> { it.second.start } + .thenByDescending { it.second.days } + .thenBy { it.first.id }, + ) + .map { it.first } diff --git a/android/core/src/main/kotlin/tech/kadence/core/Timer.kt b/android/core/src/main/kotlin/tech/kadence/core/Timer.kt index fa1690c..50d6c75 100644 --- a/android/core/src/main/kotlin/tech/kadence/core/Timer.kt +++ b/android/core/src/main/kotlin/tech/kadence/core/Timer.kt @@ -11,7 +11,10 @@ * * `POST /api/timer/start` **abandons** whatever is running, with reason * `"superseded"` — so a queued start is destructive, not additive. * * `POST /api/timer/complete` may **auto-start the next interval**, so a reply can - * contain an interval nobody explicitly asked for. + * contain an interval nobody explicitly asked for. It also takes a `session_id`: + * the interval the caller means to finish. Without one the call is unconditional, + * and two clients hitting zero in the same second end up finishing two intervals — + * the second call landing on the break the first one's auto-start had just begun. * * There is **no stop endpoint**: stopping is `abandon`, and under `strict_mode` a * work interval refuses to be abandoned without a reason. */ diff --git a/android/core/src/test/kotlin/tech/kadence/core/CalendarParityTest.kt b/android/core/src/test/kotlin/tech/kadence/core/CalendarParityTest.kt new file mode 100644 index 0000000..6a95f02 --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/CalendarParityTest.kt @@ -0,0 +1,67 @@ +package tech.kadence.core + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The calendar algorithm, checked against the web app's own source. + * + * `Calendar.kt` is a hand port of `frontend/src/lib/calendar.ts`, and the two can + * only drift silently. This does what `TokenParityTest` does for the palette: + * reads the actual TypeScript and fails when it disagrees. + * + * It can check two things honestly. **`LONG_TASK_DAYS` is a shared product rule** + * — a week means a week on both clients — so its value is asserted. And **every + * exported function must exist on both sides**, which is weak but catches the + * real failure mode: a rule added to one copy and forgotten in the other. + * + * `maxLanes` is deliberately *not* checked. It is a per-client layout budget + * (the phone has less room than a browser), so pinning it across clients would + * encode a false equivalence — which is why it is a required argument with no + * default on either side. + * + * If `frontend/` is ever absent (a checkout of this module alone), these skip + * rather than fail — they are a guard, not a dependency. + */ +class CalendarParityTest { + + private val repoRoot: File? = generateSequence(File("").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "frontend/src/lib/calendar.ts").isFile } + + private val calendarTs: File? = repoRoot?.let { File(it, "frontend/src/lib/calendar.ts") } + + @Test + fun `the parity guard actually found the web app`() { + // Without this the whole file silently passes by skipping forever. + assertTrue( + calendarTs != null || System.getenv("CI") == null, + "frontend/src/lib/calendar.ts was not found from ${File("").absolutePath}", + ) + } + + @Test + fun `the long-task threshold matches calendar ts`() { + val source = calendarTs?.readText() ?: return + val declared = Regex("""export const LONG_TASK_DAYS\s*=\s*(\d+)""") + .find(source) + ?.groupValues + ?.get(1) + ?.toInt() + assertEquals(LONG_TASK_DAYS, declared, "LONG_TASK_DAYS drifted from calendar.ts") + } + + @Test + fun `every exported function has a kotlin counterpart`() { + val source = calendarTs?.readText() ?: return + val exported = Regex("""export function (\w+)""").findAll(source).map { it.groupValues[1] }.toSortedSet() + assertTrue(exported.isNotEmpty(), "could not read the exports out of calendar.ts") + + val kotlin = File(repoRoot, "android/core/src/main/kotlin/tech/kadence/core/Calendar.kt").readText() + val missing = exported.filterNot { name -> + Regex("""fun (<[^>]+> )?$name\b""").containsMatchIn(kotlin) + } + assertEquals(emptyList(), missing, "calendar.ts exports these and Calendar.kt does not") + } +} diff --git a/android/core/src/test/kotlin/tech/kadence/core/CalendarTest.kt b/android/core/src/test/kotlin/tech/kadence/core/CalendarTest.kt new file mode 100644 index 0000000..5c9886a --- /dev/null +++ b/android/core/src/test/kotlin/tech/kadence/core/CalendarTest.kt @@ -0,0 +1,325 @@ +package tech.kadence.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The calendar layout, against hand-written items. + * + * There is no test runner on the web side, so this suite is the only place the + * algorithm is actually executed — `calendar.ts` and `Calendar.kt` are the same + * code written twice and only this copy runs. Every case here is a bug that was + * either found in review or is cheap to reintroduce. + */ +class CalendarTest { + + private data class Item( + override val id: Long, + override val dueDate: String? = null, + override val startDate: String? = null, + override val source: String = "jira", + override val sourceLabel: String = "Jira", + override val project: String? = null, + ) : CalendarItem + + private fun laneOf(layouts: List, week: Int, id: Long): Int? = + layouts[week].bars.firstOrNull { it.id == id }?.lane + + // -- the grid ---------------------------------------------------------- + + @Test + fun `october 2026 runs from 28 september to 1 november`() { + val weeks = monthGrid(2026, 10, weekStartsMonday = true) + assertEquals(5, weeks.size, "October 2026 needs five rows, not a padded six") + assertEquals("2026-09-28", weeks.first().first()) + assertEquals("2026-11-01", weeks.last().last()) + assertTrue(weeks.all { it.size == 7 }) + } + + @Test + fun `a sunday-first grid shifts the month by one day`() { + val monday = monthGrid(2026, 10, weekStartsMonday = true) + val sunday = monthGrid(2026, 10, weekStartsMonday = false) + assertEquals("2026-09-28", monday.first().first()) + assertEquals("2026-09-27", sunday.first().first()) + } + + @Test + fun `a 28-day month starting on its own first weekday needs only four rows`() { + // February 2026 begins on a Sunday; February 2027 begins on a Monday. + assertEquals(4, monthGrid(2026, 2, weekStartsMonday = false).size) + assertEquals(4, monthGrid(2027, 2, weekStartsMonday = true).size) + } + + @Test + fun `shiftMonth crosses the year in both directions`() { + assertEquals(Pair(2025, 12), shiftMonth(2026, 1, -1)) + assertEquals(Pair(2027, 1), shiftMonth(2026, 12, 1)) + assertEquals(Pair(2026, 10), shiftMonth(2026, 10, 0)) + } + + // -- spans ------------------------------------------------------------- + + @Test + fun `a span needs a due date`() { + assertEquals(Span("2026-10-19", "2026-10-21", 3), spanOf(Item(1, dueDate = "2026-10-21", startDate = "2026-10-19"))) + assertEquals(Span("2026-10-21", "2026-10-21", 1), spanOf(Item(2, dueDate = "2026-10-21"))) + // Start-only is the Jira backlog: a start date with no deadline, often + // months old. Drawing it on its start day is a confident wrong answer. + assertNull(spanOf(Item(3, startDate = "2026-06-01"))) + assertNull(spanOf(Item(4))) + } + + @Test + fun `a due date before the start date collapses to the deadline`() { + assertEquals( + Span("2026-10-19", "2026-10-19", 1), + spanOf(Item(1, dueDate = "2026-10-19", startDate = "2026-10-25")), + ) + } + + @Test + fun `exactly a week is not long`() { + val seven = spanOf(Item(1, startDate = "2026-10-19", dueDate = "2026-10-25"))!! + val eight = spanOf(Item(2, startDate = "2026-10-19", dueDate = "2026-10-26"))!! + assertEquals(7, seven.days) + assertFalse(isLong(seven)) + assertEquals(8, eight.days) + assertTrue(isLong(eight)) + } + + @Test + fun `a long span stays long in the middle of its run`() { + // The bar below covers a whole week of March with nothing sticking out. + // Measured on the clipped segment it would read as exactly seven days and + // escape the filter in that month only. + val long = spanOf(Item(1, startDate = "2026-03-02", dueDate = "2026-04-02"))!! + assertTrue(isLong(long)) + } + + // -- lane packing ------------------------------------------------------ + + @Test + fun `a multi-week bar keeps its lane when a longer one starts beside it`() { + // The review counterexample. Packing each week independently and sorting + // longest-first hands lane 0 to B in week two and steps A down mid-bar. + val a = Item(1, startDate = "2026-03-02", dueDate = "2026-03-18") // 17 days + val b = Item(2, startDate = "2026-03-09", dueDate = "2026-04-02") // 25 days + val weeks = monthGrid(2026, 3, weekStartsMonday = true) + val layouts = layoutMonth(weeks, listOf(a, b), maxLanes = 4) + + // Weeks of March 2026, Monday-first: [Feb23, Mar2, Mar9, Mar16, Mar23, Mar30] + assertEquals(0, laneOf(layouts, 1, 1L), "A owns lane 0 in its first week") + assertEquals(0, laneOf(layouts, 2, 1L), "A must still own lane 0 beside B") + assertEquals(0, laneOf(layouts, 3, 1L), "and in its last week") + assertEquals(1, laneOf(layouts, 2, 2L), "B takes the next free lane") + assertEquals(1, laneOf(layouts, 3, 2L)) + assertEquals(1, laneOf(layouts, 4, 2L), "B keeps it after A ends") + } + + @Test + fun `three overlapping multi-week bars keep distinct unchanged lanes`() { + val items = listOf( + Item(1, startDate = "2026-03-02", dueDate = "2026-03-27"), + Item(2, startDate = "2026-03-03", dueDate = "2026-03-26"), + Item(3, startDate = "2026-03-04", dueDate = "2026-03-25"), + ) + val layouts = layoutMonth(monthGrid(2026, 3, true), items, maxLanes = 4) + val lanes = (1L..3L).map { id -> (1..4).map { week -> laneOf(layouts, week, id) } } + for ((index, perWeek) in lanes.withIndex()) { + assertEquals( + 1, perWeek.filterNotNull().distinct().size, + "task ${index + 1} changed lane part-way through: $perWeek", + ) + } + assertEquals(3, lanes.map { it.filterNotNull().first() }.distinct().size, "lanes must not collide") + } + + @Test + fun `a bar running in from before the grid is drawn open-ended and still gets a lane`() { + val item = Item(1, startDate = "2026-02-01", dueDate = "2026-03-10") + val layouts = layoutMonth(monthGrid(2026, 3, true), listOf(item), maxLanes = 3) + val first = layouts.first().bars.single() + assertTrue(first.continuesLeft, "geometry says it began before this week") + assertEquals(0, first.lane, "bookkeeping had no lane for it, so it competes as a newcomer") + } + + @Test + fun `overflow counts every day a dropped task covers`() { + // Six single-day tasks on one day, with room for two. + val day = "2026-10-14" + val items = (1L..6L).map { Item(it, dueDate = day) } + val layouts = layoutMonth(monthGrid(2026, 10, true), items, maxLanes = 2) + val week = layouts.first { it.dates.contains(day) } + val column = week.dates.indexOf(day) + + assertEquals(2, week.bars.size, "two lanes, two bars") + assertEquals(4, week.overflow[column], "the other four are counted on that day") + assertTrue(week.hasOverflow) + assertEquals(0, week.overflow.filterIndexed { index, _ -> index != column }.sum()) + } + + @Test + fun `a dropped multi-day task is counted on each of its days`() { + val items = listOf( + Item(1, dueDate = "2026-10-16", startDate = "2026-10-14"), + Item(2, dueDate = "2026-10-16", startDate = "2026-10-14"), + Item(3, dueDate = "2026-10-16", startDate = "2026-10-14"), + ) + val layouts = layoutMonth(monthGrid(2026, 10, true), items, maxLanes = 2) + val week = layouts.first { it.dates.contains("2026-10-14") } + val from = week.dates.indexOf("2026-10-14") + assertEquals(listOf(1, 1, 1), (from..from + 2).map { week.overflow[it] }) + } + + @Test + fun `a task that overflows one week can win a lane the next`() { + // Two single-day blockers in week one only, plus a bar crossing both weeks. + val crossing = Item(99, startDate = "2026-10-09", dueDate = "2026-10-14") + val blockers = listOf( + Item(1, startDate = "2026-10-05", dueDate = "2026-10-11"), + Item(2, startDate = "2026-10-05", dueDate = "2026-10-11"), + ) + val layouts = layoutMonth(monthGrid(2026, 10, true), blockers + crossing, maxLanes = 2) + val first = layouts.first { it.dates.contains("2026-10-09") } + val second = layouts.first { it.dates.contains("2026-10-14") } + + assertNull(first.bars.firstOrNull { it.id == 99L }, "no lane left for it in week one") + assertTrue(first.overflow[first.dates.indexOf("2026-10-09")] > 0) + + val bar = assertNotNull(second.bars.firstOrNull { it.id == 99L }, "week two has room, so it is drawn") + assertTrue(bar.continuesLeft, "and drawn open on the left, because it did start earlier") + } + + @Test + fun `no overflow means no reserved strip`() { + val items = listOf(Item(1, dueDate = "2026-10-14"), Item(2, dueDate = "2026-10-15")) + val layouts = layoutMonth(monthGrid(2026, 10, true), items, maxLanes = 3) + assertTrue(layouts.none { it.hasOverflow }) + assertTrue(layouts.all { it.overflow.sum() == 0 }) + } + + @Test + fun `the layout does not depend on the order of the input`() { + // This is what guarantees the phone and the browser draw the same month. + val items = listOf( + Item(1, startDate = "2026-10-01", dueDate = "2026-10-20"), + Item(2, dueDate = "2026-10-14"), + Item(3, startDate = "2026-10-12", dueDate = "2026-10-16"), + Item(4, startDate = "2026-09-20", dueDate = "2026-10-06"), + Item(5, dueDate = "2026-10-14"), + ) + val weeks = monthGrid(2026, 10, true) + val expected = layoutMonth(weeks, items, maxLanes = 3) + assertEquals(expected, layoutMonth(weeks, items.reversed(), maxLanes = 3)) + assertEquals(expected, layoutMonth(weeks, items.shuffled(java.util.Random(7)), maxLanes = 3)) + } + + @Test + fun `an undated task is never laid out`() { + val layouts = layoutMonth(monthGrid(2026, 10, true), listOf(Item(1, startDate = "2026-10-12")), maxLanes = 3) + assertTrue(layouts.all { it.bars.isEmpty() && it.overflow.sum() == 0 }) + } + + // -- the type filter --------------------------------------------------- + + private val mixed = listOf( + Item(1, dueDate = "2026-10-14", source = "jira", sourceLabel = "Jira", project = "MIPT"), + Item(2, dueDate = "2026-10-15", source = "jira", sourceLabel = "Jira", project = "MIPT"), + Item(3, dueDate = "2026-10-15", source = "jira", sourceLabel = "Jira", project = "PLAT"), + Item(4, dueDate = "2026-10-15", source = "jira", sourceLabel = "Jira"), + Item(5, dueDate = "2026-10-15", source = "todoist", sourceLabel = "Todoist", project = "Home"), + Item(6, dueDate = "2026-10-15", source = "laundry", sourceLabel = "Laundry", project = "Laundry"), + ) + + @Test + fun `the tree is sources expanded into their projects`() { + val tree = buildFilterTree(mixed) + assertEquals(listOf("jira", "laundry", "todoist"), tree.map { it.key }, "ordered by label") + val jira = tree.first { it.key == "jira" } + assertEquals(4, jira.count, "the project-less issue still counts against the source") + assertEquals(listOf("jira/MIPT", "jira/PLAT"), jira.children.map { it.key }) + assertEquals(listOf(2, 1), jira.children.map { it.count }) + } + + @Test + fun `hiding a source hides its projects with it`() { + val hidden = setOf("jira") + assertTrue(mixed.filter { it.source == "jira" }.all { isHidden(it, hidden) }) + assertFalse(isHidden(mixed.first { it.source == "todoist" }, hidden)) + } + + @Test + fun `hiding one project leaves its siblings alone`() { + val hidden = setOf("jira/MIPT") + assertTrue(isHidden(mixed[0], hidden)) + assertFalse(isHidden(mixed[2], hidden), "PLAT is untouched") + assertFalse(isHidden(mixed[3], hidden), "and so is the issue with no project") + } + + @Test + fun `a project nobody has hidden is visible`() { + // The set holds hidden keys, never visible ones, so a project that first + // appears after a refresh shows up instead of being filtered out by a set + // written last week. + val hidden = setOf("jira/MIPT") + val fresh = Item(7, dueDate = "2026-10-15", source = "jira", sourceLabel = "Jira", project = "NEW") + assertFalse(isHidden(fresh, hidden)) + } + + // -- the model --------------------------------------------------------- + + @Test + fun `the model splits visible, long and undated in one pass`() { + val items = listOf( + Item(1, dueDate = "2026-10-14"), + Item(2, startDate = "2026-10-01", dueDate = "2026-10-20"), + Item(3, startDate = "2026-06-01"), + Item(4, dueDate = "2026-10-15", source = "laundry", sourceLabel = "Laundry", project = "Laundry"), + ) + + val hiddenNothing = emptySet() + val collapsed = buildModel(items, hiddenNothing, showLong = false) + assertEquals(listOf(1L, 4L), collapsed.visible.map { it.id }) + assertEquals(listOf(3L), collapsed.undated.map { it.id }) + assertEquals(1, collapsed.longCount) + + val expanded = buildModel(items, hiddenNothing, showLong = true) + assertEquals(listOf(1L, 2L, 4L), expanded.visible.map { it.id }) + assertEquals(1, expanded.longCount, "the count must not move when the toggle flips") + + val filtered = buildModel(items, setOf("laundry"), showLong = false) + assertEquals(listOf(1L), filtered.visible.map { it.id }) + } + + @Test + fun `a laundry slot is a one-day bar and is never long`() { + // `laundry.py` emits due_date=today with no start date, one row per slot. + val slot = Item(1, dueDate = "2026-10-14", source = "laundry", sourceLabel = "Laundry", project = "Laundry") + val span = spanOf(slot)!! + assertEquals(1, span.days) + assertFalse(isLong(span)) + } + + @Test + fun `itemsOnDay returns everything covering that day, longest first`() { + val items = listOf( + Item(1, dueDate = "2026-10-14"), + Item(2, startDate = "2026-10-12", dueDate = "2026-10-16"), + Item(3, dueDate = "2026-10-20"), + ) + assertEquals(listOf(2L, 1L), itemsOnDay(items, "2026-10-14").map { it.id }) + assertEquals(emptyList(), itemsOnDay(items, "2026-10-18").map { it.id }) + } + + @Test + fun `weekday labels follow the week start`() { + assertEquals("Mon", weekdayLabels(true).first()) + assertEquals("Sun", weekdayLabels(false).first()) + assertEquals(7, weekdayLabels(true).size) + } +} diff --git a/backend/app/api/routes_timer.py b/backend/app/api/routes_timer.py index 9151718..a511c33 100644 --- a/backend/app/api/routes_timer.py +++ b/backend/app/api/routes_timer.py @@ -63,11 +63,18 @@ def resume(session: DbSession, settings: Settings) -> dict: @router.post("/complete") def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict: try: - finished, follow_on = pomodoro.complete(session, settings, auto_next=body.auto_next) + finished, follow_on = pomodoro.complete( + session, + settings, + auto_next=body.auto_next, + expect_id=body.session_id, + ) except pomodoro.TimerError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - celebration = _maybe_celebrate(session, settings) if finished.is_work else None + # `finished` is None when the named interval was no longer the running one: + # nothing happened, so there is nothing to celebrate and nothing to announce. + celebration = _maybe_celebrate(session, settings) if finished and finished.is_work else None state = pomodoro.state(session, settings) state["finished"] = pomodoro.session_dict(finished) state["auto_started"] = pomodoro.session_dict(follow_on) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 0dbc1d6..2bfa9e0 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -29,6 +29,16 @@ class AttachTaskRequest(BaseModel): class CompleteTimerRequest(BaseModel): auto_next: bool = True + session_id: int | None = None + """Which interval the client believes it is finishing. + + Every client ticks its own local countdown and calls `complete` when it + reaches zero, so with two of them open (the phone and the desktop, say) + both fire within the same second. Unconditionally, the second call lands + on whatever `auto_next` has just started and ends *that* — which is why a + break could vanish the instant it began. Sent along, a stale call is a + no-op instead. Omitted, the old unconditional behaviour stands. + """ class TaskCreateRequest(BaseModel): diff --git a/backend/app/services/pomodoro.py b/backend/app/services/pomodoro.py index bb13500..a1d8d68 100644 --- a/backend/app/services/pomodoro.py +++ b/backend/app/services/pomodoro.py @@ -176,10 +176,28 @@ def resume(session: Session) -> PomodoroSession: def complete( - session: Session, settings: AppSettings, *, auto_next: bool = True -) -> tuple[PomodoroSession, PomodoroSession | None]: - """Finish the current interval and, if configured, roll into the next one.""" - row = _require_active(session) + session: Session, + settings: AppSettings, + *, + auto_next: bool = True, + expect_id: int | None = None, +) -> tuple[PomodoroSession | None, PomodoroSession | None]: + """Finish the current interval and, if configured, roll into the next one. + + `expect_id` is the interval the caller believes it is finishing. Every + client runs its own local countdown and calls this when it hits zero, so + two clients open at once both call within the same second — and the second + call used to land on the interval `auto_next` had just created and end it + immediately. That is a break disappearing the moment it starts, and it is + not something a client can fix on its own: only the server knows which + interval is current. So a call that names an interval which is no longer + running does nothing at all, and says so by returning no `finished`. + """ + row = current_session(session) + if expect_id is not None and (row is None or row.id != expect_id): + return None, None + if row is None: + raise TimerError("No interval is running.") finished = _finish(session, settings, row, status="completed") follow_on: PomodoroSession | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1b2fa2d..9efce6f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -29,6 +29,28 @@ def test_a_whole_pomodoro_through_the_api(client): assert "Daily report" in report["telegram_preview"] +def test_a_stale_complete_leaves_the_auto_started_break_alone(client): + client.put( + "/api/settings", + json={"pomodoro": {"auto_start_breaks": True, "auto_start_work": True}}, + ) + state = client.post("/api/timer/start", json={"kind": "work"}).json() + work_id = state["current"]["id"] + + done = client.post( + "/api/timer/complete", json={"auto_next": True, "session_id": work_id} + ).json() + assert done["current"]["kind"] in ("short_break", "long_break") + break_id = done["current"]["id"] + + # The other client's countdown, one beat behind. + again = client.post( + "/api/timer/complete", json={"auto_next": True, "session_id": work_id} + ).json() + assert again["finished"] is None + assert again["current"]["id"] == break_id + + def test_timer_errors_are_400_not_500(client): assert client.post("/api/timer/pause").status_code == 400 assert client.post("/api/timer/complete", json={}).status_code == 400 diff --git a/backend/tests/test_pomodoro.py b/backend/tests/test_pomodoro.py index be94d92..52a2d04 100644 --- a/backend/tests/test_pomodoro.py +++ b/backend/tests/test_pomodoro.py @@ -69,6 +69,40 @@ def test_auto_start_breaks_rolls_straight_on(session, settings): assert follow_on is not None and follow_on.kind == "short_break" +def test_a_complete_naming_a_finished_interval_does_nothing(session, settings): + """Two clients reaching zero at once must not cost you the break. + + Every client ticks its own countdown and calls `complete` when it runs out. + The first call finishes the work interval and auto-starts the break; the + second — sent a moment earlier, from a client that has not polled since — + used to finish *that* break, so a break ended the instant it began. + """ + settings.pomodoro.auto_start_breaks = True + settings.pomodoro.auto_start_work = True + settings_service.save_settings(session, settings) + + work = pomodoro.start(session, settings, kind="work") + finished, follow_on = pomodoro.complete(session, settings, expect_id=work.id) + assert finished is not None and follow_on is not None + assert follow_on.kind == "short_break" + + # The second client's call, naming the work interval it was still showing. + stale_finished, stale_follow_on = pomodoro.complete(session, settings, expect_id=work.id) + assert stale_finished is None and stale_follow_on is None + + still_running = pomodoro.current_session(session) + assert still_running is not None + assert still_running.id == follow_on.id and still_running.status == "running" + + +def test_complete_without_a_session_id_stays_unconditional(session, settings): + settings.pomodoro.auto_start_breaks = False + settings_service.save_settings(session, settings) + pomodoro.start(session, settings, kind="work") + finished, _ = pomodoro.complete(session, settings) + assert finished is not None and finished.status == "completed" + + def test_strict_mode_demands_a_reason(session, settings): settings.pomodoro.strict_mode = True settings_service.save_settings(session, settings) diff --git a/desktop/kadence_desktop/api.py b/desktop/kadence_desktop/api.py index 536c9a1..356e465 100644 --- a/desktop/kadence_desktop/api.py +++ b/desktop/kadence_desktop/api.py @@ -87,8 +87,18 @@ class Api: def resume(self) -> dict: return self._request("POST", "/api/timer/resume", {}) - def complete(self, auto_next: bool = True) -> dict: - return self._request("POST", "/api/timer/complete", {"auto_next": auto_next}) + def complete(self, auto_next: bool = True, session_id: int | None = None) -> dict: + """`session_id` names the interval we think we are finishing. + + The server ignores the call if something else is running by then, which + is what keeps two clients hitting zero at the same moment from eating + the break the first one's `auto_next` just started. + """ + return self._request( + "POST", + "/api/timer/complete", + {"auto_next": auto_next, "session_id": session_id}, + ) def abandon(self, reason: str | None = None) -> dict: return self._request("POST", "/api/timer/abandon", {"reason": reason}) diff --git a/desktop/kadence_desktop/controller.py b/desktop/kadence_desktop/controller.py index 1e972fe..af38f58 100644 --- a/desktop/kadence_desktop/controller.py +++ b/desktop/kadence_desktop/controller.py @@ -78,7 +78,17 @@ class Controller(QObject): self._last_session_id: int | None = None self._last_kind = "" self._last_status = "" - self._completing = False + self._completed_id: int | None = None + """The interval we have already asked the server to finish. + + An id rather than a bare "in flight" flag. A poll issued just before the + countdown ran out answers with that interval still running at 00:00, and + a flag cleared by that answer sends `complete` a second time — onto the + break `auto_next` has since started, which is why a break could end the + instant it began. Ids are never reused, so the second attempt is never + made. Cleared only when the call itself fails, so a refused or dropped + complete is still retried. + """ self._paused_by_lock = False self._mini_visible = False self._mini_dismissed = False @@ -174,6 +184,13 @@ class Controller(QObject): current = self._state.get("current") return str(current["status"]) if current else "idle" + @property + def _session_id(self) -> int | None: + """The running interval's id, or None when nothing is running.""" + current = self._state.get("current") or {} + session_id = current.get("id") + return int(session_id) if session_id is not None else None + @pyqtProperty(int, notify=stateChanged) def remainingSeconds(self) -> int: if self._state.get("current"): @@ -393,7 +410,7 @@ class Controller(QObject): @pyqtSlot() def complete(self) -> None: - self._send("complete", {"auto_next": True}) + self._send("complete", {"auto_next": True, "session_id": self._session_id}) @pyqtSlot() @pyqtSlot(str) @@ -567,7 +584,6 @@ class Controller(QObject): self._last_session_id = session_id self._last_kind = kind self._last_status = status - self._completing = False self.stateChanged.emit() self._update_launcher() @@ -610,12 +626,18 @@ class Controller(QObject): def _on_command_failed(self, name: str, detail: str) -> None: log.warning("%s failed: %s", name, detail) + if name == "complete": + # It never landed, so the interval at 00:00 is still owed a complete. + self._completed_id = None self.toast.emit(detail) def _on_command_done(self, name: str, result: dict[str, Any]) -> None: if name == "complete": - finished = result.get("finished") or {} - self._announce_finished(str(finished.get("kind", "work")), result) + finished = result.get("finished") + # None means the server declined: something else was running by the + # time the call arrived, so nothing finished and nothing is announced. + if finished: + self._announce_finished(str(finished.get("kind", "work")), result) elif name == "send_report": self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings") elif name == "refresh_sources": @@ -706,10 +728,11 @@ class Controller(QObject): if self._remaining % 5 == 0: self._update_launcher() return - if not self._completing: + session_id = self._session_id + if session_id is not None and self._completed_id != session_id: # The countdown reached zero: the server books the interval, and its # reply decides what happens next (auto-break, or nothing). - self._completing = True + self._completed_id = session_id self.complete() def _check_idle(self) -> None: diff --git a/desktop/packaging/kadence.desktop b/desktop/packaging/kadence.desktop index 6257731..d794ca7 100644 --- a/desktop/packaging/kadence.desktop +++ b/desktop/packaging/kadence.desktop @@ -3,7 +3,7 @@ Type=Application Name=Kadence GenericName=Pomodoro timer Comment=Pomodoro timer and focus guard — a tray agent with a floating countdown -Exec=kadence-desktop --mini +Exec=kadence-desktop TryExec=kadence-desktop Icon=kadence Terminal=false diff --git a/frontend/src/components/calendar/TaskCalendar.tsx b/frontend/src/components/calendar/TaskCalendar.tsx new file mode 100644 index 0000000..e56977b --- /dev/null +++ b/frontend/src/components/calendar/TaskCalendar.tsx @@ -0,0 +1,387 @@ +/** + * The month, as bars from start date to deadline. + * + * Built like a printed table rather than a grid of cards: hairlines between the + * cells, tabular day figures, the month set in the display serif, and exactly + * one accent — today. + * + * Bars are coloured by **state, not by source**. Five saturated fills across a + * month grid would read as Material rather than Kadence, and five categorical + * hues cannot clear the data-viz palette validator's all-pairs normal-vision + * floor anyway (the first three slots are the documented limit). Identity comes + * from the title and from the filter; colour is kept for what changes how you + * act on a task — done, overdue, or still waiting. + * + * The layout maths all live in `lib/calendar.ts`, which `:core` ports verbatim. + */ +import { useMemo, useState } from "react"; +import { Box, Button, Divider, Popover, Stack, Typography, useMediaQuery, useTheme } from "@mui/material"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; + +import { Panel } from "../Layout"; +import TaskRow from "../tasks/TaskRow"; +import { + itemsOnDay, + layoutMonth, + monthGrid, + shiftMonth, + todayISO, + weekdayLabels, + type WeekLayout, +} from "../../lib/calendar"; +import type { Task } from "../../lib/types"; + +const DAY_STRIP = 22; +const BAR_H = 20; +const BAR_GAP = 3; +const OVERFLOW_H = 18; +const MIN_ROW = 88; + +/** + * Monday-first on both clients for now. + * + * `AppSettings.week_starts_monday` exists and the web app could read it, but the + * phone cannot — `AppSettingsDto` does not carry it and the tasks screen loads + * no settings — so honouring it here alone is how the two clients start + * disagreeing about what a week is. The backend default is Monday anyway. + */ +const WEEK_STARTS_MONDAY = true; + +export default function TaskCalendar({ + tasks, + longCount, + showLong, + onShowLong, + onPin, + onStart, + onDelete, +}: { + /** Already filtered: the type tree and the long-task toggle have been applied. */ + tasks: Task[]; + longCount: number; + showLong: boolean; + onShowLong: (next: boolean) => void; + onPin: (task: Task) => Promise; + onStart: (task: Task) => Promise; + onDelete: (task: Task) => Promise; +}) { + const theme = useTheme(); + const wide = useMediaQuery(theme.breakpoints.up("sm")); + const today = todayISO(); + const [cursor, setCursor] = useState<[number, number]>(() => { + const now = new Date(); + return [now.getFullYear(), now.getMonth() + 1]; + }); + const [day, setDay] = useState<{ date: string; anchor: HTMLElement } | null>(null); + + const [year, month] = cursor; + // Showing long bars needs the room they take, or they simply push the day's + // real work into "+N" and the month gets less informative, not more. + const maxLanes = wide ? (showLong ? 5 : 4) : 2; + + const weeks = useMemo(() => monthGrid(year, month, WEEK_STARTS_MONDAY), [year, month]); + const layouts = useMemo(() => layoutMonth(weeks, tasks, maxLanes), [weeks, tasks, maxLanes]); + const byId = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); + + const label = new Date(year, month - 1, 1).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + }); + const dayTasks = day ? itemsOnDay(tasks, day.date) : []; + + return ( + <> + + + + {label} + + + + + + + + + + {weekdayLabels(WEEK_STARTS_MONDAY).map((name) => ( + + {wide ? name : name.slice(0, 1)} + + ))} + + + {layouts.map((week, index) => ( + setDay({ date, anchor })} + /> + ))} + + + setDay(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + transformOrigin={{ vertical: "top", horizontal: "center" }} + slotProps={{ paper: { sx: { mt: 0.5, width: 380, maxWidth: "92vw", maxHeight: 420 } } }} + > + + + {day && new Date(`${day.date}T00:00:00`).toLocaleDateString(undefined, { + weekday: "long", + day: "numeric", + month: "long", + })} + + + {dayTasks.length === 0 ? ( + + Nothing on this day. + + ) : ( + }> + {dayTasks.map((task) => ( + onPin(task)} + onStart={() => onStart(task)} + onDelete={() => onDelete(task)} + /> + ))} + + )} + + + + ); +} + +function WeekRow({ + week, + month, + today, + byId, + last, + onPickDay, +}: { + week: WeekLayout; + month: number; + today: string; + byId: Map; + last: boolean; + onPickDay: (date: string, anchor: HTMLElement) => void; +}) { + const height = + DAY_STRIP + week.lanes * (BAR_H + BAR_GAP) + (week.hasOverflow ? OVERFLOW_H : 0) + 6; + + return ( + + {week.dates.map((date, column) => { + const outside = Number(date.slice(5, 7)) !== month; + const isToday = date === today; + return ( + onPickDay(date, event.currentTarget)} + sx={{ + borderRight: column === 6 ? 0 : 1, + borderColor: "divider", + px: 0.75, + pt: 0.5, + cursor: "pointer", + minWidth: 0, + "&:hover": { bgcolor: "action.hover" }, + }} + > + + {Number(date.slice(8, 10))} + + + ); + })} + + {week.bars.map((bar) => { + const task = byId.get(bar.id); + if (!task) return null; + return ( + + + + ); + })} + + {week.hasOverflow && + week.overflow.map((count, column) => + count > 0 ? ( + + +{count} more + + ) : null, + )} + + ); +} + +/** + * One bar. + * + * `days_overdue` and `bucket` are computed against *today* by the backend, so a + * stale laundry slot from March claims to be two hundred days overdue forever. + * Neither is used here: lateness is decided from the task's own due date, and + * only for something still open and still offered by its source. + */ +function TaskBar({ + task, + today, + continuesLeft, + continuesRight, +}: { + task: Task; + today: string; + continuesLeft: boolean; + continuesRight: boolean; +}) { + const late = !task.is_done && !task.stale && task.due_date !== null && task.due_date < today; + const muted = task.is_done || task.stale; + const radius = "4px"; + + return ( + + {!continuesLeft && ( + + )} + + {task.title} + + + ); +} diff --git a/frontend/src/components/tasks/AddTaskDialog.tsx b/frontend/src/components/tasks/AddTaskDialog.tsx new file mode 100644 index 0000000..daf47a1 --- /dev/null +++ b/frontend/src/components/tasks/AddTaskDialog.tsx @@ -0,0 +1,75 @@ +/** + * The "and this too" you type at 9am. + * + * The draft lives here rather than in the page: it is this dialog's business and + * nothing else ever reads it. + */ +import { useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; + +import { api } from "../../lib/api"; + +export default function AddTaskDialog({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: () => Promise; +}) { + const [draft, setDraft] = useState({ title: "", due_date: "" }); + + return ( + + New task + + + setDraft({ ...draft, title: event.target.value })} + /> + setDraft({ ...draft, due_date: event.target.value })} + /> + + Local tasks live only here, so they are the one kind you can delete. + + + + + + + + + ); +} diff --git a/frontend/src/components/tasks/SourceFilterTree.tsx b/frontend/src/components/tasks/SourceFilterTree.tsx new file mode 100644 index 0000000..c96175c --- /dev/null +++ b/frontend/src/components/tasks/SourceFilterTree.tsx @@ -0,0 +1,156 @@ +/** + * Hide whole categories of work: a source, or one project inside it. + * + * The set it edits holds **hidden** keys, never visible ones, so a Jira project + * that first appears after a refresh shows up rather than being filtered out by + * a set written last week. See `isHidden` in `lib/calendar.ts`. + * + * Switching a source off disables its projects rather than silently unticking + * them: the parent governs, and switching it back on restores the whole subtree + * instead of leaving ghosts from whatever was unticked before. + */ +import { useMemo, useState } from "react"; +import { + Box, + Button, + Checkbox, + Divider, + Popover, + Stack, + Typography, +} from "@mui/material"; +import FilterListIcon from "@mui/icons-material/FilterList"; + +import { buildFilterTree, type CalendarItem } from "../../lib/calendar"; + +export default function SourceFilterTree({ + items, + hidden, + onChange, +}: { + /** The *unfiltered* list — the counts have to describe what is there. */ + items: CalendarItem[]; + hidden: string[]; + onChange: (next: string[]) => void; +}) { + const [anchor, setAnchor] = useState(null); + const tree = useMemo(() => buildFilterTree(items), [items]); + const hiddenSet = useMemo(() => new Set(hidden), [hidden]); + + const setHidden = (key: string, isHidden: boolean, alsoClear: string[] = []) => { + const next = new Set(hiddenSet); + if (isHidden) next.add(key); + else next.delete(key); + for (const extra of alsoClear) next.delete(extra); + onChange([...next]); + }; + + return ( + <> + + + setAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + slotProps={{ paper: { sx: { mt: 0.5, minWidth: 260, maxHeight: 420 } } }} + > + + + + Show + + + + + + + {tree.length === 0 && ( + + Nothing to filter yet. + + )} + + {tree.map((node) => { + const sourceHidden = hiddenSet.has(node.key); + return ( + + + setHidden( + node.key, + !visible, + // Turning a source back on restores its projects with it. + visible ? node.children.map((child) => child.key) : [], + ) + } + /> + {node.children.map((child) => ( + setHidden(child.key, !visible)} + /> + ))} + + ); + })} + + + + ); +} + +function FilterRow({ + label, + count, + checked, + onChange, + indent = false, + bold = false, + disabled = false, +}: { + label: string; + count: number; + checked: boolean; + onChange: (visible: boolean) => void; + indent?: boolean; + bold?: boolean; + disabled?: boolean; +}) { + return ( + !disabled && onChange(!checked)} + > + + + {label} + + + {count} + + + ); +} diff --git a/frontend/src/components/tasks/TaskList.tsx b/frontend/src/components/tasks/TaskList.tsx new file mode 100644 index 0000000..bf16dfa --- /dev/null +++ b/frontend/src/components/tasks/TaskList.tsx @@ -0,0 +1,88 @@ +/** + * Everything you owe, grouped by deadline — the original Tasks view. + * + * Moved out of `TasksPage.tsx` when the page grew a calendar beside it. The + * grouping and the empty state are unchanged; the one new thing is that the + * list filters `is_done`/`stale` itself rather than trusting the URL it was + * fetched from, because the page now asks the backend for history so the + * calendar has something to draw. + */ +import { useMemo } from "react"; +import { Box, Divider, Stack, Typography } from "@mui/material"; + +import { Panel, SectionTitle } from "../Layout"; +import { BUCKET_LABEL } from "../../lib/format"; +import type { Task } from "../../lib/types"; +import TaskRow from "./TaskRow"; + +const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"]; + +/** What the list has always shown: open, still offered by its source. */ +export function openTasks(tasks: Task[]): Task[] { + return tasks.filter((task) => !task.is_done && !task.stale); +} + +export default function TaskList({ + tasks, + onPin, + onStart, + onDelete, +}: { + tasks: Task[]; + onPin: (task: Task) => Promise; + onStart: (task: Task) => Promise; + onDelete: (task: Task) => Promise; +}) { + const grouped = useMemo(() => { + const map: Record = { overdue: [], today: [], active: [], upcoming: [], someday: [] }; + for (const task of tasks) { + const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday"; + map[bucket].push(task); + } + return map; + }, [tasks]); + + if (tasks.length === 0) { + return ( + + + Nothing on the list + + + Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself. + + + ); + } + + return ( + <> + {BUCKETS.map((bucket) => + grouped[bucket].length ? ( + + + {grouped[bucket].length} + + } + > + {BUCKET_LABEL[bucket]} + + }> + {grouped[bucket].map((task) => ( + onPin(task)} + onStart={() => onStart(task)} + onDelete={() => onDelete(task)} + /> + ))} + + + ) : null, + )} + + ); +} diff --git a/frontend/src/components/tasks/TaskRow.tsx b/frontend/src/components/tasks/TaskRow.tsx new file mode 100644 index 0000000..65ceb97 --- /dev/null +++ b/frontend/src/components/tasks/TaskRow.tsx @@ -0,0 +1,120 @@ +/** + * One task, as a row of hairline-separated facts. + * + * Moved out of `TasksPage.tsx` unchanged when the page grew a second view; the + * calendar's day popover renders the same row, so a task reads identically + * wherever you meet it. + */ +import { Box, IconButton, Link, Stack, Tooltip, Typography } from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined"; +import StarIcon from "@mui/icons-material/Star"; +import StarBorderIcon from "@mui/icons-material/StarBorder"; + +import type { Task } from "../../lib/types"; + +export const BUCKET_COLOR: Record = { + overdue: "error.main", + today: "warning.main", + active: "success.main", + upcoming: "text.disabled", + someday: "divider", +}; + +export default function TaskRow({ + task, + onPin, + onStart, + onDelete, +}: { + task: Task; + onPin: () => Promise; + onStart: () => Promise; + onDelete: () => Promise; +}) { + return ( + + + + + + + {task.title} + + {task.url && ( + + + + )} + + + + {task.source_label} + + {task.project && ( + + {task.project} + + )} + {task.status && ( + + {task.status} + + )} + {task.priority && ( + + {task.priority} + + )} + {/* `days_overdue` is computed against today, so it is only the truth for + something still open. A stale laundry slot carries one forever. */} + {task.days_overdue > 0 && !task.is_done && !task.stale ? ( + + {task.days_overdue}d overdue + + ) : ( + task.due_date && ( + + due {task.due_date} + + ) + )} + + + + + + void onPin()}> + {task.pinned ? : } + + + + void onStart()}> + + + + {task.source === "local" && ( + + void onDelete()}> + + + + )} + + + ); +} diff --git a/frontend/src/lib/calendar.ts b/frontend/src/lib/calendar.ts new file mode 100644 index 0000000..bf050d9 --- /dev/null +++ b/frontend/src/lib/calendar.ts @@ -0,0 +1,443 @@ +/** + * The month grid, and the two filters that make it readable. + * + * A calendar of *tasks* is not a calendar of events: nothing here has a time of + * day, only a `start_date` and a `due_date`, so every item is a whole-day bar and + * the only interesting layout question is which lane it sits in. + * + * Three rules exist because of what the sources actually return rather than for + * their own sake: + * + * * **Long tasks are measured on their true span, before clipping.** Jira hands + * back every open issue with whatever "Start date" the custom field holds, + * which is routinely months old. Clipping to the week first would make a + * fifteen-day bar look like seven days in each of three rows and it would + * never trip the filter — which is the entire point of the filter. + * * **The filter stores hidden keys, never visible ones.** A Jira project that + * appears for the first time after a refresh must show up, not be silently + * filtered out because it was absent from a set written last week. + * * **A due date is what makes a task plottable** — see `spanOf`. + * + * Nothing here may key off `bucket` or `days_overdue`: the backend computes both + * against *today* (`_bucket_of` in `backend/app/api/routes_tasks.py`), so a stale + * laundry slot from March reports `overdue` with a `days_overdue` that grows + * forever. In a cell you are looking at in a past month that is noise — style + * from the cell's own date against `is_done` and `stale` instead. + * + * `android/core/src/main/kotlin/tech/kadence/core/Calendar.kt` is a literal port + * of this file, and `CalendarTest` asserts the outputs this produces. + * `CalendarParityTest` reads `LONG_TASK_DAYS` straight out of this source. + */ + +/** Longer than this many days and a bar is "long". Mirrored in `Calendar.kt`. */ +export const LONG_TASK_DAYS = 7; + +/** + * What the layout needs off a task. `Task` satisfies this structurally, so + * callers pass their tasks straight in. + */ +export interface CalendarItem { + id: number; + source: string; + source_label: string; + project: string | null; + start_date: string | null; + due_date: string | null; +} + +export interface Span { + start: string; + end: string; + /** Inclusive length, so a one-day task is 1. */ + days: number; +} + +// -------------------------------------------------------------------------- +// Dates +// -------------------------------------------------------------------------- +// ISO day strings compare lexicographically in calendar order, so ordering and +// overlap tests never need a Date at all. Only arithmetic does. + +/** `new Date("2026-10-19")` is parsed as UTC; this is the local-midnight form. */ +function parseISO(iso: string): Date { + return new Date(`${iso}T00:00:00`); +} + +function toISO(date: Date): string { + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; +} + +/** Whole days from `from` to `to`; rounded, so a DST day is still one day. */ +export function diffDays(from: string, to: string): number { + return Math.round((parseISO(to).getTime() - parseISO(from).getTime()) / 86_400_000); +} + +export function addDays(iso: string, days: number): string { + const date = parseISO(iso); + return toISO(new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)); +} + +export function todayISO(): string { + return toISO(new Date()); +} + +// -------------------------------------------------------------------------- +// Spans +// -------------------------------------------------------------------------- + +function span(start: string, end: string): Span { + return { start, end, days: diffDays(start, end) + 1 }; +} + +/** + * Where a task sits on the calendar, or null if it cannot be plotted. + * + * **A due date is what makes a task plottable.** A task with only a start date + * is deliberately *not* drawn, which is the one rule here that costs something + * and is worth the cost: the Jira JQL in `backend/app/sources/jira.py:77` + * selects issues whose start date is today-or-earlier with no lower bound, and + * Jira issues very often carry a start date and no due date. Drawing those on + * their start day puts work the user thinks of as "now" into a cell four months + * back — a confident wrong answer, which is worse than an honest absence. They + * come back as `undated` instead and get their own list. + * + * A due date *before* the start date is bad data — Jira permits it — and + * collapses to the deadline, because the deadline is the half that matters. + */ +export function spanOf(item: CalendarItem): Span | null { + const due = item.due_date; + if (!due) return null; + const start = item.start_date; + if (!start || due < start) return span(due, due); + return span(start, due); +} + +export function isLong(value: Span): boolean { + return value.days > LONG_TASK_DAYS; +} + +// -------------------------------------------------------------------------- +// The grid +// -------------------------------------------------------------------------- + +/** + * The weeks a month needs — five or six, never a fixed six. + * + * October 2026 starts on a Thursday and ends on a Saturday, so Monday-first it + * runs 28 September to 1 November in five rows. Padding that to six would hang + * an empty week off the bottom of the page. + * + * `month` is 1-12. + */ +export function monthGrid(year: number, month: number, weekStartsMonday: boolean): string[][] { + const first = new Date(year, month - 1, 1); + const weekday = first.getDay(); + const offset = weekStartsMonday ? (weekday + 6) % 7 : weekday; + const lastDay = new Date(year, month, 0).getDate(); + + const weeks: string[][] = []; + let cursor = 1 - offset; + while (cursor <= lastDay) { + const row: string[] = []; + for (let index = 0; index < 7; index += 1) { + row.push(toISO(new Date(year, month - 1, cursor + index))); + } + weeks.push(row); + cursor += 7; + } + return weeks; +} + +const WEEKDAYS_FROM_MONDAY = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +export function weekdayLabels(weekStartsMonday: boolean): string[] { + if (weekStartsMonday) return [...WEEKDAYS_FROM_MONDAY]; + return [WEEKDAYS_FROM_MONDAY[6], ...WEEKDAYS_FROM_MONDAY.slice(0, 6)]; +} + +// -------------------------------------------------------------------------- +// Lane packing +// -------------------------------------------------------------------------- + +export interface Bar { + id: number; + lane: number; + /** Inclusive column indices into the week, 0-6. */ + startCol: number; + endCol: number; + /** The task began before this week / ends after it — square that edge off. */ + continuesLeft: boolean; + continuesRight: boolean; +} + +export interface WeekLayout { + dates: string[]; + bars: Bar[]; + /** Per column, how many tasks this week could not draw — the "+N" counts. */ + overflow: number[]; + /** Lanes actually drawn, so a row can size itself to its content. */ + lanes: number; + /** Whether any day in this week needs the "+N" strip under the bars. */ + hasOverflow: boolean; +} + +interface Placed { + item: CalendarItem; + span: Span; +} + +/** + * Lay out a whole month, keeping a multi-week bar in one lane throughout. + * + * This has to be a month-at-a-time function rather than a week-at-a-time one, + * because the lane a continuing bar occupies is information that flows from one + * week to the next. Packing each week independently — however the segments are + * sorted — lets a bar step between lanes halfway along. With A spanning + * 2 to 18 March and B spanning 9 March to 2 April, A owns lane 0 in the first + * week; in the second both clip to Monday, and *any* tie-break that prefers B + * (longest-first does) moves A down to lane 1 mid-bar. + * + * So, FullCalendar's rule, in two phases per week: + * + * 1. **Continuations keep the lane they already hold.** This can never + * collide: every bar continuing into this week covers its first day, so + * every pair of them also overlapped on the previous week's last day, and + * they were given distinct lanes there. + * 2. **Everything else competes** for the lowest free lane, longest first. + * + * Note that `continuesLeft` is geometry (the span starts before this week) while + * "is a continuation" is bookkeeping (we already gave it a lane). They are not + * the same, and conflating them breaks the first visible week: a bar running in + * from before the grid has no lane yet, so it competes as a newcomer while still + * drawing with an open left edge. + * + * A task that finds no free lane is not drawn and is counted against **every** + * day it covers, because each cell's "+2" has to describe that cell — it is what + * opens when you click it. It is deliberately given no lane, so next week it + * competes again and may well win one. + * + * `maxLanes` is a per-client layout budget, not a shared rule: it is a required + * argument with no default so that neither client can drift into its own idea of + * what "the" value is. + */ +export function layoutMonth( + weeks: string[][], + items: CalendarItem[], + maxLanes: number, +): WeekLayout[] { + const placed: Placed[] = []; + for (const item of items) { + const value = spanOf(item); + if (value) placed.push({ item, span: value }); + } + + const laneOf = new Map(); + const layouts: WeekLayout[] = []; + + for (const week of weeks) { + const weekStart = week[0]; + const weekEnd = week[6]; + const inWeek = placed.filter((entry) => entry.span.start <= weekEnd && entry.span.end >= weekStart); + + const occupied: boolean[][] = []; + for (let lane = 0; lane < maxLanes; lane += 1) occupied.push(new Array(7).fill(false)); + const bars: Bar[] = []; + const overflow = new Array(7).fill(0); + + const claim = (lane: number, from: number, to: number) => { + for (let column = from; column <= to; column += 1) occupied[lane][column] = true; + }; + const free = (lane: number, from: number, to: number) => { + for (let column = from; column <= to; column += 1) { + if (occupied[lane][column]) return false; + } + return true; + }; + const columns = (entry: Placed): [number, number] => [ + entry.span.start < weekStart ? 0 : diffDays(weekStart, entry.span.start), + entry.span.end > weekEnd ? 6 : diffDays(weekStart, entry.span.end), + ]; + const barFor = (entry: Placed, lane: number, from: number, to: number): Bar => ({ + id: entry.item.id, + lane, + startCol: from, + endCol: to, + continuesLeft: entry.span.start < weekStart, + continuesRight: entry.span.end > weekEnd, + }); + + // Phase 1 — continuations reclaim their lane, in lane order. + const continuing = inWeek + .filter((entry) => entry.span.start < weekStart && laneOf.has(entry.item.id)) + .sort((a, b) => (laneOf.get(a.item.id) as number) - (laneOf.get(b.item.id) as number)); + for (const entry of continuing) { + const lane = laneOf.get(entry.item.id) as number; + const [from, to] = columns(entry); + claim(lane, from, to); + bars.push(barFor(entry, lane, from, to)); + } + + // Phase 2 — the rest take the lowest lane that fits. + const seen = new Set(continuing.map((entry) => entry.item.id)); + const starting = inWeek + .filter((entry) => !seen.has(entry.item.id)) + .sort( + (a, b) => + b.span.days - a.span.days || + a.span.start.localeCompare(b.span.start) || + a.item.id - b.item.id, + ); + for (const entry of starting) { + const [from, to] = columns(entry); + let lane = -1; + for (let candidate = 0; candidate < maxLanes; candidate += 1) { + if (free(candidate, from, to)) { + lane = candidate; + break; + } + } + if (lane < 0) { + for (let column = from; column <= to; column += 1) overflow[column] += 1; + continue; + } + claim(lane, from, to); + laneOf.set(entry.item.id, lane); + bars.push(barFor(entry, lane, from, to)); + } + + const lanes = bars.reduce((most, bar) => Math.max(most, bar.lane + 1), 0); + layouts.push({ + dates: week, + bars, + overflow, + lanes, + hasOverflow: overflow.some((count) => count > 0), + }); + } + + return layouts; +} + +/** `shiftMonth(2026, 1, -1)` is December 2025. `month` is 1-12 throughout. */ +export function shiftMonth(year: number, month: number, delta: number): [number, number] { + const zero = year * 12 + (month - 1) + delta; + return [Math.floor(zero / 12), (zero % 12) + 1]; +} + +// -------------------------------------------------------------------------- +// The type filter +// -------------------------------------------------------------------------- + +export interface FilterNode { + /** `"jira"` for a source, `"jira/MIPT"` for one of its projects. */ + key: string; + label: string; + count: number; + children: FilterNode[]; +} + +function projectKey(source: string, project: string): string { + return `${source}/${project}`; +} + +/** + * Sources, each expanded into the projects seen under it. + * + * Only tasks that *name* a project get a child node, so a source's children can + * sum to less than its own count; those tasks are reachable by hiding the whole + * source, which is the honest thing a "(none)" bucket would only dress up. + */ +export function buildFilterTree(items: CalendarItem[]): FilterNode[] { + const sources = new Map }>(); + + for (const item of items) { + let entry = sources.get(item.source); + if (!entry) { + entry = { label: item.source_label || item.source, count: 0, projects: new Map() }; + sources.set(item.source, entry); + } + entry.count += 1; + const project = (item.project ?? "").trim(); + if (project) entry.projects.set(project, (entry.projects.get(project) ?? 0) + 1); + } + + return [...sources.entries()] + .sort((a, b) => a[1].label.localeCompare(b[1].label)) + .map(([source, entry]) => ({ + key: source, + label: entry.label, + count: entry.count, + children: [...entry.projects.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([project, count]) => ({ + key: projectKey(source, project), + label: project, + count, + children: [], + })), + })); +} + +/** Hiding a source hides its projects with it. */ +export function isHidden(item: CalendarItem, hidden: ReadonlySet): boolean { + if (hidden.has(item.source)) return true; + const project = (item.project ?? "").trim(); + return project.length > 0 && hidden.has(projectKey(item.source, project)); +} + +export interface CalendarModel { + /** What the grid draws. */ + visible: T[]; + /** Passing the type filter but carrying no due date, so nowhere to draw them. */ + undated: T[]; + /** Spanning more than a week — the toggle's count, whether or not it is on. */ + longCount: number; +} + +/** + * One pass over the list for everything the toolbar and the grid need. + * + * `longCount` deliberately ignores `showLong`, so the toggle's own label does + * not change the moment you press it. + */ +export function buildModel( + items: T[], + hidden: ReadonlySet, + showLong: boolean, +): CalendarModel { + const visible: T[] = []; + const undated: T[] = []; + let longCount = 0; + + for (const item of items) { + if (isHidden(item, hidden)) continue; + const value = spanOf(item); + if (!value) { + undated.push(item); + continue; + } + if (isLong(value)) { + longCount += 1; + if (!showLong) continue; + } + visible.push(item); + } + + return { visible, undated, longCount }; +} + +/** The tasks touching one day, in the order the grid stacks them. */ +export function itemsOnDay(items: T[], day: string): T[] { + return items + .filter((item) => { + const value = spanOf(item); + return value !== null && value.start <= day && value.end >= day; + }) + .sort((a, b) => { + const left = spanOf(a) as Span; + const right = spanOf(b) as Span; + return left.start.localeCompare(right.start) || right.days - left.days || a.id - b.id; + }); +} diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx index 9e920e8..cf7b400 100644 --- a/frontend/src/pages/TasksPage.tsx +++ b/frontend/src/pages/TasksPage.tsx @@ -1,9 +1,16 @@ /** - * Everything you owe, from every source, grouped by deadline. + * Everything you owe, from every source — as a list, or as a month. * * There is no checkbox. A task is closed in Jira, Todoist or Google Tasks — * wherever it lives — and the next refresh notices and credits it to the day. * Two places to tick something off is two places to be wrong. + * + * The page asks the backend for **done and stale rows too**, because the + * calendar's whole point is that a past month shows what actually happened. The + * list therefore filters `is_done`/`stale` itself rather than trusting the URL + * it was fetched from, and the source buttons are counted from that filtered + * list — `sources` and `counts` in the response now describe the archive, not + * the work in front of you. */ import { useMemo, useState } from "react"; import { @@ -13,51 +20,43 @@ import { Button, Chip, Collapse, - Dialog, - DialogActions, - DialogContent, - DialogTitle, Divider, - IconButton, LinearProgress, - Link, Stack, - TextField, ToggleButton, ToggleButtonGroup, - Tooltip, Typography, } from "@mui/material"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined"; -import StarIcon from "@mui/icons-material/Star"; -import StarBorderIcon from "@mui/icons-material/StarBorder"; import { useNavigate } from "react-router-dom"; import { PageHeader, Panel, SectionTitle } from "../components/Layout"; +import AddTaskDialog from "../components/tasks/AddTaskDialog"; +import SourceFilterTree from "../components/tasks/SourceFilterTree"; +import TaskList, { openTasks } from "../components/tasks/TaskList"; +import TaskRow from "../components/tasks/TaskRow"; +import TaskCalendar from "../components/calendar/TaskCalendar"; import { api } from "../lib/api"; -import { BUCKET_LABEL } from "../lib/format"; -import { useResource } from "../lib/hooks"; +import { buildModel } from "../lib/calendar"; +import { useLocalState, useResource } from "../lib/hooks"; import type { RefreshResult, Task, TaskListResponse } from "../lib/types"; -const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"]; -const BUCKET_COLOR: Record = { - overdue: "error.main", - today: "warning.main", - active: "success.main", - upcoming: "text.disabled", - someday: "divider", -}; +/** History included: the calendar draws past months, the list filters it back out. */ +const TASKS_PATH = "/api/tasks?include_done=true&include_stale=true"; export default function TasksPage({ onToast }: { onToast: (message: string) => void }) { const navigate = useNavigate(); - const tasks = useResource("/api/tasks", 60000); + const tasks = useResource(TASKS_PATH, 60000); const [refreshing, setRefreshing] = useState(false); const [sourceStatus, setSourceStatus] = useState(null); const [filter, setFilter] = useState("all"); const [addOpen, setAddOpen] = useState(false); - const [draft, setDraft] = useState({ title: "", due_date: "" }); + + // Per-device UI preferences. `useLocalState` JSON-stringifies, and a `Set` + // stringifies to `{}` — so the hidden keys are persisted as an array and the + // set is derived. + const [view, setView] = useLocalState<"list" | "calendar">("kadence.tasks.view", "list"); + const [hiddenKeys, setHiddenKeys] = useLocalState("kadence.tasks.hidden", []); + const [showLong, setShowLong] = useLocalState("kadence.tasks.showLong", false); const refresh = async () => { setRefreshing(true); @@ -79,19 +78,38 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v } }; - const visible = useMemo(() => { - const all = tasks.data?.tasks ?? []; - return filter === "all" ? all : all.filter((task) => task.source === filter); - }, [tasks.data, filter]); + const onPin = async (task: Task) => { + await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned }); + await tasks.reload(); + }; + const onStart = async (task: Task) => { + await api.post("/api/timer/start", { kind: "work", task_id: task.id }); + onToast(`Focusing on “${task.title}”`); + navigate("/"); + }; + const onDelete = async (task: Task) => { + await api.del(`/api/tasks/${task.id}`); + await tasks.reload(); + }; - const grouped = useMemo(() => { - const map: Record = { overdue: [], today: [], active: [], upcoming: [], someday: [] }; - for (const task of visible) { - const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday"; - map[bucket].push(task); - } - return map; - }, [visible]); + const all = useMemo(() => tasks.data?.tasks ?? [], [tasks.data]); + const open = useMemo(() => openTasks(all), [all]); + + // The response's `sources` now lists every source that ever had a row; the + // buttons have to describe what is actually on the list. + const openSources = useMemo(() => { + const labels = new Map(); + for (const task of open) labels.set(task.source, task.source_label || task.source); + return [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1])); + }, [open]); + + const listed = useMemo( + () => (filter === "all" ? open : open.filter((task) => task.source === filter)), + [open, filter], + ); + + const hiddenSet = useMemo(() => new Set(hiddenKeys), [hiddenKeys]); + const model = useMemo(() => buildModel(all, hiddenSet, showLong), [all, hiddenSet, showLong]); const failed = sourceStatus?.filter((item) => !item.ok) ?? []; @@ -130,16 +148,31 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v value && setFilter(value)} + value={view} + onChange={(_event, value) => value && setView(value as "list" | "calendar")} > - All - {(tasks.data?.sources ?? []).map((source) => ( - - {tasks.data?.tasks.find((task) => task.source === source)?.source_label ?? source} - - ))} + List + Calendar + + {view === "list" ? ( + value && setFilter(value)} + > + All + {openSources.map(([source, label]) => ( + + {label} + + ))} + + ) : ( +