This commit is contained in:
2024-03-02 00:34:29 -05:00
committed by inportb
commit 7be5ebcdaa
49 changed files with 3907 additions and 0 deletions

10
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

38
frontend/README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "vistassh-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.4",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"svelte": "^4.2.8",
"vite": "^5.0.10"
},
"type": "module",
"dependencies": {
"bootstrap": "^5.3.2"
}
}

12
frontend/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
//export const prerender = true;
export const ssr = false;

View File

@ -0,0 +1,30 @@
<script>
import { onDestroy } from 'svelte';
import 'bootstrap/dist/css/bootstrap.css';
import url_bootstrap from 'bootstrap/dist/js/bootstrap.bundle.js?url';
import { navlinks } from '$lib/stores.js';
import Navbar from '$lib/Navbar.svelte';
const links = navlinks.push({
name: 'VistA-SSH',
href: '/',
children: [
{ name: 'Lookup', href: '/lookup' },
{ name: 'Appointments', href: '/appointments' },
{ name: 'Clinics', href: '/clinics' },
{ name: 'RCRS', href: '/rcrs' },
{ name: 'Log', href: '/api/log.txt', target: '_blank' },
]
});
onDestroy(() => navlinks.pop(links));
</script>
<svelte:head>
<script src={url_bootstrap}></script>
</svelte:head>
<Navbar />
<main class="container-md">
<slot />
</main>

View File

@ -0,0 +1,15 @@
<svelte:head>
<title>VistA-SSH</title>
</svelte:head>
<div class="bg-body-tertiary p-5 rounded">
<h1 style="font-family: monospace; white-space: pre; text-align: center;">VVVV VVAAAA
VVVV VVAAAAAA
VVVV VVAA AAAA
VVVV VVAA AAAA
VVVV VVAA AAAA
VVVVVVAA AAAA
VVVVAA AAAAAAAAAAA
VVAA AAAAAAAAAAA</h1>
<h1 style="text-align: center;">🚧 VistA-SSH 🚧</h1>
</div>

View File

@ -0,0 +1,11 @@
import { get_api_appointments } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let clinics = await (await fetch('/api/config/user/clinics')).json();
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);
return {
clinics, appointments
};
}

View File

