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 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> =
api.post("/api/timer/abandon", body("reason" to reason))
@@ -98,7 +109,25 @@ class KadenceRepository(val api: KadenceApi) {
// ---- 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. */
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.Serializable
import tech.kadence.core.CalendarItem
@Serializable
data class TaskRefDto(
@@ -63,20 +64,25 @@ data class TimerStateDto(
@SerialName("timer_enabled") val timerEnabled: Boolean = true,
)
/**
* A task, and — via [CalendarItem] — something the month grid can lay out
* directly, the way [KadenceConfig] is an `AuthConfig`. The alternative was a
* mapping layer between the wire and the grid that would only ever copy fields.
*/
@Serializable
data class TaskDto(
val id: Long = 0,
val source: String = "",
@SerialName("source_label") val sourceLabel: String = "",
override val id: Long = 0,
override val source: String = "",
@SerialName("source_label") override val sourceLabel: String = "",
@SerialName("external_id") val externalId: String = "",
val title: String = "",
val description: String? = null,
val url: String? = null,
val project: String? = null,
override val project: String? = null,
val status: String? = null,
val priority: String? = null,
@SerialName("due_date") val dueDate: String? = null,
@SerialName("start_date") val startDate: String? = null,
@SerialName("due_date") override val dueDate: String? = null,
@SerialName("start_date") override val startDate: String? = null,
val labels: List<String> = emptyList(),
@SerialName("is_done") val isDone: Boolean = false,
val pinned: Boolean = false,
@@ -84,7 +90,7 @@ data class TaskDto(
@SerialName("estimated_pomodoros") val estimatedPomodoros: Int? = null,
@SerialName("days_overdue") val daysOverdue: Int = 0,
val bucket: String = "someday",
)
) : CalendarItem
@Serializable
data class TaskListDto(
@@ -75,6 +75,28 @@ class KadencePrefs(context: Context) {
get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L)
set(value) = prefs.edit().putLong(KEY_UPDATE_CHECKED_AT, value).apply()
/**
* How the Tasks screen was last left, deliberately outside [KadenceConfig]
* for the same reason as the update bookkeeping above: a view toggle or a
* filter checkbox must not push the config StateFlow and recompose every
* screen in the app. Nothing outside that one screen reads them, and
* `TimerService` certainly does not.
*/
var tasksCalendarView: Boolean
get() = prefs.getBoolean(KEY_TASKS_CALENDAR, false)
set(value) = prefs.edit().putBoolean(KEY_TASKS_CALENDAR, value).apply()
var tasksShowLong: Boolean
get() = prefs.getBoolean(KEY_TASKS_SHOW_LONG, false)
set(value) = prefs.edit().putBoolean(KEY_TASKS_SHOW_LONG, value).apply()
/** Hidden source/project keys — never the visible ones. See `isHidden`. */
var tasksHiddenKeys: Set<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) {
val next = transform(_config.value)
prefs.edit().apply {
@@ -105,6 +127,9 @@ class KadencePrefs(context: Context) {
private const val KEY_ONBOARDED = "onboarded"
private const val KEY_UPDATE_SKIPPED = "update_skipped_version"
private const val KEY_UPDATE_CHECKED_AT = "update_checked_at"
private const val KEY_TASKS_CALENDAR = "tasks_calendar_view"
private const val KEY_TASKS_SHOW_LONG = "tasks_show_long"
private const val KEY_TASKS_HIDDEN = "tasks_hidden_keys"
fun normalizeBaseUrl(raw: String): String {
val trimmed = raw.trim().trimEnd('/')
@@ -22,7 +22,10 @@
* whose time is up — it just reports `remaining_seconds = 0`. A client has to call
* `complete`. `desktop/kadence_desktop/controller.py` does the same, with the same
* re-entry guard, because without one a 1 Hz tick at zero fires `complete` every second
* until the reply lands.
* until the reply lands. The guard is per interval id and the call names the interval
* it means to finish: the countdown runs out on every client at once, and a complete
* that arrives after someone else's has already rolled the timer on would otherwise
* finish the interval that rolling on created.
*/
package tech.kadence.android.timer
@@ -147,8 +150,17 @@ class TimerEngine(
*/
private val mutex = Mutex()
/** The `complete` re-entry guard. See the file header. */
private var completing = false
/**
* The interval we have already asked the server to finish. See the file header.
*
* An id, not a bare flag: a poll issued just before the countdown ran out answers
* with that interval still running at 00:00, and a flag cleared by that answer
* sends `complete` a second time — onto whatever `auto_next` has since started,
* which is how a break could end the instant it began. Ids are never reused, so
* the second attempt is never made; a call that *failed* clears it so the zero
* crossing is still retried.
*/
private var completedId: Long? = null
private var pollJob: Job? = null
private var tickJob: Job? = null
@@ -257,10 +269,12 @@ class TimerEngine(
if (remaining != _state.value.remaining) {
_state.value = _state.value.copy(remaining = remaining)
}
if (remaining == 0 && _state.value.isActive && !current.paused && !completing) {
if (remaining == 0 && _state.value.isActive && !current.paused &&
completedId != current.sessionId
) {
// The countdown has run out. The server books the interval and its
// reply decides what follows — an auto-started break, or nothing.
completing = true
completedId = current.sessionId
complete()
}
}
@@ -369,7 +383,6 @@ class TimerEngine(
val current = server.current
if (current == null) {
anchor = null
completing = false
} else {
val changed = anchor?.sessionId != current.id ||
anchor?.paused != current.isPaused ||
@@ -386,7 +399,6 @@ class TimerEngine(
paused = current.isPaused,
)
}
if (current.id != _state.value.sessionId) completing = false
}
_state.value = _state.value.copy(
@@ -467,12 +479,15 @@ class TimerEngine(
_state.value = before.ended()
anchor = null
enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before)
completing = false
return@launch
}
// Naming the interval is what makes this safe to send at all: by the time it
// arrives, another client's countdown may already have finished it and started
// the next one, and an unconditional complete would end *that* instead.
val expected = before.sessionId?.takeIf { it != LOCAL_SESSION_ID }
mutex.withLock {
_state.value = _state.value.copy(busy = true)
when (val result = repo.complete(autoNext)) {
when (val result = repo.complete(autoNext, expected)) {
is ApiResult.Ok -> {
val dto = result.value
applyServer(dto, fromCommand = true)
@@ -493,10 +508,14 @@ class TimerEngine(
anchor = null
enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before)
}
else -> handleFailure(result)
else -> {
// Nothing was booked, so the interval sitting at 00:00 is still
// owed a complete; let the next tick try again.
if (completedId == before.sessionId) completedId = null
handleFailure(result)
}
}
_state.value = _state.value.copy(busy = false)
completing = false
}
}
@@ -704,7 +723,10 @@ class TimerEngine(
)
OutboxOp.PAUSE -> repo.pause()
OutboxOp.RESUME -> repo.resume()
OutboxOp.COMPLETE -> repo.complete(autoNext = false)
OutboxOp.COMPLETE -> repo.complete(
autoNext = false,
sessionId = entry.anchorSessionId?.takeIf { it != LOCAL_SESSION_ID },
)
OutboxOp.ABANDON -> repo.abandon(entry.reason)
OutboxOp.EXTEND -> repo.extend(entry.minutes)
OutboxOp.INTERRUPT -> repo.interrupt()
@@ -69,6 +69,12 @@ fun KadenceApp(app: KadenceApplication) {
val updateState by app.updates.state.collectAsStateWithLifecycle()
var showOnboarding by remember { mutableStateOf(!config.onboarded) }
// How the Tasks screen was last left. Held here and written straight back to
// prefs rather than kept in `KadenceConfig`, so a filter checkbox does not
// push the config StateFlow and recompose every screen in the app.
var tasksCalendarView by remember { mutableStateOf(app.prefs.tasksCalendarView) }
var tasksShowLong by remember { mutableStateOf(app.prefs.tasksShowLong) }
var tasksHiddenKeys by remember { mutableStateOf(app.prefs.tasksHiddenKeys) }
var showUpdateDialog by remember { mutableStateOf(false) }
var destination by remember { mutableStateOf(Destination.Timer) }
val scope = rememberCoroutineScope()
@@ -157,6 +163,21 @@ fun KadenceApp(app: KadenceApplication) {
cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()),
runningTaskTitle = ui.taskTitle,
intervalRunning = ui.isActive,
calendarView = tasksCalendarView,
onSetCalendarView = { next ->
tasksCalendarView = next
app.prefs.tasksCalendarView = next
},
showLong = tasksShowLong,
onSetShowLong = { next ->
tasksShowLong = next
app.prefs.tasksShowLong = next
},
hiddenKeys = tasksHiddenKeys,
onSetHiddenKeys = { next ->
tasksHiddenKeys = next
app.prefs.tasksHiddenKeys = next
},
onAttach = { taskId ->
// `attach` needs something to attach to: with nothing
// running the server answers "No interval is running."
@@ -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
* a task off here.** Completion belongs to the tool the task lives in — close it in
* Jira, Todoist or Google Tasks and the next refresh notices. Two places to mark
* something done is two places to be wrong.
* Read-only, and that is the product decision rather than a shortcut: **you cannot
* tick a task off here.** Completion belongs to the tool the task lives in — close
* it in Jira, Todoist or Google Tasks and the next refresh notices. Two places to
* mark something done is two places to be wrong.
*
* So a row has exactly two actions and they follow from that. Tapping it **opens the
* task where it lives**, which is where closing it happens; "Track" attaches it to the
* running interval, which is the one thing that belongs to Kadence. A task with no
* `url` — a local one, or a source that supplied none — is simply not tappable rather
* than tappable-and-inert.
* The screen asks the backend for **done and stale rows too**, because a past
* month is only worth looking at if the work that finished is on it, and laundry
* slots go `stale` rather than done. The list therefore filters those out itself
* rather than trusting the URL the payload came from — which also means the single
* `Cached.TASKS` slot stays correct whichever view wrote it last.
*
* Bucket order and labels come from `:core`'s `BUCKET_ORDER` / `BUCKET_LABEL`, so the
* phone cannot drift from the web app's wording — including the deliberate choice of
* "Would be nice to start" over the desktop's "In progress".
* Bucket order and labels come from `:core`, and so does the whole calendar
* layout, so the phone cannot drift from the web app.
*
* One Compose constraint worth not re-learning: this is a single `LazyColumn`, and
* both bodies contribute `item`s to *it*. The month grid is a plain `Column` of
* `Row`s inside one item — never a nested `LazyVerticalGrid`, which throws on
* same-axis nested scrolling.
*/
package tech.kadence.android.ui.tasks
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.TaskDto
import tech.kadence.android.data.dto.TaskListDto
import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.Loadable
import tech.kadence.android.ui.calendar.FilterPanel
import tech.kadence.android.ui.calendar.MonthGrid
import tech.kadence.android.ui.components.Banner
import tech.kadence.android.ui.components.Eyebrow
import tech.kadence.android.ui.components.GhostButton
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.components.PageHeader
import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.PresetChip
import tech.kadence.android.ui.components.SectionTitle
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.BUCKET_LABEL
import tech.kadence.core.BUCKET_ORDER
import tech.kadence.core.dueLabel
import tech.kadence.core.buildModel
import tech.kadence.core.itemsOnDay
import tech.kadence.core.layoutMonth
import tech.kadence.core.monthGrid
import tech.kadence.core.shiftMonth
import tech.kadence.core.todayISO
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
/**
* Monday-first, hardcoded on both clients for now.
*
* `week_starts_monday` is in the settings document, but `AppSettingsDto` does not
* carry it and this screen loads no settings — so honouring it on the web alone is
* how the two clients start disagreeing about what a week is. The backend default
* is Monday anyway.
*/
private const val WEEK_STARTS_MONDAY = true
@Composable
fun TasksScreen(
repo: KadenceRepository,
/** Yesterday's list, so a phone with no signal is not an empty screen. */
cache: CacheSlot<tech.kadence.android.data.dto.TaskListDto>? = null,
cache: CacheSlot<TaskListDto>? = null,
runningTaskTitle: String?,
/** An interval is running — with or without a task attached to it. */
intervalRunning: Boolean,
calendarView: Boolean,
onSetCalendarView: (Boolean) -> Unit,
showLong: Boolean,
onSetShowLong: (Boolean) -> Unit,
hiddenKeys: Set<String>,
onSetHiddenKeys: (Set<String>) -> Unit,
onAttach: (Long) -> Unit,
) {
val colors = KadenceTheme.colors
val context = LocalContext.current
val scope = rememberCoroutineScope()
val loader = rememberLoad(cache = cache) { repo.tasks() }
val loader = rememberLoad(cache = cache) {
repo.tasks(includeDone = true, includeStale = true)
}
var refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<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() {
refreshing = true
refreshNote = null
@@ -106,23 +140,21 @@ fun TasksScreen(
}
val state = loader.value
val all = (state as? Loadable.Loaded)?.value?.tasks.orEmpty()
// Always, regardless of which URL answered: that is what keeps one cache slot
// correct for two views.
val open = all.filter { !it.isDone && !it.stale }
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 20.dp,
end = 20.dp,
top = 24.dp,
bottom = 32.dp,
),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
val total = (state as? Loadable.Loaded)?.value?.tasks?.size
PageHeader(
eyebrow = "Everything open",
title = "Tasks",
caption = total?.let { "$it across your sources" },
caption = if (state is Loadable.Loaded) "${open.size} across your sources" else null,
) {
GhostButton(
text = if (refreshing) "Refreshing…" else "Refresh sources",
@@ -132,6 +164,13 @@ fun TasksScreen(
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PresetChip(text = "List", selected = !calendarView, onClick = { onSetCalendarView(false) })
PresetChip(text = "Calendar", selected = calendarView, onClick = { onSetCalendarView(true) })
}
}
refreshNote?.let { note ->
item { Banner(text = note, actionLabel = "Retry", onAction = ::refresh) }
}
@@ -155,148 +194,239 @@ fun TasksScreen(
item { Banner(text = stale, actionLabel = "Retry", onAction = loader::reload) }
}
val byBucket = state.value.tasks.groupBy { it.bucket }
// BUCKET_ORDER first, then anything the backend invented later — an
// unknown bucket must still be visible rather than silently dropped.
val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } +
byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted()
if (state.value.tasks.isEmpty()) {
item {
Panel {
Text(
text = "Nothing open. Either you are done, or no source " +
"is configured yet — the backend's Settings page " +
"is where they go.",
style = KadenceTheme.type.bodySmall,
color = colors.inkMuted,
)
}
}
}
buckets.forEach { bucket ->
val tasks = byBucket[bucket].orEmpty()
item(key = "head-$bucket") {
SectionTitle(
label = BUCKET_LABEL[bucket] ?: bucket,
trailing = "${tasks.size}",
)
}
item(key = "body-$bucket") {
Panel(padding = 0.dp) {
tasks.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
// Tapping the row opens the task where it lives —
// Jira, Todoist, Google Tasks. That is where it
// gets *closed*, and this app deliberately cannot
// close it, so sending you there is the whole of
// what a tap should do.
onOpen = { openTask(context, task) },
// Attaching is the separate, explicit action.
onTrack = { onAttach(task.id) },
)
}
}
}
if (calendarView) {
calendarBody(
all = all,
hiddenKeys = hiddenKeys,
onSetHiddenKeys = onSetHiddenKeys,
showLong = showLong,
onSetShowLong = onSetShowLong,
today = today,
year = cursorYear,
month = cursorMonth,
onShiftMonth = { delta ->
val (y, m) = shiftMonth(cursorYear, cursorMonth, delta)
cursorYear = y
cursorMonth = m
selectedDay = null
},
onToday = {
cursorYear = LocalDate.now().year
cursorMonth = LocalDate.now().monthValue
selectedDay = null
},
selectedDay = selectedDay,
onSelectDay = { selectedDay = if (it == selectedDay) null else it },
filterOpen = filterOpen,
onToggleFilter = { filterOpen = !filterOpen },
runningTaskTitle = runningTaskTitle,
intervalRunning = intervalRunning,
onOpenTask = { openTask(context, it) },
onAttach = onAttach,
)
} else {
listBody(
tasks = open,
runningTaskTitle = runningTaskTitle,
intervalRunning = intervalRunning,
onOpenTask = { openTask(context, it) },
onAttach = onAttach,
)
}
}
}
}
}
@Composable
private fun TaskRow(
task: TaskDto,
isRunning: Boolean,
anyIntervalRunning: Boolean,
onOpen: () -> Unit,
onTrack: () -> Unit,
/** The original view: grouped by deadline bucket. */
private fun LazyListScope.listBody(
tasks: List<TaskDto>,
runningTaskTitle: String?,
intervalRunning: Boolean,
onOpenTask: (TaskDto) -> Unit,
onAttach: (Long) -> Unit,
) {
val colors = KadenceTheme.colors
val due = dueLabel(task.dueDate, task.daysOverdue)
val linkable = !task.url.isNullOrBlank()
val byBucket = tasks.groupBy { it.bucket }
// BUCKET_ORDER first, then anything the backend invented later — an unknown
// bucket must still be visible rather than silently dropped.
val buckets = BUCKET_ORDER.filter { byBucket.containsKey(it) } +
byBucket.keys.filterNot { it in BUCKET_ORDER }.sorted()
Row(
Modifier
.fillMaxWidth()
// A local task has no URL and nothing to open; making it look tappable
// and then doing nothing is worse than it being plainly inert.
.then(if (linkable) Modifier.clickable(onClick = onOpen) else Modifier)
.padding(start = 14.dp, end = 4.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = task.title,
style = KadenceTheme.type.body,
color = if (task.stale) colors.inkMuted else colors.ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Eyebrow(task.sourceLabel.ifBlank { task.source })
if (due.isNotBlank()) {
Text(
text = due,
style = KadenceTheme.type.caption,
// Overdue is the one case worth colouring; everything else is
// a date and a date is not an alarm.
color = if (task.daysOverdue > 0) colors.clay else colors.inkFaint,
)
}
task.project?.takeIf { it.isNotBlank() }?.let { project ->
Text(
text = project,
style = KadenceTheme.type.caption,
color = colors.inkFaint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (isRunning) {
Text(
text = "running",
style = KadenceTheme.type.caption,
color = colors.accent,
modifier = Modifier.padding(end = 10.dp),
)
} else {
TextButton(onClick = onTrack, contentPadding = PaddingValues(horizontal = 10.dp)) {
// "Focus" when the tap will begin an interval on this task, "Track"
// when it only re-points the one already running.
if (tasks.isEmpty()) {
item {
Panel {
Text(
text = if (anyIntervalRunning) "Track" else "Focus",
style = KadenceTheme.type.button,
color = colors.accent,
text = "Nothing open. Either you are done, or no source is " +
"configured yet — the backend's Settings page is where they go.",
style = KadenceTheme.type.bodySmall,
color = KadenceTheme.colors.inkMuted,
)
}
}
}
buckets.forEach { bucket ->
val inBucket = byBucket[bucket].orEmpty()
item(key = "head-$bucket") {
SectionTitle(label = BUCKET_LABEL[bucket] ?: bucket, trailing = "${inBucket.size}")
}
item(key = "body-$bucket") {
Panel(padding = 0.dp) {
inBucket.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
onOpen = { onOpenTask(task) },
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
/**
* Open the task in whatever app or browser owns it.
*
* `url` is absent for local tasks, and for a source that did not supply one; the row
* is not clickable in that case, so this is only ever reached with something to open.
* Still guarded, because an unparseable URL from a third-party API must not crash the
* list.
*/
private fun openTask(context: android.content.Context, task: TaskDto) {
val url = task.url?.takeIf { it.isNotBlank() } ?: return
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
.onFailure { android.util.Log.w("Kadence", "cannot open $url: ${it.message}") }
private val MONTH_LABEL = DateTimeFormatter.ofPattern("LLLL yyyy")
private fun LazyListScope.calendarBody(
all: List<TaskDto>,
hiddenKeys: Set<String>,
onSetHiddenKeys: (Set<String>) -> Unit,
showLong: Boolean,
onSetShowLong: (Boolean) -> Unit,
today: String,
year: Int,
month: Int,
onShiftMonth: (Int) -> Unit,
onToday: () -> Unit,
selectedDay: String?,
onSelectDay: (String) -> Unit,
filterOpen: Boolean,
onToggleFilter: () -> Unit,
runningTaskTitle: String?,
intervalRunning: Boolean,
onOpenTask: (TaskDto) -> Unit,
onAttach: (Long) -> Unit,
) {
val model = buildModel(all, hiddenKeys, showLong)
val weeks = monthGrid(year, month, WEEK_STARTS_MONDAY)
// Showing long bars needs the room they take, or they simply push the day's
// real work into "+N" and the month gets less informative, not more.
val layouts = layoutMonth(weeks, model.visible, maxLanes = if (showLong) 4 else 3)
val byId = model.visible.associateBy { it.id }
item(key = "cal-toolbar") {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
GhostButton(text = "‹", onClick = { onShiftMonth(-1) })
Text(
text = YearMonth.of(year, month).format(MONTH_LABEL),
style = KadenceTheme.type.statSmall,
color = KadenceTheme.colors.ink,
modifier = Modifier.weight(1f),
)
GhostButton(text = "›", onClick = { onShiftMonth(1) })
GhostButton(text = "Today", onClick = onToday)
}
}
item(key = "cal-filters") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PresetChip(
text = if (hiddenKeys.isEmpty()) "Filter" else "Filter (${hiddenKeys.size})",
selected = filterOpen,
onClick = onToggleFilter,
)
PresetChip(
text = "Long tasks (${model.longCount})",
selected = showLong,
onClick = { onSetShowLong(!showLong) },
)
}
}
if (filterOpen) {
item(key = "cal-filter-panel") {
Panel(padding = 0.dp) {
FilterPanel(items = all, hidden = hiddenKeys, onChange = onSetHiddenKeys)
}
}
}
item(key = "cal-grid") {
Panel(padding = 0.dp) {
MonthGrid(
weeks = layouts,
month = month,
today = today,
tasksById = byId,
selectedDay = selectedDay,
weekStartsMonday = WEEK_STARTS_MONDAY,
onSelectDay = onSelectDay,
)
}
}
// The tapped day's work, under the grid. Items of the *outer* LazyColumn, not
// a nested lazy list.
selectedDay?.let { day ->
val onDay = itemsOnDay(model.visible, day)
item(key = "cal-day-head") {
SectionTitle(
label = LocalDate.parse(day).format(DAY_LABEL),
trailing = "${onDay.size}",
)
}
item(key = "cal-day-body") {
Panel(padding = 0.dp) {
if (onDay.isEmpty()) {
Text(
text = "Nothing on this day.",
style = KadenceTheme.type.bodySmall,
color = KadenceTheme.colors.inkMuted,
modifier = Modifier.padding(14.dp),
)
} else {
onDay.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
onOpen = { onOpenTask(task) },
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
// The tasks a calendar cannot place: no due date, so no column to sit in.
// Jira keeps the same affordance and calls it the backlog.
if (model.undated.isNotEmpty()) {
item(key = "cal-undated-head") {
SectionTitle(label = "No due date", trailing = "${model.undated.size}")
}
item(key = "cal-undated-body") {
Panel(padding = 0.dp) {
model.undated.forEachIndexed { index, task ->
if (index > 0) HorizontalRule()
TaskRow(
task = task,
isRunning = task.title == runningTaskTitle,
anyIntervalRunning = intervalRunning,
onOpen = { onOpenTask(task) },
onTrack = { onAttach(task.id) },
)
}
}
}
}
}
private val DAY_LABEL = DateTimeFormatter.ofPattern("EEEE, d MMMM")
@@ -79,6 +79,8 @@ class TimerEngineTest {
path.endsWith("/api/timer/pause") -> running(pauseReplyStatus, remaining = 900)
path.endsWith("/api/timer/resume") -> running("running")
path.endsWith("/api/timer/extend") -> running("running", 1404, 1800)
path.endsWith("/api/timer/complete") ->
fixture("timer_complete_reply.json")
else -> fixture("timer_state_running.json")
}
return MockResponse.Builder()
@@ -140,6 +142,30 @@ class TimerEngineTest {
assertTrue(engine.state.value.interruptions >= 1)
}
@Test
fun `complete names the interval it is finishing`() {
// Every client runs its own countdown, so they all reach zero together. An
// unconditional complete from the slowest of them lands on the break the
// fastest one's auto-start has just created and ends it on the spot — which
// is what made breaks look like they turned straight into focus. Naming the
// interval is what lets the server ignore the late call.
val engine = engine()
runBlocking { engine.complete().join() }
val sent = server.takeRequest().let { first ->
// The first recorded request is the fixture poll from `engine()`.
if (first.url.encodedPath.endsWith("/api/timer/complete")) first
else server.takeRequest()
}
assertEquals("/api/timer/complete", sent.url.encodedPath)
assertTrue(
sent.body?.utf8()?.contains("\"session_id\":412") == true,
"the complete must name interval 412, not just say 'finish whatever is running'",
)
assertEquals(413L, engine.state.value.sessionId, "the auto-started break is adopted")
}
@Test
fun `toggle reads the state it just changed`() {
val engine = engine()
@@ -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
* `"superseded"` — so a queued start is destructive, not additive.
* * `POST /api/timer/complete` may **auto-start the next interval**, so a reply can
* contain an interval nobody explicitly asked for.
* contain an interval nobody explicitly asked for. It also takes a `session_id`:
* the interval the caller means to finish. Without one the call is unconditional,
* and two clients hitting zero in the same second end up finishing two intervals —
* the second call landing on the break the first one's auto-start had just begun.
* * There is **no stop endpoint**: stopping is `abandon`, and under `strict_mode` a
* work interval refuses to be abandoned without a reason.
*/
@@ -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)
}
}