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

@@ -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)
}
}