Compare commits

..

4 Commits

7 changed files with 317 additions and 14 deletions

3
frontend/.gitignore vendored
View File

@ -8,3 +8,6 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Override
!/src/*

View File

@ -0,0 +1,54 @@
<script>
import { page } from '$app/stores';
import { navlinks } from '$lib/stores.js';
</script>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<div class="container-fluid">
{#if $navlinks.length > 0}
{@const first = $navlinks[0]}
<a class="navbar-brand" href={first.href || '#'} target={first.target}>{first.name || 'VistA-SSH'}</a>
{:else}
<a class="navbar-brand" href="#">VistA-SSH</a>
{/if}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
{#if $navlinks.length > 0}
{@const first = $navlinks[0]}
{#if first.children}
{#each first.children as item}{#if (item.name) && (item.href)}<li class="nav-item"><a class="nav-link" class:active={$page.url.pathname.includes(item.href)} href={item.href} target={item.target}>{item.name}</a></li>{/if}{/each}
{/if}
{#each $navlinks as parent, idx}
{#if (idx > 0) && (parent.name)}
{#if (parent.children) && (parent.children.length > 0)}
<div class="btn-group">
{#if parent.href}
<a class="nav-link" class:active={$page.url.pathname.includes(parent.href)} href={parent.href} target={parent.target}>{parent.name}</a>
<button type="button" class="nav-link btn dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">Toggle Dropdown</span></button>
{:else}
<button type="button" class="nav-link btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">{parent.name}</button>
{/if}
<ul class="dropdown-menu dropdown-menu-dark">{#each parent.children as item}{#if (item.name) && (item.href)}<li><a class="dropdown-item" class:active={$page.url.pathname.includes(item.href)} href={item.href} target={item.target}>{item.name}</a></li>{/if}{/each}</ul>
</div>
{:else if parent.href}
<li class="nav-item"><a class="nav-link" class:active={$page.url.pathname.includes(parent.href)} href={parent.href} target={parent.target}>{parent.name}</a></li>
{/if}
{/if}
{/each}
{/if}
</ul>
<form role="search" method="get" action="/lookup" target="_blank">
<input class="form-control me-2" type="search" name="q" placeholder="Patient lookup..." aria-label="Patient lookup">
</form>
</div>
</div>
</nav>
<style>
:global(body) {
padding-top: 4.5rem;
}
</style>

View File

@ -0,0 +1,36 @@
export async function get_api_appointments({ fetch, clinics = [], date = 'T' } = {}) {
if(clinics.constructor === Array) clinics = clinics.map(x => x.replace(/^\s+|\s+$/g, '').replace(/\s+/, ' ')).filter(x => x).join('^').replace(/\//g, '|');
else clinics = clinics.replace(/^\s+|\s+$/g, '').replace(/\s+/, ' ').replace(/\//g, '|');
return await (await (fetch || window.fetch)('/api/appointments/' + clinics + '/' + date)).json();
}
export async function get_api_lookup({ fetch, query, ordinal, force = false } = {}) {
if(query) {
if(ordinal === undefined) return await (await (fetch || window.fetch)('/api/lookup/' + encodeURIComponent(query))).json();
else return (await (fetch || window.fetch)('/api/lookup/' + encodeURIComponent(query) + '/' + (ordinal || '0') + (force ? '/force' : ''))).text()
}
}
export async function get_api_rcrs_patients({ fetch, alpha = 'T-30', omega = 'N' } = {}) {
return await (await (fetch || window.fetch)('/api/rcrs/patients/' + alpha + '/' + omega)).json();
}
export async function get_api_rcrs_tumors({ fetch, alpha = 'T-30', omega = 'N' } = {}) {
return await (await (fetch || window.fetch)('/api/rcrs/tumors/' + alpha + '/' + omega)).json();
}
export async function get_api_measurements({ fetch, mrn, alpha = 'T-30', omega = 'N' } = {}) {
return await (await (fetch || window.fetch)('/api/measurements/' + mrn + '/' + alpha + '/' + omega)).json();
}
export async function get_api_labs({ fetch, mrn, alpha = 'T-30', omega = 'N' } = {}) {
return await (await (fetch || window.fetch)('/api/labs/' + mrn + '/' + alpha + '/' + omega)).json();
}
export async function get_api_notes({ fetch, mrn, alpha = 'T-30', omega = 'N' } = {}) {
return await (await (fetch || window.fetch)('/api/notes/' + mrn + '/' + alpha + '/' + omega)).json();
}
export async function get_api_orders({ fetch, mrn, alpha = 'T-30', omega = 'N' } = {}) {
return await (await (fetch || window.fetch)('/api/orders/' + mrn + '/' + alpha + '/' + omega)).json();
}

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,18 @@
import { get, writable } from 'svelte/store';
export const navlinks = writable([]);
navlinks.push = function(item) {
navlinks.pop(item);
navlinks.update(value => (value.unshift(item), value));
return item;
};
navlinks.pop = function(item) {
navlinks.update(value => {
if(item !== undefined) {
let idx = value.indexOf(item);
if(idx >= 0) value.splice(idx, 1);
} else value.shift();
return value;
});
return item;
}

194
frontend/src/lib/util.js Normal file
View File

@ -0,0 +1,194 @@
export const comp = (...fs) => x0 => fs.reduceRight((x, f) => f(x), x0);
export const flow = (...fs) => x0 => fs.reduce((x, f) => f(x), x0);
export const pipe = (x0, ...fs) => fs.reduce((x, f) => f(x), x0);
export const aflow = (f0, ...fs) => async (...args) => fs.reduce((x, f) => f(x), await f0(...args));
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 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 function strtr_unscramble(name) {
return name.length > 0 ? (name.charAt(0) + strtr(name.substring(1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'LKJIHGFEDCBAZYXWVUTSRQPONM')) : name;
}
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 datetime_datestr(dt) {
return dt ? dt.toLocaleDateString('sv-SE') : undefined;
}
export function datetime_timestr(dt) {
if(dt) {
const res = dt.toLocaleTimeString('en-GB');
return res.endsWith(':00') ? res.substring(0, 5) : res;
}
}
export function datetime_dtstr(dt) {
if(dt) {
const res = dt.toLocaleTimeString('en-GB');
return dt.toLocaleDateString('sv-SE') + ' ' + (res.endsWith(':00') ? res.substring(0, 5) : res);
}
}
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 strfdate_vista(date) {
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate();
}
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 escapeHTML(unsafe) {
return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
export function escapeRegExp(unsafe) {
return unsafe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function debounce(fn, delay) {
var clock = null;
return function() {
window.clearTimeout(clock);
var self = this, args = arguments;
clock = window.setTimeout(function() { fn.apply(self, args) }, delay);
}
}
export function isInViewport(el, entirely = false) {
const rect = el.getBoundingClientRect();
return entirely ? (
(rect.top >= 0) &&
(rect.left >= 0) &&
(rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)) &&
(rect.right <= (window.innerWidth || document.documentElement.clientWidth))
) : (
(rect.bottom >= 0) &&
(rect.right >= 0) &&
(rect.top <= (window.innerHeight || document.documentElement.clientHeight)) &&
(rect.left <= (window.innerWidth || document.documentElement.clientWidth))
);
}
export function filter_pattern(query, between, flags) {
if((query = query.replace(/^\s+|\s+$/g, '')).length > 0) {
if(query.startsWith('/')) {
if(query.length > 1) {
var m = /^\/(.*)\/([a-z]*)$/.exec(query);
return m ? new RegExp(m[1], m[2]) : new RegExp(query.substring(1), flags || 'gims');
}
} else {
query = query.split('|').map(x => filter_part(x, between || 5)).filter(x => x);
if(query.length > 0) return new RegExp(query.join('|'), flags || 'gims');
}
}
}
function filter_part(query, between) {
if((query = query.replace(/^\s+|\s+$/g, '')).length > 0) {
if(query.startsWith('"')) {
query = query.substring(1, query.length - ((query.length > 1) && (query.endsWith('"')) ? 1 : 0));
if(query.length > 0) return query.split(/\s+/).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*');
} else return '(?:' + query.split(/\s+/).map(x => '(?<!\\w)' + x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\S*\\s+(?:\\S+\\s+){0,' + (+(between || 5)) + '}?') + ')';
}
}
export function filter_test(pattern, haystack) {
pattern.lastIndex = 0;
return pattern.test(haystack);
}
export function filter_mark(pattern, haystack, mark) {
return haystack.replace(pattern, mark || '<mark>$&</mark>');
}
export function filter_snippets(pattern, haystack, mark, before, after) {
var res = [], context = new RegExp('(?:\\S+[ \t\v\xa0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u202f\u205f\u3000\ufeff]+){0,' + (+(before || 3)) + '}\\S*(' + pattern.source + ')\\S*(?:[ \t\v\xa0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u202f\u205f\u3000\ufeff]+\\S+){0,' + (+(after || before || 3)) + '}', pattern.flags), match;
if(context.global) while((match = context.exec(haystack)) !== null) res.push(match[0].replace(pattern, mark || '<mark>$&</mark>').replace(/\s+/g, ' ').replace(/([\W_])\1{2,}/g, '$1$1'));
else if((match = context.exec(haystack)) !== null) res.push(match[0].replace(pattern, mark || '<mark>$&</mark>').replace(/\s+/g, ' ').replace(/([\W_])\1{2,}/g, '$1$1'));
return uniq(res);
}
export function filter_snippets_lines(pattern, haystack, mark) {
var res = [], context = new RegExp('[^\r\n]*(' + pattern.source + ')[^\r\n]*', pattern.flags), match;
if(context.global) while((match = context.exec(haystack)) !== null) res.push(match[0].replace(pattern, mark || '<mark>$&</mark>').replace(/[\r\n]/g, ' '));
else if((match = context.exec(haystack)) !== null) res.push(match[0].replace(pattern, mark || '<mark>$&</mark>').replace(/[\r\n]/g, ' '));
return uniq(res);
}
function Descendant() {}
export function inherit(obj) {
Descendant.prototype = obj;
return new Descendant();
}

View File

@ -19,7 +19,7 @@
} }
function components(reports) { function components(reports) {
const res = [], re_lab_test = /^(?<name>\w[^\r\n]{26})(?<value>[^\r\n]{8}) (?:(?<flag>\w[ \*])|[ ]) (?<unit>[^\r\n]{10}) (?<range>[^\r\n]{16}) \[(?<site>\d+)\]$/gm; const res = [], re_lab_test = /^(?<name>\w[^\r\n]{26})(?<value>[^\r\n]{8}) (?:(?<flag>\w[ \*])| ) (?<unit>[^\r\n]{10}) (?<range>[^\r\n]{16}) \[(?<site>\d+)\]$/gm;
reports.forEach(function(x) { reports.forEach(function(x) {
let datestr = datetime_datestr(x._ts), timestr = datetime_timestr(x._ts), m, r; let datestr = datetime_datestr(x._ts), timestr = datetime_timestr(x._ts), m, r;
while(m = re_lab_test.exec(x.body)) { while(m = re_lab_test.exec(x.body)) {
@ -258,9 +258,7 @@
</th> </th>
{#each component_groups as group, idx}<th class="date" class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) }><div class="year">{group.datestr.substring(0, 4)}</div><div class="monthdate">{group.datestr.substring(5)}</div>{#if false}<div class="hourminute daily">{datetime_timestr(group.values[0]._ts)}</div>{/if}</th>{/each} {#each component_groups as group, idx}<th class="date" class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) }><div class="year">{group.datestr.substring(0, 4)}</div><div class="monthdate">{group.datestr.substring(5)}</div>{#if false}<div class="hourminute daily">{datetime_timestr(group.values[0]._ts)}</div>{/if}</th>{/each}
</tr> </tr>
</thead>
{#if pattern} {#if pattern}
<tbody>
{#each component_names as name} {#each component_names as name}
{#if filter_test(pattern, name)} {#if filter_test(pattern, name)}
<tr class="match"> <tr class="match">
@ -271,8 +269,10 @@
</tr> </tr>
{/if} {/if}
{/each} {/each}
</tbody> {/if}
</thead>
<tbody> <tbody>
{#if pattern}
{#each component_names as name} {#each component_names as name}
{#if !filter_test(pattern, name)} {#if !filter_test(pattern, name)}
<tr> <tr>
@ -283,9 +283,7 @@
</tr> </tr>
{/if} {/if}
{/each} {/each}
</tbody>
{:else} {:else}
<tbody>
{#each component_names as name} {#each component_names as name}
<tr> <tr>
<th>{name}</th> <th>{name}</th>
@ -294,8 +292,8 @@
{/each} {/each}
</tr> </tr>
{/each} {/each}
</tbody>
{/if} {/if}
</tbody>
</table> </table>
</div> </div>
</div> </div>
@ -325,9 +323,9 @@
white-space: nowrap; white-space: nowrap;
text-align: center; text-align: center;
} }
table.table-sticky thead th { table.table-sticky thead {
position: sticky; position: sticky;
z-index: 1010; z-index: 1020;
top: 0; top: 0;
background-color: #fff; background-color: #fff;
} }
@ -339,7 +337,6 @@
} }
table.table-sticky thead th.corner { table.table-sticky thead th.corner {
padding: 0; padding: 0;
z-index: 1020;
} }
.navbar { .navbar {
position: sticky; position: sticky;