From 29d092aaac4d3bfa96ba84f7c7049f00076ddb9b Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 11 May 2024 03:07:23 +0200 Subject: [PATCH 1/4] refactor: SchemaNewExecution definition --- src/routes/(app)/entry/[id]/+page.server.ts | 2 +- src/routes/(app)/entry/[id]/+page.svelte | 2 +- src/routes/(app)/entry/[id]/+page.ts | 2 +- .../(app)/entry/[id]/editExecution/+page.server.ts | 2 +- .../(app)/entry/[id]/editExecution/+page.svelte | 2 +- src/routes/(app)/entry/[id]/editExecution/+page.ts | 2 +- src/routes/(app)/entry/[id]/editExecution/schema.ts | 9 --------- src/routes/(app)/entry/[id]/schema.ts | 13 ++++++------- 8 files changed, 12 insertions(+), 22 deletions(-) delete mode 100644 src/routes/(app)/entry/[id]/editExecution/schema.ts diff --git a/src/routes/(app)/entry/[id]/+page.server.ts b/src/routes/(app)/entry/[id]/+page.server.ts index bfd54cc..49aa997 100644 --- a/src/routes/(app)/entry/[id]/+page.server.ts +++ b/src/routes/(app)/entry/[id]/+page.server.ts @@ -7,7 +7,7 @@ import { ZUrlEntityId } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; import { loadWrap, moveEntryTodoDate } from "$lib/shared/util"; -import { SchemaNewExecution } from "./editExecution/schema"; +import { SchemaNewExecution } from "./schema"; export const actions: Actions = { default: async (event) => loadWrap(async () => { diff --git a/src/routes/(app)/entry/[id]/+page.svelte b/src/routes/(app)/entry/[id]/+page.svelte index 8da72e7..6828514 100644 --- a/src/routes/(app)/entry/[id]/+page.svelte +++ b/src/routes/(app)/entry/[id]/+page.svelte @@ -10,7 +10,7 @@ import EntryTodoButton from "$lib/components/ui/EntryTodoButton.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; - import { SchemaNewExecution } from "./editExecution/schema"; + import { SchemaNewExecution } from "./schema"; export let data: PageData; diff --git a/src/routes/(app)/entry/[id]/+page.ts b/src/routes/(app)/entry/[id]/+page.ts index 3e48be4..98499b7 100644 --- a/src/routes/(app)/entry/[id]/+page.ts +++ b/src/routes/(app)/entry/[id]/+page.ts @@ -6,7 +6,7 @@ import { ZUrlEntityId } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; import { loadWrap } from "$lib/shared/util"; -import { SchemaNewExecution } from "./editExecution/schema"; +import { SchemaNewExecution } from "./schema"; export const load: PageLoad = async (event) => { const entry = await loadWrap(async () => { diff --git a/src/routes/(app)/entry/[id]/editExecution/+page.server.ts b/src/routes/(app)/entry/[id]/editExecution/+page.server.ts index 2fd0dbe..92b9b6c 100644 --- a/src/routes/(app)/entry/[id]/editExecution/+page.server.ts +++ b/src/routes/(app)/entry/[id]/editExecution/+page.server.ts @@ -7,7 +7,7 @@ import { ZUrlEntityId } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; import { loadWrap, moveEntryTodoDate } from "$lib/shared/util"; -import { SchemaNewExecution } from "./schema"; +import { SchemaNewExecution } from "../schema"; export const actions: Actions = { default: async (event) => loadWrap(async () => { diff --git a/src/routes/(app)/entry/[id]/editExecution/+page.svelte b/src/routes/(app)/entry/[id]/editExecution/+page.svelte index 6815372..d67ec0f 100644 --- a/src/routes/(app)/entry/[id]/editExecution/+page.svelte +++ b/src/routes/(app)/entry/[id]/editExecution/+page.svelte @@ -10,7 +10,7 @@ import EntryTodoButton from "$lib/components/ui/EntryTodoButton.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; - import { SchemaNewExecution } from "./schema"; + import { SchemaNewExecution } from "../schema"; export let data: PageData; diff --git a/src/routes/(app)/entry/[id]/editExecution/+page.ts b/src/routes/(app)/entry/[id]/editExecution/+page.ts index 98499b7..dcaced1 100644 --- a/src/routes/(app)/entry/[id]/editExecution/+page.ts +++ b/src/routes/(app)/entry/[id]/editExecution/+page.ts @@ -6,7 +6,7 @@ import { ZUrlEntityId } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; import { loadWrap } from "$lib/shared/util"; -import { SchemaNewExecution } from "./schema"; +import { SchemaNewExecution } from "../schema"; export const load: PageLoad = async (event) => { const entry = await loadWrap(async () => { diff --git a/src/routes/(app)/entry/[id]/editExecution/schema.ts b/src/routes/(app)/entry/[id]/editExecution/schema.ts deleted file mode 100644 index dd65863..0000000 --- a/src/routes/(app)/entry/[id]/editExecution/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { zod } from "sveltekit-superforms/adapters"; - -import { ZEntryExecutionNew, fields } from "$lib/shared/model/validation"; - -export const SchemaNewExecution = zod( - ZEntryExecutionNew.extend({ - old_execution_id: fields.EntityId().optional(), - }), -); diff --git a/src/routes/(app)/entry/[id]/schema.ts b/src/routes/(app)/entry/[id]/schema.ts index 5613684..dd65863 100644 --- a/src/routes/(app)/entry/[id]/schema.ts +++ b/src/routes/(app)/entry/[id]/schema.ts @@ -1,10 +1,9 @@ import { zod } from "sveltekit-superforms/adapters"; -import { z } from "zod"; -import { fields } from "$lib/shared/model/validation"; +import { ZEntryExecutionNew, fields } from "$lib/shared/model/validation"; -const ZEntryDone = z.object({ - text: fields.TextString(), -}); - -export const SchemaEntryExecution = zod(ZEntryDone); +export const SchemaNewExecution = zod( + ZEntryExecutionNew.extend({ + old_execution_id: fields.EntityId().optional(), + }), +); From b2a188831fb19f3bc5e39b0dfdc53a6767c60e8a Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 12 May 2024 03:59:57 +0200 Subject: [PATCH 2/4] feat: improve date selector (allow for manual range input, add day/week arrows on visit view) --- eslint.config.js | 4 - src/lib/components/filter/Autocomplete.svelte | 11 +- src/lib/components/filter/FilterChip.svelte | 9 ++ src/lib/components/filter/filters.ts | 10 +- src/lib/components/filter/types.ts | 3 + src/lib/components/ui/WeekSelector.svelte | 49 ++++----- src/lib/shared/util/date.test.ts | 70 +++++++++++- src/lib/shared/util/date.ts | 102 ++++++++++++++++-- src/lib/shared/util/util.test.ts | 2 +- src/lib/shared/util/util.ts | 2 +- src/routes/(app)/visit/+page.svelte | 2 +- 11 files changed, 216 insertions(+), 48 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 4d7656d..3f8cd75 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -335,10 +335,6 @@ export default [ // disallow use of the Array constructor "@typescript-eslint/no-array-constructor": "warn", - // disallow if as the only statement in an else block - // https://eslint.org/docs/rules/no-lonely-if - "no-lonely-if": "warn", - // disallow un-paren'd mixes of different operators // https://eslint.org/docs/rules/no-mixed-operators "@stylistic/no-mixed-operators": [ diff --git a/src/lib/components/filter/Autocomplete.svelte b/src/lib/components/filter/Autocomplete.svelte index 05badf5..bbcfd30 100644 --- a/src/lib/components/filter/Autocomplete.svelte +++ b/src/lib/components/filter/Autocomplete.svelte @@ -13,7 +13,7 @@ import IconButton from "$lib/components/ui/IconButton.svelte"; import outclick from "$lib/actions/outclick"; - import type { BaseItem } from "./types"; + import type { BaseItem, OnSelectResult } from "./types"; /** * This is a simplified version of simple-svelte-autocomplete @@ -23,7 +23,6 @@ */ type T = $$Generic; - type OnSelectResult = { newValue: string; close: boolean }; /** List of items to choose from (or an async function fetching them) */ export let items: T[] | (() => Promise); @@ -58,6 +57,7 @@ export let filterFn: (item: T) => boolean = () => true; /** Selection callback. Returns the new input value after selection */ + export let onTextInput: (value: string) => OnSelectResult | void = () => {}; export let onSelect: (item: T, kb: boolean) => OnSelectResult | void = () => {}; export let onUnselect = (): void => {}; export let onClose = (kb: boolean): void => {}; @@ -267,6 +267,13 @@ function selectItem(): void { const listItem = filteredItems[highlightIndex]; selectListItem(listItem, true); + if (!listItem && inputElm && inputElm.value.length > 0) { + const res = onTextInput(inputElm.value); + if (res) { + setInputValue(res.newValue); + if (res.close) close(true); + } + } } const [floatingRef, floatingContent] = createFloatingActions({ diff --git a/src/lib/components/filter/FilterChip.svelte b/src/lib/components/filter/FilterChip.svelte index 1f76870..50bac86 100644 --- a/src/lib/components/filter/FilterChip.svelte +++ b/src/lib/components/filter/FilterChip.svelte @@ -33,6 +33,14 @@ else onRemove(); } + const onTextInput = filter.textToItem ? (text: string) => { + const res = filter.textToItem!(text); + if (res) { + fdata.selection = res; + return { close: true, newValue: "" }; + } + } : undefined; + $: if (fdata.editing && autocomplete) { autocomplete.open(); } @@ -91,6 +99,7 @@ } return { close: false, newValue: item.name ?? "" }; }} + {onTextInput} padding={false} partOfFilterbar selection={fdata.selection?.id diff --git a/src/lib/components/filter/filters.ts b/src/lib/components/filter/filters.ts index 905e389..50dac8a 100644 --- a/src/lib/components/filter/filters.ts +++ b/src/lib/components/filter/filters.ts @@ -28,7 +28,7 @@ export function weekFilterItems(): BaseItem[] { res.push({ id: r.toString(), name: r.format() }); }; - addRange(new DateRange(null, range.start)); + addRange(new DateRange(null, range.end)); for (let i = 0; i < WEEK_LIMIT; i++) { addRange(range); range.addDays(7); @@ -104,10 +104,16 @@ export const ENTRY_FILTERS: Record = { }, date: { id: "date", - name: "Woche", + name: "Datum", icon: mdiCalendar, inputType: InputType.FilterList, options: async () => weekFilterItems(), + textToItem: (s) => { + const parsed = DateRange.parseHuman(s); + if (parsed) { + return { id: parsed.toString(), name: parsed.format() }; + } + }, }, }; diff --git a/src/lib/components/filter/types.ts b/src/lib/components/filter/types.ts index a5128e3..cc3eb6b 100644 --- a/src/lib/components/filter/types.ts +++ b/src/lib/components/filter/types.ts @@ -28,6 +28,7 @@ export type FilterDef = { icon?: string; }; options?: () => Promise; + textToItem?: (s: string) => BaseItem | void; }; export type FilterData = { @@ -41,6 +42,8 @@ export type FilterQdata = Record< string | number | boolean | { id: string | number; name?: string }[] >; +export type OnSelectResult = { newValue: string; close: boolean }; + export function isFilterValueless(inputType: InputType): boolean { return inputType === InputType.None || inputType === InputType.Boolean; } diff --git a/src/lib/components/ui/WeekSelector.svelte b/src/lib/components/ui/WeekSelector.svelte index ce4008d..d7cec35 100644 --- a/src/lib/components/ui/WeekSelector.svelte +++ b/src/lib/components/ui/WeekSelector.svelte @@ -1,11 +1,13 @@
- + addDays(true, false)} /> + addDays(false, false)} /> {#if editing} weekFilterItems()} + noAutoselect1 onClose={stopEditing} onSelect={(item) => { if (typeof item.id === "string") { @@ -67,10 +62,12 @@ } return { close: true, newValue: "" }; }} + {onTextInput} partOfFilterbar selection={{ id: dateRange.toString(), name: dateRange.format() }} /> {:else} - + {/if} - + addDays(false, true)} /> + addDays(true, true)} />
diff --git a/src/lib/shared/util/date.test.ts b/src/lib/shared/util/date.test.ts index eca9cc9..38c0401 100644 --- a/src/lib/shared/util/date.test.ts +++ b/src/lib/shared/util/date.test.ts @@ -1,7 +1,14 @@ import { expect, it, vi } from "vitest"; import { - DateRange, dateFromYMD, dateToYMD, formatDate, humanDate, utcDateToYMD, + DateRange, + dateFromHuman, + dateFromYMD, + dateToYMD, + formatDate, + humanDate, + shiftDateRange, + utcDateToYMD, } from "./date"; const MINUTE = 60000; @@ -72,3 +79,64 @@ it.each([ expect(res.toString()).toBe(s); } }); + +it.each([ + // Open ranges + { + r: "..2024-04-14", week: true, fwd: true, exp: "2024-04-15..2024-04-21", + }, + { + r: "..2024-04-14", week: true, fwd: false, exp: "2024-04-08..2024-04-14", + }, + { + r: "2024-04-08..", week: true, fwd: true, exp: "2024-04-08..2024-04-14", + }, + { + r: "2024-04-08..", week: true, fwd: false, exp: "2024-04-01..2024-04-07", + }, + // Full week + { + r: "2024-04-08..2024-04-14", week: true, fwd: true, exp: "2024-04-15..2024-04-21", + }, + { + r: "2024-04-08..2024-04-14", week: true, fwd: false, exp: "2024-04-01..2024-04-07", + }, + // Partial week + { + r: "2024-04-13..2024-04-16", week: true, fwd: true, exp: "2024-04-15..2024-04-21", + }, + { + r: "2024-04-13..2024-04-16", week: true, fwd: false, exp: "2024-04-08..2024-04-14", + }, + + // Days + { + r: "2024-04-13..2024-04-13", week: false, fwd: true, exp: "2024-04-14..2024-04-14", + }, + { + r: "2024-04-13..2024-04-13", week: false, fwd: false, exp: "2024-04-12..2024-04-12", + }, + // Full range to day + { + r: "2024-04-08..2024-04-14", week: false, fwd: true, exp: "2024-04-14..2024-04-14", // TODO: inc date (15) + }, + { + r: "2024-04-08..2024-04-14", week: false, fwd: false, exp: "2024-04-08..2024-04-08", // TODO: dec date (7) + }, + // Open range to day + { + r: "..2024-04-14", week: false, fwd: true, exp: "2024-04-15..2024-04-15", + }, +])("shiftDateRange $r", ({ + r, week, fwd, exp, +}) => { + const range = DateRange.parse(r, true)!; + const expected = DateRange.parse(exp, true); + shiftDateRange(range, week, fwd); + expect(range).toStrictEqual(expected); +}); + +it("dateFromHuman", () => { + expect(dateFromHuman("04.12.2023")).toStrictEqual(new Date(2023, 11, 4)); + expect(dateFromHuman("04.12.")).toStrictEqual(new Date(new Date().getFullYear(), 11, 4)); +}); diff --git a/src/lib/shared/util/date.ts b/src/lib/shared/util/date.ts index 1e21442..1b546a6 100644 --- a/src/lib/shared/util/date.ts +++ b/src/lib/shared/util/date.ts @@ -42,6 +42,22 @@ export function dateFromYMD(s: string): Date { return NaN; } +export function dateFromHuman(s: string): Date { + const parts = s.split(".").filter((x) => x.length > 0).map((x) => parseInt(x)); + if (parts.length > 0 && parts.length < 4) { + const [d, m, y] = parts; + const now = new Date(); + return new Date( + // eslint-disable-next-line no-nested-ternary + y ? y < 1000 ? y + 2000 : y : now.getFullYear(), + m ? m - 1 : now.getMonth(), + d, + ); + } + // @ts-expect-error emulate behavior of date constructor + return NaN; +} + /** Convert the given date to a string (using the internal UTC format) */ export function utcDateToYMD(date: Date): string { return date.toISOString().slice(0, 10); @@ -106,14 +122,19 @@ export class DateRange { return new DateRange(start, end); } + /** Get a date range of the week containing the given date */ + static weekOf(day: Date, offset = 0): DateRange { + const thisDay = new Date(day); + const todayWd = thisDay.getDay(); + // Day starts at Sunday (0) + const daysMinus = (todayWd === 0 ? 6 : todayWd - 1) - offset * 7; + thisDay.setDate(thisDay.getDate() - daysMinus); + return DateRange.withLength(thisDay, 6); + } + /** Create a date range of the current calendar week */ static thisWeek(): DateRange { - const dayStart = new Date(); - const todayWd = dayStart.getDay(); - // Day starts at Sunday (0) - const daysMinus = todayWd === 0 ? 6 : todayWd - 1; - dayStart.setDate(dayStart.getDate() - daysMinus); - return DateRange.withLength(dayStart, 6); + return DateRange.weekOf(new Date()); } /** Parse a date range from a string @@ -124,10 +145,23 @@ export class DateRange { * - Range with 1 end: `2024-04-13..`; `..2024-04-20` */ static parse(s: string, utc = false): DateRange | null { - const parts = s.split("..", 2); + return DateRange.parseInternal(s, "..", (p) => utc ? new Date(p) : dateFromYMD(p)); + } + + /** Parse a date range from human input */ + static parseHuman(s: string): DateRange | null { + return DateRange.parseInternal(s, "-", dateFromHuman); + } + + private static parseInternal( + s: string, + split: string | RegExp, + dateParser: (s: string) => Date, + ): DateRange | null { + const parts = s.split(split, 2); const parsed = parts.map((p) => { if (p.length === 0) return null; - return utc ? new Date(p) : dateFromYMD(p); + return dateParser(p); }); if (parsed.length === 0 @@ -145,8 +179,8 @@ export class DateRange { /** Shift the range by the given number of days. This modifies the range in-place */ addDays(n: number): void { - this.start?.setDate(this.start.getDate() + n); - this.end?.setDate(this.end.getDate() + n); + this.start?.setUTCDate(this.start.getUTCDate() + n); + this.end?.setUTCDate(this.end.getUTCDate() + n); } /** Return a parsable string representation */ @@ -162,6 +196,54 @@ export class DateRange { format(): string { if (this.start === null) return "bis " + formatDate(this.end!); if (this.end === null) return "ab " + formatDate(this.start); + if (this.start.getFullYear() === this.end.getFullYear() + && this.start.getMonth() === this.end.getMonth() + && this.start.getDate() === this.end.getDate()) return formatDate(this.start); return formatDate(this.start) + " \u2013 " + formatDate(this.end); } + + eq(b: DateRange): boolean { + return dateEq(this.start, b.start) && dateEq(this.end, b.end); + } +} + +function dateEq(d1: Date | null, d2: Date | null): boolean { + if (!d1 || !d2) return d1 === d2; + return d1.getUTCFullYear() === d2.getUTCFullYear() + && d1.getUTCMonth() === d2.getUTCMonth() + && d1.getUTCDate() === d2.getUTCDate(); +} + +export function shiftDateRange(dateRange: DateRange, week: boolean, fwd: boolean) { + let modDir = null; + if (dateRange.start === null) { + dateRange.start = new Date(dateRange.end!); + modDir = true; + } + if (dateRange.end === null) { + dateRange.end = new Date(dateRange.start!); + modDir = false; + } + + const inc = fwd ? 1 : -1; + const leader = fwd ? dateRange.end : dateRange.start; + + if (week) { + // Align range with week + const lweek = DateRange.weekOf(leader); + if (dateRange.eq(lweek)) { + if (modDir === null || modDir === fwd) dateRange.addDays(inc * 7); + } else { + dateRange.start = lweek.start; + dateRange.end = lweek.end; + if (modDir === fwd) dateRange.addDays(inc * 7); + } + } else { + if (dateEq(dateRange.start, dateRange.end)) { + dateRange.addDays(inc); + } else { + dateRange.start = leader; + dateRange.end = leader; + } + } } diff --git a/src/lib/shared/util/util.test.ts b/src/lib/shared/util/util.test.ts index f94ce78..c1ea818 100644 --- a/src/lib/shared/util/util.test.ts +++ b/src/lib/shared/util/util.test.ts @@ -20,7 +20,7 @@ it("getQueryUrl", () => { }; const queryUrl = getQueryUrl(query, ""); - expect(queryUrl).toBe("?filter%5Bauthor%5D%5B0%5D%5Bid%5D=2&filter%5Bauthor%5D%5B0%5D%5Bname%5D=Max&filter%5Bcategory%5D%5B0%5D%5Bid%5D=1&filter%5Bcategory%5D%5B0%5D%5Bname%5D=Blutabnahme&filter%5Bcategory%5D%5B1%5D%5Bid%5D=2&filter%5Bcategory%5D%5B1%5D%5Bname%5D=Labortests&filter%5Bdone%5D=true&filter%5Bsearch%5D=Hello%20World&pagination%5Blimit%5D=10&pagination%5Boffset%5D=20&sort%5Bfield%5D=room&sort%5Basc%5D=true"); + expect(queryUrl).toBe("?filter%5Bauthor%5D%5B0%5D%5Bid%5D=2&filter%5Bauthor%5D%5B0%5D%5Bname%5D=Max&filter%5Bcategory%5D%5B0%5D%5Bid%5D=1&filter%5Bcategory%5D%5B0%5D%5Bname%5D=Blutabnahme&filter%5Bcategory%5D%5B1%5D%5Bid%5D=2&filter%5Bcategory%5D%5B1%5D%5Bname%5D=Labortests&filter%5Bdone%5D=true&filter%5Bsearch%5D=Hello%20World&pagination%5Blimit%5D=10&pagination%5Boffset%5D=20&sort%5B0%5D=room"); const decoded = ZEntriesQuery.parse(parseQueryUrl(queryUrl)); expect(decoded).toStrictEqual(query); diff --git a/src/lib/shared/util/util.ts b/src/lib/shared/util/util.ts index afe25b4..33204f1 100644 --- a/src/lib/shared/util/util.ts +++ b/src/lib/shared/util/util.ts @@ -161,7 +161,7 @@ export function defaultVisitUrl(): string { export async function moveEntryTodoDate(id: number, nTodoDays: number, init?: TRPCClientInit) { if (nTodoDays > 0) { const entry = await trpc(init).entry.get.query(id); - const newDate = new Date(entry.current_version.date); + const newDate = new Date(); newDate.setDate(newDate.getDate() + nTodoDays); await trpc(init).entry.newVersion.mutate({ diff --git a/src/routes/(app)/visit/+page.svelte b/src/routes/(app)/visit/+page.svelte index ef23819..ca26df6 100644 --- a/src/routes/(app)/visit/+page.svelte +++ b/src/routes/(app)/visit/+page.svelte @@ -74,7 +74,7 @@
- Woche: + Zeitraum:
From 50861a804bcec4606311d75f1de03f948b355964 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 12 May 2024 14:46:18 +0200 Subject: [PATCH 3/4] add icon --- src/lib/assets/icon.opt.svg | 1 + src/lib/assets/icon.png | Bin 0 -> 2545 bytes src/lib/assets/icon.svg | 83 ++++++++++++++++++++++++++++ src/routes/(app)/about/+page.svelte | 3 + static/favicon.png | Bin 1571 -> 2545 bytes 5 files changed, 87 insertions(+) create mode 100644 src/lib/assets/icon.opt.svg create mode 100644 src/lib/assets/icon.png create mode 100644 src/lib/assets/icon.svg diff --git a/src/lib/assets/icon.opt.svg b/src/lib/assets/icon.opt.svg new file mode 100644 index 0000000..c31cd4e --- /dev/null +++ b/src/lib/assets/icon.opt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/assets/icon.png b/src/lib/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..33a689f6131289f89f102b5fa24594b12b408294 GIT binary patch literal 2545 zcmaJ@c{mi>8$L5;m=G#6m@5pGec!Vr#&(NP7&V2EeI47BW$H>7jmf@+7CU3yf$<4%A5{<^YDvNS!;iRJ_V95*+^+OQ(& zry$6%*rAnuCo)@k6m^u3;)y zv<##in91Y%VTzYB>}ZSQt&4lp_XZZ_{~T=FOj+w$oH;D^$YzhDmFy}QBs&&>2NG~T zVQJE=83bm^DW+uuI+CD>M1F$+&W&M@z`%dPo?5yat7LuG5>6az-93urh-U7k%NOcA zE^(9k&Lo{!DKN55Jqwbs;>y~ps(-kK@0<9+n;5GEFbxmjZ2ue_C~0{pREUf+^4jZh zoQkdOZ098108Dhko9kq+4^Kqemw(uuG-UG3(oU>6r1paYaNyUluWD->jf%tSFEXUI zwMjkm!~{MS1aVz;4u)|Zn*XP{w+T}TrQs{rys}ZCn(VHl;nq~-NxfCI4F*kU$7X-r z)hsXj$m1kir2(5&H#8#s{tpT(P%p`Qm$?g*RM%^3+KBflm#HdJUz!mi15WTlma|#NUdM3NjCJNb?QkG zBGCeRe4-o4tzaah2;sLR8prm9$qce{{zUQr0Q_Gmd01xL!=b<*9D*V{ptmciwXcSs zNY}$Fre}Pjseaedy&H}M>-mbJVk#4mnWSxN<{o7epQ~Q&%4=W-o!@50Zc*BfMmikb ziPDMpqgx*3C30ov&qooc3pIFNrBhsUGm&l2MW(WFh>r4NEBevt9iAgJe8B#A+0b>I z3F*zQQY?qQfm}q&u4>E~rKoKQZMg`pB2lPKuK-QwfFn3!GT-s%r$45~G5?eso%b(hMX@d!(2 ztWqEc*zK;{o91$;{nBZ33q6?G&i~lhlUfeYa5x(L|(*o|J@2yU08^Nsj;fsU$?#UaY)EiUF&Tn2B zwbyx%Wqz{E*C$)#XarWWmz0*?pR|Zp8CY79dC(!YeHT|#0U{$KGqbXucl$CLk&;(} zh^>E(t?y5W_%Rp^^6oBiV5Hi`0fNAIcze6VyXk37ypZBtT3X7yedGFd+Gw(LIubaE zvJX7fW-ET*OXbNQBzk#FE|N+UbGI6NsS>BMDAOPl#{UbYIo%SwV>2?C0=k)-44 zDB!dPUs_(KkF+4Z) z_?~Z+oxQ!wSmn(Rv;eKgI$^6{Ml18i4M{x|QI~*#C+G&>NeyA0qM55>4$+4e78Xhy zD?V9HToAA&RG-%gmcQkPbjpa1n~=7)LMy0L>vrj*Z9BFmpCLSxKdYRCZWU%o*hu*n%6!@#K2*cOq8D}p&-JZO)lR7kH>~JFt^n=+S8%j zyu2=6Uh!yqM@L>;wxwoyPub*e_d#8O1`mVqKc|$`6I%C!TmW>TGf6(->dE{L5 z^7t)QdxBuf$_OZf-8_`t=vxEk1%y4j6789&r(hyX}qE6NaR; zG-Pl{2#YVv%WYfUVRXD!_`28+g53%eauEi`d6LJL-`r>8Xx+@sY+FiQ2lf*Lz1K){u`DvQb8sY_@ zzrSBQw}E2vk`}pNpNL%G#lU|zc_}C&GO)Kv=X5<6!w=s2s(6g3Mk^ZOAg^+}7;Vvx z8Ri8Je0C(CjKEObzCRWRA-&@*D)KCfn!T!ls6!rDoY&D&R#AB!5k1@01rtv~ES5bs z_JE)S1nBzMkDTjh=(idZ6#;zf)mzY^s^%xIf1VOCrz4(8{i#N27Juo9&R9!L05MOl z5dTIB8C!qR)O1bB=t(bnA(fb%oa_mV8zlu&dfvZ(e>S^lcrCb}PeG4V*?Uo@`II@?U}Nw{ezQ-cBZ}tGvn-o{GeFzq_k@?At(}YI@v=HWosL`Km5{k zDo^W8h^&?fg(4llApsmWe0qv>C1Q3YlHT6TBL`d%;3JzHm6k320`%t@>ExXca{8Du8ra0BDzYi%w6)bM zEAvxs2B$6}9K>ROV literal 0 HcmV?d00001 diff --git a/src/lib/assets/icon.svg b/src/lib/assets/icon.svg new file mode 100644 index 0000000..3311f23 --- /dev/null +++ b/src/lib/assets/icon.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + diff --git a/src/routes/(app)/about/+page.svelte b/src/routes/(app)/about/+page.svelte index 0ef0423..57482f6 100644 --- a/src/routes/(app)/about/+page.svelte +++ b/src/routes/(app)/about/+page.svelte @@ -1,4 +1,6 @@
+

Visitenbuch

Version: {version}

Letzte Änderung: {lastmod}

diff --git a/static/favicon.png b/static/favicon.png index 825b9e65af7c104cfb07089bb28659393b4f2097..33a689f6131289f89f102b5fa24594b12b408294 100644 GIT binary patch literal 2545 zcmaJ@c{mi>8$L5;m=G#6m@5pGec!Vr#&(NP7&V2EeI47BW$H>7jmf@+7CU3yf$<4%A5{<^YDvNS!;iRJ_V95*+^+OQ(& zry$6%*rAnuCo)@k6m^u3;)y zv<##in91Y%VTzYB>}ZSQt&4lp_XZZ_{~T=FOj+w$oH;D^$YzhDmFy}QBs&&>2NG~T zVQJE=83bm^DW+uuI+CD>M1F$+&W&M@z`%dPo?5yat7LuG5>6az-93urh-U7k%NOcA zE^(9k&Lo{!DKN55Jqwbs;>y~ps(-kK@0<9+n;5GEFbxmjZ2ue_C~0{pREUf+^4jZh zoQkdOZ098108Dhko9kq+4^Kqemw(uuG-UG3(oU>6r1paYaNyUluWD->jf%tSFEXUI zwMjkm!~{MS1aVz;4u)|Zn*XP{w+T}TrQs{rys}ZCn(VHl;nq~-NxfCI4F*kU$7X-r z)hsXj$m1kir2(5&H#8#s{tpT(P%p`Qm$?g*RM%^3+KBflm#HdJUz!mi15WTlma|#NUdM3NjCJNb?QkG zBGCeRe4-o4tzaah2;sLR8prm9$qce{{zUQr0Q_Gmd01xL!=b<*9D*V{ptmciwXcSs zNY}$Fre}Pjseaedy&H}M>-mbJVk#4mnWSxN<{o7epQ~Q&%4=W-o!@50Zc*BfMmikb ziPDMpqgx*3C30ov&qooc3pIFNrBhsUGm&l2MW(WFh>r4NEBevt9iAgJe8B#A+0b>I z3F*zQQY?qQfm}q&u4>E~rKoKQZMg`pB2lPKuK-QwfFn3!GT-s%r$45~G5?eso%b(hMX@d!(2 ztWqEc*zK;{o91$;{nBZ33q6?G&i~lhlUfeYa5x(L|(*o|J@2yU08^Nsj;fsU$?#UaY)EiUF&Tn2B zwbyx%Wqz{E*C$)#XarWWmz0*?pR|Zp8CY79dC(!YeHT|#0U{$KGqbXucl$CLk&;(} zh^>E(t?y5W_%Rp^^6oBiV5Hi`0fNAIcze6VyXk37ypZBtT3X7yedGFd+Gw(LIubaE zvJX7fW-ET*OXbNQBzk#FE|N+UbGI6NsS>BMDAOPl#{UbYIo%SwV>2?C0=k)-44 zDB!dPUs_(KkF+4Z) z_?~Z+oxQ!wSmn(Rv;eKgI$^6{Ml18i4M{x|QI~*#C+G&>NeyA0qM55>4$+4e78Xhy zD?V9HToAA&RG-%gmcQkPbjpa1n~=7)LMy0L>vrj*Z9BFmpCLSxKdYRCZWU%o*hu*n%6!@#K2*cOq8D}p&-JZO)lR7kH>~JFt^n=+S8%j zyu2=6Uh!yqM@L>;wxwoyPub*e_d#8O1`mVqKc|$`6I%C!TmW>TGf6(->dE{L5 z^7t)QdxBuf$_OZf-8_`t=vxEk1%y4j6789&r(hyX}qE6NaR; zG-Pl{2#YVv%WYfUVRXD!_`28+g53%eauEi`d6LJL-`r>8Xx+@sY+FiQ2lf*Lz1K){u`DvQb8sY_@ zzrSBQw}E2vk`}pNpNL%G#lU|zc_}C&GO)Kv=X5<6!w=s2s(6g3Mk^ZOAg^+}7;Vvx z8Ri8Je0C(CjKEObzCRWRA-&@*D)KCfn!T!ls6!rDoY&D&R#AB!5k1@01rtv~ES5bs z_JE)S1nBzMkDTjh=(idZ6#;zf)mzY^s^%xIf1VOCrz4(8{i#N27Juo9&R9!L05MOl z5dTIB8C!qR)O1bB=t(bnA(fb%oa_mV8zlu&dfvZ(e>S^lcrCb}PeG4V*?Uo@`II@?U}Nw{ezQ-cBZ}tGvn-o{GeFzq_k@?At(}YI@v=HWosL`Km5{k zDo^W8h^&?fg(4llApsmWe0qv>C1Q3YlHT6TBL`d%;3JzHm6k320`%t@>ExXca{8Du8ra0BDzYi%w6)bM zEAvxs2B$6}9K>ROV delta 1565 zcmV+&2IBeg6Qc}}8Gi%-003z>sXzb#00DDSM?wIu&K&6g00rGiL_t(|+U;H2ZR7!U+OIJ=AN-Q1gX#-xrWp&w_y z@)MXta?U)dS;xZ{pUV3+C2Y|J*@|1M^0HtEVPlk0_Dnt!KkXPkj3F58)>t5Za} zPWJ{u@{+FYBDs9nh1rO^E}w?y3qK3y$v6W}E>;PzkLJZ-7I9{iTo&xYYz=0W7yFQ0 z3euH%Gf^X8~_%M2E@)E5I?D7}a-=qr6=Kbg0qzZl!-c71tD&bia zsL=c?H44};bAPx?JP;+}<#6b63Ike{Fuo!?M{yEffez;|p!PfsuaC)>h>-AdbnwN1 z3g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f2%uGv?vr=KNq7YYa2Roj z;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5P#L)3xr2NyarBd`46Es|9}oU z=;TZAptAhHqfZbXeFiql%jXG?M@uk)^71VlreHvW90nF8jvjPq1ew|Ng)G0Exc5yk zaTecY1^i8)ok)27$s1jqSJ{`6t`veD9l{$?!+aqvBpkoP4B7`ax|h)*muPf46>ijz zKTmk0M}L|_!l3UBrfa?E{^*tH5}{9o=OeyqBH@)VFtID({Qf&kfrAb3DXK4-rRNmD z-~VRP-WM>zlB2;$7(o^LUy;sExKy89tX5Y6W-IT>NIN^>V*fY^C7}S?0Ct44!^wxo zVTsHjc>xN_+2N(eh+a1>W}fER{}CmOf*erj=6{5;XVF3BogH3uNl+?wxmgJ>()KCo z-}j`mqc8SBQvoV_pQHFCJYBxcQ1hG406lEb!fSLGPy82$lDGhh3Me5Wfbt3)J+g#0 z`~oag70^IJJA1`o3{*n)oDiTDP?Hq{-m2K;k1C*0s~FTos}!Mt&?vCsDhBNJa&|FO zC4XQl3-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F0Bun8U!cRB7-2ap zz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL@iUO?SBBG-0cQuo z+an4TsLy-g-+vjPe+`bX;s2dai14j32f@AJ%cKRt1jz?@ zL#U|&2Rk|USm`^&cIlJY7yzm-Xlmfy=y#0!&4E~S2*vpR^W#>)>m%0>%$Ld>rvQHc zgX^}zMo*T3P=I1AAa`^0Vt7;r6+mTFgaT;$FRP(MgCn~Cq(yy_v^Y<*&}9MTv43|y zb0`w1avZ=5-)r0x#c{elh8MoqxYmsYw8!6hGR<|nMCB;ZkqhN}jawKZA3#zd-|@wl z%5vR|P`dIciwGr4YLb}mHhfXd12i5(?YhG?J<}b)E1(l6`}HS@N90Uxh$q4BBJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6OXRY=5%C^* z26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6ROlh0MFA5h z;my}8lB0tAV-Rvc2Zs_CCVyVS^YIEviJkC##wQ4gpYZ1D8M^Avr<`+(Zr}qtfwth% z2+uEH?AbkbnS|%(7o6=Ny=21k<^*@Ogy#pl%$~27@civTy1&uy#(g>o?nW2?n^|0E zyBqv=uQes9e3Pu6&CmlZ=^Q%cGi~> P00000NkvXXu0mjf=A8Fj From 25a60d5d6120ed94128d85d17737c2291d226a14 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 12 May 2024 15:03:54 +0200 Subject: [PATCH 4/4] feat: add group by station --- src/lib/components/ui/EntryCard.svelte | 6 ++ .../components/ui/PaginationButtons.svelte | 13 +++- src/lib/components/ui/WeekSelector.svelte | 44 ++++++------ src/lib/shared/model/validation.ts | 4 +- src/lib/shared/util/date.ts | 16 +++-- src/routes/(app)/visit/+page.svelte | 67 +++++++++++++++---- src/routes/(app)/visit/+page.ts | 58 +++++++++++++--- 7 files changed, 154 insertions(+), 54 deletions(-) diff --git a/src/lib/components/ui/EntryCard.svelte b/src/lib/components/ui/EntryCard.svelte index 409c5db..eff1442 100644 --- a/src/lib/components/ui/EntryCard.svelte +++ b/src/lib/components/ui/EntryCard.svelte @@ -18,6 +18,12 @@ {#if entry.current_version.priority}
Priorität
{/if} + {#if entry.execution && !entry.execution.done} +
Notiz
+ {/if} + {#if entry.patient.room} +
{entry.patient.room.name}
+ {/if} {formatPatientName(entry.patient)} diff --git a/src/lib/components/ui/PaginationButtons.svelte b/src/lib/components/ui/PaginationButtons.svelte index 3e49d40..f6133da 100644 --- a/src/lib/components/ui/PaginationButtons.svelte +++ b/src/lib/components/ui/PaginationButtons.svelte @@ -4,14 +4,21 @@ } from "@mdi/js"; import { PAGINATION_LIMIT } from "$lib/shared/constants"; - import type { Pagination, PaginationRequest } from "$lib/shared/model"; + import type { PaginationRequest } from "$lib/shared/model"; import { screenWidthSmall } from "$lib/stores"; import Icon from "./Icon.svelte"; + type PaginationX = { + items?: unknown[]; + nItems?: number; + total: number; + offset: number; + }; + export let paginationData: PaginationRequest | null | undefined; - export let data: Pagination; + export let data: PaginationX; export let onUpdate: (pagination: PaginationRequest) => void = () => {}; $: limit = paginationData?.limit ?? PAGINATION_LIMIT; @@ -52,7 +59,7 @@

- {data.offset + 1}-{data.offset + data.items.length} von {data.total} + {data.offset + 1}-{data.offset + (data.items?.length ?? data.nItems ?? 0)} von {data.total}

diff --git a/src/lib/components/ui/WeekSelector.svelte b/src/lib/components/ui/WeekSelector.svelte index d7cec35..8bc29b7 100644 --- a/src/lib/components/ui/WeekSelector.svelte +++ b/src/lib/components/ui/WeekSelector.svelte @@ -48,26 +48,30 @@
addDays(true, false)} /> addDays(false, false)} /> - {#if editing} - weekFilterItems()} - noAutoselect1 - onClose={stopEditing} - onSelect={(item) => { - if (typeof item.id === "string") { - const parsed = DateRange.parse(item.id); - if (parsed) dateRange = parsed; - onSelect(dateRange); - } - return { close: true, newValue: "" }; - }} - {onTextInput} - partOfFilterbar - selection={{ id: dateRange.toString(), name: dateRange.format() }} /> - {:else} - - {/if} + +
+ {#if editing} + weekFilterItems()} + noAutoselect1 + onClose={stopEditing} + onSelect={(item) => { + if (typeof item.id === "string") { + const parsed = DateRange.parse(item.id); + if (parsed) dateRange = parsed; + onSelect(dateRange); + } + return { close: true, newValue: "" }; + }} + {onTextInput} + partOfFilterbar + selection={{ id: dateRange.toString(), name: dateRange.format() }} /> + {:else} + + {/if} +
+ addDays(false, true)} /> addDays(true, true)} />
diff --git a/src/lib/shared/model/validation.ts b/src/lib/shared/model/validation.ts index 95e9d47..bde6428 100644 --- a/src/lib/shared/model/validation.ts +++ b/src/lib/shared/model/validation.ts @@ -153,7 +153,9 @@ export const ZEntriesFilter = returnDataInSameOrderAsPassed(z }) .partial()); -export const ZEntriesQuery = paginatedQuery(ZEntriesFilter); +export const ZEntriesQuery = paginatedQuery(ZEntriesFilter).extend({ + group: z.string().optional(), +}); export const ZPatientsFilter = returnDataInSameOrderAsPassed(z .object({ diff --git a/src/lib/shared/util/date.ts b/src/lib/shared/util/date.ts index 1b546a6..731ea29 100644 --- a/src/lib/shared/util/date.ts +++ b/src/lib/shared/util/date.ts @@ -77,19 +77,25 @@ function dateDiffInDays(a: Date, b: Date): number { return Math.round((ts2 - ts1) / MS_PER_DAY); } -export function humanDate(date: Date | string, time = false): string { +export function humanDate(date: Date | string, time = false, cap = false): string { const now = new Date(); const dt = coerceDate(date); const threshold = 302400000; // 3.5 * 24 * 3_600_000 const diff = Number(dt) - Number(now); // pos: Future, neg: Past - if (Math.abs(diff) > threshold) return `am ${formatDate(date, time)}`; + if (Math.abs(diff) > threshold) { + const datestr = formatDate(date, time); + return cap ? datestr : "am" + datestr; + } const intl = new Intl.RelativeTimeFormat(LOCALE); + const outstr = cap ? (s: string) => { + return s.charAt(0).toUpperCase() + s.slice(1); + } : (s: string) => s; const diffDays = dateDiffInDays(now, dt); if (diffDays !== 0) { - if (diffDays === 1) return "morgen"; - if (diffDays === -1) return "gestern"; + if (diffDays === 1) return outstr("morgen"); + if (diffDays === -1) return outstr("gestern"); return intl.format(diffDays, "day"); } @@ -101,7 +107,7 @@ export function humanDate(date: Date | string, time = false): string { if (diffMinutes !== 0) return intl.format(diffMinutes, "minute"); } - return time ? "jetzt gerade" : "heute"; + return outstr(time ? "jetzt gerade" : "heute"); } export class DateRange { diff --git a/src/routes/(app)/visit/+page.svelte b/src/routes/(app)/visit/+page.svelte index ca26df6..08aa4fe 100644 --- a/src/routes/(app)/visit/+page.svelte +++ b/src/routes/(app)/visit/+page.svelte @@ -3,13 +3,18 @@ import { goto } from "$app/navigation"; import type { PageData } from "./$types"; + import { mdiCalendar, mdiDomain } from "@mdi/js"; + import { URL_VISIT } from "$lib/shared/constants"; import type { PaginationRequest, Station } from "$lib/shared/model"; import { trpc } from "$lib/shared/trpc"; - import { DateRange, getQueryUrl } from "$lib/shared/util"; + import { + DateRange, dateFromYMD, getQueryUrl, humanDate, + } from "$lib/shared/util"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import EntryCard from "$lib/components/ui/EntryCard.svelte"; + import IconButton from "$lib/components/ui/IconButton.svelte"; import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte"; import WeekSelector from "$lib/components/ui/WeekSelector.svelte"; @@ -40,9 +45,12 @@ selection = null; } + $: groupByStation = data.query.group === "station"; + function paginationUpdate(pagination: PaginationRequest): void { updateQuery({ filter: data.query.filter, + group: data.query.group, pagination, }); } @@ -54,6 +62,14 @@ station: selection ? [selection] : undefined, date: dateRange ? [{ id: dateRange.toString() }] : undefined, }, + group: data.query.group, + }); + } + + function toggleGroup(): void { + updateQuery({ + ...data.query, + group: groupByStation ? undefined : "station", }); } @@ -74,30 +90,53 @@
- Zeitraum: +
-
- Station: - trpc().station.list.query()} - onSelect={filterUpdate} - onUnselect={filterUpdate} - bind:selection /> +
+
+ Gruppierung: + +
+
+ Station: + trpc().station.list.query()} + onSelect={filterUpdate} + onUnselect={filterUpdate} + bind:selection /> +
- {#each data.entries.items as entry} - + {#each data.groups as group} + {@const first = group.items[0] ?? group.prio[0]} +
+ {#if data.groupByStation} + {#if first.patient.room} + Station {first.patient.room?.station.name} + {:else} + Keine Station + {/if} + {:else} + {humanDate(dateFromYMD(first.current_version.date), false, true)} + {/if} +
+ {#each group.prio as entry} + + {/each} + {#each group.items as entry} + + {/each} {/each}
diff --git a/src/routes/(app)/visit/+page.ts b/src/routes/(app)/visit/+page.ts index b3c727a..b5baeec 100644 --- a/src/routes/(app)/visit/+page.ts +++ b/src/routes/(app)/visit/+page.ts @@ -4,9 +4,16 @@ import { redirect } from "@sveltejs/kit"; import { z } from "zod"; import { ZEntriesQuery } from "$lib/shared/model/validation"; -import { trpc } from "$lib/shared/trpc"; +import { trpc, type RouterOutput } from "$lib/shared/trpc"; import { defaultVisitUrl, loadWrap, parseQueryUrl } from "$lib/shared/util"; +type EntryItems = RouterOutput["entry"]["list"]["items"]; +type EntryItem = EntryItems[0]; +type EntryGroup = { + items: EntryItems, + prio: EntryItems, +}; + export const load: PageLoad = async (event) => { return loadWrap(async () => { let query: z.infer = {}; @@ -20,20 +27,49 @@ export const load: PageLoad = async (event) => { redirect(302, defaultVisitUrl()); } + const groupByStation = query.group === "station"; + // Sort entries by date - if (!query.sort) { - query.sort = ["priority:dsc", "date"]; - } + query.sort = ["date", "room"]; + if (groupByStation) query.sort.reverse(); const entries = await trpc(event).entry.list.query(query); - // Move prioritized items to the front - entries.items.forEach((itm, i) => { - if (itm.current_version.priority) { - entries.items.unshift(entries.items.splice(i, 1)[0]); - } - }); + // Group items by date + const getGroupKey = groupByStation + ? (e: EntryItem) => e.patient.room?.station.id + : (e: EntryItem) => e.current_version.date; - return { query, entries }; + const groups: EntryGroup[] = []; + let dg = null; + let items: EntryItems = []; + let prio: EntryItems = []; + + for (const entry of entries.items) { + // New group + if (getGroupKey(entry) !== dg) { + dg = getGroupKey(entry); + if (items.length > 0 || prio.length > 0) { + groups.push({ items, prio }); + items = []; + prio = []; + } + } + if (entry.current_version.priority) { + prio.push(entry); + } else { + items.push(entry); + } + } + if (items.length > 0 || prio.length > 0) { + groups.push({ items, prio }); + } + + return { + query, + groups, + pagination: { offset: entries.offset, total: entries.total, nItems: entries.items.length }, + groupByStation, + }; }); };