Source: Fitbit/engine.js

  1. /*
  2. * Copyright 2020-2022 Sleepdiary Developers <sleepdiary@pileofstuff.org>
  3. *
  4. * Permission is hereby granted, free of charge, to any person
  5. * obtaining a copy of this software and associated documentation
  6. * files (the "Software"), to deal in the Software without
  7. * restriction, including without limitation the rights to use, copy,
  8. * modify, merge, publish, distribute, sublicense, and/or sell copies
  9. * of the Software, and to permit persons to whom the Software is
  10. * furnished to do so, subject to the following conditions:
  11. *
  12. * The above copyright notice and this permission notice shall be
  13. * included in all copies or substantial portions of the Software.
  14. *
  15. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  16. * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  17. * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  18. * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  19. * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  20. * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  21. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. * SOFTWARE.
  23. */
  24. "use strict";
  25. /**
  26. * @public
  27. * @unrestricted
  28. * @augments DiaryBase
  29. *
  30. * @example
  31. * let diary = new_sleep_diary(contents_of_my_file));
  32. *
  33. * console.log(diary.records);
  34. * -> [
  35. * {
  36. * "start" : 12345678,
  37. * "end" : 23456789,
  38. * "Start Time" : "2010-10-10 8:09PM",
  39. * "End Time" : "2010-10-11 7:08AM",
  40. * "Minutes Asleep" : "500",
  41. * "Minutes Awake" : "50",
  42. * "Number of Awakenings": "30",
  43. * "Time in Bed" : "500",
  44. * "Minutes REM Sleep" : "100",
  45. * "Minutes Light Sleep" : "300",
  46. * "Minutes Deep Sleep" : "100",
  47. * },
  48. * ...
  49. * ]
  50. *
  51. */
  52. class DiaryFitbit extends DiaryBase {
  53. /**
  54. * @param {Object} file - file contents
  55. * @param {Function=} serialiser - function to serialise output
  56. */
  57. constructor(file,serialiser) {
  58. super(file,serialiser); // call the DiaryBase constructor
  59. /*
  60. * PROPERTIES
  61. */
  62. let records = [];
  63. /**
  64. * Spreadsheet manager
  65. * @protected
  66. * @type {Spreadsheet}
  67. */
  68. this["spreadsheet"] = new Spreadsheet(
  69. this,
  70. [
  71. // Define one object per sheet in the spreadsheet:
  72. {
  73. "sheet" : "Records",
  74. "member" : "records",
  75. "cells": [
  76. {
  77. "member": "Start Time",
  78. "type": "time",
  79. },
  80. {
  81. "member": "End Time",
  82. "type": "time",
  83. },
  84. {
  85. "member": "Minutes Asleep",
  86. "type": "number",
  87. },
  88. {
  89. "member": "Minutes Awake",
  90. "type": "number",
  91. },
  92. {
  93. "member": "Number of Awakenings",
  94. "type": "number",
  95. "optional": true,
  96. },
  97. {
  98. "member": "Time in Bed",
  99. "type": "number",
  100. "optional": true,
  101. },
  102. {
  103. "member": "Minutes REM Sleep",
  104. "type": "number",
  105. "optional": true,
  106. },
  107. {
  108. "member": "Minutes Light Sleep",
  109. "type": "number",
  110. "optional": true,
  111. },
  112. {
  113. "member": "Minutes Deep Sleep",
  114. "type": "number",
  115. "optional": true,
  116. },
  117. {
  118. "members": [],
  119. "export": (array_element,row,offset) => true,
  120. "import": (array_element,row,offset) => {
  121. array_element["end"] = array_element["End Time"];
  122. array_element["start"] = array_element["Start Time"];
  123. return true;
  124. },
  125. },
  126. ],
  127. },
  128. ]
  129. );
  130. /*
  131. * We use a regex-based parser here instead of the general CSV parser.
  132. * The file begins with a magic number "Sleep\n", which is not currently
  133. * handled by the general parser. The rest of the format is simple
  134. * enough not to bother adding complexity elsewhere.
  135. */
  136. const fitbit_header
  137. = "Sleep\n"
  138. + "Start Time,End Time,Minutes Asleep,Minutes Awake,Number of Awakenings,Time in Bed,Minutes REM Sleep,Minutes Light Sleep,Minutes Deep Sleep\n"
  139. ;
  140. const fitbit_footer = "\n";
  141. const fitbit_timestamp = '"(([0-9][0-9]*)-([0-9][0-9]*)-([0-9][0-9]*) ([0-9][0-9]*):([0-9][0-9]*) *([AP])M)"';
  142. const fitbit_number = '"([0-9][0-9,]*)"';
  143. const fitbit_maybe_number = '"([0-9][0-9,]*|N/A)"';
  144. const fitbit_line
  145. = fitbit_timestamp
  146. + ',' + fitbit_timestamp
  147. + ',' + fitbit_number // Minutes Asleep
  148. + ',' + fitbit_number // Minutes Awake
  149. + ',' + fitbit_maybe_number // Number of Awakenings
  150. + ',' + fitbit_maybe_number // Time in Bed
  151. + ',' + fitbit_maybe_number // Minutes REM Sleep
  152. + ',' + fitbit_maybe_number // Minutes Light Sleep
  153. + ',' + fitbit_maybe_number // Minutes Deep Sleep
  154. + "\n"
  155. ;
  156. const fitbit_file_re = new RegExp(
  157. '^' + fitbit_header
  158. + '(?:' + fitbit_line + ')*'
  159. + fitbit_footer + '$',
  160. 'i'
  161. );
  162. function parse_timestamp( year, month, day, hour, minute, ap ) {
  163. year = parseInt(year,10);
  164. month = parseInt(month,10);
  165. day = parseInt(day,10);
  166. hour = parseInt(hour,10);
  167. if ( hour == 12 ) {
  168. if ( ap == 'A' ) hour = 0;
  169. } else if ( ap == 'P' ) {
  170. hour += 12;
  171. }
  172. minute = parseInt(minute,10);
  173. if ( day > 31 ) { // DD-MM-YYYY instead of YYYY-MM-DD
  174. return new Date(day, month-1, year, hour, minute).getTime();
  175. } else {
  176. return new Date(year, month-1, day, hour, minute).getTime();
  177. }
  178. }
  179. function parse_number(str) {
  180. return parseInt(str.replace(/,/g,''),10);
  181. }
  182. function parse_maybe_number(str) {
  183. return ( str == "N/A" ) ? null : parse_number(str);
  184. }
  185. switch ( file["file_format"]() ) {
  186. case "string":
  187. const contents = file["contents"];
  188. if ( !fitbit_file_re.test(contents) ) {
  189. return this.invalid(file);
  190. } else {
  191. contents.replace(
  192. new RegExp(fitbit_line,'gi'),
  193. (_,
  194. start_time, start_year,start_month,start_day,start_hour,start_minute,start_ap,
  195. end_time, end_year,end_month,end_day,end_hour,end_minute,end_ap,
  196. minutes_asleep,minutes_awake,number_of_awakenings,time_in_bed,minutes_rem_sleep,minutes_light_sleep,minutes_deep_sleep
  197. ) => {
  198. let end = parse_timestamp( end_year, end_month, end_day, end_hour, end_minute, end_ap.toUpperCase() ),
  199. record = {
  200. "End Time" : end,
  201. "Minutes Asleep" : parse_number(minutes_asleep),
  202. "Minutes Awake" : parse_number(minutes_awake),
  203. "Number of Awakenings": parse_maybe_number(number_of_awakenings),
  204. "Time in Bed" : parse_maybe_number(time_in_bed),
  205. "Minutes REM Sleep" : parse_maybe_number(minutes_rem_sleep),
  206. "Minutes Light Sleep" : parse_maybe_number(minutes_light_sleep),
  207. "Minutes Deep Sleep" : parse_maybe_number(minutes_deep_sleep),
  208. "end" : end,
  209. };
  210. record["Start Time"] = record["start"] = end - ( record["Minutes Asleep"] + record["Minutes Awake"] ) * 60*1000;
  211. records.push(record);
  212. }
  213. );
  214. this["records"] = records;
  215. }
  216. break;
  217. default:
  218. if ( this.initialise_from_common_formats(file) ) {
  219. this["records"].forEach(
  220. record => [
  221. "Number of Awakenings",
  222. "Time in Bed",
  223. "Minutes REM Sleep",
  224. "Minutes Light Sleep",
  225. "Minutes Deep Sleep"
  226. ].forEach( key => Object.prototype.hasOwnProperty.call(record,key) || ( record[key] = null ) )
  227. );
  228. return;
  229. }
  230. /**
  231. * Individual records from the sleep diary
  232. * @type {Array}
  233. */
  234. this["records"] = (
  235. file["to"]("Standard")["records"]
  236. .filter( r => r["status"] == "asleep" )
  237. .map( (r,n) => ({
  238. "start" : r["start"],
  239. "end" : r["end" ],
  240. "Start Time" : r["start"],
  241. "End Time" : r["end"],
  242. "Minutes Asleep" : Math.round( ( r["end"] - r["start"] ) / (60*1000) ),
  243. "Minutes Awake" : 0,
  244. "Number of Awakenings": null,
  245. "Time in Bed" : null,
  246. "Minutes REM Sleep" : null,
  247. "Minutes Light Sleep" : null,
  248. "Minutes Deep Sleep" : null,
  249. }))
  250. );
  251. break;
  252. }
  253. }
  254. ["to"](to_format) {
  255. switch ( to_format ) {
  256. case "output":
  257. return this.serialise({
  258. "file_format": () => "string",
  259. "contents": [
  260. "Sleep",
  261. "Start Time,End Time,Minutes Asleep,Minutes Awake,Number of Awakenings,Time in Bed,Minutes REM Sleep,Minutes Light Sleep,Minutes Deep Sleep",
  262. ].concat(
  263. this["records"].map(
  264. r => [
  265. "Start Time",
  266. "End Time",
  267. ].map(
  268. date => {
  269. date = new Date(r[date]);
  270. const hours = date["getHours"]();
  271. return (
  272. '"' +
  273. date["getFullYear"]() +
  274. '-' +
  275. DiaryBase.zero_pad( date["getMonth"]()+1 ) +
  276. '-' +
  277. DiaryBase.zero_pad( date["getDate" ] () ) +
  278. ' ' +
  279. ( ( hours % 12 ) || 12 ) +
  280. ':' +
  281. DiaryBase.zero_pad( date["getMinutes"]() ) +
  282. ( ( hours >= 12 ) ? 'PM' : 'AM' ) +
  283. '"'
  284. );
  285. }
  286. ).concat([
  287. "Minutes Asleep",
  288. "Minutes Awake",
  289. "Number of Awakenings",
  290. "Time in Bed",
  291. "Minutes REM Sleep",
  292. "Minutes Light Sleep",
  293. "Minutes Deep Sleep"
  294. ].map(
  295. key => (
  296. ( r[key] === null )
  297. ? '"N/A"'
  298. // based on https://stackoverflow.com/a/2901298
  299. : '"' + r[key].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + '"'
  300. )
  301. )).join(',')
  302. ),
  303. "\n" // Fitbit format includes a trailing newline
  304. ).join("\n")
  305. });
  306. case "Standard":
  307. return new DiaryStandard({
  308. "records": this["records"].map(
  309. r => ({
  310. "status" : "asleep",
  311. "start" : r["start"],
  312. "end" : r["end" ],
  313. "duration": r["end"] - r["start"],
  314. })
  315. ),
  316. }, this.serialiser);
  317. default:
  318. return super["to"](to_format);
  319. }
  320. }
  321. ["merge"](other) {
  322. other = other["to"](this["file_format"]());
  323. this["records"] = this["records"].concat(
  324. DiaryBase.unique(
  325. this["records"],
  326. other["records"],
  327. ["start","end"]
  328. )
  329. )
  330. // Fitbit records are always in reverse chronological order:
  331. .sort( (a,b) => b["start"] - a["start"] )
  332. ;
  333. return this;
  334. }
  335. ["file_format"]() { return "Fitbit"; }
  336. ["format_info"]() {
  337. return {
  338. "name": "Fitbit",
  339. "title": "fitbit",
  340. "url": "/src/Fitbit",
  341. "statuses": [ "asleep" ],
  342. "extension": ".csv",
  343. "logo": "https://community.fitbit.com/html/assets/fitbit_logo_1200.png",
  344. "timezone": "tzdata",
  345. }
  346. }
  347. }
  348. DiaryBase.register(DiaryFitbit);