diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..595addb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. + + +## [v0.2.0](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.1.0..v0.2.0) - 2024-05-06 + +### 🚀 Features + +- Add toast error messages - ([80c6243](https://code.thetadev.de/HSA/Visitenbuch/commit/80c6243e2b1fe1c8c92b57809c094219be666065)) +- Improve week selector, select all TODO items by default - ([21f145f](https://code.thetadev.de/HSA/Visitenbuch/commit/21f145f5f08e4f44beecd3d373dfc69e5fc94f23)) +- Use global store for saved filters, add default filters - ([f36ae71](https://code.thetadev.de/HSA/Visitenbuch/commit/f36ae71d32ebcfc5054dca9c6907ffc1554f39e3)) +- Hide rooms/stations/categories - ([2cb8dce](https://code.thetadev.de/HSA/Visitenbuch/commit/2cb8dce51a880d46af3a77ee9cbdfce680b88755)) +- Add about page, licenses - ([5230ee3](https://code.thetadev.de/HSA/Visitenbuch/commit/5230ee375c7b25e577dc2ae045309d8cd54cfdf4)) + +### 🐛 Bug Fixes + +- Date timezone issue, refactor utils - ([efb0e24](https://code.thetadev.de/HSA/Visitenbuch/commit/efb0e246127489d618ca6e3ea50f10dd1140a954)) +- Normalize line endings - ([799dbc4](https://code.thetadev.de/HSA/Visitenbuch/commit/799dbc4097b5cf72e813e67b6ca6ccfe6ab5f08b)) +- Light/dark theme - ([8630b6a](https://code.thetadev.de/HSA/Visitenbuch/commit/8630b6a32d850e93ff1b426a92f68400f7b2f666)) +- Not using UTC dates for parsing date ranges in backend - ([519ae01](https://code.thetadev.de/HSA/Visitenbuch/commit/519ae01eeec294f7522c5ad9bcf8f2b6b44539fb)) + +### 🚜 Refactor + +- Added return types - ([e6302f3](https://code.thetadev.de/HSA/Visitenbuch/commit/e6302f380b903e3ab3f4ef6b5c6e1483d2939027)) + +### ⚙️ Miscellaneous Tasks + +- Update dependencies - ([e3f7341](https://code.thetadev.de/HSA/Visitenbuch/commit/e3f7341a0e575a9b0cb922c9b697c21f6f6875c6)) +- Add git-cliff - ([1b96a46](https://code.thetadev.de/HSA/Visitenbuch/commit/1b96a46dcf2b34318bed5fc383a839a6d7cfd25e)) + +### Build + +- Remove docker multistage build, add entrypoint - ([be52e70](https://code.thetadev.de/HSA/Visitenbuch/commit/be52e70e8d5abed676c9594f2de0e749b7d2da08)) + + +## [v0.1.0](https://code.thetadev.de/HSA/Visitenbuch/commits/tag/v0.1.0) - 2024-05-06 + +Initial release + + diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..8593819 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,100 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% set repo_url = "https://code.thetadev.de/HSA/Visitenbuch" %}\ +{% if version %}\ + {%set vname = version | split(pat="/") | last %} + {%if previous.version %}\ + ## [{{ vname }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\ + {% else %}\ + ## [{{ vname }}]({{ repo_url }}/commits/tag/{{ version }})\ + {% endif %} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% if previous.version %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }} - \ + ([{{ commit.id | truncate(length=7, end="") }}]({{ repo_url }}/commit/{{ commit.id }}))\ + {% endfor %} +{% endfor %}\ +{% else %} +Initial release +{% endif %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "🚀 Features" }, + { message = "^fix", group = "🐛 Bug Fixes" }, + { message = "^doc", group = "📚 Documentation" }, + { message = "^perf", group = "⚡ Performance" }, + { message = "^refactor", group = "🚜 Refactor" }, + { message = "^style", group = "🎨 Styling" }, + { message = "^test", group = "🧪 Testing" }, + { message = "^chore\\(release\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore", group = "⚙️ Miscellaneous Tasks" }, + { message = "^ci", skip = true }, + { body = ".*security", group = "🛡️ Security" }, + { message = "^revert", group = "◀️ Revert" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +# tag_pattern = "v[0-9].*" +# regex for skipping tags +# skip_tags = "" +# regex for ignoring tags +# ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42 diff --git a/eslint.config.js b/eslint.config.js index 4f58599..4d7656d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -493,7 +493,7 @@ export default [ // specify whether double or single quotes should be used // https://eslint.style/rules/default/quotes - "@stylistic/quotes": ["warn", "double", { avoidEscape: true }], + "@stylistic/quotes": ["warn", "double", { avoidEscape: true, allowTemplateLiterals: true }], // require or disallow use of semicolons instead of ASI // https://eslint.style/rules/default/semi diff --git a/package.json b/package.json index 9ad9517..fbb40e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "visitenbuch", - "version": "0.0.1", + "version": "0.2.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -45,6 +45,7 @@ "@types/node": "^20.12.8", "@types/qs": "^6.9.15", "@types/set-cookie-parser": "^2.4.7", + "@zerodevx/svelte-toast": "^0.9.5", "autoprefixer": "^10.4.19", "daisyui": "^4.10.5", "dotenv": "^16.4.5", @@ -57,6 +58,7 @@ "globals": "^15.1.0", "postcss-import": "^16.1.0", "postcss-nesting": "^12.1.2", + "rollup-license-plugin": "^3.0.0", "svelte": "^4.2.15", "svelte-check": "^3.7.1", "sveltekit-superforms": "^2.13.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1c54b7..e5a6cc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ devDependencies: '@types/set-cookie-parser': specifier: ^2.4.7 version: 2.4.7 + '@zerodevx/svelte-toast': + specifier: ^0.9.5 + version: 0.9.5(svelte@4.2.15) autoprefixer: specifier: ^10.4.19 version: 10.4.19(postcss@8.4.38) @@ -126,6 +129,9 @@ devDependencies: postcss-nesting: specifier: ^12.1.2 version: 12.1.2(postcss@8.4.38) + rollup-license-plugin: + specifier: ^3.0.0 + version: 3.0.0 svelte: specifier: ^4.2.15 version: 4.2.15 @@ -1448,6 +1454,14 @@ packages: pretty-format: 29.7.0 dev: true + /@zerodevx/svelte-toast@0.9.5(svelte@4.2.15): + resolution: {integrity: sha512-JLeB/oRdJfT+dz9A5bgd3Z7TuQnBQbeUtXrGIrNWMGqWbabpepBF2KxtWVhL2qtxpRqhae2f6NAOzH7xs4jUSw==} + peerDependencies: + svelte: ^3.57.0 || ^4.0.0 + dependencies: + svelte: 4.2.15 + dev: true + /acorn-jsx@5.3.2(acorn@8.11.3): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1923,6 +1937,11 @@ packages: - postcss dev: true + /data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dev: true + /data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -2584,6 +2603,14 @@ packages: reusify: 1.0.4 dev: true + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2642,6 +2669,13 @@ packages: mime-types: 2.1.35 dev: false + /formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: true + /fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: true @@ -2697,6 +2731,11 @@ packages: has-symbols: 1.0.3 hasown: 2.0.2 + /get-npm-tarball-url@2.1.0: + resolution: {integrity: sha512-ro+DiMu5DXgRBabqXupW38h7WPZ9+Ad8UjwhvsmmN8w1sU7ab0nzAXvVZ4kqYg57OrqomRtJvepX5/xvFKNtjA==} + engines: {node: '>=12.17'} + dev: true + /get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -3938,6 +3977,20 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: true + + /node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: true + /node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} dev: true @@ -4528,6 +4581,15 @@ packages: glob: 7.2.3 dev: true + /rollup-license-plugin@3.0.0: + resolution: {integrity: sha512-dcTdmq+vgqNrSq8Q7uXWp+49u8Mq4gDGeUUI92/4+STMYDyJmO+H+WFLq8DGLWECZ8ckUYAzlfbOyfmbHBE68w==} + engines: {node: '>=18.0.0'} + dependencies: + get-npm-tarball-url: 2.1.0 + node-fetch: 3.3.2 + spdx-expression-validate: 2.0.0 + dev: true + /rollup@4.17.2: resolution: {integrity: sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4729,6 +4791,27 @@ packages: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} dev: false + /spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + dev: true + + /spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.17 + dev: true + + /spdx-expression-validate@2.0.0: + resolution: {integrity: sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg==} + dependencies: + spdx-expression-parse: 3.0.1 + dev: true + + /spdx-license-ids@3.0.17: + resolution: {integrity: sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==} + dev: true + /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true @@ -5571,6 +5654,11 @@ packages: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} dev: false + /web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + dev: true + /webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} diff --git a/prisma/migrations/20240506134647_hidden_room_station/migration.sql b/prisma/migrations/20240506134647_hidden_room_station/migration.sql new file mode 100644 index 0000000..c7d42bc --- /dev/null +++ b/prisma/migrations/20240506134647_hidden_room_station/migration.sql @@ -0,0 +1,26 @@ +-- DropForeignKey +ALTER TABLE "entry_versions" DROP CONSTRAINT "entry_versions_category_id_fkey"; + +-- DropForeignKey +ALTER TABLE "patients" DROP CONSTRAINT "patients_room_id_fkey"; + +-- DropForeignKey +ALTER TABLE "rooms" DROP CONSTRAINT "rooms_station_id_fkey"; + +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "rooms" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "stations" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false; + +-- AddForeignKey +ALTER TABLE "rooms" ADD CONSTRAINT "rooms_station_id_fkey" FOREIGN KEY ("station_id") REFERENCES "stations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "patients" ADD CONSTRAINT "patients_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rooms"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "entry_versions" ADD CONSTRAINT "entry_versions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38f77ff..b626e89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,9 +49,10 @@ model User { // Hospital station model Station { - id Int @id @default(autoincrement()) - name String - Room Room[] + id Int @id @default(autoincrement()) + name String + Room Room[] + hidden Boolean @default(false) @@map("stations") } @@ -60,9 +61,10 @@ model Station { model Room { id Int @id @default(autoincrement()) name String - station Station @relation(fields: [station_id], references: [id], onDelete: Cascade) + station Station @relation(fields: [station_id], references: [id], onDelete: Restrict) station_id Int Patient Patient[] + hidden Boolean @default(false) @@map("rooms") } @@ -72,7 +74,7 @@ model Patient { first_name String last_name String age Int? - room Room? @relation(fields: [room_id], references: [id], onDelete: SetNull) + room Room? @relation(fields: [room_id], references: [id], onDelete: Restrict) room_id Int? Entry Entry[] hidden Boolean @default(false) @@ -92,6 +94,7 @@ model Category { color String? description String? EntryVersion EntryVersion[] + hidden Boolean @default(false) @@map("categories") } @@ -120,7 +123,7 @@ model EntryVersion { text String date DateTime @db.Date - category Category? @relation(fields: [category_id], references: [id], onDelete: SetNull) + category Category? @relation(fields: [category_id], references: [id], onDelete: Restrict) category_id Int? priority Boolean diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..06abc7b --- /dev/null +++ b/release.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -e + +CHANGELOG="CHANGELOG.md" + +VERSION=$(jq -r '.version' package.json) +TAG="v${VERSION}" +echo "Releasing $TAG:" + +if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi + +CLIFF_ARGS="--tag '${TAG}' --unreleased" +echo "git-cliff $CLIFF_ARGS" +if [ -f "$CHANGELOG" ]; then + eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'" +else + eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'" +fi + +editor "$CHANGELOG" + +git add "$CHANGELOG" +git commit -m "chore(release): release v$VERSION" + +awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG" + +echo "🚀 Run 'git push origin $TAG' to publish" diff --git a/src/app.d.ts b/src/app.d.ts index 0b9d41b..13a994c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -6,7 +6,7 @@ declare global { namespace App { // interface Error {} interface Locals { - session: Session; + session: Session | null; } // interface PageData {} // interface Platform {} @@ -18,6 +18,9 @@ declare global { "on:outclick"?: CompositionEventHandler; } } + + declare const __VERSION__: string; + declare const __LASTMOD__: string; } export {}; diff --git a/src/app.pcss b/src/app.pcss index 7ddf037..244e176 100644 --- a/src/app.pcss +++ b/src/app.pcss @@ -15,6 +15,32 @@ } } +:root { + --toastBackground: oklch(var(--b3)); + --toastColor: oklch(var(--bc)); + --toastLeftBorder: oklch(var(--p)); + --toastBarBackground: oklch(var(--bc) / 0.4); + --toastContainerTop: 4rem; + --toastContainerRight: 1rem; + --toastBarHeight: 3px; +} + +.toast-error { + --toastLeftBorder: oklch(var(--er)); +} + +._toastItem { + border-left: solid 6px var(--toastLeftBorder) !important; +} + +._toastMsg { + white-space: pre-wrap; +} + +.badge { + border: none; +} + button { text-align: initial; } diff --git a/src/lib/actions/outclick.ts b/src/lib/actions/outclick.ts index 8811649..1fc1dcd 100644 --- a/src/lib/actions/outclick.ts +++ b/src/lib/actions/outclick.ts @@ -1,5 +1,7 @@ -export default function clickOutside(node: Element) { - const handleClick = (event: MouseEvent) => { +import type { ActionReturn } from "svelte/action"; + +export default function clickOutside(node: Element): ActionReturn { + const handleClick = (event: MouseEvent): void => { const tnode = event.target as Element; if (!node.contains(tnode)) { diff --git a/src/lib/components/filter/Autocomplete.svelte b/src/lib/components/filter/Autocomplete.svelte index 9a0de09..05badf5 100644 --- a/src/lib/components/filter/Autocomplete.svelte +++ b/src/lib/components/filter/Autocomplete.svelte @@ -1,4 +1,5 @@ @@ -61,19 +82,15 @@ Gespeicherte Filter: - {#if filters} - {#each filters as filter, i (filter.id)} - remove(i)} - onSave={() => update(i)} - > - {filter.name} - - {/each} - {:else} - - {/if} + {#each filters as filter, i (filter.id)} + remove(i)} + onSave={() => update(i)} + > + {filter.name} + + {/each} +{:else if hasEntries} + +{:else} + +{/if} diff --git a/src/lib/components/form/PatientForm.svelte b/src/lib/components/form/PatientForm.svelte index dfdec17..91a6ad5 100644 --- a/src/lib/components/form/PatientForm.svelte +++ b/src/lib/components/form/PatientForm.svelte @@ -7,6 +7,7 @@ import { ZPatientNew } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; import type { RouterOutput } from "$lib/shared/trpc"; + import { superformConfig } from "$lib/shared/util"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import FormField from "$lib/components/ui/FormField.svelte"; @@ -23,6 +24,7 @@ } = superForm(formData, { validators: schema, resetForm: patient === null, + ...superformConfig("Patient"), }); diff --git a/src/lib/components/form/RoomForm.svelte b/src/lib/components/form/RoomForm.svelte index 0fb07ba..c906211 100644 --- a/src/lib/components/form/RoomForm.svelte +++ b/src/lib/components/form/RoomForm.svelte @@ -7,6 +7,7 @@ import { ZRoomNew } from "$lib/shared/model/validation"; import { trpc, type RouterOutput } from "$lib/shared/trpc"; + import { superformConfig } from "$lib/shared/util"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import FormField from "$lib/components/ui/FormField.svelte"; @@ -22,6 +23,7 @@ } = superForm(formData, { validators: schema, resetForm: room === null, + ...superformConfig("Zimmer"), }); diff --git a/src/lib/components/form/StationForm.svelte b/src/lib/components/form/StationForm.svelte index 93c1b93..d8ba548 100644 --- a/src/lib/components/form/StationForm.svelte +++ b/src/lib/components/form/StationForm.svelte @@ -7,6 +7,7 @@ import type { Station } from "$lib/shared/model"; import { ZStationNew } from "$lib/shared/model/validation"; + import { superformConfig } from "$lib/shared/util"; import FormField from "$lib/components/ui/FormField.svelte"; import Header from "$lib/components/ui/Header.svelte"; @@ -21,6 +22,7 @@ } = superForm(formData, { validators: schema, resetForm: station === null, + ...superformConfig("Station"), }); diff --git a/src/lib/components/table/CategoryField.svelte b/src/lib/components/table/CategoryField.svelte index 3bb0505..8c438d7 100644 --- a/src/lib/components/table/CategoryField.svelte +++ b/src/lib/components/table/CategoryField.svelte @@ -13,7 +13,7 @@ ? colorToHex(getTextColor(hexToColor(category.color))) : null; - function onClick(e: MouseEvent) { + function onClick(e: MouseEvent): void { gotoEntityQuery( { filter: { diff --git a/src/lib/components/table/FilteredEntryTable.svelte b/src/lib/components/table/FilteredEntryTable.svelte index 6e5e389..fcb0e38 100644 --- a/src/lib/components/table/FilteredEntryTable.svelte +++ b/src/lib/components/table/FilteredEntryTable.svelte @@ -22,7 +22,7 @@ export let patientId: number | null = null; export let view: string | undefined = undefined; - function paginationUpdate(pagination: PaginationRequest) { + function paginationUpdate(pagination: PaginationRequest): void { updateQuery({ filter: query.filter, pagination, @@ -30,11 +30,11 @@ }); } - function filterUpdate(filter: FilterQdata | undefined) { + function filterUpdate(filter: FilterQdata | undefined): void { updateQuery({ filter, sort: query.sort }); } - function sortUpdate(sort: SortRequest | undefined) { + function sortUpdate(sort: SortRequest | undefined): void { updateQuery({ filter: query.filter, pagination: query.pagination, @@ -42,7 +42,7 @@ }); } - function updateQuery(q: typeof query) { + function updateQuery(q: typeof query): void { if (browser) { if (patientId !== null && q.filter?.patient) delete q.filter.patient; diff --git a/src/lib/components/table/FilteredPatientTable.svelte b/src/lib/components/table/FilteredPatientTable.svelte index 935a276..f87ac24 100644 --- a/src/lib/components/table/FilteredPatientTable.svelte +++ b/src/lib/components/table/FilteredPatientTable.svelte @@ -20,7 +20,7 @@ export let patients: RouterOutput["patient"]["list"]; export let baseUrl: string; - function paginationUpdate(pagination: PaginationRequest) { + function paginationUpdate(pagination: PaginationRequest): void { updateQuery({ filter: query.filter, pagination, @@ -28,11 +28,11 @@ }); } - function filterUpdate(filter: FilterQdata | undefined) { + function filterUpdate(filter: FilterQdata | undefined): void { updateQuery({ filter, sort: query.sort }); } - function sortUpdate(sort: SortRequest | undefined) { + function sortUpdate(sort: SortRequest | undefined): void { updateQuery({ filter: query.filter, pagination: query.pagination, @@ -40,7 +40,7 @@ }); } - function updateQuery(q: typeof query) { + function updateQuery(q: typeof query): void { if (browser) { // Update page URL const url = getQueryUrl(q, baseUrl); diff --git a/src/lib/components/table/PatientField.svelte b/src/lib/components/table/PatientField.svelte index 296c797..c3480b5 100644 --- a/src/lib/components/table/PatientField.svelte +++ b/src/lib/components/table/PatientField.svelte @@ -5,7 +5,7 @@ export let patient: RouterOutput["patient"]["list"]["items"][0]; export let baseUrl: string; - function onClick(e: MouseEvent) { + function onClick(e: MouseEvent): void { gotoEntityQuery( { filter: { diff --git a/src/lib/components/table/PatientTable.svelte b/src/lib/components/table/PatientTable.svelte index 2fcd3f3..8df3916 100644 --- a/src/lib/components/table/PatientTable.svelte +++ b/src/lib/components/table/PatientTable.svelte @@ -1,5 +1,5 @@ + + + + diff --git a/src/lib/components/ui/LoadingBar.svelte b/src/lib/components/ui/LoadingBar.svelte index 53522de..36737a7 100644 --- a/src/lib/components/ui/LoadingBar.svelte +++ b/src/lib/components/ui/LoadingBar.svelte @@ -7,7 +7,7 @@ let showProgress = false; let showError = false; - export function start() { + export function start(): void { navprogress = 5; showProgress = true; showError = false; @@ -17,7 +17,7 @@ }, 500); } - export function reset() { + export function reset(): void { clearInterval(navInterval); navprogress = 100; @@ -30,7 +30,7 @@ }, 500); } - export function error() { + export function error(): void { showError = true; reset(); } diff --git a/src/lib/components/ui/NavLink.svelte b/src/lib/components/ui/NavLink.svelte index 4a532d6..3dbc1dc 100644 --- a/src/lib/components/ui/NavLink.svelte +++ b/src/lib/components/ui/NavLink.svelte @@ -1,13 +1,13 @@ -
- -
+ diff --git a/src/lib/components/ui/PaginationButtons.svelte b/src/lib/components/ui/PaginationButtons.svelte index 3a070e0..3e49d40 100644 --- a/src/lib/components/ui/PaginationButtons.svelte +++ b/src/lib/components/ui/PaginationButtons.svelte @@ -6,7 +6,7 @@ import { PAGINATION_LIMIT } from "$lib/shared/constants"; import type { Pagination, PaginationRequest } from "$lib/shared/model"; - import { screenWidthSmall } from "$lib/stores/layout"; + import { screenWidthSmall } from "$lib/stores"; import Icon from "./Icon.svelte"; @@ -44,7 +44,7 @@ return pag; } - function pagClick(page: number) { + function pagClick(page: number): void { const pag = getPaginationRequest(page); if (pag) onUpdate(pag); } diff --git a/src/lib/components/ui/WeekSelector.svelte b/src/lib/components/ui/WeekSelector.svelte index 3242800..ce4008d 100644 --- a/src/lib/components/ui/WeekSelector.svelte +++ b/src/lib/components/ui/WeekSelector.svelte @@ -12,26 +12,38 @@ let editing = false; let autocomplete: Autocomplete | undefined; - export let dateRange: DateRange = DateRange.thisWeek(); + export let dateRange: DateRange = new DateRange(null, DateRange.thisWeek().end); export let onSelect: (value: DateRange) => void = () => {}; - function nextWeek() { - dateRange.addDays(7); + function addDays(n: number): void { + if (dateRange.start === null) { + dateRange.start = new Date(dateRange.end!); + dateRange.start.setDate(dateRange.start.getDate() - 6); + } else if (dateRange.end === null) { + dateRange.end = new Date(dateRange.start!); + dateRange.end.setDate(dateRange.end.getDate() + 6); + } else { + dateRange.addDays(n); + } + } + + function nextWeek(): void { + addDays(7); dateRange = dateRange; // update reactive onSelect(dateRange); } - function previousWeek() { - dateRange.addDays(-7); + function previousWeek(): void { + addDays(-7); dateRange = dateRange; // update reactive onSelect(dateRange); } - function startEditing() { + function startEditing(): void { editing = true; } - function stopEditing() { + function stopEditing(): void { editing = false; } @@ -45,7 +57,7 @@ {#if editing} weekFilterItems(false)} + items={async () => weekFilterItems()} onClose={stopEditing} onSelect={(item) => { if (typeof item.id === "string") { diff --git a/src/lib/components/ui/markdown/carta.pcss b/src/lib/components/ui/markdown/carta.pcss index f325440..17e80dd 100644 --- a/src/lib/components/ui/markdown/carta.pcss +++ b/src/lib/components/ui/markdown/carta.pcss @@ -121,3 +121,9 @@ .carta-theme__default .carta-toolbar-left button.carta-active { @apply font-semibold border-primary; } + +@media (prefers-color-scheme: dark) { + .shiki, .shiki span { + color: var(--shiki-dark) !important; + } +} diff --git a/src/lib/components/ui/markdown/carta.ts b/src/lib/components/ui/markdown/carta.ts index 693f304..0d6b549 100644 --- a/src/lib/components/ui/markdown/carta.ts +++ b/src/lib/components/ui/markdown/carta.ts @@ -3,7 +3,6 @@ import type { Options } from "carta-md"; import { sanitizeHtml } from "$lib/shared/util"; export const CARTA_CFG: Options = { - theme: "github-dark", sanitizer: sanitizeHtml, disableIcons: ["taskList"], }; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index da6120d..cee1579 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,6 +7,7 @@ import { type AuthConfig, } from "@auth/core"; import Keycloak from "@auth/core/providers/keycloak"; +import type { Session } from "@auth/core/types"; import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit"; import { parse } from "set-cookie-parser"; @@ -70,7 +71,7 @@ export async function makeAuthjsRequest( event: RequestEvent, authjsEndpoint: string, params: Record, -) { +): Promise { const headers = new Headers(event.request.headers); headers.set("Content-Type", "application/x-www-form-urlencoded"); @@ -87,7 +88,7 @@ export async function makeAuthjsRequest( return redirect(302, res.redirect ?? ""); } -export async function auth(event: RequestEvent) { +export async function auth(event: RequestEvent): Promise { const { request: req } = event; setEnvDefaults(env, AUTH_CFG); diff --git a/src/lib/server/query/category.ts b/src/lib/server/query/category.ts index 0f5f9aa..05226b6 100644 --- a/src/lib/server/query/category.ts +++ b/src/lib/server/query/category.ts @@ -1,7 +1,13 @@ -import type { Category, CategoryNew } from "$lib/shared/model"; +import type { Category, CategoryDetail, CategoryNew } from "$lib/shared/model"; import { prisma } from "$lib/server/prisma"; +import { handleDeleteConflict } from "./util"; + +const SELECT = { + id: true, name: true, description: true, color: true, +}; + export async function newCategory(category: CategoryNew): Promise { const created = await prisma.category.create({ data: category, @@ -10,18 +16,26 @@ export async function newCategory(category: CategoryNew): Promise { return created.id; } -export async function updateCategory(id: number, category: Partial) { +export async function updateCategory(id: number, category: Partial): Promise { await prisma.category.update({ where: { id }, data: category }); } -export async function deleteCategory(id: number) { - await prisma.category.delete({ where: { id } }); +export async function deleteCategory(id: number): Promise { + await handleDeleteConflict(prisma.category.delete({ where: { id } }), "category with entries"); } -export async function getCategory(id: number): Promise { +export async function hideCategory(id: number, hidden: boolean): Promise { + await prisma.category.update({ where: { id }, data: { hidden } }); +} + +export async function getCategory(id: number): Promise { return prisma.category.findUniqueOrThrow({ where: { id } }); } -export async function getCategories(): Promise { - return prisma.category.findMany({ orderBy: { id: "asc" } }); +export async function getCategories(hidden = false): Promise { + return prisma.category.findMany({ select: SELECT, where: { hidden }, orderBy: { id: "asc" } }); +} + +export async function getCategoryNEntries(id: number): Promise { + return prisma.entry.count({ where: { EntryVersion: { some: { category_id: id } } } }); } diff --git a/src/lib/server/query/entry.ts b/src/lib/server/query/entry.ts index 86ab620..8fa402b 100644 --- a/src/lib/server/query/entry.ts +++ b/src/lib/server/query/entry.ts @@ -276,7 +276,7 @@ left join stations s on s.id = r.station_id`, if (filter?.date) { filterListToArray(filter.date).forEach((itm) => { - const dateRange = DateRange.parse(itm); + const dateRange = DateRange.parse(itm, true); if (dateRange?.start) { qb.addFilterClause(`ev.date >= ${qb.pvar()}`, dateRange.start); } @@ -286,7 +286,7 @@ left join stations s on s.id = r.station_id`, }); } - const SORT_FIELDS: { [key: string]: string[] } = { + const SORT_FIELDS: Record = { id: ["e.id"], patient: ["p.last_name", "p.first_name"], room: ["s.name", "r.name"], @@ -405,7 +405,19 @@ export async function getNTodo(date: Date): Promise { order by ev2.created_at desc limit 1) - where ev.date <= ${date}`; + left join entry_executions ex on + ex.entry_id = e.id + and ex.id = ( + select + id + from + entry_executions ex2 + where + ex2.entry_id = ex.entry_id + order by + ex2.created_at desc + limit 1) + where ev.date <= ${date} and ex.id is null`; // @ts-expect-error type checked const count = Number(result[0].count); return count; diff --git a/src/lib/server/query/index.ts b/src/lib/server/query/index.ts index 220256b..c35bfb2 100644 --- a/src/lib/server/query/index.ts +++ b/src/lib/server/query/index.ts @@ -3,3 +3,4 @@ export * from "./category"; export * from "./patient"; export * from "./user"; export * from "./room"; +export * from "./station"; diff --git a/src/lib/server/query/mapping.ts b/src/lib/server/query/mapping.ts index 1be7996..ad7e041 100644 --- a/src/lib/server/query/mapping.ts +++ b/src/lib/server/query/mapping.ts @@ -14,7 +14,6 @@ import type { Patient, User, UserTag, - Room, EntryVersion, EntryExecution, UserTagNameNonnull, @@ -65,10 +64,6 @@ export function mapUserTagNameNonnull(user: Omit): UserTagNameN return { id: user.id, name: user.name || "" }; } -export function mapRoom(room: DbRoomLn): Room { - return { id: room.id, name: room.name, station: room.station }; -} - export function mapVersion(version: DbEntryVersionLn): EntryVersion { return { id: version.id, diff --git a/src/lib/server/query/patient.ts b/src/lib/server/query/patient.ts index 9f0f4dd..5180280 100644 --- a/src/lib/server/query/patient.ts +++ b/src/lib/server/query/patient.ts @@ -1,5 +1,3 @@ -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; - import type { Patient, PatientNew, @@ -9,12 +7,12 @@ import type { PatientTag, SortRequest, } from "$lib/shared/model"; -import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error"; +import { ErrorInvalidInput } from "$lib/shared/util/error"; import { prisma } from "$lib/server/prisma"; import { mapPatient } from "./mapping"; -import { QueryBuilder } from "./util"; +import { QueryBuilder, handleDeleteConflict } from "./util"; export async function newPatient(patient: PatientNew): Promise { const created = await prisma.patient.create({ data: patient, select: { id: true } }); @@ -22,27 +20,17 @@ export async function newPatient(patient: PatientNew): Promise { } /** Update a patient */ -export async function updatePatient(id: number, patient: Partial) { +export async function updatePatient(id: number, patient: Partial): Promise { await prisma.patient.update({ where: { id }, data: patient }); } /** Delete a patient (Note: this only works if the patient is not associated with any entries) */ -export async function deletePatient(id: number) { - try { - await prisma.patient.delete({ where: { id } }); - } catch (error) { - if (error instanceof PrismaClientKnownRequestError) { - // Foreign key constraint failed - if (error.code === "P2003") { - throw new ErrorConflict("cannot delete patient with entries"); - } - } - throw error; - } +export async function deletePatient(id: number): Promise { + await handleDeleteConflict(prisma.patient.delete({ where: { id } }), "patient with entries"); } /** Hide/show a patient */ -export async function hidePatient(id: number, hidden: boolean) { +export async function hidePatient(id: number, hidden: boolean): Promise { await prisma.patient.update({ where: { id }, data: { hidden } }); } @@ -109,7 +97,7 @@ export async function getPatients( qb.addFilterList("r.id", filter.room); qb.addFilterList("s.id", filter.station); - const SORT_FIELDS: { [key: string]: string[] } = { + const SORT_FIELDS: Record = { id: ["p.id"], name: ["p.last_name", "p.first_name"], first_name: ["p.first_name"], diff --git a/src/lib/server/query/room.ts b/src/lib/server/query/room.ts index 1e6b9a5..eecf463 100644 --- a/src/lib/server/query/room.ts +++ b/src/lib/server/query/room.ts @@ -1,61 +1,43 @@ import type { - RoomNew, Room, Station, StationNew, + RoomNew, Room, + RoomDetail, } from "$lib/shared/model"; import { prisma } from "$lib/server/prisma"; -import { mapRoom } from "./mapping"; +import { handleDeleteConflict } from "./util"; -export async function newStation(station: StationNew): Promise { - const created = await prisma.station.create({ data: station, select: { id: true } }); - return created.id; -} - -export async function updateStation(id: number, station: Partial) { - await prisma.station.update({ where: { id }, data: station }); -} - -export async function deleteStation(id: number) { - await prisma.station.delete({ where: { id } }); -} - -export async function getStation(id: number): Promise { - return prisma.station.findUniqueOrThrow({ where: { id } }); -} - -export async function getStations(): Promise { - return prisma.station.findMany({ orderBy: { id: "asc" } }); -} +const SELECT = { id: true, name: true, station: { select: { id: true, name: true } } }; export async function newRoom(room: RoomNew): Promise { const created = await prisma.room.create({ data: room, select: { id: true } }); return created.id; } -export async function updateRoom(id: number, room: Partial) { +export async function updateRoom(id: number, room: Partial): Promise { await prisma.room.update({ where: { id }, data: room }); } -export async function deleteRoom(id: number) { - await prisma.room.delete({ where: { id } }); +export async function deleteRoom(id: number): Promise { + await handleDeleteConflict(prisma.room.delete({ where: { id } }), "room with patients"); } -export async function getRoom(id: number): Promise { - const room = await prisma.room.findUniqueOrThrow({ - where: { id }, - include: { station: true }, - }); - return { - id: room.id, - name: room.name, - station: room.station, - }; +export async function hideRoom(id: number, hidden: boolean): Promise { + await prisma.room.update({ where: { id }, data: { hidden } }); } -export async function getRooms(): Promise { - const rooms = await prisma.room.findMany({ - include: { station: true }, +export async function getRoom(id: number): Promise { + return prisma.room.findUniqueOrThrow({ select: { ...SELECT, hidden: true }, where: { id } }); +} + +export async function getRooms(hidden = false): Promise { + return prisma.room.findMany({ + select: SELECT, + where: { hidden }, orderBy: [{ station: { name: "asc" } }, { name: "asc" }], }); - return rooms.map(mapRoom); +} + +export async function getRoomNPatients(id: number): Promise { + return prisma.patient.count({ where: { room_id: id } }); } diff --git a/src/lib/server/query/savedFilter.ts b/src/lib/server/query/savedFilter.ts index 761a2c7..2dd0d3e 100644 --- a/src/lib/server/query/savedFilter.ts +++ b/src/lib/server/query/savedFilter.ts @@ -1,7 +1,11 @@ +import { DEFAULT_FILTER_NAME } from "$lib/shared/constants"; import type { SavedFilter, SavedFilterNew } from "$lib/shared/model"; +import { isDefaultFilter } from "$lib/shared/util"; import { prisma } from "$lib/server/prisma"; +const SELECT = { id: true, name: true, query: true }; + export async function newSavedFilter(filter: SavedFilterNew, user_id: number): Promise { const created = await prisma.savedFilter.create({ data: { @@ -12,17 +16,49 @@ export async function newSavedFilter(filter: SavedFilterNew, user_id: number): P return created.id; } -export async function updateSavedFilter(id: number, query: string, user_id: number) { +export async function updateSavedFilter(id: number, query: string, user_id: number): Promise { await prisma.savedFilter.update({ where: { id, user_id }, data: { query } }); } -export async function deleteSavedFilter(id: number, user_id: number) { +export async function deleteSavedFilter(id: number, user_id: number): Promise { await prisma.savedFilter.delete({ where: { id, user_id } }); } export async function getSavedFilters(user_id: number, view: string): Promise { - return prisma.savedFilter.findMany({ - select: { id: true, name: true, query: true }, + const filters = await prisma.savedFilter.findMany({ + select: SELECT, where: { user_id, view }, }); + + // Move default filter to the top + const dix = filters.findIndex((f) => f.name.toLowerCase() === DEFAULT_FILTER_NAME); + if (dix !== -1) filters.unshift(filters.splice(dix, 1)[0]); + + return filters; +} + +export async function getDefaultFilter(user_id: number, view: string): Promise { + return prisma.savedFilter.findFirst({ + select: SELECT, + where: { user_id, view, name: { mode: "insensitive", equals: DEFAULT_FILTER_NAME } }, + }); +} + +export async function getAllFilters(user_id: number): Promise> { + const filters = await prisma.savedFilter.findMany({ + select: { + id: true, name: true, query: true, view: true, + }, + where: { user_id }, + }); + + const grouped: Record = {}; + for (const filter of filters) { + if (!grouped[filter.view]) grouped[filter.view] = []; + const f = { id: filter.id, name: filter.name, query: filter.query }; + // Place default filter at the first position + if (isDefaultFilter(f.name)) grouped[filter.view].unshift(f); + else grouped[filter.view].push(f); + } + return grouped; } diff --git a/src/lib/server/query/station.ts b/src/lib/server/query/station.ts new file mode 100644 index 0000000..4ae5d92 --- /dev/null +++ b/src/lib/server/query/station.ts @@ -0,0 +1,40 @@ +import type { Station, StationDetail, StationNew } from "$lib/shared/model"; + +import { prisma } from "$lib/server/prisma"; + +import { handleDeleteConflict } from "./util"; + +const SELECT = { id: true, name: true }; + +export async function newStation(station: StationNew): Promise { + const created = await prisma.station.create({ data: station, select: { id: true } }); + return created.id; +} + +export async function updateStation(id: number, station: Partial): Promise { + await prisma.station.update({ where: { id }, data: station }); +} + +export async function deleteStation(id: number): Promise { + await handleDeleteConflict(prisma.station.delete({ where: { id } }), "station with rooms"); +} + +export async function hideStation(id: number, hidden: boolean): Promise { + await prisma.station.update({ where: { id }, data: { hidden } }); +} + +export async function getStation(id: number): Promise { + return prisma.station.findUniqueOrThrow({ where: { id } }); +} + +export async function getStations(hidden = false): Promise { + return prisma.station.findMany({ + select: SELECT, + where: { hidden }, + orderBy: { id: "asc" }, + }); +} + +export async function getStationNRooms(id: number): Promise { + return prisma.room.count({ where: { station_id: id } }); +} diff --git a/src/lib/server/query/util.ts b/src/lib/server/query/util.ts index 9fd629f..c1c7c3f 100644 --- a/src/lib/server/query/util.ts +++ b/src/lib/server/query/util.ts @@ -1,5 +1,8 @@ +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"; enum QueryComponentType { Normal = 1, @@ -20,6 +23,20 @@ export function filterListToArray(fl: FilterList): T[] { return [fl]; } +export async function handleDeleteConflict(act: Promise, msg: string): Promise { + try { + await act; + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + // Foreign key constraint failed + if (error.code === "P2003") { + throw new ErrorConflict(`cannot delete ${msg}; hide instead`); + } + } + throw error; + } +} + class SearchQueryComponent { word: string; @@ -131,16 +148,16 @@ export class QueryBuilder { this.fromClause = fromClause; } - setPagination(pag: PaginationRequest) { + setPagination(pag: PaginationRequest): void { if (pag.limit) this.limit = pag.limit; if (pag.offset) this.offset = pag.offset; } - addOrderClause(orderClause: string) { + addOrderClause(orderClause: string): void { this.orderClauses.push(orderClause); } - orderByFields(fields: string[], asc: boolean | undefined = undefined) { + orderByFields(fields: string[], asc: boolean | undefined = undefined): void { const sortDir = asc === false ? " desc" : " asc"; const orderClause = fields.join(`${sortDir}, `) + sortDir; this.addOrderClause(orderClause); @@ -153,7 +170,7 @@ export class QueryBuilder { } /** Add a simple filter checking for equality */ - addFilter(fname: string, val: unknown | undefined) { + addFilter(fname: string, val: unknown | undefined): void { if (val === undefined) return; this.params.push(val); @@ -161,13 +178,13 @@ export class QueryBuilder { } /** Add a SQL filter clause */ - addFilterClause(clause: string, ...params: unknown[]) { + addFilterClause(clause: string, ...params: unknown[]): void { this.filterClauses.push(clause); this.params.push(...params); } /** Add a list filter (value matches any item from the filter list) */ - addFilterList(fname: string, fl: FilterList | undefined) { + addFilterList(fname: string, fl: FilterList | undefined): void { if (fl === undefined) return; this.filterClauses.push(`${fname} = any (${this.pvar()})`); diff --git a/src/lib/server/trpc/context.ts b/src/lib/server/trpc/context.ts index d1f8a90..e474256 100644 --- a/src/lib/server/trpc/context.ts +++ b/src/lib/server/trpc/context.ts @@ -1,12 +1,13 @@ import type { RequestEvent } from "@sveltejs/kit"; import { type inferAsyncReturnType, TRPCError } from "@trpc/server"; +import type { User } from "$lib/shared/model"; import { ZUser } from "$lib/shared/model/validation"; // we're not using the event parameter is this example, // hence the eslint-disable rule // eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function createContext(event: RequestEvent) { +export async function createContext(event: RequestEvent): Promise<{ user: User }> { if (!event.locals.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" }); } diff --git a/src/lib/server/trpc/routes/category.ts b/src/lib/server/trpc/routes/category.ts index 2333808..b5f3aa3 100644 --- a/src/lib/server/trpc/routes/category.ts +++ b/src/lib/server/trpc/routes/category.ts @@ -1,11 +1,15 @@ import { z } from "zod"; -import { ZEntityId, ZCategoryNew } from "$lib/shared/model/validation"; +import { + ZEntityId, ZCategoryNew, ZHide, ZListHidden, +} from "$lib/shared/model/validation"; import { deleteCategory, getCategories, getCategory, + getCategoryNEntries, + hideCategory, newCategory, updateCategory, } from "$lib/server/query"; @@ -13,10 +17,18 @@ import { import { t, trpcWrap } from ".."; export const categoryRouter = t.router({ - list: t.procedure.query(async () => trpcWrap(getCategories)), + list: t.procedure.input(ZListHidden).query(async (opts) => trpcWrap( + async () => getCategories(opts.input?.hidden), + )), get: t.procedure .input(ZEntityId) - .query(async (opts) => trpcWrap(async () => getCategory(opts.input))), + .query(async (opts) => trpcWrap(async () => { + const [category, n_entries] = await Promise.all([ + getCategory(opts.input), + getCategoryNEntries(opts.input), + ]); + return { ...category, n_entries }; + })), create: t.procedure.input(ZCategoryNew).mutation(async (opts) => trpcWrap(async () => { const id = await newCategory(opts.input); return id; @@ -29,4 +41,7 @@ export const categoryRouter = t.router({ delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { await deleteCategory(opts.input); })), + hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => { + await hideCategory(opts.input.id, opts.input.hidden); + })), }); diff --git a/src/lib/server/trpc/routes/patient.ts b/src/lib/server/trpc/routes/patient.ts index dfe77a3..2f90645 100644 --- a/src/lib/server/trpc/routes/patient.ts +++ b/src/lib/server/trpc/routes/patient.ts @@ -1,6 +1,8 @@ import { z } from "zod"; -import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation"; +import { + ZEntityId, ZHide, ZPatientNew, ZPatientsQuery, +} from "$lib/shared/model/validation"; import { deletePatient, @@ -42,14 +44,7 @@ export const patientRouter = t.router({ delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { await deletePatient(opts.input); })), - hide: t.procedure - .input( - z.object({ - id: ZEntityId, - hidden: z.boolean().default(true), - }), - ) - .mutation(async (opts) => trpcWrap(async () => { - await hidePatient(opts.input.id, opts.input.hidden); - })), + hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => { + await hidePatient(opts.input.id, opts.input.hidden); + })), }); diff --git a/src/lib/server/trpc/routes/room.ts b/src/lib/server/trpc/routes/room.ts index 9872f2d..c0e2bc9 100644 --- a/src/lib/server/trpc/routes/room.ts +++ b/src/lib/server/trpc/routes/room.ts @@ -1,18 +1,28 @@ import { z } from "zod"; -import { ZEntityId, ZRoomNew } from "$lib/shared/model/validation"; +import { + ZEntityId, ZHide, ZListHidden, ZRoomNew, +} from "$lib/shared/model/validation"; import { - deleteRoom, getRoom, getRooms, newRoom, updateRoom, + deleteRoom, getRoom, getRoomNPatients, getRooms, hideRoom, newRoom, updateRoom, } from "$lib/server/query"; import { t, trpcWrap } from ".."; export const roomRouter = t.router({ - list: t.procedure.query(async (opts) => trpcWrap(getRooms)), + list: t.procedure.input(ZListHidden).query(async (opts) => trpcWrap( + async () => getRooms(opts.input?.hidden), + )), get: t.procedure .input(ZEntityId) - .query(async (opts) => trpcWrap(async () => getRoom(opts.input))), + .query(async (opts) => trpcWrap(async () => { + const [room, n_patients] = await Promise.all([ + getRoom(opts.input), + getRoomNPatients(opts.input), + ]); + return { ...room, n_patients }; + })), create: t.procedure.input(ZRoomNew).mutation(async (opts) => trpcWrap(async () => { const id = await newRoom(opts.input); return id; @@ -25,4 +35,7 @@ export const roomRouter = t.router({ delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { await deleteRoom(opts.input); })), + hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => { + await hideRoom(opts.input.id, opts.input.hidden); + })), }); diff --git a/src/lib/server/trpc/routes/savedFilter.ts b/src/lib/server/trpc/routes/savedFilter.ts index 78d9388..3c333ff 100644 --- a/src/lib/server/trpc/routes/savedFilter.ts +++ b/src/lib/server/trpc/routes/savedFilter.ts @@ -3,7 +3,12 @@ import { } from "$lib/shared/model/validation"; import { - deleteSavedFilter, getSavedFilters, newSavedFilter, updateSavedFilter, + deleteSavedFilter, + getAllFilters, + getDefaultFilter, + getSavedFilters, + newSavedFilter, + updateSavedFilter, } from "$lib/server/query/savedFilter"; import { t, trpcWrap } from ".."; @@ -12,6 +17,12 @@ export const savedFilterRouter = t.router({ get: t.procedure.input(fields.NameString()).query(async (opts) => trpcWrap( async () => getSavedFilters(opts.ctx.user.id, opts.input), )), + getDefault: t.procedure.input(fields.NameString()).query(async (opts) => trpcWrap( + async () => getDefaultFilter(opts.ctx.user.id, opts.input), + )), + getAll: t.procedure.query(async (opts) => trpcWrap( + async () => getAllFilters(opts.ctx.user.id), + )), create: t.procedure.input(ZSavedFilterNew).mutation(async (opts) => trpcWrap( async () => newSavedFilter(opts.input, opts.ctx.user.id), )), diff --git a/src/lib/server/trpc/routes/station.ts b/src/lib/server/trpc/routes/station.ts index d611f86..d9d68aa 100644 --- a/src/lib/server/trpc/routes/station.ts +++ b/src/lib/server/trpc/routes/station.ts @@ -1,11 +1,15 @@ import { z } from "zod"; -import { ZEntityId, ZStationNew } from "$lib/shared/model/validation"; +import { + ZEntityId, ZHide, ZListHidden, ZStationNew, +} from "$lib/shared/model/validation"; import { deleteStation, getStation, + getStationNRooms, getStations, + hideStation, newStation, updateStation, } from "$lib/server/query"; @@ -13,10 +17,16 @@ import { import { t, trpcWrap } from ".."; export const stationRouter = t.router({ - list: t.procedure.query(getStations), + list: t.procedure.input(ZListHidden).query(async (opts) => getStations(opts.input?.hidden)), get: t.procedure .input(ZEntityId) - .query(async (opts) => trpcWrap(async () => getStation(opts.input))), + .query(async (opts) => trpcWrap(async () => { + const [station, n_rooms] = await Promise.all([ + getStation(opts.input), + getStationNRooms(opts.input), + ]); + return { ...station, n_rooms }; + })), create: t.procedure.input(ZStationNew).mutation(async (opts) => trpcWrap(async () => { const id = await newStation(opts.input); return id; @@ -29,4 +39,7 @@ export const stationRouter = t.router({ delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { await deleteStation(opts.input); })), + hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => { + await hideStation(opts.input.id, opts.input.hidden); + })), }); diff --git a/src/lib/shared/constants.ts b/src/lib/shared/constants.ts index 374b071..12a15f7 100644 --- a/src/lib/shared/constants.ts +++ b/src/lib/shared/constants.ts @@ -4,3 +4,5 @@ export const WEEK_LIMIT = 8; export const URL_ENTRIES = "/plan"; export const URL_VISIT = "/visit"; export const URL_PATIENTS = "/patients"; + +export const DEFAULT_FILTER_NAME = "default"; diff --git a/src/lib/shared/model/model.ts b/src/lib/shared/model/model.ts index e3c75de..bbecac8 100644 --- a/src/lib/shared/model/model.ts +++ b/src/lib/shared/model/model.ts @@ -35,31 +35,40 @@ export type UserTagNameNonnull = { name: string; }; -export type Station = { +export type StationDetail = { id: number; name: string; + hidden: boolean; }; +export type Station = Omit; + export type StationNew = Omit; -export type Room = { +export type RoomDetail = { id: number; name: string; station: Station; + hidden: boolean; }; +export type Room = Omit; + export type RoomNew = { name: string; station_id: number; }; -export type Category = { +export type CategoryDetail = { id: number; name: string; color: Option; description: Option; + hidden: boolean; }; +export type Category = Omit; + export type CategoryNew = Omit; export type Patient = { diff --git a/src/lib/shared/model/validation.ts b/src/lib/shared/model/validation.ts index 8c78b06..d353444 100644 --- a/src/lib/shared/model/validation.ts +++ b/src/lib/shared/model/validation.ts @@ -45,7 +45,7 @@ const coercedBool = z.string().toLowerCase().transform((v) => v === "true").or(z function returnDataInSameOrderAsPassed>( schema: Schema, -) { +): z.ZodEffects | undefined> { return z.any().transform((value, ctx) => { const parsed = schema.safeParse(value); if (parsed.success) { @@ -181,3 +181,10 @@ export const ZSavedFilterUpdate = z.object({ id: ZEntityId, query: z.string(), }); + +export const ZHide = z.object({ + id: ZEntityId, + hidden: z.boolean().default(true), +}); + +export const ZListHidden = z.object({ hidden: z.boolean().optional() }).optional(); diff --git a/src/lib/shared/util/date.ts b/src/lib/shared/util/date.ts index 43770bb..1e21442 100644 --- a/src/lib/shared/util/date.ts +++ b/src/lib/shared/util/date.ts @@ -109,9 +109,6 @@ export class DateRange { /** 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; @@ -126,11 +123,11 @@ export class DateRange { * - 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 { + static parse(s: string, utc = false): DateRange | null { const parts = s.split("..", 2); const parsed = parts.map((p) => { if (p.length === 0) return null; - return dateFromYMD(p); + return utc ? new Date(p) : dateFromYMD(p); }); if (parsed.length === 0 @@ -147,7 +144,7 @@ export class DateRange { } /** Shift the range by the given number of days. This modifies the range in-place */ - addDays(n: number) { + addDays(n: number): void { this.start?.setDate(this.start.getDate() + n); this.end?.setDate(this.end.getDate() + n); } @@ -163,10 +160,8 @@ export class DateRange { /** 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; + if (this.start === null) return "bis " + formatDate(this.end!); + if (this.end === null) return "ab " + formatDate(this.start); + return formatDate(this.start) + " \u2013 " + formatDate(this.end); } } diff --git a/src/lib/shared/util/toast.ts b/src/lib/shared/util/toast.ts new file mode 100644 index 0000000..8eefab0 --- /dev/null +++ b/src/lib/shared/util/toast.ts @@ -0,0 +1,8 @@ +import { toast } from "@zerodevx/svelte-toast"; + +export function toastInfo(msg: string): void { + toast.push({ msg }); +} +export function toastError(msg: string): void { + toast.push({ msg, classes: ["toast-error"] }); +} diff --git a/src/lib/shared/util/util.ts b/src/lib/shared/util/util.ts index d074d52..ee63e61 100644 --- a/src/lib/shared/util/util.ts +++ b/src/lib/shared/util/util.ts @@ -4,10 +4,15 @@ import { isRedirect, error } from "@sveltejs/kit"; import { TRPCClientError } from "@trpc/client"; import DOMPurify from "isomorphic-dompurify"; import qs from "qs"; +import type { FormOptions } from "sveltekit-superforms"; import { ZodError } from "zod"; -import type { EntityQuery } from "$lib/shared/model"; -import type { RouterOutput } from "$lib/shared/trpc"; +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 { DateRange } from "./date"; +import { toastError, toastInfo } from "./toast"; export function formatBool(val: boolean): string { return val ? "Ja" : "Nein"; @@ -24,7 +29,7 @@ export function parseQueryUrl(search: string): any { return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true }); } -export function gotoEntityQuery(query: EntityQuery, basePath: string) { +export function gotoEntityQuery(query: EntityQuery, basePath: string): void { if (window && window.location.pathname.startsWith(`${basePath}/`)) { if (window.location.search) { const oldQuery: EntityQuery = parseQueryUrl(window.location.search); @@ -43,7 +48,7 @@ export function gotoEntityQuery(query: EntityQuery, basePath: string) { * * Converts TRPC errors to SvelteKit ones */ -export async function loadWrap(f: () => Promise) { +export async function loadWrap(f: () => Promise): Promise { try { return await f(); } catch (e) { @@ -77,16 +82,16 @@ export class Debouncer { this.handler = handler; } - clear() { + clear(): void { if (this.timeout) window.clearTimeout(this.timeout); } - trigger() { + trigger(): void { this.clear(); this.timeout = window.setTimeout(this.handler, this.delay); } - now() { + now(): void { this.clear(); this.handler(); } @@ -107,3 +112,45 @@ export function divFloor(a: number, b: number): number { export function normalizeLineEndings(s: string): string { return s.replaceAll("\r\n", "\n"); } + +export function superformConfig(entity?: string): Pick { + return { + onError: ({ result }) => { + toastError(result.error.message); + }, + onResult: ({ result }) => { + if (result.type === "success") { + if (result.data?.form && typeof result.data.form.message === "string") { + toastInfo(result.data.form.message); + } else if (entity) { + toastInfo(entity + " aktualisiert"); + } else { + toastInfo("OK"); + } + } + }, + }; +} + +export function isDefaultFilter(name: string): boolean { + return name.toLowerCase() === DEFAULT_FILTER_NAME; +} + +export function defaultFilterUrl( + savedFilters: Record, + view: string, +): string { + let df = undefined; + const filters = savedFilters[view]; + if (filters) df = filters.find((f) => isDefaultFilter(f.name)); + return "/" + view + (df ? "?" + df.query : ""); +} + +export function defaultVisitUrl(): string { + return getQueryUrl({ + filter: { + done: false, + date: [{ id: new DateRange(null, DateRange.thisWeek().end).toString() }], + }, + }, URL_VISIT); +} diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts new file mode 100644 index 0000000..556cc5d --- /dev/null +++ b/src/lib/stores/index.ts @@ -0,0 +1,9 @@ +import { derived, writable, type Writable } from "svelte/store"; + +import type { SavedFilter } from "$lib/shared/model"; + +// Width of the main section of the layout +export const screenWidth = writable(0); +export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500); + +export const savedFilters: Writable> = writable({}); diff --git a/src/lib/stores/layout.ts b/src/lib/stores/layout.ts deleted file mode 100644 index d69b86a..0000000 --- a/src/lib/stores/layout.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { derived, writable } from "svelte/store"; - -// Width of the main section of the layout -export const screenWidth = writable(0); -export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 9385be4..61cacf4 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,15 +1,23 @@
diff --git a/src/routes/(app)/+layout.ts b/src/routes/(app)/+layout.ts new file mode 100644 index 0000000..75f44f6 --- /dev/null +++ b/src/routes/(app)/+layout.ts @@ -0,0 +1,9 @@ +import type { LayoutLoad } from "./$types"; + +import { trpc } from "$lib/shared/trpc"; + +export const load: LayoutLoad = async (event) => { + const savedFilters = await trpc(event).savedFilter.getAll.query(); + + return { savedFilters }; +}; diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 3eaa8cd..fadfee1 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -2,6 +2,10 @@ import { page } from "$app/stores"; import type { PageData } from "./$types"; + import { defaultFilterUrl } from "$lib/shared/util"; + + import { savedFilters } from "$lib/stores"; + export let data: PageData; @@ -22,7 +26,7 @@

Planung

Hier können sie neue Visitenbucheinträge erstellen.

diff --git a/src/routes/(app)/about/+page.svelte b/src/routes/(app)/about/+page.svelte new file mode 100644 index 0000000..0ef0423 --- /dev/null +++ b/src/routes/(app)/about/+page.svelte @@ -0,0 +1,13 @@ + + +
+

Visitenbuch

+

Version: {version}

+

Letzte Änderung: {lastmod}

+

Open-Source-Lizenzen

+
diff --git a/src/routes/(app)/categories/+page.svelte b/src/routes/(app)/categories/+page.svelte index bf183e0..9ae3771 100644 --- a/src/routes/(app)/categories/+page.svelte +++ b/src/routes/(app)/categories/+page.svelte @@ -3,6 +3,7 @@ import CategoryField from "$lib/components/table/CategoryField.svelte"; import Header from "$lib/components/ui/Header.svelte"; + import HiddenToggle from "$lib/components/ui/HiddenToggle.svelte"; export let data: PageData; @@ -11,8 +12,11 @@ Kategorien -
- Neue Kategorie +
+
diff --git a/src/routes/(app)/categories/+page.ts b/src/routes/(app)/categories/+page.ts index 6845c60..c3b130f 100644 --- a/src/routes/(app)/categories/+page.ts +++ b/src/routes/(app)/categories/+page.ts @@ -5,7 +5,8 @@ import { loadWrap } from "$lib/shared/util"; export const load: PageLoad = async (event) => { return loadWrap(async () => { - const categories = await trpc(event).category.list.query(); - return { categories }; + const hidden = event.url.searchParams.get("hidden") !== null; + const categories = await trpc(event).category.list.query({ hidden }); + return { categories, hidden }; }); }; diff --git a/src/routes/(app)/category/[id]/+page.server.ts b/src/routes/(app)/category/[id]/+page.server.ts index 07968b3..9851d12 100644 --- a/src/routes/(app)/category/[id]/+page.server.ts +++ b/src/routes/(app)/category/[id]/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions } from "./$types"; import { fail, redirect } from "@sveltejs/kit"; -import { superValidate } from "sveltekit-superforms"; +import { message, superValidate } from "sveltekit-superforms"; import { zod } from "sveltekit-superforms/adapters"; import { ZCategoryNew, ZUrlEntityId } from "$lib/shared/model/validation"; @@ -12,14 +12,18 @@ export const actions: Actions = { default: async (event) => loadWrap(async () => { const id = ZUrlEntityId.parse(event.params.id); const formData = await event.request.formData(); + const form = await superValidate(formData, zod(ZCategoryNew)); + const hide = formData.get("hide"); const del = formData.get("delete"); - if (del) { + if (hide) { + const hidden = Boolean(parseInt(hide.toString())); + await trpc(event).category.hide.mutate({ id, hidden }); + return message(form, "Kategorie " + (hidden ? "ausgeblendet" : "eingeblendet")); + } else if (del) { await trpc(event).category.delete.mutate(id); redirect(302, "/categories"); } else { - const form = await superValidate(formData, zod(ZCategoryNew)); - if (!form.valid) { return fail(400, { form }); } @@ -28,8 +32,7 @@ export const actions: Actions = { id, category: form.data, }); - - return { form }; } + return { form }; }), }; diff --git a/src/routes/(app)/category/[id]/+page.svelte b/src/routes/(app)/category/[id]/+page.svelte index a311900..f27f3b7 100644 --- a/src/routes/(app)/category/[id]/+page.svelte +++ b/src/routes/(app)/category/[id]/+page.svelte @@ -2,6 +2,7 @@ import type { PageData } from "./$types"; import CategoryForm from "$lib/components/form/CategoryForm.svelte"; + import HideDelete from "$lib/components/form/HideDelete.svelte"; export let data: PageData; @@ -11,5 +12,5 @@ - + 0} hidden={data.category.hidden} /> diff --git a/src/routes/(app)/entry/[id]/+page.server.ts b/src/routes/(app)/entry/[id]/+page.server.ts index 92565c6..693fb9c 100644 --- a/src/routes/(app)/entry/[id]/+page.server.ts +++ b/src/routes/(app)/entry/[id]/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions } from "./$types"; import { fail } from "@sveltejs/kit"; -import { superValidate } from "sveltekit-superforms"; +import { superValidate, message } from "sveltekit-superforms"; import { ZUrlEntityId } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; @@ -22,5 +22,6 @@ export const actions: Actions = { old_execution_id: null, execution: { text: form.data.text }, }); + return message(form, "Eintrag erledigt"); }), }; diff --git a/src/routes/(app)/entry/[id]/+page.svelte b/src/routes/(app)/entry/[id]/+page.svelte index 46a4e0f..7d0464d 100644 --- a/src/routes/(app)/entry/[id]/+page.svelte +++ b/src/routes/(app)/entry/[id]/+page.svelte @@ -4,6 +4,8 @@ import { defaults, superForm } from "sveltekit-superforms"; + import { superformConfig } from "$lib/shared/util"; + import EntryBody from "$lib/components/entry/EntryBody.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; @@ -12,8 +14,11 @@ export let data: PageData; const formData = defaults(SchemaEntryExecution); - const { form, errors, enhance } = superForm(formData, { + const { + form, errors, enhance, + } = superForm(formData, { validators: SchemaEntryExecution, + ...superformConfig("Eintrag"), }); diff --git a/src/routes/(app)/entry/[id]/edit/+page.svelte b/src/routes/(app)/entry/[id]/edit/+page.svelte index f29d052..8717329 100644 --- a/src/routes/(app)/entry/[id]/edit/+page.svelte +++ b/src/routes/(app)/entry/[id]/edit/+page.svelte @@ -6,6 +6,7 @@ import { trpc } from "$lib/shared/trpc"; import { formatDate, humanDate } from "$lib/shared/util"; + import { superformConfig } from "$lib/shared/util"; import PatientCard from "$lib/components/entry/PatientCard.svelte"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; @@ -24,6 +25,7 @@ form, errors, constraints, enhance, tainted, } = superForm(data.form, { validators: SchemaNewEntryVersion, + ...superformConfig("Eintrag"), }); diff --git a/src/routes/(app)/entry/[id]/editExecution/+page.svelte b/src/routes/(app)/entry/[id]/editExecution/+page.svelte index b82c776..4e94ca4 100644 --- a/src/routes/(app)/entry/[id]/editExecution/+page.svelte +++ b/src/routes/(app)/entry/[id]/editExecution/+page.svelte @@ -4,6 +4,8 @@ import { defaults, superForm } from "sveltekit-superforms"; + import { superformConfig } from "$lib/shared/util"; + import EntryBody from "$lib/components/entry/EntryBody.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; @@ -14,6 +16,7 @@ const formData = defaults(SchemaNewExecution); const { form, errors, enhance } = superForm(formData, { validators: SchemaNewExecution, + ...superformConfig("Eintrag"), }); diff --git a/src/routes/(app)/entry/new/+page.svelte b/src/routes/(app)/entry/new/+page.svelte index 9ff091a..8030b63 100644 --- a/src/routes/(app)/entry/new/+page.svelte +++ b/src/routes/(app)/entry/new/+page.svelte @@ -2,6 +2,8 @@ import { defaults, superForm } from "sveltekit-superforms"; import { trpc } from "$lib/shared/trpc"; + import { superformConfig } from "$lib/shared/util"; + import { toastError } from "$lib/shared/util/toast"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import FormField from "$lib/components/ui/FormField.svelte"; @@ -14,6 +16,7 @@ form, errors, constraints, enhance, } = superForm(formData, { validators: SchemaNewEntryWithPatient, + ...superformConfig("Eintrag"), }); @@ -56,7 +59,7 @@ $form.patient_first_name = p.first_name; $form.patient_last_name = p.last_name; $form.patient_age = p.age; - }); + }).catch((e) => toastError("Konnte Patient nicht laden:\n" + e)); return { newValue: item.name ?? "", close: true }; }} onUnselect={() => { diff --git a/src/routes/(app)/patient/[id]/+page.server.ts b/src/routes/(app)/patient/[id]/+page.server.ts index 55e08a2..e32dc20 100644 --- a/src/routes/(app)/patient/[id]/+page.server.ts +++ b/src/routes/(app)/patient/[id]/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions } from "./$types"; import { fail, redirect } from "@sveltejs/kit"; -import { superValidate } from "sveltekit-superforms"; +import { message, superValidate } from "sveltekit-superforms"; import { zod } from "sveltekit-superforms/adapters"; import { ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation"; @@ -12,20 +12,18 @@ export const actions: Actions = { default: async (event) => loadWrap(async () => { const id = ZUrlEntityId.parse(event.params.id); const formData = await event.request.formData(); + const form = await superValidate(formData, zod(ZPatientNew)); const hide = formData.get("hide"); const del = formData.get("delete"); if (hide) { - await trpc(event).patient.hide.mutate({ - id, - hidden: Boolean(parseInt(hide.toString())), - }); + const hidden = Boolean(parseInt(hide.toString())); + await trpc(event).patient.hide.mutate({ id, hidden }); + return message(form, "Patient " + (hidden ? "ausgeblendet" : "eingeblendet")); } else if (del) { await trpc(event).patient.delete.mutate(id); redirect(302, "/patients"); } else { - const form = await superValidate(formData, zod(ZPatientNew)); - if (!form.valid) { return fail(400, { form }); } @@ -34,8 +32,7 @@ export const actions: Actions = { id, patient: form.data, }); - - return { form }; } + return { form }; }), }; diff --git a/src/routes/(app)/patient/[id]/+page.svelte b/src/routes/(app)/patient/[id]/+page.svelte index ad88658..6fd3d82 100644 --- a/src/routes/(app)/patient/[id]/+page.svelte +++ b/src/routes/(app)/patient/[id]/+page.svelte @@ -1,6 +1,7 @@ @@ -10,8 +11,11 @@ Zimmer -
- Neues Zimmer +
+
+
diff --git a/src/routes/(app)/rooms/+page.ts b/src/routes/(app)/rooms/+page.ts index 774e5e6..87a0e36 100644 --- a/src/routes/(app)/rooms/+page.ts +++ b/src/routes/(app)/rooms/+page.ts @@ -5,7 +5,8 @@ import { loadWrap } from "$lib/shared/util"; export const load: PageLoad = async (event) => { return loadWrap(async () => { - const rooms = await trpc(event).room.list.query(); - return { rooms }; + const hidden = event.url.searchParams.get("hidden") !== null; + const rooms = await trpc(event).room.list.query({ hidden }); + return { rooms, hidden }; }); }; diff --git a/src/routes/(app)/station/[id]/+page.server.ts b/src/routes/(app)/station/[id]/+page.server.ts index d03c112..ee30f40 100644 --- a/src/routes/(app)/station/[id]/+page.server.ts +++ b/src/routes/(app)/station/[id]/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions } from "./$types"; import { fail, redirect } from "@sveltejs/kit"; -import { superValidate } from "sveltekit-superforms"; +import { message, superValidate } from "sveltekit-superforms"; import { zod } from "sveltekit-superforms/adapters"; import { ZStationNew, ZUrlEntityId } from "$lib/shared/model/validation"; @@ -12,14 +12,18 @@ export const actions: Actions = { default: async (event) => loadWrap(async () => { const id = ZUrlEntityId.parse(event.params.id); const formData = await event.request.formData(); + const form = await superValidate(formData, zod(ZStationNew)); + const hide = formData.get("hide"); const del = formData.get("delete"); - if (del) { + if (hide) { + const hidden = Boolean(parseInt(hide.toString())); + await trpc(event).station.hide.mutate({ id, hidden }); + return message(form, "Station " + (hidden ? "ausgeblendet" : "eingeblendet")); + } else if (del) { await trpc(event).station.delete.mutate(id); redirect(302, "/stations"); } else { - const form = await superValidate(formData, zod(ZStationNew)); - if (!form.valid) { return fail(400, { form }); } @@ -28,8 +32,7 @@ export const actions: Actions = { id, station: form.data, }); - - return { form }; } + return { form }; }), }; diff --git a/src/routes/(app)/station/[id]/+page.svelte b/src/routes/(app)/station/[id]/+page.svelte index 34531e1..2f99db3 100644 --- a/src/routes/(app)/station/[id]/+page.svelte +++ b/src/routes/(app)/station/[id]/+page.svelte @@ -1,6 +1,7 @@ @@ -10,8 +11,11 @@ Stationen -
- Neue Station +
+
+
diff --git a/src/routes/(app)/stations/+page.ts b/src/routes/(app)/stations/+page.ts index 679ca82..747cc98 100644 --- a/src/routes/(app)/stations/+page.ts +++ b/src/routes/(app)/stations/+page.ts @@ -5,7 +5,8 @@ import { loadWrap } from "$lib/shared/util"; export const load: PageLoad = async (event) => { return loadWrap(async () => { - const stations = await trpc(event).station.list.query(); - return { stations }; + const hidden = event.url.searchParams.get("hidden") !== null; + const stations = await trpc(event).station.list.query({ hidden }); + return { stations, hidden }; }); }; diff --git a/src/routes/(app)/test/+page.svelte b/src/routes/(app)/test/+page.svelte new file mode 100644 index 0000000..a08fe61 --- /dev/null +++ b/src/routes/(app)/test/+page.svelte @@ -0,0 +1,8 @@ + + +
+ + +
diff --git a/src/routes/(app)/visit/+page.svelte b/src/routes/(app)/visit/+page.svelte index 1ff17d6..ef23819 100644 --- a/src/routes/(app)/visit/+page.svelte +++ b/src/routes/(app)/visit/+page.svelte @@ -4,9 +4,8 @@ import type { PageData } from "./$types"; import { URL_VISIT } from "$lib/shared/constants"; - import type { PaginationRequest } from "$lib/shared/model"; + import type { PaginationRequest, Station } from "$lib/shared/model"; import { trpc } from "$lib/shared/trpc"; - import type { RouterOutput } from "$lib/shared/trpc"; import { DateRange, getQueryUrl } from "$lib/shared/util"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; @@ -17,7 +16,7 @@ export let data: PageData; let dateRange: DateRange; - let selection: RouterOutput["station"]["get"] | null; + let selection: Station | null; $: if (data.query.filter?.date) { const date = data.query.filter?.date[0]; @@ -41,14 +40,14 @@ selection = null; } - function paginationUpdate(pagination: PaginationRequest) { + function paginationUpdate(pagination: PaginationRequest): void { updateQuery({ filter: data.query.filter, pagination, }); } - function filterUpdate() { + function filterUpdate(): void { updateQuery({ filter: { done: false, @@ -58,7 +57,7 @@ }); } - function updateQuery(q: typeof data.query) { + function updateQuery(q: typeof data.query): void { if (browser) { // Update page URL const url = getQueryUrl(q, URL_VISIT); diff --git a/src/routes/(app)/visit/+page.ts b/src/routes/(app)/visit/+page.ts index bd1917b..c6b99cc 100644 --- a/src/routes/(app)/visit/+page.ts +++ b/src/routes/(app)/visit/+page.ts @@ -3,12 +3,9 @@ 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 { defaultVisitUrl, loadWrap, parseQueryUrl } from "$lib/shared/util"; export const load: PageLoad = async (event) => { return loadWrap(async () => { @@ -20,13 +17,7 @@ export const load: PageLoad = async (event) => { } if (!query.filter) { - const url = getQueryUrl({ - filter: { - done: false, - date: [{ id: DateRange.thisWeek().toString() }], - }, - }, URL_VISIT); - redirect(302, url); + redirect(302, defaultVisitUrl()); } // Sort entries by date diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0d87d59..a18fc7e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,8 +3,10 @@ import { navigating } from "$app/stores"; + import { SvelteToast } from "@zerodevx/svelte-toast"; + import LoadingBar from "$lib/components/ui/LoadingBar.svelte"; - import { screenWidth } from "$lib/stores/layout"; + import { screenWidth } from "$lib/stores"; let loadingBar: LoadingBar | undefined; @@ -13,9 +15,12 @@ } else { loadingBar?.reset(); } + + const options = { pausable: true }; +
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index e01e0df..b5212e1 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -10,7 +10,7 @@ import { makeAuthjsRequest } from "$lib/server/auth"; */ const COOKIE_NAME = "autoLoginTs"; -async function doLogin(event: RequestEvent) { +async function doLogin(event: RequestEvent): Promise { const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url); return makeAuthjsRequest(event, "signin/keycloak", { callbackUrl }); diff --git a/tailwind.config.cjs b/tailwind.config.cjs index db96d07..99f32dd 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,4 +1,7 @@ /** @type {import('tailwindcss').Config}*/ + +const themes = require("daisyui/src/theming/themes"); + const config = { content: [ "./src/**/*.{html,js,svelte,ts}", @@ -9,6 +12,15 @@ const config = { daisyui: { logs: false, + themes: [ + { + light: { + ...themes["light"], + "primary": "#799dff", + } + }, + "dark" + ], }, safelist: ["prose"], }; diff --git a/tests/helpers/reset-db.ts b/tests/helpers/reset-db.ts index 2cbe018..a7d12f8 100644 --- a/tests/helpers/reset-db.ts +++ b/tests/helpers/reset-db.ts @@ -1,9 +1,12 @@ import { prisma } from "$lib/server/prisma"; -import { CATEGORIES, STATIONS, USERS } from "./testdata"; +import { + CATEGORIES, ROOMS, STATIONS, USERS, +} from "./testdata"; export default async () => { await prisma.$transaction([ + prisma.savedFilter.deleteMany(), prisma.entryExecution.deleteMany(), prisma.entryVersion.deleteMany(), prisma.entry.deleteMany(), @@ -17,11 +20,9 @@ export default async () => { prisma.category.createMany({ data: CATEGORIES }), prisma.station.createMany({ data: STATIONS }), prisma.room.createMany({ - data: [ - { id: 1, name: "R1.1", station_id: 1 }, - { id: 2, name: "R1.2", station_id: 1 }, - { id: 3, name: "R2.1", station_id: 2 }, - ], + data: ROOMS.map((v) => { + return { id: v.id, name: v.name, station_id: v.station.id }; + }), }), prisma.patient.createMany({ data: [ @@ -55,7 +56,7 @@ export default async () => { prisma.$executeRawUnsafe( `alter sequence stations_id_seq restart with ${STATIONS.length + 1}`, ), - prisma.$executeRawUnsafe("alter sequence rooms_id_seq restart with 4"), + prisma.$executeRawUnsafe(`alter sequence rooms_id_seq restart with ${ROOMS.length + 1}`), prisma.$executeRawUnsafe("alter sequence patients_id_seq restart with 4"), prisma.$executeRawUnsafe("alter sequence entry_executions_id_seq restart with 1"), prisma.$executeRawUnsafe("alter sequence entry_versions_id_seq restart with 1"), diff --git a/tests/helpers/testdata.ts b/tests/helpers/testdata.ts index e802b0e..1f7b0d2 100644 --- a/tests/helpers/testdata.ts +++ b/tests/helpers/testdata.ts @@ -1,9 +1,33 @@ -import type { Category, Station } from "$lib/shared/model"; +import type { Category, Station, Room } from "$lib/shared/model"; export const S1: Station = { id: 1, name: "S1" }; export const S2: Station = { id: 2, name: "S2" }; +export const S3: Station = { id: 3, name: "S3_tmp" }; -export const STATIONS: Station[] = [S1, S2]; +export const STATIONS: Station[] = [S1, S2, S3]; + +export const ROOMS: Room[] = [ + { + id: 1, + name: "R1.1", + station: S1, + }, + { + id: 2, + name: "R1.2", + station: S1, + }, + { + id: 3, + name: "R2.1", + station: S2, + }, + { + id: 4, + name: "Rtmp", + station: S2, + }, +]; export const CATEGORIES: Category[] = [ { diff --git a/tests/integration/query/category.ts b/tests/integration/query/category.ts index 2e4e0fc..2e17793 100644 --- a/tests/integration/query/category.ts +++ b/tests/integration/query/category.ts @@ -1,6 +1,8 @@ import { expect, test } from "vitest"; -import { getCategories, getCategory, newCategory } from "$lib/server/query"; +import { + deleteCategory, getCategories, getCategory, hideCategory, newCategory, +} from "$lib/server/query"; import { CATEGORIES } from "$tests/helpers/testdata"; test("create category", async () => { @@ -21,3 +23,19 @@ test("get categories", async () => { const categories = await getCategories(); expect(categories).toStrictEqual(CATEGORIES); }); + +test("delete categories", async () => { + await deleteCategory(6); + expect(getCategory(6)).rejects.toThrowError("No Category found"); +}); + +test("hide category", async () => { + await hideCategory(1, true); + const cs1 = await getCategories(); + const exp = [...CATEGORIES]; + exp.splice(0, 1); + expect(cs1).toStrictEqual(exp); + await hideCategory(1, false); + const cs2 = await getCategories(); + expect(cs2).toStrictEqual(CATEGORIES); +}); diff --git a/tests/integration/query/entry.ts b/tests/integration/query/entry.ts index 3d8709e..226075d 100644 --- a/tests/integration/query/entry.ts +++ b/tests/integration/query/entry.ts @@ -3,12 +3,14 @@ import { expect, test } from "vitest"; import { ErrorConflict } from "$lib/shared/util/error"; import { + getCategoryNEntries, getEntries, getEntry, getEntryNExecutions, getEntryNVersions, getEntryVersions, getNTodo, + getPatientNEntries, newEntry, newEntryExecution, newEntryVersion, @@ -21,6 +23,46 @@ const TEST_VERSION = { priority: false, }; +async function insertTestEntries() { + // Create some entries + const eId1 = await newEntry(1, { + patient_id: 1, + version: TEST_VERSION, + }); + const eId2 = await newEntry(1, { + patient_id: 2, + version: { + text: "Carrot cake jelly-o bonbon toffee chocolate.", + date: "2024-01-05", + priority: false, + category_id: null, + }, + }); + const eId3 = await newEntry(1, { + patient_id: 1, + version: { + text: "Cheesecake danish donut oat cake caramels.", + date: "2024-01-06", + priority: false, + category_id: null, + }, + }); + + // Update an entry + await newEntryVersion(2, eId1, { + category_id: 3, + text: `${TEST_VERSION.text}\n\n> Hello World`, + date: "2024-01-01", + priority: true, + }); + + // Execute entries + await newEntryExecution(1, eId2, { text: "Some execution txt" }); + await newEntryExecution(2, eId3, { text: "More execution txt" }); + + return { eId1, eId2, eId3 }; +} + test("create entry", async () => { const eId = await newEntry(1, { patient_id: 1, @@ -163,46 +205,6 @@ test("create entry execution (wrong old xid)", async () => { expect(async () => newEntryExecution(1, eId, { text: "x2" }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match")); }); -async function insertTestEntries() { - // Create some entries - const eId1 = await newEntry(1, { - patient_id: 1, - version: TEST_VERSION, - }); - const eId2 = await newEntry(1, { - patient_id: 2, - version: { - text: "Carrot cake jelly-o bonbon toffee chocolate.", - date: "2024-01-05", - priority: false, - category_id: null, - }, - }); - const eId3 = await newEntry(1, { - patient_id: 1, - version: { - text: "Cheesecake danish donut oat cake caramels.", - date: "2024-01-06", - priority: false, - category_id: null, - }, - }); - - // Update an entry - await newEntryVersion(2, eId1, { - category_id: 3, - text: `${TEST_VERSION.text}\n\n> Hello World`, - date: "2024-01-01", - priority: true, - }); - - // Execute entries - await newEntryExecution(1, eId2, { text: "Some execution txt" }); - await newEntryExecution(2, eId3, { text: "More execution txt" }); - - return { eId1, eId2, eId3 }; -} - test("get entries", async () => { const { eId1, eId2, eId3 } = await insertTestEntries(); const entries = await getEntries({}, {}); @@ -290,5 +292,15 @@ test("get entries", async () => { // NTodo const n = await getNTodo(new Date("2024-01-05")); - expect(n).toBe(2); + expect(n).toBe(1); +}); + +test("get patient n entries", async () => { + await insertTestEntries(); + expect(await getPatientNEntries(1)).toBe(2); +}); + +test("get category n entries", async () => { + await insertTestEntries(); + expect(await getCategoryNEntries(3)).toBe(1); }); diff --git a/tests/integration/query/patient.ts b/tests/integration/query/patient.ts index 4b0ee39..e2633de 100644 --- a/tests/integration/query/patient.ts +++ b/tests/integration/query/patient.ts @@ -64,9 +64,7 @@ test("delete patient (restricted)", async () => { }, }); - expect(async () => deletePatient(pId)).rejects.toThrowError( - "cannot delete patient with entries", - ); + expect(async () => deletePatient(pId)).rejects.toThrowError(); }); test("hide patient", async () => { diff --git a/tests/integration/query/room.ts b/tests/integration/query/room.ts index d5ca5b0..b730a3f 100644 --- a/tests/integration/query/room.ts +++ b/tests/integration/query/room.ts @@ -1,42 +1,20 @@ import { expect, test } from "vitest"; -import type { Room, Station } from "$lib/shared/model"; +import type { RoomDetail } from "$lib/shared/model"; import { - deleteStation, + deleteRoom, getRoom, + getRoomNPatients, getRooms, getStation, - getStations, + hideRoom, newRoom, - newStation, updateStation, } from "$lib/server/query"; -import { S1, S2 } from "$tests/helpers/testdata"; - -test("create station", async () => { - const sId = await newStation({ name: "S3" }); - const station = await getStation(sId); - expect(station).toStrictEqual({ id: sId, name: "S3" } satisfies Station); -}); - -test("update station", async () => { - const name = "HelloStation"; - await updateStation(S1.id, { name }); - const station = await getStation(S1.id); - expect(station.id).toBe(S1.id); - expect(station.name).toBe(name); -}); - -test("delete station", async () => { - await deleteStation(S1.id); - expect(async () => getStation(S1.id)).rejects.toThrowError("No Station found"); -}); - -test("get stations", async () => { - const stations = await getStations(); - expect(stations).toStrictEqual([S1, S2]); -}); +import { + ROOMS, S1, +} from "$tests/helpers/testdata"; test("create room", async () => { const rId = await newRoom({ name: "A1", station_id: 1 }); @@ -45,7 +23,8 @@ test("create room", async () => { id: rId, name: "A1", station: S1, - } satisfies Room); + hidden: false, + } satisfies RoomDetail); }); test("update room", async () => { @@ -57,27 +36,26 @@ test("update room", async () => { }); test("delete room", async () => { - await deleteStation(S1.id); - expect(async () => getStation(S1.id)).rejects.toThrowError("No Station found"); + await deleteRoom(ROOMS[3].id); + expect(async () => getRoom(ROOMS[3].id)).rejects.toThrowError("No Room found"); +}); + +test("hide room", async () => { + await hideRoom(ROOMS[0].id, true); + const rs1 = await getRooms(); + const exp = [...ROOMS]; + exp.splice(0, 1); + expect(rs1).toStrictEqual(exp); + await hideRoom(ROOMS[0].id, false); + const rs2 = await getRooms(); + expect(rs2).toStrictEqual(ROOMS); }); test("get rooms", async () => { const rooms = await getRooms(); - expect(rooms).toStrictEqual([ - { - id: 1, - name: "R1.1", - station: S1, - }, - { - id: 2, - name: "R1.2", - station: S1, - }, - { - id: 3, - name: "R2.1", - station: S2, - }, - ] satisfies Room[]); + expect(rooms).toStrictEqual(ROOMS); +}); + +test("get room n patients", async () => { + expect(await getRoomNPatients(ROOMS[0].id)).toBe(1); }); diff --git a/tests/integration/query/savedFilter.ts b/tests/integration/query/savedFilter.ts new file mode 100644 index 0000000..c7c02cc --- /dev/null +++ b/tests/integration/query/savedFilter.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vitest"; + +import { + deleteSavedFilter, + getAllFilters, + getDefaultFilter, getSavedFilters, newSavedFilter, updateSavedFilter, +} from "$lib/server/query/savedFilter"; + +const VIEW = "plan"; + +async function createFilters() { + const fA1 = await newSavedFilter({ + name: "Todo", + query: "filter[done]=false", + view: VIEW, + }, 1); + const fA2 = await newSavedFilter({ + name: "Done", + query: "filter[done]=true", + view: VIEW, + }, 1); + const fADefault = await newSavedFilter({ + name: "Default", + query: "filter[done]=true&filter[station][0][id]=1&filter[station][0][name]=S1", + view: VIEW, + }, 1); + const fADefaultP = await newSavedFilter({ + name: "Default", + query: "filter[station][0][id]=1&filter[station][0][name]=S1", + view: "patients", + }, 1); + const fAOther = await newSavedFilter({ + name: "Done", + query: "filter[done]=true", + view: "other", + }, 1); + const fB1 = await newSavedFilter({ + name: "Prio", + query: "filter[priority]=true", + view: VIEW, + }, 2); + return { + fA1, fA2, fADefault, fB1, fADefaultP, fAOther, + }; +} + +const U1_FILTERS = (ids: Awaited>) => [ + // Default filter must be ordered on top + { + id: ids.fADefault, + name: "Default", + query: "filter[done]=true&filter[station][0][id]=1&filter[station][0][name]=S1", + }, + { + id: ids.fA1, + name: "Todo", + query: "filter[done]=false", + }, + { + id: ids.fA2, + name: "Done", + query: "filter[done]=true", + }, +]; + +test("get filters", async () => { + const ids = await createFilters(); + + const u1Filters = await getSavedFilters(1, VIEW); + expect(u1Filters).toStrictEqual(U1_FILTERS(ids)); + + const u2Filters = await getSavedFilters(2, VIEW); + expect(u2Filters).toStrictEqual([ + { + id: ids.fB1, + name: "Prio", + query: "filter[priority]=true", + }, + ]); +}); + +test("get default filter", async () => { + const ids = await createFilters(); + + const filter = await getDefaultFilter(1, VIEW); + expect(filter).toStrictEqual(U1_FILTERS(ids)[0]); +}); + +test("update filter", async () => { + const q = "filter[done]=true"; + const ids = await createFilters(); + await updateSavedFilter(ids.fADefault, q, 1); + + const filter = await getDefaultFilter(1, VIEW); + expect(filter?.query).toBe(q); +}); + +test("update filter not found", async () => { + expect(updateSavedFilter(1, "Hello World", 1)).rejects.toThrowError(); +}); + +test("delete filter", async () => { + const ids = await createFilters(); + await deleteSavedFilter(ids.fADefault, 1); + const filter = await getDefaultFilter(1, VIEW); + expect(filter).toBe(null); +}); + +test("get all filters", async () => { + const ids = await createFilters(); + const filters = await getAllFilters(1); + expect(filters).toStrictEqual({ + other: [ + { + id: ids.fAOther, + name: "Done", + query: "filter[done]=true", + }, + ], + patients: [ + { + id: ids.fADefaultP, + name: "Default", + query: "filter[station][0][id]=1&filter[station][0][name]=S1", + }, + ], + plan: U1_FILTERS(ids), + }); +}); diff --git a/tests/integration/query/station.ts b/tests/integration/query/station.ts new file mode 100644 index 0000000..65a8db9 --- /dev/null +++ b/tests/integration/query/station.ts @@ -0,0 +1,47 @@ +import { expect, test } from "vitest"; + +import type { StationDetail } from "$lib/shared/model"; + +import { + deleteStation, getStation, getStationNRooms, getStations, hideStation, newStation, updateStation, +} from "$lib/server/query"; +import { + STATIONS, S1, S2, S3, +} from "$tests/helpers/testdata"; + +test("create station", async () => { + const sId = await newStation({ name: "S3" }); + const station = await getStation(sId); + expect(station).toStrictEqual({ id: sId, name: "S3", hidden: false } satisfies StationDetail); +}); + +test("update station", async () => { + const name = "HelloStation"; + await updateStation(S1.id, { name }); + const station = await getStation(S1.id); + expect(station.id).toBe(S1.id); + expect(station.name).toBe(name); +}); + +test("delete station", async () => { + await deleteStation(S3.id); + expect(async () => getStation(S3.id)).rejects.toThrowError("No Station found"); +}); + +test("hide station", async () => { + await hideStation(S1.id, true); + const cs1 = await getStations(); + expect(cs1).toStrictEqual([S2, S3]); + await hideStation(S1.id, false); + const cs2 = await getStations(); + expect(cs2).toStrictEqual(STATIONS); +}); + +test("get stations", async () => { + const stations = await getStations(); + expect(stations).toStrictEqual(STATIONS); +}); + +test("get station n rooms", async () => { + expect(await getStationNRooms(1)).toBe(2); +}); diff --git a/vite.config.js b/vite.config.js index 49c9e7c..6539b16 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,9 +1,77 @@ +import { exec } from "child_process"; +import { promisify } from "util"; + import { sveltekit } from "@sveltejs/kit/vite"; +import { createViteLicensePlugin } from "rollup-license-plugin"; import { defineConfig } from "vitest/config"; +// Get current tag/commit and last commit date from git +const pexec = promisify(exec); +let [version, lastmod] = ( + await Promise.allSettled([ + pexec("git describe --tags || git rev-parse --short HEAD"), + pexec('git log -1 --format=%cd --date=format:"%Y-%m-%d %H:%M"'), + ]) +).map((v) => { + if (v.status !== "fulfilled") throw Error("could not get version info from git"); + return JSON.stringify(v.value.stdout.trim()); +}); + export default defineConfig({ - plugins: [sveltekit()], + plugins: [ + sveltekit(), + createViteLicensePlugin({ + additionalFiles: { + "oss-licenses.html": (packages) => { + let res = ` + +Visitenbuch - Lizenzen + + +

Open-Source-Lizenzen

+JSON-formatted license list +`; + for (const p of packages) { + // @ts-expect-error repo not present in type definition + let rp = p.repository; + // @ts-expect-error author not present in type definition + let aut = p.author; + if (typeof aut === "object") { + aut = aut.name; + } + + let repoUrl = null; + if (typeof rp === "string") { + if (rp.startsWith("http")) repoUrl = rp; + else if (rp.startsWith("git+http")) repoUrl = rp.substring(4); + else if (rp.startsWith("git://")) repoUrl = "https://" + rp.substring(6); + else if (rp.startsWith("github:")) repoUrl = "https://github.com/" + rp.substring(7); + else if (rp.match(/^[\w-]+\/[\w-]+$/)) repoUrl = "https://github.com/" + rp; + } + + res += `
\n`; + res += `

${p.name}

\n`; + res += `\n`; + res += `\n`; + if (aut) res += `\n`; + res += `\n`; + if (repoUrl) res += `\n`; + else if (rp) res += `\n`; + res += `
Version:${p.version}
Author:${aut}
License:${p.license}
Repository:${repoUrl}
Repository:${rp}
\n`; + res += "
"; + } + + res += "\n\n"; + return res; + }, + }, + }), + ], test: { include: ["src/**/*.{test,spec}.{js,ts}"], }, + define: { + __VERSION__: version, + __LASTMOD__: lastmod, + }, });