Compare commits

..

8 commits

43 changed files with 649 additions and 110 deletions

10
.dockerignore 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-*

36
Dockerfile Normal file
View file

@ -0,0 +1,36 @@
# stage build
FROM node:20-alpine
WORKDIR /app
# copy everything to the container
COPY . .
# clean install all dependencies
RUN corepack enable && pnpm i
# remove potential security issues
RUN pnpm audit fix
# build SvelteKit appmakeAuthjsRequest
RUN pnpm run build
# stage run
FROM node:20-alpine
WORKDIR /app
# copy dependency list
COPY --from=0 /app/package*.json /app/pnpm-lock.yaml ./
COPY --from=0 /app/patches ./patches
COPY --from=0 /app/prisma ./prisma
# Setup pnpm, install Prisma CLI (for generating client) and install dependencies
RUN corepack enable && pnpm i --prod && pnpm audit fix && npx prisma generate
# copy built SvelteKit app to /app
COPY --from=0 /app/build ./
EXPOSE 3000
CMD ["node", "./index.js"]

View file

@ -415,18 +415,6 @@ export default [
},
],
// disallow dangling underscores in identifiers
// https://eslint.org/docs/rules/no-underscore-dangle
"no-underscore-dangle": [
"warn",
{
allow: [],
allowAfterThis: false,
allowAfterSuper: false,
enforceInMethodNames: true,
},
],
// disallow the use of Boolean literals in conditional expressions
// also, prefer `a || b` over `a ? a : b`
// https://eslint.org/docs/rules/no-unneeded-ternary

View file

@ -21,9 +21,10 @@
"@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.12.1",
"carta-md": "^4.0.0",
"carta-md": "4.0.2",
"diff": "^5.2.0",
"isomorphic-dompurify": "^2.7.0",
"prisma": "^5.12.1",
"qs": "^6.12.1",
"set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8",
@ -56,7 +57,6 @@
"globals": "^15.0.0",
"postcss-import": "^16.1.0",
"postcss-nesting": "^12.1.1",
"prisma": "^5.12.1",
"svelte": "^4.2.15",
"svelte-check": "^3.6.9",
"sveltekit-superforms": "^2.12.5",
@ -72,7 +72,7 @@
"type": "module",
"pnpm": {
"patchedDependencies": {
"carta-md@4.0.0": "patches/carta-md@4.0.0.patch"
"carta-md@4.0.2": "patches/carta-md@4.0.2.patch"
}
}
}

View file

@ -5,9 +5,9 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
carta-md@4.0.0:
hash: 4dz4dhp4dewr3xkzk2rwum2jdq
path: patches/carta-md@4.0.0.patch
carta-md@4.0.2:
hash: i33ea43vfgrg3ziu25cfu7s2zq
path: patches/carta-md@4.0.2.patch
dependencies:
'@auth/core':
@ -23,8 +23,8 @@ dependencies:
specifier: ^5.12.1
version: 5.12.1(prisma@5.12.1)
carta-md:
specifier: ^4.0.0
version: 4.0.0(patch_hash=4dz4dhp4dewr3xkzk2rwum2jdq)(svelte@4.2.15)
specifier: 4.0.2
version: 4.0.2(patch_hash=i33ea43vfgrg3ziu25cfu7s2zq)(svelte@4.2.15)
diff:
specifier: ^5.2.0
version: 5.2.0
@ -1075,8 +1075,8 @@ packages:
dev: true
optional: true
/@shikijs/core@1.3.0:
resolution: {integrity: sha512-7fedsBfuILDTBmrYZNFI8B6ATTxhQAasUHllHmjvSZPnoq4bULWoTpHwmuQvZ8Aq03/tAa2IGo6RXqWtHdWaCA==}
/@shikijs/core@1.4.0:
resolution: {integrity: sha512-CxpKLntAi64h3j+TwWqVIQObPTED0FyXLHTTh3MKXtqiQNn2JGcMQQ362LftDbc9kYbDtrksNMNoVmVXzKFYUQ==}
dev: false
/@sideway/address@4.1.5:
@ -1935,8 +1935,8 @@ packages:
resolution: {integrity: sha512-19NuN1/3PjA3QI8Eki55N8my4LzfkMCRLgCVfrl/slbSAchQfV0+GwjPrK3rq37As4UCLlM/DHajbKkAqbv92Q==}
dev: true
/carta-md@4.0.0(patch_hash=4dz4dhp4dewr3xkzk2rwum2jdq)(svelte@4.2.15):
resolution: {integrity: sha512-mxIoN3dqcjgv8i5FIUBH69lclx8A1/FB/FaFymWBzKE4AvUdy/X6VQGBNzAO3ybSAdceMT0RrAhY5/KnoFI8Hg==}
/carta-md@4.0.2(patch_hash=i33ea43vfgrg3ziu25cfu7s2zq)(svelte@4.2.15):
resolution: {integrity: sha512-wMlw0r5RZiVwvF3dyxE/vHj9pXlXbzpijJ3m/o9zqZe7Cf6D96AjyBHBpa0A0OPj/uEJVF3k0R6ctopBJCpCQg==}
peerDependencies:
svelte: ^3.54.0 || ^4.0.0
dependencies:
@ -1944,7 +1944,7 @@ packages:
remark-gfm: 4.0.0
remark-parse: 11.0.0
remark-rehype: 11.1.0
shiki: 1.3.0
shiki: 1.4.0
svelte: 4.2.15
unified: 11.0.4
transitivePeerDependencies:
@ -4860,10 +4860,10 @@ packages:
engines: {node: '>=8'}
dev: true
/shiki@1.3.0:
resolution: {integrity: sha512-9aNdQy/etMXctnPzsje1h1XIGm9YfRcSksKOGqZWXA/qP9G18/8fpz5Bjpma8bOgz3tqIpjERAd6/lLjFyzoww==}
/shiki@1.4.0:
resolution: {integrity: sha512-5WIn0OL8PWm7JhnTwRWXniy6eEDY234mRrERVlFa646V2ErQqwIFd2UML7e0Pq9eqSKLoMa3Ke+xbsF+DAuy+Q==}
dependencies:
'@shikijs/core': 1.3.0
'@shikijs/core': 1.4.0
dev: false
/side-channel@1.0.6:

