This commit is contained in:
2022-09-22 07:10:08 -04:00
parent 2028b35226
commit 7f5ed98386
33 changed files with 2611 additions and 1 deletions

52
htdocs/App.vue Normal file
View File

@ -0,0 +1,52 @@
<template>
<div class="container-fluid">
<Navbar :user="user" />
<div class="container">
<router-view v-if="user"></router-view>
<Login :secret="secret" v-model:client="client" v-model:user="user" />
</div>
</div>
</template>
<script>
import Navbar from './Navbar.vue';
import Login from './Login.vue';
import RouteSchedule from './RouteSchedule.vue';
import RoutePatientLookup from './RoutePatientLookup.vue';
import RoutePatientDetail from './RoutePatientDetail.vue';
export default {
components: {
Navbar, Login
},
props: {
secret: String
},
data() {
return {
client: null,
user: null,
heartbeat: null,
banner: '',
authenticated: false
};
},
computed: {
store: () => store
},
watch: {
async client(value) {
if(this.heartbeat) window.clearInterval(this.heartbeat);
else {
[
{ path: '/', component: RouteSchedule, props: { client: this.client } },
{ path: '/patient', component: RoutePatientLookup, props: { client: this.client } },
{ path: '/patient/:id', component: RoutePatientDetail, props: { client: this.client } },
].forEach(route => this.$root.$router.addRoute(route));
await this.$root.$router.replace(this.$route);
}
this.heartbeat = await value.heartbeat();
}
}
};
</script>

104
htdocs/Autocomplete.vue Normal file
View File

@ -0,0 +1,104 @@
<template>
<div class="autocomplete">
<input type="text" @input="option_open" v-model="xvalue" @keydown.down="option_down" @keydown.up="option_up" @keydown.enter="option_enter" />
<ul id="autocomplete-results" v-show="open" class="autocomplete-results">
<li class="loading" v-if="!items">Loading results...</li>
<li v-else v-for="(result, i) in results" :key="i" @click="option_click(result)" class="autocomplete-result" :class="{ 'is-active': i === index }">{{ result }}</li>
</ul>
</div>
</template>
<style scoped>
.autocomplete {
position: relative;
}
.autocomplete-results {
padding: 0;
margin: 0;
border: 1px solid #eeeeee;
height: 120px;
overflow: auto;
}
.autocomplete-result {
list-style: none;
text-align: left;
padding: 4px 2px;
cursor: pointer;
}
.autocomplete-result.is-active,
.autocomplete-result:hover {
background-color: #4AAE9B;
color: white;
}
</style>
<script>
export default {
props: {
value: {
type: String,
default: ''
},
items: {
type: Array,
required: false,
default: () => [],
}
},
data() {
return {
xvalue: '',
results: [],
open: false,
index: -1,
};
},
watch: {
value(val) {
this.xvalue = val;
},
xvalue(val) {
this.$emit('update:value', val);
}
},
mounted() {
this.xvalue = this.value;
document.addEventListener('click', this.option_close)
},
destroyed() {
document.removeEventListener('click', this.option_close)
},
methods: {
option_open() {
if(this.items) {
this.results = this.items.filter((item) => item.toLowerCase().indexOf(this.xvalue.toLowerCase()) > -1);
this.open = true;
}
},
option_down() {
if(this.index < this.results.length) this.index++;
},
option_up() {
if(this.index > 0) this.index--;
},
option_enter() {
this.xvalue = this.results[this.index];
this.open = false;
this.index = -1;
},
option_click(result) {
this.xvalue = result;
this.open = false;
},
option_close(evt) {
if(!this.$el.contains(evt.target)) {
this.open = false;
this.index = -1;
}
}
}
};
</script>

154
htdocs/DateRangePicker.vue Normal file
View File

@ -0,0 +1,154 @@
<template>
<div class="btn-group" role="group">
<template v-if="x_range == '1D'">
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, -24*60*60*1000*(reversed ? -1 : +1))">🡠</button>
<input type="date" class="form-control" v-model="disp_date" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, 24*60*60*1000*(reversed ? -1 : +1))">🡢</button>
</template>
<template v-if="x_range == '1W'">
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, -7*24*60*60*1000*(reversed ? -1 : +1))">🡠</button>
<input type="date" class="form-control" v-model="disp_date" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, 7*24*60*60*1000*(reversed ? -1 : +1))">🡢</button>
</template>
<template v-if="x_range == '1M'">
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -1*(reversed ? -1 : +1))">🡠</button>
<input type="date" class="form-control" v-model="disp_date" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 1*(reversed ? -1 : +1))">🡢</button>
</template>
<template v-if="x_range == '6M'">
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -6*(reversed ? -1 : +1))">🡠</button>
<input type="date" class="form-control" v-model="disp_date" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 6*(reversed ? -1 : +1))">🡢</button>
</template>
<template v-if="x_range == '1Y'">
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -12*(reversed ? -1 : +1))">🡠</button>
<input type="date" class="form-control" v-model="disp_date" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 12*(reversed ? -1 : +1))">🡢</button>
</template>
<template v-if="x_range == '2Y'">
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -24*(reversed ? -1 : +1))">🡠</button>
<input type="date" class="form-control" v-model="disp_date" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 24*(reversed ? -1 : +1))">🡢</button>
</template>
<template v-if="x_range == 'Range'">
<input type="date" class="form-control" v-model="disp_date" />
<input type="date" class="form-control" v-model="disp_date_end" />
<DateRangePickerRange v-model="x_range" :direction="direction" />
</template>
</div>
</template>
<script>
import DateRangePickerRange from './DateRangePickerRange.vue';
function timeshift(date, ms) {
return new Date(date.getTime() + ms);
}
function timeshift_month(date, diff) {
var month = date.getMonth() + diff;
return new Date(date.getFullYear() + Math.floor(month/12), month >= 0 ? (month%12) : (month%12 + 12), date.getDate());
}
function datecalc(date, range, direction) {
switch(range) {
case '1D':
return timeshift(date, Math.sign(direction)*24*60*60*1000);
case '1W':
return timeshift(date, Math.sign(direction)*7*24*60*60*1000);
case '1M':
return timeshift_month(date, Math.sign(direction)*1);
case '6M':
return timeshift_month(date, Math.sign(direction)*6);
case '1Y':
return timeshift_month(date, Math.sign(direction)*12);
case '2Y':
return timeshift_month(date, Math.sign(direction)*24);
}
return date;
}
export default {
components: {
DateRangePickerRange
},
props: {
range: {
type: String,
default: '1D'
},
direction: {
type: String,
default: '-1'
},
date: {
type: Date,
default: new Date()
},
date_end: {
type: Date,
default: new Date()
},
reversed: {
type: Boolean,
default: false
}
},
data() {
return {
x_range: this.range,
x_date: this.date
};
},
computed: {
disp_date: {
get() {
return this.x_date.toLocaleDateString('en-CA');
},
set(value) {
value = value.split('-')
this.x_date = new Date(value[0], value[1] - 1, value[2]);
}
},
disp_date_end: {
get() {
return this.x_date_end.toLocaleDateString('en-CA');
},
set(value) {
value = value.split('-')
this.x_date_end = new Date(value[0], value[1] - 1, value[2]);
}
},
params() {
return {
x_date: this.x_date,
x_range: this.x_range,
direction: this.direction
};
}
},
watch: {
params(value) {
if(value.x_range != 'Range') this.x_date_end = datecalc(value.x_date, value.x_range, value.direction);
this.$emit('update:date', value.x_date);
this.$emit('update:date_end', this.x_date_end);
},
date(value) { this.x_date = value; },
x_date(value) { this.$emit('update:date', value); },
range(value) { this.x_range = value; },
x_range(value) { this.$emit('update:range', value); }
},
methods: {
timeshift, timeshift_month
},
mounted() {
this.$emit('update:date_end', this.x_date_end = datecalc(this.x_date, this.x_range, this.direction));
}
};
</script>

