hydration: spoken 'hydrate now' alert, per-activity sweat, drink reconciliation

- signal is now a loud TTS 'hydrate now' on the ALARM stream (TTS warmed at
  session start), so it is unmistakably BalDav and audible when the phone is not
  in view (walking/VR); the old ringtone was indistinguishable from a system alarm.
- sweat need scales by activity instead of a flat 600 ml/h: bike 600, walk 200,
  run 450, workout/vr 250 ml/h. Walking no longer nags every 15 min.
- deficit split into baseDeficit (seeded from the food log) + accrued sweat;
  logging a drink mid-session recomputes the base and re-seeds via seedSeq, so it
  silences the reminder. seedSeq=0 (profile save / init) never zeroes it mid-ride.
- persistent 'БалДав: попей воды (HH:MM)' notification left as a record.
This commit is contained in:
4aiman 2026-10-27 21:47:10 +03:00
commit 1101016ef4
3 changed files with 100 additions and 40 deletions

View file

@ -1364,6 +1364,7 @@ function showFoodModal() {
amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10; amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10;
} }
await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount); await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount);
hydrationOnDrink(); // if it was liquid, quiet the reminder once you've drunk
} else { } else {
let desc = overlay.querySelector("#m-desc").value.trim(); let desc = overlay.querySelector("#m-desc").value.trim();
if (!desc) return false; if (!desc) return false;
@ -1850,6 +1851,7 @@ async function renderProducts(container) {
let note = overlay.querySelector("#m-note").value.trim() || null; let note = overlay.querySelector("#m-note").value.trim() || null;
if (isNaN(amount) || amount <= 0) return false; if (isNaN(amount) || amount <= 0) return false;
await DB.addFoodEntry(state.date, time, productId, amount, note); await DB.addFoodEntry(state.date, time, productId, amount, note);
hydrationOnDrink(); // quiet the drink reminder if this was liquid
}); });
} }
@ -2824,23 +2826,46 @@ let gpsMarker = null;
// At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water // At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water
// you are for the time of day (need-so-far minus logged intake). During the session the native // you are for the time of day (need-so-far minus logged intake). During the session the native
// service adds sweat on top. Intake comes from what you log, not a button. // service adds sweat on top. Intake comes from what you log, not a button.
async function seedHydrationDeficit() { // Sweat (extra water loss) per activity — cycling sweats far more than a walk, so a walk must
// NOT accrue the ride rate (that made it falsely hit the threshold after ~1 h of walking).
let HYDR_SWEAT = { bike: 600, walk: 200, run: 450, workout: 250, vr: 250 };
function hydrSweatFor(type) { return HYDR_SWEAT[type] || 600; }
// Compute the current sedentary shortfall (need-so-far for the time of day minus what you've
// logged drinking today). Shared by the session seed and the on-drink re-seed.
async function hydrBaseDeficit() {
let target = parseInt(state.profile?.daily_water_ml) || 2000;
let now = new Date();
let nowMin = now.getHours() * 60 + now.getMinutes();
let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00
let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart)));
let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10));
return Math.max(0, Math.min(5000, Math.round(target * frac - intake)));
}
async function seedHydrationDeficit(activityType) {
if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off
try { try {
let target = parseInt(state.profile?.daily_water_ml) || 2000; let startDeficit = await hydrBaseDeficit();
let now = new Date();
let nowMin = now.getHours() * 60 + now.getMinutes();
let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00
let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart)));
let needSoFar = target * frac;
let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10));
let startDeficit = Math.max(0, Math.min(5000, Math.round(needSoFar - intake)));
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
await window.__TAURI__.core.invoke("set_hydration_config", await window.__TAURI__.core.invoke("set_hydration_config",
{ enabled: true, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: startDeficit }); { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(activityType), reAlertMin: 15, startDeficitMl: startDeficit, seedSeq: Date.now() });
} catch (e) { console.error("seedHydration", e); } } catch (e) { console.error("seedHydration", e); }
} }
// After you log a drink DURING a session: recompute the base deficit (now lower) and push it with
// a fresh seedSeq so the native side drops baseDeficit — the reminder goes quiet once you've drunk.
async function hydrationOnDrink() {
try {
if (!(GPS.recording && GPS.recording.active)) return; // only matters during a session
if (localStorage.getItem("baldav_hydr_on") === "0") return;
let base = await hydrBaseDeficit();
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
await window.__TAURI__.core.invoke("set_hydration_config",
{ enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(GPS.recording.activityType), reAlertMin: 15, startDeficitMl: base, seedSeq: Date.now() });
} catch (e) {}
}
async function renderGpsScreen(container) { async function renderGpsScreen(container) {
// Check Bluetooth state if BLE device is paired // Check Bluetooth state if BLE device is paired
let bleAddr = await DB.getProfileValue("ble_device_addr"); let bleAddr = await DB.getProfileValue("ble_device_addr");
@ -2955,7 +2980,7 @@ async function renderGpsScreen(container) {
heightCm: parseFloat(state.profile?.height_cm) || 178, heightCm: parseFloat(state.profile?.height_cm) || 178,
} : null; } : null;
await GPS.startRecording(type, wkg, hrProfile); await GPS.startRecording(type, wkg, hrProfile);
seedHydrationDeficit(); seedHydrationDeficit(type);
renderScreen("recording"); renderScreen("recording");
} }
}; };

View file

@ -12,8 +12,10 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.speech.tts.TextToSpeech
import android.util.Log import android.util.Log
import java.io.File import java.io.File
import java.util.Locale
import org.json.JSONObject import org.json.JSONObject
// Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based, // Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based,
@ -25,18 +27,23 @@ import org.json.JSONObject
object Hydration { object Hydration {
private const val TAG = "Hydration" private const val TAG = "Hydration"
private const val CH = "baldav_hydration" private const val CH = "baldav_hydration"
private var accruedMl = 0.0 // running water deficit: start-of-session shortfall + sweat private var baseDeficit = 0.0 // sedentary shortfall at (re)seed: need-so-far minus intake
private var startDeficitMl = 0.0 // how far behind you were at session start (from the food log) private var sweatMl = 0.0 // activity sweat accrued this session (grows with the session)
private var pendingStart = false // apply startDeficitMl on the first tick after reset() private var startDeficitMl = 0.0 // last seeded base from the food log; applied on (re)seed
private var seedSeq = 0L // JS bumps it on (re)seed (e.g. after you log a drink)
private var lastSeedSeq = -1L // a change re-applies startDeficitMl to baseDeficit mid-session
private var pendingStart = false // apply the seed on the first tick after reset()
private var lastTickMs = 0L private var lastTickMs = 0L
private var lastBeepMs = 0L private var lastBeepMs = 0L
private var lastCfgMs = 0L private var lastCfgMs = 0L
private var enabled = true // ON by default — works out of the box, no toggle to find private var enabled = true // ON by default — works out of the box, no toggle to find
private var sweatMlPerMin = 10.0 // need rate during an active session (600 ml/h default) private var sweatMlPerMin = 10.0 // need rate during an active session (set per activity by JS)
private var alertMl = 600.0 // beep once the deficit reaches this private var alertMl = 600.0 // beep once the deficit reaches this
private var reAlertMs = 15 * 60 * 1000L private var reAlertMs = 15 * 60 * 1000L
private var lastLogMs = 0L private var lastLogMs = 0L
private var savedAlarmVol = -1 // alarm volume to restore after an alert private var savedAlarmVol = -1 // alarm volume to restore after an alert
private var tts: TextToSpeech? = null
private var ttsReady = false // the signal is a SPOKEN "hydrate now" — unmistakably BalDav
private fun readCfg(ctx: Context) { private fun readCfg(ctx: Context) {
try { try {
@ -48,6 +55,11 @@ object Hydration {
alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble() alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble()
reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L
startDeficitMl = j.optInt("startDeficitMl", 0).coerceIn(0, 5000).toDouble() startDeficitMl = j.optInt("startDeficitMl", 0).coerceIn(0, 5000).toDouble()
seedSeq = j.optLong("seedSeq", 0L)
// a NON-ZERO new seedSeq means JS recomputed the base (session start / after a drink)
// -> re-apply it to baseDeficit mid-session; sweat keeps accruing. seedSeq=0 (profile
// save, init) must NOT zero the deficit mid-ride, so it's ignored here.
if (seedSeq != 0L && seedSeq != lastSeedSeq) { baseDeficit = startDeficitMl; lastSeedSeq = seedSeq }
} else enabled = true // no config yet -> ON by default } else enabled = true // no config yet -> ON by default
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true } } catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true }
} }
@ -63,14 +75,15 @@ object Hydration {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now } if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now }
if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return } if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return }
if (pendingStart) { accruedMl = startDeficitMl; pendingStart = false } if (pendingStart) { baseDeficit = startDeficitMl; sweatMl = 0.0; lastSeedSeq = seedSeq; pendingStart = false }
if (lastTickMs == 0L) { lastTickMs = now; logState(ctx, "start"); lastLogMs = now; return } if (lastTickMs == 0L) { lastTickMs = now; ensureTts(ctx); logState(ctx, "start"); lastLogMs = now; return }
val dtMin = (now - lastTickMs) / 60_000.0 val dtMin = (now - lastTickMs) / 60_000.0
lastTickMs = now lastTickMs = now
accruedMl += sweatMlPerMin * dtMin sweatMl += sweatMlPerMin * dtMin
if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) { val deficit = baseDeficit + sweatMl
if (deficit >= alertMl && now - lastBeepMs >= reAlertMs) {
lastBeepMs = now lastBeepMs = now
val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, accruedMl.toInt()) val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, deficit.toInt())
logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now
} else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now } } else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now }
} }
@ -80,8 +93,8 @@ object Hydration {
try { try {
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration_log.jsonl") val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration_log.jsonl")
f.appendText("{\"ts\":${System.currentTimeMillis() / 1000},\"ev\":\"$event\"," + f.appendText("{\"ts\":${System.currentTimeMillis() / 1000},\"ev\":\"$event\"," +
"\"enabled\":$enabled,\"accruedMl\":${accruedMl.toInt()},\"alertMl\":${alertMl.toInt()}," + "\"enabled\":$enabled,\"accruedMl\":${(baseDeficit + sweatMl).toInt()},\"sweatMl\":${sweatMl.toInt()}," +
"\"startMl\":${startDeficitMl.toInt()}}\n") "\"baseMl\":${baseDeficit.toInt()},\"alertMl\":${alertMl.toInt()},\"startMl\":${startDeficitMl.toInt()}}\n")
} catch (e: Exception) {} } catch (e: Exception) {}
} }
@ -93,24 +106,43 @@ object Hydration {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (savedAlarmVol < 0) savedAlarmVol = am.getStreamVolume(AudioManager.STREAM_ALARM) if (savedAlarmVol < 0) savedAlarmVol = am.getStreamVolume(AudioManager.STREAM_ALARM)
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0) am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
val uri = RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_ALARM) if (tts == null) {
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) tts = TextToSpeech(ctx.applicationContext) { status ->
val mp = MediaPlayer() ttsReady = status == TextToSpeech.SUCCESS
mp.setAudioAttributes(AudioAttributes.Builder() if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {}; speakNow() }
.setUsage(AudioAttributes.USAGE_ALARM) }
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build()) } else if (ttsReady) speakNow()
mp.setDataSource(ctx, uri) // restore the alarm volume a few seconds after the phrase
mp.isLooping = true
mp.prepare()
mp.start()
handler.postDelayed({ handler.postDelayed({
try { mp.stop(); mp.release() } catch (e: Exception) {}
try { if (savedAlarmVol >= 0) { am.setStreamVolume(AudioManager.STREAM_ALARM, savedAlarmVol, 0); savedAlarmVol = -1 } } catch (e: Exception) {} try { if (savedAlarmVol >= 0) { am.setStreamVolume(AudioManager.STREAM_ALARM, savedAlarmVol, 0); savedAlarmVol = -1 } } catch (e: Exception) {}
}, 4000) }, 5000)
true true
} catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false } } catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false }
} }
// Warm the TTS engine up at session start so the first alert isn't silent (init is async).
private fun ensureTts(ctx: Context) {
if (tts != null) return
try {
tts = TextToSpeech(ctx.applicationContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {} }
}
} catch (e: Exception) { Log.e(TAG, "tts init: ${e.message}") }
}
// Speak the phrase over the ALARM stream so it's loud even with media muted.
private fun speakNow() {
try {
val t = tts ?: return
t.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
t.speak("hydrate now", TextToSpeech.QUEUE_FLUSH, null, "hyd1")
t.speak("hydrate now", TextToSpeech.QUEUE_ADD, null, "hyd2")
} catch (e: Exception) { Log.e(TAG, "speak: ${e.message}") }
}
private fun vibrate(ctx: Context) { private fun vibrate(ctx: Context) {
try { try {
val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return
@ -127,11 +159,12 @@ object Hydration {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) {
nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH)) nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH))
} }
val time = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date())
val n = Notification.Builder(ctx, CH) val n = Notification.Builder(ctx, CH)
.setContentTitle("Попей воды!") .setContentTitle("БалДав: попей воды")
.setContentText("Отстаёшь примерно на $deficitMl мл — попей") .setContentText("$time — отстаёшь примерно на $deficitMl мл")
.setSmallIcon(android.R.drawable.ic_menu_compass) .setSmallIcon(android.R.drawable.ic_menu_compass)
.setAutoCancel(true) .setAutoCancel(true) // stays in the shade as a record until you tap it
.build() .build()
nm.notify(776601, n) nm.notify(776601, n)
} catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") } } catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") }

View file

@ -550,10 +550,12 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
// start_deficit_ml: how far behind on water you were at session start (from logged intake vs // start_deficit_ml: how far behind on water you were at session start (from logged intake vs
// need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0. // need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0.
#[tauri::command] #[tauri::command]
fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32) -> Result<String, String> { fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32, seed_seq: Option<u64>) -> Result<String, String> {
// seed_seq: bump it (e.g. a timestamp) whenever the base deficit is recomputed mid-session
// (after logging a drink) so the native side re-applies start_deficit_ml to its baseDeficit.
let json = format!( let json = format!(
"{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{}}}", "{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{},\"seedSeq\":{}}}",
enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000) enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000), seed_seq.unwrap_or(0)
); );
write_app_root_file(&app, "hydration.json", &json); write_app_root_file(&app, "hydration.json", &json);
Ok(json) Ok(json)