Compare commits

...

2 commits

14 changed files with 318 additions and 70 deletions

View file

@ -33,7 +33,6 @@ button {
.card2 {
@apply bg-base-200;
@apply rounded-xl;
@apply mb-8;
@apply flex flex-col;
@apply border-solid border-base-content/30 border-[1px];
@ -51,10 +50,14 @@ button {
}
.c-light {
@apply bg-base-content/20;
@apply bg-base-content/20 py-1;
}
.c-vlight {
@apply bg-base-content/10 py-1;
}
.c-primary {
@apply bg-primary text-primary-content;
@apply bg-primary text-primary-content py-1;
}
}

View file

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

View file

@ -0,0 +1,15 @@
<!-- Button showing the amount of entry versions -->
<script lang="ts">
import { mdiHistory } from "@mdi/js";
import Icon from "./Icon.svelte";
export let n: number;
export let href: string;
</script>
{#if n > 1}
<a {href} class="btn btn-xs btn-primary rounded-full">
<Icon path={mdiHistory} size={1.2} />
<span>{n}</span>
</a>
{/if}

View file

@ -39,6 +39,14 @@ export async function getEntry(id: number): Promise<Entry> {
return mapEntry(entry);
}
export async function getEntryNVersions(id: number): Promise<number> {
return prisma.entryVersion.count({ where: { entry_id: id } });
}
export async function getEntryNExecutions(id: number): Promise<number> {
return prisma.entryExecution.count({ where: { entry_id: id } });
}
export async function getEntryVersions(id: number): Promise<EntryVersion[]> {
const versions = await prisma.entryVersion.findMany({
where: { entry_id: id },

View file

@ -12,19 +12,28 @@ import {
getEntries,
getEntry,
getEntryExecutions,
getEntryNExecutions,
getEntryNVersions,
getEntryVersions,
newEntry,
newEntryExecution,
newEntryVersion,
} from "$lib/server/query";
import { versionsDiff } from "$lib/shared/util/diff";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
const ZEntityId = fields.EntityId();
export const entryRouter = t.router({
get: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntry(opts.input))),
get: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const [entry, n_versions, n_executions] = await Promise.all([
getEntry(opts.input),
getEntryNVersions(opts.input),
getEntryNExecutions(opts.input),
]);
return { ...entry, n_versions, n_executions };
})
),
list: t.procedure
.input(ZEntriesQuery)
.query(async (opts) =>
@ -48,6 +57,12 @@ export const entryRouter = t.router({
executions: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),
executionsDiff: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const executions = await getEntryExecutions(opts.input);
return executionsDiff(executions);
})
),
create: t.procedure
.input(ZEntryNew)
.mutation(async (opts) =>

View file

@ -1,5 +1,11 @@
import { diffWords } from "diff";
import type { EntryVersion, Category, Option, UserTag } from "$lib/shared/model";
import type {
EntryVersion,
EntryExecution,
Category,
Option,
UserTag,
} from "$lib/shared/model";
export type EntryVersionChange = {
id: number;
@ -12,6 +18,14 @@ export type EntryVersionChange = {
priority?: boolean;
};
export type EntryExecutionChange = {
id: number;
author: UserTag;
created_at: Date;
text: Diff.Change[];
};
function newOrUndef<T>(o: T, n: T): T | undefined {
return o === n ? undefined : n;
}
@ -44,3 +58,29 @@ export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] {
return changes;
}
export function executionsDiff(executions: EntryExecution[]): EntryExecutionChange[] {
let prev = executions[executions.length - 1];
const changes: EntryExecutionChange[] = [
{
...prev,
text: diffWords("", prev.text),
},
];
for (let i = executions.length - 2; i >= 0; i--) {
const v = executions[i];
const text = diffWords(prev.text, v.text);
changes.push({
id: v.id,
author: v.author,
created_at: v.created_at,
text,
});
prev = v;
}
return changes;
}

View file

@ -0,0 +1,32 @@
import { expect, test, vi } from "vitest";
import { humanDate } from ".";
const MINUTE = 60000;
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
test.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();
});

View file