View File

@ -0,0 +1,39 @@
<template>
<select class="form-select form-select-sm" style="width: auto;" v-model="x_modelValue">
<option value="1D">{{disp_direction}}1D</option>
<option value="1W">{{disp_direction}}1W</option>
<option value="1M">{{disp_direction}}1M</option>
<option value="6M">{{disp_direction}}6M</option>
<option value="1Y">{{disp_direction}}1Y</option>
<option value="2Y">{{disp_direction}}2Y</option>
<option value="Range">Range</option>
</select>
</template>
<script>
export default {
props: {
modelValue: {
type: String,
default: '1D'
},
direction: {
default: '-1'
}
},
data() {
return {
x_modelValue: this.modelValue
};
},
computed: {
disp_direction() {
return this.direction > 0 ? '+' : this.direction < 0 ? '' : '';
}
},
watch: {
modelValue(value) { this.x_modelValue = value; },
x_modelValue(value) { this.$emit('update:modelValue', this.x_modelValue); }
}
};
</script>

75
htdocs/Login.vue Normal file
View File

@ -0,0 +1,75 @@
<template>
<div class="card mb-3 shadow">
<div class="card-header"><template v-if="user">{{user[2]}}</template><template v-else>Login</template></div>
<div class="card-body">
<p class="card-text row"><code class="col" v-if="banner"><pre>{{banner.join('\n')}}</pre></code><code class="col" v-if="user"><pre>{{user.join('\n')}}</pre></code></p>
</div>
<div class="input-group flex-nowrap" v-if="!user">
<span class="input-group-text">🔑</span>
<input type="password" class="form-control" placeholder="Access Code" v-model="accesscode" />
<input type="password" class="form-control" placeholder="Verify Code" v-model="verifycode" />
<button class="btn btn-primary" type="button" v-on:click="submit">Login<template v-if="!(accesscode || verifycode)"> (omit AV codes for SAML)</template></button>
</div>
</div>
</template>
<script>
import vistax from './vistax.mjs';
export default {
props: {
secret: String,
client: Object,
user: {
type: Array,
default: null
}
},
emits: {
'update:client': Object,
'update:user': {
type: Array,
default: null
}
},
data() {
return {
x_client: this.client,
x_user: this.user,
banner: null,
accesscode: null,
verifycode: null
};
},
watch: {
client(value) { this.x_client = value; },
x_client(value) { this.$emit('update:client', value); },
user(value) { this.x_user = value; },
x_user(value) { this.$emit('update:user', value); }
},
async mounted() {
this.x_client = await vistax.Client.fromCookie(this.secret);
this.banner = await this.x_client.XUS_INTRO_MSG();
if((await this.x_client.userinfo()).result) try {
var user = await this.x_client.XUS_GET_USER_INFO();
this.x_user = user[0] ? user : null
} catch(ex) {
this.x_user = null;
}
this.$emit('update:user', this.x_user);
console.log('Backend secret', this.secret);
console.log(this.banner);
},
methods: {
async submit(evt) {
var res = await ((this.accesscode && this.verifycode) ? this.x_client.authenticate(this.accesscode + ';' + this.verifycode) : this.x_client.authenticate());
if(!!res.result[0]) {
var user = await this.x_client.XUS_GET_USER_INFO();
this.x_user = user[0] ? user : null
} else this.x_user = null;
this.$emit('update:user', this.x_user);
console.log('Authenticate', res);
}
}
};
</script>

43
htdocs/Navbar.vue Normal file
View File

@ -0,0 +1,43 @@
<template>
<nav class="navbar navbar-expand-lg bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/"><template v-if="user">{{user[2]}}</template><template v-else>nuVistA</template></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/">Schedule</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/patient">Patient</a>
</li>
<li class="nav-item" v-if="user">
<a class="nav-link disabled">{{user[3]}}</a>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>
</template>
<script>
import vistax from './vistax.mjs';
export default {
props: {
user: {
type: Array,
default: null
}
},
data() {
return {};
}
};
</script>

View File

@ -0,0 +1,80 @@
<template>
<div v-if="info">
<div class="card mb-3 shadow">
<div class="card-header">{{info.name}} #{{$route.params.id}} ${{info.pid}}</div>
<div class="card-body row" style="font-family: monospace;">
<div class="col" v-if="info.dob"><strong>DOB:</strong> {{strptime_vista(info.dob).toLocaleDateString('en-CA')}}</div>
<div class="col" v-if="info.age"><strong>Age:</strong> {{info.age}}</div>
<div class="col" v-if="info.sex"><strong>Sex:</strong> {{info.sex}}</div>
<div class="col" v-if="info.sc_percentage"><strong>SC%:</strong> {{info.sc_percentage}}</div>
<div class="col" v-if="info.type"><strong>Type:</strong> {{info.type}}</div>
<div class="col" v-if="info.ward"><strong>Ward:</strong> {{info.ward}}</div>
<div class="col" v-if="info.room_bed"><strong>Room/bed:</strong> {{info.room_bed}}</div>
</div>
</div>
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Vitals</span>
<DateRangePicker range="1M" direction="-1" v-model:date="vitals_date" v-model:date_end="vitals_date_begin" />
</div>
<div class="card-body">
<ViewVitalsLabs :client="client" :dfn="$route.params.id" :date_begin="vitals_date_begin" :date_end="vitals_date" />
</div>
</div>
</div>
</template>
<script>
import { uniq, groupByArray, strptime_vista } from './util.mjs';
import DateRangePicker from './DateRangePicker.vue';
import ViewVitalsLabs from './ViewVitalsLabs.vue';
var now = new Date();
export default {
components: {
DateRangePicker, ViewVitalsLabs
},
props: {
client: Object
},
data() {
return {
info: null,
vitals_date: now,
vitals_date_begin: now,
labs_date: now,
labs_date_begin: now
};
},
watch: {
info(value) {
if((value) && (value.name)) document.title = value.name;
}
},
methods: {
strptime_vista
},
async mounted() {
if(this.$route.params.id.startsWith('$')) {
var id = this.$route.params.id.substring(1);
if(id.length == 9) {
var patient = await this.client.ORWPT_FULLSSN(id);
this.$router.replace('/patient/' + patient[0].dfn);
} else if(id.length == 5) {
var name = this.$route.query.name.toUpperCase();
var patient = await this.client.ORWPT_LAST5(id);
for(var i = 0; i < patient.length; ++i) if(name == patient[i].name) {
this.$router.replace('/patient/' + patient[0].dfn);
break;
}
}
} else this.info = await this.client.ORWPT16_ID_INFO(this.$route.params.id);
},
async beforeRouteUpdate(to, from, next) {
this.info = await this.client.ORWPT16_ID_INFO(to.params.id);
next();
}
};
</script>

View File

@ -0,0 +1,32 @@
<template>
<div>
<div class="card mb-3 shadow">
<div class="card-header">Patients</div>
<div class="card-body">
<ViewPatientLookup :client="client" v-model:selection="selection" />
</div>
</div>
</div>
</template>
<script>
import ViewPatientLookup from './ViewPatientLookup.vue';
export default {
components: {
ViewPatientLookup
},
props: {
client: Object
},
data() {
return {
selection: null
};
},
watch: {
},
methods: {
}
};
</script>

59
htdocs/RouteSchedule.vue Normal file
View File

