diff --git a/apps/crsclock/ChangeLog b/apps/crsclock/ChangeLog new file mode 100644 index 0000000000..647493b2b7 --- /dev/null +++ b/apps/crsclock/ChangeLog @@ -0,0 +1,6 @@ +0.01: Initial release – shows CRS, Steps, Mood, LightExp +0.02: Added README.md and linked in metadata.json +0.03: Added ChangeLog and linked in metadata.json +0.04: Improved stats layout with 5px margins and truncation +0.05: Added header color strip and shadowed time display +0.06: Memory optimizations – history pruning and limited HRM sampling diff --git a/apps/crsclock/README.md b/apps/crsclock/README.md new file mode 100644 index 0000000000..17302cce05 --- /dev/null +++ b/apps/crsclock/README.md @@ -0,0 +1,119 @@ +## Circadian Rhythm Clock (CRS Clock) for Bangle.js 2 + +### **Overview** + +**Circadian Rhythm Clock** is an advanced wellness clock for Bangle.js 2 that estimates and visualizes your body’s internal circadian time and alignment. It calculates a personalized **Circadian Rhythm Score (CRS)** using your recent sleep timing, physical activity patterns, and (optionally) heart rate data. The app helps users monitor their biological rhythms, optimize their sleep, and maintain healthy light/activity habits—even on a device with limited sensors. + +### **Core Features** + +#### **1. Real-Time CRS Calculation** + +* **CRS** is computed as a composite index using: + + * **Sleep Timing** (from sleep/wake detection or manual window) + * **Activity Stability** (based on step data over the last 24 hours) + * **Heart Rate Stability** (if HR sensor is enabled) + * **Light Exposure Proxy** (counts “light hours” based on daytime activity if no light sensor is present) +* **CRS** is shown directly on the main clock face, updated every minute. + +#### **2. Biological Time Display** + +* Shows the current **estimated biological time (“BioTime”)** in large, clear digits. +* BioTime is adjusted using the user’s sleep phase offset and a configurable reference point (e.g., DLMO or CBTmin). + +#### **3. Health and Circadian Stats** + +* The main screen presents: + + * **CRS:** Current circadian rhythm score (0–100) + * **Steps:** Total steps in the last 24 hours + * **Mood:** Detected from heart rate variability (HRV proxy) + * **Light:** Estimated hours of daytime activity (“light exposure”) +* All stats update automatically. + +#### **4. Trend Visualization** + +* **CRS Trend Chart:** Shows CRS for each hour over the past 8–12 hours in a bar graph (visually displays circadian stability). +* Helps users see the rhythm and recent disruptions. + +#### **5. Automatic Sleep & Wake Detection** + +* Uses step activity and (optionally) movement detection to infer sleep start/end times. +* Triggers friendly alerts when sleep or wake is detected. + +#### **6. Hydration and Wellness Reminders** + +* Optional periodic reminders to hydrate, based on a user-configurable interval. + +#### **7. Simple Settings Menu** + +* Configure: + + * Sleep window (manual entry) + * Hydration reminder interval + * Theme/color scheme + * Circadian reference (DLMO/CBTmin) + * Heart rate and sleep detection settings + +#### **8. Data Export & Reset** + +* Exports historical step, heart rate, and light proxy data as JSON for external analysis. +* Full app data reset available from the menu. + +#### **9. Efficient and Stable Operation** + +* Aggressively trims stored history to a set maximum (e.g., 200 entries) to ensure stability and prevent memory overflows. +* All heavy computations (like trend chart) are run only on-demand. + +### **Sensor/Hardware Adaptation** + +* If the **heart rate sensor** is unavailable or disabled, the app uses step/activity stability only. +* If **no light sensor** is present (default for Bangle.js 2), the app estimates “light exposure” as the number of hours the user is active during daytime, outside their sleep window. +* The app is robust to missing or partial data. + +### **User Experience** + +* **Always-on Clock:** Appears as the default watch face, with tap, button, or swipe to access stats and menu. +* **Vivid, readable interface** with color themes and a clean, minimal design. +* **Alerts** for hydration and sleep transitions are gentle and non-intrusive. + + +### **Intended Users** + +* Anyone interested in **improving sleep, productivity, or circadian health**. +* Shift workers, travelers, or students seeking to track and align their biological clocks. +* Users who want more than a step counter—actionable, science-based feedback on internal time. + +### **How It Works** + +1. **Collects step and (optionally) heart rate data** in real-time. +2. **Calculates CRS** using the variability and timing of steps, sleep, and HR (if available). +3. **Estimates light exposure** hours using step data during daytime. +4. **Presents key circadian health metrics** on the main watch face. +5. **Offers friendly reminders** and data export for deeper analysis. + +### **Technical Details** + +* **Code:** JavaScript (Espruino), optimized for Bangle.js 2 +* **Persistent storage:** Steps, heart rate, and light/“light hour” proxies saved locally, aggressively pruned for memory safety. +* **User interface:** Button/touch/swipe navigation, E.showMenu/E.showAlert for dialogs, custom clock face rendering. +* **No dependencies:** Runs entirely on the watch—no phone, no cloud required. + +## Author + +Jakub Tencl, Ph.D. +[https://ihypnosis.org.uk](https://ihypnosis.org.uk) + +## Patent and Licensing + +This app is based on methods described in UK Patent Application GB2509149.7: +**"Wearable Circadian Rhythm Score (CRS) System for Estimating Biological Time"** + +Unless otherwise stated, this project is released under the MIT license. +Use of the patented method may be subject to licensing or permission. +For inquiries, contact the author. + +### **Summary** + +The Circadian Rhythm Clock transforms the Bangle.js 2 into a **bio-aware, personalized circadian dashboard**, guiding users toward better alignment with their biological clock and modern life. +It is ideal for anyone who wants to visualize and optimize their internal rhythms using open, transparent algorithms—right on their wrist. diff --git a/apps/crsclock/app-icon.js b/apps/crsclock/app-icon.js new file mode 100644 index 0000000000..715f878f21 --- /dev/null +++ b/apps/crsclock/app-icon.js @@ -0,0 +1 @@ +atob("MDDE/wAA///mEGT+BhEUpeYIQwAgAGQAaUpyjAX/5RDQc4QAAAAAAAAAiAAIqrVVVVuqgACIAAAAAAAAAAAAAAAIAIqxERERERERG6gAgAAAAAAAAAAAAAAAjhERERVVVVERERHoAAAAAAAAAAAAAIAKUREV6t+Xef2uURERoAgAAAAAAAAACA1REVrXliRu5k1pfaURFdCAAAAAAAAAgKERGvkkRCT8OUJEQpmlERoIAAAAAAAIChEb2UQiQiLTOUIkQkT/sRGggAAAAACAoRGnJCIiIkTzP0Ii8kRCmhEaCAAAAAgKERp0QiIiIkTzP0JNqmIkSaERoIAAAADREalCIiIiIiLTOdRtzp1G3ZoRHQAAAIBRGnQiRCIiQkLzPyRkypQvO9mhFYgACAoRt0JCkkIiIiJkRkQtOmSTyvR+EeCAgHEVlCIk5PQiIiRNTflk3U/sqUJHUR0IgOEa9CT6EUZCQiSawzrfJJ7KkkIvoRsIBxFZQiRNUVJkIiSew8zO1mrvREIkdRFwChGvQiJClRFNQiSawzM8OmaUad1EahGgBRX0IiT5b1FU1CSewzMzPK1Nrs70SVFQcRt0JEelr/UV3USewzM8M80pvDokR+EdoRr0IvsRpk9RVGSewzPDM87y3ZYiRqEa4RQiSVEedCL1FdJqw8yn7DzWJkQiItEbUV9E/hEa1CQpUVSezDqaPDzpREIiJJUVUVdE9REdIiJCdRXdo9eswzw9IkRERHsVUblPoRFfQiIkKVFV5vrMMzM9aZmZkp4REblJ4RFZQiIiQpURFdwzMzPNKqqq6p4REblHsREdIiIiJJoREdwzMzPN3MzMyn4RUbdHsREUZCIiJGoRG6wzMzM9bd3d0p4RUVdJ4REedCIiIk+rrTwzMzw/QiZmZHsVUV9PoRERTUIiIiT9o8MzMzypQiIiRJUV4RQk8RERV9QiJCT+wzMzMzzSIkIiTSEeoRr0nhERFUkkRCSewzMzM8qUTZJCT6EagRt0R1ERER5JkiSewzMzPD0tPORkSeEXCxX0QvUREREVUiSewzMzw9JCo8qUSVFQChGvRCmhERER7USawzPMPW/0ad0kahGgCBFXQiSdqu6tlESew8w60tqpRCIklRFwgOEa9CJC2ZliRERqwzrZRJPK9CJPoR4IiIUV8kIiREREIiJN3ZlC8i08qU0nURcICAoRt0IiIiIiIkRkRkRkNCbTyvR+EaCAAIBRGnQkIiIiIiSTP0T6ypQtu9mhFQgAAABxEalCIiIiJCSTPST6ymJG3ZoRHQAAAAgEERp0QiIiJCTzMiJk5GIkSaERoIAAAACAoRGnRCIiJCTzPSJCltJCmhEaCAAAAAAIChEb/0QiRCTzPSIiRET54RGggAAAAAAAgEURWp9EQiTzPSJERGelERoIAAAAAAAACAdREVr39kRuvURvf6URFdCAAAAAAAAAAIAKERFbpPmYeZkqtRERoAgAAAAAAAAAAAAAfhEREVW+61URERHnAAAAAAAAAAAAAAAIAHpRERERERERFacAgAAAAAAAAAAAAAAAiAANq1URERW60ACAAAAAAAAA") diff --git a/apps/crsclock/crsclock.js b/apps/crsclock/crsclock.js new file mode 100644 index 0000000000..5a7c1bf9d2 --- /dev/null +++ b/apps/crsclock/crsclock.js @@ -0,0 +1,804 @@ + +// --- 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 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" + + "© 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 diff --git a/apps/crsclock/icon.png b/apps/crsclock/icon.png new file mode 100644 index 0000000000..231da6a33a Binary files /dev/null and b/apps/crsclock/icon.png differ diff --git a/apps/crsclock/metadata.json b/apps/crsclock/metadata.json new file mode 100644 index 0000000000..631a0985a8 --- /dev/null +++ b/apps/crsclock/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "crsclock", + "name": "Circadian Rhythm Clock", + "shortName":"Circadian", + "version": "0.06", + "description": "Shows CRS (Circadian Rhythm Score), steps, mood, and light exposure.", + "icon": "icon.png", + "type": "clock", + "tags": "clock", + "supports": ["BANGLEJS2"], + "readme": "README.md", + "storage": [ + { "name": "crsclock.app.js", "url": "crsclock.js" }, + { "name": "crsclock.img", "url":"app-icon.js","evaluate":true } + ] +}