Skip to content

Commit d15034c

Browse files
authored
Merge pull request #215 from openscript-ch/16-implement-gap-detection-logic
16 implement gap detection logic
2 parents 4f18364 + 9dd9b61 commit d15034c

File tree

6 files changed

+202
-29
lines changed

6 files changed

+202
-29
lines changed

.changeset/dirty-knives-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@quassel/frontend": patch
3+
---
4+
5+
Add validation and highlighting for gaps in calendar

apps/frontend/src/components/questionnaire/calendar/EntryCalendar.tsx

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import FullCalendar from "@fullcalendar/react";
22
import interactionPlugin from "@fullcalendar/interaction";
33
import timeGridPlugin from "@fullcalendar/timegrid";
4-
import { EventChangeArg, EventInput } from "@fullcalendar/core";
4+
import { DateSelectArg, EventChangeArg, EventInput } from "@fullcalendar/core";
55
import { formatDate, getDateFromTimeAndWeekday, getTime, isSame, Modal, useDisclosure, useMantineTheme } from "@quassel/ui";
66
import { QuestionnaireEntry } from "./QuestionnaireEntry";
77
import { components } from "../../../api.gen";
88
import { EntityForm, EntryFormValues } from "./EntryForm";
99
import { useEffect, useState } from "react";
1010
import { i18n } from "../../../stores/i18n";
1111
import { useStore } from "@nanostores/react";
12+
import { GapsPerDay } from "../../../utils/entry";
13+
import { EventImpl } from "@fullcalendar/core/internal";
1214