@ -0,0 +1,59 @@
<template>
<div>
<div class="card mb-3 shadow">
<div class="card-header">Clinics</div>
<div class="card-body">
<ViewResourceLookup :client="client" v-model:selection="selection" />
</div>
</div>
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Schedule</span>
<DateRangePicker range="1D" direction="+1" v-model:date="date" v-model:date_end="date_end" />
</div>
<div class="card-body">
<ViewSchedule :client="client" :selection="selection" :date_begin="datefmt(date)" :date_end="datefmt(new Date(date_end.getTime() - 1))" />
</div>
</div>
</div>
</template>
<script>
import cookie from './cookie.mjs';
import ViewResourceLookup from './ViewResourceLookup.vue';
import DateRangePicker from './DateRangePicker.vue';
import ViewSchedule from './ViewSchedule.vue';
function dateonly(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
export default {
components: {
ViewResourceLookup, DateRangePicker, ViewSchedule
},
props: {
client: Object
},
data() {
var resources = cookie.get('vista.resources');
return {
selection: resources ? (resources.split(',').filter(x => x) || []) : [],
date: dateonly(new Date()),
date_end: dateonly(new Date())
};
},
watch: {
selection(value, oldvalue) {
cookie.set('vista.resources', value.join(','), 7);
}
},
methods: {
datefmt(date) {
return date ? date.toLocaleDateString('en-CA') : '';
//return (new Date(date.getTime() + date.getTimezoneOffset()*60000)).toLocaleDateString('en-CA');
}
}
};
</script>

233
htdocs/ViewData.vue Normal file
View File

@ -0,0 +1,233 @@
<template>
<div style="font-family: monospace;" role="region" tabindex="0" v-if="(resultset) && (resultset.length > 0)">
<table class="table-sticky table-data">
<thead>
<tr><th class="name">{{name}}</th><th class="date" v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr) }" ref="headers"><div class="year">{{group.datehdr.year}}</div><div class="monthdate">{{group.datehdr.monthdate}}</div><div class="hourminute" :class="{ daily }">{{group.datehdr.hourminute}}</div></th></tr>
</thead>
<tbody v-for="report in reports">
<template v-for="name in report">
<tr v-if="names[name]">
<th>{{name}}</th>
<td v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr), abnormal_ref: abnormal_ref(group.values[name]), abnormal_ref_low: abnormal_ref_low(group.values[name]), abnormal_ref_high: abnormal_ref_high(group.values[name]), abnormal_iqr: abnormal_iqr(group.values[name]), abnormal_iqr_low: abnormal_iqr_low(group.values[name]), abnormal_iqr_high: abnormal_iqr_high(group.values[name]) }" :title="tooltip(group.values[name])">{{group.values[name] ? group.values[name].value : ''}}</td>
</tr>
</template>
</tbody>
<tbody>
<template v-for="name in names">
<tr v-if="!names_excluded[name]">
<th>{{name}}</th>
<td v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr), abnormal_ref: abnormal_ref(group.values[name]), abnormal_ref_low: abnormal_ref_low(group.values[name]), abnormal_ref_high: abnormal_ref_high(group.values[name]), abnormal_iqr: abnormal_iqr(group.values[name]), abnormal_iqr_low: abnormal_iqr_low(group.values[name]), abnormal_iqr_high: abnormal_iqr_high(group.values[name]) }" :title="tooltip(group.values[name])">{{group.values[name] ? group.values[name].value : ''}}</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<style scoped>
table.table-sticky {
border: 2px solid #dee2e6;
}
table.table-sticky th:first-child {
border-right: 2px solid #dee2e6;
}
table.table-sticky tbody {
border-top: 2px solid #dee2e6;
}
table.table-sticky tbody tr {
border-top: 1px dashed #dee2e6;
}
table.table-sticky td:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
table.table-sticky tbody th, table.table-sticky th.name {
font-weight: bold;
text-align: center;
padding-left: 1rem;
padding-right: 1rem;
}
table.table-sticky th.date {
font-size: 80%;
font-weight: normal;
text-align: center;
}
table.table-sticky th.date .monthdate {
font-size: 125%;
font-weight: bold;
}
table.table-sticky th.date .hourminute.daily {
display: none;
}
table.table-sticky tbody td {
padding: 0 0.5rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.first {
border-left: 1px solid #dee2e6;
}
.year, .monthdate {
visibility: hidden;
}
.first .year, .first .monthdate {
visibility: visible;
}
[role="region"][tabindex] {
max-height: 75vh;
}
</style>
<script>
import { uniq, groupByArray, quantile_sorted } from './util.mjs';
function isNumeric(x) {
return (x !== '') && (x !== false) && (x !== null) && (!isNaN(x));
}
function statistics(resultset) {
var res = {}, group, item;
for(var i = resultset.length - 1; i >= 0; --i) {
item = resultset[i];
if(isNumeric(item.value)) {
if(res[item.name]) res[item.name].push(+item.value);
else res[item.name] = [+item.value];
}
}
for(var k in res) if(res.hasOwnProperty(k)) {
item = res[k].sort((a, b) => a - b);
item = res[k] = {
n: item.length,
q25: quantile_sorted(item, 0.25),
q50: quantile_sorted(item, 0.50),
q75: quantile_sorted(item, 0.75)
}
item.range = item.q25 != item.q75 ? ('IQR: ' + item.q25 + ' - ' + item.q75) : ('Median: ' + item.q50);
}
return res;
}
function date_header(date) {
var datestr = date.toLocaleDateString('en-CA');
var timestr = date.toLocaleTimeString('en-GB');
return {
datestr, timestr,
year: datestr.substring(0, 4),
monthdate: datestr.substring(5),
hourminute: timestr.substring(0, 5),
second: timestr.substring(6)
};
}
export default {
props: {
name: {
type: String,
default: null
},
resultset: {
type: Array,
default: []
},
daily: {
type: Boolean,
default: false
},
reports: {
type: Array,
default: []
}
},
data() {
return {};
},
computed: {
groups() {
if(this.daily) return groupByArray(this.resultset, x => new Date(x.time.getFullYear(), x.time.getMonth(), x.time.getDate())).map(function(group) {
group = group.values.reduce(((acc, x) => ((acc.values[x.name] || (acc.values[x.name] = [])).push(x), acc)), { key: group.key, datehdr: date_header(group.key), values: {}});
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
var items = group.values[k].sort((a, b) => a.time - b.time);
var strings = items.map(item => item.time.toLocaleTimeString('en-GB') + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : ''));
var flags = uniq(items.map(item => item.flag).filter(x => x).map(x => x.charAt(0)));
var comments = uniq(items.map(item => item.comment).filter(x => x));
var numbers = uniq(items.map(item => item.value).filter(x => isNumeric(x)));
var min = Math.min.apply(null, numbers);
var max = Math.max.apply(null, numbers);
group.values[k] = {
time: group.key,
name: k,
unit: items[0].unit,
range: items[0].range,
value: numbers.length > 1 ? min + ' - ' + max : numbers.length == 1 ? numbers[0] : items.length == 1 ? items[0].value : 'MULTIPLE', min: min, max: max,
flag: flags.length > 1 ? '*' : flags.length == 1 ? flags[0] : null,
comment: (strings.join('\n') + '\n\n' + comments.join('\n\n')).replace(/^\s+|\s+$/g, '')
};
}
return group;
}).sort((a, b) => (a.key > b.key) - (a.key < b.key));
else return groupByArray(this.resultset, x => x.time).map(group => group.values.reduce(((acc, x) => (acc.values[x.name] = x, acc)), { key: group.key, datehdr: date_header(group.key), values: {}})).sort((a, b) => (a.key > b.key) - (a.key < b.key));
},
names() {
var res = uniq(this.resultset.map(x => x.name));
return res.reduce((acc, x) => (acc[x] = true, acc), res);
},
names_excluded() {
var res = {};
this.reports.forEach(report => report.reduce((acc, x) => (acc[x] = true, acc), res));
return res;
},
statistics() {
return statistics(this.resultset);
}
},
watch: {
async resultset(value) {
this.$nextTick(() => this.$refs.headers ? this.$refs.headers.scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
}
},
methods: {
tooltip(item) {
if(item) {
var res = [], stat;
if(item.range) res.push('Ref: ' + item.range + ' ' + item.unit + (item.flag ? ' [' + item.flag + ']' : ''));
if(stat = this.statistics[item.name]) res.push(stat.range + (item.range ? ' ' + item.unit : '') + (isNaN(parseFloat(item.value)) ? '' : item.value < stat.q25 ? ' [L]' : item.value > stat.q75 ? ' [H]' : ''));
if(item.comment) {
if(res.length > 0) res.push('');
res.push(item.comment);
}
return res.join('\n');
}
},
abnormal_ref(item) {
return (item) && (item.flag);
},
abnormal_ref_low(item) {
return (item) && (item.flag) && (item.flag.indexOf('L') >= 0);
},
abnormal_ref_high(item) {
return (item) && (item.flag) && (item.flag.indexOf('H') >= 0);
},
abnormal_iqr(item) {
var stat;
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return (item.min < stat.q25) || (item.max > stat.q75);
else if(isNumeric(item.value)) return (item.value < stat.q25) || (item.value > stat.q75);
}
},
abnormal_iqr_low(item) {
var stat;
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.min < stat.q25;
else if(isNumeric(item.value)) return item.value < stat.q25;
}
},
abnormal_iqr_high(item) {
var stat;
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.max > stat.q75;
else if(isNumeric(item.value)) return item.value > stat.q75;
}
}
}
};
</script>

