fix timers
lint / ruff (push) Failing after 15s
tests / desktop (push) Successful in 21s
tests / web (push) Successful in 46s
docker / image (push) Failing after 1m31s
tests / android-core (push) Successful in 1m5s
tests / backend (push) Successful in 2m33s
android / apk (push) Successful in 4m34s

This commit is contained in:
timka123 committed 2026-10-27 18:52:54 +03:00
1 parent ff77d8f89f
commit 157a113c39
30 files changed
+3331 -419

No files matched your search

@@ -82,8 +82,19 @@ class KadenceRepository(val api: KadenceApi) {
suspend fun resume(): ApiResult<TimerStateDto> = api.post("/api/timer/resume") suspend fun resume(): ApiResult<TimerStateDto> = api.post("/api/timer/resume")
suspend fun complete(autoNext: Boolean = true): ApiResult<TimerStateDto> = /**
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<TimerStateDto> = api.post(
"/api/timer/complete",
body("auto_next" to autoNext, "session_id" to sessionId),
)
suspend fun abandon(reason: String? = null): ApiResult<TimerStateDto> = suspend fun abandon(reason: String? = null): ApiResult<TimerStateDto> =
api.post("/api/timer/abandon", body("reason" to reason)) api.post("/api/timer/abandon", body("reason" to reason))
@@ -98,7 +109,25 @@ class KadenceRepository(val api: KadenceApi) {
// ---- tasks -------------------------------------------------------------- // ---- tasks --------------------------------------------------------------
suspend fun tasks(): ApiResult<TaskListDto> = 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<TaskListDto> = 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. */ /** Fans out to Jira, Todoist, Google and the laundry site; slow by nature. */
suspend fun refreshTasks(): ApiResult<RefreshResultDto> = api.post("/api/tasks/refresh") suspend fun refreshTasks(): ApiResult<RefreshResultDto> = api.post("/api/tasks/refresh")
@@ -20,6 +20,7 @@ package tech.kadence.android.data.dto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import tech.kadence.core.CalendarItem
@Serializable @Serializable
data class TaskRefDto( data class TaskRefDto(
@@ -63,20 +64,25 @@ data class TimerStateDto(
@SerialName("timer_enabled") val timerEnabled: Boolean = true, @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 @Serializable
data class TaskDto( data class TaskDto(
val id: Long = 0, override val id: Long = 0,
val source: String = "", override val source: String = "",
@SerialName("source_label") val sourceLabel: String = "", @SerialName("source_label") override val sourceLabel: String = "",
@SerialName("external_id") val externalId: String = "", @SerialName("external_id") val externalId: String = "",
val title: String = "", val title: String = "",
val description: String? = null, val description: String? = null,
val url: String? = null, val url: String? = null,
val project: String? = null, override val project: String? = null,
val status: String? = null, val status: String? = null,
val priority: String? = null, val priority: String? = null,
@SerialName("due_date") val dueDate: String? = null, @SerialName("due_date") override val dueDate: String? = null,
@SerialName("start_date") val startDate: String? = null, @SerialName("start_date") override val startDate: String? = null,
val labels: List<String> = emptyList(), val labels: List<String> = emptyList(),
@SerialName("is_done") val isDone: Boolean = false, @SerialName("is_done") val isDone: Boolean = false,
val pinned: Boolean = false, val pinned: Boolean = false,
@@ -84,7 +90,7 @@ data class TaskDto(
@SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null, @SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null,
@SerialName("days_overdue") val daysOverdue: Int = 0, @SerialName("days_overdue") val daysOverdue: Int = 0,
val bucket: String = "someday", val bucket: String = "someday",
) ) : CalendarItem
@Serializable @Serializable
data class TaskListDto( data class TaskListDto(
@@ -75,6 +75,28 @@ class KadencePrefs(context: Context) {
get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L) get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L)
set(value) = prefs.edit().putLong(KEY_UPDATE_CHECKED_AT, value).apply() 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<String>
// 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) { fun update(transform: (KadenceConfig) -> KadenceConfig) {
val next = transform(_config.value) val next = transform(_config.value)
prefs.edit().apply { prefs.edit().apply {
@@ -105,6 +127,9 @@ class KadencePrefs(context: Context) {
private const val KEY_ONBOARDED = "onboarded" private const val KEY_ONBOARDED = "onboarded"
private const val KEY_UPDATE_SKIPPED = "update_skipped_version" private const val KEY_UPDATE_SKIPPED = "update_skipped_version"
private const val KEY_UPDATE_CHECKED_AT = "update_checked_at" 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 { fun normalizeBaseUrl(raw: String): String {
val trimmed = raw.trim().trimEnd('/') val trimmed = raw.trim().trimEnd('/')
@@ -22,7 +22,10 @@
* whose time is up — it just reports `remaining_seconds = 0`. A client has to call * 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 * `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 * 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 package tech.kadence.android.timer
@@ -147,8 +150,17 @@ class TimerEngine(
*/ */
private val mutex = Mutex() 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 pollJob: Job? = null
private var tickJob: Job? = null private var tickJob: Job? = null
@@ -257,10 +269,12 @@ class TimerEngine(
if (remaining != _state.value.remaining) { if (remaining != _state.value.remaining) {
_state.value = _state.value.copy(remaining = 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 // The countdown has run out. The server books the interval and its
// reply decides what follows — an auto-started break, or nothing. // reply decides what follows — an auto-started break, or nothing.
completing = true completedId = current.sessionId
complete() complete()
} }
} }
@@ -369,7 +383,6 @@ class TimerEngine(
val current = server.current val current = server.current
if (current == null) { if (current == null) {
anchor = null anchor = null
completing = false
} else { } else {
val changed = anchor?.sessionId != current.id || val changed = anchor?.sessionId != current.id ||
anchor?.paused != current.isPaused || anchor?.paused != current.isPaused ||
@@ -386,7 +399,6 @@ class TimerEngine(
paused = current.isPaused, paused = current.isPaused,
) )
} }
if (current.id != _state.value.sessionId) completing = false
} }
_state.value = _state.value.copy( _state.value = _state.value.copy(
@@ -467,12 +479,15 @@ class TimerEngine(
_state.value = before.ended() _state.value = before.ended()
anchor = null anchor = null
enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before) enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before)
completing = false
return@launch 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 { mutex.withLock {
_state.value = _state.value.copy(busy = true) _state.value = _state.value.copy(busy = true)
when (val result = repo.complete(autoNext)) { when (val result = repo.complete(autoNext, expected)) {
is ApiResult.Ok -> { is ApiResult.Ok -> {
val dto = result.value val dto = result.value
applyServer(dto, fromCommand = true) applyServer(dto, fromCommand = true)
@@ -493,10 +508,14 @@ class TimerEngine(
anchor = null anchor = null
enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before) 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) _state.value = _state.value.copy(busy = false)
completing = false
} }
} }
@@ -704,7 +723,10 @@ class TimerEngine(
) )
OutboxOp.PAUSE -> repo.pause() OutboxOp.PAUSE -> repo.pause()
OutboxOp.RESUME -> repo.resume() 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.ABANDON -> repo.abandon(entry.reason)
OutboxOp.EXTEND -> repo.extend(entry.minutes) OutboxOp.EXTEND -> repo.extend(entry.minutes)
OutboxOp.INTERRUPT -> repo.interrupt() OutboxOp.INTERRUPT -> repo.interrupt()
@@ -69,6 +69,12 @@ fun KadenceApp(app: KadenceApplication) {
val updateState by app.updates.state.collectAsStateWithLifecycle() val updateState by app.updates.state.collectAsStateWithLifecycle()
var showOnboarding by remember { mutableStateOf(!config.onboarded) } 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 showUpdateDialog by remember { mutableStateOf(false) }
var destination by remember { mutableStateOf(Destination.Timer) } var destination by remember { mutableStateOf(Destination.Timer) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -157,6 +163,21 @@ fun KadenceApp(app: KadenceApplication) {
cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()), cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()),
runningTaskTitle = ui.taskTitle, runningTaskTitle = ui.taskTitle,
intervalRunning = ui.isActive, 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 -> onAttach = { taskId ->
// `attach` needs something to attach to: with nothing // `attach` needs something to attach to: with nothing
// running the server answers "No interval is running." // running the server answers "No interval is running."
@@ -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<CalendarItem>,
hidden: Set<String>,
onChange: (Set<String>) -> 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,
)
}
}
@@ -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<WeekLayout>,
/** 1-12 — days outside it are drawn faint. */
month: Int,
today: String,
tasksById: Map<Long, TaskDto>,
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<Long, TaskDto>,
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,
)
}
}
}
@@ -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}") }
}
@@ -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 * Read-only, and that is the product decision rather than a shortcut: **you cannot
* a task off here.** Completion belongs to the tool the task lives in — close it in * tick a task off here.** Completion belongs to the tool the task lives in — close
* Jira, Todoist or Google Tasks and the next refresh notices. Two places to mark * it in Jira, Todoist or Google Tasks and the next refresh notices. Two places to
* something done is two places to be wrong. * 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 * The screen asks the backend for **done and stale rows too**, because a past
* task where it lives**, which is where closing it happens; "Track" attaches it to the * month is only worth looking at if the work that finished is on it, and laundry
* running interval, which is the one thing that belongs to Kadence. A task with no * slots go `stale` rather than done. The list therefore filters those out itself
* `url` — a local one, or a source that supplied none — is simply not tappable rather * rather than trusting the URL the payload came from — which also means the single
* than tappable-and-inert. * `Cached.TASKS` slot stays correct whichever view wrote it last.
* *
* Bucket order and labels come from `:core`'s `BUCKET_ORDER` / `BUCKET_LABEL`, so the * Bucket order and labels come from `:core`, and so does the whole calendar
* phone cannot drift from the web app's wording — including the deliberate choice of * layout, so the phone cannot drift from the web app.
* "Would be nice to start" over the desktop's "In progress". *
* 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 package tech.kadence.android.ui.tasks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.TaskDto 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.net.ApiResult
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.Loadable 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.Banner
import tech.kadence.android.ui.components.Eyebrow
import tech.kadence.android.ui.components.GhostButton import tech.kadence.android.ui.components.GhostButton
import tech.kadence.android.ui.components.HorizontalRule import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.components.PageHeader import tech.kadence.android.ui.components.PageHeader
import tech.kadence.android.ui.components.Panel 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.ui.components.SectionTitle
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.rememberLoad import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.BUCKET_LABEL import tech.kadence.core.BUCKET_LABEL
import tech.kadence.core.BUCKET_ORDER 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 @Composable
fun TasksScreen( fun TasksScreen(
repo: KadenceRepository, repo: KadenceRepository,
/** Yesterday's list, so a phone with no signal is not an empty screen. */ /** Yesterday's list, so a phone with no signal is not an empty screen. */
cache: CacheSlot<tech.kadence.android.data.dto.TaskListDto>? = null, cache: CacheSlot<TaskListDto>? = null,
runningTaskTitle: String?, runningTaskTitle: String?,
/** An interval is running — with or without a task attached to it. */ /** An interval is running — with or without a task attached to it. */
intervalRunning: Boolean, intervalRunning: Boolean,
calendarView: Boolean,
onSetCalendarView: (Boolean) -> Unit,
showLong: Boolean,
onSetShowLong: (Boolean) -> Unit,
hiddenKeys: Set<String>,
onSetHiddenKeys: (Set<String>) -> Unit,
onAttach: (Long) -> Unit, onAttach: (Long) -> Unit,
) { ) {
val colors = KadenceTheme.colors val colors = KadenceTheme.colors
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() 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 refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<String?>(null) } var refreshNote by remember { mutableStateOf<String?>(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<String?>(null) }
var filterOpen by rememberSaveable { mutableStateOf(false) }
fun refresh() { fun refresh() {
refreshing = true refreshing = true
refreshNote = null refreshNote = null
@@ -106,23 +140,21 @@ fun TasksScreen(
} }
val state = loader.value 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( LazyColumn(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp),
start = 20.dp,
end = 20.dp,
top = 24.dp,
bottom = 32.dp,
),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
item { item {
val total = (state as? Loadable.Loaded)?.value?.tasks?.size
PageHeader( PageHeader(
eyebrow = "Everything open", eyebrow = "Everything open",
title = "Tasks", title = "Tasks",
caption = total?.let { "$it across your sources" }, caption = if (state is Loadable.Loaded) "${open.size} across your sources" else null,
) { ) {
GhostButton( GhostButton(
text = if (refreshing) "Refreshing…" else "Refresh sources", 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 -> refreshNote?.let { note ->
item { Banner(text = note, actionLabel = "Retry", onAction = ::refresh) } item { Banner(text = note, actionLabel = "Retry", onAction = ::refresh) }
} }
@@ -155,148 +194,239 @@ fun TasksScreen(
item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) } item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) }
} }
val byBucket = state.value.tasks.groupBy { it.bucket } if (calendarView) {
// BUCKET_ORDER first, then anything the backend invented later — an calendarBody(
// unknown bucket must still be visible rather than silently dropped. all = all,
val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } + hiddenKeys = hiddenKeys,
byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted() onSetHiddenKeys = onSetHiddenKeys,
showLong = showLong,
if (state.value.tasks.isEmpty()) { onSetShowLong = onSetShowLong,
item { today = today,
Panel { year = cursorYear,
Text( month = cursorMonth,
text = "Nothing open. Either you are done, or no source " + onShiftMonth = { delta ->
"is configured yet — the backend's Settings page " + val (y, m) = shiftMonth(cursorYear, cursorMonth, delta)
"is where they go.", cursorYear = y
style = KadenceTheme.type.bodySmall, cursorMonth = m
color = colors.inkMuted, selectedDay = null
) },
} onToday = {
} cursorYear = LocalDate.now().year
} cursorMonth = LocalDate.now().monthValue
selectedDay = null
buckets.forEach { bucket -> },
val tasks = byBucket[bucket].orEmpty() selectedDay = selectedDay,
item(key = "head-$bucket") { onSelectDay = { selectedDay = if (it == selectedDay) null else it },
SectionTitle( filterOpen = filterOpen,
label = BUCKET_LABEL[bucket] ?: bucket, onToggleFilter = { filterOpen = !filterOpen },
trailing = "${tasks.size}", runningTaskTitle = runningTaskTitle,
) intervalRunning = intervalRunning,
} onOpenTask = { openTask(context, it) },
item(key = "body-$bucket") { onAttach = onAttach,
Panel(padding = 0.dp) { )
tasks.forEachIndexed { index, task -> } else {
if (index > 0) HorizontalRule() listBody(
TaskRow( tasks = open,
task = task, runningTaskTitle = runningTaskTitle,
isRunning = task.title == runningTaskTitle, intervalRunning = intervalRunning,
anyIntervalRunning = intervalRunning, onOpenTask = { openTask(context, it) },
// Tapping the row opens the task where it lives — onAttach = onAttach,
// 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) },
)
}
}
}
} }
} }
} }
} }
} }
@Composable /** The original view: grouped by deadline bucket. */
private fun TaskRow( private fun LazyListScope.listBody(
task: TaskDto, tasks: List<TaskDto>,
isRunning: Boolean, runningTaskTitle: String?,
anyIntervalRunning: Boolean, intervalRunning: Boolean,
onOpen: () -> Unit, onOpenTask: (TaskDto) -> Unit,
onTrack: () -> Unit, onAttach: (Long) -> Unit,
) { ) {
val colors = KadenceTheme.colors val byBucket = tasks.groupBy { it.bucket }
val due = dueLabel(task.dueDate, task.daysOverdue) // BUCKET_ORDER first, then anything the backend invented later — an unknown
val linkable = !task.url.isNullOrBlank() // 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( if (tasks.isEmpty()) {
Modifier item {
.fillMaxWidth() Panel {
// 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.
Text( Text(
text = if (anyIntervalRunning) "Track" else "Focus", text = "Nothing open. Either you are done, or no source is " +
style = KadenceTheme.type.button, "configured yet — the backend's Settings page is where they go.",
color = colors.accent, 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) },
)
}
}
}
}
} }
/** private val MONTH_LABEL = DateTimeFormatter.ofPattern("LLLL yyyy")
* Open the task in whatever app or browser owns it.
* private fun LazyListScope.calendarBody(
* `url` is absent for local tasks, and for a source that did not supply one; the row all: List<TaskDto>,
* is not clickable in that case, so this is only ever reached with something to open. hiddenKeys: Set<String>,
* Still guarded, because an unparseable URL from a third-party API must not crash the onSetHiddenKeys: (Set<String>) -> Unit,
* list. showLong: Boolean,
*/ onSetShowLong: (Boolean) -> Unit,
private fun openTask(context: android.content.Context, task: TaskDto) { today: String,
val url = task.url?.takeIf { it.isNotBlank() } ?: return year: Int,
val intent = android.content.Intent( month: Int,
android.content.Intent.ACTION_VIEW, onShiftMonth: (Int) -> Unit,
android.net.Uri.parse(url), onToday: () -> Unit,
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) selectedDay: String?,
runCatching { context.startActivity(intent) } onSelectDay: (String) -> Unit,
.onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") } 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")
@@ -79,6 +79,8 @@ class TimerEngineTest {
path.endsWith("/api/timer/pause") -> running(pauseReplyStatus, remaining = 900) path.endsWith("/api/timer/pause") -> running(pauseReplyStatus, remaining = 900)
path.endsWith("/api/timer/resume") -> running("running") path.endsWith("/api/timer/resume") -> running("running")
path.endsWith("/api/timer/extend") -> running("running", 1404, 1800) 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") else -> fixture("timer_state_running.json")
} }
return MockResponse.Builder() return MockResponse.Builder()
@@ -140,6 +142,30 @@ class TimerEngineTest {
assertTrue(engine.state.value.interruptions >= 1) 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 @Test
fun `toggle reads the state it just changed`() { fun `toggle reads the state it just changed`() {
val engine = engine() val engine = engine()
@@ -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<List<String>> {
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<List<String>>()
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<String> =
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<Int, Int> {
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<String>,
val bars: List<Bar>,
/** Per column, how many tasks this week could not draw — the "+N" counts. */
val overflow: List<Int>,
/** 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<List<String>>,
items: List<CalendarItem>,
maxLanes: Int,
): List<WeekLayout> {
val placed = items.mapNotNull { item -> spanOf(item)?.let { Placed(item, it) } }
val laneOf = mutableMapOf<Long, Int>()
val layouts = mutableListOf<WeekLayout>()
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<Bar>()
val overflow = IntArray(7)
fun columns(entry: Placed): Pair<Int, Int> = 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<Placed> { 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<FilterNode>,
)
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<CalendarItem>): List<FilterNode> {
class Entry(var label: String) {
var count = 0
val projects = linkedMapOf<String, Int>()
}
val sources = linkedMapOf<String, Entry>()
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<String>): 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<T : CalendarItem>(
/** What the grid draws. */
val visible: List<T>,
/** Passing the type filter but carrying no due date, so nowhere to draw them. */
val undated: List<T>,
/** 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 <T : CalendarItem> buildModel(
items: List<T>,
hidden: Set<String>,
showLong: Boolean,
): CalendarModel<T> {
val visible = mutableListOf<T>()
val undated = mutableListOf<T>()
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 <T : CalendarItem> itemsOnDay(items: List<T>, day: String): List<T> =
items
.mapNotNull { item -> spanOf(item)?.let { Pair(item, it) } }
.filter { (_, span) -> span.start <= day && span.end >= day }
.sortedWith(
compareBy<Pair<T, Span>> { it.second.start }
.thenByDescending { it.second.days }
.thenBy { it.first.id },
)
.map { it.first }
@@ -11,7 +11,10 @@
* * `POST /api/timer/start` **abandons** whatever is running, with reason * * `POST /api/timer/start` **abandons** whatever is running, with reason
* `"superseded"` — so a queued start is destructive, not additive. * `"superseded"` — so a queued start is destructive, not additive.
* * `POST /api/timer/complete` may **auto-start the next interval**, so a reply can * * `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 * * There is **no stop endpoint**: stopping is `abandon`, and under `strict_mode` a
* work interval refuses to be abandoned without a reason. * work interval refuses to be abandoned without a reason.
*/ */
@@ -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")
}
}
@@ -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<WeekLayout>, 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<String>()
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)
}
}
+9 -2
View File
@@ -63,11 +63,18 @@ def resume(session: DbSession, settings: Settings) -> dict:
@router.post("/complete") @router.post("/complete")
def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict: def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict:
try: 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: except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from 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 = pomodoro.state(session, settings)
state["finished"] = pomodoro.session_dict(finished) state["finished"] = pomodoro.session_dict(finished)
state["auto_started"] = pomodoro.session_dict(follow_on) state["auto_started"] = pomodoro.session_dict(follow_on)
+10
View File
@@ -29,6 +29,16 @@ class AttachTaskRequest(BaseModel):
class CompleteTimerRequest(BaseModel): class CompleteTimerRequest(BaseModel):
auto_next: bool = True 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): class TaskCreateRequest(BaseModel):
+22 -4
View File
@@ -176,10 +176,28 @@ def resume(session: Session) -> PomodoroSession:
def complete( def complete(
session: Session, settings: AppSettings, *, auto_next: bool = True session: Session,
) -> tuple[PomodoroSession, PomodoroSession | None]: settings: AppSettings,
"""Finish the current interval and, if configured, roll into the next one.""" *,
row = _require_active(session) 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") finished = _finish(session, settings, row, status="completed")
follow_on: PomodoroSession | None = None follow_on: PomodoroSession | None = None
+22
View File
@@ -29,6 +29,28 @@ def test_a_whole_pomodoro_through_the_api(client):
assert "Daily report" in report["telegram_preview"] 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): def test_timer_errors_are_400_not_500(client):
assert client.post("/api/timer/pause").status_code == 400 assert client.post("/api/timer/pause").status_code == 400
assert client.post("/api/timer/complete", json={}).status_code == 400 assert client.post("/api/timer/complete", json={}).status_code == 400
+34
View File
@@ -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" 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): def test_strict_mode_demands_a_reason(session, settings):
settings.pomodoro.strict_mode = True settings.pomodoro.strict_mode = True
settings_service.save_settings(session, settings) settings_service.save_settings(session, settings)
+12 -2
View File
@@ -87,8 +87,18 @@ class Api:
def resume(self) -> dict: def resume(self) -> dict:
return self._request("POST", "/api/timer/resume", {}) return self._request("POST", "/api/timer/resume", {})
def complete(self, auto_next: bool = True) -> dict: def complete(self, auto_next: bool = True, session_id: int | None = None) -> dict:
return self._request("POST", "/api/timer/complete", {"auto_next": auto_next}) """`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: def abandon(self, reason: str | None = None) -> dict:
return self._request("POST", "/api/timer/abandon", {"reason": reason}) return self._request("POST", "/api/timer/abandon", {"reason": reason})
+30 -7
View File
@@ -78,7 +78,17 @@ class Controller(QObject):
self._last_session_id: int | None = None self._last_session_id: int | None = None
self._last_kind = "" self._last_kind = ""
self._last_status = "" 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._paused_by_lock = False
self._mini_visible = False self._mini_visible = False
self._mini_dismissed = False self._mini_dismissed = False
@@ -174,6 +184,13 @@ class Controller(QObject):
current = self._state.get("current") current = self._state.get("current")
return str(current["status"]) if current else "idle" 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) @pyqtProperty(int, notify=stateChanged)
def remainingSeconds(self) -> int: def remainingSeconds(self) -> int:
if self._state.get("current"): if self._state.get("current"):
@@ -393,7 +410,7 @@ class Controller(QObject):
@pyqtSlot() @pyqtSlot()
def complete(self) -> None: def complete(self) -> None:
self._send("complete", {"auto_next": True}) self._send("complete", {"auto_next": True, "session_id": self._session_id})
@pyqtSlot() @pyqtSlot()
@pyqtSlot(str) @pyqtSlot(str)
@@ -567,7 +584,6 @@ class Controller(QObject):
self._last_session_id = session_id self._last_session_id = session_id
self._last_kind = kind self._last_kind = kind
self._last_status = status self._last_status = status
self._completing = False
self.stateChanged.emit() self.stateChanged.emit()
self._update_launcher() self._update_launcher()
@@ -610,12 +626,18 @@ class Controller(QObject):
def _on_command_failed(self, name: str, detail: str) -> None: def _on_command_failed(self, name: str, detail: str) -> None:
log.warning("%s failed: %s", name, detail) 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) self.toast.emit(detail)
def _on_command_done(self, name: str, result: dict[str, Any]) -> None: def _on_command_done(self, name: str, result: dict[str, Any]) -> None:
if name == "complete": if name == "complete":
finished = result.get("finished") or {} finished = result.get("finished")
self._announce_finished(str(finished.get("kind", "work")), result) # 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": elif name == "send_report":
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings") self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
elif name == "refresh_sources": elif name == "refresh_sources":
@@ -706,10 +728,11 @@ class Controller(QObject):
if self._remaining % 5 == 0: if self._remaining % 5 == 0:
self._update_launcher() self._update_launcher()
return 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 # The countdown reached zero: the server books the interval, and its
# reply decides what happens next (auto-break, or nothing). # reply decides what happens next (auto-break, or nothing).
self._completing = True self._completed_id = session_id
self.complete() self.complete()
def _check_idle(self) -> None: def _check_idle(self) -> None:
+1 -1
View File
@@ -3,7 +3,7 @@ Type=Application
Name=Kadence Name=Kadence
GenericName=Pomodoro timer GenericName=Pomodoro timer
Comment=Pomodoro timer and focus guard — a tray agent with a floating countdown Comment=Pomodoro timer and focus guard — a tray agent with a floating countdown
Exec=kadence-desktop --mini Exec=kadence-desktop
TryExec=kadence-desktop TryExec=kadence-desktop
Icon=kadence Icon=kadence
Terminal=false Terminal=false
@@ -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<void>;
onStart: (task: Task) => Promise<void>;
onDelete: (task: Task) => Promise<void>;
}) {
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 (
<>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}
>
<Button size="small" sx={{ minWidth: 32, px: 0 }} onClick={() => setCursor(shiftMonth(year, month, -1))}>
<ChevronLeftIcon fontSize="small" />
</Button>
<Typography
sx={{
fontFamily: theme.typography.h4.fontFamily,
fontSize: 20,
minWidth: { xs: 0, sm: 190 },
letterSpacing: "-0.01em",
}}
>
{label}
</Typography>
<Button size="small" sx={{ minWidth: 32, px: 0 }} onClick={() => setCursor(shiftMonth(year, month, 1))}>
<ChevronRightIcon fontSize="small" />
</Button>
<Button
size="small"
onClick={() => {
const now = new Date();
setCursor([now.getFullYear(), now.getMonth() + 1]);
}}
>
Today
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button
size="small"
variant={showLong ? "contained" : "outlined"}
disabled={longCount === 0}
onClick={() => onShowLong(!showLong)}
>
Long tasks ({longCount})
</Button>
</Stack>
<Panel padded={false} sx={{ overflow: "hidden" }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
bgcolor: "action.hover",
borderBottom: 1,
borderColor: "divider",
}}
>
{weekdayLabels(WEEK_STARTS_MONDAY).map((name) => (
<Typography
key={name}
variant="overline"
color="text.secondary"
sx={{ textAlign: "center", py: 0.75, fontSize: 10 }}
>
{wide ? name : name.slice(0, 1)}
</Typography>
))}
</Box>
{layouts.map((week, index) => (
<WeekRow
key={week.dates[0]}
week={week}
month={month}
today={today}
byId={byId}
last={index === layouts.length - 1}
onPickDay={(date, anchor) => setDay({ date, anchor })}
/>
))}
</Panel>
<Popover
open={day !== null}
anchorEl={day?.anchor ?? null}
onClose={() => setDay(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
slotProps={{ paper: { sx: { mt: 0.5, width: 380, maxWidth: "92vw", maxHeight: 420 } } }}
>
<Box sx={{ p: 2 }}>
<Typography variant="overline" color="text.secondary">
{day && new Date(`${day.date}T00:00:00`).toLocaleDateString(undefined, {
weekday: "long",
day: "numeric",
month: "long",
})}
</Typography>
<Divider sx={{ mt: 0.5 }} />
{dayTasks.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>
Nothing on this day.
</Typography>
) : (
<Stack divider={<Divider />}>
{dayTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={() => onPin(task)}
onStart={() => onStart(task)}
onDelete={() => onDelete(task)}
/>
))}
</Stack>
)}
</Box>
</Popover>
</>
);
}
function WeekRow({
week,
month,
today,
byId,
last,
onPickDay,
}: {
week: WeekLayout;
month: number;
today: string;
byId: Map<number, Task>;
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 (
<Box
sx={{
position: "relative",
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
minHeight: Math.max(MIN_ROW, height),
borderBottom: last ? 0 : 1,
borderColor: "divider",
}}
>
{week.dates.map((date, column) => {
const outside = Number(date.slice(5, 7)) !== month;
const isToday = date === today;
return (
<Box
key={date}
onClick={(event) => 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" },
}}
>
<Typography
component="span"
variant="caption"
sx={{
fontVariantNumeric: "tabular-nums",
color: isToday ? "primary.main" : outside ? "text.disabled" : "text.secondary",
fontWeight: isToday ? 600 : 400,
borderBottom: isToday ? 2 : 0,
borderColor: "primary.main",
pb: isToday ? "1px" : 0,
}}
>
{Number(date.slice(8, 10))}
</Typography>
</Box>
);
})}
{week.bars.map((bar) => {
const task = byId.get(bar.id);
if (!task) return null;
return (
<Box
key={`${bar.id}-${bar.startCol}`}
sx={{
position: "absolute",
pointerEvents: "none",
top: DAY_STRIP + bar.lane * (BAR_H + BAR_GAP),
left: `${(bar.startCol / 7) * 100}%`,
width: `${((bar.endCol - bar.startCol + 1) / 7) * 100}%`,
px: "2px",
boxSizing: "border-box",
}}
>
<TaskBar task={task} today={today} continuesLeft={bar.continuesLeft} continuesRight={bar.continuesRight} />
</Box>
);
})}
{week.hasOverflow &&
week.overflow.map((count, column) =>
count > 0 ? (
<Typography
key={`more-${column}`}
variant="caption"
color="text.secondary"
sx={{
position: "absolute",
pointerEvents: "none",
top: DAY_STRIP + week.lanes * (BAR_H + BAR_GAP) + 1,
left: `${(column / 7) * 100}%`,
width: `${(1 / 7) * 100}%`,
px: "6px",
boxSizing: "border-box",
fontSize: 11,
}}
>
+{count} more
</Typography>
) : null,
)}
</Box>
);
}
/**
* 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 (
<Box
sx={{
height: BAR_H,
display: "flex",
alignItems: "center",
gap: "6px",
px: "6px",
bgcolor: "action.hover",
borderTopLeftRadius: continuesLeft ? 0 : radius,
borderBottomLeftRadius: continuesLeft ? 0 : radius,
borderTopRightRadius: continuesRight ? 0 : radius,
borderBottomRightRadius: continuesRight ? 0 : radius,
minWidth: 0,
}}
>
{!continuesLeft && (
<Box
sx={{
width: 2,
height: "60%",
flexShrink: 0,
borderRadius: 2,
bgcolor: late ? "error.main" : muted ? "text.disabled" : "text.secondary",
}}
/>
)}
<Typography
noWrap
sx={{
fontSize: 11.5,
lineHeight: 1,
minWidth: 0,
color: muted ? "text.disabled" : late ? "error.main" : "text.primary",
textDecoration: task.is_done ? "line-through" : "none",
}}
>
{task.title}
</Typography>
</Box>
);
}
@@ -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<void>;
}) {
const [draft, setDraft] = useState({ title: "", due_date: "" });
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>New task</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
autoFocus
label="What needs doing?"
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
<TextField
label="Due date"
type="date"
InputLabelProps={{ shrink: true }}
value={draft.due_date}
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
/>
<Typography variant="caption" color="text.secondary">
Local tasks live only here, so they are the one kind you can delete.
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
disabled={!draft.title.trim()}
onClick={async () => {
await api.post("/api/tasks", {
title: draft.title.trim(),
due_date: draft.due_date || null,
});
setDraft({ title: "", due_date: "" });
onClose();
await onCreated();
}}
>
Add
</Button>
</DialogActions>
</Dialog>
);
}
@@ -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<HTMLElement | null>(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 (
<>
<Button
size="small"
variant="outlined"
startIcon={<FilterListIcon sx={{ fontSize: 16 }} />}
onClick={(event) => setAnchor(event.currentTarget)}
>
{hidden.length ? `Filter (${hidden.length} hidden)` : "Filter"}
</Button>
<Popover
open={anchor !== null}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 260, maxHeight: 420 } } }}
>
<Box sx={{ p: 2 }}>
<Stack direction="row" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="overline" color="text.secondary">
Show
</Typography>
<Box sx={{ flexGrow: 1 }} />
<Button size="small" disabled={hidden.length === 0} onClick={() => onChange([])}>
Reset
</Button>
</Stack>
<Divider sx={{ mb: 1 }} />
{tree.length === 0 && (
<Typography variant="body2" color="text.secondary">
Nothing to filter yet.
</Typography>
)}
{tree.map((node) => {
const sourceHidden = hiddenSet.has(node.key);
return (
<Box key={node.key} sx={{ mb: 0.5 }}>
<FilterRow
label={node.label}
count={node.count}
checked={!sourceHidden}
bold
onChange={(visible) =>
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) => (
<FilterRow
key={child.key}
label={child.label}
count={child.count}
indent
disabled={sourceHidden}
checked={!sourceHidden && !hiddenSet.has(child.key)}
onChange={(visible) => setHidden(child.key, !visible)}
/>
))}
</Box>
);
})}
</Box>
</Popover>
</>
);
}
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 (
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ pl: indent ? 3 : 0, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.45 : 1 }}
onClick={() => !disabled && onChange(!checked)}
>
<Checkbox size="small" checked={checked} disabled={disabled} sx={{ p: 0.5 }} />
<Typography variant="body2" noWrap sx={{ flexGrow: 1, minWidth: 0, fontWeight: bold ? 600 : 400 }}>
{label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: "tabular-nums" }}>
{count}
</Typography>
</Stack>
);
}
@@ -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<void>;
onStart: (task: Task) => Promise<void>;
onDelete: (task: Task) => Promise<void>;
}) {
const grouped = useMemo(() => {
const map: Record<string, Task[]> = { 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 (
<Panel>
<Typography variant="h6" sx={{ mb: 1 }}>
Nothing on the list
</Typography>
<Typography variant="body2" color="text.secondary">
Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself.
</Typography>
</Panel>
);
}
return (
<>
{BUCKETS.map((bucket) =>
grouped[bucket].length ? (
<Box key={bucket} sx={{ mb: 4 }}>
<SectionTitle
action={
<Typography variant="caption" color="text.secondary">
{grouped[bucket].length}
</Typography>
}
>
{BUCKET_LABEL[bucket]}
</SectionTitle>
<Stack divider={<Divider />}>
{grouped[bucket].map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={() => onPin(task)}
onStart={() => onStart(task)}
onDelete={() => onDelete(task)}
/>
))}
</Stack>
</Box>
) : null,
)}
</>
);
}
+120
View File
@@ -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<string, string> = {
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<void>;
onStart: () => Promise<void>;
onDelete: () => Promise<void>;
}) {
return (
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }}
>
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} />
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography
variant="body1"
noWrap
sx={{
fontWeight: task.pinned ? 600 : 400,
textDecoration: task.is_done ? "line-through" : "none",
color: task.is_done ? "text.disabled" : "text.primary",
}}
>
{task.title}
</Typography>
{task.url && (
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}>
<OpenInNewIcon sx={{ fontSize: 14 }} />
</Link>
)}
</Stack>
<Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{task.source_label}
</Typography>
{task.project && (
<Typography variant="caption" color="text.secondary">
{task.project}
</Typography>
)}
{task.status && (
<Typography variant="caption" color="text.secondary">
{task.status}
</Typography>
)}
{task.priority && (
<Typography variant="caption" color="text.secondary">
{task.priority}
</Typography>
)}
{/* `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 ? (
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
{task.days_overdue}d overdue
</Typography>
) : (
task.due_date && (
<Typography variant="caption" color="text.secondary">
due {task.due_date}
</Typography>
)
)}
</Stack>
</Box>
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
<IconButton size="small" onClick={() => void onPin()}>
{task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
</IconButton>
</Tooltip>
<Tooltip title="Start a pomodoro on this">
<IconButton size="small" onClick={() => void onStart()}>
<PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{task.source === "local" && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => void onDelete()}>
<DeleteOutlineIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
)}
</Stack>
</Stack>
);
}
+443
View File
@@ -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<number, number>();
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<string, { label: string; count: number; projects: Map<string, number> }>();
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<string>): 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<T> {
/** 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<T extends CalendarItem>(
items: T[],
hidden: ReadonlySet<string>,
showLong: boolean,
): CalendarModel<T> {
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<T extends CalendarItem>(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;
});
}
+137 -211
View File
@@ -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 — * 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. * 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. * 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 { useMemo, useState } from "react";
import { import {
@@ -13,51 +20,43 @@ import {
Button, Button,
Chip, Chip,
Collapse, Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider, Divider,
IconButton,
LinearProgress, LinearProgress,
Link,
Stack, Stack,
TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Tooltip,
Typography, Typography,
} from "@mui/material"; } 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 { useNavigate } from "react-router-dom";
import { PageHeader, Panel, SectionTitle } from "../components/Layout"; 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 { api } from "../lib/api";
import { BUCKET_LABEL } from "../lib/format"; import { buildModel } from "../lib/calendar";
import { useResource } from "../lib/hooks"; import { useLocalState, useResource } from "../lib/hooks";
import type { RefreshResult, Task, TaskListResponse } from "../lib/types"; import type { RefreshResult, Task, TaskListResponse } from "../lib/types";
const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"]; /** History included: the calendar draws past months, the list filters it back out. */
const BUCKET_COLOR: Record<string, string> = { const TASKS_PATH = "/api/tasks?include_done=true&include_stale=true";
overdue: "error.main",
today: "warning.main",
active: "success.main",
upcoming: "text.disabled",
someday: "divider",
};
export default function TasksPage({ onToast }: { onToast: (message: string) => void }) { export default function TasksPage({ onToast }: { onToast: (message: string) => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const tasks = useResource<TaskListResponse>("/api/tasks", 60000); const tasks = useResource<TaskListResponse>(TASKS_PATH, 60000);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null); const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null);
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [addOpen, setAddOpen] = useState(false); 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<string[]>("kadence.tasks.hidden", []);
const [showLong, setShowLong] = useLocalState<boolean>("kadence.tasks.showLong", false);
const refresh = async () => { const refresh = async () => {
setRefreshing(true); setRefreshing(true);
@@ -79,19 +78,38 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
} }
}; };
const visible = useMemo(() => { const onPin = async (task: Task) => {
const all = tasks.data?.tasks ?? []; await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
return filter === "all" ? all : all.filter((task) => task.source === filter); await tasks.reload();
}, [tasks.data, filter]); };
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 all = useMemo(() => tasks.data?.tasks ?? [], [tasks.data]);
const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] }; const open = useMemo(() => openTasks(all), [all]);
for (const task of visible) {
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday"; // The response's `sources` now lists every source that ever had a row; the
map[bucket].push(task); // buttons have to describe what is actually on the list.
} const openSources = useMemo(() => {
return map; const labels = new Map<string, string>();
}, [visible]); 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) ?? []; const failed = sourceStatus?.filter((item) => !item.ok) ?? [];
@@ -130,16 +148,31 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
exclusive exclusive
value={filter} value={view}
onChange={(_event, value) => value && setFilter(value)} onChange={(_event, value) => value && setView(value as "list" | "calendar")}
> >
<ToggleButton value="all">All</ToggleButton> <ToggleButton value="list">List</ToggleButton>
{(tasks.data?.sources ?? []).map((source) => ( <ToggleButton value="calendar">Calendar</ToggleButton>
<ToggleButton key={source} value={source}>
{tasks.data?.tasks.find((task) => task.source === source)?.source_label ?? source}
</ToggleButton>
))}
</ToggleButtonGroup> </ToggleButtonGroup>
{view === "list" ? (
<ToggleButtonGroup
size="small"
exclusive
value={filter}
onChange={(_event, value) => value && setFilter(value)}
>
<ToggleButton value="all">All</ToggleButton>
{openSources.map(([source, label]) => (
<ToggleButton key={source} value={source}>
{label}
</ToggleButton>
))}
</ToggleButtonGroup>
) : (
<SourceFilterTree items={all} hidden={hiddenKeys} onChange={setHiddenKeys} />
)}
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
{sourceStatus && failed.length === 0 && ( {sourceStatus && failed.length === 0 && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
@@ -150,182 +183,75 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
)} )}
</Stack> </Stack>
{tasks.data && visible.length === 0 && ( {view === "calendar" ? (
<Panel> <>
<Typography variant="h6" sx={{ mb: 1 }}> <TaskCalendar
Nothing on the list tasks={model.visible}
</Typography> longCount={model.longCount}
<Typography variant="body2" color="text.secondary"> showLong={showLong}
Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself. onShowLong={setShowLong}
</Typography> onPin={onPin}
</Panel> onStart={onStart}
onDelete={onDelete}
/>
<UndatedSection tasks={model.undated} onPin={onPin} onStart={onStart} onDelete={onDelete} />
</>
) : (
<TaskList tasks={listed} onPin={onPin} onStart={onStart} onDelete={onDelete} />
)} )}
{BUCKETS.map((bucket) => <AddTaskDialog open={addOpen} onClose={() => setAddOpen(false)} onCreated={tasks.reload} />
grouped[bucket].length ? (
<Box key={bucket} sx={{ mb: 4 }}>
<SectionTitle
action={
<Typography variant="caption" color="text.secondary">
{grouped[bucket].length}
</Typography>
}
>
{BUCKET_LABEL[bucket]}
</SectionTitle>
<Stack divider={<Divider />}>
{grouped[bucket].map((task) => (
<TaskRow
key={task.id}
task={task}
onPin={async () => {
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
await tasks.reload();
}}
onStart={async () => {
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
onToast(`Focusing on “${task.title}”`);
navigate("/");
}}
onDelete={async () => {
await api.del(`/api/tasks/${task.id}`);
await tasks.reload();
}}
/>
))}
</Stack>
</Box>
) : null,
)}
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>New task</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
autoFocus
label="What needs doing?"
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
<TextField
label="Due date"
type="date"
InputLabelProps={{ shrink: true }}
value={draft.due_date}
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
/>
<Typography variant="caption" color="text.secondary">
Local tasks live only here, so they are the one kind you can delete.
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
<Button
variant="contained"
disabled={!draft.title.trim()}
onClick={async () => {
await api.post("/api/tasks", {
title: draft.title.trim(),
due_date: draft.due_date || null,
});
setDraft({ title: "", due_date: "" });
setAddOpen(false);
await tasks.reload();
}}
>
Add
</Button>
</DialogActions>
</Dialog>
</> </>
); );
} }
function TaskRow({ /**
task, * 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. Collapsed by default
* — it is routinely the longest list on the page, and it is not what you opened
* a calendar to look at.
*/
function UndatedSection({
tasks,
onPin, onPin,
onStart, onStart,
onDelete, onDelete,
}: { }: {
task: Task; tasks: Task[];
onPin: () => Promise<void>; onPin: (task: Task) => Promise<void>;
onStart: () => Promise<void>; onStart: (task: Task) => Promise<void>;
onDelete: () => Promise<void>; onDelete: (task: Task) => Promise<void>;
}) { }) {
const [open, setOpen] = useState(false);
if (tasks.length === 0) return null;
return ( return (
<Stack <Box sx={{ mt: 3 }}>
direction="row" <SectionTitle
spacing={2} action={
alignItems="center" <Button size="small" onClick={() => setOpen(!open)}>
sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }} {open ? "Hide" : "Show"}
> </Button>
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} /> }
>
<Box sx={{ flexGrow: 1, minWidth: 0 }}> No due date ({tasks.length})
<Stack direction="row" spacing={1} alignItems="center"> </SectionTitle>
<Typography variant="body1" noWrap sx={{ fontWeight: task.pinned ? 600 : 400 }}> <Collapse in={open}>
{task.title} <Panel padded={false} sx={{ px: 2 }}>
</Typography> <Stack divider={<Divider />}>
{task.url && ( {tasks.map((task) => (
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}> <TaskRow
<OpenInNewIcon sx={{ fontSize: 14 }} /> key={task.id}
</Link> task={task}
)} onPin={() => onPin(task)}
</Stack> onStart={() => onStart(task)}
<Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}> onDelete={() => onDelete(task)}
<Typography variant="caption" color="text.secondary"> />
{task.source_label} ))}
</Typography> </Stack>
{task.project && ( </Panel>
<Typography variant="caption" color="text.secondary"> </Collapse>
{task.project} </Box>
</Typography>
)}
{task.status && (
<Typography variant="caption" color="text.secondary">
{task.status}
</Typography>
)}
{task.priority && (
<Typography variant="caption" color="text.secondary">
{task.priority}
</Typography>
)}
{task.days_overdue > 0 ? (
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
{task.days_overdue}d overdue
</Typography>
) : (
task.due_date && (
<Typography variant="caption" color="text.secondary">
due {task.due_date}
</Typography>
)
)}
</Stack>
</Box>
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
<IconButton size="small" onClick={() => void onPin()}>
{task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
</IconButton>
</Tooltip>
<Tooltip title="Start a pomodoro on this">
<IconButton size="small" onClick={() => void onStart()}>
<PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{task.source === "local" && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => void onDelete()}>
<DeleteOutlineIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
)}
</Stack>
</Stack>
); );
} }
+20 -6
View File
@@ -100,13 +100,22 @@ export default function TimerPage({
[onToast, timer, today], [onToast, timer, today],
); );
const firing = useRef(false); // Which interval we have already told the server to finish. Keyed by id, not a
// bare "in flight" flag: a poll issued just before the countdown ran out answers
// with the finished interval still running at 00:00, and a flag cleared by that
// reply fires `complete` a second time — onto the break `auto_next` has since
// started, which is a break that ends the instant it begins. An id is never
// reused, so the second attempt is simply never made.
const completed = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (!current || remaining > 0 || firing.current) return; if (!current || remaining > 0 || completed.current === current.id) return;
firing.current = true; completed.current = current.id;
const wasWork = current.kind === "work"; const wasWork = current.kind === "work";
void (async () => { void (async () => {
const next = await call("/api/timer/complete", { auto_next: true }); const next = await call("/api/timer/complete", { auto_next: true, session_id: current.id });
// It never landed, so this interval is still owed a complete: let the next
// tick try again rather than leave the dial stuck at 00:00.
if (!next && completed.current === current.id) completed.current = null;
const volume = settings?.pomodoro.alarm_volume ?? 70; const volume = settings?.pomodoro.alarm_volume ?? 70;
if (settings?.pomodoro.alarm_sound ?? true) { if (settings?.pomodoro.alarm_sound ?? true) {
wasWork ? sound.workDone(volume) : sound.breakDone(volume); wasWork ? sound.workDone(volume) : sound.breakDone(volume);
@@ -115,7 +124,6 @@ export default function TimerPage({
notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it."); notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it.");
} }
if (next?.celebration) onToast("Milestone reached — Telegram got a cheer"); if (next?.celebration) onToast("Milestone reached — Telegram got a cheer");
firing.current = false;
})(); })();
}, [remaining, current, call, settings, onToast]); }, [remaining, current, call, settings, onToast]);
@@ -218,7 +226,13 @@ export default function TimerPage({
<Button <Button
variant="contained" variant="contained"
disabled={busy} disabled={busy}
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval finished")} onClick={() =>
void call(
"/api/timer/complete",
{ auto_next: true, session_id: current.id },
"Interval finished",
)
}
> >
Finish Finish
</Button> </Button>