View file

@ -4,11 +4,9 @@
@tailwind utilities;
@layer components {
.btn-transition {
.btn-animation {
@apply duration-200 ease-out;
animation: button-pop var(--animation-btn, 0.25s) ease-out;
transition-property: color, background-color, border-color, opacity, box-shadow,
transform;
&:active:hover,
&:active:focus {
animation: button-pop 0s ease-out;
@ -47,7 +45,7 @@ button {
@apply border border-solid border-base-content/30;
.row {
@apply flex flex-row;
@apply flex;
}
.row,
@ -76,3 +74,12 @@ button {
@apply bg-primary text-primary-content py-1;
}
}
.card-animation {
@apply duration-200 ease-out;
&:active:hover,
&:active:focus {
animation: button-pop 0s ease-out;
transform: scale(0.99);
}
}

View file

@ -31,3 +31,7 @@ export const handle = sequence(
authorization,
createTRPCHandle({ router, createContext }),
);
// Allow server application to exit
process.on("SIGINT", process.exit); // Ctrl+C
process.on("SIGTERM", process.exit); // docker stop

View file

@ -36,7 +36,7 @@
</a>
</Header>
<p class="text-sm flex flex-row gap-2">
<p class="text-sm flex gap-2">
<span>Erstellt {humanDate(entry.created_at, true)}</span>
<span>·</span>
{#if entry.execution}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { RouterOutput } from "$lib/shared/trpc";
import { formatPatientName } from "$lib/shared/util";
import RoomField from "$lib/components/table/RoomField.svelte";
@ -13,9 +14,7 @@
<RoomField room={patient.room} />
{/if}
<a href="/patient/{patient.id}">
{patient.first_name}
{patient.last_name}
({patient.age})
{formatPatientName(patient)}
</a>
</div>
</div>

View file

@ -1,4 +1,17 @@
<script lang="ts">
import { browser } from "$app/environment";
import { mdiClose } from "@mdi/js";
import { onMount } from "svelte";
import { createFloatingActions } from "svelte-floating-ui";
import { shift } from "svelte-floating-ui/dom";
import Icon from "$lib/components/ui/Icon.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
import outclick from "$lib/actions/outclick";
import type { BaseItem } from "./types";
/**
* This is a simplified version of simple-svelte-autocomplete
* Original source: https://github.com/pstanoev/simple-svelte-autocomplete
@ -6,17 +19,6 @@
* MIT License
*/
import { browser } from "$app/environment";
import { onMount } from "svelte";
import { createFloatingActions } from "svelte-floating-ui";
import { shift } from "svelte-floating-ui/dom";
import Icon from "$lib/components/ui/Icon.svelte";
import outclick from "$lib/actions/outclick";
import type { BaseItem } from "./types";
type T = $$Generic<BaseItem>;
type OnSelectResult = { newValue: string; close: boolean };
@ -48,6 +50,7 @@
/** Name of the form input. This sets a hidden field to the ID of the selected element
* (to allow Svelte Superforms to work) */
export let idInputName: string | undefined = undefined;
export let clearBtn = false;
/** Function to filter shown items */
export let filterFn: (item: T) => boolean = () => true;
@ -278,7 +281,7 @@
}
</script>
<div class="flex-grow {cls}" on:outclick={close} use:outclick>
<div class="flex-grow {cls}" class:relative={clearBtn} on:outclick={close} use:outclick>
<input
bind:this={inputElm}
class={inputCls}
@ -329,6 +332,12 @@
<!-- Hidden input field (acting as form input) -->
<input name={idInputName} type="hidden" value={selection?.id ?? ""} />
{/if}
{#if clearBtn && selection}
<div class="absolute bottom-0 right-0 h-full flex items-center">
<IconButton cls="" path={mdiClose} on:click={clearSelection} />
</div>
{/if}
</div>
<style lang="postcss">

View file

@ -4,7 +4,7 @@
import { Debouncer } from "$lib/shared/util";
import Icon from "$lib/components/ui/Icon.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
import Autocomplete from "./Autocomplete.svelte";
import EntryFilterChip from "./FilterChip.svelte";
@ -16,7 +16,7 @@
BaseItem,
SelectionOrText,
} from "./types";
import { isFilterValueless } from "./types";
import { InputType, isFilterValueless } from "./types";
/** Filter definitions */
export let FILTERS: { [key: string]: FilterDef };
@ -28,7 +28,7 @@
export let hiddenFilters: string[] = [];
/** True if a separate search field should be displayed */
export let search = false;
export let view: string;
export let view: string | undefined = undefined;
let autocomplete: Autocomplete<BaseItem> | undefined;
let activeFilters: FilterData[] = [];
@ -116,26 +116,24 @@
const key = filter.id;
let val = null;
if (filter.inputType === 0) {
if (filter.inputType === InputType.None) {
// Valueless filter (val = true)
val = true;
} else if (filter.inputType === 1) {
} else if (filter.inputType === InputType.FreeText) {
// Text input
val = fdata.selection?.name;
} else if (filter.inputType === 2 && fdata.selection) {
} else if (filter.inputType === InputType.FilterList && fdata.selection) {
// Filter list
// @ts-expect-error TODO
val = { id: fdata.selection.id, name: fdata.selection.name };
} else if (filter.inputType === 3) {
val = [{ id: fdata.selection.id!, name: fdata.selection.name }];
} else if (filter.inputType === InputType.Boolean) {
val = Boolean(fdata.selection?.toggle);
}
if (val !== null && val !== undefined) {
if (filter.inputType === 2) {
// @ts-expect-error fd[key] is checked
if (Array.isArray(fd[key])) fd[key].push(val);
// @ts-expect-error fd[key] is checked
else fd[key] = [val];
if (Array.isArray(fd[key])) fd[key].push(...val);
else fd[key] = val;
} else {
fd[key] = val;
}
@ -223,17 +221,15 @@
partOfFilterbar
placeholder="Filter"
/>
<button
class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0"
aria-label="Alle Filter entfernen"
<IconButton
ariaLabel="Alle Filter entfernen"
cls="absolute bottom-0 right-0"
path={mdiClose}
on:click={() => {
activeFilters = [];
searchVal = "";
updateFilter();
}}
>
<Icon path={mdiClose} size={1.2} />
</button>
}} />
</div>
{#if search}
<input
@ -248,7 +244,9 @@
<slot />
</div>
{#if view}
<SavedFilters {view} />
{/if}
<style lang="postcss">
.filterbar-outer {

View file

@ -4,8 +4,9 @@
import Icon from "$lib/components/ui/Icon.svelte";
import Autocomplete from "./Autocomplete.svelte";
import type {
BaseItem, FilterData, FilterDef, SelectionOrText,
import {
InputType,
type BaseItem, type FilterData, type FilterDef, type SelectionOrText,
} from "./types";
export let filter: FilterDef;
@ -36,7 +37,7 @@
autocomplete.open();
}
const TOFF = " aus";
const TOFF = " aus"; // Toggle name suffix (in case toggleOff.name is unset)
$: toggleState = fdata.selection?.toggle !== false;
$: filterName = toggleState
@ -72,7 +73,7 @@
{#if hasInputField}
{#if fdata.editing}
{#if filter.inputType === 2}
{#if filter.inputType === InputType.FilterList}
{@const hids = hiddenIds()}
<Autocomplete
bind:this={autocomplete}
@ -83,8 +84,8 @@
onBackspace={onRemove}
{onClose}
onSelect={(item) => {
// Accept the selection if this is a free text field or the user selected a variant
if (filter.inputType !== 2 || item.id) {
// Accept the selection if the user selected a variant
if (item.id) {
fdata.selection = item;
return { close: true, newValue: "" };
}

View file

@ -18,7 +18,7 @@
}
</script>
<a class="chip btn-transition" {href}>
<a class="chip btn-animation" {href}>
<span class="mx-2">
<slot />
</span>

View file

@ -56,7 +56,7 @@
}
</script>
<div class="flex flex-row flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm h-8 flex items-center">
Gespeicherte Filter:
</div>

View file

@ -4,6 +4,7 @@ import {
mdiAccountMultipleOutline,
mdiAccountRemoveOutline,
mdiBedKingOutline,
mdiCalendar,
mdiCheckboxBlankOutline,
mdiCheckboxOutline,
mdiDoctor,
@ -13,58 +14,75 @@ import {
mdiTag,
} from "@mdi/js";
import { WEEK_LIMIT } from "$lib/shared/constants";
import { trpc } from "$lib/shared/trpc";
import { DateRange, dateToYMD } from "$lib/shared/util";
import type { FilterDef } from "./types";
import { type FilterDef, InputType, type BaseItem } from "./types";
export function weekFilterItems(earlierLater: boolean): BaseItem[] {
const range = DateRange.thisWeek();
const res = [];
if (earlierLater) res.push({ id: ".." + dateToYMD(range.start!), name: "Früher" });
for (let i = 0; i < WEEK_LIMIT; i++) {
res.push({ id: range.toString(), name: range.format() });
range.addDays(7);
}
if (earlierLater) res.push({ id: dateToYMD(range.start!) + "..", name: "Später" });
return res;
}
export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
category: {
id: "category",
name: "Kategorie",
icon: mdiTag,
inputType: 2,
inputType: InputType.FilterList,
options: async () => trpc().category.list.query(),
},
author: {
id: "author",
name: "Autor",
icon: mdiAccount,
inputType: 2,
inputType: InputType.FilterList,
options: async () => trpc().user.getNames.query(),
},
executor: {
id: "executor",
name: "Erledigt von",
icon: mdiDoctor,
inputType: 2,
inputType: InputType.FilterList,
options: async () => trpc().user.getNames.query(),
},
patient: {
id: "patient",
name: "Patient",
icon: mdiAccountInjury,
inputType: 2,
inputType: InputType.FilterList,
options: async () => trpc().patient.getNames.query(),
},
station: {
id: "station",
name: "Station",
icon: mdiDomain,
inputType: 2,
inputType: InputType.FilterList,
options: async () => trpc().station.list.query(),
},
room: {
id: "room",
name: "Zimmer",
icon: mdiBedKingOutline,
inputType: 2,
inputType: InputType.FilterList,
options: async () => trpc().room.list.query(),
},
done: {
id: "done",
name: "Erledigt",
icon: mdiCheckboxOutline,
inputType: 3,
inputType: InputType.Boolean,
toggleOff: {
name: "Zu erledigen",
icon: mdiCheckboxBlankOutline,
@ -74,13 +92,20 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
id: "priority",
name: "Priorität",
icon: mdiExclamation,
inputType: 0,
inputType: InputType.None,
},
search: {
id: "search",
name: "Beschreibung",
icon: mdiMagnify,
inputType: 1,
inputType: InputType.FreeText,
},
date: {
id: "date",
name: "Woche",
icon: mdiCalendar,
inputType: InputType.FilterList,
options: async () => weekFilterItems(true),
},
};

View file

@ -1,3 +1,10 @@
export enum InputType {
None = 0,
FreeText = 1,
FilterList = 2,
Boolean = 3,
}
export type BaseItem = {
id: string | number;
name: string;
@ -15,8 +22,7 @@ export type FilterDef = {
id: string;
name: string;
icon?: string;
// 0: No input (value: true); 1: Free text input; 2: Filter list; 3: Boolean switch
inputType: 0 | 1 | 2 | 3;
inputType: InputType;
toggleOff?: {
name: string;
icon?: string;
@ -34,6 +40,6 @@ export type FilterQdata = {
[key: string]: string | number | boolean | { id: string | number; name?: string }[];
};
export function isFilterValueless(inputType: 0 | 1 | 2 | 3): boolean {
return inputType === 0 || inputType === 3;
export function isFilterValueless(inputType: InputType): boolean {
return inputType === InputType.None || inputType === InputType.Boolean;
}

View file

@ -7,6 +7,7 @@
export let category: Category;
export let baseUrl = URL_ENTRIES;
export let href: string | undefined = undefined;
export let nolink = false;
$: textColor = category.color
? colorToHex(getTextColor(hexToColor(category.color)))
@ -25,7 +26,7 @@
}
</script>
{#if href}
{#if href || nolink}
<a
style:color={category.color ? textColor : undefined}
style:background-color={category.color}

View file

@ -20,6 +20,7 @@
export let entries: RouterOutput["entry"]["list"];
export let baseUrl: string;
export let patientId: number | null = null;
export let view: string | undefined = undefined;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
@ -57,7 +58,7 @@
filterData={query.filter}
hiddenFilters={patientId !== null ? ["patient"] : []}
onUpdate={filterUpdate}
view="plan"
{view}
>
<slot name="filterbar" />
</FilterBar>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate, formatPatientName } from "$lib/shared/util";
import CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "./markdown/Markdown.svelte";
export let entry: RouterOutput["entry"]["list"]["items"][0];
</script>
<a class="card2 card-animation" href="/entry/{entry.id}">
<div class="row items-center gap-2 text-sm">
<span>{formatDate(entry.current_version.date)}</span>
{#if entry.current_version.category}
<CategoryField category={entry.current_version.category} nolink />
{/if}
{#if entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div>
{/if}
<span>{formatPatientName(entry.patient)}</span>
<span class="ml-auto hidden md:block">von {entry.current_version.author.name}</span>
</div>
<div class="row">
<div class="line-clamp-3">
<Markdown src={entry.current_version.text} />
</div>
</div>
</a>

View file

@ -7,7 +7,7 @@
export let backHref: string | undefined = undefined;
</script>
<div class="flex flex-row">
<div class="flex">
<div class="flex flex-wrap items-center gap-2">
{#if backHref}
<a class="btn btn-sm btn-circle btn-ghost" href={backHref}>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import Icon from "./Icon.svelte";
type BtnSize = "sm" | "md";
export let path: string;
export let size: BtnSize = "sm";
export let cls = "";
export let ariaLabel: undefined | string = undefined;
export let ariaPressed: undefined | boolean = undefined;
export let tabindex: undefined | number = undefined;
</script>
<button
class={`btn btn-ghost btn-circle ${
size === "md" ? "" : "btn-" + size
} ${cls}`}
aria-label={ariaLabel}
aria-pressed={ariaPressed}
{tabindex}
on:click
>
<Icon {path} size={size === "md" ? 2 : 1.5} />
</button>

View file

@ -6,6 +6,8 @@
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { Pagination, PaginationRequest } from "$lib/shared/model";
import { screenWidthSmall } from "$lib/stores/layout";
import Icon from "./Icon.svelte";
export let paginationData: PaginationRequest | null | undefined;
@ -17,18 +19,21 @@
$: nPages = Math.ceil(data.total / limit);
let windowBottom: number;
let windowTop: number;
$: if (nPages <= 10) {
$: buttonLim = $screenWidthSmall ? 4 : 10;
$: if (nPages <= buttonLim) {
windowBottom = 1;
windowTop = nPages;
} else if (thisPage <= 6) {
} else if (thisPage <= buttonLim * 0.6) {
windowBottom = 1;
windowTop = 10;
} else if (thisPage + 4 >= nPages) {
windowBottom = nPages - 9;
windowTop = buttonLim;
} else if (thisPage + (buttonLim * 0.4) >= nPages) {
windowBottom = nPages - buttonLim + 1;
windowTop = nPages;
} else {
windowBottom = thisPage - 5;
windowTop = thisPage + 4;
windowBottom = thisPage - Math.floor(buttonLim / 2);
windowTop = thisPage + Math.ceil(buttonLim / 2) - 1;
}
function getPaginationRequest(page: number): PaginationRequest | null {

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { mdiChevronLeft, mdiChevronRight } from "@mdi/js";
import { DateRange } from "$lib/shared/util";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import { weekFilterItems } from "$lib/components/filter/filters";
import type { BaseItem } from "$lib/components/filter/types";
import IconButton from "./IconButton.svelte";
let editing = false;
let autocomplete: Autocomplete<BaseItem> | undefined;
export let dateRange: DateRange = DateRange.thisWeek();
export let onSelect: (value: DateRange) => void = () => {};
function nextWeek() {
dateRange.addDays(7);
dateRange = dateRange; // update reactive
onSelect(dateRange);
}
function previousWeek() {
dateRange.addDays(-7);
dateRange = dateRange; // update reactive
onSelect(dateRange);
}
function startEditing() {
editing = true;
}
function stopEditing() {
editing = false;
}
$: if (editing && autocomplete) {
autocomplete.open();
}
</script>
<div class="flex items-center">
<IconButton path={mdiChevronLeft} on:click={previousWeek} />
{#if editing}
<Autocomplete
bind:this={autocomplete}
items={async () => weekFilterItems(false)}
onClose={stopEditing}
onSelect={(item) => {
if (typeof item.id === "string") {
const parsed = DateRange.parse(item.id);
if (parsed) dateRange = parsed;
onSelect(dateRange);
}
return { close: true, newValue: "" };
}}
partOfFilterbar
selection={{ id: dateRange.toString(), name: dateRange.format() }} />
{:else}
<button on:click={startEditing}>{dateRange.format()}</button>
{/if}
<IconButton path={mdiChevronRight} on:click={nextWeek} />
</div>

View file

@ -68,7 +68,7 @@
.carta-theme__default .carta-icon-full {
border: 0;
background: transparent;
@apply btn-transition;
@apply btn-animation;
}
.carta-theme__default .carta-icon-full {

View file

@ -10,7 +10,7 @@ import type {
PaginationRequest,
SortRequest,
} from "$lib/shared/model";
import { dateToYMD } from "$lib/shared/util";
import { DateRange, dateToYMD } from "$lib/shared/util";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma";
@ -274,6 +274,18 @@ left join stations s on s.id = r.station_id`,
);
}
if (filter?.date) {
filterListToArray(filter.date).forEach((itm) => {
const dateRange = DateRange.parse(itm);
if (dateRange?.start) {
qb.addFilterClause(`ev.date >= ${qb.pvar()}`, dateRange.start);
}
if (dateRange?.end) {
qb.addFilterClause(`ev.date <= ${qb.pvar()}`, dateRange.end);
}
});
}
const SORT_FIELDS: { [key: string]: string[] } = {
id: ["e.id"],
patient: ["p.last_name", "p.first_name"],
@ -378,3 +390,23 @@ left join stations s on s.id = r.station_id`,
return { items, offset: qb.getOffset(), total };
}
/** Get the amount of entries with todo dates before/equal the given date */
export async function getNTodo(date: Date): Promise<number> {
const result = await prisma.$queryRaw`select count(*) from entries e
join entry_versions ev on ev.entry_id = e.id
and ev.id = (
select
id
from
entry_versions ev2
where
ev2.entry_id = ev.entry_id
order by
ev2.created_at desc
limit 1)
where ev.date <= ${date}`;
// @ts-expect-error type checked
const count = Number(result[0].count);
return count;
}

View file

@ -152,6 +152,7 @@ export class QueryBuilder {
return `$${this.nP}`;
}
/** Add a simple filter checking for equality */
addFilter(fname: string, val: unknown | undefined) {
if (val === undefined) return;
@ -159,11 +160,13 @@ export class QueryBuilder {
this.filterClauses.push(`${fname} = ${this.pvar()}`);
}
/** Add a SQL filter clause */
addFilterClause(clause: string, ...params: unknown[]) {
this.filterClauses.push(clause);
this.params.push(...params);
}
/** Add a list filter (value matches any item from the filter list) */
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
if (fl === undefined) return;

View file

@ -6,6 +6,7 @@ import {
ZEntryNew,
ZEntryVersionNew,
ZEntityId,
fields,
} from "$lib/shared/model/validation";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
@ -16,6 +17,7 @@ import {
getEntryNExecutions,
getEntryNVersions,
getEntryVersions,
getNTodo,
newEntry,
newEntryExecution,
newEntryVersion,
@ -53,6 +55,9 @@ export const entryRouter = t.router({
const executions = await getEntryExecutions(opts.input);
return executionsDiff(executions);
})),
nTodo: t.procedure.input(fields.DateString().optional()).query(async (opts) => trpcWrap(
async () => getNTodo(opts.input ? new Date(opts.input) : new Date()),
)),
create: t.procedure
.input(ZEntryNew)
.mutation(async (opts) => trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))),

View file

@ -1,4 +1,6 @@
export const PAGINATION_LIMIT = 20;
export const WEEK_LIMIT = 8;
export const URL_ENTRIES = "/plan";
export const URL_VISIT = "/visit";
export const URL_PATIENTS = "/patients";

View file

@ -26,6 +26,7 @@ export type EntriesFilter = Partial<{
station: FilterList<number>;
room: FilterList<number>;
priority: boolean;
date: FilterList<string>;
}>;
export type PatientsFilter = Partial<{

View file

@ -1,5 +1,6 @@
import { z } from "zod";
import { DateRange } from "$lib/shared/util";
import { implement } from "$lib/shared/util/zod";
import type {
@ -36,6 +37,7 @@ export const fields = {
},
{ message: "Datum muss in der Zukunft liegen" },
),
DateRange: () => z.string().refine((val) => Boolean(DateRange.parse(val))),
};
const coercedUint = z.coerce.number().int().nonnegative();
@ -138,6 +140,8 @@ const paginatedQuery = <T extends z.ZodTypeAny>(f: T) => z
})
.partial();
const DateFilterList = z.array(z.object({ id: fields.DateRange(), name: z.string().optional() }));
export const ZEntriesFilter = returnDataInSameOrderAsPassed(z
.object({
author: ZFilterList,
@ -149,6 +153,7 @@ export const ZEntriesFilter = returnDataInSameOrderAsPassed(z
room: ZFilterList,
search: z.string(),
station: ZFilterList,
date: DateFilterList,
})
.partial());

View file

@ -3,7 +3,9 @@ import { expect, it, vi } from "vitest";
import type { EntityQuery } from "$lib/shared/model";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import { getQueryUrl, humanDate, parseQueryUrl } from ".";
import {
getQueryUrl, humanDate, DateRange, parseQueryUrl,
} from ".";
const MINUTE = 60000;
const HOUR = 3_600_000;
@ -53,3 +55,20 @@ it("getQueryUrl", () => {
const decoded = ZEntriesQuery.parse(parseQueryUrl(queryUrl));
expect(decoded).toStrictEqual(query);
});
it.each([
{ s: "", exp: null },
{ s: "..", exp: null },
{ s: "foo..bar", exp: null },
{ s: "2024-04-15", exp: new DateRange(new Date("2024-04-15"), new Date("2024-04-15")) },
{ s: "2024-04-13..2024-04-20", exp: new DateRange(new Date("2024-04-13"), new Date("2024-04-20")) },
{ s: "2024-04-13..", exp: new DateRange(new Date("2024-04-13"), null) },
{ s: "..2024-04-20", exp: new DateRange(null, new Date("2024-04-20")) },
])("parse date range $s", ({ s, exp }) => {
const res = DateRange.parse(s);
expect(res).toStrictEqual(exp);
if (res && s !== "2024-04-15") {
expect(res.toString()).toBe(s);
}
});

View file

@ -2,11 +2,12 @@ import { goto } from "$app/navigation";
import { isRedirect, error } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client";
import DOMPurify from "isomorphic-dompurify";
import qs from "qs";
import { ZodError } from "zod";
import DOMPurify from "isomorphic-dompurify";
import type { EntityQuery } from "$lib/shared/model";
import type { EntityQuery, Patient } from "$lib/shared/model";
import type { RouterOutput } from "../trpc";
const LOCALE = "de-DE";
@ -161,3 +162,90 @@ export class Debouncer {
export function sanitizeHtml(s: string): string {
return DOMPurify.sanitize(s, { FORBID_TAGS: ["img"] });
}
export class DateRange {
start: Date | null;
end: Date | null;
constructor(start: Date | null, end: Date | null) {
if (start === null && end == null) throw Error("this is not a range");
this.start = start;
this.end = end;
}
/** Construct a new DateRange with the given length in days */
static withLength(start: Date, nDays: number): DateRange {
const end = new Date(start);
end.setDate(start.getDate() + nDays);
return new DateRange(start, end);
}
/** Create a date range of the current calendar week */
static thisWeek(): DateRange {
const dayStart = new Date();
// Correct for timezone
dayStart.setMinutes(dayStart.getMinutes() - dayStart.getTimezoneOffset());
const todayWd = dayStart.getDay();
// Day starts at Sunday (0)
const daysMinus = todayWd === 0 ? 6 : todayWd - 1;
dayStart.setDate(dayStart.getDate() - daysMinus);
return DateRange.withLength(dayStart, 6);
}
/** Parse a date range from a string
*
* Accepted formats:
* - Single date `2024-04-15` => `2024-04-15..2024-04-15`
* - Range with 2 ends: `2024-04-13..2024-04-20`
* - Range with 1 end: `2024-04-13..`; `..2024-04-20`
*/
static parse(s: string): DateRange | null {
const parts = s.split("..", 2);
const parsed = parts.map((p) => {
if (p.length === 0) return null;
return new Date(p);
});
if (parsed.length === 0
// @ts-expect-error Parsed dates can be NaN
|| parsed.findIndex(isNaN) !== -1
|| parsed.every((e) => e === null)
) return null;
if (parsed.length === 2) {
return new DateRange(parsed[0], parsed[1]);
} else {
return new DateRange(parsed[0], parsed[0]);
}
}
/** Shift the range by the given number of days. This modifies the range in-place */
addDays(n: number) {
this.start?.setDate(this.start.getDate() + n);
this.end?.setDate(this.end.getDate() + n);
}
/** Return a parsable string representation */
toString(): string {
let res = "";
if (this.start) res += dateToYMD(this.start);
res += "..";
if (this.end) res += dateToYMD(this.end);
return res;
}
/** Return a string representation for display purposes */
format(): string {
let res = "";
if (this.start) res += formatDate(this.start);
res += " \u2013 ";
if (this.end) res += formatDate(this.end);
return res;
}
}
export function formatPatientName(patient: RouterOutput["patient"]["list"]["items"][0]): string {
return `${patient.first_name} ${patient.last_name} (${patient.age})`;
}

5
src/lib/stores/layout.ts Normal file
View file

@ -0,0 +1,5 @@
import { derived, writable } from "svelte/store";
// Width of the main section of the layout
export const screenWidth = writable(0);
export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500);

View file

@ -1,5 +1,8 @@
<script lang="ts">
import { page } from "$app/stores";
import type { PageData } from "./$types";
export let data: PageData;
</script>
<svelte:head>
@ -28,12 +31,12 @@
<div class="card-body">
<h2 class="card-title">Visite</h2>
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
<p>Heute müssen (n) Einträge erledigt werden.</p>
<p>Heute müssen {data.nTodo} Einträge erledigt werden.</p>
<div class="card-actions justify-end">
<a class="btn btn-primary" href="/visit">Visite</a>
</div>
</div>
</div>
<a href="/plan/%7B%22filter%22:%7B%22done%22:%22foo%22%7D%7D">Throw error</a>
<!-- <a href="/plan/%7B%22filter%22:%7B%22done%22:%22foo%22%7D%7D">Throw error</a> -->
</div>

12
src/routes/(app)/+page.ts Normal file
View file

@ -0,0 +1,12 @@
import type { PageLoad } from "./$types";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
export const load: PageLoad = async (event) => {
return loadWrap(async () => {
const nTodo = await trpc(event).entry.nTodo.query();
return { nTodo };
});
};

View file

@ -33,7 +33,7 @@
<Header backHref={basePath} title="Eintrag #{data.entry.id} bearbeiten" />
<p class="text-sm flex flex-row gap-2">
<p class="text-sm flex gap-2">
<span>Erstellt {humanDate(data.entry.created_at, true)}</span>
<span>·</span>
<span>

View file

@ -42,6 +42,7 @@
<FormField label="Patient">
<Autocomplete
clearBtn
filterFn={(itm) => $form.room_id === null || itm.room_id === $form.room_id}
idInputName="patient_id"
inputCls="input input-bordered w-full max-w-xs"

View file

@ -12,6 +12,6 @@
<title>Planung</title>
</svelte:head>
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query}>
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} view="plan">
<a slot="filterbar" class="btn btn-sm btn-primary" href="/entry/new">Neuer Eintrag</a>
</FilteredEntryTable>

View file

@ -1,4 +1,70 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import type { PageData } from "./$types";
import { URL_VISIT } from "$lib/shared/constants";
import type { PaginationRequest } from "$lib/shared/model";
import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc";
import { DateRange, getQueryUrl } from "$lib/shared/util";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import EntryCard from "$lib/components/ui/EntryCard.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
import WeekSelector from "$lib/components/ui/WeekSelector.svelte";
export let data: PageData;
let dateRange: DateRange;
let selection: RouterOutput["station"]["get"] | null;
$: if (data.query.filter?.date) {
const date = data.query.filter?.date[0];
if (date) {
dateRange = DateRange.parse(date.id) ?? DateRange.thisWeek();
} else {
dateRange = DateRange.thisWeek();
}
} else {
dateRange = DateRange.thisWeek();
}
$: if (data.query.filter?.station) {
const station = data.query.filter?.station[0];
if (station) {
selection = { id: station.id, name: station.name ?? "" };
} else {
selection = null;
}
} else {
selection = null;
}
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: data.query.filter,
pagination,
});
}
function filterUpdate() {
updateQuery({
filter: {
done: false,
station: selection ? [selection] : undefined,
date: dateRange ? [{ id: dateRange.toString(), name: dateRange.format() }] : undefined,
},
});
}
function updateQuery(q: typeof data.query) {
if (browser) {
// Update page URL
const url = getQueryUrl(q, URL_VISIT);
goto(url, { replaceState: true, keepFocus: true });
}
}
</script>
<svelte:head>
@ -6,3 +72,33 @@
</svelte:head>
<h1 class="heading">Visite</h1>
<div class="flex flex-wrap gap-2 justify-between">
<div class="flex gap-2 items-center">
<span>Woche:</span>
<WeekSelector onSelect={filterUpdate} bind:dateRange />
</div>
<div class="flex gap-2 items-center">
<span>Station:</span>
<Autocomplete
clearBtn
inputCls="input input-sm input-bordered"
items={async () => trpc().station.list.query()}
onSelect={filterUpdate}
onUnselect={filterUpdate}
bind:selection />
</div>
</div>
<div class="flex flex-col gap-4">
{#each data.entries.items as entry}
<EntryCard {entry} />
{/each}
</div>
<PaginationButtons
data={data.entries}
onUpdate={paginationUpdate}
paginationData={data.query.pagination}
/>

View file

@ -0,0 +1,40 @@
import type { PageLoad } from "./$types";
import { z } from "zod";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { DateRange, loadWrap, parseQueryUrl } from "$lib/shared/util";
export const load: PageLoad = async (event) => {
return loadWrap(async () => {
let query: z.infer<typeof ZEntriesQuery> = {};
if (event.url.search) {
const decoded = parseQueryUrl(event.url.search);
query = ZEntriesQuery.parse(decoded);
}
// Default filter (current week)
const drange = DateRange.thisWeek();
if (!query.filter) {
query.filter = {
date: [{ id: drange.toString() }],
};
}
if (!query.sort) {
query.sort = { field: "date" };
}
const entries = await trpc(event).entry.list.query(query);
// Move prioritized items to the front
entries.items.forEach((itm, i) => {
if (itm.current_version.priority) {
entries.items.unshift(entries.items.splice(i, 1)[0]);
}
});
return { query, entries };
});
};

View file

@ -4,6 +4,7 @@
import { navigating } from "$app/stores";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
import { screenWidth } from "$lib/stores/layout";
let loadingBar: LoadingBar | undefined;
@ -16,6 +17,6 @@
<LoadingBar bind:this={loadingBar} cls="fixed top-0 left-0 z-50" />
<div class="bg-base-100 text-base-content">
<div class="bg-base-100 text-base-content" bind:clientWidth={$screenWidth}>
<slot />
</div>

View file

@ -8,6 +8,7 @@ import {
getEntryNExecutions,
getEntryNVersions,
getEntryVersions,
getNTodo,
newEntry,
newEntryExecution,
newEntryVersion,
@ -269,9 +270,25 @@ test("get entries", async () => {
expect(entriesPrio.total).toBe(1);
expect(entriesPrio.items[0].id).toBe(eId1);
// Filter by date range
const entriesDate = await getEntries({ date: "2024-01-05" });
expect(entriesDate.items).length(1);
expect(entriesDate.total).toBe(1);
expect(entriesDate.items[0].id).toBe(eId2);
const entriesDateRange = await getEntries({ date: "2024-01-05..2024-01-06" });
expect(entriesDateRange.items).length(2);
expect(entriesDateRange.total).toBe(2);
expect(entriesDateRange.items[0].id).toBe(eId3);
expect(entriesDateRange.items[1].id).toBe(eId2);
// Search
const entriesSearch = await getEntries({ search: "Blu" }, {});
expect(entriesSearch.items).length(1);
expect(entriesSearch.total).toBe(1);
expect(entriesSearch.items[0].id).toBe(eId1);
// NTodo
const n = await getNTodo(new Date("2024-01-05"));
expect(n).toBe(2);
});