package com.vgmlr.kiln
import java.time.LocalDate
import java.time.YearMonth
import java.time.temporal.ChronoUnit
import java.time.DayOfWeek
import java.time.temporal.TemporalAdjusters
import kotlin.math.roundToInt
private data class MoodTally(
val top: Int?,
val topCount: Int,
val second: Int?,
val secondCount: Int,
val total: Int
)
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?
) {
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?
): 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,
intermissionAvg = KilnCyclePredictor.avgCycleExact(starts),
preMenstrualAvg = preMenstrualAvg,
menstruationAvg = KilnCyclePredictor.avgPeriodLength(records, buttons)
)
}
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, 0, 1, 2)
fun moodSeries(records: List<KilnDayRecord>): List<Pair<LocalDate, Int>> =
records.mapNotNull { r ->
r.mood?.let { LocalDate.ofEpochDay(r.epochDay) to it }
}
.groupBy { (d, _) -> d.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) }
.map { (week, days) -> week to score(days.map { it.second }) }
.sortedBy { it.first }
private fun score(moods: List<Int>): Int =
moods.map { MOOD_VALUE.getOrElse(it) { 0 } }.average().roundToInt()
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) }
val last = starts.last()
val gap = ChronoUnit.DAYS.between(last, date)
val k = (gap + cycle - 1) / cycle
return ChronoUnit.DAYS.between(date, last.plusDays(k * cycle))
}
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)))
for (i in 0L..scanSpan(cycle)) {
val day = last.plusDays(i)
val now = KilnGradientPalette.isPreMenstrual(hueOf(day))
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).coerceAtLeast(0L)
}
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)
}
}
}