Source: Fitbit/engine.js

/*
 * 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";

/**
 * @public
 * @unrestricted
 * @augments DiaryBase
 *
 * @example
 * let diary = new_sleep_diary(contents_of_my_file));
 *
 * console.log(diary.records);
 * -> [
 *      {
 *        "start"               : 12345678,
 *        "end"                 : 23456789,
 *        "Start Time"          : "2010-10-10 8:09PM",
 *        "End Time"            : "2010-10-11 7:08AM",
 *        "Minutes Asleep"      : "500",
 *        "Minutes Awake"       : "50",
 *        "Number of Awakenings": "30",
 *        "Time in Bed"         : "500",
 *        "Minutes REM Sleep"   : "100",
 *        "Minutes Light Sleep" : "300",
 *        "Minutes Deep Sleep"  : "100",
 *      },
 *      ...
 *    ]
 *
 */
class DiaryFitbit extends DiaryBase {

    /**
     * @param {Object} file - file contents
     * @param {Function=} serialiser - function to serialise output
     */
    constructor(file,serialiser) {

        super(file,serialiser); // call the DiaryBase constructor

        /*
         * PROPERTIES
         */

        let records = [];

        /**
         * Spreadsheet manager
         * @protected
         * @type {Spreadsheet}
         */
        this["spreadsheet"] = new Spreadsheet(
            this,
            [
                // Define one object per sheet in the spreadsheet:
                {
                    "sheet" : "Records",
                    "member" : "records",
                    "cells": [
                        {
                            "member": "Start Time",
                            "type": "time",
                        },
                        {
                            "member": "End Time",
                            "type": "time",
                        },
                        {
                            "member": "Minutes Asleep",
                            "type": "number",
                        },
                        {
                            "member": "Minutes Awake",
                            "type": "number",
                        },
                        {
                            "member": "Number of Awakenings",
                            "type": "number",
                            "optional": true,
                        },
                        {
                            "member": "Time in Bed",
                            "type": "number",
                            "optional": true,
                        },
                        {
                            "member": "Minutes REM Sleep",
                            "type": "number",
                            "optional": true,
                        },
                        {
                            "member": "Minutes Light Sleep",
                            "type": "number",
                            "optional": true,
                        },
                        {
                            "member": "Minutes Deep Sleep",
                            "type": "number",
                            "optional": true,
                        },
                        {
                            "members": [],
                            "export": (array_element,row,offset) => true,
                            "import": (array_element,row,offset) => {
                                array_element["end"] = array_element["End Time"];
                                array_element["start"] = array_element["Start Time"];
                                return true;
                            },
                        },
                    ],
                },
            ]
        );

        /*
         * We use a regex-based parser here instead of the general CSV parser.
         * The file begins with a magic number "Sleep\n", which is not currently
         * handled by the general parser.  The rest of the format is simple
         * enough not to bother adding complexity elsewhere.
         */

        const fitbit_header
              = "Sleep\n"
              + "Start Time,End Time,Minutes Asleep,Minutes Awake,Number of Awakenings,Time in Bed,Minutes REM Sleep,Minutes Light Sleep,Minutes Deep Sleep\n"
        ;

        const fitbit_footer = "\n";

        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)"';

        const fitbit_number       = '"([0-9][0-9,]*)"';
        const fitbit_maybe_number = '"([0-9][0-9,]*|N/A)"';

        const fitbit_line
              = fitbit_timestamp
              + ',' + fitbit_timestamp
              + ',' + fitbit_number // Minutes Asleep
              + ',' + fitbit_number // Minutes Awake
              + ',' + fitbit_maybe_number // Number of Awakenings
              + ',' + fitbit_maybe_number // Time in Bed
              + ',' + fitbit_maybe_number // Minutes REM Sleep
              + ',' + fitbit_maybe_number // Minutes Light Sleep
              + ',' + fitbit_maybe_number // Minutes Deep Sleep
              + "\n"
        ;

        const fitbit_file_re = new RegExp(
              '^'   + fitbit_header
            + '(?:' + fitbit_line   + ')*'
            +         fitbit_footer + '$',
            'i'
        );

        function parse_timestamp( year, month, day, hour, minute, ap ) {
            year = parseInt(year,10);
            month = parseInt(month,10);
            day = parseInt(day,10);
            hour = parseInt(hour,10);
            if ( hour == 12 ) {
                if ( ap == 'A' ) hour = 0;
            } else if ( ap == 'P' ) {
                hour += 12;
            }
            minute = parseInt(minute,10);
            if ( day > 31 ) { // DD-MM-YYYY instead of YYYY-MM-DD
                return new Date(day, month-1, year, hour, minute).getTime();
            } else {
                return new Date(year, month-1, day, hour, minute).getTime();
            }
        }

        function parse_number(str) {
            return parseInt(str.replace(/,/g,''),10);
        }

        function parse_maybe_number(str) {
            return ( str == "N/A" ) ? null : parse_number(str);
        }

