From 0bbc76e84439693db67bfb7b47f6f004fcb1daa0 Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:30:10 +0100 Subject: [PATCH] Schedule: an extra dose restarts "count from last dose" intervals A taken unscheduled dose now restarts the every-N-days and every-X-hours items of its compound, like a planned dose. Skipped doses still never move the plan. --- .../protocoltracker/data/TrackerRepository.kt | 9 ++-- .../apollof/protocoltracker/data/db/Daos.kt | 8 +-- .../data/TrackerRepositoryTest.kt | 6 ++- .../domain/schedule/Anchors.kt | 32 +++++++----- .../domain/schedule/Occurrences.kt | 19 ++++--- .../domain/schedule/ReanchorTest.kt | 52 ++++++++++++++++++- 6 files changed, 96 insertions(+), 30 deletions(-) diff --git a/core/data/src/main/kotlin/com/apollof/protocoltracker/data/TrackerRepository.kt b/core/data/src/main/kotlin/com/apollof/protocoltracker/data/TrackerRepository.kt index b1c6d8f..9e3436a 100644 --- a/core/data/src/main/kotlin/com/apollof/protocoltracker/data/TrackerRepository.kt +++ b/core/data/src/main/kotlin/com/apollof/protocoltracker/data/TrackerRepository.kt @@ -24,6 +24,7 @@ import com.apollof.protocoltracker.domain.pk.PresetMigration import com.apollof.protocoltracker.domain.pk.allows import com.apollof.protocoltracker.domain.pk.present import com.apollof.protocoltracker.domain.pk.Presets +import com.apollof.protocoltracker.domain.schedule.ExtraDose import com.apollof.protocoltracker.domain.schedule.IntervalAnchors import com.apollof.protocoltracker.domain.schedule.Occurrence import com.apollof.protocoltracker.domain.schedule.TakenDose @@ -57,13 +58,15 @@ class TrackerRepository( val protocol: Flow = combine(phases, items, compounds) { p, i, c -> Protocol(p, i, c.associateBy { it.id }) } - /** Every taken scheduled dose, for re-anchoring interval schedules. */ + /** Every taken dose, for re-anchoring interval schedules. */ val anchors: Flow = db.logs().observeTaken().map(::toAnchors) suspend fun anchorsNow(): IntervalAnchors = toAnchors(db.logs().getTaken()) - private fun toAnchors(rows: List) = - IntervalAnchors(rows.map { TakenDose(it.planItemId, it.occurrenceKey, Instant.ofEpochMilli(it.takenAtMs)) }) + private fun toAnchors(rows: List) = IntervalAnchors( + rows.mapNotNull { TakenDose(it.planItemId ?: return@mapNotNull null, it.occurrenceKey ?: return@mapNotNull null, Instant.ofEpochMilli(it.takenAtMs)) }, + rows.filter { it.occurrenceKey == null }.map { ExtraDose(it.compoundId, Instant.ofEpochMilli(it.takenAtMs)) }, + ) fun logsSince(from: Instant): Flow> = db.logs().observeSince(from.toEpochMilli()).map { list -> list.map { it.toDomain() } } diff --git a/core/data/src/main/kotlin/com/apollof/protocoltracker/data/db/Daos.kt b/core/data/src/main/kotlin/com/apollof/protocoltracker/data/db/Daos.kt index a9ce699..db80066 100644 --- a/core/data/src/main/kotlin/com/apollof/protocoltracker/data/db/Daos.kt +++ b/core/data/src/main/kotlin/com/apollof/protocoltracker/data/db/Daos.kt @@ -74,7 +74,7 @@ interface PlanItemDao { suspend fun clear() } -data class TakenDoseRow(val planItemId: String, val occurrenceKey: String, val takenAtMs: Long) +data class TakenDoseRow(val planItemId: String?, val compoundId: String, val occurrenceKey: String?, val takenAtMs: Long) @Dao interface DoseLogDao { @@ -97,11 +97,11 @@ interface DoseLogDao { @Query("SELECT * FROM dose_logs WHERE id = :id") suspend fun get(id: String): DoseLogEntity? - /** Taken scheduled doses; they re-anchor interval schedules. */ - @Query("SELECT planItemId, occurrenceKey, takenAtMs FROM dose_logs WHERE status = 'TAKEN' AND planItemId IS NOT NULL AND occurrenceKey IS NOT NULL") + /** Taken doses, scheduled and unscheduled; they re-anchor interval schedules. */ + @Query("SELECT planItemId, compoundId, occurrenceKey, takenAtMs FROM dose_logs WHERE status = 'TAKEN'") fun observeTaken(): Flow> - @Query("SELECT planItemId, occurrenceKey, takenAtMs FROM dose_logs WHERE status = 'TAKEN' AND planItemId IS NOT NULL AND occurrenceKey IS NOT NULL") + @Query("SELECT planItemId, compoundId, occurrenceKey, takenAtMs FROM dose_logs WHERE status = 'TAKEN'") suspend fun getTaken(): List @Query("SELECT * FROM dose_logs WHERE planItemId = :planItemId") diff --git a/core/data/src/test/kotlin/com/apollof/protocoltracker/data/TrackerRepositoryTest.kt b/core/data/src/test/kotlin/com/apollof/protocoltracker/data/TrackerRepositoryTest.kt index 4125b37..22393e5 100644 --- a/core/data/src/test/kotlin/com/apollof/protocoltracker/data/TrackerRepositoryTest.kt +++ b/core/data/src/test/kotlin/com/apollof/protocoltracker/data/TrackerRepositoryTest.kt @@ -1,5 +1,6 @@ package com.apollof.protocoltracker.data +import com.apollof.protocoltracker.domain.schedule.ExtraDose import com.apollof.protocoltracker.domain.schedule.IntervalAnchors import com.apollof.protocoltracker.domain.schedule.TakenDose import androidx.test.core.app.ApplicationProvider @@ -231,7 +232,7 @@ class TrackerRepositoryTest { } @Test - fun anchorsHoldOnlyTakenScheduledDoses() = runTest { + fun anchorsHoldTakenScheduledAndUnscheduledDoses() = runTest { repo.seedPresets(); repo.savePhase(phase); repo.saveItem(item) val occs = occurrences(listOf(phase), listOf(item), Instant.parse("2026-09-21T00:00:00Z"), Instant.parse("2026-09-25T00:00:00Z"), ZoneOffset.UTC, IntervalAnchors.NONE) repo.logOccurrence(occs[0], LogStatus.TAKEN, takenAt = now) @@ -239,7 +240,8 @@ class TrackerRepositoryTest { repo.logUnscheduled(repo.compounds.first().first { it.id == item.compoundId }, Amount(10.0, DoseUnit.MG), Formulation(), now) val expected = listOf(TakenDose(item.id, occs[0].key, now)) assertEquals(expected, repo.anchorsNow().forItem(item.id)) - assertEquals(IntervalAnchors(expected), repo.anchors.first()) + assertEquals(listOf(now), repo.anchorsNow().extrasFor(item.compoundId)) + assertEquals(IntervalAnchors(expected, listOf(ExtraDose(item.compoundId, now))), repo.anchors.first()) } @Test diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Anchors.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Anchors.kt index 9278889..049c488 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Anchors.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Anchors.kt @@ -7,30 +7,38 @@ import java.time.Instant /** A taken dose of a plan item: which occurrence it confirmed and when it was actually taken. */ data class TakenDose(val planItemId: String, val occurrenceKey: String, val takenAt: Instant) +/** A taken unscheduled dose of a compound: it restarts that compound's interval plan items too. */ +data class ExtraDose(val compoundId: String, val takenAt: Instant) + /** * Taken doses that re-anchor interval schedules with "count from last dose" on (see [occurrences]). - * Must hold every taken plan dose, not a window: the deciding dose can be older than any view's range. + * Must hold every taken dose, not a window: the deciding dose can be older than any view's range. */ -class IntervalAnchors(doses: Collection) { +class IntervalAnchors(doses: Collection, extras: Collection = emptyList()) { private val byItem: Map> = doses.groupBy { it.planItemId } + private val extrasByCompound: Map> = extras.groupBy({ it.compoundId }, { it.takenAt }) fun forItem(itemId: String): List = byItem[itemId].orEmpty() - val isEmpty: Boolean get() = byItem.isEmpty() + fun extrasFor(compoundId: String): List = extrasByCompound[compoundId].orEmpty() + + val isEmpty: Boolean get() = byItem.isEmpty() && extrasByCompound.isEmpty() - override fun equals(other: Any?): Boolean = other is IntervalAnchors && other.byItem == byItem - override fun hashCode(): Int = byItem.hashCode() + override fun equals(other: Any?): Boolean = + other is IntervalAnchors && other.byItem == byItem && other.extrasByCompound == extrasByCompound + override fun hashCode(): Int = 31 * byItem.hashCode() + extrasByCompound.hashCode() companion object { /** Plain plan grid, ignoring logs. */ val NONE = IntervalAnchors(emptyList()) - /** Anchors from logs; only taken scheduled doses count (skipped and unscheduled doses never move the plan). */ - fun from(logs: Collection) = IntervalAnchors( - logs.mapNotNull { log -> - if (log.status != LogStatus.TAKEN) return@mapNotNull null - TakenDose(log.planItemId ?: return@mapNotNull null, log.occurrenceKey ?: return@mapNotNull null, log.takenAt) - }, - ) + /** Anchors from logs; only taken doses count (skipped doses never move the plan). */ + fun from(logs: Collection): IntervalAnchors { + val taken = logs.filter { it.status == LogStatus.TAKEN } + return IntervalAnchors( + taken.mapNotNull { log -> TakenDose(log.planItemId ?: return@mapNotNull null, log.occurrenceKey ?: return@mapNotNull null, log.takenAt) }, + taken.filter { it.occurrenceKey == null }.map { ExtraDose(it.compoundId, it.takenAt) }, + ) + } } } diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt index 8b44d34..64d10a2 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt @@ -142,7 +142,8 @@ class OccurrenceLimitException : IllegalStateException("Too many scheduled doses * * Interval schedules with `fromLastDose` restart from each taken dose in [anchors]: the grid runs from the plan's * anchor until the first taken dose, then from each taken dose's date (every N days) or time (every X hours) - * until the next one. The occurrence each taken dose confirmed is always kept, so its log still matches. + * until the next one. The occurrence each taken dose confirmed is always kept, so its log still matches. A taken + * unscheduled dose of the item's compound restarts the grid the same way but confirms no occurrence. */ fun occurrences( phases: List, @@ -223,7 +224,7 @@ fun occurrences( val end = if (endExclusive == null) lastDate else minOf(lastDate, endExclusive.minusDays(1)) forEachDate(start, end, n) { emitDay(item, it, s.timings) } } - val restarts = if (s.followsLastDose) dayRestarts(anchors.forItem(item.id), item.id, s.anchor, zone, slotTimes) else null + val restarts = if (s.followsLastDose) dayRestarts(anchors.forItem(item.id), anchors.extrasFor(item.compoundId), item.id, s.anchor, zone, slotTimes) else null if (restarts == null || restarts.breaks.isEmpty()) { grid(s.anchor, null) } else { @@ -246,7 +247,7 @@ fun occurrences( k++ } } - val restarts = if (s.followsLastDose) intervalRestarts(anchors.forItem(item.id), item.id, s.anchor) else null + val restarts = if (s.followsLastDose) intervalRestarts(anchors.forItem(item.id), anchors.extrasFor(item.compoundId), item.id, s.anchor) else null if (restarts == null || restarts.breaks.isEmpty()) { grid(s.anchor, null) } else { @@ -268,9 +269,9 @@ private class Restarts(val breaks: List, val pinned: Collection) /** * Every-N-days restarts: each scheduled day with a taken dose restarts the grid on the day its first dose was * taken (its logical day, so a dose taken at 01:00 before the day start counts for the day before). Doses keyed before - * the plan's anchor are ignored, so moving the anchor later starts afresh. + * the plan's anchor are ignored, so moving the anchor later starts afresh. Unscheduled doses restart it on their logical day. */ -private fun dayRestarts(doses: List, itemId: String, anchor: LocalDate, zone: ZoneId, slotTimes: SlotTimes): Restarts { +private fun dayRestarts(doses: List, extras: List, itemId: String, anchor: LocalDate, zone: ZoneId, slotTimes: SlotTimes): Restarts { val firstTakenByDay = HashMap() for (dose in doses) { val day = when (val ref = parseOccurrenceKey(dose.occurrenceKey)?.takeIf { it.itemId == itemId }) { @@ -282,12 +283,13 @@ private fun dayRestarts(doses: List, itemId: String, anchor: LocalDat if (day < anchor) continue firstTakenByDay.merge(day, dose.takenAt) { a, b -> minOf(a, b) } } - val breaks = firstTakenByDay.values.map { slotTimes.dateOf(it, zone) }.distinct().sorted() + val extraDays = extras.map { slotTimes.dateOf(it, zone) }.filter { it >= anchor } + val breaks = (firstTakenByDay.values.map { slotTimes.dateOf(it, zone) } + extraDays).distinct().sorted() return Restarts(breaks, firstTakenByDay.keys) } -/** Every-X-hours restarts: each taken dose restarts the grid at the minute it was taken. */ -private fun intervalRestarts(doses: List, itemId: String, anchor: Instant): Restarts { +/** Every-X-hours restarts: each taken dose, scheduled or not, restarts the grid at the minute it was taken. */ +private fun intervalRestarts(doses: List, extras: List, itemId: String, anchor: Instant): Restarts { val pinned = HashSet() val breaks = HashSet() for (dose in doses) { @@ -296,6 +298,7 @@ private fun intervalRestarts(doses: List, itemId: String, anchor: Ins pinned += ref.at breaks += dose.takenAt.truncatedTo(ChronoUnit.MINUTES) } + for (at in extras) if (at >= anchor) breaks += at.truncatedTo(ChronoUnit.MINUTES) return Restarts(breaks.sorted(), pinned) } diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/ReanchorTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/ReanchorTest.kt index db90e75..9ac5371 100644 --- a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/ReanchorTest.kt +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/ReanchorTest.kt @@ -37,6 +37,10 @@ class ReanchorTest { key, itemId, "c", key, null, takenAt, Amount(500.0, DoseUnit.IU), null, status, snapshot = DoseSnapshot("hCG", "hCG", CompoundCategory.SUPPORT, BaseUnit.IU, null), createdAt = takenAt, ) + private fun extra(takenAt: Instant, compoundId: String = "c") = DoseLog( + "extra-$takenAt", null, compoundId, null, null, takenAt, Amount(500.0, DoseUnit.IU), null, LogStatus.TAKEN, + snapshot = DoseSnapshot("hCG", "hCG", CompoundCategory.SUPPORT, BaseUnit.IU, null), createdAt = takenAt, + ) private fun dates(item: PlanItem, logs: List, from: String = "2026-09-01", to: String = "2026-09-20") = occurrences(emptyList(), listOf(item), at(from), at(to), zone, IntervalAnchors.from(logs)).map { it.localDate.toString() } @@ -64,7 +68,7 @@ class ReanchorTest { } @Test - fun skippedAndUnscheduledDosesDoNotMoveThePlan() { + fun skippedDosesAndLogsOfOtherItemsDoNotMoveThePlan() { val logs = listOf( log(slotKey("2026-09-04"), at("2026-09-05", "10:00"), LogStatus.SKIPPED), log(slotKey("2026-09-07"), at("2026-09-08", "10:00"), itemId = null), @@ -72,6 +76,52 @@ class ReanchorTest { assertEquals(dates(every3(false), emptyList()), dates(every3(), logs)) } + @Test + fun anUnscheduledDoseOnAnOffDayRestartsTheGrid() { + val logs = listOf(extra(at("2026-09-02", "21:00"))) + assertEquals(listOf("2026-09-01", "2026-09-05", "2026-09-08", "2026-09-11", "2026-09-14", "2026-09-17"), dates(every3(), logs)) + } + + @Test + fun anUnscheduledDoseOnADoseDayReplacesThatDose() { + val logs = listOf(extra(at("2026-09-04", "08:00"))) + assertEquals(listOf("2026-09-01", "2026-09-07", "2026-09-10"), dates(every3(), logs, to = "2026-09-11")) + } + + @Test + fun unscheduledDosesOfOtherCompoundsBeforeTheAnchorOrWithTheSwitchOffDoNotMoveThePlan() { + val plain = dates(every3(false), emptyList()) + assertEquals(plain, dates(every3(), listOf(extra(at("2026-09-02", "21:00"), compoundId = "other")))) + assertEquals(plain, dates(every3(false), listOf(extra(at("2026-09-02", "21:00"))))) + val moved = item(Schedule.EveryNDays(3, day("2026-09-10"), any)) + assertEquals(listOf("2026-09-10", "2026-09-13", "2026-09-16", "2026-09-19"), dates(moved, listOf(extra(at("2026-09-05", "10:00"))))) + } + + @Test + fun anUnscheduledDoseTheEveningBeforeMovesTodaysDoseAndItsReminder() { + // hCG every other day and a weekly peptide, both due Sunday at noon; both taken as extras on Saturday night. + val noon = listOf(Timing.At(LocalTime.NOON)) + val eod = item(Schedule.EveryNDays(2, day("2026-10-23"), noon), id = "hcg") + val weekly = PlanItem("reta", null, "r", Amount(2.0, DoseUnit.MG), schedule = Schedule.EveryNDays(7, day("2026-10-18"), noon)) + val logs = listOf(extra(at("2026-10-24", "21:26")), extra(at("2026-10-24", "21:26"), compoundId = "r")) + val anchors = IntervalAnchors.from(logs) + val night = SlotTimes(dayStart = LocalTime.of(4, 0)) + val agenda = buildAgenda(emptyList(), listOf(eod, weekly), logs, at("2026-10-25", "09:00"), zone, anchors, night) + assertTrue(agenda.groups.isEmpty()) + val next = occurrences(emptyList(), listOf(eod, weekly), at("2026-10-25"), at("2026-11-02"), zone, anchors, night) + assertEquals(listOf("hcg@2026-10-26", "hcg@2026-10-28", "hcg@2026-10-30", "reta@2026-10-31", "hcg@2026-11-01"), next.map { "${it.item.id}@${it.localDate}" }) + } + + @Test + fun everyHoursRestartsFromAnUnscheduledDose() { + val anchor = at("2026-09-01", "10:00") + val s = item(Schedule.EveryHours(84.0, anchor)) + val takenAt = anchor.plus(Duration.ofHours(30)).plusSeconds(42) + val occ = occurrences(emptyList(), listOf(s), at("2026-09-01"), at("2026-09-10"), zone, IntervalAnchors.from(listOf(extra(takenAt)))).map { it.at } + val restart = takenAt.truncatedTo(java.time.temporal.ChronoUnit.MINUTES) + assertEquals(listOf(anchor, restart.plus(Duration.ofHours(84)), restart.plus(Duration.ofHours(168))), occ) + } + @Test fun earlyDoseKeepsItsOwnDoseAndCountsFromTheDayTaken() { val logs = listOf(log(slotKey("2026-09-04"), at("2026-09-03", "20:00")))