Compare commits

...

10 Commits

20 changed files with 438 additions and 38 deletions

View File

@ -1,3 +1,20 @@
# vistassh-py # vistassh-py
Python-based web interface for VistA roll-and-scroll terminal Python-based web interface for VistA roll-and-scroll terminal
## Getting started
You will need:
- Python 3.10+ & pip
- Node.js & npm
To install the dependencies and build the frontend, perform the following steps:
```bash
pip install -r requirements.txt
cd frontend
npm install
npm run build
```
To run, invoke `main.py`.

View File

@ -9,7 +9,7 @@ import contextlib
import logging import logging
from collections import namedtuple from collections import namedtuple
from typing import Optional, Union, Sequence, NamedTuple, Callable from typing import Any, Optional, Union, Sequence, NamedTuple, Callable, AsyncGenerator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,7 +28,7 @@ class ExpectQ(object):
"""Clear or restore buffer""" """Clear or restore buffer"""
self.buffer = buffer self.buffer = buffer
clear = reset clear = reset
async def prompts(self, endl: str='\r\n', timeout_settle: Optional[float]=None, throw: bool=False): async def prompts(self, endl: str='\r\n', timeout_settle: Optional[float]=None, throw: bool=False) -> AsyncGenerator[tuple[Optional[str], Optional[int]], None]:
len_endl = len(endl) len_endl = len(endl)
while True: while True:
if (pos := self.buffer.rfind(endl)) >= 0: if (pos := self.buffer.rfind(endl)) >= 0:
@ -43,7 +43,7 @@ class ExpectQ(object):
if throw: if throw:
raise raise
yield None, None yield None, None
async def promptmatches(self, *mappings: Union[str, re.Pattern, tuple, list], endl: str='\r\n', timeout_settle: Optional[float]=None, throw: bool=False): async def promptmatches(self, *mappings: Union[str, re.Pattern, tuple, list], endl: str='\r\n', timeout_settle: Optional[float]=None, throw: bool=False) -> AsyncGenerator[tuple[Optional[ExpectMatch], Any], Optional[bool]]:
for i, mapping in enumerate(mappings): for i, mapping in enumerate(mappings):
try: try:
match mapping: match mapping:

View File

@ -26,5 +26,11 @@ async def cmd_listclinics(proc):
proc.sendline('^') proc.sendline('^')
break break
proc.sendline('^Patient information AND OE/RR') proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()

View File

@ -74,7 +74,13 @@ async def cmd_reports(proc, mrn, alpha, omega):
else: else:
print(repr(before)) print(repr(before))
assert False assert False
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
text = re.sub(r'\r\n\s+>> CONTINUATION OF .+? <<(?:(?:\r\n)|(?:\s+page \d+))', '', '\r\n'.join(pages)) text = re.sub(r'\r\n\s+>> CONTINUATION OF .+? <<(?:(?:\r\n)|(?:\s+page \d+))', '', '\r\n'.join(pages))
positions = [m.start() for m in re.finditer(r'(?:(?:[ ]+----MICROBIOLOGY----[ ]+page \d+\r\n\r\n)|(?:[ ]+))Reporting Lab:', text)] positions = [m.start() for m in re.finditer(r'(?:(?:[ ]+----MICROBIOLOGY----[ ]+page \d+\r\n\r\n)|(?:[ ]+))Reporting Lab:', text)]

View File

