├── .gitignore
├── CNAME
├── .travis.yml
├── README.md
├── styles.css
├── package.json
├── index.html
├── src
├── index.js
└── lib.js
├── verify.html
└── test
└── libTest.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | accountable-triage.cs.jhu.edu
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - node
5 | script:
6 | - npm test
7 | os: linux
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Accountable Triage
2 |
3 | ## Development Setup
4 |
5 | Make sure you have Node.js and NPM installed.
6 |
7 | Run `npm install` to install dependencies, `npm start` to start the server.
8 |
9 | Do NOT edit `index.js` directly; instead edit files in `src` and then run `npm
10 | run build`.
11 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-size: 20px;
3 | padding: 20px;
4 | }
5 |
6 | li {
7 | margin: 10px;
8 | }
9 |
10 | input {
11 | width: 200px;
12 | font-size: 18px;
13 | }
14 |
15 | button {
16 | display: block;
17 | margin-top: 15px;
18 | margin-bottom: 15px;
19 | font-size: 13px;
20 | }
21 |
22 | #results {
23 | padding-top: 15px;
24 | padding-bottom: 15px;
25 | }
26 |
27 | #dates {
28 | margin-top: 25px;
29 | margin-bottom: 10px;
30 | }
31 |
32 | #date {
33 | font-size: 18px;
34 | }
35 |
36 | #submit {
37 | margin-top: 20px;
38 | }
39 |
40 | .indent {
41 | margin-left: 50px;
42 | }
43 |
44 | #resultsContainer {
45 | width: 600px;
46 | margin: 50px;
47 | display: none;
48 | }
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "accountable-triage",
3 | "version": "0.0.1",
4 | "description": "TBD",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "start": "npm run build && python3 -m http.server 8000 --bind 127.0.0.1",
8 | "test": "mochify --timeout 5000",
9 | "watch": "mochify --watch",
10 | "webdriver": "mochify --wd",
11 | "lint": "standard",
12 | "build": "browserify src/*.js -o index.js"
13 | },
14 | "author": "",
15 | "license": "MIT",
16 | "devDependencies": {
17 | "browserify": "^16.5.0",
18 | "mochify": "^6.6.0"
19 | },
20 | "dependencies": {
21 | "jspdf": "^2.3.1",
22 | "sha3": "^2.1.2"
23 | },
24 | "standard": {
25 | "ignore": [
26 | "index.js",
27 | "dist/"
28 | ]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Accountable Triage
8 |
9 |
10 | Enter one patient MRN per row:
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
Highest to lowest priority:
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const lib = require('./lib')
3 | const { jsPDF } = require('jspdf')
4 |
5 | window.onload = () => {
6 | const e = {
7 | ids: document.getElementById('ids'),
8 | submit: document.getElementById('submit'),
9 | addRow: document.getElementById('addRow'),
10 | date: document.getElementById('date'),
11 | resultsContainer: document.getElementById('resultsContainer'),
12 | results: document.getElementById('results')
13 | }
14 |
15 | const generatePDF = (day) => {
16 | var pdf = new jsPDF('p', 'pt', 'a4')
17 | pdf.html(e.resultsContainer, {
18 | html2canvas: {
19 | scale: 0.9
20 | },
21 | callback: (doc) => {
22 | doc.save(`lottery-${day}.pdf`)
23 | }
24 | })
25 | }
26 |
27 | e.submit.onclick = () => {
28 | const dateInput = document.getElementById('dateInput')
29 | const tzInput = document.getElementById('tzInput')
30 | const d = new Date()
31 | const day = dateInput ? dateInput.value : lib.getDate(d)
32 | const tzCode = tzInput ? tzInput.value : lib.getTZCode(d)
33 | if (!day) {
34 | window.alert('Please enter a valid date.')
35 | return
36 | }
37 | if (!tzCode) {
38 | window.alert('Please enter a valid time zone.')
39 | return
40 | }
41 | e.results.innerHTML = ''
42 | e.resultsContainer.style.display = 'none'
43 | const array = []
44 | const inputs = ids.querySelectorAll('input')
45 | inputs.forEach((input) => {
46 | array.push(input.value)
47 | })
48 | lib.permuteIDs(array, day, tzCode).then((result) => {
49 | result.forEach((item) => {
50 | const li = document.createElement('li')
51 | li.innerText = item
52 | e.results.appendChild(li)
53 | })
54 | e.resultsContainer.style.display = 'block'
55 | if (e.date) {
56 | e.date.innerText = `Results generated on: ${d.toString()}`
57 | generatePDF(day)
58 | }
59 | }).catch((e) => {
60 | console.log(e)
61 | alert('Error getting results.')
62 | })
63 | }
64 |
65 | e.addRow.onclick = () => {
66 | const li = document.createElement('li')
67 | const input = document.createElement('input')
68 | input.type = 'text'
69 | li.appendChild(input)
70 | e.ids.appendChild(li)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/verify.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Verify triage results
8 |
9 |
10 | Enter one patient MRN per row:
11 |
12 |
13 |
14 |
15 |
16 |
17 | Enter lottery date:
18 |
19 | Enter local time zone where the lottery was run:
20 |
49 |
50 |
51 |
Highest to lowest priority:
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/src/lib.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const { SHA3 } = require('sha3')
3 |
4 | /**
5 | * Converts UTC midnight of local YYYY-MM-DD string to ms since 1970-01-01 UTC
6 | * and gets NIST beacon for that time.
7 | * @param {string} dateString
8 | * @returns {number}
9 | */
10 | const fetchNistBeacon = async (dateString, tzCode) => {
11 | if (!tzCode || tzCode.length !== 6 || tzCode[3] !== ':') {
12 | throw new Error('Invalid time zone.')
13 | }
14 | const date = Date.parse(`${dateString}T00:00:00.000${tzCode}`)
15 | if (!date) {
16 | throw new Error('Invalid date.')
17 | }
18 | const url = `https://beacon.nist.gov/beacon/2.0/pulse/time/${date}`
19 | const resp = await window.fetch(url)
20 | const json = await resp.json()
21 | return json.pulse.outputValue
22 | }
23 |
24 | module.exports = {
25 | /**
26 | * Gets SHA3 hash of some input string.
27 | * @param {string} input
28 | * @returns {string}
29 | */
30 | hash: (input) => {
31 | const hash = new SHA3(512)
32 | hash.update(input)
33 | return hash.digest('hex')
34 | },
35 |
36 | /**
37 | * Takes hash of date + normalized ID, copys it to a map of hash to ID
38 | * @param {string} id
39 | * @param {string} salt
40 | * @param {Object} outputs
41 | */
42 | idToHash: (id, salt, outputs) => {
43 | if (typeof id !== 'string') {
44 | return
45 | }
46 | id = id.replace(/\s/g, '').toLowerCase()
47 | if (id.length) {
48 | const input = [salt, id].join('|')
49 | outputs[module.exports.hash(input)] = id
50 | }
51 | },
52 |
53 | /**
54 | * Computes a permutation of the IDs based on the hash
55 | * @param {Array} ids
56 | * @param {string} date
57 | * @returns {Array}
58 | */
59 | permuteIDs: async (ids, date, tzCode) => {
60 | const salt = await fetchNistBeacon(date, tzCode)
61 | const hashesToIds = {}
62 | ids.forEach((id) => module.exports.idToHash(id, salt, hashesToIds))
63 | // Sorts lexicographically. This should be equivalent to
64 | // sorting numerically in this case since hashes are equal length hex.
65 | // Ex: ['0f', 'a'].sort() => ['0f', 'a']
66 | // ['0f', '0a'].sort() => ['0a', '0f']
67 | const sortedHashes = Object.keys(hashesToIds).sort()
68 | // Return array of IDs sorted by hash
69 | return sortedHashes.map(hash => hashesToIds[hash])
70 | },
71 |
72 | /**
73 | * Gets current day (YYYY-MM-DD) in local time
74 | * @returns {string}
75 | */
76 | getDate: (d) => {
77 | d = d || new Date()
78 | const month = String(d.getMonth() + 1)
79 | const date = String(d.getDate())
80 | const monthString = month.length < 2 ? `0${month}` : month
81 | const dateString = date.length < 2 ? `0${date}` : date
82 | return [d.getFullYear(), monthString, dateString].join('-')
83 | },
84 |
85 | /**
86 | * Returns local timezone's offset from UTC as a string
87 | * +/- HH:MM, e.g. -04:00
88 | */
89 | getTZCode: (d) => {
90 | d = d || new Date()
91 | const s = d.toString().split('GMT')[1]
92 | return [s.slice(0, 3), s.slice(3, 5)].join(':')
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/test/libTest.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 | 'use strict'
3 |
4 | const assert = require('assert')
5 | const lib = require('../src/lib')
6 |
7 | describe('hash', () => {
8 | it('returns known output', () => {
9 | assert.strict.equal(
10 | lib.hash('abc'),
11 | 'b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0'
12 | )
13 | assert.strict.equal(
14 | lib.hash(''),
15 | 'a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26'
16 | )
17 | assert.strict.equal(
18 | lib.hash('abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu'),
19 | 'afebb2ef542e6579c50cad06d2e578f9f8dd6881d7dc824d26360feebf18a4fa73e3261122948efcfd492e74e82e2189ed0fb440d187f382270cb455f21dd185'
20 | )
21 | })
22 | it('throws on non-string input', () => {
23 | assert.throws(() => {
24 | lib.hash(123)
25 | })
26 | })
27 | })
28 |
29 | describe('idToHash', () => {
30 | it('normalizes the ID', () => {
31 | const date = '2020-04-04'
32 | const output = {}
33 | lib.idToHash('', date, output)
34 | lib.idToHash(' ', date, output)
35 | lib.idToHash(undefined, date, output)
36 | lib.idToHash('123', date, output)
37 | lib.idToHash(' 4 5 6 ', date, output)
38 | lib.idToHash('aZUki', date, output)
39 | lib.idToHash('ABC 11111 0000000000', date, output)
40 | assert.strict.deepEqual(output, {
41 | '0c395fe62e46cd4ac547437d8f10ad897b61781ee31530fcc2acded9ede3c1e7539a578e161289bf4b5693254eca6f4c0dc190cf1728a2aab3daf742de74d5c8': 'abc111110000000000',
42 | 'a39878927fe717f4f0135e37a2ea55b0d03b638921fc35aeec00897d4213c4f2c104389c2d820d4e5cd8b8364e772ed8a9f7e1831f1444799184a8412a2b5ccd': '123',
43 | '385317ff17c0acfd6c92f8fbb11f8af1390b97733183f19bc02be5c71eb7d23bfa1ebd7f3a16e980df6a8fd470dc9ae3296e097d9b5084cbd46d1d3908cccec4': '456',
44 | '822af1ded907b8b455210b2c99005eb9a87f34c21c9e70951da4d33f8b7434e03e8563c2bcc209109ad1194553809cb844bfdc18c858d355bc9bdae6feb15656': 'azuki'
45 | })
46 | })
47 | })
48 |
49 | describe('permuteIDs', () => {
50 | const tzCode = '-07:00'
51 | it('is deterministic', async () => {
52 | const date = '2020-04-19'
53 | const res = await lib.permuteIDs(['hello', 'this', 'is', 'number', '42'], date, tzCode)
54 | const res2 = await lib.permuteIDs(['hello', 'this', 'is', 'number', '42'], date, tzCode)
55 | assert.strict.deepEqual(res, ['hello', '42', 'this', 'number', 'is'])
56 | assert.strict.deepEqual(res2, res)
57 | })
58 | it('filters out dupes and invalid inputs', async () => {
59 | const date = '2019-12-31'
60 | const res = await lib.permuteIDs(['hello', '', 'this', 'is', 'number', 42, 'hello'], date, tzCode)
61 | assert.strict.deepEqual(res, ['number', 'hello', 'is', 'this'])
62 | })
63 | it('gives same results given a fixed date', async () => {
64 | const date = '1983-12-01'
65 | const res1 = await lib.permuteIDs(['1', '2', '3'], date, tzCode)
66 | const res2 = await lib.permuteIDs(['1', '2', '3'], date, tzCode)
67 | assert.strict.deepEqual(res1, ['2', '1', '3'])
68 | assert.strict.deepEqual(res2, res1)
69 | const res3 = await lib.permuteIDs(['1', '2', '3'], '2020-01-01', tzCode)
70 | assert.strict.deepEqual(res3, ['1', '3', '2'])
71 | })
72 | })
73 |
74 | describe('getDate', () => {
75 | it('returns a correctly formatted string', () => {
76 | const date = lib.getDate()
77 | const split = date.split('-')
78 | assert(split.length === 3)
79 | assert(split[0].length === 4)
80 | assert(split[1].length === 2)
81 | assert(split[2].length === 2)
82 | })
83 | it('gets correct date', () => {
84 | // note this is time zone dependent
85 | let date = lib.getDate(new Date(1587265965761))
86 | assert.strict.equal(date, '2020-04-18')
87 | date = lib.getDate(new Date(0))
88 | assert.strict.equal(date, '1969-12-31')
89 | })
90 | })
91 |
92 | describe('getTZCode', () => {
93 | it('no args', () => {
94 | const tz = lib.getTZCode()
95 | assert.strict.equal(tz.length, 6)
96 | assert.strict.equal(tz[3], ':')
97 | assert(['-', '+'].includes(tz[0]))
98 | })
99 | it('args', () => {
100 | const tz = lib.getTZCode(new Date(1587265965761))
101 | assert.strict.equal(tz.length, 6)
102 | assert.strict.equal(tz[3], ':')
103 | assert(['-', '+'].includes(tz[0]))
104 | const tz2 = lib.getTZCode(new Date(1587506737836))
105 | assert.strict.equal(tz, tz2)
106 | const tz3 = lib.getTZCode(new Date('2010-05-05T07:03:51+0800'))
107 | assert.strict.equal(tz3, tz2)
108 | })
109 | })
110 |
--------------------------------------------------------------------------------