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 @@ -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
Expand Down Expand Up @@ -57,13 +58,15 @@ class TrackerRepository(

val protocol: Flow<Protocol> = 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<IntervalAnchors> = db.logs().observeTaken().map(::toAnchors)

suspend fun anchorsNow(): IntervalAnchors = toAnchors(db.logs().getTaken())

private fun toAnchors(rows: List<TakenDoseRow>) =
IntervalAnchors(rows.map { TakenDose(it.planItemId, it.occurrenceKey, Instant.ofEpochMilli(it.takenAtMs)) })
private fun toAnchors(rows: List<TakenDoseRow>) = 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<List<DoseLog>> =
db.logs().observeSince(from.toEpochMilli()).map { list -> list.map { it.toDomain() } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<List<TakenDoseRow>>

@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<TakenDoseRow>

@Query("SELECT * FROM dose_logs WHERE planItemId = :planItemId")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -231,15 +232,16 @@ 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)
repo.logOccurrence(occs[1], LogStatus.SKIPPED)
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TakenDose>) {
class IntervalAnchors(doses: Collection<TakenDose>, extras: Collection<ExtraDose> = emptyList()) {
private val byItem: Map<String, List<TakenDose>> = doses.groupBy { it.planItemId }
private val extrasByCompound: Map<String, List<Instant>> = extras.groupBy({ it.compoundId }, { it.takenAt })

fun forItem(itemId: String): List<TakenDose> = byItem[itemId].orEmpty()

val isEmpty: Boolean get() = byItem.isEmpty()
fun extrasFor(compoundId: String): List<Instant> = 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<DoseLog>) = 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<DoseLog>): 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) },
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Phase>,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -268,9 +269,9 @@ private class Restarts<T>(val breaks: List<T>, val pinned: Collection<T>)
/**
* 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<TakenDose>, itemId: String, anchor: LocalDate, zone: ZoneId, slotTimes: SlotTimes): Restarts<LocalDate> {
private fun dayRestarts(doses: List<TakenDose>, extras: List<Instant>, itemId: String, anchor: LocalDate, zone: ZoneId, slotTimes: SlotTimes): Restarts<LocalDate> {
val firstTakenByDay = HashMap<LocalDate, Instant>()
for (dose in doses) {
val day = when (val ref = parseOccurrenceKey(dose.occurrenceKey)?.takeIf { it.itemId == itemId }) {
Expand All @@ -282,12 +283,13 @@ private fun dayRestarts(doses: List<TakenDose>, 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<TakenDose>, itemId: String, anchor: Instant): Restarts<Instant> {
/** Every-X-hours restarts: each taken dose, scheduled or not, restarts the grid at the minute it was taken. */
private fun intervalRestarts(doses: List<TakenDose>, extras: List<Instant>, itemId: String, anchor: Instant): Restarts<Instant> {
val pinned = HashSet<Instant>()
val breaks = HashSet<Instant>()
for (dose in doses) {
Expand All @@ -296,6 +298,7 @@ private fun intervalRestarts(doses: List<TakenDose>, 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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DoseLog>, 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() }

Expand Down Expand Up @@ -64,14 +68,60 @@ 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),
)
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")))
Expand Down
Loading