diff --git a/src/app.pcss b/src/app.pcss index 77319c1..a46d9f4 100644 --- a/src/app.pcss +++ b/src/app.pcss @@ -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; } } diff --git a/src/lib/components/ui/Header.svelte b/src/lib/components/ui/Header.svelte index 9b9db35..99e6062 100644 --- a/src/lib/components/ui/Header.svelte +++ b/src/lib/components/ui/Header.svelte @@ -7,7 +7,7 @@ export let backHref: string | undefined = undefined; -
+
{#if backHref} diff --git a/src/lib/components/ui/VersionsButton.svelte b/src/lib/components/ui/VersionsButton.svelte new file mode 100644 index 0000000..9f15586 --- /dev/null +++ b/src/lib/components/ui/VersionsButton.svelte @@ -0,0 +1,15 @@ + + + +{#if n > 1} + + + {n} + +{/if} diff --git a/src/lib/server/query/entry.ts b/src/lib/server/query/entry.ts index d6798b4..2c1a380 100644 --- a/src/lib/server/query/entry.ts +++ b/src/lib/server/query/entry.ts @@ -39,6 +39,14 @@ export async function getEntry(id: number): Promise { return mapEntry(entry); } +export async function getEntryNVersions(id: number): Promise { + return prisma.entryVersion.count({ where: { entry_id: id } }); +} + +export async function getEntryNExecutions(id: number): Promise { + return prisma.entryExecution.count({ where: { entry_id: id } }); +} + export async function getEntryVersions(id: number): Promise { const versions = await prisma.entryVersion.findMany({ where: { entry_id: id }, diff --git a/src/lib/server/trpc/routes/entry.ts b/src/lib/server/trpc/routes/entry.ts index b6372f0..6518958 100644 --- a/src/lib/server/trpc/routes/entry.ts +++ b/src/lib/server/trpc/routes/entry.ts @@ -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) => diff --git a/src/lib/shared/util/diff.ts b/src/lib/shared/util/diff.ts index 726346e..309ea16 100644 --- a/src/lib/shared/util/diff.ts +++ b/src/lib/shared/util/diff.ts @@ -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(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; +} diff --git a/src/lib/shared/util/index.test.ts b/src/lib/shared/util/index.test.ts new file mode 100644 index 0000000..ee34a4b --- /dev/null +++ b/src/lib/shared/util/index.test.ts @@ -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(); +}); diff --git a/src/lib/shared/util/index.ts b/src/lib/shared/util/index.ts index df6daf1..af5c3ba 100644 --- a/src/lib/shared/util/index.ts +++ b/src/lib/shared/util/index.ts @@ -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"; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 8114609..2d78b2d 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -48,6 +48,6 @@
-
+
diff --git a/src/routes/(app)/entry/[id]/+page.svelte b/src/routes/(app)/entry/[id]/+page.svelte index c9f9cc9..bb26785 100644 --- a/src/routes/(app)/entry/[id]/+page.svelte +++ b/src/routes/(app)/entry/[id]/+page.svelte @@ -1,15 +1,18 @@ @@ -23,45 +26,62 @@ {#if data.entry.current_version.priority}
Priorität
{/if} - - - - - +

+ Erstellt {humanDate(data.entry.created_at, true)} + · + Zu erledigen {humanDate(data.entry.current_version.date)} +

+
Patient
{#if data.entry.patient.room} {/if} - {data.entry.patient.first_name} - {data.entry.patient.last_name} - ({data.entry.patient.age}) + + {data.entry.patient.first_name} + {data.entry.patient.last_name} + ({data.entry.patient.age}) +
-
Beschreibung
+
+ Beschreibung +
+ + +
+

+
+ Zuletzt bearbeitet am {formatDate(data.entry.current_version.created_at, true)} von  + +
{#if data.entry.execution}
-
+

Erledigt am {formatDate(data.entry.execution.created_at, true)} von :

+
+ + +

diff --git a/src/routes/(app)/entry/[id]/executions/+page.svelte b/src/routes/(app)/entry/[id]/executions/+page.svelte new file mode 100644 index 0000000..1001441 --- /dev/null +++ b/src/routes/(app)/entry/[id]/executions/+page.svelte @@ -0,0 +1,45 @@ + + + + Eintrag #{entryId} - Erledigt + + +

+ +{#each data.versions as version, i} +
+
+ #{i + 1}  + , {formatDate(version.created_at, true)} +
+ {#if version.text.length > 0} +
+ {#each version.text as change} + + {change.value} + + {/each} +
+ {/if} +
+{/each} + + diff --git a/src/routes/(app)/entry/[id]/executions/+page.ts b/src/routes/(app)/entry/[id]/executions/+page.ts new file mode 100644 index 0000000..64cc833 --- /dev/null +++ b/src/routes/(app)/entry/[id]/executions/+page.ts @@ -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 }; +}; diff --git a/src/routes/(app)/entry/[id]/versions/+page.svelte b/src/routes/(app)/entry/[id]/versions/+page.svelte index aa5833f..8741f6b 100644 --- a/src/routes/(app)/entry/[id]/versions/+page.svelte +++ b/src/routes/(app)/entry/[id]/versions/+page.svelte @@ -16,45 +16,44 @@
-
- {#each data.versions as version} -
-
- , {formatDate(version.created_at, true)} -
- {#if version.category} -
-
Kategeorie
-
-
- {/if} - {#if version.text.length > 0} -
-
Text
-
- {#each version.text as change} - - {change.value} - - {/each} -
-
- {/if} - {#if version.date !== undefined} -
-
Datum
-
{formatDate(version.date)}
-
- {/if} - {#if version.priority !== undefined} -
-
Priorität
-
{formatBool(version.priority)}
-
- {/if} +{#each data.versions as version, i} +
+
+ #{i + 1}  + , {formatDate(version.created_at, true)}
- {/each} -
+ {#if version.category} +
+
Kategeorie
+
+
+ {/if} + {#if version.text.length > 0} +
+
Text
+
+ {#each version.text as change} + + {change.value} + + {/each} +
+
+ {/if} + {#if version.date !== undefined} +
+
Datum
+
{formatDate(version.date)}
+
+ {/if} + {#if version.priority !== undefined} +
+
Priorität
+
{formatBool(version.priority)}
+
+ {/if} +
+{/each}