805 lines
21 KiB
JavaScript
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
|