Compare commits

..

No commits in common. "e3f7341a0e575a9b0cb922c9b697c21f6f6875c6" and "cfe5fc2d9f012aafc8aa470a5b2f8d1366d29dcc" have entirely different histories.

16 changed files with 992 additions and 852 deletions

View file

@ -1,5 +1,6 @@
.DS_Store .DS_Store
node_modules node_modules
/build
/.svelte-kit /.svelte-kit
/package /package
.env .env

View file

@ -1,17 +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 FROM node:20-alpine
WORKDIR /app WORKDIR /app
# copy dependency list # copy dependency list
COPY package.json pnpm-lock.yaml run/entrypoint.sh ./ COPY --from=0 /app/package*.json /app/pnpm-lock.yaml ./
COPY patches ./patches COPY --from=0 /app/patches ./patches
COPY prisma ./prisma COPY --from=0 /app/prisma ./prisma
# Setup pnpm, install Prisma CLI (for generating client) and install dependencies # Setup pnpm, install Prisma CLI (for generating client) and install dependencies
RUN corepack enable && pnpm i --prod && pnpm audit fix && npx prisma generate RUN corepack enable && pnpm i --prod && pnpm audit fix && npx prisma generate
# copy built SvelteKit app to /app # copy built SvelteKit app to /app
COPY build ./ COPY --from=0 /app/build ./
EXPOSE 3000 EXPOSE 3000
CMD ["./entrypoint.sh"] CMD ["node", "./index.js"]

View file

@ -18,56 +18,56 @@
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.30.0", "@auth/core": "^0.30.0",
"@floating-ui/core": "^1.6.1", "@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@prisma/client": "^5.13.0", "@prisma/client": "^5.12.1",
"carta-md": "4.0.2", "carta-md": "4.0.2",
"diff": "^5.2.0", "diff": "^5.2.0",
"isomorphic-dompurify": "^2.9.0", "isomorphic-dompurify": "^2.7.0",
"prisma": "^5.13.0", "prisma": "^5.12.1",
"qs": "^6.12.1", "qs": "^6.12.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8", "svelte-floating-ui": "^1.5.8",
"zod": "^3.23.6", "zod": "^3.22.4",
"zod-form-data": "^2.0.2" "zod-form-data": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.43.1", "@playwright/test": "^1.43.1",
"@stylistic/eslint-plugin": "^1.8.0", "@stylistic/eslint-plugin": "^1.7.2",
"@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.7", "@sveltejs/kit": "^2.5.6",
"@sveltejs/vite-plugin-svelte": "^3.1.0", "@sveltejs/vite-plugin-svelte": "^3.1.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.12",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"@types/diff": "^5.2.0", "@types/diff": "^5.0.9",
"@types/node": "^20.12.8", "@types/node": "^20.12.7",
"@types/qs": "^6.9.15", "@types/qs": "^6.9.15",
"@types/set-cookie-parser": "^2.4.7", "@types/set-cookie-parser": "^2.4.7",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"daisyui": "^4.10.5", "daisyui": "^4.10.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-no-relative-import-paths": "^1.5.4", "eslint-plugin-no-relative-import-paths": "^1.5.4",
"eslint-plugin-svelte": "^2.38.0", "eslint-plugin-svelte": "^2.37.0",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.1.0",
"globals": "^15.1.0", "globals": "^15.0.0",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-nesting": "^12.1.2", "postcss-nesting": "^12.1.1",
"svelte": "^4.2.15", "svelte": "^4.2.15",
"svelte-check": "^3.7.1", "svelte-check": "^3.6.9",
"sveltekit-superforms": "^2.13.0", "sveltekit-superforms": "^2.12.5",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"trpc-sveltekit": "^3.6.1", "trpc-sveltekit": "^3.6.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"tsx": "^4.9.1", "tsx": "^4.7.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"typescript-eslint": "^7.8.0", "typescript-eslint": "^7.7.0",
"vite": "^5.2.11", "vite": "^5.2.9",
"vitest": "^1.6.0" "vitest": "^1.5.0"
}, },
"type": "module", "type": "module",
"pnpm": { "pnpm": {

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
#!/bin/sh
set -e
# Migrate database before starting server
npx prisma migrate deploy
node ./index.js

View file

@ -10,7 +10,7 @@
</script> </script>
<a class="card2 card-animation" href="/entry/{entry.id}"> <a class="card2 card-animation" href="/entry/{entry.id}">
<div class="row items-center flex-wrap gap-2 text-sm"> <div class="row items-center gap-2 text-sm">
<span>{formatDate(entry.current_version.date)}</span> <span>{formatDate(entry.current_version.date)}</span>
{#if entry.current_version.category} {#if entry.current_version.category}
<CategoryField category={entry.current_version.category} nolink /> <CategoryField category={entry.current_version.category} nolink />

View file

@ -10,7 +10,7 @@ import type {
PaginationRequest, PaginationRequest,
SortRequest, SortRequest,
} from "$lib/shared/model"; } from "$lib/shared/model";
import { DateRange, utcDateToYMD } from "$lib/shared/util"; import { DateRange, dateToYMD } from "$lib/shared/util";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error"; import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
@ -365,7 +365,7 @@ left join stations s on s.id = r.station_id`,
current_version: { current_version: {
id: item.version_id, id: item.version_id,
text: item.text, text: item.text,
date: utcDateToYMD(item.date), date: dateToYMD(item.date),
category: item.category_id category: item.category_id
? { ? {
id: item.category_id, id: item.category_id,

View file

@ -19,7 +19,7 @@ import type {
EntryExecution, EntryExecution,
UserTagNameNonnull, UserTagNameNonnull,
} from "$lib/shared/model"; } from "$lib/shared/model";
import { utcDateToYMD } from "$lib/shared/util"; import { dateToYMD } from "$lib/shared/util";
import { ErrorNotFound } from "$lib/shared/util/error"; import { ErrorNotFound } from "$lib/shared/util/error";
type DbRoomLn = DbRoom & { station: DbStation }; type DbRoomLn = DbRoom & { station: DbStation };
@ -73,7 +73,7 @@ export function mapVersion(version: DbEntryVersionLn): EntryVersion {
return { return {
id: version.id, id: version.id,
text: version.text, text: version.text,
date: utcDateToYMD(version.date), date: dateToYMD(version.date),
category: version.category, category: version.category,
priority: version.priority, priority: version.priority,
author: version.author, author: version.author,

View file

@ -1,74 +0,0 @@
import { expect, it, vi } from "vitest";
import {
DateRange, dateFromYMD, dateToYMD, formatDate, humanDate, utcDateToYMD,
} from "./date";
const MINUTE = 60000;
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
it("formatDate", () => {
const date = new Date(2024, 0, 2);
expect(formatDate(date)).toBe("02.01.2024");
});
it.each([
{ s: "2024-01-02", exp: new Date(2024, 0, 2) },
{ s: "24-01-02", exp: NaN },
])("dateFromYMD", ({ s, exp }) => {
expect(dateFromYMD(s)).toStrictEqual(exp);
});
it("utcDateToYMD", () => {
const utcDate = new Date(Date.UTC(2024, 0, 2));
expect(utcDateToYMD(utcDate)).toBe("2024-01-02");
});
it("dateToYMD", () => {
const date = new Date(2024, 0, 2);
expect(dateToYMD(date)).toBe("2024-01-02");
});
it.each([
{ s: 0, txt: "jetzt gerade" },
{ s: -30 * MINUTE, txt: "vor 30 Minuten" },
{ s: -11 * HOUR, txt: "vor 11 Stunden" },
{ s: -DAY, txt: "gestern" },
{ s: -2.5 * DAY, txt: "vor 2 Tagen" },
{ s: -2.6 * DAY, txt: "vor 3 Tagen" },
{ s: -3.5 * DAY, txt: "vor 3 Tagen" },
{ s: -4 * DAY, txt: "am 28.12.2023, 12:00" },
{ s: 28 * MINUTE, txt: "in 28 Minuten" },
{ s: 8 * HOUR, txt: "in 8 Stunden" },
{ s: DAY, txt: "morgen" },
{ s: 2.6 * DAY, txt: "in 3 Tagen" },
{ s: 4 * DAY, txt: "am 05.01.2024, 12:00" },
])("humanDate", ({ s, txt }) => {
const mockDate = new Date(2024, 0, 1, 12, 0);
vi.setSystemTime(mockDate);
const dt = new Date(Number(mockDate) + s);
const res = humanDate(dt, true);
expect(res).toBe(txt);
vi.useRealTimers();
});
it.each([
{ s: "", exp: null },
{ s: "..", exp: null },
{ s: "foo..bar", exp: null },
{ s: "2024-04-15", exp: new DateRange(dateFromYMD("2024-04-15"), dateFromYMD("2024-04-15")) },
{ s: "2024-04-13..2024-04-20", exp: new DateRange(dateFromYMD("2024-04-13"), dateFromYMD("2024-04-20")) },
{ s: "2024-04-13..", exp: new DateRange(dateFromYMD("2024-04-13"), null) },
{ s: "..2024-04-20", exp: new DateRange(null, dateFromYMD("2024-04-20")) },
])("parse daterange $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

@ -1,172 +0,0 @@
const LOCALE = "de-DE";
const MS_PER_DAY = 86400000;
function coerceDate(date: Date | string): Date {
if (!(date instanceof Date)) {
const d1 = dateFromYMD(date);
if (d1) return d1;
return new Date(date);
}
return date;
}
export function formatDate(date: Date | string, time = false): string {
const dt = coerceDate(date);
if (time) {
return dt.toLocaleString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
return dt.toLocaleDateString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function dateFromYMD(s: string): Date {
const re = /^(\d{4})-(\d{2})-(\d{2})$/;
const match = s.match(re);
if (match) {
return new Date(
parseInt(match[1]),
parseInt(match[2]) - 1,
parseInt(match[3]),
);
}
// @ts-expect-error emulate behavior of date constructor
return NaN;
}
/** Convert the given date to a string (using the internal UTC format) */
export function utcDateToYMD(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** Convert the given date to a string (using the local timezone) */
export function dateToYMD(date: Date): string {
return date.getFullYear().toString().padStart(4, "0") + "-"
+ (date.getMonth() + 1).toString().padStart(2, "0") + "-"
+ date.getDate().toString().padStart(2, "0");
}
function dateDiffInDays(a: Date, b: Date): number {
const ts1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
const ts2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((ts2 - ts1) / MS_PER_DAY);
}
export function humanDate(date: Date | string, time = false): string {
const now = new Date();
const dt = coerceDate(date);
const threshold = 302400000; // 3.5 * 24 * 3_600_000
const diff = Number(dt) - Number(now); // pos: Future, neg: Past
if (Math.abs(diff) > threshold) return `am ${formatDate(date, time)}`;
const intl = new Intl.RelativeTimeFormat(LOCALE);
const diffDays = dateDiffInDays(now, dt);
if (diffDays !== 0) {
if (diffDays === 1) return "morgen";
if (diffDays === -1) return "gestern";
return intl.format(diffDays, "day");
}
if (time) {
const diffHours = Math.round(diff / 3_600_000);
if (diffHours !== 0) return intl.format(diffHours, "hour");
const diffMinutes = Math.round(diff / 60_000);
if (diffMinutes !== 0) return intl.format(diffMinutes, "minute");
}
return time ? "jetzt gerade" : "heute";
}
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 dateFromYMD(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;
}
}

View file

@ -0,0 +1,74 @@
import { expect, it, vi } from "vitest";
import type { EntityQuery } from "$lib/shared/model";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import {
getQueryUrl, humanDate, DateRange, parseQueryUrl,
} from ".";
const MINUTE = 60000;
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
it.each([
{ s: 0, txt: "jetzt gerade" },
{ s: -30 * MINUTE, txt: "vor 30 Minuten" },
{ s: -11 * HOUR, txt: "vor 11 Stunden" },
{ s: -DAY, txt: "gestern" },
{ s: -2.5 * DAY, txt: "vor 2 Tagen" },
{ s: -2.6 * DAY, txt: "vor 3 Tagen" },
{ s: -3.5 * DAY, txt: "vor 3 Tagen" },
{ s: -4 * DAY, txt: "am 28.12.2023, 12:00" },
{ s: 28 * MINUTE, txt: "in 28 Minuten" },
{ s: 8 * HOUR, txt: "in 8 Stunden" },
{ s: DAY, txt: "morgen" },
{ s: 2.6 * DAY, txt: "in 3 Tagen" },
{ s: 4 * DAY, txt: "am 05.01.2024, 12:00" },
])("humanDate", ({ s, txt }) => {
const mockDate = new Date(2024, 0, 1, 12, 0);
vi.setSystemTime(mockDate);
const dt = new Date(Number(mockDate) + s);
const res = humanDate(dt, true);
expect(res).toBe(txt);
vi.useRealTimers();
});
it("getQueryUrl", () => {
const query: EntityQuery = {
filter: {
author: [{ id: 2, name: "Max" }],
category: [{ id: 1, name: "Blutabnahme" }, { id: 2, name: "Labortests" }],
done: true,
search: "Hello World",
},
pagination: { limit: 10, offset: 20 },
sort: { field: "room", asc: true },
};
const queryUrl = getQueryUrl(query, "");
expect(queryUrl).toBe("?filter%5Bauthor%5D%5B0%5D%5Bid%5D=2&filter%5Bauthor%5D%5B0%5D%5Bname%5D=Max&filter%5Bcategory%5D%5B0%5D%5Bid%5D=1&filter%5Bcategory%5D%5B0%5D%5Bname%5D=Blutabnahme&filter%5Bcategory%5D%5B1%5D%5Bid%5D=2&filter%5Bcategory%5D%5B1%5D%5Bname%5D=Labortests&filter%5Bdone%5D=true&filter%5Bsearch%5D=Hello%20World&pagination%5Blimit%5D=10&pagination%5Boffset%5D=20&sort%5Bfield%5D=room&sort%5Basc%5D=true");
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

@ -1,2 +1,251 @@
export * from "./util"; import { goto } from "$app/navigation";
export * from "./date";
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 type { EntityQuery, Patient } from "$lib/shared/model";
import type { RouterOutput } from "../trpc";
const LOCALE = "de-DE";
function coerceDate(date: Date | string): Date {
if (!(date instanceof Date)) {
return new Date(date);
}
return date;
}
export function formatDate(date: Date | string, time = false): string {
const dt = coerceDate(date);
if (time) {
return dt.toLocaleString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
return dt.toLocaleDateString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
const MS_PER_DAY = 86400000;
function dateDiffInDays(a: Date, b: Date): number {
const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((utc2 - utc1) / MS_PER_DAY);
}
export function humanDate(date: Date | string, time = false): string {
const now = new Date();
const dt = coerceDate(date);
const threshold = 302400000; // 3.5 * 24 * 3_600_000
const diff = Number(dt) - Number(now); // pos: Future, neg: Past
if (Math.abs(diff) > threshold) return `am ${formatDate(date, time)}`;
const intl = new Intl.RelativeTimeFormat(LOCALE);
const diffDays = dateDiffInDays(now, dt);
if (diffDays !== 0) {
if (diffDays === 1) return "morgen";
if (diffDays === -1) return "gestern";
return intl.format(diffDays, "day");
}
if (time) {
const diffHours = Math.round(diff / 3_600_000);
if (diffHours !== 0) return intl.format(diffHours, "hour");
const diffMinutes = Math.round(diff / 60_000);
if (diffMinutes !== 0) return intl.format(diffMinutes, "minute");
}
return time ? "jetzt gerade" : "heute";
}
export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein";
}
/** Encode an entity query (search and filter) into an URL */
export function getQueryUrl(query: EntityQuery, basePath: string): string {
if (Object.values(query).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "?" + qs.stringify(query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseQueryUrl(search: string): any {
return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true });
}
export function gotoEntityQuery(query: EntityQuery, basePath: string) {
if (window && window.location.pathname.startsWith(`${basePath}/`)) {
if (window.location.search) {
const oldQuery: EntityQuery = parseQueryUrl(window.location.search);
const newQuery: EntityQuery = {
filter: { ...oldQuery.filter, ...query.filter },
sort: query.sort,
};
goto(getQueryUrl(newQuery, basePath));
return;
}
}
goto(getQueryUrl(query, basePath));
}
/** Wrap a page load query to handle occuring errors
*
* Converts TRPC errors to SvelteKit ones
*/
export async function loadWrap<T>(f: () => Promise<T>) {
try {
return await f();
} catch (e) {
if (isRedirect(e)) {
throw e;
} else if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof ZodError) {
error(400, e.message);
} else if (e instanceof Error) {
error(500, e.message);
} else {
error(500, "unknown error");
}
}
}
export function baseUrl(url: URL): string {
return `${url.protocol}//${url.host}`;
}
export function dateToYMD(date: Date): string {
return date.toISOString().slice(0, 10);
}
export class Debouncer {
private delay: number;
private handler: () => unknown;
private timeout: number | null = null;
constructor(delay: number, handler: () => unknown) {
this.delay = delay;
this.handler = handler;
}
clear() {
if (this.timeout) window.clearTimeout(this.timeout);
}
trigger() {
this.clear();
this.timeout = window.setTimeout(this.handler, this.delay);
}
now() {
this.clear();
this.handler();
}
}
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})`;
}

View file

@ -1,27 +0,0 @@
import { expect, it } from "vitest";
import type { EntityQuery } from "$lib/shared/model";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import {
getQueryUrl, parseQueryUrl,
} from ".";
it("getQueryUrl", () => {
const query: EntityQuery = {
filter: {
author: [{ id: 2, name: "Max" }],
category: [{ id: 1, name: "Blutabnahme" }, { id: 2, name: "Labortests" }],
done: true,
search: "Hello World",
},
pagination: { limit: 10, offset: 20 },
sort: { field: "room", asc: true },
};
const queryUrl = getQueryUrl(query, "");
expect(queryUrl).toBe("?filter%5Bauthor%5D%5B0%5D%5Bid%5D=2&filter%5Bauthor%5D%5B0%5D%5Bname%5D=Max&filter%5Bcategory%5D%5B0%5D%5Bid%5D=1&filter%5Bcategory%5D%5B0%5D%5Bname%5D=Blutabnahme&filter%5Bcategory%5D%5B1%5D%5Bid%5D=2&filter%5Bcategory%5D%5B1%5D%5Bname%5D=Labortests&filter%5Bdone%5D=true&filter%5Bsearch%5D=Hello%20World&pagination%5Blimit%5D=10&pagination%5Boffset%5D=20&sort%5Bfield%5D=room&sort%5Basc%5D=true");
const decoded = ZEntriesQuery.parse(parseQueryUrl(queryUrl));
expect(decoded).toStrictEqual(query);
});

View file

@ -1,105 +0,0 @@
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 type { EntityQuery } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc";
export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein";
}
/** Encode an entity query (search and filter) into an URL */
export function getQueryUrl(query: EntityQuery, basePath: string): string {
if (Object.values(query).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "?" + qs.stringify(query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseQueryUrl(search: string): any {
return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true });
}
export function gotoEntityQuery(query: EntityQuery, basePath: string) {
if (window && window.location.pathname.startsWith(`${basePath}/`)) {
if (window.location.search) {
const oldQuery: EntityQuery = parseQueryUrl(window.location.search);
const newQuery: EntityQuery = {
filter: { ...oldQuery.filter, ...query.filter },
sort: query.sort,
};
goto(getQueryUrl(newQuery, basePath));
return;
}
}
goto(getQueryUrl(query, basePath));
}
/** Wrap a page load query to handle occuring errors
*
* Converts TRPC errors to SvelteKit ones
*/
export async function loadWrap<T>(f: () => Promise<T>) {
try {
return await f();
} catch (e) {
if (isRedirect(e)) {
throw e;
} else if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof ZodError) {
error(400, e.message);
} else if (e instanceof Error) {
error(500, e.message);
} else {
error(500, "unknown error");
}
}
}
export function baseUrl(url: URL): string {
return `${url.protocol}//${url.host}`;
}
export class Debouncer {
private delay: number;
private handler: () => unknown;
private timeout: number | null = null;
constructor(delay: number, handler: () => unknown) {
this.delay = delay;
this.handler = handler;
}
clear() {
if (this.timeout) window.clearTimeout(this.timeout);
}
trigger() {
this.clear();
this.timeout = window.setTimeout(this.handler, this.delay);
}
now() {
this.clear();
this.handler();
}
}
export function sanitizeHtml(s: string): string {
return DOMPurify.sanitize(s, { FORBID_TAGS: ["img"] });
}
export function formatPatientName(patient: RouterOutput["patient"]["list"]["items"][0]): string {
return `${patient.first_name} ${patient.last_name} (${patient.age})`;
}
export function divFloor(a: number, b: number): number {
return Math.floor(a / b);
}

View file

@ -53,7 +53,7 @@
filter: { filter: {
done: false, done: false,
station: selection ? [selection] : undefined, station: selection ? [selection] : undefined,
date: dateRange ? [{ id: dateRange.toString() }] : undefined, date: dateRange ? [{ id: dateRange.toString(), name: dateRange.format() }] : undefined,
}, },
}); });
} }

View file

@ -1,14 +1,10 @@
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { redirect } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { URL_VISIT } from "$lib/shared/constants";
import { ZEntriesQuery } from "$lib/shared/model/validation"; import { ZEntriesQuery } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { import { DateRange, loadWrap, parseQueryUrl } from "$lib/shared/util";
DateRange, getQueryUrl, loadWrap, parseQueryUrl,
} from "$lib/shared/util";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
return loadWrap(async () => { return loadWrap(async () => {
@ -19,17 +15,13 @@ export const load: PageLoad = async (event) => {
query = ZEntriesQuery.parse(decoded); query = ZEntriesQuery.parse(decoded);
} }
// Default filter (current week)
const drange = DateRange.thisWeek();
if (!query.filter) { if (!query.filter) {
const url = getQueryUrl({ query.filter = {
filter: { date: [{ id: drange.toString() }],
done: false, };
date: [{ id: DateRange.thisWeek().toString() }],
},
}, URL_VISIT);
redirect(302, url);
} }
// Sort entries by date
if (!query.sort) { if (!query.sort) {
query.sort = { field: "date" }; query.sort = { field: "date" };
} }