        switch ( file["file_format"]() ) {

        case "string":

            const contents = file["contents"];
            if ( !fitbit_file_re.test(contents) ) {

                return this.invalid(file);

            } else {

                contents.replace(
                    new RegExp(fitbit_line,'gi'),
                    (_,
                     start_time, start_year,start_month,start_day,start_hour,start_minute,start_ap,
                     end_time, end_year,end_month,end_day,end_hour,end_minute,end_ap,
                     minutes_asleep,minutes_awake,number_of_awakenings,time_in_bed,minutes_rem_sleep,minutes_light_sleep,minutes_deep_sleep
                    ) => {
                        let end = parse_timestamp( end_year, end_month, end_day, end_hour, end_minute, end_ap.toUpperCase() ),
                            record = {
                                "End Time"            : end,
                                "Minutes Asleep"      : parse_number(minutes_asleep),
                                "Minutes Awake"       : parse_number(minutes_awake),
                                "Number of Awakenings": parse_maybe_number(number_of_awakenings),
                                "Time in Bed"         : parse_maybe_number(time_in_bed),
                                "Minutes REM Sleep"   : parse_maybe_number(minutes_rem_sleep),
                                "Minutes Light Sleep" : parse_maybe_number(minutes_light_sleep),
                                "Minutes Deep Sleep"  : parse_maybe_number(minutes_deep_sleep),
                                "end"                 : end,
                            };
                        record["Start Time"] = record["start"] = end - ( record["Minutes Asleep"] + record["Minutes Awake"] ) * 60*1000;
                        records.push(record);
                    }
                );

                this["records"] = records;

            }
            break;

        default:

            if ( this.initialise_from_common_formats(file) ) {
                this["records"].forEach(
                    record => [
                        "Number of Awakenings",
                        "Time in Bed",
                        "Minutes REM Sleep",
                        "Minutes Light Sleep",
                        "Minutes Deep Sleep"
                    ].forEach( key => Object.prototype.hasOwnProperty.call(record,key) || ( record[key] = null ) )
                );
                return;
            }

            /**
             * Individual records from the sleep diary
             * @type {Array}
             */
            this["records"] = (
                file["to"]("Standard")["records"]
                    .filter( r => r["status"] == "asleep" )
                    .map( (r,n) => ({
                        "start"               : r["start"],
                        "end"                 : r["end"  ],
                        "Start Time"          : r["start"],
                        "End Time"            : r["end"],
                        "Minutes Asleep"      : Math.round( ( r["end"] - r["start"] ) / (60*1000) ),
                        "Minutes Awake"       : 0,
                        "Number of Awakenings": null,
                        "Time in Bed"         : null,
                        "Minutes REM Sleep"   : null,
                        "Minutes Light Sleep" : null,
                        "Minutes Deep Sleep"  : null,
                    }))
            );

            break;

        }

    }

    ["to"](to_format) {

        switch ( to_format ) {

        case "output":

            return this.serialise({
                "file_format": () => "string",
                "contents": [
                    "Sleep",
                    "Start Time,End Time,Minutes Asleep,Minutes Awake,Number of Awakenings,Time in Bed,Minutes REM Sleep,Minutes Light Sleep,Minutes Deep Sleep",
                ].concat(
                    this["records"].map(
                        r => [
                            "Start Time",
                            "End Time",
                        ].map(
                            date => {
                                date = new Date(r[date]);
                                const hours = date["getHours"]();
                                return (
                                    '"' +
                                    date["getFullYear"]() +
                                    '-' +
                                    DiaryBase.zero_pad( date["getMonth"]()+1 ) +
                                    '-' +
                                    DiaryBase.zero_pad( date["getDate" ] () ) +
                                    ' ' +
                                    ( ( hours % 12 ) || 12 ) +
                                    ':' +
                                    DiaryBase.zero_pad( date["getMinutes"]() ) +
                                    ( ( hours >= 12 ) ? 'PM' : 'AM' ) +
                                    '"'
                                );
                            }
                        ).concat([
                            "Minutes Asleep",
                            "Minutes Awake",
                            "Number of Awakenings",
                            "Time in Bed",
                            "Minutes REM Sleep",
                            "Minutes Light Sleep",
                            "Minutes Deep Sleep"
                        ].map(
                            key => (
                                ( r[key] === null )
                                ? '"N/A"'
                                // based on https://stackoverflow.com/a/2901298
                                : '"' + r[key].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + '"'
                            )
                        )).join(',')
                    ),
                    "\n" // Fitbit format includes a trailing newline
                ).join("\n")
            });

        case "Standard":

            return new DiaryStandard({
                "records": this["records"].map(
                    r => ({
                        "status"  : "asleep",
                        "start"   : r["start"],
                        "end"     : r["end"  ],
                        "duration": r["end"] - r["start"],
                    })
                ),
            }, this.serialiser);

        default:

            return super["to"](to_format);

        }

    }

    ["merge"](other) {

        other = other["to"](this["file_format"]());

        this["records"] = this["records"].concat(
            DiaryBase.unique(
                this["records"],
                other["records"],
                ["start","end"]
            )
        )
        // Fitbit records are always in reverse chronological order:
            .sort( (a,b) => b["start"] - a["start"] )
        ;

        return this;

    }

    ["file_format"]() { return "Fitbit"; }
    ["format_info"]() {
        return {
            "name": "Fitbit",
            "title": "fitbit",
            "url": "/src/Fitbit",
            "statuses": [ "asleep" ],
            "extension": ".csv",
            "logo": "https://community.fitbit.com/html/assets/fitbit_logo_1200.png",
            "timezone": "tzdata",
        }
    }

}

DiaryBase.register(DiaryFitbit);