├── .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 |
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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------