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
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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user