50
htdocs/ViewLabs.vue Normal file
View File

@ -0,0 +1,50 @@
<template>
<ViewData name="Labs" :resultset="resultset" />
</template>
<script>
import { strftime_vista } from './util.mjs';
import ViewData from './ViewData.vue';
function normalize(rs) {
return rs.map(function(x) {
return {
time: x.time_collected,
name: x.name,
unit: x.unit,
range: x.range,
value: x.value,
flag: x.flag,
comment: x.comment
}
});
}
export default {
components: {
ViewData
},
props: {
client: Object,
dfn: String,
date_begin: Date,
date_end: Date
},
data() {
return {
resultset: null
};
},
computed: {
params() {
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
}
},
watch: {
async params(value) {
this.resultset = normalize(await this.client.ORWLRR_INTERIM_RESULTS(value.dfn, value.date_end, value.date_begin));
}
}
};
</script>

View File

@ -0,0 +1,57 @@
<template>
<div>
<div class="input-group">
<span class="input-group-text">🔎</span>
<input class="form-control" v-model="query_raw" />
</div>
<div style="max-height: 30em; overflow-y: auto;">
<table class="table table-striped" style="font-family: monospace;" v-if="(resultset) && (resultset.length > 0)">
<thead>
<tr><th>DFN</th><th>Name</th><th>PID</th></tr>
</thead>
<tbody>
<tr v-for="row in resultset">
<td>{{row.dfn}}</td>
<td><router-link :to="'/patient/' + row.dfn">{{row.name}}</router-link></td>
<td>{{row.pid}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import { debounce } from './util.mjs';
export default {
props: {
client: Object,
selection: {}
},
data() {
return {
resultset: [],
query_raw: '',
query_view: ''
};
},
computed: {
},
watch: {
query_raw(value) {
this.query_sync(value);
}
},
methods: {
},
created() {
this.query_sync = debounce(async function(value) {
this.query_view = value = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
this.resultset = value ? (await this.client.ORWPT16_LOOKUP(value)) : [];
}, 500);
},
async mounted() {
}
};
</script>

View File

@ -0,0 +1,101 @@
<template>
<div class="input-group">
<span class="input-group-text">🔎</span>
<input class="form-control" v-model="query_raw" />
</div>
<div style="max-height: 30em; overflow-y: auto;">
<table class="table table-striped" style="font-family: monospace;" v-if="resultset_raw && resultset_raw.length > 0">
<thead>
<tr><th></th><th>ID</th><th>Name</th><th>Type</th><th>User</th></tr>
</thead>
<tbody>
<template v-for="row in resultset_filtered">
<tr :class="{ 'table-active': row.selected }" v-if="row.INACTIVE != 'YES'">
<td><input type="checkbox" v-model="row.selected" /></td>
<td>{{row.RESOURCEID}}</td>
<td>{{row.RESOURCE_NAME}}</td>
<td>{{row.RESOURCETYPE}}</td>
<td>{{row.USERNAME}}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div style="font-family: monospace;" v-if="resultset_selected.length">
<span class="badge bg-primary" style="cursor: default; margin-right: 0.35em;" v-on:click="reset">CLEAR {{resultset_selected.length}}</span>
<span class="badge bg-secondary" style="cursor: default; margin-right: 0.35em;" v-for="row in resultset_selected" v-on:click="row.selected = false;"> {{row.RESOURCEID}} {{row.RESOURCE_NAME}}</span>
</div>
</template>
<script>
import { debounce } from './util.mjs';
function arrayeq1(a, b) {
if(a.length == b.length) {
for(var i = a.length - 1; i >= 0; --i) if(a[i] != b[i]) return false;
return true;
} else return false;
}
function update_selection(resultset, selection) {
var mapping = selection.reduce((obj, key) => (obj[key] = true, obj), {});
for(var i = resultset.length -1; i >= 0; --i) resultset[i].selected = resultset[i].RESOURCEID in mapping;
}
export default {
props: {
client: Object,
selection: {
type: Array,
default: []
}
},
emits: {
'update:selection': Object
},
data() {
return {
resultset_raw: [],
query_raw: '',
query_view: ''
};
},
computed: {
resultset_filtered() {
var query_view = this.query_view.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase();
return query_view ? this.resultset_raw.filter(row => (query_view == row.RESOURCEID) || (row.RESOURCE_NAME.indexOf(query_view) >= 0) || (row.USERNAME.indexOf(query_view) >= 0)) : this.resultset_raw;
},
resultset_selected() {
return this.resultset_raw.filter(row => row.selected);
},
resultset_selection() {
return this.resultset_selected.map(x => x.RESOURCEID);
}
},
watch: {
selection(value) {
if(!arrayeq1(value, this.resultset_selection)) update_selection(this.resultset_raw, value);
},
resultset_selection(value) {
this.$emit('update:selection', value);
},
query_raw(value) {
this.query_sync(value);
}
},
methods: {
reset(evt) {
var selection = this.resultset_selected.slice();
for(var i = selection.length - 1; i >= 0; --i) selection[i].selected = false;
}
},
created() {
this.query_sync = debounce(function(value) { this.query_view = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' '); }, 500);
},
async mounted() {
var rs = (await this.client.SDEC_RESOURCE()).slice();
update_selection(rs, this.selection);
this.resultset_raw = rs;
}
};
</script>

89
htdocs/ViewSchedule.vue Normal file
View File

