/*
* Copyright 2020-2022 Sleepdiary Developers <sleepdiary@pileofstuff.org>
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy,
* modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
* BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
* ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use strict";
/**
* Valid record statuses
* @enum {string}
*/
const DiaryStandardRecordStatus = {
/** user is currently awake */
awake : "awake" ,
/** user is in bed but not asleep */
in_bed: "in bed",
/** user is asleep */
asleep: "asleep",
/** user is currently turning off the lights in preparation to go to bed */
"lights off": "lights off",
/** user is currently turning on the lights after getting out of bed */
"lights on": "lights on",
/** user is eating some food, but not a full meal */
snack: "snack",
/** user is eating a full meal */
meal: "meal",
/** user is consuming alcohol */
alcohol: "alcohol",
/** user is consuming chocolate */
chocolate: "chocolate",
/** user is consuming caffeine */
caffeine: "caffeine",
/** user is consuming a drink that doesn't fit into any other category */
drink: "drink",
/** user is taking a sleeping pill, tranqulisier, or other medication to aid sleep */
"sleep aid": "sleep aid",
/** user is exercising */
exercise: "exercise",
/** user is using the toilet */
toilet: "toilet",
/** user is experiencing noise that disturbs their sleep */
noise: "noise",
/** user's wake-up alarm is trying to wake them up */
alarm: "alarm",
/** user is currently getting into bed */
"in bed": "in bed",
/** user is currently getting out of bed */
"out of bed": "out of bed",
};
/**
* @typedef {{
* start : number,
* end : number,
* status : DiaryStandardRecordStatus,
* start_timezone : (undefined|string),
* end_timezone : (undefined|string),
* duration : (undefined|number),
* tags : (undefined|Array<string>),
* comments : (undefined|Array<string|{time:number,text:string}>),
* day_number : number,
* start_of_new_day : boolean,
* is_primary_sleep : boolean,
* missing_record_after: boolean
* }} DiaryStandardRecord
*
* A single record in a diary (e.g. one sleep) - see README.md for details
*
*/
let DiaryStandardRecord;
/**
* @typedef {{
* average : number,
* mean : number,
* interquartile_mean : number,
* standard_deviation: number,
* interquartile_standard_deviation: number,
* median : number,
* interquartile_range : number,
* durations : Array<number|undefined>,
* interquartile_durations : Array<number|undefined>,
* rolling_average : Array<number|undefined>,
* timestamps : Array<number|undefined>
* }} DiaryStandardStatistics
*
* Information about records from a diary
*/
let DiaryStandardStatistics;
/**
* @typedef {null|DiaryStandardStatistics} MaybeDiaryStandardStatistics
*/
let MaybeDiaryStandardStatistics;
/**
* @public
* @unrestricted
* @augments DiaryBase
*
* @example
* let diary = new_sleep_diary(contents_of_my_file));
*
* // print the minimum expected day duration in milliseconds:
* console.log(diary.settings.minimum_day_duration);
* -> 12345
*
* // print the maximum expected day duration in milliseconds:
* console.log(diary.settings.maximum_day_duration);
* -> 23456
*
* // Print the complete list of records
* console.log(diary.records);
* -> [
* {
* // DiaryStandardRecordStatus value, usually "awake" or "asleep"
* status: "awake",
*
* // start and end time (in milliseconds past the Unix epoch), estimated if the user forgot to log some data:
* start: 12345678,
* end: 23456789,
* start_timezone: "Etc/GMT-1",
* end_timezone: "Europe/Paris",
*
* duration: 11111111, // or missing if duration is unknown
*
* // tags associated with this period:
* tags: [
* "tag 1",
* "tag 2",
* ...
* ],
*
* // comments recorded during this period:
* comments: [
* "comment with no associated timestamp",
* { time: 23456543, text: "timestamped comment" },
* ...
* ],
*
* // (estimated) day this record is assigned to:
* day_number: 1,
*
* // true if the current day number is greater than the previous record's day number:
* start_of_new_day: true,
*
* // whether this value is the primary sleep for the current day number:
* is_primary_sleep: false,
*
* // this is set if it looks like the user forgot to log some data:
* missing_record_after: true
*
* },
*
* ...
*
* ]
*
* // Print the user's current sleep/wake status:
* console.log(diary.latest_sleep_status());
* -> "awake"
*
* // Print the user's sleep statistics:
* console.log( diary.summarise_records( record => record.status == "asleep" ) );
* -> {
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* }
*
* // Print the user's day length statistics for the past 14 days:
* let cutoff = new Date().getTime() - 1000*60*60*24*14;
* console.log( diary.summarise_days( record => record.start > cutoff ) );
* -> {
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* }
*
* // Print the user's daily schedule on a 24-hour clock:
* console.log( diary.summarise_schedule();
* -> {
* sleep: { // time (GMT) when the user falls asleep:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* wake: { // time (GMT) when the user wakes up:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* }
*
* // Print the user's daily schedule on a 24-hour clock for the past 14 days:
* let cutoff = new Date().getTime() - 1000*60*60*24*14;
* console.log( diary.summarise_schedule( record => record.start > cutoff ) );
* -> {
* sleep: { // time (GMT) when the user falls asleep:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* wake: { // time (GMT) when the user wakes up:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* }
* // Print the user's daily schedule on a 25-hour clock, defaulting to Cairo's timezone:
* console.log( diary.summarise_schedule( null, 25*60*60*1000, "Africa/Cairo" ) );
* -> {
* sleep: { // time (Cairo) when the user falls asleep:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* wake: { // time (Cairo) when the user wakes up:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* }
*/
class DiaryStandard extends DiaryBase {
/**
* @param {Object} file - file contents, or object containing records
* @param {Array=} file.records - individual records from the sleep diary
* @param {number=} file.minimum_day_duration - minimum expected day duration in milliseconds
* @param {number=} file.maximum_day_duration - maximum expected day duration in milliseconds
* @param {Function=} serialiser - function to serialise output
*/
constructor(file,serialiser) {
super(file,serialiser);
if ( file["records"] && !file["file_format"] ) {
file = {
"file_format": () => "Standard",
"contents" : file,
};
}
/**
* Spreadsheet manager
* @protected
* @type {Spreadsheet}
*/
this["spreadsheet"] = new Spreadsheet(this,[
{
"sheet" : "Records",
"member" : "records",
"cells": [
{
"member": "status",
"regexp": new RegExp('^(' + Object.values(DiaryStandardRecordStatus).join('|') + ')$'),
"type" : "string",
},
{
"member" : "start",
"type" : "time",
"optional": true,
},
{
"member" : "end",
"type" : "time",
"optional": true,
},
{
"member": "start_timezone",
"type" : "string",
"optional": true,
},
{
"member": "end_timezone",
"type" : "string",
"optional": true,
},
{
"member" : "duration",
"type" : "duration",
"optional": true,
},
{
"members": ["tags"],
"export": (array_element,row,offset) => row[offset] = Spreadsheet.create_cell( (array_element["tags"]||[]).join("; ") ),
"import": (array_element,row,offset) => {
if ( row[offset]["value"] ) {
const tags = row[offset]["value"].split(/ *; */);
array_element["tags"] = tags;
}
return true;
}
},
{
"members": ["comments"],
"export": (array_element,row,offset) => row[offset] = Spreadsheet.create_cell(
(array_element["comments"]||[])
.map( c => c["time"] ? `TIME=${c["time"]} ${c["text"]}` : c )
.join("; ")
),
"import": (array_element,row,offset) => {
if ( row[offset]["value"] ) {
const comments =
row[offset]["value"]
.split(/ *; */)
.map( c => {
var time;
c = c.replace( /^TIME=([0-9]*) */, (_,t) => { time = parseInt(t,10); return '' });
return time ? { "time": time, "text": c } : c;
});
array_element["comments"] = comments;
}
return true;
},
},
{
"member": "day_number",
"type": "number",
"optional": true,
},
{
"member": "start_of_new_day",
"type": "boolean",
"optional": true,
},
{
"member": "is_primary_sleep",
"type": "boolean",
"optional": true,
},
{
"member": "missing_record_after",
"type": "boolean",
"optional": true,
},
]
},
{
"sheet" : "Settings",
"member" : "settings",
"type" : "dictionary",
"cells": [
{
"member": "minimum_day_duration",
"type" : "duration",
},
{
"member": "maximum_day_duration",
"type" : "duration",
},
],
},
]);
switch ( file["file_format"]() ) {
case "string":
try {
file = {
"file_format": () => "Standard",
"contents": /** @type (Object|null) */ (JSON.parse(file["contents"])),
}
} catch (e) {
return this.invalid(file);
}
if ( file["contents"]["file_format"] != "Standard" ) {
return this.invalid(file);
}
// FALL THROUGH
default:
if ( this.initialise_from_common_formats(file) ) return;
let contents = file["contents"];
if (
file["file_format"]() != "Standard" ||
contents === null ||
typeof(contents) != "object" ||
!Array.isArray(contents["records"])
) {
return this.invalid(file);
}
/**
* Individual records from the sleep diary
*
* @type Array<DiaryStandardRecord>
*/
this["records"] = contents["records"]
.map( r => Object.assign({},r) )
.sort( (a,b) => ( a["start"] - b["start"] ) || ( a["end"] - b["end"] ) )
;
const settings = contents["settings"]||contents,
minimum_day_duration = settings["minimum_day_duration"] || 16*60*60*1000,
maximum_day_duration = settings["maximum_day_duration"] || minimum_day_duration*2
;
this["settings"] = {
/**
* Minimum expected length for a day
*
* <p>We calculate day numbers by looking for "asleep"
* records at least this far apart.</p>
*
* @type number
*/
"minimum_day_duration": minimum_day_duration,
/**
* Maximum expected length for a day
*
* <p>We calculate skipped days by looking for "asleep"
* records at this far apart</p>
*
* @type number
*/
"maximum_day_duration": maximum_day_duration,
};
/*
* Calculate extra information
*/
let day_start = 0,
day_number = 0,
prev = {
"status": "",
"day_number": -1
},
day_sleeps = [],
sleep_wake_record = prev
;
this["records"]
.forEach( r => {
["start","end"].forEach( key => {
if ( r[key] == undefined ) delete r[key];
});
["tags","comments"].forEach( key => {
if ( !(r[key]||[]).length ) delete r[key];
});
if ( !r.hasOwnProperty("duration") ) {
r["duration"] = r["end"] - r["start"];
if ( isNaN(r["duration"]) ) delete r["duration"];
}
if ( r.hasOwnProperty("start_of_new_day") ) {
if ( r["start_of_new_day"] ) {
day_start = r["start"];
}
} else {
r["start_of_new_day"] =
r["status"] == "asleep" &&
r["start"] > day_start + minimum_day_duration
;
}
if ( r.hasOwnProperty("day_number") ) {
day_number = r["day_number"];
} else {
if ( r["start_of_new_day"] ) {
if ( r["start"] > day_start + maximum_day_duration ) {
// assume we skipped a day
day_number += 2;
} else {
day_number += 1;
}
day_start = r["start"];
}
r["day_number"] = day_number;
}
if ( r["status"] == "awake" || r["status"] == "asleep" ) {
if ( !sleep_wake_record.hasOwnProperty("missing_record_after") ) {
sleep_wake_record["missing_record_after"] = (
r["status"] == sleep_wake_record["status"]
);
}
sleep_wake_record = r;
}
if ( r["status"] == "asleep" ) {
if ( (day_sleeps[r["day_number"]]||{"duration":-Infinity})["duration"] < r["duration"] ) {
day_sleeps[r["day_number"]] = r;
}
}
if ( r.hasOwnProperty("comments") ) {
const comments = r["comments"];
if ( comments === undefined ) {
delete r["comments"];
} else if ( !Array.isArray(comments) ) {
r["comments"] = [ comments ];
}
}
prev = r;
})
;
day_sleeps.forEach( r => {
if ( r && !r.hasOwnProperty("is_primary_sleep") ) r["is_primary_sleep"] = true;
});
}
}
["to"](to_format) {
switch ( to_format ) {
case "output":
let contents = Object.assign({"file_format":this["file_format"]()},this);
delete contents["spreadsheet"];
return this.serialise({
"file_format": () => "string",
"contents": JSON.stringify(contents),
});
default:
return super["to"](to_format);
}
}
["merge"](other) {
let records = {};
[ this, other["to"](this["file_format"]()) ].forEach(
f => f["records"].forEach(
r => records[[ r["start"], r["end"], r["status"] ].join()] = r
)
);
this["records"] = Object.values(records).sort( (a,b) => ( a["start"] - b["start"] ) || ( a["end"] - b["end"] ) );
return this;
}
["file_format"]() { return "Standard"; }
["format_info"]() {
return {
"name": "Standard",
"title": "Standardised diary format",
"url": "/src/Standard",
"extension": ".json",
"timezone": "yes",
}
}
/**
* Internal function used by summarise_*
* @param {Array<Array<number>>} durations_and_timestamps - event durations and associated timestamps
* @param {number=} rolling_average_max - maximum allowed value for the rolling average (e.g. 24 hours)
* @private
*/
static summarise(durations_and_timestamps,rolling_average_max) {
let defined_durations = durations_and_timestamps
.map( r => r[0] )
.filter( r => r !== undefined ),
total_durations = defined_durations.length
;
if ( !total_durations ) return null;
let a_plus_b = (a,b) => a+b,
a_minus_b = (a,b) => a-b,
sum_of_squares = (a,r) => a + Math.pow(r - mean, 2) ,
rolling_window = [],
sorted_durations = defined_durations.sort(a_minus_b),
interquartile_durations = sorted_durations.slice(
Math.round( sorted_durations.length*0.25 ),
Math.round( sorted_durations.length*0.75 ),
),
mean,
untrimmed_mean = defined_durations.reduce(a_plus_b) / (total_durations||1),
interquartile_mean = interquartile_durations.reduce(a_plus_b) / (interquartile_durations.length||1),
ret = {
"average": untrimmed_mean,
"mean": untrimmed_mean,
"interquartile_mean": interquartile_mean,
"median": sorted_durations[Math.floor(sorted_durations.length/2)],
"interquartile_range": (
interquartile_durations[interquartile_durations.length-1] -
interquartile_durations[0]
),
"durations": durations_and_timestamps.map( r => r ? r[0] : undefined ),
"timestamps": durations_and_timestamps.map( r => r ? r[1] : undefined ),
"interquartile_durations": interquartile_durations,
"rolling_average": durations_and_timestamps.map(
rolling_average_max
? (_,n) => {
/*
* work around a similar issue to that described in summarise_schedule(),
* but using a different approach.
*
* Unlike summarise_schedule(), we want to switch between earlier and later
* values for every calculation, and can assume the rolling average
* has values in a relatively small range.
*/
if ( n < 14 ) return undefined;
const rolling_window = durations_and_timestamps
.slice(Math.max(0,n-13),n+1)
.map( r => r[0] )
.filter( r => r !== undefined ),
extremes = [ 0, 0 ]
;
rolling_window.forEach( duration => {
if ( duration<rolling_average_max*1/4 ) {
++extremes[0]
} else if ( duration>rolling_average_max*3/4 ) {
++extremes[1];
}
});
return (
rolling_window.length
? (
rolling_window.reduce( (a,b) => a + b )
+ ( extremes[0] < extremes[1]
? extremes[0]* rolling_average_max
: extremes[1]*-rolling_average_max
)
) / rolling_window.length
: undefined
);
}
: (_,n) => {
const rolling_window = durations_and_timestamps
.slice(Math.max(0,n-13),n+1)
.map( r => r[0] )
.filter( r => r !== undefined )
;
return (
( n >= 14 && rolling_window.length )
? rolling_window.reduce( (a,b) => a+b ) / rolling_window.length
: undefined
);
}
),
};
// calculate standard deviations:
mean = untrimmed_mean;
ret["standard_deviation"] = Math.sqrt( defined_durations.reduce(sum_of_squares,0) / total_durations );
mean = interquartile_mean;
ret["interquartile_standard_deviation"] = Math.sqrt( interquartile_durations.reduce(sum_of_squares,0) / interquartile_durations.length );
return ret;
}
/**
* Summary statistics (based on individual records)
*
* <p>Because real-world data tends to be quite messy, and because
* different users have different requirements, we provide several
* summaries for the data:</p>
*
* <ul>
* <li><tt>average</tt> is the best guess at what the
* user would intuitively consider the average duration of a
* record. The exact calculation is chosen from the list
* below, and may change in future. It is currently the
* <tt>trimmed_mean</tt>. If you don't have any specific
* requirements, you should use this and ignore the
* others.</li>
* <li><tt>mean</tt> and <tt>standard_deviation</tt> are
* traditional summary statistics for the duration, but are
* not recommended because real-world data tends to skew
* these values higher than one would expect.</li>
* <li><tt>interquartile_mean</tt> and <tt>interquartile_standard_deviation</tt>
* produce more robust values in cases like ours, because they
* ignore the highest and lowest few records.
* <li><tt>median</tt> and <tt>interquartile_range</tt> produce
* more robust results, but tend to be less representative when
* there are only a few outliers in the data.
* <li><tt>durations</tt> and <tt>interquartile_durations</tt>
* are the raw values the other statistics were created from.
* </ul>
*
* @public
*
* @param {function(*)=} filter - only examine records that match this filter
*
* @return MaybeDiaryStandardStatistics
*
* @example
* console.log( diary.summarise_records( record => record.status == "asleep" ) );
* -> {
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* }
*
*/
["summarise_records"](filter) {
return DiaryStandard.summarise(
( filter ? this["records"].filter(filter) : this["records"] )
.map( r => [ r["duration"], r["start"]||r["end"] ] )
);
}
/**
* Summary statistics (based on records grouped by day_number)
*
* <p>Similar to {@link DiaryStandard#summarise_records}, but
* groups records by day_number.</p>
*
* @public
*
* @see [summarise_records]{@link DiaryStandard#summarise_records}
* @tutorial Graph your day lengths
*
* @param {function(*)=} filter - only examine records that match this filter
*
* @return MaybeDiaryStandardStatistics
*
* @example
* console.log( diary.summarise_days( record => record.start > cutoff ) );
* -> {
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* }
*/
["summarise_days"](filter) {
let starts = [];
// get the earliest start time for each day:
( filter ? this["records"].filter(filter) : this["records"] )
.forEach( r => {
const day_number = r["day_number"];
if (
r["start_of_new_day"]
// "start" of new day is unreliable for the first day:
&& day_number
) {
starts[day_number] = r["start"];
}
});
// remove leading undefined start times:
while ( starts.length && !starts[0] ) {
starts.shift();
}
// calculate day duration relative to previous day:
let durations = [];
for ( let n=1; n<starts.length; ++n ) {
if ( starts[n] && starts[n-1] ) {
durations[n-1] = [ starts[n] - starts[n-1], starts[n-1] ];
}
}
return DiaryStandard.summarise(durations);
}
/**
* Summary statistics about the number of times an event occurs per day
*
* <p>Similar to {@link DiaryStandard#summarise_days}, but
* looks at totals instead of sums.</p>
*
* <p>The <tt>summarise_*</tt> functions examine sums, so missing
* values are treated as <tt>undefined</tt>. This function
* examines totals, so missing values are treated as <tt>0</tt>.
* The <tt>record_filter</tt> and <tt>day_filter</tt> parameters
* allow you to exclude days and records separately.</p>
*
* @public
*
* @see [summarise_records]{@link DiaryStandard#summarise_records}
*
* @param {function(*)=} record_filter - only examine records that match this filter
* @param {function(*)=} day_filter - only examine days that match this filter
*
* @return MaybeDiaryStandardStatistics
*
* @example
* console.log( diary.total_per_day(
* record => record.status == "asleep", // only count sleep records
* record => record.start > cutoff // ignore old records
* ) );
* -> {
* average : 1.234,
* mean : 1.345,
* interquartile_mean : 1.234,
* standard_deviation: 0.123,
* interquartile_standard_deviation: 0.012,
* median : 1,
* interquartile_range : 1,
* counts : [ undefined, 1, undefined, ... ],
* interquartile_counts : [ 1, 1, 2, 1, 1, 0, ... ],
* // included for compatibility with summarise_* functions:
* durations : [ undefined, 1, undefined, ... ],
* interquartile_durations : [ 1, 1, 2, 1, 1, 0, ... ],
* }
*/
["total_per_day"](record_filter,day_filter) {
let counts = [],
cutoff = (
// duration cannot be calculated for an incomplete day:
this["records"].length
? this["records"][this["records"].length-1]["day_number"]
: 0
);
( day_filter ? this["records"].filter(day_filter) : this["records"] )
.forEach( r =>
(
counts[r["day_number"]] = counts[r["day_number"]] || [ 0, r["start"] ]
)[0] += ( record_filter && !record_filter(r) ? 0 : 1 )
);
counts = counts.slice( 1, cutoff );
// remove leading undefined start times:
while ( counts.length && counts[0] === undefined ) {
counts.shift();
}
return DiaryStandard.summarise(counts);
}
/**
* Summary statistics about daily events
*
* <p>Somewhat similar to {@link DiaryStandard#summarise_records}.</p>
*
* <p>Calculates the time of day when the user is likey to wake up
* or go to sleep.</p>
*
* <p>Sleep/wake times are currently calculated based on the
* beginning/end time for each day's primary sleep, although this
* may change in future.</p>
*
* <p>Times are calculated according to the associated timezone.
* For example, say you woke up in New York at 8am, flew to Los
* Angeles, went to bed and woke up again at 8am local time. You
* would be counted as waking up at 8am both days, even though 27
* hours had passed between wake events.</p>
*
* <p>Records without a timezone are treated as if they had the
* environment's default timezone</p>
*
* @public
*
* @see [summarise_records]{@link DiaryStandard#summarise_records}
*
* @param {function(*)=} [filter=null] - only examine records that match this filter
* @param {number=} [day_length=86400000] - times of day are calculated relative to this amount of time
* @param {string=} [timezone=system_timezone] - default timezone for records
*
* @return {{
* sleep : MaybeDiaryStandardStatistics,
* wake : MaybeDiaryStandardStatistics
* }}
*
* @example
* console.log( diary.summarise_schedule() );
* -> {
* sleep: { // time when the user falls asleep:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* wake: { // time when the user wakes up:
* average : 12345.678,
* mean : 12356.789,
* interquartile_mean : 12345.678,
* standard_deviation: 12.56,
* interquartile_standard_deviation: 12.45,
* median : 12345,
* interquartile_range : 12,
* durations : [ undefined, 12345, undefined, ... ],
* interquartile_durations : [ 10000, 10001 ... 19998, 19999 ],
* },
* }
*/
["summarise_schedule"](filter,day_length,timezone) {
/*
* Note: this function needs to work around a weird issue.
*
* If a user went to sleep at 00:10am then at 11:50pm, a naive
* algorithm might calculate the user's mean sleep time to be
* midday instead of midnight. To avoid this problem, we
* calculate values twice - once normally and once with all
* numbers rotated by half the day length. Then we use
* whichever one has the lowest standard deviation.
*/
const hours = 60*60*1000;
day_length = day_length || 24*hours;
timezone = timezone || system_timezone;
const half_day_length = day_length/2;
// get the earliest start time for each day:
let sleep_early = [],
sleep_late = [],
wake_early = [],
wake_late = []
;
( filter ? this["records"].filter(filter) : this["records"] )
.forEach( r => {
if ( r["is_primary_sleep"] ) {
if ( r["start"] ) {
let time = r["start"],
tz = r["start_timezone"]||timezone;
time += (
DiaryBase.date(time,tz)["offset"]()
) * 60000;
sleep_early.push([ time %day_length, time ]);
sleep_late .push([(time+half_day_length)%day_length, time ]);
}
if ( r["end"] ) {
let time = r["end"],
tz = r["end_timezone"]||timezone;
time += (
DiaryBase.date(time,tz)["offset"]()
) * 60000;
wake_early .push([ time %day_length, time ]);
wake_late .push([(time+half_day_length)%day_length, time ]);
}
}
});
let sleep_stats_early = DiaryStandard.summarise(sleep_early,day_length),
sleep_stats_late = DiaryStandard.summarise(sleep_late ,day_length),
wake_stats_early = DiaryStandard.summarise( wake_early,day_length),
wake_stats_late = DiaryStandard.summarise( wake_late ,day_length)
;
[
[ sleep_stats_late, sleep_stats_early ],
[ wake_stats_late, wake_stats_early ],
].forEach( stats => {
if ( stats[0] ) {
[ "average", "mean", "interquartile_mean", "median" ].forEach(
key => stats[0][key] = ( stats[0][key] + half_day_length ) % day_length
);
[ "durations", "interquartile_durations" ].forEach(
key => stats[0][key] = stats[1][key]
);
stats[0]["rolling_average"] = stats[1]["rolling_average"];
}
});
return {
"wake": (
(wake_stats_early||{})["standard_deviation"] < (wake_stats_late||{})["standard_deviation"]
? wake_stats_early
: wake_stats_late
),
"sleep": (
(sleep_stats_early||{})["standard_deviation"] < (sleep_stats_late||{})["standard_deviation"]
? sleep_stats_early
: sleep_stats_late
),
};
}
/**
* List of activities, grouped by day
*
* <p>It can be useful to display a diary as a series of columns
* or rows, each containing the activities for that day. This
* function returns a structure that accounts for the following
* issues:</p>
*
* <p><strong>Multi-day records</strong> - the function returns a
* list of <em>days</em>, which contain <em>activities</em> -
* parts of a record confined within that day.</p>
*
* <p><strong>Daylight savings time</strong> - if the timezone
* observes daylight savings time (e.g. `Europe/London` instead of
* `Etc/GMT`), the function will modify days to account for DST
* changes (so a 24-hour diary will start at the same time
* every day, even if that means having a 23- or 25-hour day).</p>
*
* <p><strong>Missing days</strong> - if a user skips a day, or
* stops recording altogether for months or even years, the
* function will leave a gap in the array for each missing
* day.</p>
*
* <p>Not including workarounds for a few edge cases, the start
* and end times for days are calculated like this:</p>
*
* <ol>
* <li>Find the start of the first day:
* <ol>
* <li>find the earliest date in the list of records
* <li>find the last time at or before that date
* where the time equals `day_start` in `timezone`
* <li>if that date would be invalid (because a DST change
* causes that time to be skipped), move backwards by the DST
* change duration.
* </ol>
* </li>
* <li>Find the start of the next day:
* <ol>
* <li>move forwards in time by `day_stride`
* <li>move backwards by the difference between the start and end timezone offsets<br>
* (so a 24-hour `day_stride` will always start at the same time of day)
* </ol>
* </li>
* <li>Create a new day object with the start of the current and next day</li>
* <li>Continue moving forward one day at a time until we reach the final record
* </ul>
*
* <p>An activity represents the fraction of a record that exists
* within the current day. For example, a record that lasted two
* days would have two associated records. Activity times are
* decided like this:</p>
*
* <ul>
* <li>if a record has neither a `start` nor `end` time, no activities are created
* <li>if a record has exactly one `start` or `end` time, one activity is created.
* The activity has a `time` equal to whichever was defined.
* <li>if a record has both `start` and `end`, and both are within the same day,
* one activity is created. The activity has a `time` halfway between the two.
* <li>if a record has both `start` and `end` that span multiple days,
* one activity is created for each day the record is active. The first
* activity has a `time` halfway between `start` and the end of that day.
* middle activities have a `time` equal to the middle of the day. The final
* activity has a `time` halfway between the start of that day and `end`
* </ul>
*
* <p>Each activity includes a `type`, which is one of:</p>
*
* <ul>
* <li>`start-end` - complete duration of an event
* <li>`start-mid` - start of an event that spans multiple days
* <li>`mid-mid` - middle of an event that spans multiple days
* <li>`mid-end` - end of an event that spans multiple days
* <li>`start-unknown` - start of an event with an undefined end time
* <li>`unknown-end` - end of an event with an undefined start time
* </ul>
*
* <p>If a `segment_stride` argument is passed, segments for a day
* are calculated like this:</p>
*
* <ol>
* <li>create a segment that starts at the `start` time
* <li>check whether the DST offset is the same at the start and end of the day
* <ul>
* <li>if not, create segments `segment_stride` apart
* <li>otherwise...
* <ul>
* <li>continue forwards until the current segment's end time is
* greater than the `end` time, or the timezone's DST status
* changes
* <li>stop unless a DST change was crossed
* <li>create a new segment that ends at the `end` time
* <li>continue backwards until the current segment's start time
* is less than or equal to the last segment from amove
* <li>sort all segments by start time
* </ul>
* </li>
* </ol>
*
* <p>The algorithm above should produce intuitive dates in most
* cases, but produces unexpected behaviour if there is more than
* one DST change in a single day.</p>
*
* <p>Be aware that some timezones have [a 45-minute offset from
* UTC](https://en.wikipedia.org/wiki/UTC%2B05:45), and [even more
* esoteric
* timezones](https://en.wikipedia.org/wiki/UTC%E2%88%9200:25:21),
* existed in the 20th century. You may need to test your program
* carefully to avoid incorrect behaviour in some cases.</p>
*
* @public
*
* @param {string} [timezone=system_timezone] - display dates in this timezone
* @param {number} [day_start=64800000] - start the first new day at this time (usually 6pm)
* @param {number} [day_stride=86400000] - amount of time to advance each day (usually 24 hours)
* @param {number=} [segment_stride] - amount of time to advance each segment
*
* @example
* console.log( diary.daily_activities() );
* -> [
* {
* "start" : 123456789, // Unix time when the day starts
* "end" : 234567890, // start + day_stride - dst_change
* "duration": 11111111, // end - start
* "id" : "2020-03-30T18:00:00.000 Etc/GMT" // ISO 8601 equivalent of "start"
* "year" : 2020,
* "month" : 2, // zero-based month number
* "day" : 29, // zero-based day of month
* "activities": [
* {
* "start" : 123459999, // time when the event started (optional)
* "end" : 234560000, // time when the event ended (optional)
* "time" : 200000000, // time associated with the event (required)
* "offset_start": 0.1, // ( start - day.start ) / day.duration
* "offset_end" : 0.9, // ( end - day.start ) / day.duration
* "offset_time" : 0.5, // ( time - day.start ) / day.duration
* "type" : "start-end" // see above for list of types
* "record" : { ... }, // associated record
* "index" : 0, // this is the nth activity for this record
* },
* ],
* "activity_summaries": {
* "asleep": {
* "first_start": "2020-03-30T20:00:00.000 Etc/GMT", // start of first activity
* "first_end" : "2020-03-31T06:00:00.000 Etc/GMT", // end of first activity
* "last_start": "2020-03-31T14:00:00.000 Etc/GMT", // start of last activity
* "last_end" : "2020-03-31T14:30:00.000 Etc/GMT", // end of last activity
* "duration" : 37800000, // total milliseconds (NaN if some activities have undefined duration)
* },
* ...
* },
* "segments": [
* {
* "dst_state": "on" // or "off" or "change-forward" or "change-back"
* "year" : 2020,
* "month" : 2, // zero-based month number
* "day" : 29, // zero-based day of month
* "hour" : 18,
* "minute": 0,
* "second": 0,
* "id" : "2020-03-30T18:00:00.000 Etc/GMT"
* },
* ...
* ],
* },
* ...
* ]
*/
["daily_activities"]( timezone, day_start, day_stride, segment_stride ) {
timezone = timezone || system_timezone;
day_start = ( day_start === undefined ) ? 1000*60*60*18 : day_start; // 6pm
day_stride = day_stride || 1000*60*60*24; // 24 hours
let records = this["records"].sort( (a,b) => (a["start"]||a["end"]) - (b["start"]||b["end"]) ),
today = records.find( r => r["start"]||r["end"] ),
next_dst_change = DiaryBase.next_dst_change( today?(today["start"]||today["end"])-1:0, timezone ),
timezone_str = timezone.replace(/^([A-Za-z])/," $1"),
offset,
offset_ms,
is_dst,
tomorrow,
day_index = 0,
days = [],
current_activities,
record_index
;
const milliseconds = DiaryBase.tc()["TimeUnit"]["Millisecond"],
advance_to = timestamp => {
let segment_dst_change = next_dst_change;
while ( tomorrow["unixUtcMillis"]() <= timestamp ) {
segment_dst_change = next_dst_change;
current_activities = 0;
++day_index;
today = tomorrow;
tomorrow = tomorrow["add"]( day_stride, milliseconds );
if ( tomorrow["unixUtcMillis"]() >= next_dst_change ) {
next_dst_change = DiaryBase.next_dst_change(today["unixUtcMillis"](),timezone);
const offset_tomorrow = tomorrow["add"]( ( today["offset"]() - tomorrow["offset"]() ) * 60 * 1000, milliseconds );
if ( today["lessThan"](offset_tomorrow) ) {
// just in case someone e.g. sets a stride less than the DST change amount
const passed_timestamp = tomorrow["unixUtcMillis"]() > timestamp;
tomorrow = offset_tomorrow;
if ( passed_timestamp ) break;
}
}
}
if ( !current_activities ) {
const today_ms = today["unixUtcMillis"](),
tomorrow_ms = tomorrow["unixUtcMillis"](),
day = days[day_index] = {
"start" : today_ms,
"end" : tomorrow_ms,
"duration" : tomorrow_ms - today_ms,
"id" : today.toString(),
"year" : today["year"] () ,
"month" : today["month"]()-1,
"day" : today["day"] ()-1,
"activities": current_activities = [],
}
;
if ( segment_stride > 0 ) {
let segments = day["segments"] = [],
segment_start = today_ms
;
while ( segment_start < tomorrow_ms ) {
const dt = new Date( segment_start + offset_ms );
segments.push({
"dst_state": is_dst?"on":"off",
"id" : dt.toISOString().replace(/Z/,timezone_str),
"year" : dt.getUTCFullYear() ,
"month" : dt.getUTCMonth (),
"day" : dt.getUTCDate ()-1,
"hour" : dt.getUTCHours (),
"minute" : dt.getUTCMinutes (),
"second" : dt.getUTCSeconds (),
});
segment_start += segment_stride;
if ( segment_start >= segment_dst_change ) {
const time = DiaryBase.date( segment_start, today["zone"]()["name"]() );
segments[segments.length-1]["dst_state"] = (
"change-"
+ ( is_dst ? "back" : "forward" )
);
offset = time["offsetDuration"]();
offset_ms = offset["milliseconds"]();
is_dst = !offset["equals"]( today["standardOffsetDuration"]() );
segment_dst_change = DiaryBase.next_dst_change(time["unixUtcMillis"](),timezone);
}
}
}
}
},
build_activity = (record,activity) => {
activity["time"] = (
(activity["start"]||activity["end"]) +
(activity["end"]||activity["start"])
) / 2;
activity["record"] = record;
activity["index"] = record_index++;
//activity["related"] = related; // NO! Would make `days` impossible to stringify
const day = days[days.length-1],
start = day["start"],
duration = day["duration"]
;
[ "start", "end", "time" ].forEach( key => {
if ( activity[key] ) {
activity["offset_"+key] = ( activity[key] - start ) / duration
}
});
return activity;
}
;
if ( today ) {
// get the base date:
today = DiaryBase.date(today["start"]||today["end"],timezone);
const base_time = today["unixUtcMillis"]() - today["startOfDay"]()["unixUtcMillis"]();
today = today["sub"](
( ( base_time < day_start ) ? 1000*60*60*24 : 0 ) +
base_time - day_start, milliseconds,
);
tomorrow = today["add"]( day_stride, milliseconds );
const offset_tomorrow = tomorrow["add"]( ( today["offset"]() - tomorrow["offset"]() ) * 60 * 1000, milliseconds );
if ( offset_tomorrow["greaterThan"](today) ) {
// just in case someone e.g. sets a stride less than the DST change amount
tomorrow = offset_tomorrow;
}
offset = today["offsetDuration"]();
offset_ms = offset["milliseconds"]();
is_dst = !offset["equals"]( today["standardOffsetDuration"]() );
// convert records to days
records.forEach(
record => {
record_index = 0;
let start_timestamp = record["start"],
end_timestamp = record["end"],
related = []
;
if ( start_timestamp ) {
advance_to(start_timestamp);
if ( end_timestamp ) {
if ( tomorrow["unixUtcMillis"]() >= end_timestamp ) {
current_activities.push(build_activity(record,{
"start": start_timestamp,
"end" : end_timestamp,
"type" : "start-end",
}));
} else {
current_activities.push(build_activity(record,{
"start": start_timestamp,
"end" : tomorrow["unixUtcMillis"](),
"type" : "start-mid",
}));
for (;;) {
advance_to(tomorrow["unixUtcMillis"]()+1);
if ( tomorrow["unixUtcMillis"]() >= end_timestamp ) break;
current_activities.push(build_activity(record,{
"start": today["unixUtcMillis"](),
"end" : tomorrow["unixUtcMillis"](),
"type" : "mid-mid",
}));
}
current_activities.push(build_activity(record,{
"start": today["unixUtcMillis"](),
"end" : end_timestamp,
"type" : "mid-end",
}));
}
} else {
current_activities.push(build_activity(record,{
"start": start_timestamp,
"type" : "start-unknown",
}));
}
} else if ( end_timestamp ) {
advance_to(end_timestamp);
current_activities.push(build_activity(record,{
"end" : end_timestamp,
"type": "unknown-end",
}));
}
});
}
days.forEach( day => {
const activity_summaries = day["activity_summaries"] = {};
day["activities"].forEach( activity => {
const status = activity["record"]["status"],
summary = (
activity_summaries[status] =
activity_summaries[status] || {
"duration": 0,
}
)
;
["start","end"].forEach( se => {
let time = activity[se];
if ( time !== undefined ) {
time = DiaryBase.date( time, timezone ).toString();
summary["last_"+se] = time;
if ( summary["first_"+se] == undefined ) {
summary["first_"+se] = time;
}
}
});
if ( activity["start"] && activity["end"] ) {
summary["duration"] += activity["end"] - activity["start"];
} else {
summary["duration"] = NaN;
}
});
});
return days;
}
/**
* Latest sleep/wake status
*
* @public
*
* @return {string} "awake", "asleep" or "" (for an empty diary)
*/
["latest_sleep_status"]() {
for ( let n=this["records"].length-1; n>=0; --n ) {
let status = this["records"][n]["status"];
if ( status == "awake" || status == "asleep" ) return status;
}
return "";
}
/**
* Midpoint between primary wake and sleep events on the most recent day
*
* @public
*
* @return {number|undefined} Unix time
*/
["latest_daytime_midpoint"]() {
let prev_primary_sleep, expected_day = NaN;
for ( let n=this["records"].length-1; n>=0; --n ) {
const r = this["records"][n];
if ( r["is_primary_sleep"] ) {
if ( r["end"] && r["day_number"] == expected_day ) {
return ( r["end"] + prev_primary_sleep ) / 2;
} else if ( r["start"] ) {
prev_primary_sleep = r["start"];
expected_day = r["day_number"] - 1;
}
}
}
return undefined;
}
/**
* Assign a new timezone to all records, leaving times unchanged
*
* @public
* @param {string} [timezone=system_timezone] - new timezone
*
*/
["update_timezone"](timezone) {
timezone = timezone || system_timezone;
this["records"].forEach( r =>
["start","end"].forEach( v => { if ( r[v] ) r[v+"_timezone"] = timezone })
);
}
/**
* Reinterpret all records as having actually been in the specified timezone
*
* Consider a spreadsheet with an event starting at "2020-01-01 00:00".
* With no further information, we would have to assume that time was UTC,
* even if a user actually intended it to be in some other timezone.
* This function reinterprets times to correct that mistake.
*
* @public
* @param {string} [timezone=system_timezone] - originally intended timezone
*
*/
["reset_to_timezone"](timezone) {
timezone = timezone || system_timezone;
[ "start", "end" ].forEach( v => {
let records = (
this["records"]
.filter( r => r[v] )
.sort( (a,b) => a[v] - b[v] )
),
next_dst_change = 0,
offset_ms
;
records.forEach( r => {
if ( r[v] >= next_dst_change ) {
offset_ms = DiaryBase.date( r[v], timezone )["offsetDuration"]()["milliseconds"]();
next_dst_change = DiaryBase.next_dst_change( r[v]+1, timezone );
}
r[v] -= offset_ms;
r[v+"_timezone"] = timezone;
});
});
}
}
DiaryBase.register(DiaryStandard);