Compare commits

...

4 commits

Author SHA1 Message Date
6c338e447e
feat: replace carta-md with a fork
fix: remove auto-trim from Markdown input
2024-05-11 03:02:12 +02:00
2a7aa618d9
feat: add multi-column sort 2024-05-10 15:20:04 +02:00
f630ee08b8
feat: create executions without marking entry as done ('notes') 2024-05-10 13:51:55 +02:00
f485db2968
chore(release): release v0.2.1 2024-05-09 15:29:25 +02:00
39 changed files with 324 additions and 185 deletions

View file

@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
## [v0.2.1](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.2.0..v0.2.1) - 2024-05-07
### 🐛 Bug Fixes
- Carta editor mobile context menu transparent - ([6bd7e15](https://code.thetadev.de/HSA/Visitenbuch/commit/6bd7e157eea6589be82692499cb956455386f7e5))
- Dont output null age - ([c2c21d1](https://code.thetadev.de/HSA/Visitenbuch/commit/c2c21d1296c399324cdfa661c490cee79f7e16e1))
## [v0.2.0](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.1.0..v0.2.0) - 2024-05-06
### 🚀 Features

View file

@ -1,6 +1,6 @@
{
"name": "visitenbuch",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -21,7 +21,7 @@
"@floating-ui/core": "^1.6.1",
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.13.0",
"carta-md": "4.0.2",
"@thetadev/carta-md": "^1.0.1",
"diff": "^5.2.0",
"isomorphic-dompurify": "^2.9.0",
"prisma": "^5.13.0",
@ -71,10 +71,5 @@
"vite": "^5.2.11",
"vitest": "^1.6.0"
},
"type": "module",
"pnpm": {
"patchedDependencies": {
"carta-md@4.0.2": "patches/carta-md@4.0.2.patch"
}
}
"type": "module"
}

View file

@ -1,27 +0,0 @@
# Change "markdown-body" css class to "prose" for Tailwind compatibility
diff --git a/dist/Markdown.svelte b/dist/Markdown.svelte
index 92b29fade303a14539720b9bc389e7a41202b1cf..cbdeede16d7af17a481bcac519aea92b8959803d 100644
--- a/dist/Markdown.svelte
+++ b/dist/Markdown.svelte
@@ -15,7 +15,7 @@ onMount(async () => {
});
</script>
-<div bind:this={elem} class="carta-viewer carta-theme__{theme} markdown-body">
+<div bind:this={elem} class="carta-viewer carta-theme__{theme} prose">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html rendered}
{#if mounted}
diff --git a/dist/internal/components/Renderer.svelte b/dist/internal/components/Renderer.svelte
index 1d2ff1a6937bc490ea1e6eb5c2ef9f3b33e4c326..6a95c154ea4ca7c4d19b20d02b3504fb2b65b7f7 100644
--- a/dist/internal/components/Renderer.svelte
+++ b/dist/internal/components/Renderer.svelte
@@ -17,7 +17,7 @@ onMount(() => carta.$setRenderer(elem));
onMount(() => mounted = true);
</script>
-<div bind:this={elem} on:scroll={handleScroll} class="carta-renderer markdown-body">
+<div bind:this={elem} on:scroll={handleScroll} class="carta-renderer prose">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderedHtml}
{#if mounted}

View file

@ -4,11 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
carta-md@4.0.2:
hash: i33ea43vfgrg3ziu25cfu7s2zq
path: patches/carta-md@4.0.2.patch
dependencies:
'@auth/core':
specifier: ^0.30.0
@ -22,9 +17,9 @@ dependencies:
'@prisma/client':
specifier: ^5.13.0
version: 5.13.0(prisma@5.13.0)
carta-md:
specifier: 4.0.2
version: 4.0.2(patch_hash=i33ea43vfgrg3ziu25cfu7s2zq)(svelte@4.2.15)
'@thetadev/carta-md':
specifier: ^1.0.1
version: 1.0.1(svelte@4.2.15)
diff:
specifier: ^5.2.0
version: 5.2.0
@ -889,10 +884,6 @@ packages:
dev: true
optional: true
/@shikijs/core@1.4.0:
resolution: {integrity: sha512-CxpKLntAi64h3j+TwWqVIQObPTED0FyXLHTTh3MKXtqiQNn2JGcMQQ362LftDbc9kYbDtrksNMNoVmVXzKFYUQ==}
dev: false
/@sideway/address@4.1.5:
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
requiresBuild: true
@ -1088,6 +1079,22 @@ packages:
tailwindcss: 3.4.3
dev: true
/@thetadev/carta-md@1.0.1(svelte@4.2.15):
resolution: {integrity: sha512-B6TyB5gvrc1T5pEOM5nIZA+N6gG7EK7AJBrQCfokzrpNarq7CvgJEVaQoWh5oukB2MrRYSIQAYUNIoqEKzMSag==}
peerDependencies:
svelte: ^3.54.0 || ^4.0.0
dependencies:
prismjs: 1.29.0
rehype-stringify: 10.0.0
remark-gfm: 4.0.0
remark-parse: 11.0.0
remark-rehype: 11.1.0
svelte: 4.2.15
unified: 11.0.4
transitivePeerDependencies:
- supports-color
dev: false
/@trpc/client@10.45.2(@trpc/server@10.45.2):
resolution: {integrity: sha512-ykALM5kYWTLn1zYuUOZ2cPWlVfrXhc18HzBDyRhoPYN0jey4iQHEFSEowfnhg1RvYnrAVjNBgHNeSAXjrDbGwg==}
peerDependencies:
@ -1757,23 +1764,6 @@ packages:
resolution: {integrity: sha512-RHVYKov7IcdNjVHJFNY/78RdG4oGVjbayxv8u5IO74Wv7Hlq4PnJE6mo/OjFijjVFNy5ijnCt6H3IIo4t+wfEw==}
dev: true
/carta-md@4.0.2(patch_hash=i33ea43vfgrg3ziu25cfu7s2zq)(svelte@4.2.15):
resolution: {integrity: sha512-wMlw0r5RZiVwvF3dyxE/vHj9pXlXbzpijJ3m/o9zqZe7Cf6D96AjyBHBpa0A0OPj/uEJVF3k0R6ctopBJCpCQg==}
peerDependencies:
svelte: ^3.54.0 || ^4.0.0
dependencies:
rehype-stringify: 10.0.0
remark-gfm: 4.0.0
remark-parse: 11.0.0
remark-rehype: 11.1.0
shiki: 1.4.0
svelte: 4.2.15
unified: 11.0.4
transitivePeerDependencies:
- supports-color
dev: false
patched: true
/ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
dev: false
@ -4424,6 +4414,11 @@ packages:
'@prisma/engines': 5.13.0
dev: false
/prismjs@1.29.0:
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
engines: {node: '>=6'}
dev: false
/property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
requiresBuild: true
@ -4721,12 +4716,6 @@ packages:
engines: {node: '>=8'}
dev: true
/shiki@1.4.0:
resolution: {integrity: sha512-5WIn0OL8PWm7JhnTwRWXniy6eEDY234mRrERVlFa646V2ErQqwIFd2UML7e0Pq9eqSKLoMa3Ke+xbsF+DAuy+Q==}
dependencies:
'@shikijs/core': 1.4.0
dev: false
/side-channel@1.0.6:
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
engines: {node: '>= 0.4'}

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "entry_executions" ADD COLUMN "done" BOOLEAN NOT NULL DEFAULT true;

View file

@ -142,6 +142,7 @@ model EntryExecution {
entry_id Int
text String
done Boolean @default(true)
author User @relation(fields: [author_id], references: [id])
author_id Int

View file

@ -39,7 +39,7 @@
<p class="text-sm flex gap-2">
<span>Erstellt {humanDate(entry.created_at, true)}</span>
<span>·</span>
{#if entry.execution}
{#if entry.execution?.done}
<span>Erledigt {humanDate(entry.execution.created_at)}</span>
{:else}
<span>Zu erledigen {humanDate(entry.current_version.date)}</span>
@ -53,7 +53,7 @@
Beschreibung
<div>
<VersionsButton href="{basePath}/versions" n={entry.n_versions} />
<a class="btn btn-circle btn-sm btn-ghost" href="{basePath}/edit">
<a class="btn btn-circle btn-xs btn-ghost" href="{basePath}/edit">
<Icon path={mdiPencil} size={1.2} />
</a>
</div>
@ -69,9 +69,12 @@
{#if withExecution && entry.execution}
<div class="card2">
<div class="row c-light text-sm items-center justify-between">
<div
class="row c-light text-sm items-center justify-between"
class:done={entry.execution.done}
class:note={!entry.execution.done}>
<p>
Erledigt am {formatDate(entry.execution.created_at, true)} von
{entry.execution.done ? "Erledigt am" : "Notiz von"} {formatDate(entry.execution.created_at, true)} von
<UserField filterName="executor" user={entry.execution.author} />
</p>
<div>
@ -88,3 +91,12 @@
{/if}
</div>
{/if}
<style lang="postcss">
.done {
@apply bg-success/20;
}
.note {
@apply bg-warning/20;
}
</style>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type { SortRequest } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate } from "$lib/shared/util";
@ -10,8 +9,8 @@
import UserField from "./UserField.svelte";
export let entries: RouterOutput["entry"]["list"];
export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let sortData: string[] | undefined;
export let sortUpdate: (sort: string[] | undefined) => void = () => {};
export let perPatient = false;
export let baseUrl: string;
</script>
@ -38,7 +37,7 @@
{#each entries.items as entry (entry.id)}
<tr
class="transition-colors hover:bg-neutral-content/10"
class:done={entry.execution}
class:done={entry.execution?.done}
class:priority={entry.current_version.priority}
>
<td
@ -67,7 +66,7 @@
<td><UserField {baseUrl} user={entry.current_version.author} /></td>
<td><span class="line-clamp-2">{entry.current_version.text}</span></td>
<td>
{#if entry.execution}
{#if entry.execution?.done}
{formatDate(entry.execution.created_at, true)}
<UserField
{baseUrl}

View file

@ -5,7 +5,7 @@
import { z } from "zod";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { PaginationRequest } from "$lib/shared/model";
import type { ZEntriesQuery } from "$lib/shared/model/validation";
import { type RouterOutput } from "$lib/shared/trpc";
import { getQueryUrl } from "$lib/shared/util";
@ -34,7 +34,7 @@
updateQuery({ filter, sort: query.sort });
}
function sortUpdate(sort: SortRequest | undefined): void {
function sortUpdate(sort: string[] | undefined): void {
updateQuery({
filter: query.filter,
pagination: query.pagination,

View file

@ -5,7 +5,7 @@
import { z } from "zod";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { PaginationRequest } from "$lib/shared/model";
import type { ZPatientsQuery } from "$lib/shared/model/validation";
import { type RouterOutput } from "$lib/shared/trpc";
import { getQueryUrl } from "$lib/shared/util";
@ -32,7 +32,7 @@
updateQuery({ filter, sort: query.sort });
}
function sortUpdate(sort: SortRequest | undefined): void {
function sortUpdate(sort: string[] | undefined): void {
updateQuery({
filter: query.filter,
pagination: query.pagination,

View file

@ -2,7 +2,6 @@
import { mdiFilter } from "@mdi/js";
import { URL_ENTRIES } from "$lib/shared/constants";
import type { SortRequest } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate, gotoEntityQuery } from "$lib/shared/util";
@ -12,8 +11,8 @@
import SortHeader from "./SortHeader.svelte";
export let patients: RouterOutput["patient"]["list"];
export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let sortData: string[] | undefined;
export let sortUpdate: (sort: string[] | undefined) => void = () => {};
export let baseUrl: string;
</script>

View file

@ -1,29 +1,33 @@
<script lang="ts">
import { mdiSortAscending, mdiSortDescending } from "@mdi/js";
import type { SortRequest } from "$lib/shared/model";
import Icon from "$lib/components/ui/Icon.svelte";
export let key: string;
export let title: string;
export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let sortData: string[] | undefined;
export let sortUpdate: (sort: string[] | undefined) => void = () => {};
// 1: asc, 2: desc, 0: not sorted
let sorting = 0;
$: if (sortData?.field === key) {
sorting = sortData.asc !== false ? 1 : 2;
$: index = sortData?.findIndex((itm) => itm.split(":", 1)[0] === key) ?? -1;
$: if (index !== -1) {
sorting = sortData![index].split(":", 2)[1] === "dsc" ? 2 : 1;
} else {
sorting = 0;
}
function onClick(): void {
if (sorting === 2) {
sortUpdate(undefined);
if (!sortData) {
sortData = [key];
} else if (sorting === 2) {
delete sortData[index];
} else if (index !== -1) {
sortData[index] = sortData[index].split(":", 1) + ":dsc";
} else {
sortUpdate({ field: key, asc: sorting !== 1 });
sortData.push(key);
}
sortUpdate(sortData);
}
</script>
@ -32,6 +36,9 @@
{#if sorting > 0}
<Icon path={sorting === 1 ? mdiSortAscending : mdiSortDescending} size={1} />
{/if}
{#if sortData && sortData.length > 1 && index !== -1}
({index + 1})
{/if}
{title}
</button>
</th>

View file

@ -0,0 +1,11 @@
<div class="join">
{#each { length: 4 } as _, i}
<button name="todo" class="join-item btn btn-sm" type="submit" value={i}>
{#if i === 0}
Notiz
{:else}
+{i}T
{/if}
</button>
{/each}
</div>

View file

@ -1,10 +1,15 @@
<script lang="ts">
import { Carta, Markdown } from "carta-md";
import { Carta } from "@thetadev/carta-md";
import { CARTA_CFG } from "./carta";
export let src: string;
const carta = new Carta(CARTA_CFG);
$: rendered = carta.renderSSR(src);
</script>
<Markdown {carta} value={src} />
<div class="carta-viewer carta-theme__default prose">
<!-- eslint-disable-next-line svelte/no-at-html-tags HTML is sanitized -->
{@html rendered}
</div>

View file

@ -1,11 +1,13 @@
<script lang="ts">
import "./carta.pcss";
import { Carta, MarkdownEditor, type Labels } from "carta-md";
import { Carta, MarkdownEditor, type Labels } from "@thetadev/carta-md";
import type { InputConstraint } from "sveltekit-superforms";
import { CARTA_CFG } from "./carta";
import "@thetadev/carta-md/highlight.css";
export let label = "";
export let name = "";
export let value = "";

View file

@ -123,7 +123,7 @@
}
@media (prefers-color-scheme: dark) {
.shiki, .shiki span {
color: var(--shiki-dark) !important;
.carta-highlight, .carta-highlight span {
color: var(--hl-dark) !important;
}
}

View file

@ -1,4 +1,4 @@
import type { Options } from "carta-md";
import type { Options } from "@thetadev/carta-md";
import { sanitizeHtml } from "$lib/shared/util";

View file

@ -8,15 +8,16 @@ import type {
EntryVersionNew,
Pagination,
PaginationRequest,
SortRequest,
} from "$lib/shared/model";
import { DateRange, normalizeLineEndings, utcDateToYMD } from "$lib/shared/util";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
import { ErrorConflict } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma";
import { mapEntry, mapVersion, mapExecution } from "./mapping";
import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util";
import {
QueryBuilder, filterListToArray, mapSortFields, parseSearchQuery,
} from "./util";
const USER_SELECT = { select: { id: true, name: true } };
@ -162,7 +163,7 @@ export async function newEntryExecution(
}
// Check if there are any updates
if (execution.text === cex?.text) {
if (execution.text === cex?.text && execution.done === cex?.done) {
return cex.id;
}
@ -171,6 +172,7 @@ export async function newEntryExecution(
entry_id,
author_id,
text: execution.text,
done: execution.done,
},
select: { id: true },
});
@ -181,7 +183,7 @@ export async function newEntryExecution(
export async function getEntries(
filter: EntriesFilter = {},
pagination: PaginationRequest = {},
sort: SortRequest = { field: "created_at", asc: false },
sort: string[] = [],
): Promise<Pagination<Entry>> {
const qb = new QueryBuilder(
`select
@ -199,6 +201,7 @@ export async function getEntries(
c.color as category_color,
ex.id as execution_id,
ex.text as execution_text,
ex.done as execution_done,
ex.created_at as execution_created_at,
xau.id as execution_author_id,
xau.name as execution_author_name,
@ -254,9 +257,9 @@ left join stations s on s.id = r.station_id`,
}
if (filter?.done === true) {
qb.addFilterClause("ex.id is not null");
qb.addFilterClause("ex.done");
} else if (filter?.done === false) {
qb.addFilterClause("ex.id is null");
qb.addFilterClause("(ex.id is null or not ex.done)");
}
qb.addFilterList("xau.id", filter?.executor);
@ -299,13 +302,11 @@ left join stations s on s.id = r.station_id`,
date: ["ev.date"],
author: ["vau.name"],
executor: ["xau.name"],
priority: ["ev.priority"],
};
const sortFields = SORT_FIELDS[sort.field ?? "updated_at"];
if (!sortFields) {
throw new ErrorInvalidInput(`cannot sort by "${sort.field}"`);
}
qb.orderByFields(sortFields, sort.asc);
const sortFields = mapSortFields(sort, "created_at", SORT_FIELDS);
qb.orderByFields(sortFields);
qb.setPagination(pagination);
type RowItem = {
@ -323,6 +324,7 @@ left join stations s on s.id = r.station_id`,
category_color: string;
execution_id: number;
execution_text: string;
execution_done: boolean;
execution_created_at: Date;
execution_author_id: number;
execution_author_name: string;
@ -383,6 +385,7 @@ left join stations s on s.id = r.station_id`,
id: item.execution_id,
author: { id: item.execution_author_id, name: item.execution_author_name },
text: item.execution_text,
done: item.execution_done,
created_at: item.execution_created_at,
}
: null,

View file

@ -82,6 +82,7 @@ export function mapExecution(execution: DbEntryExecutionLn): EntryExecution {
author: execution.author,
created_at: execution.created_at,
text: execution.text,
done: execution.done,
};
}

View file

@ -5,14 +5,12 @@ import type {
PaginationRequest,
PatientsFilter,
PatientTag,
SortRequest,
} from "$lib/shared/model";
import { ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma";
import { mapPatient } from "./mapping";
import { QueryBuilder, handleDeleteConflict } from "./util";
import { QueryBuilder, handleDeleteConflict, mapSortFields } from "./util";
export async function newPatient(patient: PatientNew): Promise<number> {
const created = await prisma.patient.create({ data: patient, select: { id: true } });
@ -69,7 +67,7 @@ export async function getPatientNEntries(id: number): Promise<number> {
export async function getPatients(
filter: PatientsFilter = {},
pagination: PaginationRequest = {},
sort: SortRequest = { field: "created_at", asc: false },
sort: string[] = [],
): Promise<Pagination<Patient>> {
const qb = new QueryBuilder(
`select p.id, p.first_name, p.last_name, p.created_at, p.age, p.hidden,
@ -107,11 +105,8 @@ export async function getPatients(
created_at: ["p.created_at"],
};
const sortFields = SORT_FIELDS[sort.field ?? "created_at"];
if (!sortFields) {
throw new ErrorInvalidInput(`cannot sort by "${sort.field}"`);
}
qb.orderByFields(sortFields, sort.asc);
const sortFields = mapSortFields(sort, "created_at", SORT_FIELDS);
qb.orderByFields(sortFields);
qb.setPagination(pagination);
type RowItem = {

View file

@ -1,6 +1,6 @@
import { expect, test } from "vitest";
import { QueryBuilder, parseSearchQuery } from "./util";
import { QueryBuilder, mapSortFields, parseSearchQuery } from "./util";
test("query builder", () => {
const qb = new QueryBuilder("select e.id, e.text, e.category", "from entries e");
@ -56,3 +56,19 @@ test("parse search query", () => {
"'hello' <-> 'world' & 'banana' & !'cherry' & !('b' <-> 'x' <-> 'y') & 'vis':*",
);
});
test("mapSortFields", () => {
const SORT_FIELDS: Record<string, string[]> = {
id: ["e.id"],
patient: ["p.last_name", "p.first_name"],
priority: ["ev.priority"],
};
const fields = ["id", "patient", "priority:dsc"];
const res = mapSortFields(fields, "", SORT_FIELDS);
expect(res).toStrictEqual([
{ name: "e.id", asc: true },
{ name: "p.last_name", asc: true },
{ name: "p.first_name", asc: true },
{ name: "ev.priority", asc: false },
]);
});

View file

@ -2,7 +2,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { FilterList, PaginationRequest } from "$lib/shared/model";
import { ErrorConflict } from "$lib/shared/util/error";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
enum QueryComponentType {
Normal = 1,
@ -37,6 +37,34 @@ export async function handleDeleteConflict(act: Promise<unknown>, msg: string):
}
}
export type SortField = {
name: string,
asc: boolean,
};
/** Map sort fields from request to a list of database columns for the query builder */
export function mapSortFields(
fields: string[],
defaultFilter: string,
mapping: Record<string, string[]>,
): SortField[] {
if (!fields || fields.length === 0) fields = [defaultFilter];
return fields.flatMap((sf) => {
const [fname, fdir] = sf.split(":", 2);
const mapped = mapping[fname];
if (!mapped) {
throw new ErrorInvalidInput(`cannot sort by "${sf}"`);
}
let asc = true;
if (fdir) {
if (fdir === "dsc") asc = false;
else if (fdir !== "asc") throw new ErrorInvalidInput("Direction must be asc/dsc");
}
return mapped.map((name) => ({ name, asc }));
});
}
class SearchQueryComponent {
word: string;
@ -157,10 +185,12 @@ export class QueryBuilder {
this.orderClauses.push(orderClause);
}
orderByFields(fields: string[], asc: boolean | undefined = undefined): void {
const sortDir = asc === false ? " desc" : " asc";
const orderClause = fields.join(`${sortDir}, `) + sortDir;
this.addOrderClause(orderClause);
orderByFields(fields: SortField[]): void {
const clauseParts = fields.map((f) => {
const sortDir = f.asc ? " asc" : " desc";
return f.name + sortDir;
});
this.addOrderClause(clauseParts.join(", "));
}
/** Get the next parameter variable (e.g. $1) and increment the counter */

View file

@ -39,7 +39,7 @@ export const entryRouter = t.router({
.query(async (opts) => trpcWrap(async () => getEntries(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {},
opts.input.sort ?? [],
))),
versions: t.procedure
.input(ZEntityId)

View file

@ -31,7 +31,7 @@ export const patientRouter = t.router({
.query(async (opts) => getPatients(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {},
opts.input.sort ?? [],
)),
create: t.procedure
.input(ZPatientNew)

View file

@ -130,11 +130,13 @@ export type EntryExecution = {
id: number;
author: UserTag;
text: string;
done: boolean;
created_at: Date;
};
export type EntryExecutionNew = {
text: string;
done: boolean;
};
export type SavedFilter = {

View file

@ -1,7 +1,7 @@
export type EntityQuery = Partial<{
filter: EntriesFilter | PatientsFilter;
pagination: PaginationRequest;
sort: SortRequest;
sort: string[];
}>;
export type PaginationRequest = Partial<{
@ -9,11 +9,6 @@ export type PaginationRequest = Partial<{
offset: number | undefined;
}>;
export type SortRequest = Partial<{
field: string;
asc: boolean;
}>;
export type FilterList<T> = T | T[] | { id: T; name?: string }[];
export type EntriesFilter = Partial<{

View file

@ -106,7 +106,7 @@ export const ZEntryVersionNew = implement<EntryVersionNew>().with({
export const ZEntryNew = implement<EntryNew>().with({
patient_id: fields.EntityId(),
version: implement<EntryVersionNdata>().with({
text: fields.TextString(),
text: z.string(),
date: fields.DateString(),
category_id: fields.EntityId().nullable(),
priority: z.boolean(),
@ -114,7 +114,8 @@ export const ZEntryNew = implement<EntryNew>().with({
});
export const ZEntryExecutionNew = implement<EntryExecutionNew>().with({
text: fields.TextString(),
text: z.string(),
done: z.boolean(),
});
export const ZPagination = implement<PaginationRequest>().with({
@ -122,11 +123,6 @@ export const ZPagination = implement<PaginationRequest>().with({
offset: coercedUint.optional(),
});
export const ZSort = z.object({
field: z.string().optional(),
asc: coercedBool.optional(),
});
const ZFilterListEntry = z.object({
id: coercedUint,
name: fields.NameString().optional(),
@ -136,7 +132,7 @@ const paginatedQuery = <T extends z.ZodTypeAny>(f: T) => z
.object({
filter: f,
pagination: ZPagination,
sort: ZSort,
sort: z.array(z.string()),
})
.partial();

View file

@ -25,6 +25,7 @@ export type EntryExecutionChange = {
created_at: Date;
text: Diff.Change[];
done?: boolean;
};
function newOrUndef<T>(o: T, n: T): T | undefined {
@ -83,6 +84,7 @@ export function executionsDiff(executions: EntryExecution[]): EntryExecutionChan
author: v.author,
created_at: v.created_at,
text,
done: v.done,
});
prev = v;

View file

@ -16,7 +16,7 @@ it("getQueryUrl", () => {
search: "Hello World",
},
pagination: { limit: 10, offset: 20 },
sort: { field: "room", asc: true },
sort: ["room"],
};
const queryUrl = getQueryUrl(query, "");

View file

@ -5,13 +5,14 @@ import { TRPCClientError } from "@trpc/client";
import DOMPurify from "isomorphic-dompurify";
import qs from "qs";
import type { FormOptions } from "sveltekit-superforms";
import type { TRPCClientInit } from "trpc-sveltekit";
import { ZodError } from "zod";
import { DEFAULT_FILTER_NAME, URL_VISIT } from "$lib/shared/constants";
import type { EntityQuery, SavedFilter } from "$lib/shared/model";
import { type RouterOutput } from "$lib/shared/trpc";
import { trpc, type RouterOutput } from "$lib/shared/trpc";
import { DateRange } from "./date";
import { DateRange, utcDateToYMD } from "./date";
import { toastError, toastInfo } from "./toast";
export function formatBool(val: boolean): string {
@ -156,3 +157,19 @@ export function defaultVisitUrl(): string {
},
}, URL_VISIT);
}
export async function moveEntryTodoDate(id: number, nTodoDays: number, init?: TRPCClientInit) {
if (nTodoDays > 0) {
const entry = await trpc(init).entry.get.query(id);
const newDate = new Date(entry.current_version.date);
newDate.setDate(newDate.getDate() + nTodoDays);
await trpc(init).entry.newVersion.mutate({
id,
old_version_id: entry.current_version.id,
version: {
date: utcDateToYMD(newDate),
},
});
}
}

View file

@ -5,23 +5,35 @@ import { superValidate, message } from "sveltekit-superforms";
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { loadWrap, moveEntryTodoDate } from "$lib/shared/util";
import { SchemaEntryExecution } from "./schema";
import { SchemaNewExecution } from "./editExecution/schema";
export const actions: Actions = {
default: async (event) => loadWrap(async () => {
const form = await superValidate(event.request, SchemaEntryExecution);
const formData = await event.request.formData();
const form = await superValidate(formData, SchemaNewExecution);
if (!form.valid) {
return fail(400, { form });
}
const id = ZUrlEntityId.parse(event.params.id);
const todoDays = formData.get("todo");
const done = todoDays === null;
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
await loadWrap(async () => {
await trpc(event).entry.newExecution.mutate({
id,
old_execution_id: null,
execution: { text: form.data.text },
old_execution_id: form.data.old_execution_id,
execution: { text: form.data.text, done: todoDays === null },
});
return message(form, "Eintrag erledigt");
await moveEntryTodoDate(id, nTodoDays, event);
});
if (nTodoDays > 0) {
return message(form, `Eintrag um ${nTodoDays} Tage in die Zukunft verschoben`);
}
return message(form, done ? "Eintrag erledigt" : "Eintrag mit Notiz versehen");
}),
};

View file

@ -2,29 +2,29 @@
import type { PageData } from "./$types";
import { defaults, superForm } from "sveltekit-superforms";
import { superForm } from "sveltekit-superforms";
import { superformConfig } from "$lib/shared/util";
import EntryBody from "$lib/components/entry/EntryBody.svelte";
import EntryTodoButton from "$lib/components/ui/EntryTodoButton.svelte";
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
import { SchemaEntryExecution } from "./schema";
import { SchemaNewExecution } from "./editExecution/schema";
export let data: PageData;
const formData = defaults(SchemaEntryExecution);
const {
form, errors, enhance,
} = superForm(formData, {
validators: SchemaEntryExecution,
} = superForm(data.form, {
validators: SchemaNewExecution,
...superformConfig("Eintrag"),
});
</script>
<EntryBody entry={data.entry} withExecution />
{#if !data.entry.execution}
{#if !data.entry.execution?.done}
<form method="POST" use:enhance>
<MarkdownInput
name="text"
@ -33,9 +33,11 @@
label="Eintrag erledigen"
bind:value={$form.text}
>
<div class="row c-vlight">
<div class="row c-vlight gap-2">
<button class="btn btn-sm btn-primary" type="submit">Erledigt</button>
<EntryTodoButton />
</div>
</MarkdownInput>
<input name="old_execution_id" type="hidden" bind:value={$form.old_execution_id} />
</form>
{/if}

View file

@ -1,14 +1,26 @@
import type { PageLoad } from "./$types";
import { superValidate } from "sveltekit-superforms";
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { SchemaNewExecution } from "./editExecution/schema";
export const load: PageLoad = async (event) => {
const entry = await loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
return trpc(event).entry.get.query(id);
});
return { entry };
const form = await superValidate(
{
old_execution_id: entry.execution?.id,
...entry.execution,
},
SchemaNewExecution,
);
return { entry, form };
};

View file

@ -5,24 +5,33 @@ import { superValidate } from "sveltekit-superforms";
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { loadWrap, moveEntryTodoDate } from "$lib/shared/util";
import { SchemaNewExecution } from "./schema";
export const actions: Actions = {
default: async (event) => loadWrap(async () => {
const form = await superValidate(event.request, SchemaNewExecution);
const formData = await event.request.formData();
const form = await superValidate(formData, SchemaNewExecution);
if (!form.valid) {
return fail(400, { form });
}
const id = ZUrlEntityId.parse(event.params.id);
const todoDays = formData.get("todo");
await trpc(event).entry.newExecution.mutate({
id,
execution: form.data,
execution: {
text: form.data.text,
done: todoDays === null,
},
old_execution_id: form.data.old_execution_id,
});
const nTodoDays = todoDays ? parseInt(todoDays.toString()) : 0;
await moveEntryTodoDate(id, nTodoDays, event);
redirect(302, `/entry/${id}`);
}),
};

View file

@ -2,19 +2,19 @@
import type { PageData } from "./$types";
import { defaults, superForm } from "sveltekit-superforms";
import { superForm } from "sveltekit-superforms";
import { superformConfig } from "$lib/shared/util";
import EntryBody from "$lib/components/entry/EntryBody.svelte";
import EntryTodoButton from "$lib/components/ui/EntryTodoButton.svelte";
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
import { SchemaNewExecution } from "./schema";
export let data: PageData;
const formData = defaults(SchemaNewExecution);
const { form, errors, enhance } = superForm(formData, {
const { form, errors, enhance } = superForm(data.form, {
validators: SchemaNewExecution,
...superformConfig("Eintrag"),
});
@ -30,8 +30,10 @@
label="Ausführungstext bearbeiten"
bind:value={$form.text}
>
<div class="row c-vlight">
<div class="row c-vlight gap-2">
<button class="btn btn-sm btn-primary" type="submit">Speichern</button>
<EntryTodoButton />
</div>
</MarkdownInput>
<input name="old_execution_id" type="hidden" bind:value={$form.old_execution_id} />
</form>

View file

@ -22,6 +22,9 @@
<div class="row c-light text-sm">
#{i + 1}&nbsp;
<UserField user={version.author} />, {formatDate(version.created_at, true)}
{#if !version.done}
<div class="badge ellipsis badge-warning ml-2">Notiz</div>
{/if}
</div>
{#if version.text.length > 0}
<div class="rowb whitespace-pre-wrap">

View file

@ -22,7 +22,7 @@ export const load: PageLoad = async (event) => {
// Sort entries by date
if (!query.sort) {
query.sort = { field: "date" };
query.sort = ["priority:dsc", "date"];
}
const entries = await trpc(event).entry.list.query(query);

View file

@ -57,8 +57,8 @@ async function insertTestEntries() {
});
// Execute entries
await newEntryExecution(1, eId2, { text: "Some execution txt" });
await newEntryExecution(2, eId3, { text: "More execution txt" });
await newEntryExecution(1, eId2, { text: "Some execution txt", done: true });
await newEntryExecution(2, eId3, { text: "More execution txt", done: true });
return { eId1, eId2, eId3 };
}
@ -166,7 +166,7 @@ test("create entry execution", async () => {
});
const text = "Blutabnahme erledigt.";
const xId = await newEntryExecution(1, eId, { text }, null);
const xId = await newEntryExecution(1, eId, { text, done: true }, null);
const entry = await getEntry(eId);
expect(entry.execution?.id).toBe(xId);
@ -183,8 +183,8 @@ test("create entry execution (update)", async () => {
version: TEST_VERSION,
});
const x1 = await newEntryExecution(1, eId, { text: "x1" }, null);
const x2 = await newEntryExecution(2, eId, { text: "x2" }, x1);
const x1 = await newEntryExecution(1, eId, { text: "x1", done: true }, null);
const x2 = await newEntryExecution(2, eId, { text: "x2", done: true }, x1);
const entry = await getEntry(eId);
expect(entry.execution?.id).toBe(x2);
@ -200,9 +200,9 @@ test("create entry execution (wrong old xid)", async () => {
patient_id: 1,
version: TEST_VERSION,
});
const x1 = await newEntryExecution(1, eId, { text: "x1" }, null);
const x1 = await newEntryExecution(1, eId, { text: "x1", done: true }, null);
expect(async () => newEntryExecution(1, eId, { text: "x2" }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match"));
expect(async () => newEntryExecution(1, eId, { text: "x2", done: true }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match"));
});
test("get entries", async () => {
@ -246,8 +246,8 @@ test("get entries", async () => {
const entriesPatient = await getEntries({ patient: 1 }, {});
expect(entriesPatient.items).length(2);
expect(entriesPatient.total).toBe(2);
expect(entriesPatient.items[0].id).toBe(eId3);
expect(entriesPatient.items[1].id).toBe(eId1);
expect(entriesPatient.items[0].id).toBe(eId1);
expect(entriesPatient.items[1].id).toBe(eId3);
// Filter by room
const entriesRoom = await getEntries({ room: 1 }, {});
@ -257,8 +257,8 @@ test("get entries", async () => {
const entriesDone = await getEntries({ done: true }, {});
expect(entriesDone.items).length(2);
expect(entriesDone.total).toBe(2);
expect(entriesDone.items[0].id).toBe(eId3);
expect(entriesDone.items[1].id).toBe(eId2);
expect(entriesDone.items[0].id).toBe(eId2);
expect(entriesDone.items[1].id).toBe(eId3);
// Filter not done
const entriesNotDone = await getEntries({ done: false }, {});
@ -281,8 +281,8 @@ test("get entries", async () => {
const entriesDateRange = await getEntries({ date: "2024-01-05..2024-01-06" });
expect(entriesDateRange.items).length(2);
expect(entriesDateRange.total).toBe(2);
expect(entriesDateRange.items[0].id).toBe(eId3);
expect(entriesDateRange.items[1].id).toBe(eId2);
expect(entriesDateRange.items[0].id).toBe(eId2);
expect(entriesDateRange.items[1].id).toBe(eId3);
// Search
const entriesSearch = await getEntries({ search: "Blu" }, {});
@ -293,6 +293,30 @@ test("get entries", async () => {
// NTodo
const n = await getNTodo(new Date("2024-01-05"));
expect(n).toBe(1);
// Sort by ID
const entriesSortedId = await getEntries({}, {}, ["id"]);
const entriesSortedIdDsc = await getEntries({}, {}, ["id:dsc"]);
expect(entriesSortedId.total).toBe(3);
expect(entriesSortedIdDsc.total).toBe(3);
for (let i = 0; i < 3; i++) {
expect(entriesSortedId.items[i]).toStrictEqual(
entriesSortedIdDsc.items[entriesSortedIdDsc.items.length - i - 1],
);
}
// Sort by patient and ID
const entriesSortedPatientId = await getEntries({}, {}, ["patient", "id"]);
expect(entriesSortedPatientId.items.length).toBe(3);
expect(entriesSortedPatientId.items[0].id).toBe(eId1);
expect(entriesSortedPatientId.items[1].id).toBe(eId3);
expect(entriesSortedPatientId.items[2].id).toBe(eId2);
const entriesSortedPatientIdDsc = await getEntries({}, {}, ["patient:dsc", "id"]);
expect(entriesSortedPatientIdDsc.items.length).toBe(3);
expect(entriesSortedPatientIdDsc.items[0].id).toBe(eId2);
expect(entriesSortedPatientIdDsc.items[1].id).toBe(eId1);
expect(entriesSortedPatientIdDsc.items[2].id).toBe(eId3);
});
test("get patient n entries", async () => {
@ -304,3 +328,18 @@ test("get category n entries", async () => {
await insertTestEntries();
expect(await getCategoryNEntries(3)).toBe(1);
});
test("todo execution", async () => {
const eId = await newEntry(1, {
patient_id: 1,
version: TEST_VERSION,
});
const n1 = await newEntryExecution(1, eId, { text: "note1", done: false }, null);
const entry = await getEntry(eId);
expect(entry.execution?.id).toBe(n1);
expect(entry.execution?.text).toBe("note1");
expect(entry.execution?.author.id).toBe(1);
expect(entry.execution?.done).toBe(false);
});

View file

@ -11,7 +11,7 @@ import {
} from "$lib/server/query";
import { S1, S2 } from "$tests/helpers/testdata";
const SORT_ID = { field: "id" };
const SORT_ID = ["id"];
test("create patient", async () => {
const pId = await newPatient({