@ -0,0 +1,89 @@
<template>
<table class="table" style="font-family: monospace;" v-if="appointments && appointments.length > 0">
<thead>
<tr><th>Time</th><th>Clinic</th><th>Patient</th><th>Note</th><th>Assignee</th></tr>
</thead>
<tbody>
<tr v-for="row in appointments" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
<td>{{row.ApptDate}}</td>
<td>{{row.Clinic}}</td>
<td v-if="production"><router-link :to="'/patient/$' + row.HRN">{{row.Name}} ${{row.HRN}}</router-link></td>
<td v-else><router-link :title="unscramble(row.Name)" :to="'/patient/$' + row.Name.charAt(0) + row.HRN.slice(-4) + '?name=' + row.Name">{{row.Name}} ${{row.HRN}}</router-link></td>
<td>{{row.NOTE}} [{{row.APPT_MADE_BY}} on {{row.DATE_APPT_MADE}}]</td>
<td><Autocomplete :value="practitioner[row.Name]" @update:value="x => set_practitioner(row.Name, x)" :items="practitioner_list" /></td>
</tr>
</tbody>
</table>
</template>
<script>
import cookie from './cookie.mjs';
import { uniq, strHashHSL } from './util.mjs';
import Autocomplete from './Autocomplete.vue';
function datefm(datestr) {
var date = datestr ? new Date(datestr) : new Date();
date = new Date(date.getTime() + date.getTimezoneOffset()*60000);
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate();
}
function strtr(s, a, b) {
var res = '';
for(var i = 0; i < s.length; ++i) {
var j = a.indexOf(s.charAt(i));
res += j >= 0 ? b.charAt(j) : s.charAt(i);
}
return res;
}
export default {
components: {
Autocomplete
},
props: {
client: Object,
selection: {
type: Array,
default: []
},
date_begin: String,
date_end: String
},
data() {
return {
appointments: [],
practitioner: {},
production: true
};
},
computed: {
params() {
return { selection: this.selection, date_begin: this.date_begin, date_end: this.date_end };
},
practitioner_list() {
return this.practitioner ? uniq(Object.values(this.practitioner)).sort() : [];
}
},
watch: {
async params(value) {
this.appointments = value.selection.length > 0 ? (await this.client.SDEC_CLINLET(value.selection.join('|') + '|', datefm(value.date_begin), datefm(value.date_end))).sort((a, b) => (new Date(a.ApptDate)) - (new Date(b.ApptDate))) : [];
}
},
methods: {
strHashHSL,
unscramble(name) {
return name.length > 0 ? (name.charAt(0) + strtr(name.substring(1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'LKJIHGFEDCBAZYXWVUTSRQPONM')) : name;
},
set_practitioner(patient, practitioner) {
this.practitioner[patient] = practitioner;
cookie.set('vista.practitioner', JSON.stringify(this.practitioner), 1);
}
},
async mounted() {
var practitioner = cookie.get('vista.practitioner');
if(practitioner) this.practitioner = JSON.parse(practitioner);
this.production = (await this.client.serverinfo()).result.production == '1';
}
};
</script>

57
htdocs/ViewVitals.vue Normal file
View File

@ -0,0 +1,57 @@
<template>
<ViewData name="Vitals" :resultset="resultset" />
</template>
<script>
import { strftime_vista, strptime_vista } from './util.mjs';
import ViewData from './ViewData.vue';
const mapping = {
'T': { range: '35 - 38' },
'P': { range: '60 - 100', unit: 'bpm' },
'R': { range: '12 - 19', unit: 'bpm' },
'Pulse Oximetry': { range: '95 - 100' }
};
function normalize(rs) {
return rs.map(function(x) {
var res = {
time: x.datetime,
name: x.name,
unit: x.unit,
value: x.value,
flag: x.flag,
comment: x.user
};
return mapping[x.name] ? Object.assign(res, mapping[x.name]) : res;
});
}
export default {
components: {
ViewData
},
props: {
client: Object,
dfn: String,
date_begin: Date,
date_end: Date
},
data() {
return {
resultset: null
};
},
computed: {
params() {
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
}
},
watch: {
async params(value) {
this.resultset = normalize(await this.client.GMV_EXTRACT_REC(value.dfn, value.date_end, value.date_begin));
}
}
};
</script>

92
htdocs/ViewVitalsLabs.vue Normal file
View File

@ -0,0 +1,92 @@
<template>
<div>
<label class="form-check form-check-inline form-switch form-check-label" v-for="report in reports">
<input class="form-check-input" type="checkbox" v-model="report.selected" /> {{report.name}}
</label>
</div>
<ViewData :resultset="resultset" :daily="true" :reports="reports_selected" />
</template>
<script>
import { strftime_vista, strptime_vista } from './util.mjs';
import ViewData from './ViewData.vue';
const reports = [
{ name: 'Vitals', value: ['T', 'P', 'R', 'SBP', 'DBP', 'Pulse Oximetry', 'Wt', 'Ht', 'Pain'], selected: true },
{ name: 'CBC', value: ['HGB', 'MCV', 'PLT', 'WBC', 'NEUTROPHIL#'], selected: false },
{ name: 'Renal', value: ['CREATININE', 'UREA NITROGEN', 'EGFR CKD-EPI 2021', 'Estimated GFR dc\'d 3/30/2022'], selected: false },
{ name: 'Hepatic', value: ['SGOT', 'SGPT', 'LDH', 'ALKALINE PHOSPHATASE', 'GAMMA-GTP', 'TOT. BILIRUBIN', 'DIR. BILIRUBIN', 'ALBUMIN'], selected: false },
{ name: 'Electrolytes', value: ['SODIUM', 'CHLORIDE', 'CO2', 'CALCIUM', 'IONIZED CALCIUM (LABCORP)', 'POTASSIUM', 'MAGNESIUM', 'PO4', 'ANION GAP', 'OSMOBLD'], selected: false },
{ name: 'Coagulation', value: ['PT', 'INR', 'PTT'], selected: false },
{ name: 'Vitamins', value: ['FERRITIN', 'IRON', 'TIBC', 'B 12', 'FOLATE', 'VITAMIN D TOTAL 25-OH'], selected: false },
{ name: 'Thyroid', value: ['TSH', 'T4 (THYROXINE)'], selected: false }
];
reports.reduce((acc, x) => acc[x] = x, reports);
const vitals_mapping = {
'T': { range: '35 - 38' },
'P': { range: '60 - 100', unit: 'bpm' },
'R': { range: '12 - 19', unit: 'bpm' },
'Pulse Oximetry': { range: '95 - 100' }
};
function vitals_normalize(rs) {
return rs.map(function(x) {
var res = {
time: x.datetime,
name: x.name,
unit: x.unit,
value: x.value,
flag: x.flag,
comment: x.user
};
return vitals_mapping[x.name] ? Object.assign(res, vitals_mapping[x.name]) : res;
});
}
function labs_normalize(rs) {
return rs.map(function(x) {
return {
time: x.time_collected,
name: x.name,
unit: x.unit,
range: x.range,
value: x.value,
flag: x.flag,
comment: x.comment
}
});
}
export default {
components: {
ViewData
},
props: {
client: Object,
dfn: String,
date_begin: Date,
date_end: Date
},
data() {
return {
resultset: null,
reports
};
},
computed: {
params() {
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
},
reports_selected() {
return this.reports.filter(x => x.selected).map(x => x.value);
}
},
watch: {
async params(value, oldvalue) {
this.resultset = vitals_normalize(await this.client.GMV_EXTRACT_REC(value.dfn, value.date_end, value.date_begin)).concat(labs_normalize(await this.client.ORWLRR_INTERIM_RESULTS(value.dfn, value.date_end, value.date_begin)));
}
}
};
</script>

29
htdocs/cookie.mjs Normal file
View File

@ -0,0 +1,29 @@
// https://stackoverflow.com/a/24103596
// https://www.quirksmode.org/js/cookies.html
export function set(name, value, days) {
var expires = '';
if(days) {
var date = new Date();
date.setTime(date.getTime() + (days*24*60*60*1000));
expires = '; expires=' + date.toUTCString();
}
document.cookie = name + '=' + (value || '') + expires + '; path=/';
}
export function get(name) {
var nameEQ = name + '=';
var ca = document.cookie.split(';');
for(var i = 0; i < ca.length; i++) {
var c = ca[i];
while(c.charAt(0)==' ') c = c.substring(1, c.length);
if(c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
export function reset(name) {
document.cookie = name +'=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
export default { set, get, reset };

46
htdocs/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nuVistA</title>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="/table-sticky.css" />
<link rel="stylesheet" type="text/css" href="/userstyle.css" />
</head>
<body><div id='root'></div></body>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue@3.2"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue-router@4"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/js/bootstrap.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue3-sfc-loader/dist/vue3-sfc-loader.js"></script>
<script type="text/javascript">
var loadModule = window['vue3-sfc-loader'].loadModule;
var options = {
moduleCache: {
vue: Vue
},
async getFile(url) {
const res = await fetch(url);
if(res.ok) return {
getContentData: asBinary => asBinary ? res.arrayBuffer() : res.text(),
}
else throw Object.assign(new Error(res.statusText + ' ' + url), { res });
},
addStyle(textContent) {
const style = Object.assign(document.createElement('style'), { textContent });
const ref = document.head.getElementsByTagName('style')[0] || null;
document.head.insertBefore(style, ref);
},
};
var secret = window.location.hash.substring(1);
var app = Vue.createApp({
components: {
'app': Vue.defineAsyncComponent(function() { return loadModule('/App.vue', options); })
},
data: function() { return { secret: secret }; },
template: '<app :secret="secret"></app>'
});
app.use(VueRouter.createRouter({ history: VueRouter.createWebHistory(), routes: [] }));
app.mount('#root');
</script>
</html>

158
htdocs/reportparser.mjs Normal file
View File

@ -0,0 +1,158 @@
function isEqualArray(a, b) {
if(a.length == b.length) {
for(var i = a.length - 1; i >= 0; --i) if(a[i] != b[i]) return false;
return true;
} else return false;
}
export function lab_parse(data) {
data = data.join('\n');
if(data == '\nNo Data Found') return [];
return data.split('\n===============================================================================\n \n').map(lab_parse1).filter(x => x);
}
export function lab_reparse_results(reports) {
var res = [], report, result;
for(var i = 0; i < reports.length; ++i) {
if((report = reports[i]).hasOwnProperty('results')) {
report = Object.assign({}, report);
var results = report.results;
delete report.results;
if(report.hasOwnProperty('comment')) delete report.comment;
for(var j = 0; j < results.length; ++j) res.push(result = Object.assign({}, report, results[j]));
}
}
return res;
}
function lab_parse1(data) {
if(data.startsWith('\n')) return lab_parse1default(data);
if(data.startsWith(' ---- MICROBIOLOGY ----\n')) return lab_parse1microbiology(data);
if(data.startsWith('Performing Lab Sites\n')) return null;
}
function lab_parse1default(data) {
var res = {}, m, x, line;
if(m = data.match(/^Report Released Date\/Time: (.*)/m)) res.time_released = new Date(m[1]); // 'Aug 24, 2022@07:15'
if(m = data.match(/^Provider: (.*)/m)) res.practitioner = m[1]; // 'BARGNES,VINCENT HARRY III'
if(m = data.match(/^ Specimen: (.*?)\.\s*(.*)/m)) {
res.specimen = m[1]; // 'SERUM'
res.accession = m[2]; // 'CH 0800 6706'
}
if(m = data.match(/^ Specimen Collection Date: (.*)/m)) res.time_collected = new Date(m[1]); // 'Aug 24, 2022'
data = data.split('\n Test name Result units Ref. range Site Code\n')[1].split('\n');
var results = res.results = [];
for(var i = 0; i < data.length; ++i) {
if((line = data[i]).startsWith('Comment: ')) {
res.comment = data.slice(i).join('\n').substring(9);
break;
} else if(line.startsWith(' Eval: ')) {
if(results.length > 0) {
x = results[results.length - 1];
if(x.comment) x.comment.push(line.substring(12));
else x.comment = [line.substring(12)];
} else console.log('DANGLING:', line);
} else if((line.startsWith('COVID-19 SCR (CEPHEID-RAPID)')) && (m = line.substring(28).match(/^(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.{16}) \[(?<site>\d+)\])?$/))) {
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
results.push(x = m.groups);
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
x.name = 'COVID-19 SCR (CEPHEID-RAPID)';
} else if(m = line.match(/^\b(?<name>.*?)\s{2,}(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.{16}) \[(?<site>\d+)\])?$/)) {
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
results.push(x = m.groups);
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
} else if(line.startsWith(' [')) {
if(results.length > 0) results[results.length - 1].site = line.split('[')[1].split(']')[0]
else console.log('DANGLING:', line);
} else if(line.startsWith(' ')) {
if(results.length > 0) {
x = results[results.length - 1];
if(line.endsWith(']')) {
x.range = line.split('[')[0].replace(/^\s+|\s+$/g, '');
x.site = line.split('[')[1].split(']')[0];
} else x.range = line.replace(/^\s+|\s+$/g, '');
} else console.log('DANGLING:', line);
} else console.log('INVALID:', line);
}
for(var i = results.length - 1; i >= 0; --i) {
results[(x = results[i]).name] = x;
if(x.comment) x.comment = x.comment.join('\n');
}
if((res.accession.startsWith('HE ')) && ((results.hasOwnProperty('SEGS')) || (results.hasOwnProperty('BANDS')))) {
results.push(results['NEUTROPHIL%'] = {
name: 'NEUTROPHIL%', unit: '%', range: '42.2 - 75.2',
value: x = (results.hasOwnProperty('SEGS') ? +results.SEGS.value : 0) + (results.hasOwnProperty('BANDS') ? +results.BANDS.value : 0),
flag: (x < 42.2 ? 'L' : x > 75.2 ? 'H' : undefined)
});
results.push(results['NEUTROPHIL#'] = {
name: 'NEUTROPHIL#', unit: 'K/cmm', range: '1.4 - 6.5',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 1.4 ? 'L' : x > 6.5 ? 'H' : undefined)
});
}
return res;
}
function lab_parse1microbiology(data) {
var res = {}, lines = data.split('\n'), line, m;
var idx_body = lines.indexOf(' ');
for(var i = 0; i < lines.length; ++i) {
line = lines[i];
if(line.startsWith('Accession [UID]: ')) {
if(m = line.match(/^Accession \[UID\]: (?<accession>.*?) \[(?<accession_uid>\d+)\]/)) { // 'BCUL 22 819 [3922000819]'
res.accession = m.groups.accession;
res.accession_uid = m.groups.accession_uid;
}
if(m = line.match(/Received: (.*)$/)) res.time_received = new Date(m[1]); // 'Aug 01, 2022@11:57'
} else if(line.startsWith('Collection sample: ')) {
res.sample = line.substring(0, 39).substring(19).replace(/^\s+|\s+$/g, '');
res.time_collected = new Date(line.substring(39).split('Collection date: ')[1].replace(/^\s+|\s+$/g, ''));
} else if(line.startsWith('Site/Specimen: ')) {
res.specimen = line.substring(15).replace(/^\s+|\s+$/g, '');
} else if(line.startsWith('Provider: ')) {
res.practitioner = line.substring(10).replace(/^\s+|\s+$/g, '');
} else if(line.startsWith('Comment on specimen:')) {
res.comment = lines.slice(i, idx_body).join('\n').substring(20).replace(/^\s+|\s+$/g, '');
break
}
}
var idx_footer = lines.indexOf('=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--')
if(idx_footer > idx_body) {
res.body = lines.slice(idx_body, idx_footer).join('\n').replace(/^\s+|\s+$/g, '');
res.footer = lines.slice(idx_footer + 1).join('\n').replace(/^\s+|\s+$/g, '');
} else res.body = lines.slice(idx_body).join('\n').replace(/^\s+|\s+$/g, '');
return res;
}
export function measurement_parse(data) {
var extras = [];
var res = data.map(function(row) {
if(row.charAt(0) != ' ') {
var res = {}, idx = 0, value, m;
res.measurement_ien = row.substring(0, idx = row.indexOf('^'));
if(res.measurement_ien == '0') return; // '0^NO VITALS/MEASUREMENTS ENTERED WITHIN THIS PERIOD'
res.datetime = new Date(row.substring(idx + 1, idx = row.indexOf(' ', idx)));
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
res.user = row.substring(idx + 3);
m = value.match(/^(?:(.*?)(?: (\S+))?)(\*)?(?: \((?:(.*?)(?: (\S+))?)\))?\s*$/);
res.value = m[4] ? m[4] : m[1];
res.unit = m[4] ? m[5] : m[2];
res.flag = m[3];
res.value_american = m[4] ? m[1] : m[4];
res.unit_american = m[4] ? m[2] : m[5];
if(res.value.charAt(res.value.length - 1) == '%') {
res.unit = '%';
res.value = res.value.substring(0, res.value.length - 1);
}
if(res.name == 'B/P') {
var bpsplit = res.value.split('/');
extras.push({...res, name: 'SBP', range: '90 - 120', unit: 'mmHg', value: bpsplit[0] });
extras.push({...res, name: 'DBP', range: '60 - 80', unit: 'mmHg', value: bpsplit[1] });
}
return res;
}
}).filter(x => x);
res.push(...extras);
return res;
}

45
htdocs/table-sticky.css Normal file
View File

@ -0,0 +1,45 @@
table.table-sticky {
white-space: nowrap;
table-layout: fixed;
}
table.table-sticky thead th {
position: sticky;
top: 0;
z-index: 1;
width: 25vw;
background: white;
}
table.table-sticky td {
background: #fff;
text-align: center;
}
table.table-sticky tbody th {
position: relative;
}
table.table-sticky thead th:first-child {
position: sticky;
left: 0;
z-index: 2;
}
table.table-sticky tbody th {
position: sticky;
left: 0;
background: white;
z-index: 1;
}
table.table-sticky caption {
text-align: left;
position: sticky;
left: 0;
}
[role="region"][tabindex] {
width: 100%;
max-height: 98vh;
overflow: auto;
}
[role="region"][tabindex]:focus {
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5);
outline: 0;
}

25
htdocs/userstyle.css Normal file
View File

@ -0,0 +1,25 @@
table.table-data .abnormal_ref::after {
content: ' *';
}
table.table-data .abnormal_ref_low::after {
content: ' L';
}
table.table-data .abnormal_ref_high::after {
content: ' H';
}
table.table-data .abnormal_iqr, table.table-data .abnormal_iqr_low.abnormal_iqr_high {
color: #f39a27;
}
table.table-data .abnormal_iqr_low {
color: #976ed7;
}
table.table-data .abnormal_iqr_high {
color: #c23b23;
}
table.table-data .abnormal_ref, table.table-data .abnormal_iqr {
background-color: #fbffde;
}
table.table-data .abnormal_ref_low.abnormal_iqr_low, table.table-data .abnormal_ref_high.abnormal_iqr_high {
font-weight: bold;
background-color: #ffd1d1;
}

79
htdocs/util.mjs Normal file
View File

@ -0,0 +1,79 @@
export function uniq(xs) {
var seen = {};
return xs.filter(x => seen.hasOwnProperty(x) ? false : (seen[x] = true));
}
export function groupBy(xs, key) {
return xs.reduce(function(rv, x) {
var v = key instanceof Function ? key(x) : x[key];
(rv[v] = rv[v] || []).push(x);
return rv;
}, {});
}
export function groupByArray(xs, key) {
var mapping = {};
return xs.reduce(function(rv, x) {
var v = key instanceof Function ? key(x) : x[key];
var el = mapping[v];
if(el) el.values.push(x);
else rv.push(mapping[v] = { key: v, values: [x] });
return rv;
}, []);
}
export function pivotByArray(xs, key, reducer) {
var groups = groupByArray(xs, key);
groups.forEach(function(group) {
group.aggregate = group.values.reduce(reducer, {});
});
return groups;
}
export function quantile_sorted(arr_sorted, quantile) {
var pos = (arr_sorted.length - 1) * quantile, base = Math.floor(pos), rest = pos - base;
return arr_sorted[base + 1] !== undefined ? arr_sorted[base] + rest * (arr_sorted[base + 1] - arr_sorted[base]) : arr_sorted[base];
}
export function strHashCode(str) {
var hash = 0;
for(var i = 0; i < str.length; ++i) hash = str.charCodeAt(i) + ((hash << 5) - hash);
return hash & hash; // convert to 32 bit
}
export function strHashJenkins(str) {
for(var hash = 0, i = str.length; i--;) hash += str.charCodeAt(i), hash += hash << 10, hash ^= hash >> 6;
hash += hash << 3;
hash ^= hash >> 11;
return (hash + (hash << 15) & 4294967295) >>> 0
}
export function strHashHex(str) {
var hash = strHashJenkins(str), color = '#';
for(var i = 0; i < 3; ++i) color += ('00' + ((hash >> (i * 8)) & 0xFF).toString(16)).slice(-2);
return color;
}
export function strHashHSL(str, lightness='50%') {
var hash = strHashJenkins(str);
return 'hsl(' + (hash%360) + ',' + (hash%100) + '%,' + lightness + ')';
}
export function strftime_vista(date) {
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate() + date.getHours()/100 + date.getMinutes()/10000 + date.getSeconds()/1000000 + date.getMilliseconds()/1000000000;
}
export function strptime_vista(s) {
s = +s;
var date = Math.floor(s), time = s - date;
return new Date(Math.floor(date/10000) + 1700, (Math.floor(date/100) + '').slice(-2) - 1, (date + '').slice(-2), Math.floor(time*100), (Math.floor(time*10000) + '').slice(-2), (Math.floor(time*1000000) + '').slice(-2), (Math.floor(time*1000000000) + '').slice(-3));
}
export function debounce(fn, delay) {
var clock = null;
return function() {
clearTimeout(clock);
var self = this, args = arguments;
clock = setTimeout(function() { fn.apply(self, args) }, delay);
}
}

51
htdocs/vista.mjs Normal file
View File

@ -0,0 +1,51 @@
export async function connect(secret, host='vista.northport.med.va.gov', port=19209) {
return await (await fetch('/v1/vista', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params: { secret: secret, host: host, port: port }, id: Date.now() })
})).json();
}
export async function call(cid, method, ...params) {
return await (await fetch('/v1/vista/' + cid, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: method, params: params, id: Date.now() })
})).json();
}
export async function callctx(cid, context, method, ...params) {
return await (await fetch('/v1/vista/' + cid, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: method, params: params, context: context, id: Date.now() })
})).json();
}
export async function serverinfo(cid) {
return await (await fetch('/v1/vista/' + cid + '/serverinfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})).json();
}
export async function userinfo(cid) {
return await (await fetch('/v1/vista/' + cid + '/userinfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})).json();
}
export async function authenticate(cid, avcode=null) {
return await (await fetch('/v1/vista/' + cid + '/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params: avcode ? { avcode } : {} })
})).json();
}
export default window.vista = {
connect, call, callctx, serverinfo, userinfo, authenticate
};

171
htdocs/vistax.mjs Normal file
View File

@ -0,0 +1,171 @@
import vista from './vista.mjs';
import cookie from './cookie.mjs';
import { lab_parse, lab_reparse_results, measurement_parse } from './reportparser.mjs';
function RPCError(type, ...args) {
this.name = type;
this.message = args;
}
RPCError.prototype = Object.create(Error.prototype);
export function logged(fn, name) {
return async function(...args) {
var res = await fn(...args);
console.log(name, ...args, res);
return res;
}
}
export function unwrapped(fn) {
return async function(...args) {
var res = await fn(...args);
if(res.error) throw new RPCError(res.error.type, ...res.error.args);
else return res.result;
}
}
export function memoized(fn) {
var cache = {};
return async function(...args) {
var key = JSON.stringify(args);
return cache.hasOwnProperty(key) ? cache[key] : (cache[key] = await fn(...args));
}
}
export function caretseparated(fn, columns=null) {
return async function(...args) {
if(columns) return (await fn(...args)).map(function(row) {
row = row.split('^');
for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) row[columns[i]] = row[i];
return row;
});
else return (await fn(...args)).map(function(row) { return row.split('^'); });
}
}
export function caretseparated1(fn, columns=null) {
return async function(...args) {
var res = (await fn(...args)).split('^');
if(columns) for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) res[columns[i]] = res[i];
return res;
}
}
export function labreportparsed(fn) {
return async function(...args) {
return lab_parse(await fn(...args));
}
}
export function tabulated(fn, mapping) {
return async function(...args) {
var res = (await fn(...args)).map(function(row) { return row.slice(); }), nrow = res.length;
for(var i = 0; i < nrow; ++i) {
var row = res[i], ncol = row.length;
for(var j = 0; j < ncol; ++j) if(mapping.hasOwnProperty(j)) row[mapping[j]] = row[j];
res.push()
}
return res;
}
}
export function Client(cid, secret) {
var heartbeat = null;
this.secret = secret;
this.cid = cid;
this.call = (method, ...params) => vista.call(cid, method, ...params);
this.callctx = (context, method, ...params) => vista.callctx(cid, context, method, ...params);
this.heartbeat = async function(interval=null) {
if(!interval) interval = 0.45*1000*(await this.XWB_GET_BROKER_INFO())[0];
if(heartbeat) window.clearInterval(heartbeat);
this.XWB_IM_HERE();
return heartbeat = window.setInterval(this.XWB_IM_HERE, interval);
}
this.serverinfo = () => vista.serverinfo(cid);
this.userinfo = () => vista.userinfo(cid);
this.authenticate = (avcode=null) => vista.authenticate(cid, avcode);
this.XWB_IM_HERE = unwrapped(logged(() => vista.call(cid, 'XWB_IM_HERE'), 'XWB_IM_HERE'));
this.XUS_INTRO_MSG = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XUS_INTRO_MSG'), 'XUS_INTRO_MSG')));
this.XWB_GET_BROKER_INFO = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XWB_GET_BROKER_INFO'), 'XWB_GET_BROKER_INFO')));
this.XUS_GET_USER_INFO = memoized(unwrapped(logged(() => vista.call(cid, 'XUS_GET_USER_INFO'), 'XUS_GET_USER_INFO')));
this.SDEC_RESOURCE = memoized(unwrapped(logged(() => vista.callctx(cid, ['SDECRPC'], 'SDEC_RESOURCE'), 'SDEC_RESOURCE')));
this.SDEC_CLINLET = memoized(unwrapped(logged((...args) => vista.callctx(cid, ['SDECRPC'], 'SDEC_CLINLET', ...args), 'SDEC_CLINLET')));
this.ORWPT_FULLSSN = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_FULLSSN', ...args), 'ORWPT_FULLSSN')), ['dfn', 'name', 'date', 'pid']));
this.ORWPT_LAST5 = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_LAST5', ...args), 'ORWPT_LAST5')), ['dfn', 'name', 'date', 'pid']));
this.ORWPT_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_ID_INFO', ...args), 'ORWPT_ID_INFO')), ['pid', 'dob', 'sex', 'vet', 'sc_percentage', 'ward', 'room_bed', 'name']));
this.ORWPT16_LOOKUP = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT16_LOOKUP', ...args), 'ORWPT16_LOOKUP')), ['dfn', 'name', 'pid']));
this.ORWPT16_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT16_ID_INFO', ...args), 'ORWPT16_ID_INFO')), ['pid', 'dob', 'age', 'sex', 'sc_percentage', 'type', 'ward', 'room_bed', 'name']));
this.ORQQVI_VITALS = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORQQVI_VITALS', ...args), 'ORQQVI_VITALS')), ['measurement_ien', 'type', 'value', 'datetime', 'value_american', 'value_metric']));
this.ORQQVI_VITALS_FOR_DATE_RANGE = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORQQVI_VITALS_FOR_DATE_RANGE', ...args), 'ORQQVI_VITALS_FOR_DATE_RANGE')), ['measurement_ien', 'type', 'value', 'datetime']));
this.GMV_EXTRACT_REC = memoized(async (dfn, oredt, orsdt) => measurement_parse(await unwrapped(logged((...args0) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'GMV_EXTRACT_REC', args0.join('^')), 'GMV_EXTRACT_REC'))(dfn, oredt, '', orsdt)));
this.ORWLRR_INTERIM = memoized(labreportparsed(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWLRR_INTERIM', ...args), 'ORWLRR_INTERIM'))));
this.ORWLRR_INTERIM_RESULTS = memoized(async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args)));
return this;
}
Client._registry = {};
Client.fromID = function(cid, secret) {
if(Client._registry[cid]) return Client._registry[cid];
return Client._registry[cid] = new Client(cid, secret);
};
Client.fromScratch = async function(secret, host='vista.northport.med.va.gov', port=19209) {
var data = await vista.connect(secret, host, port);
if(data.result) return Client.fromID(data.result, secret);
};
Client.fromCookie = async function(secret, host='vista.northport.med.va.gov', port=19209) {
if(!secret) secret = cookie.get('secret');
if(secret) {
if(secret != cookie.get('secret')) {
console.log('Using new secret', secret);
var client = await Client.fromScratch(secret, host, port);
if(client) {
cookie.set('secret', secret);
cookie.set('cid', client.cid);
console.log('Established connection', client.cid);
return client;
} else {
cookie.reset('secret');
cookie.reset('cid');
console.log('Failed to connect');
return null;
}
} else if(!cookie.get('cid')) {
console.log('Using saved secret', secret);
var client = await Client.fromScratch(secret, host, port);
if(client) {
cookie.set('secret', secret);
cookie.set('cid', client.cid);
console.log('Established connection', client.cid);
return client;
} else {
cookie.reset('secret');
cookie.reset('cid');
console.log('Failed connection');
return null;
}
} else {
console.log('Using saved secret and connection', secret);
var cid = cookie.get('cid');
var client = Client.fromID(cid, secret);
if((await vista.call(cid, 'XWB_IM_HERE')).result == '1') return client;
cookie.reset('cid');
return await Client.fromCookie(secret, host, port);
}
}
};
export default window.vistax = {
RPCError, Client, connect: Client.fromCookie
};