@ -0,0 +1,69 @@
<script>
import { debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_dtstr, filter_pattern, filter_test, filter_mark, filter_snippets } from '$lib/util.js';
export let data;
let query = '', pattern = null;
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
data.appointments.forEach(x => (delete x._content, x._content = escapeHTML(Object.values(x).join('\x00'))));
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
</script>
<svelte:head>
<title>Appointments</title>
</svelte:head>
<h1>Appointments</h1>
<div class="card mb-3 shadow">
{#if data.appointments.length > 0}
<table class="table appointments">
<thead>
<tr>
<th colspan="100" style="padding: 0;">
<div class="input-group">
<input type="text" class="form-control" placeholder="Filter" name="q" bind:value={query} />
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</th>
</tr>
</thead>
<tbody>
{#if pattern}
{#each data.appointments as row}
{#if filter_test(pattern, row._content)}
<tr style:--bs-table-bg={strHashHSL(row.clinic, '85%')}>
<td><div>{row.clinic}</div><div>{datetime_dtstr(new Date(row.time_scheduled))}</div></td>
<td><div><a href="/lookup?q={encodeURIComponent(row.patient_name.charAt(0) + row.patient_last4)}&name={encodeURIComponent(row.patient_name)}&rd=true">{row.patient_name} {row.patient_last4}</a></div><div>{row.patient_phone}</div></td>
<td class="comment">{@html filter_mark(pattern, escapeHTML(row.comment))}</td>
</tr>
{/if}
{/each}
{:else}
{#each data.appointments as row}
<tr style:--bs-table-bg={strHashHSL(row.clinic, '85%')}>
<td><div>{row.clinic}</div><div>{datetime_dtstr(new Date(row.time_scheduled))}</div></td>
<td><div><a href="/lookup?q={encodeURIComponent(row.patient_name.charAt(0) + row.patient_last4)}&name={encodeURIComponent(row.patient_name)}&rd=true">{row.patient_name} {row.patient_last4}</a></div><div>{row.patient_phone}</div></td>
<td class="comment">{row.comment}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
</div>
<style>
:global(table.appointments mark) {
padding: 0;
font-weight: bold;
background-color: #fff;
}
.card table.table {
margin-bottom: 0;
}
td.comment {
white-space: pre-line;
}
</style>

View File

@ -0,0 +1,14 @@
import { get_api_lookup } from '$lib/backend.js';
/** @type {import('./$types').LayoutLoad} */
export async function load({ params, fetch }) {
const mrn = params.mrn;
const facesheet = await get_api_lookup({ fetch, query: mrn, ordinal: '0', force: true });
const match = /^(?<name>[^\r\n;]+);(?:\((?<alias>[^\)]*?)\))? (?:(?<icn>\d+) )?(?<ssn>\d{3}-\d{2}-\d{4}P?) (?<dob>.+?)\s*$/m.exec(facesheet);
const facesheet_data = match ? match.groups : {};
const m_sex = /^Birth Sex[ ]+:[ ]+(.+?)$/m.exec(facesheet);
if(m_sex) facesheet_data.sex = m_sex[1];
return {
mrn, sensitive: facesheet.includes('***RESTRICTED RECORD***'), facesheet, facesheet_data
};
}

View File

@ -0,0 +1,19 @@
<script>
import { onDestroy } from 'svelte';
import { navlinks } from '$lib/stores.js';
export let data;
const links = navlinks.push({
name: data.facesheet_data.name ? data.facesheet_data.name.split(',')[0].toLowerCase().replace(/(?:^|\s|["'([{])+\S/g, m => m.toUpperCase()) + ' ' + data.facesheet_data.ssn.split('-')[2] : 'Chart',
href: '/chart/' + data.mrn,
children: [
{ name: 'Orders', href: '/chart/' + data.mrn + '/orders' },
{ name: 'Labs', href: '/chart/' + data.mrn + '/labs' },
{ name: 'Notes', href: '/chart/' + data.mrn + '/notes' },
{ name: 'Flowsheet', href: '/chart/' + data.mrn + '/flowsheet' },
]
});
onDestroy(() => navlinks.pop(links));
</script>
<slot />

View File

@ -0,0 +1,17 @@
<script>
import { page } from '$app/stores';
/** @type {import('./$types').PageData} */
export let data;
</script>
<svelte:head>
<title>{data.facesheet_data.name}</title>
</svelte:head>
<h1>{data.facesheet_data.name}{#if data.sensitive}{/if}</h1>
<div class="card">
<div class="card-body">
<pre class="card-text">{data.facesheet}</pre>
</div>
</div>

View File

@ -0,0 +1,14 @@
import { get_api_measurements, get_api_labs } from '$lib/backend.js';
const time_min = new Date(1700, 0, 1);
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch, parent }) {
const mrn = params.mrn, offset = 30;
const parentdata = await parent();
const measurements = await get_api_measurements({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
const labs = await get_api_labs({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, offset, facesheet_data: parentdata.facesheet_data, measurements, labs
};
}

View File

@ -0,0 +1,483 @@
<script>
import { tick } from 'svelte';
import { uniq, groupByArray, quantile_sorted, debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets_lines, inherit } from '$lib/util.js';
import { get_api_measurements, get_api_labs } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, component_items = decorate_measurements(data.measurements).concat(decorate_labs(data.labs));
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate_measurements(xs) {
return xs.map(x => (x = Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time) }, x), x.timestr = datetime_timestr(x._ts), x));
}
function decorate_labs(xs) {
return components(xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time_collected) }, x)));
}
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;
reports.forEach(function(x) {
let datestr = datetime_datestr(x._ts), timestr = datetime_timestr(x._ts), m, r;
while(m = re_lab_test.exec(x.body)) {
m = m.groups;
for(let k in m) if(m[k]) m[k] = m[k].replace(/^\s+|\s+$/g, '');
if((r = m.range) && (r.includes(' - '))) {
r = r.split(' - ');
m.rangeL = r[0];
m.rangeH = r[1];
}
m.datestr = datestr;
m.timestr = timestr;
m.report = x;
Object.assign(m, x);
delete m.body;
delete m._content;
res.push(m);
}
});
return res;
}
function calculate(items) {
var snapshots = [], results = [], history, update, item;
groupByArray(items, x => x._ts).map(group => group.values.reduce(((acc, x) => (acc.values[x.name] = x, acc)), { key: group.key, values: {}})).sort((a, b) => (a.key > b.key) - (a.key < b.key)).forEach(function(group) {
snapshots.push({ key: group.key, values: history = Object.assign(snapshots.length > 0 ? inherit(snapshots[snapshots.length - 1].values) : inherit(calc_constants), update = group.values) });
history['Time'] = update['Time'] = { time: group.key, value: group.key };
for(var i = 0; i < calc_functions.length; ++i) {
var calculation = calc_functions[i], deps = calculation.deps;
for(var j = deps.length - 1, satisfied = true, updated = false; j >= 0; --j) if(!history[deps[j]]) { satisfied = false; break; }
else if(update[deps[j]]) updated = true;
if((satisfied) && (updated)) {
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
results.push(history[calculation.name] = update[calculation.name] = Object.assign({ _ts: group.key, value: item }, calculation));
if((calculation.hasOwnProperty('rangeL')) && (item < calculation.rangeL)) update[calculation.name].flag = 'L';
else if((calculation.hasOwnProperty('rangeH')) && (item > calculation.rangeH)) update[calculation.name].flag = 'H';
}
}
}
});
return results;
}
const calc_constants = {
DOB: { _ts: null, value: new Date(data.facesheet_data.dob) },
Sex: { _ts: null, value: data.facesheet_data.sex }
};
const calc_functions = [
{ name: 'Age', unit: 'yr', deps: ['Time', 'DOB'], calc(Time, DOB, prev) { var x = Math.floor((Time - DOB.getTime())/3.15576e10); return x != prev ? x : undefined; } },
{ name: 'BMI', unit: 'kg/m²', rangeL: 18.5, rangeH: 24.9, range: '18.5 - 24.9', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (10000*Wt/(Ht*Ht)).toPrecision(3) },
{ name: 'BSA', unit: 'm²', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (0.007184*Math.pow(Ht, 0.725)*Math.pow(Wt, 0.425)).toPrecision(3) },
{ name: 'CrCl', unit: 'mL/min', deps: ['Age', 'Sex', 'Wt', 'CREATININE'], calc: (Age, Sex, Wt, CREATININE) => (((140 - Age) * Wt)/(72*CREATININE)*(Sex == 'MALE' ? 1 : 0.85)).toPrecision(4) },
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) }
];
$: component_calculated = component_items.concat(calculate(component_items));
$: component_groups = groupByArray(component_calculated, x => new Date(x._ts.getFullYear(), x._ts.getMonth(), x._ts.getDate())).map(function(group) {
group = group.values.reduce(((acc, x) => ((acc.values[x.name] || (acc.values[x.name] = [])).push(x), acc)), { key: group.key, datestr: datetime_datestr(group.key), datestr_year: group.key.getFullYear(), values: {}});
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
var items = group.values[k].sort((a, b) => a._ts - b._ts);
var strings = items.map(item => item.timestr + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : '')/* + (item.comment && item.comment.indexOf('\n') < 0 ? ' • ' + item.comment : '')*/);
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 && x.indexOf('\n') >= 0));
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,
reports: items.map(item => item.report).filter(x => x),
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));
$: component_names = uniq(component_calculated.map(x => x.name));
$: component_stats = statistics(component_calculated);
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
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 tooltip(item) {
if(item) {
var res = [], stat;
if(item.range) res.push('Ref: ' + item.range + ' ' + item.unit + (item.flag ? ' [' + item.flag + ']' : ''));
if(stat = component_stats[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');
}
};
function abnormal_ref(item) {
return (item) && (item.flag);
};
function abnormal_ref_low(item) {
return (item) && (item.flag) && (item.flag.indexOf('L') >= 0);
};
function abnormal_ref_high(item) {
return (item) && (item.flag) && (item.flag.indexOf('H') >= 0);
};
function abnormal_iqr(item) {
var stat;
if((item) && (stat = component_stats[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);
}
};
function abnormal_iqr_low(item) {
var stat;
if((item) && (stat = component_stats[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;
}
};
function abnormal_iqr_high(item) {
var stat;
if((item) && (stat = component_stats[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;
}
};
function abstract(body) {
let re = /^(\w.*?)[ \t]{2,}\S.*?[ \t]{2,}/gm, m, res = [];
while(m = re.exec(body)) res.push(m[1]);
re = /^\*[ ]+((?:.+?) REPORT) =>/gm;
while(m = re.exec(body)) res.push(m[1]);
return res;
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
const next_offset = data.offset + (loadmore.limit = (factor*loadmore.limit)|0);
const measurements = await get_api_measurements({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + next_offset });
Array.prototype.push.apply(component_items, decorate_measurements(measurements));
const labs = await get_api_labs({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + next_offset });
Array.prototype.push.apply(component_items, decorate_labs(labs));
component_items = component_items; // reactivity hint
data.offset = next_offset;
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
let header;
(async function loadinit(target = 16, requests = 4) {
await tick();
for(let i = 0; (i < requests) && (component_groups.length < target); ++i) await loadmore();
await tick();
if((header) && (component_groups)) header.children[header.children.length - 1].scrollIntoView({ block: 'nearest', inline: 'end' });
})();
/*
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
*/
</script>
<svelte:head>
<title>Flowsheet</title>
</svelte:head>
{#if (selection) && (selection.length > 0)}
<div class="halfpane rightpane">
{#each selection as row, idx}
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</span>
{#if idx == 0}<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>{/if}
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
{/each}
</div>
{/if}
<div class={(selection) && (selection.length > 0) ? 'halfpane leftpane' : ''}>
<div style="font-family: monospace;" role="region" tabindex="0">
<table class="table-sticky table-data">
<thead>
<tr bind:this={header}>
<th class="corner">
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if !loadmore.loading}<span class="badge rounded-pill bg-primary position-absolute top-0 start-100 translate-middle-x" style="cursor: pointer;" on:click={loadmore}>More</span>{/if}
</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}
</tr>
</thead>
{#if pattern}
<tbody>
{#each component_names as name}
{#if filter_test(pattern, name)}
<tr class="match">
<th>{@html pattern ? filter_mark(pattern, escapeHTML(name)) : escapeHTML(name)}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/if}
{/each}
</tbody>
<tbody>
{#each component_names as name}
{#if !filter_test(pattern, name)}
<tr>
<th>{name}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/if}
{/each}
</tbody>
{:else}
<tbody>
{#each component_names as name}
<tr>
<th>{name}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/each}
</tbody>
{/if}
</table>
</div>
</div>
<style>
:global(th mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
[role="region"][tabindex] {
max-height: calc(100vh - 4.5rem);
overflow: auto;
}
table {
width: 100%;
}
table tr.match th:first-child {
background-color: #c6def1;
}
table th, table td {
padding: 0 0.5rem;
white-space: nowrap;
text-align: center;
}
table.table-sticky thead th {
position: sticky;
z-index: 1010;
top: 0;
background-color: #fff;
}
table.table-sticky th:first-child {
position: sticky;
z-index: 1010;
left: 0;
background-color: #fff;
}
table.table-sticky thead th.corner {
padding: 0;
z-index: 1020;
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
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 tbody tr:hover {
border: 1px solid #6c757d;
}
td:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
table.table-sticky tbody th, table.table-sticky th.name {
cursor: default;
font-weight: bold;
text-align: center;
padding-left: 1rem;
padding-right: 1rem;
}
table.table-sticky th.filtered {
background-color: #6c757d;
color: #fff;
}
table.table-sticky th.date {
cursor: default;
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;
}
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;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
left: 0;
z-index: -1;
}
.leftpane [role="region"][tabindex] {
max-height: calc(100vh - 3.5rem);
direction: rtl;
}
.leftpane [role="region"][tabindex] > * {
direction: ltr;
}
.rightpane {
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_labs } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let mrn = params.mrn, offset = 30;
let reports = await get_api_labs({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, reports, offset
};
}

View File

@ -0,0 +1,183 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets_lines } from '$lib/util.js';
import { get_api_labs } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time_collected) }, x));
}
function abstract(body) {
let re = /^(\w.*?)[ \t]{2,}\S.*?[ \t]{2,}/gm, m, res = [];
while(m = re.exec(body)) res.push(m[1]);
re = /^\*[ ]+((?:.+?) REPORT) =>/gm;
while(m = re.exec(body)) res.push(m[1]);
return res;
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_labs({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>Labs</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(selection._ts)}@{datetime_timestr(selection._ts)} {selection.accession} {selection.specimen}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(selection.body)) : escapeHTML(selection.body)}</div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">Labs</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
{@const abs = abstract(row.body)}
<li class="list-group-item" class:active={(selection) && (selection.uid == row.uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</div>
{#if abs.length > 0}<div class="abstract singleline">{abs.join(', ')}</div>{/if}
<div class="snippets">{#each filter_snippets_lines(pattern, escapeHTML(row.body), undefined, 3, 6) as match}<div>{@html match}</div>{/each}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
{@const abs = abstract(row.body)}
<li class="list-group-item" class:active={(selection) && (selection.uid == row.uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</div>
{#if abs.length > 0}<div class="abstract singleline">{abs.join(', ')}</div>{/if}
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.snippets mark, div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_notes } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let mrn = params.mrn, offset = 30;
let reports = await get_api_notes({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, reports, offset
};
}

View File

@ -0,0 +1,173 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets } from '$lib/util.js';
import { get_api_notes } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _uid: x.note_dated + x.local_title + x.standard_title + (x.visit || x.admitted), _ts: new Date(x.note_dated) }, x)).sort((a, b) => b._ts - a._ts);
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_notes({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>Progress notes</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(selection._ts)}@{datetime_timestr(selection._ts)} {selection.local_title}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(selection.body)) : escapeHTML(selection.body)}</div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">Progress notes</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.local_title}</div>
<div class="abstract singleline">{row.visit || row.admitted}</div>
<div class="snippets">{#each filter_snippets(pattern, escapeHTML(row.body), undefined, 3, 6) as match}<div>{@html match}</div>{/each}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.local_title}</div>
<div class="abstract singleline">{row.visit || row.admitted}</div>
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.snippets mark, div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_orders } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let mrn = params.mrn, offset = 30;
let reports = await get_api_orders({ fetch, mrn, alpha: 'T-' + offset, omega: 'N' });
return {
mrn, reports, offset
};
}

View File

@ -0,0 +1,166 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, isInViewport, filter_pattern, filter_test, filter_mark } from '$lib/util.js';
import { get_api_orders } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _uid: x.body, _ts: new Date(x.datetime_entered) }, x));
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_orders({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>Orders</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{selection.text || ''}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid"><dl class="report">{#each Object.entries(selection) as entry}{#if entry[0].charAt(0) != '_'}<dt>{entry[0]}</dt><dd>{entry[1]}</dd>{/if}{/each}</dl></div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">Orders</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.text || ''}</div>
<div class="report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.text || ''}</div>
<div class="report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,10 @@
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let clinics = await (await fetch('/api/clinic/list')).json();
clinics.reduce((acc, item) => (acc[item.name] = item, acc), clinics);
let selection = await (await fetch('/api/config/user/clinics')).json();
selection.forEach(x => clinics[x] ? clinics[x].active = true : false);
return {
clinics
};
}

View File

@ -0,0 +1,45 @@
<script>
export let data;
let filter = '';
$: filter = filter.toUpperCase();
$: selection = data.clinics.filter(row => row.active);
$: {
fetch('/api/config/user/clinics', { method: 'PUT', headers: { 'Content-type': 'application/json' }, body: JSON.stringify(selection.map(row => row.name)) });
}
</script>
<svelte:head>
<title>Clinics</title>
</svelte:head>
<h1>Clinics</h1>
<div class="card">
<div class="input-group">
<span class="input-group-text">🔎</span>
<input type="text" class="form-control" placeholder="Clinic" bind:value={filter} />
</div>
{#if filter.length > 0}
<ul class="list-group list-group-flush">
{#each data.clinics as row}{#if (row.name.charAt(0) != 'Z') && (row.name != 'DELETED CLINIC') && (row.name != 'CLINIC DELETED') && (row.name.startsWith(filter))}<li class="list-group-item" class:active={row.active} on:click={evt => row.active = !row.active}>{row.name}</li>{/if}{/each}
</ul>
{/if}
{#if selection.length > 0}
<div class="card-footer">
{#each selection as row}<span class="badge text-bg-primary">{row.name} <span on:click={evt => data.clinics[row.name].active = false}>❌</span></span>{/each}
</div>
{/if}
</div>
<style>
.list-group {
max-height: 50vh;
overflow-y: auto;
}
.list-group-item {
cursor: default;
}
.card-footer .badge:not(:last-child) {
margin-right: 0.25em;
}
</style>

View File

@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
import { get_api_lookup } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ url, fetch }) {
let query = (url.searchParams.get('q') || '').replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
let ordinal = parseInt(url.searchParams.get('ordinal') || '');
let name = (url.searchParams.get('name') || '').replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase();
let items = query ? await get_api_lookup({ fetch, query }) : [];
if(ordinal) items = items.filter(row => row.ordinal == ordinal);
else if(name) items = items.filter(row => row.name.startsWith(name));
let detail, match;
if((items.length == 1) && (url.searchParams.get('rd'))) {
detail = await get_api_lookup({ fetch, query, ordinal: (items[0].ordinal || '0'), force: url.searchParams.get('force') });
if(match = /(^[^\r\n;]+);(?:\([^\)]*?\))? (?:(\d+) )?(\d{3}-\d{2}-\d{4}P?) (.+?)\s*$/m.exec(detail)) {
if(match[2]) throw redirect(302, '/chart/' + match[2]);
if(match[3]) throw redirect(302, '/chart/' + match[3].replace(/[^\dP]/g, ''));
}
}
return {
query, ordinal, name, items, detail
};
}

View File

@ -0,0 +1,62 @@
<script>
import { tick } from 'svelte';
import { page } from '$app/stores';
export let data;
let ref = null;
$: tick().then(() => ref ? ref.focus() : null);
</script>
<svelte:head>
<title>Lookup</title>
</svelte:head>
<h1>Lookup</h1>
<div class="card mb-3 shadow">
<form method="get" action="?">
<div class="input-group">
<span class="input-group-text">🔎</span>
<input type="text" class="form-control" placeholder="Lookup" name="q" bind:value={data.query} bind:this={ref} />
<button type="submit" class="btn btn-primary">Search</button>
</div>
</form>
{#if data.items.length > 0}
<table class="table" data-sveltekit-preload-data="tap">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">DOB</th>
<th scope="col">SSN</th>
<th scope="col"></th>
<th scope="col">Type</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{#each data.items as row}
<tr>
<td><a href="/lookup?q={data.query}&ordinal={row.ordinal || 0}&rd=true">{row.name}{#if row.alias}{' (' + row.alias + ')'}{/if}</a></td>
<td>{row.dob}</td>
<td>{row.ssn}</td>
<td>{row.yesno}</td>
<td>{row.type}</td>
<td>{row.no}</td>
</tr>
{/each}
</tbody>
</table>
{#if (data.items.length) == 1 && (data.detail)}
<div class="card-body">
<pre class="card-text">{data.detail}</pre>
<p class="card-text"><a class="btn btn-danger" href="/lookup?q={data.query}&ordinal={data.items[0].ordinal || 0}&rd=true&force=true">Proceed to {data.items[0].name}</a></p>
</div>
{/if}
{/if}
</div>
<style>
.card table.table {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,10 @@
import { get_api_rcrs_patients } from '$lib/backend.js';
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
let offset = 365;
let reports = await get_api_rcrs_patients({ fetch, alpha: 'T-' + offset, omega: 'N' });
return {
reports, offset
};
}

View File

@ -0,0 +1,491 @@
<script>
import { tick } from 'svelte';
import { debounce, escapeHTML, escapeRegExp, strHashHSL, isInViewport, filter_pattern, filter_test, filter_mark } from '$lib/util.js';
import { get_api_rcrs_patients } from '$lib/backend.js';
export let data;
const sitecodes = {
'C00.0': 'External lip upper',
'C00.1': 'External lip lower',
'C00.2': 'External lip NOS',
'C00.3': 'Upper lip, mucosa',
'C00.4': 'Lower lip, mucosa',
'C00.5': 'Mucosa lip, NOS',
'C00.6': 'Commissure lip',
'C00.8': 'Overlapping lesion of lip',
'C00.9': 'Lip, NOS',
'C01.9': 'Base of tongue, NOS',
'C02.0': 'Dorsal surface tongue, NOS',
'C02.1': 'Border of tongue',
'C02.2': 'Ventral surface of tongue NOS',
'C02.3': 'Anterior 2/3 of tongue NOS',
'C02.4': 'Lingual tonsil',
'C02.8': 'Overlapping lesion of tongue',
'C02.9': 'Tongue NOS',
'C03.0': 'Upper gum',
'C03.1': 'Lower gum',
'C03.9': 'Gum NOS',
'C04.0': 'Anterior floor of mouth',
'C04.1': 'Lateral floor of mouth',
'C04.8': 'Overlapping lesion of floor of mouth',
'C04.9': 'Floor of mouth NOS',
'C05.0': 'Hard palate',
'C05.1': 'Soft palate NOS (excludes Nasopharyngcal surface C11.3)',
'C05.2': 'Uvula',
'C05.8': 'Overlapping lesion of palate',
'C05.9': 'Palate NOS',
'C06.0': 'Cheek mucosa',
'C06.1': 'Vestibule of mouth',
'C06.2': 'Retromolar area',
'C06.8': 'Overlapping lesion of other and unspecified parts of mouth',
'C06.9': 'Mouth NOS',
'C07.9': 'Parotid gland',
'C08.0': 'Submaxillary gland',
'C08.1': 'Sublingual gland',
'C08.8': 'Overlapping lesion of major salivary glands',
'C08.9': 'Major salivary gland, NOS',
'C09.0': 'Tonsillar fossa',
'C09.1': 'Tonsillar pillar',
'C09.8': 'Overlapping lesion of tonsil',
'C09.9': 'Tonsil NOS (excludes Lingual tonsil C02.4 and Pharyngeal tonsil C11.1)',
'C10.0': 'Vallecula',
'C10.1': 'Anterior surface of epiglottis',
'C10.2': 'Lateral wall oropharynx',
'C10.3': 'Posterior wall oropharynx',
'C10.4': 'Branchial cleft (site of neoplosm)',
'C10.8': 'Overlapping lesion of oropharynx',
'C10.9': 'Oropharynx NOS',
'C11.0': 'Superior wall of nasopharynx',
'C11.1': 'Posterior wall nasopharynx',
'C11.2': 'Lateral wall nasopharynx',
'C11.3': 'Anterior wall nasopharynx',
'C11.8': 'Overlapping lesion of nasopharynx',
'C11.9': 'Nasopharynx NOS',
'C12.9': 'Pyriform sinus',
'C13.0': 'Postcricoid region',
'C13.1': 'Hypopharyngeal aspect of aryepiglottic fold',
'C13.2': 'Posterior wall hypopharynx',
'C13.8': 'Overlapping lesion of hypopharynx',
'C13.9': 'Hypopharynx, NOS',
'C14.0': 'Pharynx NOS',
'C14.2': 'Waldeyer\'s ring',
'C14.8': 'Overlapping lesion of lip, oral cavity and pharynx',
'C15.0': 'Cervical esophagus',
'C15.1': 'Thoracic esophagus',
'C15.2': 'Abdominal esophagus',
'C15.3': 'Upper third of esophagus',
'C15.4': 'Middle third of esophagus',
'C15.5': 'Esophagus lower third',
'C15.8': 'Overlapping lesion of esophagus',
'C15.9': 'Esophagus NOS',
'C16.0': 'Cardia, NOS',
'C16.1': 'Fundus stomach',
'C16.2': 'Body stomach',
'C16.3': 'Gastric antrum',
'C16.4': 'Pylorus',
'C16.5': 'Lesser curvature of stomach, NOS (not classifiable to C16.1 to C16.4)',
'C16.6': 'Greater curvature of stomach, NOS (not classifiable to C16.0 to C16.4)',
'C16.8': 'Overlapping lesion of stomach',
'C16.9': 'Stomach NOS',
'C17.0': 'Duodenum',
'C17.1': 'Jejunum',
'C17.2': 'Ileum (excludes ileocecal valve C18.0)',
'C17.3': 'Meckel\'s diverticulum (site of neoplasm)',
'C17.8': 'Overlapping lesion of small intestine',
'C17.9': 'Small intestine NOS',
'C18.0': 'Cecum',
'C18.1': 'Appendix',
'C18.2': 'Ascending colon',
'C18.3': 'Hepatic flexure of colon',
'C18.4': 'Transverse colon',
'C18.5': 'Splenic flexure of colon',
'C18.6': 'Descending colon',
'C18.7': 'Sigmoid colon',
'C18.8': 'Overlapping lesion of colon',
'C18.9': 'Colon NOS',
'C19.9': 'Rectosigmoid junction',
'C20.9': 'Rectum, NOS',
'C21.0': 'Anus, NOS (excludes Skin of anus and Perianal skin (C44.5)',
'C21.1': 'Anal canal',
'C21.2': 'Cloacogenic zone',
'C21.8': 'Overlapping lesion of rectum, anus and anal canal',
'C22.0': 'Liver',
'C22.1': 'Intrahepatic bile duct',
'C23.9': 'Gallbladder',
'C24.0': 'Extrahepatic bile duct',
'C24.1': 'Ampulla of Vater',
'C24.8': 'Overlapping lesion of biliary tract',
'C24.9': 'Biliary tract, NOS',
'C25.0': 'Head of pancreas',
'C25.1': 'Body pancreas',
'C25.2': 'Tail pancreas',
'C25.3': 'Pancreatic duct',
'C25.4': 'Islets of Langerhans',
'C25.7': 'Neck of pancreas',
'C25.8': 'Overlapping lesion of pancreas',
'C25.9': 'Pancreas NOS',
'C26.0': 'Intestinal tract, NOS',
'C26.8': 'Overlapping lesion of digestive system',
'C26.9': 'Gastrointestinal tract, NOS',
'C30.0': 'Nasal cavity (excludes Nose, NOS C76.0)',
'C30.1': 'Middle ear',
'C31.0': 'Maxillary sinus',
'C31.1': 'Ethmoid sinus',
'C31.2': 'Frontal sinus',
'C31.3': 'Sphenoid sinus',
'C31.8': 'Overlapping lesion of accessory sinuses',
'C31.9': 'Accessory sinus, NOS',
'C32.0': 'Glottis',
'C32.1': 'Supraglottis',
'C32.2': 'Subglottis',
'C32.3': 'Laryngeal cartilage',
'C32.8': 'Overlapping lesion of larynx',
'C32.9': 'Larynx NOS',
'C33.9': 'Trachea',
'C34.0': 'Main bronchus',
'C34.1': 'Upper lobe, lung',
'C34.2': 'Middle lobe, lung',
'C34.3': 'Lower lobe, lung',
'C34.8': 'Overlapping lesion of lung',
'C34.9': 'Lung NOS',
'C37.9': 'Thymus',
'C38.0': 'Heart',
'C38.1': 'Anterior mediastinum',
'C38.2': 'Posterior mediastinum',
'C38.3': 'Mediastinum NOS',
'C38.4': 'Pleura NOS',
'C38.8': 'Overlapping lesion of heart, mediastinum and pleura',
'C39.0': 'Upper respiratory tract, NOS',
'C39.8': 'Overlapping lesion of respiratory system and intrathoracic organs',
'C39.9': 'Respiratory tract, NOS',
'C40.0': 'Upper limb long bones, joints',
'C40.1': 'Upper limb short bones, joints',
'C40.3': 'Lower limb short bones, joints',
'C40.8': 'Overlapping lesion of bones, joints and articular cartilage of limbs',
'C40.9': 'Bone limb, NOS',
'C41.0': 'Skull and facial bone',
'C41.1': 'Mandible',
'C41.2': 'Vertebral column (excludes Sacrum and Coccyx C41.4)',
'C41.3': 'Rib, sternum, clavicle',
'C41.4': 'Pelvic bone',
'C41.8': 'Overlapping lesion of bones, joints and articular cartilage',
'C41.9': 'Bone NOS',
'C42.0': 'Blood',
'C42.1': 'Bone marrow',
'C42.2': 'Spleen',
'C42.3': 'Reticuloendothelial system, NOS',
'C42.4': 'Hematopoietic system, NOS',
'C44.0': 'Skin lip, NOS',
'C44.1': 'Eyelid NOS',
'C44.2': 'External ear',
'C44.3': 'Skin face',
'C44.4': 'Skin scalp, neck',
'C44.5': 'Skin trunk',
'C44.6': 'Skin limb, upper',
'C44.7': 'Skin limb, lower',
'C47.0': 'Peripheral nerve head, neck',
'C47.1': 'Peripheral nerve shoulder, arm',
'C47.2': 'Peripheral nerve leg',
'C47.3': 'Peripheral nerve thorax (excludes Thymus, Heart and Mediastinum C37. , C38. )',
'C47.4': 'Peripheral nerve abdomen',
'C47.5': 'Peripheral nerve pelvis',
'C47.6': 'Peripheral nerve trunk',
'C47.8': 'Overlapping lesion of peripheral nerves and autonomic nervous system',
'C47.9': 'Autonomic nervous system NOS',
'C48.0': 'Retroperitoneum',
'C48.1': 'Peritoneum',
'C48.2': 'Peritoneum NOS',
'C48.8': 'Overlapping lesion of retroperitoneum and peritoneum',
'C49.0': 'Connective tissue head',
'C49.1': 'Connective tissue arm',
'C49.2': 'Connective tissue leg',
'C49.3': 'Connective tissue thorax (excludes Thymus, Heart and Mediastinum C37. , C38. )',
'C49.4': 'Connective tissue abdomen',
'C49.5': 'Connective tissue pelvis',
'C49.6': 'Connective tissue trunk, NOS',
'C49.8': 'Overlapping lesion of connective, subcutaneous and other soft tissues',
'C49.9': 'Connective tissue NOS',
'C50.0': 'Nipple',
'C50.1': 'Central portion of breast',
'C50.2': 'Upper inner quadrant of breast',
'C50.3': 'Lower inner quadrant of breast',
'C50.4': 'Upper outer quadrant of breast',
'C50.5': 'Lower outer quadrant of breast',
'C50.6': 'Axillary tail of breast',
'C50.8': 'Overlapping lesion of breast',
'C50.9': 'Breast NOS (excludes Skin of breast C44.5)',
'C51.0': 'Labium majus',
'C51.1': 'Labium minus',
'C51.2': 'Clitoris',
'C51.8': 'Overlapping lesion of vulva',
'C51.9': 'Vulva, NOS',
'C52.9': 'Vagina, NOS',
'C53.0': 'Endocervix',
'C53.1': 'Exocervix',
'C53.8': 'Overlapping lesion of cervix uteri',
'C53.9': 'Cervix uteri',
'C54.0': 'Isthmus uteri',
'C54.1': 'Endometrium',
'C54.2': 'Myometrium',
'C54.3': 'Fundus uteri',
'C54.8': 'Overlapping lesion of corpus uteri',
'C54.9': 'Corpus uteri',
'C55.9': 'Uterus NOS',
'C56.9': 'Ovary',
'C57.0': 'Fallopian tube',
'C57.1': 'Broad ligament',
'C57.2': 'Round ligament',
'C57.3': 'Parametrium',
'C57.4': 'Uterine adnexa',
'C57.7': 'Wolffian body',
'C57.8': 'Overlapping lesion of female genital organs',
'C57.9': 'Female genital tract, NOS',
'C60.0': 'Prepuce',
'C60.1': 'Glans penis',
'C60.2': 'Body penis',
'C60.8': 'Overlapping lesion of penis',
'C60.9': 'Penis NOS',
'C61.9': 'Prostate gland',
'C62.0': 'Undescended testis (site of neoplasm)',
'C62.1': 'Descended testis',
'C62.9': 'Testis NOS',
'C63.0': 'Epididymis',
'C63.1': 'Spermatic cord',
'C63.2': 'Scrotum, NOS',
'C63.7': 'Tunica vaginalis',
'C63.8': 'Overlapping lesion of male genital organs',
'C63.9': 'Male genital organs, NOS',
'C64.9': 'Kidney NOS',
'C65.9': 'Renal pelvis',
'C66.9': 'Ureter',
'C67.0': 'Trigone, bladder',
'C67.1': 'Dome, bladder',
'C67.2': 'Lateral wall bladder',
'C67.4': 'Posterior wall bladder',
'C67.6': 'Ureteric orifice',
'C67.7': 'Urachus',
'C67.8': 'Overlapping lesion of bladder',
'C67.9': 'Bladder NOS',
'C68.0': 'Urethra',
'C68.1': 'Paraurethral gland',
'C68.8': 'Overlapping lesion of urinary organs',
'C68.9': 'Urinary system, NOS',
'C69.0': 'Conjunctiva',
'C69.1': 'Cornea, NOS',
'C69.2': 'Retina',
'C69.3': 'Choroid',
'C69.4': 'Ciliary body',
'C69.5': 'Lacrimal gland',
'C69.6': 'Orbit NOS',
'C69.8': 'Overlapping lesion of eye and adnexa',
'C69.9': 'Eye NOS',
'C70.0': 'Cerebral meninges',
'C70.1': 'Spinal meninges',
'C70.9': 'Meninges NOS',
'C71.0': 'Cerebrum',
'C71.1': 'Frontal lobe',
'C71.2': 'Temporal lobe',
'C71.3': 'Parietal lobe',
'C71.4': 'Occipital lobe',
'C71.5': 'Ventricle NOS',
'C71.6': 'Cerebellum, NOS',
'C71.7': 'Brain stem',
'C71.8': 'Overlapping lesion of brain',
'C71.9': 'Brain NOS',
'C72.0': 'Spinal cord',
'C72.1': 'Cauda equina',
'C72.2': 'Olfactory nerve',
'C72.3': 'Optic nerve',
'C72.4': 'Acoustic nerve',
'C72.5': 'Cranial nerve, NOS',
'C72.8': 'Overlapping lesion of brain and central nervous system',
'C72.9': 'Nervous system NOS',
'C73.9': 'Thyroid gland',
'C74.0': 'Adrenal gland cortex',
'C74.1': 'Adrenal gland medulla',
'C74.9': 'Adrenal gland NOS',
'C75.0': 'Parathyroid gland',
'C75.1': 'Pituitary gland',
'C75.2': 'Craniopharyngeal duct',
'C75.3': 'Pineal gland',
'C75.4': 'Carotid body',
'C75.5': 'Aortic body',
'C75.8': 'Overlapping lesion of endocrine glands and related structures',
'C75.9': 'Endocrine gland, NOS',
'C76.0': 'Head, face or neck NOS',
'C76.1': 'Thorax NOS',
'C76.2': 'Abdomen NOS',
'C76.3': 'Pelvis NOS',
'C76.4': 'Upper limb NOS',
'C76.5': 'Lower limb NOS',
'C76.7': 'Other illdefined sites',
'C76.8': 'Overlapping lesion of ill-defined sites',
'C77.0': 'Lymph node face, head ,neck',
'C77.1': 'Intrathoracic lymph node',
'C77.2': 'Intra-abdominal lymph nodes',
'C77.3': 'Lymph node axilla, arm',
'C77.4': 'Lymph node inguinal region, leg',
'C77.5': 'Lymph node pelvic',
'C77.8': 'Lymph nodes of multiple regions',
'C77.9': 'Lymph node NOS',
'C80.9': 'Unknown primary site'
};
let query = '', pattern = null, selection = null, all_reports = decorate(data.reports);
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate(xs) {
return xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00') + '\x00' + x.tumors.map(y => y.meta.primarySite + '\x00' + sitecodes[y.meta.primarySite]).join('\x00')), _uid: x.last5 + x.name + x.tumors.map(y => y.meta.primarySite).join(' ') }, x));
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
let reports = await get_api_rcrs_patients({ omega: 'T-' + (data.offset + 1), alpha: 'T-' + (data.offset += (loadmore.limit = (factor*loadmore.limit)|0)) });
Array.prototype.push.apply(all_reports, decorate(reports));
all_reports = all_reports; // reactivity hint
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
(async function loadinit(target = 16, requests = 4) {
for(let i = 0; (i < requests) && (all_reports.length < target); ++i) await loadmore();
})();
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
</script>
<svelte:head>
<title>RCRS</title>
</svelte:head>
{#if selection}
<div class="halfpane rightpane">
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{selection.last5} {selection.name}</span>
<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>
</div>
</nav>
<div class="container-fluid"><dl class="report">{#each Object.entries(selection) as entry}{#if entry[0].charAt(0) != '_'}<dt>{entry[0]}</dt><dd>{typeof entry[1] == 'string' ? entry[1] : JSON.stringify(entry[1])}</dd>{/if}{/each}</dl></div>
</div>
{/if}
<div class={selection ? 'halfpane leftpane' : ''}>
<div class="card {selection ? '' : 'mb-3 shadow'}">
<nav class="navbar bg-body-tertiary">
<form class="container-fluid">
<div class="input-group">
<span class="input-group-text">RCRS</span>
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if query}<button type="button" class="btn btn-outline-secondary" on:click={() => query = ''}>❌</button>{/if}
</div>
</form>
</nav>
<ul class="list-group list-group-flush" bind:this={reportlist}>
{#if pattern}
{#each all_reports as row}
{#if filter_test(pattern, row._content)}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.last5} {row.name} {row.tumors.map(x => x.meta.primarySite).join(' ')}</div>
</li>
{/if}
{/each}
{:else}
{#each all_reports as row}
<li class="list-group-item" class:active={(selection) && (selection._uid == row._uid)} on:click={() => selection = selection !== row ? row : null}>
<div class="singleline" style="font-weight: bold;">{row.last5} {row.name} {row.tumors.map(x => x.meta.primarySite).join(' ')}</div>
</li>
{/each}
{/if}
<li class="list-group-item" style="padding: 0;" bind:this={bottom}>{#if loadmore.loading}<button type="button" class="btn btn-primary w-100" disabled>Loading...</button>{:else}<button type="button" class="btn btn-primary w-100" on:click={loadmore}>Load more</button>{/if}</li>
</ul>
</div>
</div>
<style>
:global(div.report mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
width: 33%;
left: 0;
z-index: -1;
direction: rtl;
}
.leftpane > * {
direction: ltr;
}
.rightpane {
width: 67%;
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>

View File

@ -0,0 +1,117 @@
<script>
const id_detail = Math.random().toString(36).replace('0.', 'id-');
let innerWidth = 0;
$: wide = innerWidth > 768;
</script>
<svelte:window bind:innerWidth />
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#{id_detail}" aria-controls="{id_detail}">Enable both scrolling & backdrop</button>
<div class="offcanvas" class:offcanvas-end={wide} class:offcanvas-bottom={!wide} data-bs-scroll="true" tabindex="-1" id="{id_detail}" aria-labelledby="{id_detail}-label">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="{id_detail}-label">Backdrop with scrolling</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<p>Try scrolling the rest of the page to see this option in action.</p>
</div>
</div>
<div class="card mb-3 shadow">
<ul class="list-group list-group-flush">
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item">Vestibulum at eros</li>
<li class="list-group-item">Cras justo odio</li>
<li class="list-group-item">Dapibus ac facilisis in</li>
<li class="list-group-item"><a data-bs-toggle="offcanvas" href="#{id_detail}">Vestibulum at eros</a></li>
</ul>
</div>
<style>
/*
.card {
position: absolute;
top: 0;
bottom: 0;
margin-top: 8rem;
overflow: auto;
}
*/
</style>

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

10
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({ fallback: '200.html' })
}
};
export default config;

6
frontend/vite.config.js Normal file
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});