The AgendaScreen is the container for the Tasks tab in the bottom navigation. It hosts a TabRow with four tabs — Agenda, Habits, Projects, Tagged — and renders the day-calendar agenda view in the first tab. The other three tabs are stubs for now (steps 5-6).
The AgendaViewModel loads tasks and events for a selected date from the AgendaRepository, presents them as sorted =AgendaItem=s, and exposes toggleTodoState to mark tasks done (which triggers repeater rescheduling via OrgDocumentEditor's position-based methods).
AgendaItem presentation model
A lightweight wrapper around TaskHeading that carries the relevant timestamp for the current view and sorting metadata.
package computer.whatthefuck.arcology.app.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.capture.CaptureService
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.OrgFile
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.domain.TaskTimestamp
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
private const val TAG = "AgendaViewModel"
/**
* One-shot navigation events emitted by [AgendaViewModel.openTask].
* The screen collects these and forwards them to the NavController.
*/
sealed class AgendaNavEvent {
/** Open the editor at this node id (resolved from the task or its file-level node). */
data class OpenNode(val nodeId: String) : AgendaNavEvent()
}
/**
* A single task or event rendered in the agenda day view.
*
* @param task The underlying TaskHeading
* @param timestamp The timestamp matching the selected date, or null for overdue-no-date
* @param isOverdue True if this task is overdue (past deadline/scheduled, not done)
* @param hasTime True if the timestamp has a time-of-day component (for ordering)
*/
data class AgendaItem(
val task: TaskHeading,
val timestamp: TaskTimestamp?,
val isOverdue: Boolean,
val hasTime: Boolean
)
/**
* ViewModel for the Agenda screen.
*
* Loads tasks and events for a selected date from the AgendaRepository,
* presents them as sorted =AgendaItem=s, and exposes =toggleTodoState= to mark tasks done (which triggers repeater rescheduling via [[id:20260518085900.000004][OrgDocumentEditor]]'s position-based methods).
*
* =openTask= resolves a task to a node id for the editor: it uses the heading's =nodeId= when present, otherwise falls back to the file-level node (level 0) via [[id:20260520143000.000001][RoamRepository.getNodesByFile()]], then the first node in the file. If no node exists for the file, no event is emitted.
*
* @param agendaRepository The agenda data source
* @param roamRepository Used for file → node resolution when a task has no node id
* @param documentEditor The editor for TODO state changes (position-based)
* @param backgroundDispatcher Overridable for tests
*/
open class AgendaViewModel(
private val agendaRepository: AgendaRepository,
private val roamRepository: RoamRepository,
private val documentEditor: OrgDocumentEditor,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default
) : ViewModel() {
private fun today(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
private val _selectedDate = MutableStateFlow(today())
val selectedDate: StateFlow<LocalDate> = _selectedDate.asStateFlow()
private val _agendaItems = MutableStateFlow<List<AgendaItem>>(emptyList())
val agendaItems: StateFlow<List<AgendaItem>> = _agendaItems.asStateFlow()
private val _overdueItems = MutableStateFlow<List<AgendaItem>>(emptyList())
val overdueItems: StateFlow<List<AgendaItem>> = _overdueItems.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _navEvents = MutableSharedFlow<AgendaNavEvent>(extraBufferCapacity = 4)
val navEvents: SharedFlow<AgendaNavEvent> = _navEvents.asSharedFlow()
/**
* Node id of the journal (daily) page for [selectedDate], or null when no
* journal page exists for that date. A journal page is an indexed org file
* whose name matches the daily file naming scheme (YYYY-MM-DD.org) — see
* [[id:20260518T000000.000001][CaptureService.dailyFileName]].
*/
private val _journalNodeId = MutableStateFlow<String?>(null)
val journalNodeId: StateFlow<String?> = _journalNodeId.asStateFlow()
init {
loadAgenda()
}
fun selectDate(date: LocalDate) {
_selectedDate.value = date
loadAgenda()
}
fun goToNextDay() {
_selectedDate.value = _selectedDate.value.plus(DatePeriod(days = 1))
loadAgenda()
}
fun goToPreviousDay() {
_selectedDate.value = _selectedDate.value.plus(DatePeriod(days = -1))
loadAgenda()
}
fun goToToday() {
_selectedDate.value = today()
loadAgenda()
}
fun refresh() {
loadAgenda()
}
/**
* Resolve [item] to a node id and emit an [AgendaNavEvent.OpenNode].
* Uses the heading's own =nodeId= if present, otherwise falls back to the
* file-level node (level 0) for the heading's file, then the first node
* in the file. Sets [errorMessage] if the file has no nodes.
*/
fun openTask(item: AgendaItem) {
viewModelScope.launch {
val nodeId = item.task.nodeId ?: run {
val nodes = roamRepository.getNodesByFile(item.task.file)
val target = nodes.firstOrNull { it.level == 0 } ?: nodes.firstOrNull()
target?.id
}
if (nodeId != null) {
_navEvents.tryEmit(AgendaNavEvent.OpenNode(nodeId))
} else {
_errorMessage.value = "No node found for ${item.task.file.substringAfterLast("/")}"
}
}
}
/**
* Open the journal page for [selectedDate], if one exists. Emits an
* [AgendaNavEvent.OpenNode] for the journal's file-level node so the
* editor opens at the top of the file.
*/
fun openJournal() {
val nodeId = _journalNodeId.value ?: return
viewModelScope.launch {
_navEvents.tryEmit(AgendaNavEvent.OpenNode(nodeId))
}
}
/**
* Find the node id for the journal page of [date]. Scans indexed files for
* one whose name matches [CaptureService.dailyFileName] and resolves its
* file-level node (level 0, else the first node in the file). Returns null
* when no such file or node exists.
*/
private suspend fun findJournalNodeId(date: LocalDate): String? {
val fileName = CaptureService.dailyFileName(date)
val file = roamRepository.getAllFiles()
.firstOrNull { it.path.substringAfterLast('/') == fileName }
?: return null
val nodes = roamRepository.getNodesByFile(file.path)
val node = nodes.firstOrNull { it.level == 0 } ?: nodes.firstOrNull()
return node?.id
}
fun toggleTodoState(item: AgendaItem) {
val currentState = item.task.todo
val newState = if (currentState != null && currentState in setOf("TODO", "NEXT", "INPROGRESS")) {
"DONE"
} else if (currentState == "DONE") {
"TODO"
} else {
"DONE"
}
viewModelScope.launch {
try {
val result = documentEditor.updateTodoStateByPosition(
item.task.file,
item.task.position,
newState
)
if (result is EditResult.Error) {
_errorMessage.value = result.message
} else {
loadAgenda()
}
} catch (e: Exception) {
Log.e(TAG, "toggleTodoState failed", e)
_errorMessage.value = e.message
}
}
}
private fun loadAgenda() {
viewModelScope.launch {
_isLoading.value = true
_errorMessage.value = null
try {
val date = _selectedDate.value
val dayTasks = agendaRepository.getTasksForDateRange(date, date)
val overdue = agendaRepository.getOverdueTasks()
// A journal page exists when an indexed file matches the daily
// naming scheme for the selected date. Failures here must not
// break the agenda itself, so this is isolated.
_journalNodeId.value = runCatching { findJournalNodeId(date) }.getOrNull()
// Build a set of (file, position) for tasks already in the day view
val dayKeys = dayTasks.map { it.file to it.position }.toSet()
val dayItems = dayTasks.map { task ->
val ts = task.timestamps.firstOrNull { it.date == date.toString() }
AgendaItem(
task = task,
timestamp = ts,
isOverdue = false,
hasTime = ts?.timestamp != null
)
}.sortedWith(compareBy(nullsLast()) { it.timestamp?.timestamp })
val overdueItems = overdue
.filter { (it.file to it.position) !in dayKeys }
.map { task ->
val ts = task.timestamps.firstOrNull { it.timestamp != null }
AgendaItem(
task = task,
timestamp = ts,
isOverdue = true,
hasTime = ts?.timestamp != null
)
}.sortedWith(compareBy(nullsLast()) { it.timestamp?.timestamp })
_agendaItems.value = dayItems
_overdueItems.value = overdueItems
} catch (e: Exception) {
Log.e(TAG, "loadAgenda failed", e)
_errorMessage.value = e.message
} finally {
_isLoading.value = false
}
}
}
}AgendaScreen — container with TabRow
The screen hosts a TabRow with four tabs. Only the Agenda tab is implemented; the others are stubs.
package computer.whatthefuck.arcology.app.ui.screens
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.app.ui.components.InlineMarkupConfig
import computer.whatthefuck.arcology.app.ui.components.buildOrgAnnotatedString
import computer.whatthefuck.arcology.app.viewmodel.AgendaItem
import computer.whatthefuck.arcology.app.viewmodel.AgendaNavEvent
import computer.whatthefuck.arcology.app.viewmodel.AgendaViewModel
import computer.whatthefuck.arcology.app.viewmodel.HabitViewModel
import computer.whatthefuck.arcology.app.viewmodel.ProjectViewModel
import computer.whatthefuck.arcology.app.viewmodel.TaggedTasksViewModel
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.koin.androidx.compose.koinViewModel
import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AgendaScreen(
viewModel: AgendaViewModel = koinViewModel(),
habitViewModel: HabitViewModel = koinViewModel(),
projectViewModel: ProjectViewModel = koinViewModel(),
taggedViewModel: TaggedTasksViewModel = koinViewModel(),
onNavigateToNode: (String) -> Unit = {},
onTagLongClick: (String) -> Unit = {}
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Agenda", "Habits", "Projects", "Tagged")
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is AgendaNavEvent.OpenNode -> onNavigateToNode(event.nodeId)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
when (selectedTab) {
0 -> AgendaTabContent(
viewModel = viewModel,
snackbarHostState = snackbarHostState,
onTagLongClick = onTagLongClick
)
1 -> HabitScreen(viewModel = habitViewModel, onNavigateToNode = onNavigateToNode)
2 -> ProjectsTabContent(viewModel = projectViewModel, onNavigateToNode = onNavigateToNode)
3 -> TaggedTasksScreen(viewModel = taggedViewModel, onNavigateToNode = onNavigateToNode)
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
@Composable
private fun AgendaTabContent(
viewModel: AgendaViewModel,
snackbarHostState: SnackbarHostState,
onTagLongClick: (String) -> Unit
) {
val selectedDate by viewModel.selectedDate.collectAsState()
val agendaItems by viewModel.agendaItems.collectAsState()
val overdueItems by viewModel.overdueItems.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val journalNodeId by viewModel.journalNodeId.collectAsState()
val today = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date }
var showDatePicker by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (showDatePicker) {
DatePickerDialog(
selectedDate = selectedDate,
onDateSelected = { date ->
viewModel.selectDate(date)
showDatePicker = false
},
onDismiss = { showDatePicker = false }
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Date navigation header
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { viewModel.goToPreviousDay() }) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous day")
}
TextButton(onClick = { showDatePicker = true }) {
Text(formatDate(selectedDate), style = MaterialTheme.typography.titleMedium)
}
IconButton(onClick = { viewModel.goToNextDay() }) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next day")
}
}
if (selectedDate != today) {
TextButton(
onClick = { viewModel.goToToday() },
modifier = Modifier.align(Alignment.CenterHorizontally)
) { Text("Today") }
}
if (journalNodeId != null) {
OutlinedButton(
onClick = { viewModel.openJournal() },
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(Icons.AutoMirrored.Filled.MenuBook, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Open journal")
}
}
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Overdue section
if (overdueItems.isNotEmpty()) {
item {
Text(
text = "Overdue (${overdueItems.size})",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(overdueItems) { item ->
AgendaItemCard(
item = item,
onToggle = { viewModel.toggleTodoState(item) },
onClick = { viewModel.openTask(item) },
onTagClick = { tag ->
scope.launch {
snackbarHostState.showSnackbar(
"tag: $tag — Projects coming soon"
)
}
},
onTagLongClick = onTagLongClick
)
}
item { HorizontalDivider() }
}
// Day section
if (agendaItems.isEmpty() && overdueItems.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
Text("No tasks or events for this day", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
items(agendaItems) { item ->
AgendaItemCard(
item = item,
onToggle = { viewModel.toggleTodoState(item) },
onClick = { viewModel.openTask(item) },
onTagClick = { tag ->
scope.launch {
snackbarHostState.showSnackbar(
"tag: $tag — Projects coming soon"
)
}
},
onTagLongClick = onTagLongClick
)
}
}
}
}
}
}
@Composable
private fun AgendaItemCard(
item: AgendaItem,
onToggle: () -> Unit,
onClick: () -> Unit,
onTagClick: (String) -> Unit,
onTagLongClick: (String) -> Unit
) {
val task = item.task
val todoColor = when (task.todo) {
"TODO", "NEXT", "INPROGRESS" -> MaterialTheme.colorScheme.tertiary
"DONE" -> MaterialTheme.colorScheme.primary
"CANCELLED", "ARCHIVED" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// IconButton toggle checkbox
IconButton(onClick = onToggle, modifier = Modifier.size(32.dp)) {
when (task.todo) {
"DONE" -> Icon(Icons.Default.Check, contentDescription = "Done", tint = todoColor)
"CANCELLED", "ARCHIVED" -> Icon(Icons.Default.Close, contentDescription = "Cancelled", tint = todoColor)
else -> Box(
modifier = Modifier.size(20.dp).border(
1.dp, todoColor, CircleShape
)
)
}
}
// TODO state chip
val todo = task.todo
if (todo != null) {
Surface(
color = todoColor,
shape = MaterialTheme.shapes.small,
modifier = Modifier.padding(start = 4.dp)
) {
Text(
text = todo,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
// Title, time label, file name, tag chips
Column(
modifier = Modifier.weight(1f).padding(start = 8.dp)
) {
// Title row with time label
Row(verticalAlignment = Alignment.CenterVertically) {
val title = task.title?.ifBlank { task.outlinePath.lastOrNull() } ?: "Untitled"
val titleAnnotated = rememberOrgTitle(title)
Text(
text = titleAnnotated,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (item.hasTime && item.timestamp != null) {
val epoch = item.timestamp.timestamp
if (epoch != null) {
val dt = kotlin.time.Instant.fromEpochSeconds(epoch)
.toLocalDateTime(TimeZone.currentSystemDefault())
Text(
text = "${dt.hour.toString().padStart(2, '0')}:${dt.minute.toString().padStart(2, '0')}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
Text(
text = task.file.substringAfterLast("/"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (task.tags.isNotEmpty()) {
Row(modifier = Modifier.padding(top = 2.dp)) {
task.tags.take(3).forEach { tag ->
AssistChip(
onClick = { onTagClick(tag) },
modifier = Modifier
.padding(end = 4.dp)
.combinedClickable(
onClick = { onTagClick(tag) },
onLongClick = { onTagLongClick(tag) }
),
label = { Text(tag, style = MaterialTheme.typography.labelSmall) }
)
}
}
}
}
}
}
@Composable
private fun rememberOrgTitle(title: String): AnnotatedString =
remember(title) {
buildOrgAnnotatedString(
text = title,
config = InlineMarkupConfig(
linkColor = androidx.compose.ui.graphics.Color.Unspecified,
textColor = androidx.compose.ui.graphics.Color.Unspecified,
verbatimColor = androidx.compose.ui.graphics.Color.Unspecified,
codeColor = androidx.compose.ui.graphics.Color.Unspecified,
onSurfaceVariant = androidx.compose.ui.graphics.Color.Unspecified,
monospaceFont = androidx.compose.ui.text.font.FontFamily.Monospace
)
)
}
@Composable
private fun DatePickerDialog(
selectedDate: LocalDate,
onDateSelected: (LocalDate) -> Unit,
onDismiss: () -> Unit
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { millis ->
onDateSelected(LocalDate.fromEpochDays((millis / 86400000L).toInt()))
}
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
) {
DatePicker(state = state)
}
}
private fun formatDate(date: LocalDate): String {
return "${date.dayOfWeek.name.take(3).lowercase().replaceFirstChar { it.uppercase() }}, ${date.monthNumber}/${date.dayOfMonth}/${date.year}"
}Tests
Tests follow the QuizViewModelTest conventions: JUnit4 + MockK + StandardTestDispatcher + runTest. AgendaRepository is mocked with coEvery per test. OrgDocumentEditor is mocked with relaxed true= — only updateTodoStateByPosition is called.
package computer.whatthefuck.arcology.app.viewmodel
import android.util.Log
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.OrgFile
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.domain.TaskTimestamp
import computer.whatthefuck.arcology.domain.TaskTimestampKind
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.time.Clock
@OptIn(ExperimentalCoroutinesApi::class)
class AgendaViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var repository: AgendaRepository
private lateinit var roamRepository: RoamRepository
private lateinit var editor: OrgDocumentEditor
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.e(any<String>(), any<String>()) } returns 0
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } returns 0
repository = mockk()
roamRepository = mockk(relaxed = true)
editor = mockk(relaxed = true)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(): AgendaViewModel {
return AgendaViewModel(repository, roamRepository, editor, backgroundDispatcher = testDispatcher)
}
private fun sampleTask(
file: String = "/test.org",
position: Int = 0,
todo: String? = "TODO",
nodeId: String? = null,
timestamps: List<TaskTimestamp> = emptyList()
): TaskHeading {
return TaskHeading(
file = file,
position = position,
level = 1,
todo = todo,
priority = null,
title = "Test task",
outlinePath = listOf("Parent", "Test task"),
nodeId = nodeId,
tags = listOf("work"),
isHabit = false,
timestamps = timestamps
)
}
private fun sampleTimestamp(
date: String = "2026-08-05",
timestamp: Long? = null,
kind: TaskTimestampKind = TaskTimestampKind.SCHEDULED
): TaskTimestamp {
return TaskTimestamp(
file = "/test.org",
position = 0,
kind = kind,
date = date,
timestamp = timestamp
)
}
@Test
fun `init loads today's agenda`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
today shouldBe vm.selectedDate.value
vm.agendaItems.value.size shouldBe 1
vm.overdueItems.value.isEmpty() shouldBe true
}
@Test
fun `selectDate updates and reloads`() = runTest {
val date1 = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val date2 = date1.plus(DatePeriod(days = 5))
coEvery { repository.getTasksForDateRange(date1, date1) } returns emptyList()
coEvery { repository.getTasksForDateRange(date2, date2) } returns listOf(sampleTask())
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.selectDate(date2)
advanceUntilIdle()
date2 shouldBe vm.selectedDate.value
vm.agendaItems.value.size shouldBe 1
}
@Test
fun `goToNextDay advances by one day`() = runTest {
coEvery { repository.getTasksForDateRange(any(), any()) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
val initial = vm.selectedDate.value
vm.goToNextDay()
advanceUntilIdle()
initial.plus(DatePeriod(days = 1)) shouldBe vm.selectedDate.value
}
@Test
fun `goToPreviousDay goes back one day`() = runTest {
coEvery { repository.getTasksForDateRange(any(), any()) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
val initial = vm.selectedDate.value
vm.goToPreviousDay()
advanceUntilIdle()
initial.plus(DatePeriod(days = -1)) shouldBe vm.selectedDate.value
}
@Test
fun `goToToday jumps to today`() = runTest {
coEvery { repository.getTasksForDateRange(any(), any()) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
vm.goToPreviousDay()
advanceUntilIdle()
vm.goToToday()
advanceUntilIdle()
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
today shouldBe vm.selectedDate.value
}
@Test
fun `items sorted by time with timed before untimed`() = runTest {
val date = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val untimedTask = sampleTask(position = 1, timestamps = listOf(sampleTimestamp(date.toString(), timestamp = null)))
val timedTask = sampleTask(position = 2, timestamps = listOf(sampleTimestamp(date.toString(), timestamp = 1722870000L)))
coEvery { repository.getTasksForDateRange(date, date) } returns listOf(untimedTask, timedTask)
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
val items = vm.agendaItems.value
2 shouldBe items.size
items[0].hasTime shouldBe true
items[1].hasTime shouldBe false
}
@Test
fun `overdue tasks loaded separately`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val overdueTask = sampleTask(position = 10, timestamps = listOf(sampleTimestamp("2026-07-01", timestamp = 1720000000L)))
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns listOf(overdueTask)
val vm = createViewModel()
advanceUntilIdle()
vm.overdueItems.value.size shouldBe 1
vm.overdueItems.value[0].isOverdue shouldBe true
}
@Test
fun `overdue tasks not duplicated in day view`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(position = 5, timestamps = listOf(sampleTimestamp(today.toString(), timestamp = 1722870000L)))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns listOf(task)
val vm = createViewModel()
advanceUntilIdle()
vm.agendaItems.value.size shouldBe 1
vm.overdueItems.value.size shouldBe 0
}
@Test
fun `empty state when no tasks`() = runTest {
coEvery { repository.getTasksForDateRange(any(), any()) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.agendaItems.value.isEmpty() shouldBe true
vm.overdueItems.value.isEmpty() shouldBe true
}
@Test
fun `error state sets errorMessage`() = runTest {
coEvery { repository.getTasksForDateRange(any(), any()) } throws RuntimeException("DB error")
coEvery { repository.getOverdueTasks() } throws RuntimeException("overdue error")
val vm = createViewModel()
advanceUntilIdle()
vm.errorMessage.value shouldBe "DB error"
}
@Test
fun `isLoading toggles during load`() = runTest {
coEvery { repository.getTasksForDateRange(any(), any()) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
vm.isLoading.value shouldBe false
advanceUntilIdle()
vm.isLoading.value shouldBe false
}
@Test
fun `refresh reloads data`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
val task = sampleTask(timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
vm.refresh()
advanceUntilIdle()
vm.agendaItems.value.size shouldBe 1
}
@Test
fun `toggleTodoState calls editor with DONE for TODO task`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(todo = "TODO", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
coEvery { editor.updateTodoStateByPosition(any(), any(), any()) } returns EditResult.Success("")
val vm = createViewModel()
advanceUntilIdle()
vm.toggleTodoState(vm.agendaItems.value[0])
advanceUntilIdle()
coVerify { editor.updateTodoStateByPosition("/test.org", 0, "DONE") }
}
@Test
fun `toggleTodoState calls editor with TODO for DONE task`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(todo = "DONE", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
coEvery { editor.updateTodoStateByPosition(any(), any(), any()) } returns EditResult.Success("")
val vm = createViewModel()
advanceUntilIdle()
vm.toggleTodoState(vm.agendaItems.value[0])
advanceUntilIdle()
coVerify { editor.updateTodoStateByPosition("/test.org", 0, "TODO") }
}
@Test
fun `toggleTodoState refreshes after call`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(todo = "TODO", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
coEvery { editor.updateTodoStateByPosition(any(), any(), any()) } returns EditResult.Success("")
val vm = createViewModel()
advanceUntilIdle()
// Change the repo return for the reload
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
vm.toggleTodoState(vm.agendaItems.value[0])
advanceUntilIdle()
vm.agendaItems.value.size shouldBe 0
}
@Test
fun `AgendaItem hasTime is true when timestamp has epoch`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(timestamps = listOf(sampleTimestamp(today.toString(), timestamp = 1722870000L)))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.agendaItems.value[0].hasTime shouldBe true
}
@Test
fun `AgendaItem hasTime is false when timestamp is date-only`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(timestamps = listOf(sampleTimestamp(today.toString(), timestamp = null)))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.agendaItems.value[0].hasTime shouldBe false
}
@Test
fun `openTask emits OpenNode with task nodeId when present`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(nodeId = "heading-id", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
val deferred = backgroundScope.async {
vm.navEvents.first()
}
advanceUntilIdle()
vm.openTask(vm.agendaItems.value[0])
advanceUntilIdle()
val event = deferred.await()
(event as AgendaNavEvent.OpenNode).nodeId shouldBe "heading-id"
}
@Test
fun `openTask falls back to file-level node when heading has no nodeId`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(nodeId = null, file = "/notes.org", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
val fileNode = OrgNode(id = "file-level-id", file = "/notes.org", level = 0, position = 0)
val childNode = OrgNode(id = "child-id", file = "/notes.org", level = 1, position = 1)
coEvery { roamRepository.getNodesByFile("/notes.org") } returns listOf(childNode, fileNode)
val vm = createViewModel()
advanceUntilIdle()
val deferred = backgroundScope.async {
vm.navEvents.first()
}
advanceUntilIdle()
vm.openTask(vm.agendaItems.value[0])
advanceUntilIdle()
val event = deferred.await()
(event as AgendaNavEvent.OpenNode).nodeId shouldBe "file-level-id"
}
@Test
fun `openTask falls back to first node when no level-0 node exists`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(nodeId = null, file = "/notes.org", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
val childNode = OrgNode(id = "child-id", file = "/notes.org", level = 1, position = 1)
coEvery { roamRepository.getNodesByFile("/notes.org") } returns listOf(childNode)
val vm = createViewModel()
advanceUntilIdle()
val deferred = backgroundScope.async {
vm.navEvents.first()
}
advanceUntilIdle()
vm.openTask(vm.agendaItems.value[0])
advanceUntilIdle()
val event = deferred.await()
(event as AgendaNavEvent.OpenNode).nodeId shouldBe "child-id"
}
@Test
fun `openTask sets errorMessage when file has no nodes`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val task = sampleTask(nodeId = null, file = "/notes.org", timestamps = listOf(sampleTimestamp(today.toString())))
coEvery { repository.getTasksForDateRange(today, today) } returns listOf(task)
coEvery { repository.getOverdueTasks() } returns emptyList()
coEvery { roamRepository.getNodesByFile("/notes.org") } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.openTask(vm.agendaItems.value[0])
advanceUntilIdle()
vm.errorMessage.value shouldNotBe null
}
@Test
fun `journalNodeId is null when no file matches the selected date`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
coEvery { roamRepository.getAllFiles() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.journalNodeId.value shouldBe null
}
@Test
fun `journalNodeId resolves file-level node when a journal file exists`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val journalFile = OrgFile(
path = "journals/$today.org",
hash = "abc",
accessTime = Clock.System.now(),
modificationTime = Clock.System.now()
)
coEvery { roamRepository.getAllFiles() } returns listOf(journalFile)
val fileNode = OrgNode(id = "journal-file-id", file = "journals/$today.org", level = 0, position = 0)
coEvery { roamRepository.getNodesByFile("journals/$today.org") } returns listOf(fileNode)
val vm = createViewModel()
advanceUntilIdle()
vm.journalNodeId.value shouldBe "journal-file-id"
}
@Test
fun `journalNodeId resolves first node when journal has no level-0 node`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val journalFile = OrgFile(
path = "journals/$today.org",
hash = "abc",
accessTime = Clock.System.now(),
modificationTime = Clock.System.now()
)
coEvery { roamRepository.getAllFiles() } returns listOf(journalFile)
val childNode = OrgNode(id = "journal-child-id", file = "journals/$today.org", level = 1, position = 1)
coEvery { roamRepository.getNodesByFile("journals/$today.org") } returns listOf(childNode)
val vm = createViewModel()
advanceUntilIdle()
vm.journalNodeId.value shouldBe "journal-child-id"
}
@Test
fun `journalNodeId ignores files that are not daily journals`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val otherFile = OrgFile(
path = "notes/project.org",
hash = "abc",
accessTime = Clock.System.now(),
modificationTime = Clock.System.now()
)
coEvery { roamRepository.getAllFiles() } returns listOf(otherFile)
val vm = createViewModel()
advanceUntilIdle()
vm.journalNodeId.value shouldBe null
}
@Test
fun `openJournal emits OpenNode for the journal node`() = runTest {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
coEvery { repository.getTasksForDateRange(today, today) } returns emptyList()
coEvery { repository.getOverdueTasks() } returns emptyList()
val journalFile = OrgFile(
path = "journals/$today.org",
hash = "abc",
accessTime = Clock.System.now(),
modificationTime = Clock.System.now()
)
coEvery { roamRepository.getAllFiles() } returns listOf(journalFile)
val fileNode = OrgNode(id = "journal-file-id", file = "journals/$today.org", level = 0, position = 0)
coEvery { roamRepository.getNodesByFile("journals/$today.org") } returns listOf(fileNode)
val vm = createViewModel()
advanceUntilIdle()
val deferred = backgroundScope.async {
vm.navEvents.first()
}
advanceUntilIdle()
vm.openJournal()
advanceUntilIdle()
val event = deferred.await()
(event as AgendaNavEvent.OpenNode).nodeId shouldBe "journal-file-id"
}
}Related Modules
agenda/index.org — overview and module index for the agenda rebuild
agenda/models.org — TaskHeading, TaskTimestamp, AgendaRepository
agenda/indexer.org — TaskIndexerPlugin
roam/editor.org — OrgDocumentEditor position-based methods, PlanningInfoUtils
app/bootstrap.org — Screen.Tasks route, bottom nav, DI wiring