Compare commits
No commits in common. "e3f7341a0e575a9b0cb922c9b697c21f6f6875c6" and "cfe5fc2d9f012aafc8aa470a5b2f8d1366d29dcc" have entirely different histories.
e3f7341a0e
...
cfe5fc2d9f
16 changed files with 992 additions and 852 deletions
|
@ -1,5 +1,6 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
|
|
29
Dockerfile
29
Dockerfile
|
@ -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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# copy dependency list
|
||||
COPY package.json pnpm-lock.yaml run/entrypoint.sh ./
|
||||
COPY patches ./patches
|
||||
COPY prisma ./prisma
|
||||
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 build ./
|
||||
COPY --from=0 /app/build ./
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["./entrypoint.sh"]
|
||||
CMD ["node", "./index.js"]
|
||||
|
|
42
package.json
42
package.json
|
@ -18,56 +18,56 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.30.0",
|
||||
"@floating-ui/core": "^1.6.1",
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@prisma/client": "^5.13.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"carta-md": "4.0.2",
|
||||
"diff": "^5.2.0",
|
||||
"isomorphic-dompurify": "^2.9.0",
|
||||
"prisma": "^5.13.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",
|
||||
"zod": "^3.23.6",
|
||||
"zod": "^3.22.4",
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.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/kit": "^2.5.7",
|
||||
"@sveltejs/kit": "^2.5.6",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@types/diff": "^5.2.0",
|
||||
"@types/node": "^20.12.8",
|
||||
"@types/diff": "^5.0.9",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/qs": "^6.9.15",
|
||||
"@types/set-cookie-parser": "^2.4.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"daisyui": "^4.10.5",
|
||||
"daisyui": "^4.10.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-no-relative-import-paths": "^1.5.4",
|
||||
"eslint-plugin-svelte": "^2.38.0",
|
||||
"eslint-plugin-unused-imports": "^3.2.0",
|
||||
"globals": "^15.1.0",
|
||||
"eslint-plugin-svelte": "^2.37.0",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"globals": "^15.0.0",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-nesting": "^12.1.2",
|
||||
"postcss-nesting": "^12.1.1",
|
||||
"svelte": "^4.2.15",
|
||||
"svelte-check": "^3.7.1",
|
||||
"sveltekit-superforms": "^2.13.0",
|
||||
"svelte-check": "^3.6.9",
|
||||
"sveltekit-superforms": "^2.12.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"trpc-sveltekit": "^3.6.1",
|
||||
"tslib": "^2.6.2",
|
||||
"tsx": "^4.9.1",
|
||||
"tsx": "^4.7.2",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^7.8.0",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.6.0"
|
||||
"typescript-eslint": "^7.7.0",
|
||||
"vite": "^5.2.9",
|
||||
"vitest": "^1.5.0"
|
||||
},
|
||||
"type": "module",
|
||||
"pnpm": {
|
||||
|
|
1030
pnpm-lock.yaml
1030
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,5 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
# Migrate database before starting server
|
||||
npx prisma migrate deploy
|
||||
node ./index.js
|
|
@ -10,7 +10,7 @@
|
|||
</script>
|
||||
|
||||
<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>
|
||||
{#if entry.current_version.category}
|
||||
<CategoryField category={entry.current_version.category} nolink />
|
||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
|||
PaginationRequest,
|
||||
SortRequest,
|
||||
} 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 { prisma } from "$lib/server/prisma";
|
||||
|
@ -365,7 +365,7 @@ left join stations s on s.id = r.station_id`,
|
|||
current_version: {
|
||||
id: item.version_id,
|
||||
text: item.text,
|
||||
date: utcDateToYMD(item.date),
|
||||
date: dateToYMD(item.date),
|
||||
category: item.category_id
|
||||
? {
|
||||
id: item.category_id,
|
||||
|
|
|
@ -19,7 +19,7 @@ import type {
|
|||
EntryExecution,
|
||||
UserTagNameNonnull,
|
||||
} from "$lib/shared/model";
|
||||
import { utcDateToYMD } from "$lib/shared/util";
|
||||
import { dateToYMD } from "$lib/shared/util";
|
||||
import { ErrorNotFound } from "$lib/shared/util/error";
|
||||
|
||||
type DbRoomLn = DbRoom & { station: DbStation };
|
||||
|
@ -73,7 +73,7 @@ export function mapVersion(version: DbEntryVersionLn): EntryVersion {
|
|||
return {
|
||||
id: version.id,
|
||||
text: version.text,
|
||||
date: utcDateToYMD(version.date),
|
||||
date: dateToYMD(version.date),
|
||||
category: version.category,
|
||||
priority: version.priority,
|
||||
author: version.author,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
74
src/lib/shared/util/index.test.ts
Normal file
74
src/lib/shared/util/index.test.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -1,2 +1,251 @@
|
|||
export * from "./util";
|
||||
export * from "./date";
|
||||
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, 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})`;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -53,7 +53,7 @@
|
|||
filter: {
|
||||
done: false,
|
||||
station: selection ? [selection] : undefined,
|
||||
date: dateRange ? [{ id: dateRange.toString() }] : undefined,
|
||||
date: dateRange ? [{ id: dateRange.toString(), name: dateRange.format() }] : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
import type { PageLoad } from "./$types";
|
||||
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
|
||||
import { URL_VISIT } from "$lib/shared/constants";
|
||||
import { ZEntriesQuery } from "$lib/shared/model/validation";
|
||||
import { trpc } from "$lib/shared/trpc";
|
||||
import {
|
||||
DateRange, getQueryUrl, loadWrap, parseQueryUrl,
|
||||
} from "$lib/shared/util";
|
||||
import { DateRange, loadWrap, parseQueryUrl } from "$lib/shared/util";
|
||||
|
||||
export const load: PageLoad = async (event) => {
|
||||
return loadWrap(async () => {
|
||||
|
@ -19,17 +15,13 @@ export const load: PageLoad = async (event) => {
|
|||
query = ZEntriesQuery.parse(decoded);
|
||||
}
|
||||
|
||||
// Default filter (current week)
|
||||
const drange = DateRange.thisWeek();
|
||||
if (!query.filter) {
|
||||
const url = getQueryUrl({
|
||||
filter: {
|
||||
done: false,
|
||||
date: [{ id: DateRange.thisWeek().toString() }],
|
||||
},
|
||||
}, URL_VISIT);
|
||||
redirect(302, url);
|
||||
query.filter = {
|
||||
date: [{ id: drange.toString() }],
|
||||
};
|
||||
}
|
||||
|
||||
// Sort entries by date
|
||||
if (!query.sort) {
|
||||
query.sort = { field: "date" };
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue