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