Merge pull request #3878 from spycat111/master
Circadian Wellness Clock: Initial submission for reviewmaster
commit
8240c2a1fa
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
atob("MDDE/wAA///mEGT+BhEUpeYIQwAgAGQAaUpyjAX/5RDQc4QAAAAAAAAAiAAIqrVVVVuqgACIAAAAAAAAAAAAAAAIAIqxERERERERG6gAgAAAAAAAAAAAAAAAjhERERVVVVERERHoAAAAAAAAAAAAAIAKUREV6t+Xef2uURERoAgAAAAAAAAACA1REVrXliRu5k1pfaURFdCAAAAAAAAAgKERGvkkRCT8OUJEQpmlERoIAAAAAAAIChEb2UQiQiLTOUIkQkT/sRGggAAAAACAoRGnJCIiIkTzP0Ii8kRCmhEaCAAAAAgKERp0QiIiIkTzP0JNqmIkSaERoIAAAADREalCIiIiIiLTOdRtzp1G3ZoRHQAAAIBRGnQiRCIiQkLzPyRkypQvO9mhFYgACAoRt0JCkkIiIiJkRkQtOmSTyvR+EeCAgHEVlCIk5PQiIiRNTflk3U/sqUJHUR0IgOEa9CT6EUZCQiSawzrfJJ7KkkIvoRsIBxFZQiRNUVJkIiSew8zO1mrvREIkdRFwChGvQiJClRFNQiSawzM8OmaUad1EahGgBRX0IiT5b1FU1CSewzMzPK1Nrs70SVFQcRt0JEelr/UV3USewzM8M80pvDokR+EdoRr0IvsRpk9RVGSewzPDM87y3ZYiRqEa4RQiSVEedCL1FdJqw8yn7DzWJkQiItEbUV9E/hEa1CQpUVSezDqaPDzpREIiJJUVUVdE9REdIiJCdRXdo9eswzw9IkRERHsVUblPoRFfQiIkKVFV5vrMMzM9aZmZkp4REblJ4RFZQiIiQpURFdwzMzPNKqqq6p4REblHsREdIiIiJJoREdwzMzPN3MzMyn4RUbdHsREUZCIiJGoRG6wzMzM9bd3d0p4RUVdJ4REedCIiIk+rrTwzMzw/QiZmZHsVUV9PoRERTUIiIiT9o8MzMzypQiIiRJUV4RQk8RERV9QiJCT+wzMzMzzSIkIiTSEeoRr0nhERFUkkRCSewzMzM8qUTZJCT6EagRt0R1ERER5JkiSewzMzPD0tPORkSeEXCxX0QvUREREVUiSewzMzw9JCo8qUSVFQChGvRCmhERER7USawzPMPW/0ad0kahGgCBFXQiSdqu6tlESew8w60tqpRCIklRFwgOEa9CJC2ZliRERqwzrZRJPK9CJPoR4IiIUV8kIiREREIiJN3ZlC8i08qU0nURcICAoRt0IiIiIiIkRkRkRkNCbTyvR+EaCAAIBRGnQkIiIiIiSTP0T6ypQtu9mhFQgAAABxEalCIiIiJCSTPST6ymJG3ZoRHQAAAAgEERp0QiIiJCTzMiJk5GIkSaERoIAAAACAoRGnRCIiJCTzPSJCltJCmhEaCAAAAAAIChEb/0QiRCTzPSIiRET54RGggAAAAAAAgEURWp9EQiTzPSJERGelERoIAAAAAAAACAdREVr39kRuvURvf6URFdCAAAAAAAAAAIAKERFbpPmYeZkqtRERoAgAAAAAAAAAAAAAfhEREVW+61URERHnAAAAAAAAAAAAAAAIAHpRERERERERFacAgAAAAAAAAAAAAAAAiAANq1URERW60ACAAAAAAAAA")
|
||||
|
|
@ -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 (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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue