Compare commits

...

2 commits

14 changed files with 318 additions and 70 deletions

View file

@ -33,7 +33,6 @@ button {
.card2 { .card2 {
@apply bg-base-200; @apply bg-base-200;
@apply rounded-xl; @apply rounded-xl;
@apply mb-8;
@apply flex flex-col; @apply flex flex-col;
@apply border-solid border-base-content/30 border-[1px]; @apply border-solid border-base-content/30 border-[1px];
@ -51,10 +50,14 @@ button {
} }
.c-light { .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 { .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; export let backHref: string | undefined = undefined;
</script> </script>
<div class="mb-4 flex flex-row"> <div class="flex flex-row">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{#if backHref} {#if backHref}
<a href={backHref} class="btn btn-sm btn-circle btn-ghost"> <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); 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[]> { export async function getEntryVersions(id: number): Promise<EntryVersion[]> {
const versions = await prisma.entryVersion.findMany({ const versions = await prisma.entryVersion.findMany({
where: { entry_id: id }, where: { entry_id: id },

View file

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

View file

@ -1,5 +1,11 @@
import { diffWords } from "diff"; 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 = { export type EntryVersionChange = {
id: number; id: number;
@ -12,6 +18,14 @@ export type EntryVersionChange = {
priority?: boolean; priority?: boolean;
}; };
export type EntryExecutionChange = {
id: number;
author: UserTag;
created_at: Date;
text: Diff.Change[];
};
function newOrUndef<T>(o: T, n: T): T | undefined { function newOrUndef<T>(o: T, n: T): T | undefined {
return o === n ? undefined : n; return o === n ? undefined : n;
} }
@ -44,3 +58,29 @@ export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] {
return changes; 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 { error } from "@sveltejs/kit";
import { ZodError } from "zod"; import { ZodError } from "zod";
export function formatDate(date: Date | string, time = false): string { const LOCALE = "de-DE";
let dt = date;
if (!(dt instanceof Date)) { function coerceDate(date: Date | string): Date {
dt = new Date(dt); 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) { if (time) {
return dt.toLocaleString("de-DE", { return dt.toLocaleString(LOCALE, {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
@ -18,7 +24,7 @@ export function formatDate(date: Date | string, time = false): string {
minute: "2-digit", minute: "2-digit",
}); });
} else { } else {
return dt.toLocaleDateString("de-DE", { return dt.toLocaleDateString(LOCALE, {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", 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 { export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein"; return val ? "Ja" : "Nein";
} }

View file

@ -48,6 +48,6 @@
</div> </div>
</nav> </nav>
</div> </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 /> <slot />
</div> </div>

View file

@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; 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 UserField from "$lib/components/table/UserField.svelte";
import RoomField from "$lib/components/table/RoomField.svelte"; import RoomField from "$lib/components/table/RoomField.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte"; import CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "$lib/components/ui/Markdown.svelte"; import Markdown from "$lib/components/ui/Markdown.svelte";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.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; export let data: PageData;
$: basePath = `/entry/${data.entry.id}`;
</script> </script>
<svelte:head> <svelte:head>
@ -23,45 +26,62 @@
{#if data.entry.current_version.priority} {#if data.entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div> <div class="badge ellipsis badge-warning">Priorität</div>
{/if} {/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> </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="card2">
<div class="row c-light text-sm">Patient</div> <div class="row c-light text-sm">Patient</div>
<div class="row items-center gap-2"> <div class="row items-center gap-2">
{#if data.entry.patient.room} {#if data.entry.patient.room}
<RoomField room={data.entry.patient.room} /> <RoomField room={data.entry.patient.room} />
{/if} {/if}
{data.entry.patient.first_name} <a href="/patient/{data.entry.patient.id}">
{data.entry.patient.last_name} {data.entry.patient.first_name}
({data.entry.patient.age}) {data.entry.patient.last_name}
({data.entry.patient.age})
</a>
</div> </div>
</div> </div>
<div class="card2"> <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"> <div class="row">
<p class="prose"> <p class="prose">
<Markdown src={data.entry.current_version.text} /> <Markdown src={data.entry.current_version.text} />
</p> </p>
</div> </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> </div>
{#if data.entry.execution} {#if data.entry.execution}
<div class="card2"> <div class="card2">
<div class="row c-light text-sm"> <div class="row c-light text-sm items-center justify-between">
<p> <p>
Erledigt am {formatDate(data.entry.execution.created_at, true)} von Erledigt am {formatDate(data.entry.execution.created_at, true)} von
<UserField user={data.entry.execution.author} filterName="executor" />: <UserField user={data.entry.execution.author} filterName="executor" />:
</p> </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>
<div class="row"> <div class="row">
<p class="prose"> <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,45 +16,44 @@
<Header title="Eintrag #{entryId} - Versionen" backHref="/entry/{entryId}" /> <Header title="Eintrag #{entryId} - Versionen" backHref="/entry/{entryId}" />
<div class="overflow-x-auto"> {#each data.versions as version, i}
{#each data.versions as version} <div class="card2">
<div class="card2"> <div class="row c-light text-sm">
<div class="row c-light text-sm"> #{i + 1}&nbsp;
<UserField user={version.author} />, {formatDate(version.created_at, true)} <UserField user={version.author} />, {formatDate(version.created_at, true)}
</div>
{#if version.category}
<div class="row">
<div>Kategeorie</div>
<div><CategoryField category={version.category} /></div>
</div>
{/if}
{#if version.text.length > 0}
<div class="row">
<div>Text</div>
<div class="whitespace-pre-wrap">
{#each version.text as change}
<span class:added={change.added} class:removed={change.removed}>
{change.value}
</span>
{/each}
</div>
</div>
{/if}
{#if version.date !== undefined}
<div class="row">
<div>Datum</div>
<div>{formatDate(version.date)}</div>
</div>
{/if}
{#if version.priority !== undefined}
<div class="row">
<div>Priorität</div>
<div>{formatBool(version.priority)}</div>
</div>
{/if}
</div> </div>
{/each} {#if version.category}
</div> <div class="row">
<div>Kategeorie</div>
<div><CategoryField category={version.category} /></div>
</div>
{/if}
{#if version.text.length > 0}
<div class="row">
<div>Text</div>
<div class="whitespace-pre-wrap">
{#each version.text as change}
<span class:added={change.added} class:removed={change.removed}>
{change.value}
</span>
{/each}
</div>
</div>
{/if}
{#if version.date !== undefined}
<div class="row">
<div>Datum</div>
<div>{formatDate(version.date)}</div>
</div>
{/if}
{#if version.priority !== undefined}
<div class="row">
<div>Priorität</div>
<div>{formatBool(version.priority)}</div>
</div>
{/if}
</div>
{/each}
<style lang="postcss"> <style lang="postcss">
.row > div:first-child { .row > div:first-child {

View file

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