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)
|
||||
}
|
||||
}
|
||||
@@ -63,11 +63,18 @@ def resume(session: DbSession, settings: Settings) -> dict:
|
||||
@router.post("/complete")
|
||||
def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
finished, follow_on = pomodoro.complete(session, settings, auto_next=body.auto_next)
|
||||
finished, follow_on = pomodoro.complete(
|
||||
session,
|
||||
settings,
|
||||
auto_next=body.auto_next,
|
||||
expect_id=body.session_id,
|
||||
)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
celebration = _maybe_celebrate(session, settings) if finished.is_work else None
|
||||
# `finished` is None when the named interval was no longer the running one:
|
||||
# nothing happened, so there is nothing to celebrate and nothing to announce.
|
||||
celebration = _maybe_celebrate(session, settings) if finished and finished.is_work else None
|
||||
state = pomodoro.state(session, settings)
|
||||
state["finished"] = pomodoro.session_dict(finished)
|
||||
state["auto_started"] = pomodoro.session_dict(follow_on)
|
||||
|
||||
@@ -29,6 +29,16 @@ class AttachTaskRequest(BaseModel):
|
||||
|
||||
class CompleteTimerRequest(BaseModel):
|
||||
auto_next: bool = True
|
||||
session_id: int | None = None
|
||||
"""Which interval the client believes it is finishing.
|
||||
|
||||
Every client ticks its own local countdown and calls `complete` when it
|
||||
reaches zero, so with two of them open (the phone and the desktop, say)
|
||||
both fire within the same second. Unconditionally, the second call lands
|
||||
on whatever `auto_next` has just started and ends *that* — which is why a
|
||||
break could vanish the instant it began. Sent along, a stale call is a
|
||||
no-op instead. Omitted, the old unconditional behaviour stands.
|
||||
"""
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
|
||||
@@ -176,10 +176,28 @@ def resume(session: Session) -> PomodoroSession:
|
||||
|
||||
|
||||
def complete(
|
||||
session: Session, settings: AppSettings, *, auto_next: bool = True
|
||||
) -> tuple[PomodoroSession, PomodoroSession | None]:
|
||||
"""Finish the current interval and, if configured, roll into the next one."""
|
||||
row = _require_active(session)
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
*,
|
||||
auto_next: bool = True,
|
||||
expect_id: int | None = None,
|
||||
) -> tuple[PomodoroSession | None, PomodoroSession | None]:
|
||||
"""Finish the current interval and, if configured, roll into the next one.
|
||||
|
||||
`expect_id` is the interval the caller believes it is finishing. Every
|
||||
client runs its own local countdown and calls this when it hits zero, so
|
||||
two clients open at once both call within the same second — and the second
|
||||
call used to land on the interval `auto_next` had just created and end it
|
||||
immediately. That is a break disappearing the moment it starts, and it is
|
||||
not something a client can fix on its own: only the server knows which
|
||||
interval is current. So a call that names an interval which is no longer
|
||||
running does nothing at all, and says so by returning no `finished`.
|
||||
"""
|
||||
row = current_session(session)
|
||||
if expect_id is not None and (row is None or row.id != expect_id):
|
||||
return None, None
|
||||
if row is None:
|
||||
raise TimerError("No interval is running.")
|
||||
finished = _finish(session, settings, row, status="completed")
|
||||
|
||||
follow_on: PomodoroSession | None = None
|
||||
|
||||
@@ -29,6 +29,28 @@ def test_a_whole_pomodoro_through_the_api(client):
|
||||
assert "Daily report" in report["telegram_preview"]
|
||||
|
||||
|
||||
def test_a_stale_complete_leaves_the_auto_started_break_alone(client):
|
||||
client.put(
|
||||
"/api/settings",
|
||||
json={"pomodoro": {"auto_start_breaks": True, "auto_start_work": True}},
|
||||
)
|
||||
state = client.post("/api/timer/start", json={"kind": "work"}).json()
|
||||
work_id = state["current"]["id"]
|
||||
|
||||
done = client.post(
|
||||
"/api/timer/complete", json={"auto_next": True, "session_id": work_id}
|
||||
).json()
|
||||
assert done["current"]["kind"] in ("short_break", "long_break")
|
||||
break_id = done["current"]["id"]
|
||||
|
||||
# The other client's countdown, one beat behind.
|
||||
again = client.post(
|
||||
"/api/timer/complete", json={"auto_next": True, "session_id": work_id}
|
||||
).json()
|
||||
assert again["finished"] is None
|
||||
assert again["current"]["id"] == break_id
|
||||
|
||||
|
||||
def test_timer_errors_are_400_not_500(client):
|
||||
assert client.post("/api/timer/pause").status_code == 400
|
||||
assert client.post("/api/timer/complete", json={}).status_code == 400
|
||||
|
||||
@@ -69,6 +69,40 @@ def test_auto_start_breaks_rolls_straight_on(session, settings):
|
||||
assert follow_on is not None and follow_on.kind == "short_break"
|
||||
|
||||
|
||||
def test_a_complete_naming_a_finished_interval_does_nothing(session, settings):
|
||||
"""Two clients reaching zero at once must not cost you the break.
|
||||
|
||||
Every client ticks its own countdown and calls `complete` when it runs out.
|
||||
The first call finishes the work interval and auto-starts the break; the
|
||||
second — sent a moment earlier, from a client that has not polled since —
|
||||
used to finish *that* break, so a break ended the instant it began.
|
||||
"""
|
||||
settings.pomodoro.auto_start_breaks = True
|
||||
settings.pomodoro.auto_start_work = True
|
||||
settings_service.save_settings(session, settings)
|
||||
|
||||
work = pomodoro.start(session, settings, kind="work")
|
||||
finished, follow_on = pomodoro.complete(session, settings, expect_id=work.id)
|
||||
assert finished is not None and follow_on is not None
|
||||
assert follow_on.kind == "short_break"
|
||||
|
||||
# The second client's call, naming the work interval it was still showing.
|
||||
stale_finished, stale_follow_on = pomodoro.complete(session, settings, expect_id=work.id)
|
||||
assert stale_finished is None and stale_follow_on is None
|
||||
|
||||
still_running = pomodoro.current_session(session)
|
||||
assert still_running is not None
|
||||
assert still_running.id == follow_on.id and still_running.status == "running"
|
||||
|
||||
|
||||
def test_complete_without_a_session_id_stays_unconditional(session, settings):
|
||||
settings.pomodoro.auto_start_breaks = False
|
||||
settings_service.save_settings(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
finished, _ = pomodoro.complete(session, settings)
|
||||
assert finished is not None and finished.status == "completed"
|
||||
|
||||
|
||||
def test_strict_mode_demands_a_reason(session, settings):
|
||||
settings.pomodoro.strict_mode = True
|
||||
settings_service.save_settings(session, settings)
|
||||
|
||||
@@ -87,8 +87,18 @@ class Api:
|
||||
def resume(self) -> dict:
|
||||
return self._request("POST", "/api/timer/resume", {})
|
||||
|
||||
def complete(self, auto_next: bool = True) -> dict:
|
||||
return self._request("POST", "/api/timer/complete", {"auto_next": auto_next})
|
||||
def complete(self, auto_next: bool = True, session_id: int | None = None) -> dict:
|
||||
"""`session_id` names the interval we think we are finishing.
|
||||
|
||||
The server ignores the call if something else is running by then, which
|
||||
is what keeps two clients hitting zero at the same moment from eating
|
||||
the break the first one's `auto_next` just started.
|
||||
"""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/api/timer/complete",
|
||||
{"auto_next": auto_next, "session_id": session_id},
|
||||
)
|
||||
|
||||
def abandon(self, reason: str | None = None) -> dict:
|
||||
return self._request("POST", "/api/timer/abandon", {"reason": reason})
|
||||
|
||||
@@ -78,7 +78,17 @@ class Controller(QObject):
|
||||
self._last_session_id: int | None = None
|
||||
self._last_kind = ""
|
||||
self._last_status = ""
|
||||
self._completing = False
|
||||
self._completed_id: int | None = None
|
||||
"""The interval we have already asked the server to finish.
|
||||
|
||||
An id rather than a bare "in flight" flag. A poll issued just before the
|
||||
countdown ran out answers with that interval still running at 00:00, and
|
||||
a flag cleared by that answer sends `complete` a second time — onto the
|
||||
break `auto_next` has since started, which is why a break could end the
|
||||
instant it began. Ids are never reused, so the second attempt is never
|
||||
made. Cleared only when the call itself fails, so a refused or dropped
|
||||
complete is still retried.
|
||||
"""
|
||||
self._paused_by_lock = False
|
||||
self._mini_visible = False
|
||||
self._mini_dismissed = False
|
||||
@@ -174,6 +184,13 @@ class Controller(QObject):
|
||||
current = self._state.get("current")
|
||||
return str(current["status"]) if current else "idle"
|
||||
|
||||
@property
|
||||
def _session_id(self) -> int | None:
|
||||
"""The running interval's id, or None when nothing is running."""
|
||||
current = self._state.get("current") or {}
|
||||
session_id = current.get("id")
|
||||
return int(session_id) if session_id is not None else None
|
||||
|
||||
@pyqtProperty(int, notify=stateChanged)
|
||||
def remainingSeconds(self) -> int:
|
||||
if self._state.get("current"):
|
||||
@@ -393,7 +410,7 @@ class Controller(QObject):
|
||||
|
||||
@pyqtSlot()
|
||||
def complete(self) -> None:
|
||||
self._send("complete", {"auto_next": True})
|
||||
self._send("complete", {"auto_next": True, "session_id": self._session_id})
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(str)
|
||||
@@ -567,7 +584,6 @@ class Controller(QObject):
|
||||
self._last_session_id = session_id
|
||||
self._last_kind = kind
|
||||
self._last_status = status
|
||||
self._completing = False
|
||||
self.stateChanged.emit()
|
||||
self._update_launcher()
|
||||
|
||||
@@ -610,12 +626,18 @@ class Controller(QObject):
|
||||
|
||||
def _on_command_failed(self, name: str, detail: str) -> None:
|
||||
log.warning("%s failed: %s", name, detail)
|
||||
if name == "complete":
|
||||
# It never landed, so the interval at 00:00 is still owed a complete.
|
||||
self._completed_id = None
|
||||
self.toast.emit(detail)
|
||||
|
||||
def _on_command_done(self, name: str, result: dict[str, Any]) -> None:
|
||||
if name == "complete":
|
||||
finished = result.get("finished") or {}
|
||||
self._announce_finished(str(finished.get("kind", "work")), result)
|
||||
finished = result.get("finished")
|
||||
# None means the server declined: something else was running by the
|
||||
# time the call arrived, so nothing finished and nothing is announced.
|
||||
if finished:
|
||||
self._announce_finished(str(finished.get("kind", "work")), result)
|
||||
elif name == "send_report":
|
||||
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
|
||||
elif name == "refresh_sources":
|
||||
@@ -706,10 +728,11 @@ class Controller(QObject):
|
||||
if self._remaining % 5 == 0:
|
||||
self._update_launcher()
|
||||
return
|
||||
if not self._completing:
|
||||
session_id = self._session_id
|
||||
if session_id is not None and self._completed_id != session_id:
|
||||
# The countdown reached zero: the server books the interval, and its
|
||||
# reply decides what happens next (auto-break, or nothing).
|
||||
self._completing = True
|
||||
self._completed_id = session_id
|
||||
self.complete()
|
||||
|
||||
def _check_idle(self) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ Type=Application
|
||||
Name=Kadence
|
||||
GenericName=Pomodoro timer
|
||||
Comment=Pomodoro timer and focus guard — a tray agent with a floating countdown
|
||||
Exec=kadence-desktop --mini
|
||||
Exec=kadence-desktop
|
||||
TryExec=kadence-desktop
|
||||
Icon=kadence
|
||||
Terminal=false
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* The month, as bars from start date to deadline.
|
||||
*
|
||||
* Built like a printed table rather than a grid of cards: hairlines between the
|
||||
* cells, tabular day figures, the month set in the display serif, and exactly
|
||||
* one accent — today.
|
||||
*
|
||||
* Bars are coloured by **state, not by source**. Five saturated fills across a
|
||||
* month grid would read as Material rather than Kadence, and five categorical
|
||||
* hues cannot clear the data-viz palette validator's all-pairs normal-vision
|
||||
* floor anyway (the first three slots are the documented limit). Identity comes
|
||||
* from the title and from the filter; colour is kept for what changes how you
|
||||
* act on a task — done, overdue, or still waiting.
|
||||
*
|
||||
* The layout maths all live in `lib/calendar.ts`, which `:core` ports verbatim.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Button, Divider, Popover, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
|
||||
import { Panel } from "../Layout";
|
||||
import TaskRow from "../tasks/TaskRow";
|
||||
import {
|
||||
itemsOnDay,
|
||||
layoutMonth,
|
||||
monthGrid,
|
||||
shiftMonth,
|
||||
todayISO,
|
||||
weekdayLabels,
|
||||
type WeekLayout,
|
||||
} from "../../lib/calendar";
|
||||
import type { Task } from "../../lib/types";
|
||||
|
||||
const DAY_STRIP = 22;
|
||||
const BAR_H = 20;
|
||||
const BAR_GAP = 3;
|
||||
const OVERFLOW_H = 18;
|
||||
const MIN_ROW = 88;
|
||||
|
||||
/**
|
||||
* Monday-first on both clients for now.
|
||||
*
|
||||
* `AppSettings.week_starts_monday` exists and the web app could read it, but the
|
||||
* phone cannot — `AppSettingsDto` does not carry it and the tasks screen loads
|
||||
* no settings — so honouring it here alone is how the two clients start
|
||||
* disagreeing about what a week is. The backend default is Monday anyway.
|
||||
*/
|
||||
const WEEK_STARTS_MONDAY = true;
|
||||
|
||||
export default function TaskCalendar({
|
||||
tasks,
|
||||
longCount,
|
||||
showLong,
|
||||
onShowLong,
|
||||
onPin,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
/** Already filtered: the type tree and the long-task toggle have been applied. */
|
||||
tasks: Task[];
|
||||
longCount: number;
|
||||
showLong: boolean;
|
||||
onShowLong: (next: boolean) => void;
|
||||
onPin: (task: Task) => Promise<void>;
|
||||
onStart: (task: Task) => Promise<void>;
|
||||
onDelete: (task: Task) => Promise<void>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const wide = useMediaQuery(theme.breakpoints.up("sm"));
|
||||
const today = todayISO();
|
||||
const [cursor, setCursor] = useState<[number, number]>(() => {
|
||||
const now = new Date();
|
||||
return [now.getFullYear(), now.getMonth() + 1];
|
||||
});
|
||||
const [day, setDay] = useState<{ date: string; anchor: HTMLElement } | null>(null);
|
||||
|
||||
const [year, month] = cursor;
|
||||
// Showing long bars needs the room they take, or they simply push the day's
|
||||
// real work into "+N" and the month gets less informative, not more.
|
||||
const maxLanes = wide ? (showLong ? 5 : 4) : 2;
|
||||
|
||||
const weeks = useMemo(() => monthGrid(year, month, WEEK_STARTS_MONDAY), [year, month]);
|
||||
const layouts = useMemo(() => layoutMonth(weeks, tasks, maxLanes), [weeks, tasks, maxLanes]);
|
||||
const byId = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
|
||||
const label = new Date(year, month - 1, 1).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const dayTasks = day ? itemsOnDay(tasks, day.date) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}
|
||||
>
|
||||
<Button size="small" sx={{ minWidth: 32, px: 0 }} onClick={() => setCursor(shiftMonth(year, month, -1))}>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</Button>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: theme.typography.h4.fontFamily,
|
||||
fontSize: 20,
|
||||
minWidth: { xs: 0, sm: 190 },
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Button size="small" sx={{ minWidth: 32, px: 0 }} onClick={() => setCursor(shiftMonth(year, month, 1))}>
|
||||
<ChevronRightIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setCursor([now.getFullYear(), now.getMonth() + 1]);
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button
|
||||
size="small"
|
||||
variant={showLong ? "contained" : "outlined"}
|
||||
disabled={longCount === 0}
|
||||
onClick={() => onShowLong(!showLong)}
|
||||
>
|
||||
Long tasks ({longCount})
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Panel padded={false} sx={{ overflow: "hidden" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
bgcolor: "action.hover",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{weekdayLabels(WEEK_STARTS_MONDAY).map((name) => (
|
||||
<Typography
|
||||
key={name}
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 0.75, fontSize: 10 }}
|
||||
>
|
||||
{wide ? name : name.slice(0, 1)}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{layouts.map((week, index) => (
|
||||
<WeekRow
|
||||
key={week.dates[0]}
|
||||
week={week}
|
||||
month={month}
|
||||
today={today}
|
||||
byId={byId}
|
||||
last={index === layouts.length - 1}
|
||||
onPickDay={(date, anchor) => setDay({ date, anchor })}
|
||||
/>
|
||||
))}
|
||||
</Panel>
|
||||
|
||||
<Popover
|
||||
open={day !== null}
|
||||
anchorEl={day?.anchor ?? null}
|
||||
onClose={() => setDay(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
slotProps={{ paper: { sx: { mt: 0.5, width: 380, maxWidth: "92vw", maxHeight: 420 } } }}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
{day && new Date(`${day.date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</Typography>
|
||||
<Divider sx={{ mt: 0.5 }} />
|
||||
{dayTasks.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Nothing on this day.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />}>
|
||||
{dayTasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onPin={() => onPin(task)}
|
||||
onStart={() => onStart(task)}
|
||||
onDelete={() => onDelete(task)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekRow({
|
||||
week,
|
||||
month,
|
||||
today,
|
||||
byId,
|
||||
last,
|
||||
onPickDay,
|
||||
}: {
|
||||
week: WeekLayout;
|
||||
month: number;
|
||||
today: string;
|
||||
byId: Map<number, Task>;
|
||||
last: boolean;
|
||||
onPickDay: (date: string, anchor: HTMLElement) => void;
|
||||
}) {
|
||||
const height =
|
||||
DAY_STRIP + week.lanes * (BAR_H + BAR_GAP) + (week.hasOverflow ? OVERFLOW_H : 0) + 6;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
minHeight: Math.max(MIN_ROW, height),
|
||||
borderBottom: last ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{week.dates.map((date, column) => {
|
||||
const outside = Number(date.slice(5, 7)) !== month;
|
||||
const isToday = date === today;
|
||||
return (
|
||||
<Box
|
||||
key={date}
|
||||
onClick={(event) => onPickDay(date, event.currentTarget)}
|
||||
sx={{
|
||||
borderRight: column === 6 ? 0 : 1,
|
||||
borderColor: "divider",
|
||||
px: 0.75,
|
||||
pt: 0.5,
|
||||
cursor: "pointer",
|
||||
minWidth: 0,
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
color: isToday ? "primary.main" : outside ? "text.disabled" : "text.secondary",
|
||||
fontWeight: isToday ? 600 : 400,
|
||||
borderBottom: isToday ? 2 : 0,
|
||||
borderColor: "primary.main",
|
||||
pb: isToday ? "1px" : 0,
|
||||
}}
|
||||
>
|
||||
{Number(date.slice(8, 10))}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{week.bars.map((bar) => {
|
||||
const task = byId.get(bar.id);
|
||||
if (!task) return null;
|
||||
return (
|
||||
<Box
|
||||
key={`${bar.id}-${bar.startCol}`}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
top: DAY_STRIP + bar.lane * (BAR_H + BAR_GAP),
|
||||
left: `${(bar.startCol / 7) * 100}%`,
|
||||
width: `${((bar.endCol - bar.startCol + 1) / 7) * 100}%`,
|
||||
px: "2px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<TaskBar task={task} today={today} continuesLeft={bar.continuesLeft} continuesRight={bar.continuesRight} />
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{week.hasOverflow &&
|
||||
week.overflow.map((count, column) =>
|
||||
count > 0 ? (
|
||||
<Typography
|
||||
key={`more-${column}`}
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
top: DAY_STRIP + week.lanes * (BAR_H + BAR_GAP) + 1,
|
||||
left: `${(column / 7) * 100}%`,
|
||||
width: `${(1 / 7) * 100}%`,
|
||||
px: "6px",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
+{count} more
|
||||
</Typography>
|
||||
) : null,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One bar.
|
||||
*
|
||||
* `days_overdue` and `bucket` are computed against *today* by the backend, so a
|
||||
* stale laundry slot from March claims to be two hundred days overdue forever.
|
||||
* Neither is used here: lateness is decided from the task's own due date, and
|
||||
* only for something still open and still offered by its source.
|
||||
*/
|
||||
function TaskBar({
|
||||
task,
|
||||
today,
|
||||
continuesLeft,
|
||||
continuesRight,
|
||||
}: {
|
||||
task: Task;
|
||||
today: string;
|
||||
continuesLeft: boolean;
|
||||
continuesRight: boolean;
|
||||
}) {
|
||||
const late = !task.is_done && !task.stale && task.due_date !== null && task.due_date < today;
|
||||
const muted = task.is_done || task.stale;
|
||||
const radius = "4px";
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: BAR_H,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
px: "6px",
|
||||
bgcolor: "action.hover",
|
||||
borderTopLeftRadius: continuesLeft ? 0 : radius,
|
||||
borderBottomLeftRadius: continuesLeft ? 0 : radius,
|
||||
borderTopRightRadius: continuesRight ? 0 : radius,
|
||||
borderBottomRightRadius: continuesRight ? 0 : radius,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{!continuesLeft && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 2,
|
||||
height: "60%",
|
||||
flexShrink: 0,
|
||||
borderRadius: 2,
|
||||
bgcolor: late ? "error.main" : muted ? "text.disabled" : "text.secondary",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1,
|
||||
minWidth: 0,
|
||||
color: muted ? "text.disabled" : late ? "error.main" : "text.primary",
|
||||
textDecoration: task.is_done ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The "and this too" you type at 9am.
|
||||
*
|
||||
* The draft lives here rather than in the page: it is this dialog's business and
|
||||
* nothing else ever reads it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export default function AddTaskDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState({ title: "", due_date: "" });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle>New task</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
label="What needs doing?"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Due date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={draft.due_date}
|
||||
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Local tasks live only here, so they are the one kind you can delete.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!draft.title.trim()}
|
||||
onClick={async () => {
|
||||
await api.post("/api/tasks", {
|
||||
title: draft.title.trim(),
|
||||
due_date: draft.due_date || null,
|
||||
});
|
||||
setDraft({ title: "", due_date: "" });
|
||||
onClose();
|
||||
await onCreated();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Hide whole categories of work: a source, or one project inside it.
|
||||
*
|
||||
* The set it edits holds **hidden** keys, never visible ones, so a Jira project
|
||||
* that first appears after a refresh shows up rather than being filtered out by
|
||||
* a set written last week. See `isHidden` in `lib/calendar.ts`.
|
||||
*
|
||||
* Switching a source off disables its projects rather than silently unticking
|
||||
* them: the parent governs, and switching it back on restores the whole subtree
|
||||
* instead of leaving ghosts from whatever was unticked before.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Popover,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
|
||||
import { buildFilterTree, type CalendarItem } from "../../lib/calendar";
|
||||
|
||||
export default function SourceFilterTree({
|
||||
items,
|
||||
hidden,
|
||||
onChange,
|
||||
}: {
|
||||
/** The *unfiltered* list — the counts have to describe what is there. */
|
||||
items: CalendarItem[];
|
||||
hidden: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const tree = useMemo(() => buildFilterTree(items), [items]);
|
||||
const hiddenSet = useMemo(() => new Set(hidden), [hidden]);
|
||||
|
||||
const setHidden = (key: string, isHidden: boolean, alsoClear: string[] = []) => {
|
||||
const next = new Set(hiddenSet);
|
||||
if (isHidden) next.add(key);
|
||||
else next.delete(key);
|
||||
for (const extra of alsoClear) next.delete(extra);
|
||||
onChange([...next]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<FilterListIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={(event) => setAnchor(event.currentTarget)}
|
||||
>
|
||||
{hidden.length ? `Filter (${hidden.length} hidden)` : "Filter"}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
open={anchor !== null}
|
||||
anchorEl={anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 260, maxHeight: 420 } } }}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Stack direction="row" alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Show
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button size="small" disabled={hidden.length === 0} onClick={() => onChange([])}>
|
||||
Reset
|
||||
</Button>
|
||||
</Stack>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
{tree.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing to filter yet.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{tree.map((node) => {
|
||||
const sourceHidden = hiddenSet.has(node.key);
|
||||
return (
|
||||
<Box key={node.key} sx={{ mb: 0.5 }}>
|
||||
<FilterRow
|
||||
label={node.label}
|
||||
count={node.count}
|
||||
checked={!sourceHidden}
|
||||
bold
|
||||
onChange={(visible) =>
|
||||
setHidden(
|
||||
node.key,
|
||||
!visible,
|
||||
// Turning a source back on restores its projects with it.
|
||||
visible ? node.children.map((child) => child.key) : [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
{node.children.map((child) => (
|
||||
<FilterRow
|
||||
key={child.key}
|
||||
label={child.label}
|
||||
count={child.count}
|
||||
indent
|
||||
disabled={sourceHidden}
|
||||
checked={!sourceHidden && !hiddenSet.has(child.key)}
|
||||
onChange={(visible) => setHidden(child.key, !visible)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({
|
||||
label,
|
||||
count,
|
||||
checked,
|
||||
onChange,
|
||||
indent = false,
|
||||
bold = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
checked: boolean;
|
||||
onChange: (visible: boolean) => void;
|
||||
indent?: boolean;
|
||||
bold?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ pl: indent ? 3 : 0, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.45 : 1 }}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
>
|
||||
<Checkbox size="small" checked={checked} disabled={disabled} sx={{ p: 0.5 }} />
|
||||
<Typography variant="body2" noWrap sx={{ flexGrow: 1, minWidth: 0, fontWeight: bold ? 600 : 400 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{count}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Everything you owe, grouped by deadline — the original Tasks view.
|
||||
*
|
||||
* Moved out of `TasksPage.tsx` when the page grew a calendar beside it. The
|
||||
* grouping and the empty state are unchanged; the one new thing is that the
|
||||
* list filters `is_done`/`stale` itself rather than trusting the URL it was
|
||||
* fetched from, because the page now asks the backend for history so the
|
||||
* calendar has something to draw.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { Box, Divider, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { Panel, SectionTitle } from "../Layout";
|
||||
import { BUCKET_LABEL } from "../../lib/format";
|
||||
import type { Task } from "../../lib/types";
|
||||
import TaskRow from "./TaskRow";
|
||||
|
||||
const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"];
|
||||
|
||||
/** What the list has always shown: open, still offered by its source. */
|
||||
export function openTasks(tasks: Task[]): Task[] {
|
||||
return tasks.filter((task) => !task.is_done && !task.stale);
|
||||
}
|
||||
|
||||
export default function TaskList({
|
||||
tasks,
|
||||
onPin,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
onPin: (task: Task) => Promise<void>;
|
||||
onStart: (task: Task) => Promise<void>;
|
||||
onDelete: (task: Task) => Promise<void>;
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] };
|
||||
for (const task of tasks) {
|
||||
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday";
|
||||
map[bucket].push(task);
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<Panel>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Nothing on the list
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself.
|
||||
</Typography>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{BUCKETS.map((bucket) =>
|
||||
grouped[bucket].length ? (
|
||||
<Box key={bucket} sx={{ mb: 4 }}>
|
||||
<SectionTitle
|
||||
action={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{grouped[bucket].length}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</SectionTitle>
|
||||
<Stack divider={<Divider />}>
|
||||
{grouped[bucket].map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onPin={() => onPin(task)}
|
||||
onStart={() => onStart(task)}
|
||||
onDelete={() => onDelete(task)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* One task, as a row of hairline-separated facts.
|
||||
*
|
||||
* Moved out of `TasksPage.tsx` unchanged when the page grew a second view; the
|
||||
* calendar's day popover renders the same row, so a task reads identically
|
||||
* wherever you meet it.
|
||||
*/
|
||||
import { Box, IconButton, Link, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
|
||||
import StarIcon from "@mui/icons-material/Star";
|
||||
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||
|
||||
import type { Task } from "../../lib/types";
|
||||
|
||||
export const BUCKET_COLOR: Record<string, string> = {
|
||||
overdue: "error.main",
|
||||
today: "warning.main",
|
||||
active: "success.main",
|
||||
upcoming: "text.disabled",
|
||||
someday: "divider",
|
||||
};
|
||||
|
||||
export default function TaskRow({
|
||||
task,
|
||||
onPin,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
onPin: () => Promise<void>;
|
||||
onStart: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }}
|
||||
>
|
||||
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} />
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography
|
||||
variant="body1"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: task.pinned ? 600 : 400,
|
||||
textDecoration: task.is_done ? "line-through" : "none",
|
||||
color: task.is_done ? "text.disabled" : "text.primary",
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</Typography>
|
||||
{task.url && (
|
||||
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}>
|
||||
<OpenInNewIcon sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.source_label}
|
||||
</Typography>
|
||||
{task.project && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.project}
|
||||
</Typography>
|
||||
)}
|
||||
{task.status && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.status}
|
||||
</Typography>
|
||||
)}
|
||||
{task.priority && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.priority}
|
||||
</Typography>
|
||||
)}
|
||||
{/* `days_overdue` is computed against today, so it is only the truth for
|
||||
something still open. A stale laundry slot carries one forever. */}
|
||||
{task.days_overdue > 0 && !task.is_done && !task.stale ? (
|
||||
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
|
||||
{task.days_overdue}d overdue
|
||||
</Typography>
|
||||
) : (
|
||||
task.due_date && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
due {task.due_date}
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
|
||||
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
|
||||
<IconButton size="small" onClick={() => void onPin()}>
|
||||
{task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Start a pomodoro on this">
|
||||
<IconButton size="small" onClick={() => void onStart()}>
|
||||
<PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{task.source === "local" && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => void onDelete()}>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* The month grid, and the two filters that make it readable.
|
||||
*
|
||||
* A calendar of *tasks* is not a calendar of events: nothing here has a time of
|
||||
* day, only a `start_date` and a `due_date`, so every item is a whole-day bar and
|
||||
* the only interesting layout question is which lane it sits in.
|
||||
*
|
||||
* Three rules exist because of what the sources actually return rather than for
|
||||
* their own sake:
|
||||
*
|
||||
* * **Long tasks are measured on their true span, before clipping.** Jira hands
|
||||
* back every open issue with whatever "Start date" the custom field holds,
|
||||
* which is routinely months old. Clipping to the week first would make a
|
||||
* fifteen-day bar look like seven days in each of three rows and it would
|
||||
* never trip the filter — which is the entire point of the filter.
|
||||
* * **The filter stores hidden keys, never visible ones.** A Jira project that
|
||||
* appears for the first time after a refresh must show up, not be silently
|
||||
* filtered out because it was absent from a set written last week.
|
||||
* * **A due date is what makes a task plottable** — see `spanOf`.
|
||||
*
|
||||
* Nothing here may key off `bucket` or `days_overdue`: the backend computes both
|
||||
* against *today* (`_bucket_of` in `backend/app/api/routes_tasks.py`), so a stale
|
||||
* laundry slot from March reports `overdue` with a `days_overdue` that grows
|
||||
* forever. In a cell you are looking at in a past month that is noise — style
|
||||
* from the cell's own date against `is_done` and `stale` instead.
|
||||
*
|
||||
* `android/core/src/main/kotlin/tech/kadence/core/Calendar.kt` is a literal port
|
||||
* of this file, and `CalendarTest` asserts the outputs this produces.
|
||||
* `CalendarParityTest` reads `LONG_TASK_DAYS` straight out of this source.
|
||||
*/
|
||||
|
||||
/** Longer than this many days and a bar is "long". Mirrored in `Calendar.kt`. */
|
||||
export const LONG_TASK_DAYS = 7;
|
||||
|
||||
/**
|
||||
* What the layout needs off a task. `Task` satisfies this structurally, so
|
||||
* callers pass their tasks straight in.
|
||||
*/
|
||||
export interface CalendarItem {
|
||||
id: number;
|
||||
source: string;
|
||||
source_label: string;
|
||||
project: string | null;
|
||||
start_date: string | null;
|
||||
due_date: string | null;
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
start: string;
|
||||
end: string;
|
||||
/** Inclusive length, so a one-day task is 1. */
|
||||
days: number;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Dates
|
||||
// --------------------------------------------------------------------------
|
||||
// ISO day strings compare lexicographically in calendar order, so ordering and
|
||||
// overlap tests never need a Date at all. Only arithmetic does.
|
||||
|
||||
/** `new Date("2026-10-19")` is parsed as UTC; this is the local-midnight form. */
|
||||
function parseISO(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00`);
|
||||
}
|
||||
|
||||
function toISO(date: Date): string {
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** Whole days from `from` to `to`; rounded, so a DST day is still one day. */
|
||||
export function diffDays(from: string, to: string): number {
|
||||
return Math.round((parseISO(to).getTime() - parseISO(from).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
export function addDays(iso: string, days: number): string {
|
||||
const date = parseISO(iso);
|
||||
return toISO(new Date(date.getFullYear(), date.getMonth(), date.getDate() + days));
|
||||
}
|
||||
|
||||
export function todayISO(): string {
|
||||
return toISO(new Date());
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Spans
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function span(start: string, end: string): Span {
|
||||
return { start, end, days: diffDays(start, end) + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a task sits on the calendar, or null if it cannot be plotted.
|
||||
*
|
||||
* **A due date is what makes a task plottable.** A task with only a start date
|
||||
* is deliberately *not* drawn, which is the one rule here that costs something
|
||||
* and is worth the cost: the Jira JQL in `backend/app/sources/jira.py:77`
|
||||
* selects issues whose start date is today-or-earlier with no lower bound, and
|
||||
* Jira issues very often carry a start date and no due date. Drawing those on
|
||||
* their start day puts work the user thinks of as "now" into a cell four months
|
||||
* back — a confident wrong answer, which is worse than an honest absence. They
|
||||
* come back as `undated` instead and get their own list.
|
||||
*
|
||||
* A due date *before* the start date is bad data — Jira permits it — and
|
||||
* collapses to the deadline, because the deadline is the half that matters.
|
||||
*/
|
||||
export function spanOf(item: CalendarItem): Span | null {
|
||||
const due = item.due_date;
|
||||
if (!due) return null;
|
||||
const start = item.start_date;
|
||||
if (!start || due < start) return span(due, due);
|
||||
return span(start, due);
|
||||
}
|
||||
|
||||
export function isLong(value: Span): boolean {
|
||||
return value.days > LONG_TASK_DAYS;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// The grid
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The weeks a month needs — five or six, never a fixed six.
|
||||
*
|
||||
* October 2026 starts on a Thursday and ends on a Saturday, so Monday-first it
|
||||
* runs 28 September to 1 November in five rows. Padding that to six would hang
|
||||
* an empty week off the bottom of the page.
|
||||
*
|
||||
* `month` is 1-12.
|
||||
*/
|
||||
export function monthGrid(year: number, month: number, weekStartsMonday: boolean): string[][] {
|
||||
const first = new Date(year, month - 1, 1);
|
||||
const weekday = first.getDay();
|
||||
const offset = weekStartsMonday ? (weekday + 6) % 7 : weekday;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
|
||||
const weeks: string[][] = [];
|
||||
let cursor = 1 - offset;
|
||||
while (cursor <= lastDay) {
|
||||
const row: string[] = [];
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
row.push(toISO(new Date(year, month - 1, cursor + index)));
|
||||
}
|
||||
weeks.push(row);
|
||||
cursor += 7;
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
const WEEKDAYS_FROM_MONDAY = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
export function weekdayLabels(weekStartsMonday: boolean): string[] {
|
||||
if (weekStartsMonday) return [...WEEKDAYS_FROM_MONDAY];
|
||||
return [WEEKDAYS_FROM_MONDAY[6], ...WEEKDAYS_FROM_MONDAY.slice(0, 6)];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Lane packing
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export interface Bar {
|
||||
id: number;
|
||||
lane: number;
|
||||
/** Inclusive column indices into the week, 0-6. */
|
||||
startCol: number;
|
||||
endCol: number;
|
||||
/** The task began before this week / ends after it — square that edge off. */
|
||||
continuesLeft: boolean;
|
||||
continuesRight: boolean;
|
||||
}
|
||||
|
||||
export interface WeekLayout {
|
||||
dates: string[];
|
||||
bars: Bar[];
|
||||
/** Per column, how many tasks this week could not draw — the "+N" counts. */
|
||||
overflow: number[];
|
||||
/** Lanes actually drawn, so a row can size itself to its content. */
|
||||
lanes: number;
|
||||
/** Whether any day in this week needs the "+N" strip under the bars. */
|
||||
hasOverflow: boolean;
|
||||
}
|
||||
|
||||
interface Placed {
|
||||
item: CalendarItem;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out a whole month, keeping a multi-week bar in one lane throughout.
|
||||
*
|
||||
* This has to be a month-at-a-time function rather than a week-at-a-time one,
|
||||
* because the lane a continuing bar occupies is information that flows from one
|
||||
* week to the next. Packing each week independently — however the segments are
|
||||
* sorted — lets a bar step between lanes halfway along. With A spanning
|
||||
* 2 to 18 March and B spanning 9 March to 2 April, A owns lane 0 in the first
|
||||
* week; in the second both clip to Monday, and *any* tie-break that prefers B
|
||||
* (longest-first does) moves A down to lane 1 mid-bar.
|
||||
*
|
||||
* So, FullCalendar's rule, in two phases per week:
|
||||
*
|
||||
* 1. **Continuations keep the lane they already hold.** This can never
|
||||
* collide: every bar continuing into this week covers its first day, so
|
||||
* every pair of them also overlapped on the previous week's last day, and
|
||||
* they were given distinct lanes there.
|
||||
* 2. **Everything else competes** for the lowest free lane, longest first.
|
||||
*
|
||||
* Note that `continuesLeft` is geometry (the span starts before this week) while
|
||||
* "is a continuation" is bookkeeping (we already gave it a lane). They are not
|
||||
* the same, and conflating them breaks the first visible week: a bar running in
|
||||
* from before the grid has no lane yet, so it competes as a newcomer while still
|
||||
* drawing with an open left edge.
|
||||
*
|
||||
* A task that finds no free lane is not drawn and is counted against **every**
|
||||
* day it covers, because each cell's "+2" has to describe that cell — it is what
|
||||
* opens when you click it. It is deliberately given no lane, so next week it
|
||||
* competes again and may well win one.
|
||||
*
|
||||
* `maxLanes` is a per-client layout budget, not a shared rule: it is a required
|
||||
* argument with no default so that neither client can drift into its own idea of
|
||||
* what "the" value is.
|
||||
*/
|
||||
export function layoutMonth(
|
||||
weeks: string[][],
|
||||
items: CalendarItem[],
|
||||
maxLanes: number,
|
||||
): WeekLayout[] {
|
||||
const placed: Placed[] = [];
|
||||
for (const item of items) {
|
||||
const value = spanOf(item);
|
||||
if (value) placed.push({ item, span: value });
|
||||
}
|
||||
|
||||
const laneOf = new Map<number, number>();
|
||||
const layouts: WeekLayout[] = [];
|
||||
|
||||
for (const week of weeks) {
|
||||
const weekStart = week[0];
|
||||
const weekEnd = week[6];
|
||||
const inWeek = placed.filter((entry) => entry.span.start <= weekEnd && entry.span.end >= weekStart);
|
||||
|
||||
const occupied: boolean[][] = [];
|
||||
for (let lane = 0; lane < maxLanes; lane += 1) occupied.push(new Array(7).fill(false));
|
||||
const bars: Bar[] = [];
|
||||
const overflow = new Array(7).fill(0);
|
||||
|
||||
const claim = (lane: number, from: number, to: number) => {
|
||||
for (let column = from; column <= to; column += 1) occupied[lane][column] = true;
|
||||
};
|
||||
const free = (lane: number, from: number, to: number) => {
|
||||
for (let column = from; column <= to; column += 1) {
|
||||
if (occupied[lane][column]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const columns = (entry: Placed): [number, number] => [
|
||||
entry.span.start < weekStart ? 0 : diffDays(weekStart, entry.span.start),
|
||||
entry.span.end > weekEnd ? 6 : diffDays(weekStart, entry.span.end),
|
||||
];
|
||||
const barFor = (entry: Placed, lane: number, from: number, to: number): Bar => ({
|
||||
id: entry.item.id,
|
||||
lane,
|
||||
startCol: from,
|
||||
endCol: to,
|
||||
continuesLeft: entry.span.start < weekStart,
|
||||
continuesRight: entry.span.end > weekEnd,
|
||||
});
|
||||
|
||||
// Phase 1 — continuations reclaim their lane, in lane order.
|
||||
const continuing = inWeek
|
||||
.filter((entry) => entry.span.start < weekStart && laneOf.has(entry.item.id))
|
||||
.sort((a, b) => (laneOf.get(a.item.id) as number) - (laneOf.get(b.item.id) as number));
|
||||
for (const entry of continuing) {
|
||||
const lane = laneOf.get(entry.item.id) as number;
|
||||
const [from, to] = columns(entry);
|
||||
claim(lane, from, to);
|
||||
bars.push(barFor(entry, lane, from, to));
|
||||
}
|
||||
|
||||
// Phase 2 — the rest take the lowest lane that fits.
|
||||
const seen = new Set(continuing.map((entry) => entry.item.id));
|
||||
const starting = inWeek
|
||||
.filter((entry) => !seen.has(entry.item.id))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.span.days - a.span.days ||
|
||||
a.span.start.localeCompare(b.span.start) ||
|
||||
a.item.id - b.item.id,
|
||||
);
|
||||
for (const entry of starting) {
|
||||
const [from, to] = columns(entry);
|
||||
let lane = -1;
|
||||
for (let candidate = 0; candidate < maxLanes; candidate += 1) {
|
||||
if (free(candidate, from, to)) {
|
||||
lane = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lane < 0) {
|
||||
for (let column = from; column <= to; column += 1) overflow[column] += 1;
|
||||
continue;
|
||||
}
|
||||
claim(lane, from, to);
|
||||
laneOf.set(entry.item.id, lane);
|
||||
bars.push(barFor(entry, lane, from, to));
|
||||
}
|
||||
|
||||
const lanes = bars.reduce((most, bar) => Math.max(most, bar.lane + 1), 0);
|
||||
layouts.push({
|
||||
dates: week,
|
||||
bars,
|
||||
overflow,
|
||||
lanes,
|
||||
hasOverflow: overflow.some((count) => count > 0),
|
||||
});
|
||||
}
|
||||
|
||||
return layouts;
|
||||
}
|
||||
|
||||
/** `shiftMonth(2026, 1, -1)` is December 2025. `month` is 1-12 throughout. */
|
||||
export function shiftMonth(year: number, month: number, delta: number): [number, number] {
|
||||
const zero = year * 12 + (month - 1) + delta;
|
||||
return [Math.floor(zero / 12), (zero % 12) + 1];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// The type filter
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export interface FilterNode {
|
||||
/** `"jira"` for a source, `"jira/MIPT"` for one of its projects. */
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
children: FilterNode[];
|
||||
}
|
||||
|
||||
function projectKey(source: string, project: string): string {
|
||||
return `${source}/${project}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources, each expanded into the projects seen under it.
|
||||
*
|
||||
* Only tasks that *name* a project get a child node, so a source's children can
|
||||
* sum to less than its own count; those tasks are reachable by hiding the whole
|
||||
* source, which is the honest thing a "(none)" bucket would only dress up.
|
||||
*/
|
||||
export function buildFilterTree(items: CalendarItem[]): FilterNode[] {
|
||||
const sources = new Map<string, { label: string; count: number; projects: Map<string, number> }>();
|
||||
|
||||
for (const item of items) {
|
||||
let entry = sources.get(item.source);
|
||||
if (!entry) {
|
||||
entry = { label: item.source_label || item.source, count: 0, projects: new Map() };
|
||||
sources.set(item.source, entry);
|
||||
}
|
||||
entry.count += 1;
|
||||
const project = (item.project ?? "").trim();
|
||||
if (project) entry.projects.set(project, (entry.projects.get(project) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [...sources.entries()]
|
||||
.sort((a, b) => a[1].label.localeCompare(b[1].label))
|
||||
.map(([source, entry]) => ({
|
||||
key: source,
|
||||
label: entry.label,
|
||||
count: entry.count,
|
||||
children: [...entry.projects.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([project, count]) => ({
|
||||
key: projectKey(source, project),
|
||||
label: project,
|
||||
count,
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Hiding a source hides its projects with it. */
|
||||
export function isHidden(item: CalendarItem, hidden: ReadonlySet<string>): boolean {
|
||||
if (hidden.has(item.source)) return true;
|
||||
const project = (item.project ?? "").trim();
|
||||
return project.length > 0 && hidden.has(projectKey(item.source, project));
|
||||
}
|
||||
|
||||
export interface CalendarModel<T> {
|
||||
/** What the grid draws. */
|
||||
visible: T[];
|
||||
/** Passing the type filter but carrying no due date, so nowhere to draw them. */
|
||||
undated: T[];
|
||||
/** Spanning more than a week — the toggle's count, whether or not it is on. */
|
||||
longCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over the list for everything the toolbar and the grid need.
|
||||
*
|
||||
* `longCount` deliberately ignores `showLong`, so the toggle's own label does
|
||||
* not change the moment you press it.
|
||||
*/
|
||||
export function buildModel<T extends CalendarItem>(
|
||||
items: T[],
|
||||
hidden: ReadonlySet<string>,
|
||||
showLong: boolean,
|
||||
): CalendarModel<T> {
|
||||
const visible: T[] = [];
|
||||
const undated: T[] = [];
|
||||
let longCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (isHidden(item, hidden)) continue;
|
||||
const value = spanOf(item);
|
||||
if (!value) {
|
||||
undated.push(item);
|
||||
continue;
|
||||
}
|
||||
if (isLong(value)) {
|
||||
longCount += 1;
|
||||
if (!showLong) continue;
|
||||
}
|
||||
visible.push(item);
|
||||
}
|
||||
|
||||
return { visible, undated, longCount };
|
||||
}
|
||||
|
||||
/** The tasks touching one day, in the order the grid stacks them. */
|
||||
export function itemsOnDay<T extends CalendarItem>(items: T[], day: string): T[] {
|
||||
return items
|
||||
.filter((item) => {
|
||||
const value = spanOf(item);
|
||||
return value !== null && value.start <= day && value.end >= day;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const left = spanOf(a) as Span;
|
||||
const right = spanOf(b) as Span;
|
||||
return left.start.localeCompare(right.start) || right.days - left.days || a.id - b.id;
|
||||
});
|
||||
}
|
||||
+137
-211
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* Everything you owe, from every source, grouped by deadline.
|
||||
* Everything you owe, from every source — as a list, or as a month.
|
||||
*
|
||||
* There is no checkbox. A task is closed in Jira, Todoist or Google Tasks —
|
||||
* wherever it lives — and the next refresh notices and credits it to the day.
|
||||
* Two places to tick something off is two places to be wrong.
|
||||
*
|
||||
* The page asks the backend for **done and stale rows too**, because the
|
||||
* calendar's whole point is that a past month shows what actually happened. The
|
||||
* list therefore filters `is_done`/`stale` itself rather than trusting the URL
|
||||
* it was fetched from, and the source buttons are counted from that filtered
|
||||
* list — `sources` and `counts` in the response now describe the archive, not
|
||||
* the work in front of you.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -13,51 +20,43 @@ import {
|
||||
Button,
|
||||
Chip,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Link,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
|
||||
import StarIcon from "@mui/icons-material/Star";
|
||||
import StarBorderIcon from "@mui/icons-material/StarBorder";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
|
||||
import AddTaskDialog from "../components/tasks/AddTaskDialog";
|
||||
import SourceFilterTree from "../components/tasks/SourceFilterTree";
|
||||
import TaskList, { openTasks } from "../components/tasks/TaskList";
|
||||
import TaskRow from "../components/tasks/TaskRow";
|
||||
import TaskCalendar from "../components/calendar/TaskCalendar";
|
||||
import { api } from "../lib/api";
|
||||
import { BUCKET_LABEL } from "../lib/format";
|
||||
import { useResource } from "../lib/hooks";
|
||||
import { buildModel } from "../lib/calendar";
|
||||
import { useLocalState, useResource } from "../lib/hooks";
|
||||
import type { RefreshResult, Task, TaskListResponse } from "../lib/types";
|
||||
|
||||
const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"];
|
||||
const BUCKET_COLOR: Record<string, string> = {
|
||||
overdue: "error.main",
|
||||
today: "warning.main",
|
||||
active: "success.main",
|
||||
upcoming: "text.disabled",
|
||||
someday: "divider",
|
||||
};
|
||||
/** History included: the calendar draws past months, the list filters it back out. */
|
||||
const TASKS_PATH = "/api/tasks?include_done=true&include_stale=true";
|
||||
|
||||
export default function TasksPage({ onToast }: { onToast: (message: string) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
|
||||
const tasks = useResource<TaskListResponse>(TASKS_PATH, 60000);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [draft, setDraft] = useState({ title: "", due_date: "" });
|
||||
|
||||
// Per-device UI preferences. `useLocalState` JSON-stringifies, and a `Set`
|
||||
// stringifies to `{}` — so the hidden keys are persisted as an array and the
|
||||
// set is derived.
|
||||
const [view, setView] = useLocalState<"list" | "calendar">("kadence.tasks.view", "list");
|
||||
const [hiddenKeys, setHiddenKeys] = useLocalState<string[]>("kadence.tasks.hidden", []);
|
||||
const [showLong, setShowLong] = useLocalState<boolean>("kadence.tasks.showLong", false);
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
@@ -79,19 +78,38 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
}
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const all = tasks.data?.tasks ?? [];
|
||||
return filter === "all" ? all : all.filter((task) => task.source === filter);
|
||||
}, [tasks.data, filter]);
|
||||
const onPin = async (task: Task) => {
|
||||
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
|
||||
await tasks.reload();
|
||||
};
|
||||
const onStart = async (task: Task) => {
|
||||
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
|
||||
onToast(`Focusing on “${task.title}”`);
|
||||
navigate("/");
|
||||
};
|
||||
const onDelete = async (task: Task) => {
|
||||
await api.del(`/api/tasks/${task.id}`);
|
||||
await tasks.reload();
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] };
|
||||
for (const task of visible) {
|
||||
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday";
|
||||
map[bucket].push(task);
|
||||
}
|
||||
return map;
|
||||
}, [visible]);
|
||||
const all = useMemo(() => tasks.data?.tasks ?? [], [tasks.data]);
|
||||
const open = useMemo(() => openTasks(all), [all]);
|
||||
|
||||
// The response's `sources` now lists every source that ever had a row; the
|
||||
// buttons have to describe what is actually on the list.
|
||||
const openSources = useMemo(() => {
|
||||
const labels = new Map<string, string>();
|
||||
for (const task of open) labels.set(task.source, task.source_label || task.source);
|
||||
return [...labels.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
}, [open]);
|
||||
|
||||
const listed = useMemo(
|
||||
() => (filter === "all" ? open : open.filter((task) => task.source === filter)),
|
||||
[open, filter],
|
||||
);
|
||||
|
||||
const hiddenSet = useMemo(() => new Set(hiddenKeys), [hiddenKeys]);
|
||||
const model = useMemo(() => buildModel(all, hiddenSet, showLong), [all, hiddenSet, showLong]);
|
||||
|
||||
const failed = sourceStatus?.filter((item) => !item.ok) ?? [];
|
||||
|
||||
@@ -130,16 +148,31 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={filter}
|
||||
onChange={(_event, value) => value && setFilter(value)}
|
||||
value={view}
|
||||
onChange={(_event, value) => value && setView(value as "list" | "calendar")}
|
||||
>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
{(tasks.data?.sources ?? []).map((source) => (
|
||||
<ToggleButton key={source} value={source}>
|
||||
{tasks.data?.tasks.find((task) => task.source === source)?.source_label ?? source}
|
||||
</ToggleButton>
|
||||
))}
|
||||
<ToggleButton value="list">List</ToggleButton>
|
||||
<ToggleButton value="calendar">Calendar</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{view === "list" ? (
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={filter}
|
||||
onChange={(_event, value) => value && setFilter(value)}
|
||||
>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
{openSources.map(([source, label]) => (
|
||||
<ToggleButton key={source} value={source}>
|
||||
{label}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
) : (
|
||||
<SourceFilterTree items={all} hidden={hiddenKeys} onChange={setHiddenKeys} />
|
||||
)}
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{sourceStatus && failed.length === 0 && (
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
|
||||
@@ -150,182 +183,75 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{tasks.data && visible.length === 0 && (
|
||||
<Panel>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Nothing on the list
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself.
|
||||
</Typography>
|
||||
</Panel>
|
||||
{view === "calendar" ? (
|
||||
<>
|
||||
<TaskCalendar
|
||||
tasks={model.visible}
|
||||
longCount={model.longCount}
|
||||
showLong={showLong}
|
||||
onShowLong={setShowLong}
|
||||
onPin={onPin}
|
||||
onStart={onStart}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<UndatedSection tasks={model.undated} onPin={onPin} onStart={onStart} onDelete={onDelete} />
|
||||
</>
|
||||
) : (
|
||||
<TaskList tasks={listed} onPin={onPin} onStart={onStart} onDelete={onDelete} />
|
||||
)}
|
||||
|
||||
{BUCKETS.map((bucket) =>
|
||||
grouped[bucket].length ? (
|
||||
<Box key={bucket} sx={{ mb: 4 }}>
|
||||
<SectionTitle
|
||||
action={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{grouped[bucket].length}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</SectionTitle>
|
||||
<Stack divider={<Divider />}>
|
||||
{grouped[bucket].map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onPin={async () => {
|
||||
await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
|
||||
await tasks.reload();
|
||||
}}
|
||||
onStart={async () => {
|
||||
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
|
||||
onToast(`Focusing on “${task.title}”`);
|
||||
navigate("/");
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await api.del(`/api/tasks/${task.id}`);
|
||||
await tasks.reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>New task</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
label="What needs doing?"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Due date"
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={draft.due_date}
|
||||
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Local tasks live only here, so they are the one kind you can delete.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!draft.title.trim()}
|
||||
onClick={async () => {
|
||||
await api.post("/api/tasks", {
|
||||
title: draft.title.trim(),
|
||||
due_date: draft.due_date || null,
|
||||
});
|
||||
setDraft({ title: "", due_date: "" });
|
||||
setAddOpen(false);
|
||||
await tasks.reload();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<AddTaskDialog open={addOpen} onClose={() => setAddOpen(false)} onCreated={tasks.reload} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
/**
|
||||
* The tasks a calendar cannot place: no due date, so no column to sit in.
|
||||
*
|
||||
* Jira keeps the same affordance and calls it the backlog. Collapsed by default
|
||||
* — it is routinely the longest list on the page, and it is not what you opened
|
||||
* a calendar to look at.
|
||||
*/
|
||||
function UndatedSection({
|
||||
tasks,
|
||||
onPin,
|
||||
onStart,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
onPin: () => Promise<void>;
|
||||
onStart: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
tasks: Task[];
|
||||
onPin: (task: Task) => Promise<void>;
|
||||
onStart: (task: Task) => Promise<void>;
|
||||
onDelete: (task: Task) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }}
|
||||
>
|
||||
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} />
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body1" noWrap sx={{ fontWeight: task.pinned ? 600 : 400 }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
{task.url && (
|
||||
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}>
|
||||
<OpenInNewIcon sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.source_label}
|
||||
</Typography>
|
||||
{task.project && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.project}
|
||||
</Typography>
|
||||
)}
|
||||
{task.status && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.status}
|
||||
</Typography>
|
||||
)}
|
||||
{task.priority && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{task.priority}
|
||||
</Typography>
|
||||
)}
|
||||
{task.days_overdue > 0 ? (
|
||||
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
|
||||
{task.days_overdue}d overdue
|
||||
</Typography>
|
||||
) : (
|
||||
task.due_date && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
due {task.due_date}
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
|
||||
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
|
||||
<IconButton size="small" onClick={() => void onPin()}>
|
||||
{task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Start a pomodoro on this">
|
||||
<IconButton size="small" onClick={() => void onStart()}>
|
||||
<PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{task.source === "local" && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => void onDelete()}>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle
|
||||
action={
|
||||
<Button size="small" onClick={() => setOpen(!open)}>
|
||||
{open ? "Hide" : "Show"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
No due date ({tasks.length})
|
||||
</SectionTitle>
|
||||
<Collapse in={open}>
|
||||
<Panel padded={false} sx={{ px: 2 }}>
|
||||
<Stack divider={<Divider />}>
|
||||
{tasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onPin={() => onPin(task)}
|
||||
onStart={() => onStart(task)}
|
||||
onDelete={() => onDelete(task)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -100,13 +100,22 @@ export default function TimerPage({
|
||||
[onToast, timer, today],
|
||||
);
|
||||
|
||||
const firing = useRef(false);
|
||||
// Which interval we have already told the server to finish. Keyed by id, not a
|
||||
// bare "in flight" flag: a poll issued just before the countdown ran out answers
|
||||
// with the finished interval still running at 00:00, and a flag cleared by that
|
||||
// reply fires `complete` a second time — onto the break `auto_next` has since
|
||||
// started, which is a break that ends the instant it begins. An id is never
|
||||
// reused, so the second attempt is simply never made.
|
||||
const completed = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!current || remaining > 0 || firing.current) return;
|
||||
firing.current = true;
|
||||
if (!current || remaining > 0 || completed.current === current.id) return;
|
||||
completed.current = current.id;
|
||||
const wasWork = current.kind === "work";
|
||||
void (async () => {
|
||||
const next = await call("/api/timer/complete", { auto_next: true });
|
||||
const next = await call("/api/timer/complete", { auto_next: true, session_id: current.id });
|
||||
// It never landed, so this interval is still owed a complete: let the next
|
||||
// tick try again rather than leave the dial stuck at 00:00.
|
||||
if (!next && completed.current === current.id) completed.current = null;
|
||||
const volume = settings?.pomodoro.alarm_volume ?? 70;
|
||||
if (settings?.pomodoro.alarm_sound ?? true) {
|
||||
wasWork ? sound.workDone(volume) : sound.breakDone(volume);
|
||||
@@ -115,7 +124,6 @@ export default function TimerPage({
|
||||
notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it.");
|
||||
}
|
||||
if (next?.celebration) onToast("Milestone reached — Telegram got a cheer");
|
||||
firing.current = false;
|
||||
})();
|
||||
}, [remaining, current, call, settings, onToast]);
|
||||
|
||||
@@ -218,7 +226,13 @@ export default function TimerPage({
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={busy}
|
||||
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval finished")}
|
||||
onClick={() =>
|
||||
void call(
|
||||
"/api/timer/complete",
|
||||
{ auto_next: true, session_id: current.id },
|
||||
"Interval finished",
|
||||
)
|
||||
}
|
||||
>
|
||||
Finish
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user