Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ interface OutboxDao {
)
suspend fun countNewerContentMutations(entryId: String, afterId: Long): Int

/**
* Metadata writes attempted before a STOP but still present because they failed or will retry.
* Once STOP succeeds, their payload/base must be advanced to the authoritative stopped
* interval so retrying the metadata cannot restart the timer and a pull cannot erase it.
*/
@Query(
"SELECT * FROM outbox WHERE timeEntryId = :entryId AND id < :stopId " +
"AND opType = 'UPDATE' ORDER BY id ASC",
)
suspend fun getUpdatesBeforeStop(entryId: String, stopId: Long): List<OutboxEntity>

/**
* Discard every queued operation for an entry that never reached the server (still on its
* `local-` id). Used when deleting a never-synced entry (SV-008): the entry's own
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,21 @@ class TimeEntryRepository @Inject constructor(
}

suspend fun stopEntry(entry: TimeEntry, userId: String) {
stopEntryInternal(entry, userId, editedEntry = null, editedTagIds = null)
}

/**
* Stop a running entry while committing the metadata currently visible in Track.
*
* A STOP request carries only timestamps, so edits need their own UPDATE operation. The Room
* write and both ordered outbox operations stay in one transaction so refresh or process death
* cannot expose a stopped row whose metadata existed only in Compose.
*/
suspend fun stopEntryWithEdits(entry: TimeEntry, userId: String, editedEntry: TimeEntry, tagIds: List<String>) {
stopEntryInternal(entry, userId, editedEntry, tagIds)
}