@ -39,7 +39,13 @@ async def cmd_entries(proc, mrn, alpha, omega):
proc.sendline(response) proc.sendline(response)
if prompt.index == 0 or prompt.index == 1: if prompt.index == 0 or prompt.index == 1:
pages.append(re.sub(r'^\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r\n[^\r\n]+? Cumulative Vitals\/Measurements Report[ ]+Page \d+\r\n\r\n-{10,}\r\n(?:\d{2}\/\d{2}\/\d{2} \(continued\)\r\n\r\n)?|\r\n\r\n\*\*\*[^\r\n]+\r\n\r\n[^\r\n]+?VAF 10-7987j\r\nUnit:[^\r\n]+\r\nDivision:[^\r\n]+(?:\r\n)?$', '', prompt.before)) pages.append(re.sub(r'^\x1b\[H\x1b\[J\x1b\[2J\x1b\[H\r\n[^\r\n]+? Cumulative Vitals\/Measurements Report[ ]+Page \d+\r\n\r\n-{10,}\r\n(?:\d{2}\/\d{2}\/\d{2} \(continued\)\r\n\r\n)?|\r\n\r\n\*\*\*[^\r\n]+\r\n\r\n[^\r\n]+?VAF 10-7987j\r\nUnit:[^\r\n]+\r\nDivision:[^\r\n]+(?:\r\n)?$', '', prompt.before))
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
for m_date in re.finditer(r'^(?P<date>\d{2}\/\d{2}\/\d{2})\r\n(?P<body>.*?\r\n)(?:(?=\d{2}\/)|\r\n|$)', '\r\n'.join(pages), re.DOTALL|re.MULTILINE): for m_date in re.finditer(r'^(?P<date>\d{2}\/\d{2}\/\d{2})\r\n(?P<body>.*?\r\n)(?:(?=\d{2}\/)|\r\n|$)', '\r\n'.join(pages), re.DOTALL|re.MULTILINE):
g_date = m_date.group('date') g_date = m_date.group('date')

View File

@ -31,15 +31,24 @@ async def cmd_reports(proc, mrn, alpha, omega):
proc.sendline(util.vista_strftime(omega)) proc.sendline(util.vista_strftime(omega))
assert await expect.endswith('\r\n Thru: ') assert await expect.endswith('\r\n Thru: ')
proc.sendline(util.vista_strftime(alpha)) proc.sendline(util.vista_strftime(alpha))
assert await expect.endswith('\r\nDo you want WORK copies or CHART copies? CHART// ') found = True
proc.sendline() # default CHART match await expect.endswith('\r\nDo you want WORK copies or CHART copies? CHART// ', '\r\nPrint Notes Beginning: '):
if await expect.endswith('\r\nDo you want to start each note on a new page? NO// '): case autoproc.ExpectMatch(index=0):
proc.sendline() # default NO proc.sendline() # default CHART
assert await expect.endswith('\r\nDEVICE: HOME// ') if await expect.endswith('\r\nDo you want to start each note on a new page? NO// '):
proc.sendline('HOME;;1023') proc.sendline() # default NO
assert await expect.earliest(' HOME(CRT)\r\n') assert await expect.endswith('\r\nDEVICE: HOME// ')
proc.sendline('HOME;;1023')
assert await expect.earliest(' HOME(CRT)\r\n')
case autoproc.ExpectMatch(index=1):
proc.sendline('^')
assert await expect.endswith('\r\nSelect PATIENT NAME: ')
proc.sendline('^')
assert await expect.endswith('\r\nSelect Progress Notes Print Options Option: ')
found = False
case _: assert False
pages = [] pages = []
while True: while found:
match m_delimiter := await expect.endswith('\r\nType <Enter> to continue or \'^\' to exit: ', '\r\nSelect PATIENT NAME: '): match m_delimiter := await expect.endswith('\r\nType <Enter> to continue or \'^\' to exit: ', '\r\nSelect PATIENT NAME: '):
case autoproc.ExpectMatch(index=0, before=before): case autoproc.ExpectMatch(index=0, before=before):
if isnew(before) and len(pages) > 0: if isnew(before) and len(pages) > 0:
@ -59,5 +68,11 @@ async def cmd_reports(proc, mrn, alpha, omega):
case _: assert False case _: assert False
proc.sendline('^') proc.sendline('^')
proc.sendline('^Patient information AND OE/RR') proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()

View File

