├── .gitignore ├── .prettierrc.json ├── docs ├── logo.png ├── favicon.ico └── index.html ├── src ├── logo.png ├── favicon.ico ├── index.html ├── settings.ts ├── app.scss ├── data.ts ├── heatmap.ts └── app.ts ├── screenshot.png ├── .eslintrc.js ├── webpack.config.js ├── LICENSE ├── package.json ├── logo.svg ├── README.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build/* 3 | .eslintcache 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitawasalreadytaken/glucoscape/HEAD/docs/logo.png -------------------------------------------------------------------------------- /src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitawasalreadytaken/glucoscape/HEAD/src/logo.png -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitawasalreadytaken/glucoscape/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitawasalreadytaken/glucoscape/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitawasalreadytaken/glucoscape/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Glucoscape 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Glucoscape 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | // API request timeouts 2 | export const TIMEOUT_STATUS = 10000 3 | export const TIMEOUT_CGM_DATA = 30000 4 | 5 | // How many days of CGM data to load 6 | export const DAYS_TO_LOAD = 14 7 | 8 | // Visualization preferences 9 | export const RESOLUTION_SECONDS = 3600 // one hour; beware that the heatmap doesn't quite work for values other than 1 hour 10 | export const COLOR_LOW = "hsl(359, 47%, 51%)" 11 | export const COLOR_ON_TARGET = "hsl(98, 32%, 45%)" 12 | export const COLOR_HIGH = "hsl(42, 100%, 40%)" 13 | export const COLOR_MISSING = "#999" 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ["standard-with-typescript", "prettier"], 7 | overrides: [ 8 | { 9 | env: { 10 | node: true, 11 | }, 12 | files: [".eslintrc.{js,cjs}"], 13 | parserOptions: { 14 | sourceType: "script", 15 | }, 16 | }, 17 | ], 18 | parserOptions: { 19 | ecmaVersion: "latest", 20 | sourceType: "module", 21 | project: ["tsconfig.json"], 22 | }, 23 | plugins: [], 24 | rules: {}, 25 | settings: {}, 26 | } 27 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | mode: "development", 5 | devtool: "source-map", 6 | entry: { 7 | app: ["./src/app.ts"], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | use: "ts-loader", 14 | exclude: /node_modules/, 15 | }, 16 | { 17 | test: /\.s[ac]ss$/i, 18 | use: [ 19 | // Creates `style` nodes from JS strings 20 | "style-loader", 21 | // Translates CSS into CommonJS 22 | "css-loader", 23 | // Compiles Sass to CSS 24 | "sass-loader", 25 | ], 26 | }, 27 | ], 28 | }, 29 | resolve: { 30 | extensions: [ 31 | ".ts", 32 | ".tsx", 33 | // '.js' // Needed for 3rd-party libraries written in pure JS 34 | ], 35 | }, 36 | output: { 37 | filename: "[name].js", 38 | path: path.resolve(__dirname, "build"), 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vita 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies 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 included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glucoscape", 3 | "version": "1.0.0", 4 | "description": "Glucoscape is a small application that helps visualize Continuous Glucose Monitoring (CGM) data quickly and intuitively. It provides a heatmap representation of time spent below range, in range, and above range, allowing for easy identification of glucose patterns.", 5 | "author": "Vita", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "webpack && cp -v src/*.html src/*.ico src/*.png build/", 9 | "dev": "npm-watch build", 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "prepare": "husky install" 12 | }, 13 | "watch": { 14 | "build": { 15 | "patterns": [ 16 | "src" 17 | ], 18 | "extensions": "ts,tsx,scss,html,png" 19 | } 20 | }, 21 | "devDependencies": { 22 | "@types/ramda": "^0.28.23", 23 | "@typescript-eslint/eslint-plugin": "^5.60.1", 24 | "css-loader": "^6.7.3", 25 | "eslint": "^8.43.0", 26 | "eslint-config-prettier": "^8.8.0", 27 | "eslint-config-standard-with-typescript": "^36.0.0", 28 | "eslint-plugin-import": "^2.27.5", 29 | "eslint-plugin-n": "^15.7.0", 30 | "eslint-plugin-promise": "^6.1.1", 31 | "husky": "^8.0.3", 32 | "lint-staged": "^13.2.3", 33 | "npm-watch": "^0.11.0", 34 | "prettier": "2.8.8", 35 | "sass": "^1.58.3", 36 | "sass-loader": "^13.2.0", 37 | "style-loader": "^3.3.1", 38 | "ts-loader": "^9.4.2", 39 | "typescript": "^4.9.5", 40 | "webpack": "^5.75.0", 41 | "webpack-cli": "^5.0.1" 42 | }, 43 | "dependencies": { 44 | "just-standard-deviation": "^2.2.0", 45 | "ramda": "^0.28.0" 46 | }, 47 | "lint-staged": { 48 | "*.{ts,tsx}": "eslint --cache --fix", 49 | "*.{ts,tsx,css,md}": "prettier --write" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 41 | 43 | 47 | 54 | 59 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/app.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body { 7 | background: #333; 8 | color: #aaa; 9 | font-family: sans-serif; 10 | font-size: 16px; 11 | padding: 8px; 12 | } 13 | 14 | a { 15 | color: #ddd; 16 | text-decoration: none; 17 | 18 | &:hover { 19 | text-decoration: underline; 20 | } 21 | } 22 | 23 | form { 24 | padding: 40px; 25 | 26 | h1 { 27 | font-size: 50px; 28 | margin: 0 0 40px; 29 | 30 | img { 31 | display: inline-block; 32 | width: 50px; 33 | margin-left: 10px; 34 | vertical-align: middle; 35 | border: 1px solid #ddd; 36 | } 37 | } 38 | 39 | &, 40 | input, 41 | button { 42 | font-size: 30px; 43 | } 44 | 45 | p { 46 | margin: 0 0 50px; 47 | } 48 | 49 | label { 50 | display: block; 51 | margin: 0 0 10px; 52 | } 53 | 54 | small { 55 | display: block; 56 | font-size: 20px; 57 | margin: 10px 0 0; 58 | } 59 | 60 | 61 | input, 62 | button { 63 | background: #000; 64 | border: 1px solid #aaa; 65 | color: #fff; 66 | display: block; 67 | box-sizing: content-box; 68 | padding: 10px; 69 | width: 20em; 70 | } 71 | 72 | button { 73 | background: hsl(98, 32%, 45%); 74 | cursor: pointer; 75 | 76 | &:disabled { 77 | background: hsl(98, 32%, 25%); 78 | cursor: default; 79 | } 80 | } 81 | } 82 | 83 | $box-height: 40px; 84 | $box-width: 40px; 85 | 86 | header { 87 | padding-bottom: 8px; 88 | 89 | .spacer { 90 | display: inline-block; 91 | width: $box-width * 1.2; 92 | } 93 | 94 | .target { 95 | display: inline-block; 96 | margin-left: 3em; 97 | font-size: 13px; 98 | } 99 | } 100 | 101 | .row { 102 | display: flex; 103 | align-items: center; 104 | 105 | >* { 106 | min-width: $box-width; 107 | flex: 1; 108 | } 109 | 110 | h2 { 111 | font-size: 16px; 112 | font-weight: normal; 113 | flex: 0 0 $box-width * 1.2; 114 | } 115 | } 116 | 117 | .aggregate, 118 | .summary { 119 | border: 1px solid #333; 120 | text-align: center; 121 | padding: 5px; 122 | height: $box-height; 123 | line-height: $box-height; 124 | color: #000; 125 | } 126 | 127 | .summary { 128 | opacity: 70%; 129 | 130 | &.interval, 131 | &.total { 132 | border-bottom-width: 4px; 133 | } 134 | 135 | &.day, 136 | &.total { 137 | border-right-width: 4px; 138 | } 139 | } 140 | 141 | .aggregate { 142 | h3 { 143 | font-size: 13px; 144 | font-weight: normal; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda" 2 | // import standardDeviation from "just-standard-deviation" 3 | 4 | export const MMOL_TO_MGDL = 18.018018018 5 | // const EXPECTED_MEASUREMENT_INTERVAL = 5 * 60 * 1000 6 | 7 | export interface Settings { 8 | nightscoutTitle: string 9 | nightscoutUrl: string 10 | displayUnits: "mgdl" | "mmol" 11 | targetRangeMgdl: [number, number] 12 | } 13 | 14 | export interface GlucoseRecord { 15 | date: number 16 | sgv: number 17 | trend: number 18 | direction: string 19 | } 20 | 21 | export interface RangePercentages { 22 | low: number 23 | onTarget: number 24 | high: number 25 | missing: number 26 | } 27 | 28 | export interface GlucoseStatistics { 29 | rangePercentages: RangePercentages 30 | } 31 | 32 | export function groupByDay(records: GlucoseRecord[]): Record { 33 | const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] 34 | return R.groupBy((r) => { 35 | const d = new Date(r.date) 36 | return `${weekdays[d.getDay()]} ${d.getDate()}/${d.getMonth() + 1}` 37 | }, records) 38 | } 39 | 40 | export function groupByInterval(records: GlucoseRecord[], interval: number): Record { 41 | return R.groupBy((r) => Math.floor(timeInDayInSeconds(r.date) / interval).toFixed(0), records) 42 | } 43 | 44 | function timeInDayInSeconds(date: number): number { 45 | const d = new Date(date) 46 | return d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds() 47 | } 48 | 49 | export function calculateGlucoseStatistics(records: GlucoseRecord[], rangeMgdl: [number, number]): GlucoseStatistics { 50 | // const sd = standardDeviation(records.map((r) => r.sgv / MMOL_TO_MGDL)) 51 | return { rangePercentages: calculatePercentages(records, rangeMgdl) } 52 | } 53 | 54 | function calculatePercentages(records: GlucoseRecord[], rangeMgdl: [number, number]): RangePercentages { 55 | const missing = calculateMissingDataCount(records) 56 | const total = records.length + missing 57 | const low = R.count((r) => r.sgv < rangeMgdl[0], records) 58 | const high = R.count((r) => r.sgv > rangeMgdl[1], records) 59 | const onTarget = total - low - high 60 | const percentage = (x: number): number => (100 * x) / total 61 | return { 62 | low: percentage(low), 63 | onTarget: percentage(onTarget), 64 | high: percentage(high), 65 | missing: percentage(missing), 66 | } 67 | } 68 | 69 | function calculateMissingDataCount(records: GlucoseRecord[]): number { 70 | return 0 71 | // This calculation doesn't work because we don't know what interval we're operating on. 72 | // To calculate the number of expected measurements we'd need to know if we're 73 | // summarising by one day, one hour, or the same hour in each day. 74 | 75 | // const [minDate, maxDate] = R.reduce(([min, max], r) => { 76 | // return [Math.min(min, +r.date), Math.max(max, +r.date)] 77 | // }, [Infinity, -Infinity], records) 78 | // const span = maxDate - minDate 79 | // const expectedMeasurements = Math.floor(span / EXPECTED_MEASUREMENT_INTERVAL) 80 | // return expectedMeasurements - records.length 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Glucoscape 4 | 5 | ## Overview 6 | 7 | Glucoscape is a small application that helps visualize Continuous Glucose Monitoring (CGM) data quickly and intuitively. It provides a heatmap representation of time spent below range, in range, and above range, allowing for easy identification of glucose patterns. 8 | 9 | ![](screenshot.png) 10 | 11 | ## Features 12 | 13 | - Loads the last 14 days of CGM data from your own Nightscout instance. 14 | - Uses the target range you have configured in Nightscout. 15 | - Displays a heatmap representation of time spent below/in/above your target range for each hour of each day. 16 | - Visually summarises time in range for each day ("what was my total time in range yesterday?") 17 | - Visually summarises time in range for each hour of day ("what's my average time in range between 9 and 10am?") 18 | 19 | ## Getting Started 20 | 21 | ### Prerequisite: Nightscout access 22 | 23 | To provide access to your Nightscout data, you need to check three things: 24 | 25 | 1. Find your Nightscout instance address (URL). 26 | 2. Create an **access token** which Glucoscape will use as a password when talking to Nightscout. 27 | - You can create a token by going to your Nightscout menu > Admin Tools > Subjects - People, Devices > Add new Subject. 28 | - Set `Name` to whatever you like, e.g. `glucoscape`. 29 | - Set `Roles` to `readable`. 30 | 3. Make sure your Nightscout instance **has CORS enabled**. 31 | - Add `cors` to the list of enabled plug-ins. 32 | - See [Nightscout docs](https://nightscout.github.io/nightscout/setup_variables/#cors-cors) for details. 33 | 34 | ### Use the app directly on GitHub 35 | 36 | This is the simplest way to use the app. It is pre-built and hosted directly by GitHub Pages. 37 | 38 | 1. Navigate to [vitawasalreadytaken.github.io/glucoscape](https://vitawasalreadytaken.github.io/glucoscape/) 39 | 2. Fill in your Nightscout address and access token and click _Connect_ 40 | 3. ⭐️ Optionally bookmark the page for easier access next time (the address will look like `https://vitawasalreadytaken.github.io/glucoscape/#session=%7B%22nightscoutUrl...`) 41 | 42 | Your Nightscout address and access token are never saved by the app. 43 | A simple way to keep them (and skip the _Connect_ step next time) is to bookmark the page. 44 | Note that the address and access token will be a part of your bookmarked address. 45 | 46 | ### Build and run the app yourself 47 | 48 | 1. Clone this repository 49 | 2. `npm install` 50 | 3. `npm run build` 51 | 4. Open `build/index.html` in your browser 52 | 5. Fill in your Nightscout address and access token and click _Connect_ 53 | 54 | ## See also 55 | 56 | I have also built [Koboscout](https://github.com/vitawasalreadytaken/koboscout), an 'ambient' app showing 57 | a chart of your latest CGM readings on an e-reader device or tablet. 58 | 59 | ## Contributing 60 | 61 | I welcome contributions from the community. If you have suggestions, bug reports, or would like to contribute code, please open an issue or pull request here on GitHub. 62 | 63 | ## License 64 | 65 | This project is licensed under the MIT License - see the LICENSE file for details. 66 | -------------------------------------------------------------------------------- /src/heatmap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type GlucoseRecord, 3 | type GlucoseStatistics, 4 | type Settings, 5 | groupByDay, 6 | groupByInterval, 7 | calculateGlucoseStatistics, 8 | MMOL_TO_MGDL, 9 | } from "./data" 10 | 11 | import { RESOLUTION_SECONDS, COLOR_LOW, COLOR_ON_TARGET, COLOR_HIGH, COLOR_MISSING } from "./settings" 12 | 13 | 14 | export function renderHeatmap(settings: Settings, glucoseData: GlucoseRecord[]): string { 15 | const byDay = groupByDay(glucoseData) 16 | const rows = Object.entries(byDay).map(([day, records]) => renderRow(settings, day, records)) 17 | return renderHeader(settings) + renderIntervalSummaryRow(settings, glucoseData) + rows.join("\n") 18 | } 19 | 20 | function renderHeader(settings: Settings): string { 21 | let targetRange: [number, number] | [string, string] = settings.targetRangeMgdl 22 | let units = "mg/dl" 23 | if (settings.displayUnits === "mmol") { 24 | targetRange = targetRange.map((x) => (x / MMOL_TO_MGDL).toFixed(1)) as [string, string] 25 | units = "mmol/l" 26 | } 27 | return ` 28 |
29 |
30 | ${settings.nightscoutTitle} 31 | ∝ 32 | Glucoscape 33 | 34 | // target ${targetRange[0]}—${targetRange[1]} ${units} 35 | 36 |
37 | ` 38 | } 39 | 40 | function renderIntervalSummaryRow(settings: Settings, records: GlucoseRecord[]): string { 41 | const byInterval = groupByInterval(records, RESOLUTION_SECONDS) // Group by interval across all days 42 | const aggregates = [] 43 | for (let i = 0; i < (24 * 3600) / RESOLUTION_SECONDS; i++) { 44 | const recordsInInterval = byInterval[String(i)] 45 | aggregates.push(renderSummary(settings, recordsInInterval, "interval")) 46 | } 47 | const total = renderSummary(settings, records, "total") 48 | return ` 49 |
50 |

51 | ${total} 52 | ${aggregates.join("")} 53 |
54 | ` 55 | } 56 | 57 | function renderRow(settings: Settings, day: string, records: GlucoseRecord[]): string { 58 | const summary = renderSummary(settings, records, "day") 59 | const aggregates = renderAggregates(settings, records) 60 | return ` 61 |
62 |

${day}

63 | ${summary} 64 | ${aggregates.join("")} 65 |
66 | ` 67 | } 68 | 69 | function renderSummary(settings: Settings, records: GlucoseRecord[], className: string): string { 70 | const stats = calculateGlucoseStatistics(records, settings.targetRangeMgdl) 71 | const title = generateTitle(stats) 72 | const background = generateGradient(stats) 73 | return ` 74 |
75 | ${stats.rangePercentages.onTarget.toFixed(0)}% 76 |
77 | ` 78 | } 79 | 80 | function renderAggregates(settings: Settings, records: GlucoseRecord[]): string[] { 81 | const byInterval = groupByInterval(records, RESOLUTION_SECONDS) 82 | const aggregates = [] 83 | for (let i = 0; i < (24 * 3600) / RESOLUTION_SECONDS; i++) { 84 | const recordsInInterval = byInterval[String(i)] 85 | let title = "" 86 | let background = COLOR_MISSING 87 | if (recordsInInterval != null) { 88 | const stats = calculateGlucoseStatistics(recordsInInterval, settings.targetRangeMgdl) 89 | title = generateTitle(stats) 90 | background = generateGradient(stats) 91 | } 92 | aggregates.push(` 93 |
94 |

${formatTime(i)}

95 |
96 | `) 97 | } 98 | return aggregates 99 | } 100 | 101 | function generateTitle(stats: GlucoseStatistics): string { 102 | return [ 103 | `Low ${Math.round(stats.rangePercentages.low)}%`, 104 | `on target ${Math.round(stats.rangePercentages.onTarget)}%`, 105 | `high ${Math.round(stats.rangePercentages.high)}%`, 106 | ].join(" / ") 107 | } 108 | 109 | function generateGradient(stats: GlucoseStatistics): string { 110 | const onTargetEnd = stats.rangePercentages.low + stats.rangePercentages.onTarget 111 | const highEnd = onTargetEnd + stats.rangePercentages.high 112 | const color = [ 113 | `${COLOR_LOW} 0%, ${COLOR_LOW} ${stats.rangePercentages.low}%`, 114 | `${COLOR_ON_TARGET} ${stats.rangePercentages.low}%, ${COLOR_ON_TARGET} ${onTargetEnd}%`, 115 | `${COLOR_HIGH} ${onTargetEnd}%, ${COLOR_HIGH} ${highEnd}%`, 116 | `${COLOR_MISSING} ${highEnd}%, ${COLOR_MISSING} 100%`, 117 | ].join(", ") 118 | return `linear-gradient(to top, ${color})` 119 | } 120 | 121 | function formatTime(hour: number): string { 122 | // const suffix = hour < 12 ? 'am' : 'pm' 123 | // hour %= 12 124 | // if (hour === 0) { 125 | // hour = 12 126 | // } 127 | // return `${hour}${suffix}` 128 | return String(hour) 129 | } 130 | 131 | // https://gist.github.com/mlocati/7210513 132 | // function percentageToColor(percentage: number, maxHue = 120, minHue = 0): string { 133 | // const hue = percentage * (maxHue - minHue) + minHue; 134 | // return `hsl(${hue}, 100%, 50%)`; 135 | // } 136 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { type GlucoseRecord, type Settings, MMOL_TO_MGDL } from "./data" 2 | import { DAYS_TO_LOAD, TIMEOUT_STATUS, TIMEOUT_CGM_DATA } from "./settings" 3 | 4 | import { renderHeatmap } from "./heatmap" 5 | 6 | import "./app.scss" 7 | 8 | interface Session { 9 | nightscoutUrl: string 10 | token: string 11 | } 12 | 13 | async function main(): Promise { 14 | const root = document.getElementById("app") 15 | if (root == null) { 16 | console.error("Could not find root element (#app)") 17 | return 18 | } 19 | 20 | // Check if we have a session stored in the URL. 21 | if (!window.location.hash.startsWith("#session=")) { 22 | loginView(root) 23 | return 24 | } 25 | // Check that we can parse it. 26 | let session 27 | try { 28 | session = JSON.parse(decodeURIComponent(window.location.hash.substring(9))) as Session 29 | } catch (e) { 30 | console.error("Could not parse session from URL", e) 31 | loginView(root) 32 | return 33 | } 34 | // Check that the session is valid and we can connect to Nightscout. 35 | const connectionTestResult = await testConnection(session.nightscoutUrl, session.token) 36 | if (connectionTestResult !== null) { 37 | alert(connectionTestResult) 38 | window.location.hash = "" 39 | loginView(root, session) 40 | return 41 | } 42 | 43 | await heatmapView(root, session) 44 | } 45 | 46 | function loginView(root: HTMLElement, prefilledSession: Session | null = null): void { 47 | root.innerHTML = ` 48 |
49 |

50 | Glucoscape 51 | 52 |

53 |

54 | 55 | 58 | 59 | Note: 60 | CORS must be enabled 61 | on your Nightscout server. 62 | 63 |

64 |

65 | 66 | 67 |

68 |

69 | 70 |

71 |
72 | ` 73 | root.getElementsByTagName("form")[0].addEventListener("submit", (event) => { 74 | event.preventDefault() 75 | const nightscoutUrl = (document.getElementById("nightscoutUrl") as HTMLInputElement).value 76 | const token = (document.getElementById("token") as HTMLInputElement).value 77 | if (nightscoutUrl === "" || token === "") { 78 | alert("Please enter your Nightscout address and authentication token") 79 | return 80 | } 81 | const session = { nightscoutUrl, token } 82 | window.location.hash = `#session=${encodeURIComponent(JSON.stringify(session))}` 83 | const submitButton = document.getElementById("submitButton") as HTMLButtonElement 84 | submitButton.disabled = true 85 | submitButton.innerText = "Connecting to Nightscout..." 86 | void main() 87 | }) 88 | } 89 | 90 | async function heatmapView(root: HTMLElement, session: Session): Promise { 91 | let settings: Settings 92 | let glucoseData: GlucoseRecord[] 93 | 94 | try { 95 | // Settings 96 | root.innerHTML = `Loading settings from ${session.nightscoutUrl}...` 97 | settings = await getSettings(session.nightscoutUrl, session.token) 98 | root.innerHTML += "done
" 99 | // Glucose data 100 | root.innerText += `Loading glucose data from ${session.nightscoutUrl}...` 101 | const toDate = new Date() 102 | toDate.setDate(toDate.getDate() + 1) 103 | const fromDate = new Date() 104 | fromDate.setDate(fromDate.getDate() - DAYS_TO_LOAD) 105 | glucoseData = await getGlucoseData(session.nightscoutUrl, session.token, fromDate, toDate) 106 | root.innerHTML += "done
" 107 | } 108 | catch (e) { 109 | console.error(e) 110 | root.innerHTML += `
Error loading data from Nightscout: ${e}
` 111 | return 112 | } 113 | // Render 114 | root.innerHTML = renderHeatmap(settings, glucoseData) 115 | } 116 | 117 | async function testConnection( 118 | nightscoutUrl: string, 119 | token: string, 120 | ): Promise { 121 | // Check that we can connect to Nightscout. 122 | console.log(`Making a test request to ${nightscoutUrl}`) 123 | let response 124 | try { 125 | response = await fetch(`${nightscoutUrl}/api/v1/status.json?token=${token}`, { signal: AbortSignal.timeout(TIMEOUT_STATUS) }) 126 | } 127 | catch (e) { 128 | // Most likely a timeout or a CORS error. 129 | // Oddly, it also times out when the token doesn't have the right roles. 130 | console.error(e) 131 | return `Cannot connect to ${nightscoutUrl}. 132 | Is the address correct? 133 | Is CORS enabled on your Nightscout server? 134 | Does the authentication token have the "readable" role?` 135 | } 136 | if (response.status === 401) { 137 | return `Authentication failed. Is the authentication token correct?` 138 | } 139 | if (!response.ok) { 140 | return `We cannot load data from ${nightscoutUrl} for unknown reasons :( Error code: ${response.status}` 141 | } 142 | return null 143 | } 144 | 145 | async function getGlucoseData( 146 | nightscoutUrl: string, 147 | token: string, 148 | fromDate: Date, 149 | toDate: Date 150 | ): Promise { 151 | // Download glucose data from NS. 152 | console.log(`Fetching glucose data from ${isoDateFormat(fromDate)} to ${isoDateFormat(toDate)}`) 153 | const response = await fetch( 154 | `${nightscoutUrl}/api/v1/entries/sgv.json` + 155 | `?token=${token}&count=0` + 156 | `&find[dateString][$gte]=${isoDateFormat(fromDate)}` + 157 | `&find[dateString][$lte]=${isoDateFormat(toDate)}`, { signal: AbortSignal.timeout(TIMEOUT_CGM_DATA) } 158 | ) 159 | return await response.json() 160 | } 161 | 162 | async function getSettings(nightscoutUrl: string, token: string): Promise { 163 | // Download NS settings and status and cherry-pick the bits we need. 164 | console.log(`Fetching settings...`) 165 | const response = await fetch(`${nightscoutUrl}/api/v1/status.json?token=${token}`, { signal: AbortSignal.timeout(TIMEOUT_STATUS) }) 166 | const status = await response.json() 167 | // Convert mmol/l to mg/dl if necessary. It's not clear when Nightscout returns the target range in mmol/l and when in mg/dl. 168 | // See the discussion in https://github.com/vitawasalreadytaken/glucoscape/pull/3 169 | // We guess the units from the target range values: CGMs typically can't measure values over 30 mmol/l, 170 | // and at the same time it makes no sense to set the target range top below 30 mg/dl (1.6 mmol/l). 171 | const targetRangeConversionToMgdl = status.settings.thresholds.bgTargetTop >= 30 ? 1 : MMOL_TO_MGDL 172 | return { 173 | nightscoutTitle: status.settings.customTitle, 174 | nightscoutUrl, 175 | displayUnits: status.settings.units, 176 | targetRangeMgdl: [status.settings.thresholds.bgTargetBottom, status.settings.thresholds.bgTargetTop].map( 177 | (x: number) => x * targetRangeConversionToMgdl 178 | ) as [number, number], 179 | } 180 | } 181 | 182 | function isoDateFormat(d: Date): string { 183 | return d.toISOString().substring(0, 10) 184 | } 185 | 186 | void main() 187 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "es2017", 17 | "dom" 18 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 19 | "types": [], 20 | "jsx": "react" /* Specify what JSX code is generated. */, 21 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 22 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 23 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 24 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 25 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 26 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 27 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 28 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 29 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 30 | 31 | /* Modules */ 32 | "module": "commonjs" /* Specify what module code is generated. */, 33 | "rootDir": "src" /* Specify the root folder within your source files. */, 34 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 35 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 36 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 37 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 38 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 39 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 40 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 41 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 42 | "resolveJsonModule": true /* Enable importing .json files. */, 43 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 44 | 45 | /* JavaScript Support */ 46 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 47 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 48 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 49 | 50 | /* Emit */ 51 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 52 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 53 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 54 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 55 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 56 | "outDir": "build" /* Specify an output folder for all emitted files. */, 57 | // "removeComments": true, /* Disable emitting comments. */ 58 | // "noEmit": true, /* Disable emitting files from a compilation. */ 59 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 60 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 61 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 62 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 65 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 66 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 67 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 68 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 69 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 70 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 71 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 72 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 73 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 74 | 75 | /* Interop Constraints */ 76 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 77 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 78 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 79 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 80 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 81 | 82 | /* Type Checking */ 83 | "strict": true /* Enable all strict type-checking options. */, 84 | "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, 85 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 86 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 87 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 88 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 89 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 90 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 91 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 92 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 93 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 94 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 95 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 96 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 97 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 98 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 99 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 100 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 101 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 102 | 103 | /* Completeness */ 104 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 105 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 106 | } 107 | } 108 | --------------------------------------------------------------------------------