Merge pull request #3878 from spycat111/master

Circadian Wellness Clock: Initial submission for review
master
Gordon Williams 2025-06-13 12:45:42 +01:00 committed by GitHub
commit 8240c2a1fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 946 additions and 0 deletions

6
apps/crsclock/ChangeLog Normal file
View File

@ -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

119
apps/crsclock/README.md Normal file
View File

@ -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 bodys 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 users 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 (0100)
* **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 812 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.

View File

@ -0,0 +1 @@
atob("MDDE/wAA///mEGT+BhEUpeYIQwAgAGQAaUpyjAX/5RDQc4QAAAAAAAAAiAAIqrVVVVuqgACIAAAAAAAAAAAAAAAIAIqxERERERERG6gAgAAAAAAAAAAAAAAAjhERERVVVVERERHoAAAAAAAAAAAAAIAKUREV6t+Xef2uURERoAgAAAAAAAAACA1REVrXliRu5k1pfaURFdCAAAAAAAAAgKERGvkkRCT8OUJEQpmlERoIAAAAAAAIChEb2UQiQiLTOUIkQkT/sRGggAAAAACAoRGnJCIiIkTzP0Ii8kRCmhEaCAAAAAgKERp0QiIiIkTzP0JNqmIkSaERoIAAAADREalCIiIiIiLTOdRtzp1G3ZoRHQAAAIBRGnQiRCIiQkLzPyRkypQvO9mhFYgACAoRt0JCkkIiIiJkRkQtOmSTyvR+EeCAgHEVlCIk5PQiIiRNTflk3U/sqUJHUR0IgOEa9CT6EUZCQiSawzrfJJ7KkkIvoRsIBxFZQiRNUVJkIiSew8zO1mrvREIkdRFwChGvQiJClRFNQiSawzM8OmaUad1EahGgBRX0IiT5b1FU1CSewzMzPK1Nrs70SVFQcRt0JEelr/UV3USewzM8M80pvDokR+EdoRr0IvsRpk9RVGSewzPDM87y3ZYiRqEa4RQiSVEedCL1FdJqw8yn7DzWJkQiItEbUV9E/hEa1CQpUVSezDqaPDzpREIiJJUVUVdE9REdIiJCdRXdo9eswzw9IkRERHsVUblPoRFfQiIkKVFV5vrMMzM9aZmZkp4REblJ4RFZQiIiQpURFdwzMzPNKqqq6p4REblHsREdIiIiJJoREdwzMzPN3MzMyn4RUbdHsREUZCIiJGoRG6wzMzM9bd3d0p4RUVdJ4REedCIiIk+rrTwzMzw/QiZmZHsVUV9PoRERTUIiIiT9o8MzMzypQiIiRJUV4RQk8RERV9QiJCT+wzMzMzzSIkIiTSEeoRr0nhERFUkkRCSewzMzM8qUTZJCT6EagRt0R1ERER5JkiSewzMzPD0tPORkSeEXCxX0QvUREREVUiSewzMzw9JCo8qUSVFQChGvRCmhERER7USawzPMPW/0ad0kahGgCBFXQiSdqu6tlESew8w60tqpRCIklRFwgOEa9CJC2ZliRERqwzrZRJPK9CJPoR4IiIUV8kIiREREIiJN3ZlC8i08qU0nURcICAoRt0IiIiIiIkRkRkRkNCbTyvR+EaCAAIBRGnQkIiIiIiSTP0T6ypQtu9mhFQgAAABxEalCIiIiJCSTPST6ymJG3ZoRHQAAAAgEERp0QiIiJCTzMiJk5GIkSaERoIAAAACAoRGnRCIiJCTzPSJCltJCmhEaCAAAAAAIChEb/0QiRCTzPSIiRET54RGggAAAAAAAgEURWp9EQiTzPSJERGelERoIAAAAAAAACAdREVr39kRuvURvf6URFdCAAAAAAAAAAIAKERFbpPmYeZkqtRERoAgAAAAAAAAAAAAAfhEREVW+61URERHnAAAAAAAAAAAAAAAIAHpRERERERERFacAgAAAAAAAAAAAAAAAiAANq1URERW60ACAAAAAAAAA")

804
apps/crsclock/crsclock.js Normal file
View File

@ -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<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 (023)?").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 (059)?").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

BIN
apps/crsclock/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -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 }
]
}