@ -77,7 +77,13 @@ async def cmd_entries(proc, mrn, alpha, omega):
break break
case _: assert False case _: assert False
proc.sendline('^Patient information AND OE/RR') proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
prev = None prev = None
for m in re.finditer(r'\b\d{2}/\d{2}/\d{2}.*?\r\n\r\n', '\r\n'.join(pages).replace('\x1b[1m', '').replace('\x1b[m', ''), re.DOTALL): for m in re.finditer(r'\b\d{2}/\d{2}/\d{2}.*?\r\n\r\n', '\r\n'.join(pages).replace('\x1b[1m', '').replace('\x1b[m', ''), re.DOTALL):

View File

@ -25,7 +25,13 @@ async def cmd_lookup_patient(proc, query):
res.append(prompt.before[:-24] if prompt.index == 0 and prompt.before.endswith('\r\nENTER \'^\' TO STOP, OR \r\n') else prompt.before) res.append(prompt.before[:-24] if prompt.index == 0 and prompt.before.endswith('\r\nENTER \'^\' TO STOP, OR \r\n') else prompt.before)
if 0 < prompt.index < 4: if 0 < prompt.index < 4:
single = True single = True
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
if single: if single:
return [re.search(r'[ ]{2}(?P<name>.+?)[ ]{2}(?:\((?P<alias>[^\)]*?)\))?[ ]{6}(?P<dob>\S+)[ ]{4}(?P<ssn>\S+(?:P \*\*Pseudo SSN\*\*)?)[ ]{5}(?P<yesno>\S+)[ ]{5}(?P<type>.+?)[ ]{6}(?P<no>[^\r\n]*)', res[0].replace('\r\n', '', 1)).groupdict()] return [re.search(r'[ ]{2}(?P<name>.+?)[ ]{2}(?:\((?P<alias>[^\)]*?)\))?[ ]{6}(?P<dob>\S+)[ ]{4}(?P<ssn>\S+(?:P \*\*Pseudo SSN\*\*)?)[ ]{5}(?P<yesno>\S+)[ ]{5}(?P<type>.+?)[ ]{6}(?P<no>[^\r\n]*)', res[0].replace('\r\n', '', 1)).groupdict()]
@ -65,6 +71,12 @@ async def cmd_lookup_patient_ordinal(proc, query, ordinal, force=False):
proc.sendline(response) proc.sendline(response)
case autoproc.ExpectMatch(index=5): case autoproc.ExpectMatch(index=5):
proc.sendline() proc.sendline()
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
return re.sub(r'\r\n\r\n(?:[^\r\n;]+);(?:\([^\)]*?\))? (?:\d+ )?(?:\d{3}-\d{2}-\d{4}P?) (?:[^\r\n]+?)[ ]*?(\r\n={10,}\r\n)\r\n', r'\1', '\r\n'.join(res)) return re.sub(r'\r\n\r\n(?:[^\r\n;]+);(?:\([^\)]*?\))? (?:\d+ )?(?:\d{3}-\d{2}-\d{4}P?) (?:[^\r\n]+?)[ ]*?(\r\n={10,}\r\n)\r\n', r'\1', '\r\n'.join(res))

View File

@ -58,7 +58,13 @@ async def cmd_patients(proc, alpha, omega):
summary.extend({k.strip(): v.strip() for k, v in row.groupdict().items()} for row in re.finditer(r'(?P<last5>[A-Z]\d{4}) (?P<name>[^\r\n]{30}) (?P<uid>[^ \r\n]+) (?P<primarySite>[^ \r\n]+) (?P<dateOfDiagnosis>\d{2}/\d{2}/\d{4}) (?P<dateCaseLastChanged>\d{2}/\d{2}/\d{4})', prompt.before)) summary.extend({k.strip(): v.strip() for k, v in row.groupdict().items()} for row in re.finditer(r'(?P<last5>[A-Z]\d{4}) (?P<name>[^\r\n]{30}) (?P<uid>[^ \r\n]+) (?P<primarySite>[^ \r\n]+) (?P<dateOfDiagnosis>\d{2}/\d{2}/\d{4}) (?P<dateCaseLastChanged>\d{2}/\d{2}/\d{4})', prompt.before))
elif prompt.index == 4: elif prompt.index == 4:
break break
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
for item in parse_xml_rcrs(doc_rcrs, summary): for item in parse_xml_rcrs(doc_rcrs, summary):
yield item yield item