private suspend fun stopEntryInternal(entry: TimeEntry, userId: String, editedEntry: TimeEntry?, editedTagIds: List<String>?) {
val now = clock.nowMs()
val end = nowIso()
database.withTransaction {
Expand Down Expand Up @@ -394,8 +409,59 @@ class TimeEntryRepository @Inject constructor(
return@withTransaction
}
val base = captureBaseSnapshot(targetId)
val currentTagIds = timeEntryDao.tagIdsFor(targetId)
val contentChanged = editedEntry != null &&
(
current == null ||
current.description.orEmpty() != editedEntry.description.orEmpty() ||
current.projectId != editedEntry.projectId ||
current.taskId != editedEntry.taskId ||
current.billable != editedEntry.billable ||
current.type != editedEntry.type ||
currentTagIds.toSet() != editedTagIds.orEmpty().toSet()
)
val content = if (contentChanged) {
val updated = (current ?: entry.toEntity(updatedAt = now, syncState = SyncState.PENDING)).copy(
id = targetId,
description = editedEntry.description,
projectId = editedEntry.projectId,
taskId = editedEntry.taskId,
billable = editedEntry.billable,
type = editedEntry.type,
updatedAt = now,
syncState = SyncState.PENDING,
)
timeEntryDao.upsert(updated)
timeEntryDao.replaceTagRefs(targetId, editedTagIds.orEmpty())
outboxDao.insert(
OutboxEntity(
opType = OutboxOpType.UPDATE,
organizationId = targetOrganizationId,
timeEntryId = targetId,
createdAtMs = now,
clientId = newClientId(),
payloadJson = json.encodeToString(
UpdatePayload(
updated.userId,
targetStart,
end = null,
updated.description,
updated.projectId,
updated.taskId,
updated.billable,
editedTagIds.orEmpty(),
type = updated.type,
),
),
baseSnapshotJson = base,
),
)
updated
} else {
current
}
val duration = completedDurationSeconds(targetStart, end)
val stopped = current?.copy(end = end, duration = duration, updatedAt = now, syncState = SyncState.PENDING)
val stopped = content?.copy(end = end, duration = duration, updatedAt = now, syncState = SyncState.PENDING)
?: entry.copy(end = end, duration = duration).toEntity(updatedAt = now, syncState = SyncState.PENDING)
timeEntryDao.upsert(stopped)
outboxDao.insert(
Expand Down
32 changes: 31 additions & 1 deletion app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,37 @@ class SyncWorker @AssistedInject constructor(
),
)
} else {
persistSynced(server)
val unresolvedMetadata = outboxDao.getUpdatesBeforeStop(op.timeEntryId, op.id)
if (current != null && unresolvedMetadata.isNotEmpty()) {
// A failed metadata UPDATE remains authoritative locally even if STOP succeeds.
// Advance its interval/base so retry cannot restart the timer and a pull cannot
// erase the user's description or catalogue selections.
val stoppedBase = json.encodeToString(server.toConflictSnapshot())
database.withTransaction {
unresolvedMetadata.forEach { updateOperation ->
val updatePayload = json.decodeFromString<UpdatePayload>(updateOperation.payloadJson)
outboxDao.update(
updateOperation.copy(
payloadJson = json.encodeToString(
updatePayload.copy(start = server.start, end = server.end),
),
baseSnapshotJson = stoppedBase,
),
)
}
timeEntryDao.upsert(
current.copy(
start = server.start,
end = server.end,
duration = server.duration,
updatedAt = clock.nowMs(),
syncState = SyncState.PENDING,
),
)
}
} else {
persistSynced(server)
}
}
return Outcome.Success()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2971,7 +2971,7 @@ private fun CompactTimeEntryRow(
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun TimeEntryFormSheet(
internal fun TimeEntryFormSheet(
entry: TimeEntry?, // null = create mode
zone: ZoneId, // account temporal-policy zone for the new-entry fallback start
suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h)
Expand Down Expand Up @@ -3054,7 +3054,11 @@ private fun TimeEntryFormSheet(
}

ModalBottomSheet(
onDismissRequest = onDismiss,
// Child date/time/split pickers use separate dialog windows. Do not let the parent sheet
// interpret their focus change as a request to close the whole editor.
onDismissRequest = {
if (canDismissTimeEntryFormSheet(editingTime != null, editingDate != null, showSplitPicker)) onDismiss()
},
modifier = Modifier.testTag(TrackingTestTags.SHEET),
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
Expand Down Expand Up @@ -3514,6 +3518,12 @@ private fun EntryTimePickerDialog(
)
}

internal fun canDismissTimeEntryFormSheet(
hasTimePicker: Boolean,
hasDatePicker: Boolean,
hasSplitPicker: Boolean,
): Boolean = !hasTimePicker && !hasDatePicker && !hasSplitPicker

/**
* About section with version info, verification details, and Obtainium button
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1536,8 +1536,22 @@ class TrackingViewModel @Inject constructor(

viewModelScope.launch {
try {
// Optimistic local stop + outbox enqueue. The collector clears the active entry.
timeEntryRepository.stopEntry(currentEntry, currentEntry.userId)
// Commit the editable running-entry fields atomically with the stop. Otherwise a
// fast Stop can leave metadata only in UI state while timestamp sync wins.
val editingTags = _uiState.value.editingTags
val editedEntry = currentEntry.copy(
description = _uiState.value.editingDescription,
projectId = _uiState.value.editingProjectId,
taskId = _uiState.value.editingTaskId,
billable = _uiState.value.editingBillable,
tags = editingTags.map(::Tag),
)
timeEntryRepository.stopEntryWithEdits(
entry = currentEntry,
userId = currentEntry.userId,
editedEntry = editedEntry,
tagIds = editingTags,
)
syncTrigger.requestSync()

val currentState = _uiState.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class FakeRemoteDataSource(
var failNextWrite: Boolean = false,
/** When set, every write fails with this throwable (use a non-IOException to exercise FAIL). */
var writeError: Throwable? = null,
var updateError: Throwable? = null,
var startResult: (TimeEntry) -> TimeEntry = { it },
var stopResult: (TimeEntry) -> TimeEntry = { it },
var updateResult: (TimeEntry) -> TimeEntry = { it },
Expand Down Expand Up @@ -118,7 +119,8 @@ class FakeRemoteDataSource(
)
}
override suspend fun updateTimeEntry(organizationId: String, timeEntry: TimeEntry, tags: List<String>) =
writeError?.let { Result.failure(it) }
updateError?.let { Result.failure(it) }
?: writeError?.let { Result.failure(it) }
?: if (failNextWrite) Result.failure(java.io.IOException("offline")) else Result.success(updateResult(timeEntry))
override suspend fun deleteTimeEntry(organizationId: String, timeEntryId: String): Result<Unit> {
writeError?.let { return Result.failure(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import dev.tricked.solidverdant.data.model.TimeEntryType
import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource
import dev.tricked.solidverdant.sync.ConflictSnapshot
import dev.tricked.solidverdant.sync.CreatePayload
import dev.tricked.solidverdant.sync.StopPayload
import dev.tricked.solidverdant.sync.UpdatePayload
import dev.tricked.solidverdant.util.Clock
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -524,6 +525,40 @@ class TimeEntryRepositoryWriteTest {
assertEquals(reconciled.id, stop.timeEntryId)
}

@Test fun stop_with_unsaved_fields_persists_metadata_and_orders_update_before_stop() = runTest {
val running = repo.startEntry("org1", "member", "u", null, null, "", emptyList())
val edited = running.copy(
description = "prep",
projectId = "project-1",
taskId = "task-1",
billable = true,
tags = listOf(Tag("tag-1")),
)

repo.stopEntryWithEdits(running, "u", edited, listOf("tag-1"))

val stored = db.timeEntryDao().getById(running.id)
assertEquals("prep", stored?.description)
assertEquals("project-1", stored?.projectId)
assertEquals("task-1", stored?.taskId)
assertEquals(true, stored?.billable)
assertTrue(stored?.end != null)
assertEquals(listOf("tag-1"), db.timeEntryDao().tagIdsFor(running.id))

val operations = db.outboxDao().peekAll()
assertEquals(
listOf(OutboxOpType.START, OutboxOpType.UPDATE, OutboxOpType.STOP),
operations.map { it.opType },
)
val update = testJson.decodeFromString<UpdatePayload>(operations[1].payloadJson)
assertEquals("prep", update.description)
assertEquals("project-1", update.projectId)
assertEquals("task-1", update.taskId)
val stop = testJson.decodeFromString<StopPayload>(operations[2].payloadJson)
assertEquals(running.start, stop.start)
assertTrue(stop.end.isNotBlank())
}

@Test fun repeated_stop_is_idempotent_and_preserves_the_first_end_time() = runTest {
val entry = TimeEntry(
id = "server-1",
Expand Down
101 changes: 101 additions & 0 deletions app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,107 @@ class SyncWorkerTest {
assertTrue(db.outboxDao().peekAll().isEmpty())
}

@Test fun rejected_update_then_successful_stop_preserves_metadata_through_refresh() = runTest {
val serverActive = TimeEntry(
id = "server-1",
userId = "u1",
organizationId = "org1",
start = "2026-08-24T08:00:00Z",
end = null,
description = null,
)
val localStopped = serverActive.copy(
end = "2026-08-24T09:00:00Z",
duration = 3_600,
description = "prep",
projectId = "project-1",
taskId = "task-1",
)
db.timeEntryDao().upsert(localStopped.toEntity(updatedAt = 2L, syncState = SyncState.PENDING))
val activeBase = json.encodeToString(
ConflictSnapshot.of(
serverActive.start,
serverActive.end,
serverActive.description,
serverActive.projectId,
serverActive.taskId,
serverActive.billable,
emptyList(),
),
)
db.outboxDao().insert(
OutboxEntity(
opType = OutboxOpType.UPDATE,
organizationId = "org1",
timeEntryId = serverActive.id,
createdAtMs = 1L,
payloadJson = json.encodeToString(
UpdatePayload(
"u1",
serverActive.start,
null,
localStopped.description,
localStopped.projectId,
localStopped.taskId,
false,
emptyList(),
),
),
baseSnapshotJson = activeBase,
),
)
db.outboxDao().insert(
OutboxEntity(
opType = OutboxOpType.STOP,
organizationId = "org1",
timeEntryId = serverActive.id,
createdAtMs = 2L,
payloadJson = json.encodeToString(
StopPayload("u1", serverActive.start, localStopped.end!!),
),
baseSnapshotJson = activeBase,
),
)
remote.entries = listOf(serverActive)
remote.memberships = listOf(Membership("m1", "member", Organization("org1", "Org", "USD")))
remote.updateError = IllegalStateException("metadata rejected")
remote.stopResult = { request -> serverActive.copy(end = request.end, duration = 3_600) }

assertEquals(ListenableWorker.Result.success(), buildWorker().doWork())

val failedUpdate = db.outboxDao().peekAll().single()
assertEquals(OutboxOpType.UPDATE, failedUpdate.opType)
assertTrue(failedUpdate.deadLettered)
val retryPayload = json.decodeFromString<UpdatePayload>(failedUpdate.payloadJson)
assertEquals(localStopped.end, retryPayload.end)
val afterStop = db.timeEntryDao().getById(serverActive.id)
assertEquals(SyncState.PENDING, afterStop?.syncState)
assertEquals("prep", afterStop?.description)
assertEquals("project-1", afterStop?.projectId)
assertEquals("task-1", afterStop?.taskId)

remote.entries = listOf(serverActive.copy(end = localStopped.end, duration = 3_600))
val repository = TimeEntryRepository(
db.timeEntryDao(),
db.catalogDao(),
db.outboxDao(),
db.syncMetaDao(),
remote,
clock,
json,
db,
)
assertTrue(repository.refreshAll("org1", "m1").isSuccess)

val refreshed = db.timeEntryDao().getById(serverActive.id)
assertEquals(SyncState.PENDING, refreshed?.syncState)
assertEquals("prep", refreshed?.description)
assertEquals("project-1", refreshed?.projectId)
assertEquals("task-1", refreshed?.taskId)
assertEquals(localStopped.end, refreshed?.end)
assertTrue(db.outboxDao().peekAll().single().deadLettered)
}

@Test fun update_conflict_preserves_mine_and_does_not_write_server() = runTest {
val base = TimeEntry(
id = "server-1",
Expand Down
Loading
Loading