Arcology Engine

Agenda Screen and ViewModel

Contents

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.

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/AgendaViewModel.kt
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.

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/AgendaScreen.kt
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.

kotlin:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/AgendaViewModelTest.kt
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