package com.vgmlr.kiln
import java.time.LocalDate
import java.time.YearMonth
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
private data class MoodTally(
val top: Int?,
val topCount: Int,
val second: Int?,
val secondCount: Int,
val total: Int,
val positive: Int = 0
)
private const val HAPPY = 2
internal fun moodBalanceText(positive: Int, total: Int): String? {
if (total == 0) return null
val negative = total - positive
return when {
positive > negative -> "${(positive * 100f / total).roundToInt()}% Positive"
negative > positive -> "${(negative * 100f / total).roundToInt()}% Negative"
else -> "Balanced"
}
}
data class KilnDayStats(
val daysToPeriod: Long?,
val daysToPreMenstrual: Long?,
val topMood: Int?,
val topMoodCount: Int,
val secondMood: Int?,
val secondMoodCount: Int,
val moodDays: Int,
val intermissionAvg: Float,
val preMenstrualAvg: Float?,
val menstruationAvg: Float?,
val intermissionLast: Int? = null,
val preMenstrualLast: Int? = null,
val menstruationLast: Int? = null,
val moodPositive: Int = 0
) {
companion object {
private fun scanSpan(cycle: Int) = cycle.toLong() + 1
fun of(
date: LocalDate,
today: LocalDate,
records: List<KilnDayRecord>,
buttons: KilnButtonSet,
starts: List<LocalDate>,
cycle: Int,
hueOf: (LocalDate) -> Float,
preMenstrualAvg: Float?,
preMenstrualLast: Int? = null
): KilnDayStats {
val moods = moodTally(records, YearMonth.from(date))
return KilnDayStats(
daysToPeriod = daysToPeriod(today, starts, cycle),
daysToPreMenstrual = preMenstrualIn(today, starts, cycle, hueOf),
topMood = moods.top,
topMoodCount = moods.topCount,
secondMood = moods.second,
secondMoodCount = moods.secondCount,
moodDays = moods.total,
moodPositive = moods.positive,
intermissionAvg = KilnCyclePredictor.avgCycleExact(starts),
preMenstrualAvg = preMenstrualAvg,
menstruationAvg = KilnCyclePredictor.avgPeriodLength(records, buttons),
intermissionLast = KilnCyclePredictor.cycleSeries(starts).lastOrNull()?.second,
preMenstrualLast = preMenstrualLast,
menstruationLast = lastClosedRun(records, buttons, today)
)
}
private fun lastClosedRun(
records: List<KilnDayRecord>,
buttons: KilnButtonSet,
today: LocalDate
): Int? = KilnCyclePredictor.periodRuns(records, buttons).lastOrNull { (s, n) ->
ChronoUnit.DAYS.between(s.plusDays(n - 1L), today) > KilnCyclePredictor.RUN_GAP
}?.second
fun preMenstrualSeries(
starts: List<LocalDate>,
cycle: Int,
hueOf: (LocalDate) -> Float
): List<Pair<LocalDate, Int>> =
KilnCyclePredictor.measurableStarts(starts)
.mapNotNull { s -> leadInto(s, cycle, hueOf)?.let { s to it.toInt() } }
fun preMenstrualAvg(
starts: List<LocalDate>,
cycle: Int,
hueOf: (LocalDate) -> Float
): Float? {
val leads = preMenstrualSeries(starts, cycle, hueOf).map { it.second }
return if (leads.isEmpty()) null else leads.average().toFloat()
}
private fun leadInto(start: LocalDate, cycle: Int, hueOf: (LocalDate) -> Float): Long? {
if (!KilnGradientPalette.isPreMenstrual(hueOf(start))) return null
for (i in 1L..scanSpan(cycle)) {
if (!KilnGradientPalette.isPreMenstrual(hueOf(start.minusDays(i)))) return i - 1
}
return null
}
private val MOOD_VALUE = intArrayOf(-2, -1, 1, -1, -2)
fun moodBalance(records: List<KilnDayRecord>): Pair<Int, Int> {
val moods = records.mapNotNull { it.mood }.filter { it in MOOD_VALUE.indices }
return moods.count { it == HAPPY } to moods.size
}
fun moodSeries(records: List<KilnDayRecord>): List<Pair<LocalDate, Int>> =
records.mapNotNull { r ->
r.mood?.let { m ->
LocalDate.ofEpochDay(r.epochDay) to MOOD_VALUE.getOrElse(m) { 0 }
}
}
.sortedBy { it.first }
fun daysToPeriod(date: LocalDate, starts: List<LocalDate>, cycle: Int): Long? {
if (starts.isEmpty()) return null
starts.firstOrNull { !it.isBefore(date) }
?.let { return ChronoUnit.DAYS.between(date, it) }
return ChronoUnit.DAYS.between(date, starts.last().plusDays(cycle.toLong()))
}
fun preMenstrualOnset(
starts: List<LocalDate>,
cycle: Int,
hueOf: (LocalDate) -> Float
): LocalDate? {
val last = starts.lastOrNull() ?: return null
var prev = KilnGradientPalette.isPreMenstrual(hueOf(last.minusDays(1)))
var inCurrent = KilnGradientPalette.isPreMenstrual(hueOf(last))
for (i in 0L..scanSpan(cycle)) {
val day = last.plusDays(i)
val now = KilnGradientPalette.isPreMenstrual(hueOf(day))
if (inCurrent) {
if (!now) inCurrent = false
} else if (now && !prev) return day
prev = now
}
return null
}
fun preMenstrualIn(
date: LocalDate,
starts: List<LocalDate>,
cycle: Int,
hueOf: (LocalDate) -> Float
): Long? {
val onset = preMenstrualOnset(starts, cycle, hueOf) ?: return null
return ChronoUnit.DAYS.between(date, onset)
}
fun preMenstrualIn(
date: LocalDate,
records: List<KilnDayRecord>,
buttons: KilnButtonSet
): Long? {
val starts = KilnCyclePredictor.periodStarts(records, buttons)
if (starts.isEmpty()) return null
val cycle = KilnCyclePredictor.avgCycleLength(starts)
return preMenstrualIn(
date, starts, cycle,
KilnGradientPalette.lookup(records, buttons, starts, cycle)
)
}
private fun moodTally(records: List<KilnDayRecord>, month: YearMonth): MoodTally {
val counts = IntArray(5)
var total = 0
for (r in records) {
val m = r.mood ?: continue
if (m !in counts.indices) continue
if (YearMonth.from(LocalDate.ofEpochDay(r.epochDay)) != month) continue
counts[m]++
total++
}
if (total == 0) return MoodTally(null, 0, null, 0, 0)
val ranked = counts.indices.filter { counts[it] > 0 }
.sortedByDescending { counts[it] }
val top = ranked[0]
val second = ranked.getOrNull(1)
return MoodTally(
top, counts[top], second, second?.let { counts[it] } ?: 0, total, counts[HAPPY]
)
}
}
}