diff --git a/apps/sleepsummary/README.md b/apps/sleepsummary/README.md
new file mode 100644
index 0000000000..13ea704261
--- /dev/null
+++ b/apps/sleepsummary/README.md
@@ -0,0 +1,60 @@
+# Sleep Summary
+This provides the module `sleepsummary`, which collects sleep data from `Sleep Log`, and generates a sleep score for you, based on average wakeup times, duration and more. The scores will generally trend higher in the first week that you use the module, however, the most accurate scores come the longer you use the module.
+
+The module also comes with an app to see detailed statistics of your sleep compared to your averages prior.
+All data is stored only on your device.
+
+It is highly reccomended to use HRM with `sleeplog` for increased accuracy in sleep tracking, leading to a more accurate sleep score here. To enable this, turn on HRM intervals in the `Health` app.
+## Formula
+
+The module takes in several data points:
+- How long you slept compared to your average
+- Duration compared to ideal duration set in settings
+- Deep sleep length compared to ideal deep sleep set in settings
+- When you woke up compared to your average
+
+The module then averages those individual scores with a weight added to get a score out of 100.
+## Settings
+- Use True Sleep - Whether or not to use True Sleep from sleeplog. If not checked, uses consecutive sleep instead
+- Show Message - Whether or not to show a good morning message / prompt when you wake up (this may not be exactly when you wake up, depending on how accurate your settings for Sleeplog are)
+- Ideal Deep Sleep - How much deep sleep per night should qualify as a deep sleep score of 95 (more than this gives you 100)
+- Ideal Sleep Time - How much sleep per night should qualify as a sleep duration score of 95 (more than this gives you 100)
+
+## Development
+To use the module, do `require("sleepsummary")` followed by any function from the list below.
+
+- `require("sleepsummary").recordData();` - Records current sleep data for the averages. It is best to only do this once a day, and is already automatically handled by the module.
+
+- `require("sleepsummary").getSummaryData();` - Returns the following:
+ - `avgSleepTime` - The average time you sleep for, returned in minutes
+ - `totalCycles` - How many times that the average was calculated / recorded
+ - `avgWakeUpTime` - The average time you wake up at every day, returned in ms (milliseconds) past midnight
+ - `promptLastShownDay` - The day of the week that the good morning prompt was last shown (0-6). This is only used by the boot.js file as a way to know if it needs to show the prompt today
+
+- `require("sleepsummary").getSleepData();` - Returns the following about this night's sleep (Most of the data comes directly from `require("sleeplog").getStats(Date.now(), 24*60*60*1000)`:
+ - `calculatedAt` - When the data was calculated
+ - `deepSleep` - How long in minutes you spent in deep sleep
+ - `lightSleep` - How long in minutes you spent in light sleep
+ - `awakeSleep` - How long you spent awake during sleep periods
+ - `consecSleep` - How long in minutes your consecutive sleep is
+ - `awakeTime` - How long you are awake for
+ - `notWornTime` - How long the watch was detected as not worn
+ - `unknownTime` - Time spent unknown
+ - `logDuration` - Time spent logging
+ - `firstDate` - Unix timestamp of the first log entry in the stats
+ - `lastDate`: Unix timestamp of the last log entry in the stats
+ - `totalSleep`: Total minutes of sleep for this night using consecutive sleep or true sleep, depending on what's selected in settings
+ - `awakeSince` - Time you woke up at, in ms past midnight
+
+ - `require("sleepsummary").getSleepScores();` - Returns the following sleep scores:
+ - `durationScore` - Sleep duration compared to ideal duration set in settings.
+ - `deepSleepScore` - Deep sleep length compared to ideal deep sleep set in settings
+ - `avgWakeUpScore` - When you woke up compared to your average
+ - `avgSleepTimeScore` - How long you slept compared to your average
+ - `overallScore` - The overall sleep score, calculated as a weighted average of all the other scores
+
+ - `require("sleepsummary").deleteData();` - Deletes learned data, automatically relearns the next time `recordData()` is called.
+
+
+## Creator
+RKBoss6
diff --git a/apps/sleepsummary/Screenshot.png b/apps/sleepsummary/Screenshot.png
new file mode 100644
index 0000000000..7d38079a86
Binary files /dev/null and b/apps/sleepsummary/Screenshot.png differ
diff --git a/apps/sleepsummary/app.js b/apps/sleepsummary/app.js
new file mode 100644
index 0000000000..68e1d8ca71
--- /dev/null
+++ b/apps/sleepsummary/app.js
@@ -0,0 +1,108 @@
+var Layout = require("Layout");
+var sleepScore = require("sleepsummary").getSleepScores().overallScore;
+
+// Convert unix timestamp (s or ms) → HH:MM
+function msToTimeStr(ms) {
+ // convert ms → minutes
+ let totalMins = Math.floor(ms / 60000);
+ let h = Math.floor(totalMins / 60) % 24; // hours in 0–23
+ let m = totalMins % 60; // minutes
+ let ampm = h >= 12 ? "p" : "a";
+
+ // convert to 12-hour clock, where 0 → 12
+ let hour12 = h % 12;
+ if (hour12 === 0) hour12 = 12;
+
+ // pad minutes
+ let mm = m.toString().padStart(2, "0");
+
+ return `${hour12}:${mm}${ampm}`;
+}
+
+function minsToTimeStr(mins) {
+ let h = Math.floor(mins / 60) % 24; // hours 0–23
+ let m = mins % 60; // minutes 0–59
+ let mm = m.toString().padStart(2,"0");
+ return `${h}:${mm}`;
+}
+
+
+
+// Custom renderer for the battery bar
+function drawGraph(l) {
+ let w = 160;
+ g.setColor(g.theme.fg);
+ g.drawRect(l.x, l.y, l.x+w, l.y+10); // outline
+ g.setColor("#00F")
+ if(g.theme.dark)g.setColor("#0F0");
+ g.fillRect(l.x, l.y, l.x+(sleepScore*1.65), l.y+10); // fill
+}
+
+// Layout definition
+var pageLayout = new Layout({
+ type: "v", c: [
+ {type:undefined, height:7}, // spacer
+ {type:"txt",filly:0, label:"Sleep Summary", font:"Vector:17", halign:0, id:"title",height:17,pad:3},
+ {
+ type:"v", c: [
+ {type:"txt", label:"Sleep Score: --", font:"8%", pad:5, id:"sleepScore"},
+ {type:"custom", render:drawGraph, height:15, width:165, id:"scoreBar"},
+
+ {type:undefined, height:4}, // spacer
+ {type:"txt", label:"Time Stats", font:"9%"},
+ {type:"h", c:[
+ {
+ type:"v", pad:3, c:[
+ {type:"txt", label:"", font:"9%",halign:1,pad:4},
+ {type:"txt", label:"Wake Up:", font:"8%",halign:1},
+ {type:"txt", label:"Sleep:", font:"8%",halign:1},
+ ]
+ },
+ {
+ type:"v", pad:3, c:[
+ {type:"txt", label:"Today", font:"9%",pad:4},
+ {type:"txt", label:"3:40a", font:"8%", id:"todayWakeupTime"},
+ {type:"txt", label:"7:40", font:"8%", id:"todaySleepTime"},
+ ]
+ },
+ {
+ type:"v", pad:3, c:[
+ {type:"txt", label:"Avg", font:"9%",pad:4},
+ {type:"txt", label:"6:33a", font:"8%", id:"avgWakeupTime"},
+ {type:"txt", label:"7:54", font:"8%", id:"avgSleepTime"},
+ ]
+ }
+ ]}
+ ]
+ }
+
+ ]
+}, {lazy:true});
+
+// Update function
+function draw() {
+ var sleepData=require("sleepsummary").getSleepData();
+ var data=require("sleepsummary").getSummaryData();
+ pageLayout.sleepScore.label = "Sleep score: "+sleepScore;
+ pageLayout.todayWakeupTime.label = msToTimeStr(sleepData.awakeSince);
+ pageLayout.avgWakeupTime.label = msToTimeStr(data.avgWakeUpTime);
+ pageLayout.todaySleepTime.label = minsToTimeStr(sleepData.totalSleep);
+ pageLayout.avgSleepTime.label = minsToTimeStr(data.avgSleepTime);
+ pageLayout.render();
+}
+
+
+// Initial draw
+g.clear();
+draw();
+
+
+// We want this app to behave like a clock:
+// i.e. show launcher when middle button pressed
+Bangle.setUI("clock");
+// But the app is not actually a clock
+// This matters for widgets that hide themselves for clocks, like widclk or widclose
+delete Bangle.CLOCK;
+
+Bangle.loadWidgets();
+Bangle.drawWidgets();
diff --git a/apps/sleepsummary/app.png b/apps/sleepsummary/app.png
new file mode 100644
index 0000000000..f6742eb2ad
Binary files /dev/null and b/apps/sleepsummary/app.png differ
diff --git a/apps/sleepsummary/appicon.js b/apps/sleepsummary/appicon.js
new file mode 100644
index 0000000000..93a1fa8c32
--- /dev/null
+++ b/apps/sleepsummary/appicon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEw4kA///9PTxEF+PQ9td1P991lmN+mOG99tymerXXjPH/nl1U1DoJj/AH4AYgUiAAgXQCwoYQFwwABkAuPl10kVJzOeGBwuCz//4W///6C6PPpn2o1s+xIOC4W/lAsBl+yC6Ov34XB1+CC6Mq5X5GYKQPC4XkxfyJITAPB4N//dilf+C6AwBknia5sRiMQGAoAFFJAXGMIQWMC44AQC7dVqoXUgoXBqAXTCwIABgczABMwC60zC+x3DC6anDC6Ud7oAC6YX/C4IARC/4Xla4IAMgIX/C5oA/AH4ANA="))
diff --git a/apps/sleepsummary/boot.js b/apps/sleepsummary/boot.js
new file mode 100644
index 0000000000..2b3e133a82
--- /dev/null
+++ b/apps/sleepsummary/boot.js
@@ -0,0 +1,119 @@
+{
+ let getMsPastMidnight=function(unixTimestamp) {
+
+ const dateObject = new Date(unixTimestamp);
+
+ const hours = dateObject.getHours();
+ const minutes = dateObject.getMinutes();
+ const seconds = dateObject.getSeconds();
+ const milliseconds = dateObject.getMilliseconds();
+
+ const msPastMidnight = (hours * 3600000) + (minutes * 60000) + (seconds * 1000) + milliseconds;
+ return msPastMidnight;
+ };
+ function formatTime(hours) {
+ let h = Math.floor(hours); // whole hours
+ let m = Math.round((hours - h) * 60); // leftover minutes
+
+ // handle rounding like 1.9999 → 2h 0m
+ if (m === 60) {
+ h += 1;
+ m = 0;
+ }
+
+ if (h > 0 && m > 0) return h + "h " + m + "m";
+ if (h > 0) return h + "h";
+ return m + "m";
+ }
+
+
+ function logNow(msg) {
+ let filename="sleepsummarylog.json";
+ let storage = require("Storage");
+
+ // load existing log (or empty array if file doesn't exist)
+ let log = storage.readJSON(filename,1) || [];
+
+ // get human-readable time
+ let d = new Date();
+ let timeStr = d.getFullYear() + "-" +
+ ("0"+(d.getMonth()+1)).slice(-2) + "-" +
+ ("0"+d.getDate()).slice(-2) + " " +
+ ("0"+d.getHours()).slice(-2) + ":" +
+ ("0"+d.getMinutes()).slice(-2) + ":" +
+ ("0"+d.getSeconds()).slice(-2)+", MSG: "+msg;
+
+ // push new entry
+ log.push(timeStr);
+
+ // keep file from growing forever
+ if (log.length > 200) log = log.slice(-200);
+
+ // save back
+ storage.writeJSON(filename, log);
+ }
+
+ let showSummary=function(){
+ logNow("shown")
+ var sleepData=require("sleepsummary").getSleepData();
+ var sleepScore=require("sleepsummary").getSleepScores().overallSleepScore;
+ //sleepData.consecSleep
+ var message="You slept for "+ formatTime(sleepData.consecSleep/60) +", with a sleep score of "+sleepScore;
+
+ E.showPrompt(message, {
+ title: "Good Morning!",
+ buttons: { "Dismiss": 1, "Open App":2},
+
+ }).then(function (answer) {
+ if(answer==1){
+ Bangle.load();
+ }else{
+ load("sleepsummary.app.js");
+ }
+ });
+ }
+
+
+ function checkIfAwake(data,thisTriggerEntry){
+
+ logNow("checked, prev status: "+data.prevStatus+", current status: "+data.status+", promptLastShownDay: "+require("sleepsummary").getSummaryData().promptLastShownDay);
+
+ let today = new Date().getDay();
+ if(require("sleepsummary").getSummaryData().promptLastShownDay!=today){
+ //if coming from sleep
+ if (data.status==2&&(data.previousStatus==3||data.previousStatus==4)) {
+ var settings=require("sleepsummary").getSettings();
+
+ //woke up
+ if(settings.showMessage){
+ setTimeout(showSummary,settings.messageDelay)
+ }
+
+ require("sleepsummary").recordData();
+ }
+
+ }
+ }
+
+ //Force-load module
+ require("sleeplog");
+
+ // first ensure that the sleeplog trigger object is available (sleeplog is enabled)
+ if (typeof (global.sleeplog || {}).trigger === "object") {
+ // then add your parameters with the function to call as object into the trigger object
+ sleeplog.trigger["sleepsummary"] = {
+ onChange: true, // false as default, if true call fn only on a status change
+ from: 0, // 0 as default, in ms, first time fn will be called
+ to: 24*60*60*1000, // 24h as default, in ms, last time fn will be called
+ // reference time to from & to is rounded to full minutes
+ fn: function(data, thisTriggerEntry) {
+
+ checkIfAwake(data,thisTriggerEntry);
+
+
+
+ } // function to be executed
+ };
+ }
+
+}
diff --git a/apps/sleepsummary/metadata.json b/apps/sleepsummary/metadata.json
new file mode 100644
index 0000000000..2e3e0cb6f8
--- /dev/null
+++ b/apps/sleepsummary/metadata.json
@@ -0,0 +1,29 @@
+ {
+ "id": "sleepsummary",
+ "name": "Sleep Summary",
+ "shortName": "Sleep Summ.",
+ "author":"RKBoss6",
+ "version": "0.01",
+ "description": "Adds a module that learns sleeping habits over time and provides a sleep score based on how good of a sleep you got",
+ "icon": "app.png",
+ "type": "app",
+ "tags": "health",
+ "supports": ["BANGLEJS2"],
+ "readme": "README.md",
+ "dependencies" : { "sleeplog":"app" },
+ "provides_modules" : ["sleepsummary"],
+ "storage": [
+ {"name":"sleepsummary.app.js","url":"app.js"},
+ {"name":"sleepsummary.boot.js","url":"boot.js"},
+ {"name":"sleepsummary","url":"module.js"},
+ {"name":"sleepsummary.settings.js","url":"settings.js"},
+ {"name":"sleepsummary.img","url":"appicon.js","evaluate":true}
+ ],
+ "data":[
+ {"name":"sleepsummarydata.json"},
+ {"name":"sleepsummary.settings.json"}
+ ],
+ "screenshots":[
+ {"url":"Screenshot.png"}
+ ]
+}
diff --git a/apps/sleepsummary/module.js b/apps/sleepsummary/module.js
new file mode 100644
index 0000000000..0f0e4e1b67
--- /dev/null
+++ b/apps/sleepsummary/module.js
@@ -0,0 +1,182 @@
+{
+ let getMsPastMidnight=function(unixTimestamp) {
+
+ const dateObject = new Date(unixTimestamp);
+
+ const hours = dateObject.getHours();
+ const minutes = dateObject.getMinutes();
+ const seconds = dateObject.getSeconds();
+ const milliseconds = dateObject.getMilliseconds();
+
+ const msPastMidnight = (hours * 3600000) + (minutes * 60000) + (seconds * 1000) + milliseconds;
+ return msPastMidnight;
+ };
+
+ let averageNumbers=function(runningAvg,totalCycles,newData){
+ return ((runningAvg*totalCycles)+newData)/(totalCycles+1);
+
+ };
+
+ let getData=function(){
+ return Object.assign({
+
+ avgSleepTime: 0,
+ totalCycles:0,
+ avgWakeUpTime:0,
+ promptLastShownDay:"",
+
+ }, require('Storage').readJSON("sleepsummarydata.json", true) || {});
+ };
+
+ let getSettings=function() {
+ return Object.assign({
+ useTrueSleep:true,
+ messageDelay: 1800000,
+ showMessage:true,
+ deepSleepHours:5,
+ idealSleepHours:10,
+
+ }, require('Storage').readJSON("sleepsummary.settings.json", true) || {});
+ };
+
+ let writeData=function(data){
+ require("Storage").writeJSON("sleepsummarydata.json", data);
+
+ };
+
+ let deleteData=function(){
+ require("Storage").erase("sleepsummarydata.json");
+ };
+
+ let getSleepData=function(){
+ var data=require("sleeplog").getStats(Date.now(), 24*60*60*1000);
+ var totalSleep=data.consecSleep;
+ if(getSettings().useTrueSleep) totalSleep=data.deepSleep+data.lightSleep;
+
+ return { calculatedAt: data.calculatedAt,
+ deepSleep: data.deepSleep,
+ lightSleep: data.lightSleep,
+ awakeSleep: data.awakeSleep,
+ consecSleep: data.consecSleep,
+ awakeTime: data.awakeTime,
+ notWornTime: data.notWornTime,
+ unknownTime: data.unknownTime,
+ logDuration: data.logDuration,
+ firstDate: data.firstDate,
+ lastDate: data.lastDate,
+ totalSleep: totalSleep,
+ awakeSince:getMsPastMidnight(global.sleeplog.info.awakeSince)
+ };
+
+
+ }
+
+
+
+
+ let recordSleepStats=function(){
+ var today = new Date().getDay();
+ var sleepData=getSleepData();
+ var data=getData();
+ //Wakeup time
+ var wakeUpTime=sleepData.awakeSince;
+ var avgWakeUpTime=averageNumbers(data.avgWakeUpTime,data.totalCycles,wakeUpTime);
+ data.avgWakeUpTime=avgWakeUpTime;
+
+ //sleep time in minutes
+ var time=sleepData.totalSleep;
+
+
+ var avgSleepTime = averageNumbers(data.avgSleepTime, data.totalCycles, time);
+ data.avgSleepTime = avgSleepTime;
+
+ data.promptLastShownDay=today;
+
+
+ data.totalCycles+=1;
+ writeData(data);
+
+ };
+
+ // takes in an object with {score, weight}
+ let getWeightedScore=function(components) {
+ // sum of weights
+ let totalWeight = 0;
+ for (let key in components) totalWeight += components[key].weight;
+
+ // avoid division by zero
+ if (totalWeight === 0) return 0;
+
+ let score = 0;
+ for (let key in components) {
+ let s = components[key].score;
+ let w = components[key].weight;
+ score += (s * (w / totalWeight));
+ }
+
+ return Math.round(score);
+ }
+ let generateScore = function(value, target) {
+ if (value >= target) {
+ let extra = Math.min(1, (value - target) / target);
+ return Math.round(95 + extra * 5); // perfect = 95, max = 100
+ } else {
+ return Math.round((value / target) * 95);
+ }
+ }
+
+
+ let getSleepScore=function(){
+
+ var sleepData=getSleepData();
+ var settings=getSettings();
+ var summaryData=getData();
+
+ //only if enabled in Health
+ //var hrmScore;
+
+ return getWeightedScore({
+ duration:
+ {score: generateScore(sleepData.consecSleep/60,settings.idealSleepHours), weight: 0.6},
+ deepSleep:
+ {score: generateScore(sleepData.deepSleep/60,settings.deepSleepHours), weight: 0.3},
+ averageSleep:
+ {score:generateScore(sleepData.totalSleep,summaryData.avgSleepTime),weight:0.15},
+ averageWakeup:
+ {score:generateScore(getMsPastMidnight(sleepData.awakeSince),summaryData.avgWakeUpTime),weight:0.1},
+ });
+ }
+
+
+
+
+
+ let getAllSleepScores=function(){
+ var data=getData();
+ var sleepData=getSleepData();
+ var settings=getSettings();
+ return {
+ durationScore:generateScore(sleepData.consecSleep/60,settings.idealSleepHours),
+ deepSleepScore:generateScore(sleepData.deepSleep/60,settings.deepSleepHours),
+ avgWakeUpScore: generateScore(getMsPastMidnight(sleepData.awakeSince),data.avgWakeUpTime),
+ avgSleepTimeScore:generateScore(sleepData.totalSleep,data.avgSleepTime),
+ overallScore:getSleepScore()
+ }
+ };
+
+
+
+
+
+
+ exports.deleteData = deleteData;
+ exports.getSummaryData=getData;
+ exports.recordData=recordSleepStats;
+ exports.getSleepScores=getAllSleepScores;
+ exports.getSleepData=getSleepData;
+ exports.getSettings=getSettings;
+
+
+
+}
+
diff --git a/apps/sleepsummary/settings.js b/apps/sleepsummary/settings.js
new file mode 100644
index 0000000000..97ffe921b6
--- /dev/null
+++ b/apps/sleepsummary/settings.js
@@ -0,0 +1,94 @@
+(function(back) {
+ var FILE = "sleepsummary.settings.json";
+ // Load settings
+ var settings = require("sleepsummary").getSettings();
+
+ function writeSettings() {
+ require('Storage').writeJSON(FILE, settings);
+ }
+
+ E.showMenu({
+ "" : { "title" : "Sleep Summary" },
+ "< Back" : () => back(),
+ 'Use True Sleep': {
+ value: !!settings.useTrueSleep,
+ onchange: v => {
+ settings.useTrueSleep = v;
+ writeSettings();
+ }
+ },
+ 'Show Message': {
+ value: !!settings.showMessage,
+ onchange: v => {
+ settings.showMessage = v;
+ writeSettings();
+ }
+
+ },
+ 'Message Delay': {
+ value: 0|settings.messageDelay,
+ min: 0, max: 7200000,
+ step:5,
+ onchange: v => {
+ settings.messageDelay = v;
+ writeSettings();
+ },
+ format : v => {
+ let h = Math.floor(v/60000);
+ let m = v % 60;
+ let str = "";
+ if (h) str += h+"h";
+ if (m) str += " "+m+"m";
+ return str || "0m";
+ }
+ },
+ 'Ideal Deep Sleep': {
+ value: 0|settings.deepSleepHours*60,
+ min: 60, max: 600,
+ step:15,
+ onchange: v => {
+ settings.deepSleepHours = v/60;
+ writeSettings();
+ },
+ format : v => {
+ let h = Math.floor(v/60);
+ let m = v % 60;
+ let str = "";
+ if (h) str += h+"h";
+ if (m) str += " "+m+"m";
+ return str || "0m";
+ }
+ },
+ 'Ideal Sleep Time': {
+ value: 0|settings.idealSleepHours*60,
+ min: 120, max: 60*14 ,
+ step:15,
+ onchange: v => {
+ settings.idealSleepHours = v/60,
+ writeSettings();
+ },
+ format : v => {
+ let h = Math.floor(v/60);
+ let m = v % 60;
+ let str = "";
+ if (h) str += h+"h";
+ if (m) str += " "+m+"m";
+ return str || "0m";
+ }
+ },
+ 'Clear Data': function () {
+ E.showPrompt("Are you sure you want to delete all averaged data?", {title:"Confirmation"})
+ .then(function(v) {
+ if (v) {
+ require("sleepsummary").deleteData();
+ E.showAlert("Cleared data!",{title:"Cleared!"}).then(function(v) {eval(require("Storage").read("sleepsummary.settings.js"))(()=>load())});
+ } else {
+ eval(require("Storage").read("sleepsummary.settings.js"))(()=>load());
+
+ }
+ });
+ },
+ });
+
+})
+