@ -4,13 +4,19 @@ import { TRPCClientError } from "@trpc/client";
import { error } from "@sveltejs/kit";
import { ZodError } from "zod";
export function formatDate(date: Date | string, time = false): string {
let dt = date;
if (!(dt instanceof Date)) {
dt = new Date(dt);
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("de-DE", {
return dt.toLocaleString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
@ -18,7 +24,7 @@ export function formatDate(date: Date | string, time = false): string {
minute: "2-digit",
});
} else {
return dt.toLocaleDateString("de-DE", {
return dt.toLocaleDateString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
@ -26,6 +32,42 @@ export function formatDate(date: Date | string, time = false): string {
}
}
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";
}

View file

@ -48,6 +48,6 @@
</div>
</nav>
</div>
<div class="max-w-[100vw] p-4 pb-8">
<div class="max-w-[100vw] p-4 pb-8 flex flex-col gap-4">
<slot />
</div>

View file

@ -1,15 +1,18 @@
<script lang="ts">
import type { PageData } from "./$types";
import { formatDate } from "$lib/shared/util";
import { formatDate, humanDate } from "$lib/shared/util";
import UserField from "$lib/components/table/UserField.svelte";
import RoomField from "$lib/components/table/RoomField.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "$lib/components/ui/Markdown.svelte";
import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { mdiHistory } from "@mdi/js";
import { mdiPencil } from "@mdi/js";
import VersionsButton from "$lib/components/ui/VersionsButton.svelte";
export let data: PageData;
$: basePath = `/entry/${data.entry.id}`;
</script>
<svelte:head>
@ -23,45 +26,62 @@
{#if data.entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div>
{/if}
<a
href="/entry/{data.entry.id}/versions"
class="btn btn-sm btn-primary ml-auto"
slot="rightBtn"
>
<Icon path={mdiHistory} />
<span class="hidden sm:inline">Versionen</span>
</a>
</Header>
<p class="text-sm flex flex-row gap-2">
<span>Erstellt {humanDate(data.entry.created_at, true)}</span>
<span>·</span>
<span>Zu erledigen {humanDate(data.entry.current_version.date)}</span>
</p>
<div class="card2">
<div class="row c-light text-sm">Patient</div>
<div class="row items-center gap-2">
{#if data.entry.patient.room}
<RoomField room={data.entry.patient.room} />
{/if}
<a href="/patient/{data.entry.patient.id}">
{data.entry.patient.first_name}
{data.entry.patient.last_name}
({data.entry.patient.age})
</a>
</div>
</div>
<div class="card2">
<div class="row c-light text-sm">Beschreibung</div>
<div class="row c-light text-sm items-center justify-between">
Beschreibung
<div>
<VersionsButton href="{basePath}/versions" n={data.entry.n_versions} />
<button class="btn btn-circle btn-sm btn-ghost">
<Icon path={mdiPencil} size={1.2} />
</button>
</div>
</div>
<div class="row">
<p class="prose">
<Markdown src={data.entry.current_version.text} />
</p>
</div>
<div class="row c-vlight text-sm">
Zuletzt bearbeitet am {formatDate(data.entry.current_version.created_at, true)} von&nbsp;
<UserField user={data.entry.current_version.author} filterName="author" />
</div>
</div>
{#if data.entry.execution}
<div class="card2">
<div class="row c-light text-sm">
<div class="row c-light text-sm items-center justify-between">
<p>
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
<UserField user={data.entry.execution.author} filterName="executor" />:
</p>
<div>
<VersionsButton href="{basePath}/executions" n={data.entry.n_executions} />
<button class="btn btn-circle btn-xs btn-ghost">
<Icon path={mdiPencil} size={1.2} />
</button>
</div>
</div>
<div class="row">
<p class="prose">

View file

@ -0,0 +1,45 @@
<script lang="ts">
import CategoryField from "$lib/components/table/CategoryField.svelte";
import UserField from "$lib/components/table/UserField.svelte";
import { formatBool, formatDate } from "$lib/shared/util";
import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte";
import { page } from "$app/stores";
export let data: PageData;
$: entryId = $page.params.id;
</script>
<svelte:head>
<title>Eintrag #{entryId} - Erledigt</title>
</svelte:head>
<Header title="Eintrag #{entryId} - Erledigt" backHref="/entry/{entryId}" />
{#each data.versions as version, i}
<div class="card2">
<div class="row c-light text-sm">
#{i + 1}&nbsp;
<UserField user={version.author} />, {formatDate(version.created_at, true)}
</div>
{#if version.text.length > 0}
<div class="row whitespace-pre-wrap">
{#each version.text as change}
<span class:added={change.added} class:removed={change.removed}>
{change.value}
</span>
{/each}
</div>
{/if}
</div>
{/each}
<style lang="postcss">
.added {
@apply bg-success/20;
}
.removed {
@apply bg-error/20;
}
</style>

View file

@ -0,0 +1,13 @@
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id);
const versions = await loadWrap(async () =>
trpc(event).entry.executionsDiff.query(id)
);
return { versions };
};

View file

@ -16,10 +16,10 @@
<Header title="Eintrag #{entryId} - Versionen" backHref="/entry/{entryId}" />
<div class="overflow-x-auto">
{#each data.versions as version}
{#each data.versions as version, i}
<div class="card2">
<div class="row c-light text-sm">
#{i + 1}&nbsp;
<UserField user={version.author} />, {formatDate(version.created_at, true)}
</div>
{#if version.category}
@ -54,7 +54,6 @@
{/if}
</div>
{/each}
</div>
<style lang="postcss">
.row > div:first-child {

View file

@ -1,6 +1,8 @@
import {
getEntries,
getEntry,
getEntryNExecutions,
getEntryNVersions,
getEntryVersions,
newEntry,
newEntryExecution,
@ -32,6 +34,11 @@ test("create entry", async () => {
expect(entry.current_version.text).toBe(TEST_VERSION.text);
expect(entry.current_version.date).toStrictEqual(TEST_VERSION.date);
expect(entry.current_version.priority).toBe(TEST_VERSION.priority);
const nVersions = await getEntryNVersions(eId);
expect(nVersions).toBe(1);
const nExecutions = await getEntryNExecutions(eId);
expect(nExecutions).toBe(0);
});
test("create entry version", async () => {
@ -65,6 +72,9 @@ test("create entry version", async () => {
expect(history[0]).toMatchObject(expectedVersion);
expect(history[1].text).toBe(TEST_VERSION.text);
expect(history[0].created_at).greaterThan(history[1].created_at);
const nVersions = await getEntryNVersions(eId);
expect(nVersions).toBe(2);
});
test("create entry version (partial)", async () => {
@ -117,6 +127,9 @@ test("create entry execution", async () => {
expect(entry.execution?.id).toBe(xId);
expect(entry.execution?.author.id).toBe(1);
expect(entry.execution?.text).toBe(text);
const nExecutions = await getEntryNExecutions(eId);
expect(nExecutions).toBe(1);
});
test("create entry execution (update)", async () => {
@ -132,6 +145,9 @@ test("create entry execution (update)", async () => {
expect(entry.execution?.id).toBe(x2);
expect(entry.execution?.text).toBe("x2");
expect(entry.execution?.author.id).toBe(2);
const nExecutions = await getEntryNExecutions(eId);
expect(nExecutions).toBe(2);
});
test("create entry execution (wrong old xid)", async () => {