BangleApps/apps/crsclock/crsclock.js

805 lines
21 KiB
JavaScript

// --- Circadian Wellness Clock for Bangle.js 2 ---
// File: crsclock.js
const STORE_KEY = "crswellness";
const STEPS_KEY = "w_steps";
const HR_KEY = "w_hr";
const LIGHT_KEY = "w_light";
const SLEEP_START_KEY = "w_sleepstart";
const VERSION = "1.2";
const storage = require("Storage");
const MAX_HISTORY_ENTRIES = 200;
const TREND_HOURS = 12;
const WRITE_DELAY = 5 * 60 * 1000; // delay before persisting history
let writeTimers = {};
function scheduleHistoryWrite(key, data) {
if (writeTimers[key]) clearTimeout(writeTimers[key]);
writeTimers[key] = setTimeout(() => {
storage.writeJSON(key, data);
writeTimers[key] = undefined;
}, WRITE_DELAY);
}
// 1. PERSISTENT SETTINGS & HISTORICAL DATA
let S = storage.readJSON(STORE_KEY, 1) || {
phaseOffset: 0,
hydrationInterval: 2 * 3600*1000,
lastHydration: Date.now(),
sleepStart: 23,
sleepEnd: 7,
colorTheme: 0,
notifications: {
hydration: true,
sleepDetection: true,
hrLogging: true,
hrPower: true
},
bioTimeRefType: "DLMO",
bioTimeRefHour: 21,
bioTimeRefMinute: 0
};
function pruneHistory(arr, maxHours) {
let cutoff = Date.now() - maxHours * 3600*1000;
while (arr.length && arr[0].t < cutoff) arr.shift();
while (arr.length > MAX_HISTORY_ENTRIES) arr.shift();
}
let stepsHist = storage.readJSON(STEPS_KEY, 1) || [];
let prevStepCount = 0;
let stepResetOffset = 0;
for (let e of stepsHist) {
if (e.s < prevStepCount) stepResetOffset += prevStepCount;
e.c = e.s + stepResetOffset;
prevStepCount = e.s;
}
let hrHist = storage.readJSON(HR_KEY, 1) || [];
let lightHist = storage.readJSON(LIGHT_KEY, 1) || [];
let sleepStartHist = storage.readJSON(SLEEP_START_KEY, 1) || [];
pruneHistory(stepsHist, 24);
pruneHistory(hrHist, 24);
pruneHistory(lightHist, 24);
pruneHistory(sleepStartHist, 24*7);
// 2. COLOR THEMES
const themes = [
{ bg:"#004080", fg:"#ffffff", accent:"#00e0e0" },
{ bg:"#105c30", fg:"#ffffff", accent:"#a0ff00" },
{ bg:"#20222a", fg:"#f5f5f5", accent:"#76d6ff" }
];
let t = themes[S.colorTheme];
// Default font for stats displayed on the clock
const STATS_FONT_NAME = "Vector";
const STATS_FONT_SIZE = 16;
// 3. HELPERS & CALCULATIONS
function getSleepWindowStr() {
let a = ("0"+S.sleepStart).substr(-2) + ":00";
let b = ("0"+S.sleepEnd).substr(-2) + ":00";
return a + "-" + b;
}
function stability(arr, key, hours) {
let cutoff = Date.now() - hours * 3600*1000;
let vals = arr.filter(e => e.t > cutoff && typeof e[key] === "number")
.map(e => e[key]);
if (!vals.length) return 0;
let sum = vals.reduce((a,b)=>a+b, 0);
let mean = sum / vals.length;
let sd = Math.sqrt(vals.reduce((a,b)=>a + (b-mean)*(b-mean), 0) / vals.length);
let cv = mean ? sd/mean : 1;
let idx = 100 - cv*100;
return Math.max(0, Math.min(100, idx));
}
function computeCrs(offsetMs) {
let now = Date.now() - (offsetMs || 0);
let sArr = stepsHist.filter(e => e.t <= now && e.t > now - 24*3600*1000);
let hArr = hrHist.filter(e => e.t <= now && e.t > now - 24*3600*1000);
let parts = [];
if (sArr.length) parts.push(stability(sArr, "c", 24));
if (hArr.length) parts.push(stability(hArr, "bpm", 24));
if (sleepStartHist.length >= 2) parts.push(sleepTimingScore());
let lh = estimateLightHours();
if (typeof lh === "number" && !isNaN(lh)) {
let lhScore = Math.min(100, Math.max(0, (lh / 12) * 100));
parts.push(lhScore);
}
if (!parts.length) return 0;
let avg = parts.reduce((a,b)=>a+b,0) / parts.length;
return Math.round(avg);
}
let crsByHour = [];
let lastCrsUpdateTime = 0;
function updateCrsCache() {
if (Date.now() - lastCrsUpdateTime < 5*60*1000 && crsByHour.length === TREND_HOURS) return;
crsByHour = [];
for (let i=0; i<TREND_HOURS; i++) {
let off = i * 3600*1000;
crsByHour.unshift( computeCrs(off) );
}
lastCrsUpdateTime = Date.now();
}
function computeBioTime() {
let d = new Date();
let m = d.getHours()*60 + d.getMinutes() + S.phaseOffset*60;
m = ((m % 1440) + 1440) % 1440;
let hh = Math.floor(m/60), mm = m % 60;
return ("0"+hh).substr(-2) + ":" + ("0"+mm).substr(-2);
}
function detectEmotion() {
let cutoff = Date.now() - 60*60*1000;
let vals = hrHist.filter(e => e.t > cutoff).map(e => e.bpm);
if (!vals.length) return 3; // No Data
let mean = vals.reduce((a,b) => a+b, 0) / vals.length;
let sd = Math.sqrt(vals.reduce((a,b) => a + (b-mean)*(b-mean), 0) / vals.length);
if (sd < 2.5) return 0; // Calm
if (sd < 5) return 1; // Neutral
if (sd < 12) return 2; // Stressed
return 3; // No Data/undefined
}
// Estimate daylight exposure in hours based solely on step data
function estimateLightHours() {
let now = Date.now();
let start = now - 24*3600*1000;
function inSleepWindow(h) {
if (S.sleepStart < S.sleepEnd)
return h >= S.sleepStart && h < S.sleepEnd;
return h >= S.sleepStart || h < S.sleepEnd;
}
let hourMap = {};
let count = 0;
for (let e of stepsHist) {
if (e.t < start) continue;
let d = new Date(e.t);
let hr = d.getHours();
if (hr < 7 || hr > 19) continue;
if (inSleepWindow(hr)) continue;
let key = Math.floor(e.t / 3600000);
if (!hourMap[key]) {
hourMap[key] = true;
count++;
}
}
return count;
}
function getStepsLast24h() {
let cutoff = Date.now() - 24*3600*1000;
let arr = stepsHist.filter(e => e.t > cutoff).map(e=>e.c);
return arr.length>1 ? (arr[arr.length-1] - arr[0]) : 0;
}
function sleepTimingScore() {
return stability(sleepStartHist, "h", 7);
}
let saveTimeout = null;
function saveSettings() {
if (saveTimeout) return;
saveTimeout = setTimeout(() => {
saveTimeout = null;
storage.writeJSON(STORE_KEY, S);
}, 2000);
}
// 4. SENSORS & LOGGING (BATTERY-SAFE)
let hrmEnabled = false;
function onHrmSample(hrm) {
if (typeof hrm.bpm === "number" && hrm.bpm > 0 && S.notifications.hrLogging) {
hrHist.push({ t: Date.now(), bpm: hrm.bpm });
pruneHistory(hrHist, 24);
scheduleHistoryWrite(HR_KEY, hrHist);
}
}
function enableHrmIfNeeded() {
if (!hrmEnabled && S.notifications.hrPower && S.notifications.hrLogging) {
hrmEnabled = true;
Bangle.setHRMPower(1);
Bangle.on("HRM", onHrmSample);
}
}
function disableHrm() {
if (hrmEnabled) {
hrmEnabled = false;
Bangle.removeListener("HRM", onHrmSample);
Bangle.setHRMPower(0);
}
}
// Steps
Bangle.on("step",(s) => {
if (s < prevStepCount) stepResetOffset += prevStepCount;
prevStepCount = s;
let c = s + stepResetOffset;
stepsHist.push({ t: Date.now(), s, c });
pruneHistory(stepsHist, 24);
scheduleHistoryWrite(STEPS_KEY, stepsHist);
});
// Light sensor
function sampleLight() {
let env = Bangle.getHealthStatus().light;
if (env !== undefined) {
lightHist.push({ t: Date.now(), light: env });
pruneHistory(lightHist, 24);
scheduleHistoryWrite(LIGHT_KEY, lightHist);
}
}
setInterval(sampleLight, 300*1000);
if (Bangle.isLCDOn()) sampleLight();
// Sleep/Wake Detection
let isSleeping = false, pendingSleepAlert = false, pendingWakeAlert = false;
function checkSleepWake() {
if (!S.notifications.sleepDetection) return;
let now = Date.now();
let windowStart = now - 30*60*1000; // 30 min ago
let arr = stepsHist.filter(e => e.t >= windowStart).map(e => e.c);
let delta = arr.length>1 ? (arr[arr.length-1] - arr[0]) : 0;
let moving = Bangle.isMoving ? Bangle.isMoving() : true;
let asleep = (delta < 5) && !moving;
if (!isSleeping && asleep) {
isSleeping = true;
S.sleepStart = (new Date()).getHours();
sleepStartHist.push({ t: now, h: S.sleepStart });
pruneHistory(sleepStartHist, 24*7);
scheduleHistoryWrite(SLEEP_START_KEY, sleepStartHist);
pendingSleepAlert = true;
saveSettings();
} else if (isSleeping && !asleep) {
isSleeping = false;
S.sleepEnd = (new Date()).getHours();
pendingWakeAlert = true;
saveSettings();
}
}
setInterval(() => {
if (Bangle.isLCDOn()) checkSleepWake();
}, 15*60*1000);
// Hydration Reminder
let pendingHydrationAlert = false;
function remindHydration() {
if (!S.notifications.hydration) return;
Bangle.buzz();
pendingHydrationAlert = true;
S.lastHydration = Date.now();
saveSettings();
}
setInterval(() => {
if (Date.now() - S.lastHydration >= S.hydrationInterval) {
remindHydration();
}
}, 60*1000);
// LCD events
Bangle.on("lcdPower",(on) => {
if (on) {
enableHrmIfNeeded();
drawClock();
} else {
disableHrm();
}
});
if (Bangle.isLCDOn()) enableHrmIfNeeded();
// ----- DISPLAY HELPERS -----
function drawStat(text, y, g) {
const left = 5;
g.drawString(text, left, y);
}
// 5. DRAW CLOCK SCREEN
function drawClock() {
g.clear();
Bangle.drawWidgets();
let w = g.getWidth(), h = g.getHeight();
// Alerts
if (pendingHydrationAlert) {
pendingHydrationAlert = false;
E.showAlert("Time to hydrate!").then(() => {
drawClock(); Bangle.setUI(uiOpts);
}); return;
}
if (pendingSleepAlert) {
pendingSleepAlert = false;
E.showAlert("Sleep detected at " + computeBioTime()).then(() => {
drawClock(); Bangle.setUI(uiOpts);
}); return;
}
if (pendingWakeAlert) {
pendingWakeAlert = false;
E.showAlert("Wake detected at " + computeBioTime()).then(() => {
drawClock(); Bangle.setUI(uiOpts);
}); return;
}
// ----- HEADER STRIP -----
let headerH = 44;
g.setColor(t.bg);
g.fillRect(0, 24, w, 24 + headerH);
// ----- TIME -----
g.setFont("Vector", 32);
let bt = computeBioTime();
let tw = g.stringWidth(bt);
let xPos = (w - tw) / 2;
let yPos = 24 + (headerH - g.getFontHeight()) / 2;
// Shadow (black, offset)
g.setColor("#000");
g.drawString(bt, xPos + 2, yPos + 2);
// Main time
g.setColor(t.fg);
g.drawString(bt, xPos, yPos);
// ----- STATS -----
let statsY = 24 + headerH + 8;
let crVal = computeCrs(0);
let cr = (isNaN(crVal) || crVal === null || typeof crVal === "undefined") ? "N/A" : crVal;
let stVal = getStepsLast24h();
if (stVal <= 0 || isNaN(stVal)) stVal = null;
let emo = detectEmotion();
let lightHoursVal = estimateLightHours();
let lightHours = (isNaN(lightHoursVal) ? "N/A" : lightHoursVal + "h");
let moodText = ["Calm","Neutral","Stressed","N/A"];
g.setFont(STATS_FONT_NAME, STATS_FONT_SIZE);
g.setColor("#000");
let y = statsY;
drawStat("CRS: " + cr, y, g); y += 18;
if (stVal !== null) { drawStat("Steps: " + stVal, y, g); y += 18; }
drawStat("Mood: " + moodText[emo], y, g); y += 18;
drawStat("Light: " + lightHours, y, g);
// ----- Footer & menu hint -----
g.drawLine(0, h-20, w, h-20);
g.setFont("6x8", 1);
g.setColor("#000");
g.drawString("Menu: btn1/tap/swipe", 10, h-18);
}
// 6. VIEW TREND CHART
function showTrend() {
let w = g.getWidth(), h = g.getHeight();
g.setColor(t.bg); g.fillRect(0, 24, w, h);
g.setFont("Vector", 20);
let title = "CRS Trend (" + TREND_HOURS + "h)";
let tw = g.stringWidth(title);
g.setColor(t.fg);
g.drawString(title, (w - tw)/2, 28);
if (!stepsHist.length && !hrHist.length) {
g.setFont("6x8", 2);
let msg = "No data to show";
let mw = g.stringWidth(msg);
g.drawString(msg, (w - mw)/2, h/2);
g.setFont("6x8", 1);
g.drawString("Tap to return", 10, h-12);
Bangle.setUI(uiOpts);
Bangle.on('touch', function tapCB() {
Bangle.removeListener('touch', tapCB);
drawClock();
Bangle.setUI(uiOpts);
});
return;
}
updateCrsCache();
let i = 0;
function drawNextBar() {
if (i >= TREND_HOURS) {
g.setColor(t.fg);
g.setFont("6x8", 1);
g.drawString("Tap to return", 10, h-12);
Bangle.setUI(uiOpts);
Bangle.on('touch', function tapCB() {
Bangle.removeListener('touch', tapCB);
drawClock();
Bangle.setUI(uiOpts);
});
return;
}
let barW = Math.floor(w / TREND_HOURS);
let val = crsByHour[i];
let barH = Math.round((val/100) * (h - 60));
let x = i * barW;
let y = h - barH - 24;
g.setColor(t.accent);
g.fillRect(x, y, x + barW - 2, h - 24);
i++;
setTimeout(drawNextBar, 0);
}
drawNextBar();
}
// 7. MENU & SETTINGS (no change to your logic)
function showStats() {
let bt = computeBioTime(),
cr = computeCrs(0),
st = getStepsLast24h(),
emo = detectEmotion(),
lightH = estimateLightHours(),
moodText = ["Calm","Neutral","Stressed","N/A"];
E.showAlert(
"BioTime: " + bt + "\n" +
"CRS: " + cr + "\n" +
"Steps: " + st + "\n" +
"Mood: " + moodText[emo] + "\n" +
"Light: " + lightH + "h",
"Today's Stats"
).then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
}
function exportData() {
let exportObj = {
timestamp: new Date().toISOString(),
steps: stepsHist,
hr: hrHist,
light: lightHist,
sleepStart: sleepStartHist,
sleepWindow: { start: S.sleepStart, end: S.sleepEnd },
phaseOffset: S.phaseOffset,
colorTheme: S.colorTheme,
notifications: S.notifications,
bioTimeRefType: S.bioTimeRefType,
bioTimeRefHour: S.bioTimeRefHour,
bioTimeRefMinute:S.bioTimeRefMinute
};
let json = JSON.stringify(exportObj, null, 2);
let filename = "crs_export.json";
storage.write(filename, json);
E.showAlert(`Exported to\n"${filename}"\n(${json.length} bytes)`).then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
}
function confirmResetAllData() {
E.showPrompt("Reset all data?").then(res => {
if (!res) {
drawClock();
Bangle.setUI(uiOpts);
return;
}
storage.erase(STORE_KEY);
storage.erase(STEPS_KEY);
storage.erase(HR_KEY);
storage.erase(LIGHT_KEY);
storage.erase(SLEEP_START_KEY);
S = {
phaseOffset: 0,
hydrationInterval: 2 * 3600*1000,
lastHydration: Date.now(),
sleepStart: 23,
sleepEnd: 7,
colorTheme: 0,
notifications: {
hydration: true,
sleepDetection: true,
hrLogging: true,
hrPower: true
},
bioTimeRefType: "DLMO",
bioTimeRefHour: 21,
bioTimeRefMinute: 0
};
// Persist defaults so they survive a restart
saveSettings();
// Optionally bypass the delayed save
storage.writeJSON(STORE_KEY, S);
stepsHist = []; hrHist = []; lightHist = []; sleepStartHist = [];
prevStepCount = 0; stepResetOffset = 0;
t = themes[S.colorTheme];
E.showAlert("All app data cleared").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
});
}
// Menu logic: Sleep window, hydration, BT calibration, theme, notifications, bio ref, about - unchanged, use as before
function setSleepWindow() {
let menu = { "": { title: "Select Start Hour" } };
for (let hr = 0; hr < 24; hr++) {
let label = (hr<10?"0":"") + hr;
menu[label] = ((h) => () => {
S.sleepStart = h;
saveSettings();
showEndHourSelector();
})(hr);
}
menu.Back = () => {
drawClock();
Bangle.setUI(uiOpts);
};
E.showMenu(menu);
function showEndHourSelector() {
let em = { "": { title: "Select End Hour" } };
for (let eh = 0; eh < 24; eh++) {
let lab2 = (eh<10?"0":"") + eh;
em[lab2] = ((e) => () => {
S.sleepEnd = e;
saveSettings();
E.showAlert("Sleep window set: " + getSleepWindowStr()).then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
})(eh);
}
em.Back = () => {
drawClock();
Bangle.setUI(uiOpts);
};
E.showMenu(em);
}
}
function setHydrationMenu() {
let m = { "": { title: "Hydration" } };
m["Remind Now"] = () => {
remindHydration();
drawClock();
Bangle.setUI(uiOpts);
};
m["Set Interval"] = setHydrationInterval;
m.Back = () => {
drawClock();
Bangle.setUI(uiOpts);
};
E.showMenu(m);
}
function setHydrationInterval() {
let m = { "": { title: "Set Hydration Interval" } };
for (let h = 1; h <= 12; h++) {
let label = h + "h";
m[label] = ((hours) => () => {
S.hydrationInterval = hours * 3600*1000;
S.lastHydration = Date.now();
saveSettings();
E.showAlert("Interval set: " + label).then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
})(h);
}
m.Off = () => {
S.hydrationInterval = Infinity;
saveSettings();
E.showAlert("Hydration off").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
};
m.Back = () => {
drawClock();
Bangle.setUI(uiOpts);
};
E.showMenu(m);
}
function calibrateBT() {
let m = { "": { title: "Calibrate BT" } };
for (let d = 1; d <= 8; d++) {
m["+" + d + "h"] = (() => () => {
S.phaseOffset += d;
saveSettings();
E.showAlert("Offset now: " + (S.phaseOffset>=0? "+"+S.phaseOffset : S.phaseOffset) + "h").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
})(d);
m["-" + d + "h"] = (() => () => {
S.phaseOffset -= d;
saveSettings();
E.showAlert("Offset now: " + (S.phaseOffset>=0? "+"+S.phaseOffset : S.phaseOffset) + "h").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
})(d);
}
m["Reset Offset"] = () => {
S.phaseOffset = 0;
saveSettings();
E.showAlert("Offset reset to 0h").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
};
m.Back = () => {
drawClock();
Bangle.setUI(uiOpts);
};
E.showMenu(m);
}
function setTheme() {
let m = { "": { title: "Select Theme" } };
m.Blue = () => { S.colorTheme = 0; applyTheme(); };
m.Green = () => { S.colorTheme = 1; applyTheme(); };
m.Dark = () => { S.colorTheme = 2; applyTheme(); };
m.Back = () => {
drawClock();
Bangle.setUI(uiOpts);
};
E.showMenu(m);
function applyTheme() {
t = themes[S.colorTheme];
saveSettings();
drawClock();
Bangle.setUI(uiOpts);
}
}
function showNotificationSettings() {
let m = { "": { title: "Notifications" } };
m["Hydration"] = {
value: S.notifications.hydration,
format: v => v ? "On" : "Off",
onchange: v => {
S.notifications.hydration = v;
saveSettings();
}
};
m["Sleep Detection"] = {
value: S.notifications.sleepDetection,
format: v => v ? "On" : "Off",
onchange: v => {
S.notifications.sleepDetection = v;
saveSettings();
}
};
m["HR Logging"] = {
value: S.notifications.hrLogging,
format: v => v ? "On" : "Off",
onchange: v => {
S.notifications.hrLogging = v;
if (!v) disableHrm();
saveSettings();
}
};
m["HR Monitoring"] = {
value: S.notifications.hrPower,
format: v => v ? "On" : "Off",
onchange: v => {
S.notifications.hrPower = v;
if (v) enableHrmIfNeeded();
else disableHrm();
saveSettings();
}
};
m.Back = () => {
showSettings();
};
E.showMenu(m);
}
function setBioTimeReference() {
let m = { "": { title: "BioTime Reference" } };
m.DLMO = () => {
S.bioTimeRefType = "DLMO";
saveSettings();
promptRefTime();
};
m.CBTmin = () => {
S.bioTimeRefType = "CBTmin";
saveSettings();
promptRefTime();
};
m.Back = () => {
showSettings();
};
E.showMenu(m);
function promptRefTime() {
E.showPrompt("Hour (0-23)?").then(h => {
if (h===undefined || h<0 || h>23) {
E.showAlert("Invalid hour").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
return;
}
S.bioTimeRefHour = h;
E.showPrompt("Minute (0-59)?").then(m => {
if (m===undefined || m<0 || m>59) {
E.showAlert("Invalid minute").then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
return;
}
S.bioTimeRefMinute = m;
saveSettings();
E.showAlert(`Reference set: ${S.bioTimeRefType} @ ${("0"+h).substr(-2)}:${("0"+m).substr(-2)}`)
.then(() => {
drawClock();
Bangle.setUI(uiOpts);
});
});
});
}
}
function showAbout() {
E.showAlert(
"Circadian Wellness Clock v" + VERSION + "\n" +
"Displays your CRS and BioTime.\n" +
"Copyright 2025"
).then(()=>{
drawClock();
Bangle.setUI(uiOpts);
});
}
function showSettings() {
const menu = {
"": { title: "Settings" },
"Sleep Window": setSleepWindow,
"Hydration": setHydrationMenu,
"Calibrate BT": calibrateBT,
"Theme": setTheme,
"Notifications": showNotificationSettings,
"BioTime Ref": setBioTimeReference,
"About / Version": showAbout,
"Back": () => {
drawClock();
Bangle.setUI(uiOpts);
}
};
E.showMenu(menu);
}
// MAIN MENU & UI HOOKUP
const menuOpts = {
"": { title: "Main Menu" },
"Show Stats": showStats,
"View Trend": showTrend,
"Sleep Window": setSleepWindow,
"Hydration": setHydrationMenu,
"Calibrate BT": calibrateBT,
"Export Data": exportData,
"Theme": setTheme,
"About": showAbout,
"Go to Launcher": () => Bangle.showLauncher(),
"Reset All Data": confirmResetAllData,
"Exit": () => Bangle.showClock()
};
const uiOpts = {
mode: "custom",
clock: false,
btn: () => E.showMenu(menuOpts),
touch: () => E.showMenu(menuOpts),
swipe: () => E.showMenu(menuOpts)
};
// 9. INITIALIZE
Bangle.setUI(uiOpts);
Bangle.loadWidgets();
drawClock();
setInterval(() => {
if (Bangle.isLCDOn()) drawClock();
}, 60*1000);
// END