View File

@ -63,7 +63,13 @@ async def cmd_appointments(proc, clinics='NPT-HEM/ONC ATTENDING', date='T', stor
item['comment'] = '\r\n'.join(m.group(1) for m in re.finditer(r'^\s{15}(\w.*?)$', detail, re.MULTILINE)) item['comment'] = '\r\n'.join(m.group(1) for m in re.finditer(r'^\s{15}(\w.*?)$', detail, re.MULTILINE))
yield item yield item
proc.sendline('^Patient information AND OE/RR') proc.sendline('^Patient information AND OE/RR')
assert await expect.endswith('\r\nSelect Patient Information and OE/RR Option: ', '\r\nSelect Patient Information and OE/RR <TEST ACCOUNT> Option: ') async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
expect.clear() expect.clear()
async def vista_appointment_clinics(proc, expect): async def vista_appointment_clinics(proc, expect):

View File

@ -23,6 +23,13 @@ async def task_smartcard(proc, config: Optional[configparser.ConfigParser]=None)
if certificate: if certificate:
config.set('auth', 'certificate', certificate) config.set('auth', 'certificate', certificate)
proc.create_task(task_keepalive(proc, True), name='@task:keepalive') proc.create_task(task_keepalive(proc, True), name='@task:keepalive')
async for prompt, response in expect.promptmatches((
(re.compile(r' Press \'RETURN\' to continue, \'\^\' to stop: $'), None),
('Select Patient Information and OE/RR Option: ', None, True),
('Select Patient Information and OE/RR <TEST ACCOUNT> Option: ', None, True),
), throw=True):
if prompt.index == 0:
proc.sendline(response)
return True return True
async def task_keepalive(proc, suppress=False): async def task_keepalive(proc, suppress=False):

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,37 @@
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, '|');
if(clinics) return await (await (fetch || window.fetch)('/api/appointments/' + clinics + '/' + date)).json();
else return [];
}
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

@ -2,7 +2,12 @@ import { get_api_appointments } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */ /** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) { export async function load({ params, fetch }) {
let clinics = await (await fetch('/api/config/user/clinics')).json(); let clinics = [];
try {
clinics = await (await fetch('/api/config/user/clinics')).json();
} catch(ex) {
console.error(ex, ex.stack);
}
let appointments = await get_api_appointments({ fetch, clinics, date: 'T' }); let appointments = await get_api_appointments({ fetch, clinics, date: 'T' });
appointments.sort((a, b) => a.time_scheduled < b.time_scheduled ? -1 : a.time_scheduled > b.time_scheduled ? 1 : 0); appointments.sort((a, b) => a.time_scheduled < b.time_scheduled ? -1 : a.time_scheduled > b.time_scheduled ? 1 : 0);
return { return {

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}
<tbody> </thead>
<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;

View File

@ -2,8 +2,12 @@
export async function load({ params, fetch }) { export async function load({ params, fetch }) {
let clinics = await (await fetch('/api/clinic/list')).json(); let clinics = await (await fetch('/api/clinic/list')).json();
clinics.reduce((acc, item) => (acc[item.name] = item, acc), clinics); clinics.reduce((acc, item) => (acc[item.name] = item, acc), clinics);
let selection = await (await fetch('/api/config/user/clinics')).json(); try {
selection.forEach(x => clinics[x] ? clinics[x].active = true : false); let selection = await (await fetch('/api/config/user/clinics')).json();
selection.forEach(x => clinics[x] ? clinics[x].active = true : false);
} catch(ex) {
console.error(ex, ex.stack);
}
return { return {
clinics clinics
}; };