1315
const calendarBaseConfig: FullCalendar["props"] = {
1416
allDaySlot: false,
@@ -27,10 +29,13 @@ const calendarBaseConfig: FullCalendar["props"] = {
2729
eventLongPressDelay: 400,
2830
};
2931

30-
export type ExtendedEvent = EventInput & { extendedProps: { entryLanguages: components["schemas"]["EntryLanguageResponseDto"][] } };
32+
export type ExtendedEvent = EventInput & {
33+
extendedProps?: { entryLanguages: components["schemas"]["EntryLanguageResponseDto"][]; weeklyRecurring?: number };
34+
};
3135

3236
export type EntryCalendarProps = {
3337
entries: components["schemas"]["QuestionnaireEntryDto"][];
38+
gaps?: GapsPerDay;
3439
onAddEntry: (entry: EntryFormValues, weekday: number) => Promise<unknown>;
3540
onUpdateEntry: (id: number, entry: Partial<EntryFormValues>, weekday: number) => Promise<unknown>;
3641
onDeleteEntry: (id: number) => Promise<unknown>;
@@ -47,6 +52,7 @@ const messages = i18n("entryCalendar", {
4752

4853
export function EntryCalendar({
4954
entries,
55+
gaps,
5056
onAddEntry,
5157
onUpdateEntry,
5258
onDeleteEntry,
@@ -69,19 +75,48 @@ export function EntryCalendar({
6975

7076
useEffect(() => {
7177
if (entries) {
72-
setEvents(
73-
entries.map(({ startedAt, endedAt, weekday, carer, entryLanguages, id }) => ({
78+
setEvents([
79+
...entries.map(({ startedAt, endedAt, weekday, carer, weeklyRecurring, entryLanguages, id }) => ({
7480
id: id.toString(),
7581
start: getDateFromTimeAndWeekday(startedAt, weekday),
7682
end: getDateFromTimeAndWeekday(endedAt, weekday),
7783
title: carer.name,
78-
extendedProps: { entryLanguages },
84+
extendedProps: { entryLanguages, weeklyRecurring },
7985
backgroundColor: carer.color ?? theme.colors[theme.primaryColor][4],
8086
borderColor: carer.color ?? theme.colors[theme.primaryColor][4],
81-
})) ?? []
82-
);
87+
})),
88+
...(gaps ?? []).flatMap((dailyGaps, index) =>
89+
dailyGaps.map((gap) => ({
90+
start: getDateFromTimeAndWeekday(gap[0], index),
91+
end: getDateFromTimeAndWeekday(gap[1], index),
92+
backgroundColor: theme.colors.uzhBerry[4],
93+
display: "background",
94+
}))
95+
),
96+
]);
8397
}
84-
}, [entries]);
98+
}, [entries, gaps]);
99+
100+
const setupEntryUpdate = (event: EventImpl) => {
101+
const { carer, entryLanguages, id, weekday, ...rest } = entries?.find((entry) => entry.id.toString() === event.id) ?? {};
102+
103+
setEntryDraft({
104+
carer: carer?.id,
105+
entryLanguages: entryLanguages?.map(({ language, ...rest }) => ({ ...rest, language: language.id })),
106+
...rest,
107+
startedAt: getTime(event.start!),
108+
endedAt: getTime(event.end!),
109+
});
110+
setSelectedWeekday(weekday);
111+
setEntryUpdadingId(id);
112+
open();
113+
};
114+
115+
const setupEntryCreate = ({ start, end }: DateSelectArg | EventImpl) => {
116+
setEntryDraft({ startedAt: getTime(start!), endedAt: getTime(end!) });
117+
setSelectedWeekday(start!.getDay());
118+
open();
119+
};
85120

86121
const handleEventChange = ({ event }: EventChangeArg) => {
87122
const { id, start, end } = event;
@@ -119,24 +154,13 @@ export function EntryCalendar({
119154
{...calendarBaseConfig}
120155
plugins={[timeGridPlugin, interactionPlugin]}
121156
events={events}
122-
select={({ start, end }) => {
123-
setEntryDraft({ startedAt: getTime(start), endedAt: getTime(end) });
124-
setSelectedWeekday(start.getDay());
125-
open();
126-
}}
157+
select={setupEntryCreate}
127158
eventClick={({ event }) => {
128-
const { carer, entryLanguages, id, weekday, ...rest } = entries?.find((entry) => entry.id.toString() === event.id) ?? {};
129-
130-
setEntryDraft({
131-
carer: carer?.id,
132-
entryLanguages: entryLanguages?.map(({ language, ...rest }) => ({ ...rest, language: language.id })),
133-
...rest,
134-
startedAt: getTime(event.start!),
135-
endedAt: getTime(event.end!),
136-
});
137-
setSelectedWeekday(weekday);
138-
setEntryUpdadingId(id);
139-
open();
159+
if (event.display === "background") {
160+
setupEntryCreate(event);
161+
} else {
162+
setupEntryUpdate(event);
163+
}
140164
}}
141165
eventChange={handleEventChange}
142166
eventContent={({ event }) => <QuestionnaireEntry event={event} />}

apps/frontend/src/components/questionnaire/calendar/QuestionnaireEntry.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const messages = i18n("questionnaireEntries", {
1414
});
1515

1616
export function QuestionnaireEntry({ event }: QuestionnaireEntryProps) {
17-
const { entryLanguages, weeklyRecurring } = event.extendedProps as ExtendedEvent["extendedProps"];
17+
const { entryLanguages, weeklyRecurring } = (event.extendedProps as ExtendedEvent["extendedProps"]) ?? {};
1818

1919
const t = useStore(messages);
2020

@@ -23,7 +23,7 @@ export function QuestionnaireEntry({ event }: QuestionnaireEntryProps) {
2323
<Text size="sm" fw="bold" truncate>
2424
{event.title}
2525
</Text>
26-
{entryLanguages.map(({ language }) => language.ietfBcp47).join(", ")}
26+
{entryLanguages?.map(({ language }) => language.ietfBcp47).join(", ")}
2727
{weeklyRecurring && weeklyRecurring > 1 && (
2828
<Text mt="sm" size="sm">
2929
<IconRepeat size={13} /> {t.labelRecurringWeekly({ weeks: weeklyRecurring })}

apps/frontend/src/routes/_auth/questionnaire/_questionnaire/$id/entries.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
1-
import { Button, Group, Stack, notifications } from "@quassel/ui";
1+
import { Button, Group, Modal, Stack, notifications, useDisclosure, useForm } from "@quassel/ui";
22
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
33
import { i18n } from "../../../../../stores/i18n";
44
import { useStore } from "@nanostores/react";
55
import { $api } from "../../../../../stores/api";
66
import { EntryFormValues } from "../../../../../components/questionnaire/calendar/EntryForm";
77
import { useQueryClient } from "@tanstack/react-query";
88
import { EntryCalendar } from "../../../../../components/questionnaire/calendar/EntryCalendar";
9+
import { useEffect, useState } from "react";
10+
import { components } from "../../../../../api.gen";
11+
import { GapsPerDay, resolveGaps } from "../../../../../utils/entry";
912

1013
const messages = i18n("questionnaireEntries", {
1114
formAction: "Continue",
1215
backAction: "Back",
1316
addEntityLabel: "Add",
1417
notificationSuccessCreateLanguage: "Successfully add a new language.",
1518
notificationSuccessCreateCarer: "Successfully add a new carer.",
19+
gapsDialogTitle: "Gaps detected in the calendar",
20+
gapsDialogContinueAnyway: "Continue anyway",
21+
gapsDialogHighlightGaps: "Highlight gaps",
1622
});
1723

1824
function QuestionnaireEntries() {
@@ -76,16 +82,57 @@ function QuestionnaireEntries() {
7682
return deleteMutation.mutateAsync({ params: { path: { id: id.toString() } } }, { onSuccess: reloadEntries });
7783
};
7884

85+
const [gaps, setGaps] = useState<GapsPerDay>();
86+
const [highlightGaps, setHighlightGaps] = useState(false);
87+
const [gapsDialogOpened, { open, close }] = useDisclosure();
88+
89+
const f = useForm<{ entries: components["schemas"]["QuestionnaireEntryDto"][] }>({
90+
initialValues: {
91+
entries: [],
92+
},
93+
validate: {
94+
entries: (value) => {
95+
const gaps = resolveGaps(value);
96+
setGaps(gaps);
97+
98+
const hasGaps = gaps.some(({ length }) => length);
99+
if (hasGaps) open();
100+
101+
return hasGaps;
102+
},
103+
},
104+
});
105+
79106
const handleSubmit = () => {
80107
n({ to: "/questionnaire/$id/remarks", params: p });
81108
};
82109

110+
useEffect(() => {
111+
f.setValues({ entries: questionnaire.entries });
112+
}, [questionnaire]);
113+
83114
return (
84115
<>
85-
<form onSubmit={handleSubmit}>
116+
<form onSubmit={f.onSubmit(handleSubmit)}>
86117
<Stack>
118+
<Modal opened={gapsDialogOpened} onClose={close} centered title={t.gapsDialogTitle}>
119+
<Group justify="flex-end">
120+
<Button onClick={handleSubmit} variant="light" type="submit">
121+
{t.gapsDialogContinueAnyway}
122+
</Button>
123+
<Button
124+
onClick={() => {
125+
setHighlightGaps(true);
126+
close();
127+
}}
128+
>
129+
{t.gapsDialogHighlightGaps}
130+
</Button>
131+
</Group>
132+
</Modal>
87133
<EntryCalendar
88134
entries={questionnaire.entries ?? []}
135+
gaps={highlightGaps ? gaps : undefined}
89136
onAddEntry={handleCreate}
90137
onUpdateEntry={handleUpdate}
91138
onDeleteEntry={handleDelete}

apps/frontend/src/utils/entry.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { components } from "../api.gen";
2+
3+
export type Entry = components["schemas"]["QuestionnaireEntryDto"];
4+
export type Gap = [string, string];
5+
export type GapsPerDay = [Gap[], Gap[], Gap[], Gap[], Gap[], Gap[], Gap[]];
6+
7+
const groupByWeekday = (entries: Entry[]) =>
8+
entries.reduce<Entry[][]>((acc, cur) => {
9+
acc[cur.weekday] = [...(acc[cur.weekday] ?? []), cur];
10+
return acc;
11+
}, []);
12+
13+
export const resolveGaps = (entries: Entry[]) => groupByWeekday(entries).map(resolveGapsInDay) as GapsPerDay;
14+
15+
// inspired by: https://cs.stackexchange.com/questions/133276/algorithm-to-compute-the-gaps-between-a-set-of-intervals
16+
export const resolveGapsInDay = (entriesOfSameDay: Entry[]) => {
17+
const entriesSortedByStart = entriesOfSameDay.toSorted((a, b) => a.startedAt.localeCompare(b.startedAt));
18+
19+
const gaps: Gap[] = [];
20+
let lastCoveredTime = entriesSortedByStart[0]?.endedAt;
21+
22+
for (const entry of entriesSortedByStart) {
23+
if (entry.startedAt > lastCoveredTime) {
24+
gaps.push([lastCoveredTime, entry.startedAt]);
25+
}
26+
lastCoveredTime = lastCoveredTime > entry.endedAt ? lastCoveredTime : entry.endedAt;
27+
}
28+
29+
return gaps;
30+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import { Entry, resolveGaps, resolveGapsInDay } from "../../src/utils/entry";
3+
4+
describe("entry utils", () => {
5+
let entries: Partial<Entry>[];
6+
7+
beforeEach(() => {
8+
entries = [
9+
{ startedAt: "08:00", endedAt: "10:00", weekday: 1 },
10+
{ startedAt: "12:00", endedAt: "14:00", weekday: 1 },
11+
];
12+
});
13+
14+
it("should detect no gaps if there are no entries", () => {
15+
const gaps = resolveGapsInDay([]);
16+
17+
expect(gaps).toEqual([]);
18+
});
19+
20+
it("should detect gap between two entries", () => {
21+
const gaps = resolveGapsInDay(entries as Entry[]);
22+
23+
expect(gaps).toEqual([["10:00", "12:00"]]);
24+
});
25+
26+
it("shouldn't detect gap with overlapping entries", () => {
27+
entries.push({ startedAt: "09:00", endedAt: "13:00" });
28+
29+
const gaps = resolveGapsInDay(entries as Entry[]);
30+
31+
expect(gaps).toEqual([]);
32+
});
33+
34+
it("should detect gap with overlapping entries", () => {
35+
entries.push({ startedAt: "09:00", endedAt: "11:00" });
36+
37+
const gaps = resolveGapsInDay(entries as Entry[]);
38+
39+
expect(gaps).toEqual([["11:00", "12:00"]]);
40+
});
41+
42+
it("should detect gaps with entries in diffrent sort order", () => {
43+
entries = entries.toReversed();
44+
45+
const gaps = resolveGapsInDay(entries as Entry[]);
46+
47+
expect(gaps).toEqual([["10:00", "12:00"]]);
48+
});
49+
50+
it("should detect multiple gaps", () => {
51+
entries.push({ startedAt: "15:00", endedAt: "16:00" });
52+
53+
const gaps = resolveGapsInDay(entries as Entry[]);
54+
55+
expect(gaps.length).toBe(2);
56+
});
57+
58+
it("should detect gaps for multiple weekdays", () => {
59+
entries.push({ startedAt: "08:00", endedAt: "10:00", weekday: 2 });
60+
entries.push({ startedAt: "12:00", endedAt: "14:00", weekday: 2 });
61+
62+
const gapsPerDay = resolveGaps(entries as Entry[]);
63+
64+
expect(gapsPerDay[1]).toHaveLength(1);
65+
expect(gapsPerDay[2]).toHaveLength(1);
66+
});
67+
});

0 commit comments

Comments
 (0)