Complete regular absences management

This commit is contained in:
Karl Ludwig Weise
2026-07-20 19:57:37 +02:00
parent 02d0ef3810
commit 8a167b3d84
22 changed files with 1705 additions and 201 deletions
+8 -2
View File
@@ -60,6 +60,8 @@
"noEndDate": "Ende ist erforderlich",
"endDateBeforeStartDate": "Das Ende muss nach dem Beginn sein",
"endDateTooEarly": "Das Ende muss in der Zukunft liegen",
"noWeekdaysSelected": "Wähle mindestens einen Wochentag",
"fromAfterTo": "»Von« muss vor »Bis« liegen",
"street": "Straße hinzufügen",
"houseNo": "Pflichtfeld",
"zip": "Postleitzahl hinzufügen",
@@ -613,6 +615,7 @@
"action": "Abwesenheit hinzufügen",
"success": "Abwesenheit hinzugefügt",
"errors": {
"conflict": "Akteur ist bereits in diesem Zeitraum (teilweise) abwesend",
"unknown": "Konnte Abwesenheit nicht hinzufügen"
},
"unavailable": {
@@ -630,12 +633,15 @@
"title": "Abwesenheit bearbeiten",
"description": "Aktualisiere die Verfügbarkeit dieses Akteurs.",
"success": "Abwesenheit aktualisiert",
"error": "Konnte Abwesenheit nicht aktualisieren.",
"errors": {
"conflict": "Akteur ist bereits in diesem Zeitraum (teilweise) abwesend",
"unknown": "Konnte Abwesenheit nicht aktualisieren"
},
"action": "Übernehmen"
},
"delete": {
"title": "Abwesenheit löschen",
"description": "{name} wird vom {startDate} bis {endDate} wieder für Termine verfügbar sein.",
"description": "{name} wird in diesem Zeitraum wieder für Termine verfügbar sein: {timespan}.",
"description_fallback": "Diese/r Akteur:in",
"action": "Abwesenheit löschen",
"success": "Abwesenheit gelöscht",
+8 -2
View File
@@ -75,6 +75,8 @@
"noEndDate": "End date is required",
"endDateBeforeStartDate": "End date must be after start date",
"endDateTooEarly": "End date must be in the future",
"noWeekdaysSelected": "Select at least one weekday",
"fromAfterTo": "»From« time must be before »To« time",
"street": "Add a street",
"houseNo": "Required",
"zip": "Add a zip code",
@@ -621,6 +623,7 @@
"action": "Add Absence",
"success": "Absence added",
"errors": {
"conflict": "Agent is already (partially) absent in this time period",
"unknown": "Could not add absence"
},
"unavailable": {
@@ -638,12 +641,15 @@
"title": "Edit Absence",
"description": "Update the agents availability.",
"success": "Absence updated",
"error": "Could not update absence.",
"errors": {
"conflict": "Agent is already (partially) absent in this time period",
"unknown": "Could not update absence"
},
"action": "Edit Absence"
},
"delete": {
"title": "Delete Absence",
"description": "{name} will be available for appointments from {startDate} to {endDate}.",
"description": "{name} will be available for appointments again in this timespan: {timespan}.",
"description_fallback": "This agent",
"action": "Delete Absence",
"success": "Absence deleted",
@@ -28,6 +28,7 @@
let {
image,
title,
icons,
description,
descriptionOnClick,
actions,
@@ -35,6 +36,7 @@
}: HTMLAttributes<HTMLLIElement> & {
image?: string | Component;
title: string;
icons?: Component[];
description?: string;
descriptionOnClick?: () => void;
actions?: ListItemAction[];
@@ -65,7 +67,16 @@
<div></div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<Text style="md" class="font-medium">{title}</Text>
<Text style="md" class="flex items-center gap-2 font-medium">
{title}
{#if icons && icons.length > 0}
<div class="flex items-center gap-1">
{#each icons as Icon, index (`icon-${index}`)}
<Icon class="size-3" />
{/each}
</div>
{/if}
</Text>
{#if badges && badges.length > 0}
<div class="flex flex-wrap gap-1 py-1">
{#each badges as badge, index (`${badge.label}-${index}`)}
@@ -100,7 +100,10 @@
{/if}
</Dialog.Trigger>
{/if}
<Dialog.Content class="max-h-[95vh] sm:max-w-106.25">
<Dialog.Content
class="max-h-[95vh] sm:max-w-106.25"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Dialog.Header>
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
{#if description}
@@ -129,6 +132,7 @@
{/if}
<Drawer.Content
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Drawer.Header class="text-left">
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
+10
View File
@@ -26,6 +26,8 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [
"NO_SHOW",
]);
export const absenceTypeEnum = pgEnum("agent_absence_type", ["ONE_TIME", "REGULAR"]);
export const notificationTypes = [
"APPOINTMENT_CONFIRMED",
"APPOINTMENT_CANCELLED",
@@ -200,6 +202,8 @@ export const appointment = pgTable("appointment", {
export const agentAbsence = pgTable("agent_absence", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Enum ONE_TIME or REGULAR, defaults to ONE_TIME */
type: absenceTypeEnum("type").notNull().default("ONE_TIME"),
/** Foreign key to agent who is absent */
agentId: uuid("agent_id")
.notNull()
@@ -212,6 +216,12 @@ export const agentAbsence = pgTable("agent_absence", {
absenceType: text("absence_type").notNull().default(""),
/** Optional description/reason for absence */
description: text("description"),
/** Set, when of type REGULAR: Bitmask for weekdays (1=Monday, 2=Tuesday, 4=Wednesday, etc.), is NULL by default */
weekdays: integer("weekdays"),
/** Set, when of type REGULAR: Start time for the regular absence */
from: time("from"),
/** Set, when of type REGULAR: End time for the regular absence */
to: time("to"),
});
/**
@@ -30,7 +30,11 @@ vi.mock("$lib/logger", () => ({
}));
// Import after mocking
import { AgentService, type AbsenceCreationRequest } from "../agent-service";
import {
AgentService,
type AbsenceCreationRequest,
type AbsenceUpdateRequest,
} from "../agent-service";
import { getTenantDb } from "../../db";
import { ValidationError, NotFoundError, ConflictError } from "../../utils/errors";
@@ -676,6 +680,7 @@ describe("AgentService", () => {
describe("createAbsence", () => {
it("should create absence successfully", async () => {
const request: AbsenceCreationRequest = {
type: "ONE_TIME",
agentId: "123e4567-e89b-12d3-a456-426614174001",
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
@@ -730,11 +735,15 @@ describe("AgentService", () => {
expect(result).toEqual(mockAbsence);
expect(insertChain.values).toHaveBeenCalledWith({
type: "ONE_TIME",
agentId: request.agentId,
startDate: new Date(request.startDate),
endDate: new Date(request.endDate),
absenceType: request.absenceType,
description: request.description,
weekdays: null,
from: null,
to: null,
});
});
@@ -754,6 +763,7 @@ describe("AgentService", () => {
it("should validate date range", async () => {
const request: AbsenceCreationRequest = {
type: "ONE_TIME",
agentId: "agent-123",
startDate: "2024-01-01T17:00:00.000Z", // End before start
endDate: "2024-01-01T08:00:00.000Z",
@@ -765,6 +775,7 @@ describe("AgentService", () => {
it("should throw NotFoundError if agent does not exist", async () => {
const request: AbsenceCreationRequest = {
type: "ONE_TIME",
agentId: "123e4567-e89b-12d3-a456-426614174099", // Valid UUID format
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
@@ -792,6 +803,7 @@ describe("AgentService", () => {
it("should throw ConflictError if absence period overlaps", async () => {
const request: AbsenceCreationRequest = {
type: "ONE_TIME",
agentId: "123e4567-e89b-12d3-a456-426614174001",
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
@@ -921,7 +933,8 @@ describe("AgentService", () => {
describe("updateAbsence", () => {
it("should update absence successfully", async () => {
const updateData = {
const updateData: AbsenceUpdateRequest = {
type: "ONE_TIME",
absenceType: "Krankheit",
description: "Sick leave",
};
@@ -959,7 +972,8 @@ describe("AgentService", () => {
});
it("should validate update request", async () => {
const invalidUpdate = {
const invalidUpdate: AbsenceUpdateRequest = {
type: "ONE_TIME",
absenceType: "", // Invalid empty type
startDate: "invalid-date",
};
@@ -970,7 +984,7 @@ describe("AgentService", () => {
});
it("should throw NotFoundError if absence does not exist", async () => {
const updateData = { absenceType: "Krankheit" };
const updateData: AbsenceUpdateRequest = { type: "ONE_TIME", absenceType: "Krankheit" };
const selectChain = {
from: vi.fn(() => ({
@@ -994,7 +1008,8 @@ describe("AgentService", () => {
});
it("should validate new date range", async () => {
const updateData = {
const updateData: AbsenceUpdateRequest = {
type: "ONE_TIME",
startDate: "2024-01-01T17:00:00.000Z", // End before start
endDate: "2024-01-01T08:00:00.000Z",
};
+122 -74
View File
@@ -2,7 +2,7 @@ import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectAgent, type SelectAgentAbsence } from "../db/tenant-schema";
import { eq, and, between, or, lte, gte, ne } from "drizzle-orm";
import { eq, and, between, or, lte, gte, ne, sql } from "drizzle-orm";
import logger from "$lib/logger";
import { z } from "zod";
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
@@ -23,20 +23,47 @@ const agentUpdateSchema = z.object({
languages: z.array(z.string()).optional(),
});
const absenceCreationSchema = z.object({
agentId: z.string().uuid({ message: "Invalid UUID format" }),
startDate: z.string().datetime({ message: "Invalid datetime format" }),
endDate: z.string().datetime({ message: "Invalid datetime format" }),
absenceType: z.string().min(1).max(100),
description: z.string().optional(),
});
const absenceCreationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("ONE_TIME"),
agentId: z.uuid({ message: "Invalid UUID format" }),
startDate: z.string().datetime({ message: "Invalid datetime format" }),
endDate: z.string().datetime({ message: "Invalid datetime format" }),
absenceType: z.string().min(1).max(100),
description: z.string().optional(),
}),
z.object({
type: z.literal("REGULAR"),
agentId: z.uuid({ message: "Invalid UUID format" }),
startDate: z.string().datetime({ message: "Invalid datetime format" }),
endDate: z.string().datetime({ message: "Invalid datetime format" }),
absenceType: z.string().min(1).max(100),
description: z.string().optional(),
weekdays: z.number().int().min(0).max(127).nullable(),
from: z.string().time({ message: "Invalid time format" }),
to: z.string().time({ message: "Invalid time format" }),
}),
]);
const absenceUpdateSchema = z.object({
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
absenceType: z.string().min(1).max(100).optional(),
description: z.string().optional(),
});
const absenceUpdateSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("ONE_TIME"),
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
absenceType: z.string().min(1).max(100).optional(),
description: z.string().optional(),
}),
z.object({
type: z.literal("REGULAR"),
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
absenceType: z.string().min(1).max(100).optional(),
description: z.string().optional(),
weekdays: z.number().int().min(0).max(127).nullable(),
from: z.string().time({ message: "Invalid time format" }),
to: z.string().time({ message: "Invalid time format" }),
}),
]);
const absenceQuerySchema = z.object({
agentId: z.string().uuid({ message: "Invalid UUID format" }).optional(),
@@ -433,6 +460,11 @@ export class AgentService {
const validation = absenceCreationSchema.safeParse(request);
if (!validation.success) {
log.error("Error creating new absence", {
tenantId: this.tenantId,
request,
validation,
});
throw new ValidationError("Invalid absence creation request");
}
@@ -446,7 +478,7 @@ export class AgentService {
log.debug("Creating new absence", {
tenantId: this.tenantId,
agentId: request.agentId,
absenceType: request.absenceType,
type: request.type,
startDate: request.startDate,
endDate: request.endDate,
});
@@ -468,37 +500,40 @@ export class AgentService {
}
// Check for overlapping absences
const overlappingAbsences = await db
.select()
.from(tenantSchema.agentAbsence)
.where(
and(
eq(tenantSchema.agentAbsence.agentId, request.agentId),
or(
// New absence starts during existing absence
and(
between(
tenantSchema.agentAbsence.startDate,
new Date(request.startDate),
new Date(request.endDate),
const overlappingAbsences =
request.type === "REGULAR"
? []
: await db
.select()
.from(tenantSchema.agentAbsence)
.where(
and(
eq(tenantSchema.agentAbsence.agentId, request.agentId),
or(
// New absence starts during existing absence
and(
between(
tenantSchema.agentAbsence.startDate,
new Date(request.startDate),
new Date(request.endDate),
),
),
// New absence ends during existing absence
and(
between(
tenantSchema.agentAbsence.endDate,
new Date(request.startDate),
new Date(request.endDate),
),
),
// New absence entirely contains existing absence
and(
gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)),
lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)),
),
),
),
),
// New absence ends during existing absence
and(
between(
tenantSchema.agentAbsence.endDate,
new Date(request.startDate),
new Date(request.endDate),
),
),
// New absence entirely contains existing absence
and(
gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)),
lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)),
),
),
),
);
);
if (overlappingAbsences.length > 0) {
throw new ConflictError("Absence period overlaps with existing absence");
@@ -507,11 +542,15 @@ export class AgentService {
const result = await db
.insert(tenantSchema.agentAbsence)
.values({
type: request.type,
agentId: request.agentId,
startDate: new Date(request.startDate),
endDate: new Date(request.endDate),
absenceType: request.absenceType,
description: request.description,
weekdays: request.type === "REGULAR" ? request.weekdays : null,
from: request.type === "REGULAR" ? request.from : null,
to: request.type === "REGULAR" ? request.to : null,
})
.returning();
@@ -621,7 +660,10 @@ export class AgentService {
.select()
.from(tenantSchema.agentAbsence)
.where(and(...conditions))
.orderBy(tenantSchema.agentAbsence.startDate);
.orderBy(
sql`CASE WHEN ${tenantSchema.agentAbsence.type} = 'ONE_TIME' THEN 0 ELSE 1 END`,
tenantSchema.agentAbsence.startDate,
);
log.debug("Retrieved absences", {
tenantId: this.tenantId,
@@ -666,7 +708,10 @@ export class AgentService {
return result;
}
const result = await query.orderBy(tenantSchema.agentAbsence.startDate);
const result = await query.orderBy(
sql`CASE WHEN ${tenantSchema.agentAbsence.type} = 'ONE_TIME' THEN 0 ELSE 1 END`,
tenantSchema.agentAbsence.startDate,
);
log.debug("Retrieved agent absences", {
tenantId: this.tenantId,
@@ -787,33 +832,36 @@ export class AgentService {
}
// Check for overlapping absences (excluding current absence)
const overlappingAbsences = await db
.select()
.from(tenantSchema.agentAbsence)
.where(
and(
eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId),
// Exclude current absence from check
ne(tenantSchema.agentAbsence.id, absenceId),
or(
between(
tenantSchema.agentAbsence.startDate,
new Date(newStartDate),
new Date(newEndDate),
),
between(
tenantSchema.agentAbsence.endDate,
new Date(newStartDate),
new Date(newEndDate),
),
// New period entirely contains existing absence
and(
gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)),
lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)),
),
),
),
);
const overlappingAbsences =
updateData.type === "REGULAR"
? []
: await db
.select()
.from(tenantSchema.agentAbsence)
.where(
and(
eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId),
// Exclude current absence from check
ne(tenantSchema.agentAbsence.id, absenceId),
or(
between(
tenantSchema.agentAbsence.startDate,
new Date(newStartDate),
new Date(newEndDate),
),
between(
tenantSchema.agentAbsence.endDate,
new Date(newStartDate),
new Date(newEndDate),
),
// New period entirely contains existing absence
and(
gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)),
lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)),
),
),
),
);
if (overlappingAbsences.length > 0) {
throw new ConflictError("Updated absence period overlaps with existing absence");
+1 -1
View File
@@ -250,7 +250,7 @@ export const getWeekDays = (day: CalendarDate, skipWeekend = false): CalendarDat
return days;
};
export const toWeekdaysLabel = (bitmap: number | undefined) => {
export const toWeekdaysLabel = (bitmap: number | undefined | null) => {
if (!bitmap) return m["components.slotTemplate.empty_weekdays"]();
switch (bitmap) {
@@ -38,9 +38,9 @@
absenceType: "VACATION",
startDate: getDefaultStartTime(),
endDate: getDefaultEndTime(),
weekdays: 0,
from: timeLocalWithoutOffsetToUTC("09:00:00"),
to: timeLocalWithoutOffsetToUTC("17:00:00"),
weekdays: 0 as number | undefined,
from: timeLocalWithoutOffsetToUTC("09:00:00") as string | undefined,
to: timeLocalWithoutOffsetToUTC("17:00:00") as string | undefined,
},
{
dataType: "json",
@@ -50,7 +50,11 @@
toast.success(m["absences.add.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["absences.add.errors.unknown"]());
if (event.result.status === 409) {
toast.error(m["absences.add.errors.conflict"]());
} else {
toast.error(m["absences.add.errors.unknown"]());
}
}
isSubmitting = false;
},
@@ -100,8 +104,15 @@
onValueChange={(v) => {
if (v === "REGULAR") {
isAllDay = true;
} else {
$formData.absenceType = "OTHER";
$formData.weekdays = 0;
$formData.from = timeLocalWithoutOffsetToUTC("09:00:00");
$formData.to = timeLocalWithoutOffsetToUTC("17:00:00");
} else {
$formData.weekdays = undefined;
$formData.from = undefined;
$formData.to = undefined;
$formData.absenceType = "VACATION";
}
}}
/>
@@ -134,7 +145,7 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="absenceType">
<Form.Field {form} name="absenceType" class={$formData.type === "REGULAR" ? "hidden" : ""}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.absenceType.title"]()}</Form.Label>
@@ -3,11 +3,45 @@ import { z } from "zod";
export const formSchema = z
.object({
type: z.enum(["ONE_TIME", "REGULAR"]),
agent: z.string().uuid({ message: m["form.errors.noAgentsSelected"]() }),
absenceType: z.string().min(1).max(100),
description: z.string().optional(),
startDate: z.string(),
endDate: z.string(),
weekdays: z.number().int().min(0).max(127).optional(),
from: z
.string()
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
.optional(),
to: z
.string()
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
.optional(),
})
.superRefine((data, ctx) => {
if (data.type === "REGULAR") {
if (data.weekdays == null || data.weekdays === 0) {
ctx.addIssue({
code: "custom",
message: m["form.errors.noWeekdaysSelected"](),
path: ["weekdays"],
});
}
if (data.from && data.to) {
const fromDate = new Date(`1970-01-01T${data.from}Z`);
const toDate = new Date(`1970-01-01T${data.to}Z`);
if (fromDate >= toDate) {
ctx.addIssue({
code: "custom",
message: m["form.errors.fromAfterTo"](),
path: ["from"],
});
}
}
}
})
.superRefine((data, ctx) => {
const start = new Date(data.startDate);
@@ -5,11 +5,11 @@
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import type { TAbsence } from "$lib/types/absence";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { renderAbsenceTimespan } from "../../utils";
const agents = $derived($agentsStore.agents ?? []);
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
@@ -43,8 +43,7 @@
name:
agents.find((a) => a.id === entity.agentId)?.name ||
m["absences.delete.description_fallback"](),
startDate: toDisplayDateTime(new Date(entity.startDate)),
endDate: toDisplayDateTime(new Date(entity.endDate)),
timespan: renderAbsenceTimespan(entity),
})}
</Text>
<Form.Field {form} name="id" class="hidden">
@@ -7,14 +7,22 @@
import * as Select from "$lib/components/ui/select";
import { agents as agentsStore } from "$lib/stores/agents";
import type { TAbsence } from "$lib/types/absence";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { reasons } from "../utils";
import { toInputDateTime } from "$lib/utils/datetime";
import { reasons, types } from "../utils";
import {
timeLocalWithoutOffsetToUTC,
timeUTCToLocalWithoutOffset,
toInputDateTime,
toWeekdaysLabel,
weekdays,
} from "$lib/utils/datetime";
import { SvelteDate } from "svelte/reactivity";
import { times } from "$lib/components/ui/slot-template/utils";
import { RadioCards } from "$lib/components/ui/radio-cards";
import { cn } from "$lib/utils";
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
@@ -23,11 +31,15 @@
// svelte-ignore state_referenced_locally
const form = superForm(
{
type: entity.type,
id: entity.id,
agent: entity.agentId,
absenceType: entity.absenceType,
startDate: entity.startDate,
endDate: entity.endDate,
weekdays: entity.weekdays || null,
from: entity.from || null,
to: entity.to || null,
},
{
dataType: "json",
@@ -37,7 +49,11 @@
toast.success(m["absences.edit.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["absences.edit.error"]());
if (event.result.status === 409) {
toast.error(m["absences.edit.errors.conflict"]());
} else {
toast.error(m["absences.edit.errors.unknown"]());
}
}
isSubmitting = false;
},
@@ -61,6 +77,19 @@
let endDateTouched = $state(false);
const { form: formData, enhance } = form;
const onWeekdaysChange = (values: string[]) => {
const newValue = values.map((v) => parseInt(v)).reduce((sum, x) => sum + x, 0);
$formData.weekdays = newValue;
};
const toSelectedWeekdays = (bitmap: number | null) => {
if (bitmap == null || bitmap === 0) return [];
return weekdays.filter(({ bit }) => (bitmap & bit) === bit).map(({ bit }) => `${bit}`);
};
const getSelectedTime = (utcTime: string) => {
const localTime = timeUTCToLocalWithoutOffset(utcTime) as keyof typeof times;
return times[localTime]?.label ?? "";
};
</script>
<Form.Root {enhance} action="?/edit">
@@ -71,6 +100,33 @@
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="type">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.type.title"]()}</Form.Label>
<RadioCards
{...props}
bind:value={$formData.type}
options={Object.values(types)}
onValueChange={(v) => {
if (v === "REGULAR") {
isAllDay = true;
$formData.absenceType = "OTHER";
$formData.weekdays = 0;
$formData.from = timeLocalWithoutOffsetToUTC("09:00:00");
$formData.to = timeLocalWithoutOffsetToUTC("17:00:00");
} else {
$formData.weekdays = null;
$formData.from = null;
$formData.to = null;
$formData.absenceType = "VACATION";
}
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="agent">
<Form.Control>
{#snippet children({ props })}
@@ -97,7 +153,7 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="absenceType">
<Form.Field {form} name="absenceType" class={$formData.type === "REGULAR" ? "hidden" : ""}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.absenceType.title"]()}</Form.Label>
@@ -131,56 +187,125 @@
setTimeout(() => (endDateTouched = false), 100);
}
}}
class="mt-2 mb-1"
class={cn("mt-2 mb-1", $formData.type === "REGULAR" ? "hidden" : "")}
/>
<Form.Field {form} name="startDate">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.startDate.title"]()}</Form.Label>
<InputDateTime
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.startDate}
defaultTime={{ hour: 0, minute: 0, second: 0 }}
onChanged={() => {
if (!endDateTouched && $formData.endDate <= $formData.startDate) {
if (!isAllDay) {
const startDate = new SvelteDate($formData.startDate);
const endDate = new Date($formData.endDate);
startDate.setHours(
endDate.getHours(),
endDate.getMinutes(),
endDate.getSeconds(),
endDate.getMilliseconds(),
);
$formData.endDate = startDate.toISOString();
} else {
const startDate = new SvelteDate($formData.startDate);
startDate.setHours(23, 59, 59, 999);
$formData.endDate = startDate.toISOString();
<div class={cn($formData.type === "REGULAR" ? "grid grid-cols-2 gap-2" : "")}>
<Form.Field {form} name="startDate">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.startDate.title"]()}</Form.Label>
<InputDateTime
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.startDate}
defaultTime={{ hour: 0, minute: 0, second: 0 }}
onChanged={() => {
if (!endDateTouched && $formData.endDate <= $formData.startDate) {
if (!isAllDay) {
const startDate = new SvelteDate($formData.startDate);
const endDate = new Date($formData.endDate);
startDate.setHours(
endDate.getHours(),
endDate.getMinutes(),
endDate.getSeconds(),
endDate.getMilliseconds(),
);
$formData.endDate = startDate.toISOString();
} else {
const startDate = new SvelteDate($formData.startDate);
startDate.setHours(23, 59, 59, 999);
$formData.endDate = startDate.toISOString();
}
}
}
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="endDate">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.endDate.title"]()}</Form.Label>
<InputDateTime
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.endDate}
defaultTime={{ hour: 23, minute: 59, second: 59 }}
onChanged={() => (endDateTouched = true)}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="endDate">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["absences.add.fields.endDate.title"]()}</Form.Label>
<InputDateTime
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.endDate}
defaultTime={{ hour: 23, minute: 59, second: 59 }}
onChanged={() => (endDateTouched = true)}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
{#if $formData.type === "REGULAR"}
<Form.Field {form} name="weekdays">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.weekdays"]()}</Form.Label>
<Select.Root
type="multiple"
onValueChange={onWeekdaysChange}
name={props.name}
value={toSelectedWeekdays($formData.weekdays)}
>
<Select.Trigger {...props} class="w-full">
{toWeekdaysLabel($formData.weekdays)}
</Select.Trigger>
<Select.Content>
<Select.Item value={weekdays[0].bit.toString()}>{weekdays[0].day}</Select.Item>
<Select.Item value={weekdays[1].bit.toString()}>{weekdays[1].day}</Select.Item>
<Select.Item value={weekdays[2].bit.toString()}>{weekdays[2].day}</Select.Item>
<Select.Item value={weekdays[3].bit.toString()}>{weekdays[3].day}</Select.Item>
<Select.Item value={weekdays[4].bit.toString()}>{weekdays[4].day}</Select.Item>
<Select.Item value={weekdays[5].bit.toString()}>{weekdays[5].day}</Select.Item>
<Select.Item value={weekdays[6].bit.toString()}>{weekdays[6].day}</Select.Item>
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="flex gap-2">
<Form.Field {form} name="from" class="flex-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.from"]()}</Form.Label>
<Select.Root type="single" name={props.name} bind:value={$formData.from as string}>
<Select.Trigger {...props} class="w-full">
{getSelectedTime($formData.from as keyof typeof times)}
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="to" class="flex-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.to"]()}</Form.Label>
<Select.Root type="single" name={props.name} bind:value={$formData.to as string}>
<Select.Trigger {...props} class="w-full">
{getSelectedTime($formData.to as keyof typeof times)}
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
{/if}
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{m["absences.edit.action"]()}
@@ -4,11 +4,45 @@ import { z } from "zod";
export const formSchema = z
.object({
id: z.string(),
type: z.enum(["ONE_TIME", "REGULAR"]),
agent: z.string().uuid({ message: m["form.errors.noAgentsSelected"]() }),
absenceType: z.string().min(1).max(100),
description: z.string().optional(),
startDate: z.string(),
endDate: z.string(),
weekdays: z.number().int().min(0).max(127).nullable(),
from: z
.string()
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
.nullable(),
to: z
.string()
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
.nullable(),
})
.superRefine((data, ctx) => {
if (data.type === "REGULAR") {
if (data.weekdays == null || data.weekdays === 0) {
ctx.addIssue({
code: "custom",
message: m["form.errors.noWeekdaysSelected"](),
path: ["weekdays"],
});
}
if (data.from && data.to) {
const fromDate = new Date(`1970-01-01T${data.from}Z`);
const toDate = new Date(`1970-01-01T${data.to}Z`);
if (fromDate >= toDate) {
ctx.addIssue({
code: "custom",
message: m["form.errors.fromAfterTo"](),
path: ["from"],
});
}
}
}
})
.superRefine((data, ctx) => {
const start = new Date(data.startDate);
@@ -17,7 +51,7 @@ export const formSchema = z
if (end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: m["form.errors.endDateBeforeStartDate"](),
path: ["endDate"],
});
@@ -25,7 +59,7 @@ export const formSchema = z
if (end <= now) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: m["form.errors.endDateTooEarly"](),
path: ["endDate"],
});
@@ -80,10 +80,14 @@ export const actions: Actions = {
},
credentials: "same-origin",
body: JSON.stringify({
type: form.data.type,
description: form.data.description,
absenceType: form.data.absenceType,
startDate: new Date(form.data.startDate).toISOString(),
endDate: new Date(form.data.endDate).toISOString(),
weekdays: form.data.weekdays,
from: form.data.from,
to: form.data.to,
}),
},
);
@@ -98,7 +102,7 @@ export const actions: Actions = {
} catch (e) {
log.error("Failed to parse add absence error response", { error: e });
}
return fail(400, {
return fail(resp.status, {
form,
error,
});
@@ -129,10 +133,14 @@ export const actions: Actions = {
},
credentials: "same-origin",
body: JSON.stringify({
type: form.data.type,
description: form.data.description,
absenceType: form.data.absenceType,
startDate: new Date(form.data.startDate).toISOString(),
endDate: new Date(form.data.endDate).toISOString(),
weekdays: form.data.weekdays,
from: form.data.from,
to: form.data.to,
}),
},
);
@@ -147,7 +155,7 @@ export const actions: Actions = {
} catch (e) {
log.error("Failed to parse edit absences error response", { error: e });
}
return fail(400, {
return fail(resp.status, {
form: { ...form, data: { ...form.data } },
error,
});
@@ -11,7 +11,6 @@
import { ROUTES } from "$lib/const/routes";
import { agents as agentsStore } from "$lib/stores/agents";
import { type TAbsence } from "$lib/types/absence";
import { toDisplayDateTime } from "$lib/utils/datetime";
import EditIcon from "@lucide/svelte/icons/pencil";
import PlusIcon from "@lucide/svelte/icons/plus";
import DeleteIcon from "@lucide/svelte/icons/trash-2";
@@ -21,7 +20,8 @@
import { DeleteAbsenceForm } from "./(components)/delete-absence-form";
import EditAbsenceForm from "./(components)/edit-absence-form/edit-absence-form.svelte";
import { reasons } from "./(components)/utils";
import { getLocalTimeZone } from "@internationalized/date";
import { renderAbsenceTimespan } from "./utils";
import { RefreshCw } from "@lucide/svelte";
const { data } = $props();
const agents = $derived($agentsStore.agents ?? []);
@@ -35,34 +35,11 @@
});
const renderDescription = (item: TAbsence) => {
const startDate = toDisplayDateTime(new Date(item.startDate));
const fullStartDay = toDisplayDateTime(new Date(item.startDate), {
year: "numeric",
month: "long",
day: "numeric",
timeZone: getLocalTimeZone(),
});
const endDate = toDisplayDateTime(new Date(item.endDate));
const fullEndDay = toDisplayDateTime(new Date(item.endDate), {
year: "numeric",
month: "long",
day: "numeric",
timeZone: getLocalTimeZone(),
});
const reason = reasons.find((r) => r.value === item.absenceType);
const isAllDay =
new Date(item.startDate).getHours() === 0 &&
new Date(item.startDate).getMinutes() === 0 &&
new Date(item.endDate).getHours() === 23 &&
new Date(item.endDate).getMinutes() === 59;
const isSameDay =
new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString();
if (isSameDay && isAllDay) {
return `${reason?.label}: ${fullStartDay}`;
} else if (isAllDay) {
return `${reason?.label}: ${fullStartDay} - ${fullEndDay}`;
if (item.type === "ONE_TIME") {
const reason = reasons.find((r) => r.value === item.absenceType);
return `${reason?.label}: ${renderAbsenceTimespan(item)}`;
} else {
return `${reason?.label}: ${startDate} - ${endDate}`;
return renderAbsenceTimespan(item);
}
};
</script>
@@ -107,6 +84,7 @@
title={agent?.name || item.agentId}
image={agent?.image || UnknownItemIcon}
description={renderDescription(item)}
icons={item.type === "REGULAR" ? [RefreshCw] : []}
actions={[
{
type: "action",
@@ -0,0 +1,60 @@
import { times } from "$lib/components/ui/slot-template/utils";
import type { TAbsence } from "$lib/types/absence";
import {
timeUTCToLocalWithoutOffset,
toDisplayDateTime,
toWeekdaysLabel,
} from "$lib/utils/datetime";
import { getLocalTimeZone } from "@internationalized/date";
export const renderAbsenceTimespan = (item: TAbsence) => {
if (item.type === "ONE_TIME") {
return renderOneTimeAbsenceTimespan(item);
} else if (item.type === "REGULAR") {
return renderRegularAbsenceTimespan(item);
} else {
return "";
}
};
export const renderOneTimeAbsenceTimespan = (item: TAbsence) => {
const startDate = toDisplayDateTime(new Date(item.startDate));
const fullStartDay = toDisplayDateTime(new Date(item.startDate), {
year: "numeric",
month: "long",
day: "numeric",
timeZone: getLocalTimeZone(),
});
const endDate = toDisplayDateTime(new Date(item.endDate));
const fullEndDay = toDisplayDateTime(new Date(item.endDate), {
year: "numeric",
month: "long",
day: "numeric",
timeZone: getLocalTimeZone(),
});
const isAllDay =
new Date(item.startDate).getHours() === 0 &&
new Date(item.startDate).getMinutes() === 0 &&
new Date(item.endDate).getHours() === 23 &&
new Date(item.endDate).getMinutes() === 59;
const isSameDay =
new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString();
if (isSameDay && isAllDay) {
return `${fullStartDay}`;
} else if (isAllDay) {
return `${fullStartDay} - ${fullEndDay}`;
} else {
return `${startDate} - ${endDate}`;
}
};
export const renderRegularAbsenceTimespan = (item: TAbsence) => {
if (!item.from || !item.to) {
return "";
}
const weekdays = item.weekdays ?? 0;
const from = timeUTCToLocalWithoutOffset(item.from) as keyof typeof times;
const to = timeUTCToLocalWithoutOffset(item.to) as keyof typeof times;
return `${renderOneTimeAbsenceTimespan(item)}; ${toWeekdaysLabel(weekdays)} ${times[from]?.label ?? ""} - ${times[to]?.label ?? ""}`;
};
@@ -35,6 +35,12 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
schema: {
type: "object",
properties: {
type: {
type: "string",
enum: ["ONE_TIME", "REGULAR"],
description: "Type of absence",
example: "ONE_TIME",
},
startDate: {
type: "string",
format: "date-time",
@@ -58,14 +64,26 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
description: "Optional description of the absence",
example: "Jahresurlaub",
},
isFullDay: {
type: "boolean",
description: "Whether this is a full day absence",
example: true,
default: true,
weekdays: {
type: "integer",
description:
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
example: 62,
},
from: {
type: "string",
format: "time",
description: "Start time for REGULAR absences",
example: "09:00:00",
},
to: {
type: "string",
format: "time",
description: "End time for REGULAR absences",
example: "17:00:00",
},
},
required: ["startDate", "endDate", "absenceType"],
required: ["type", "startDate", "endDate", "absenceType"],
},
},
},
@@ -83,14 +101,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
type: {
type: "string",
enum: ["ONE_TIME", "REGULAR"],
description: "Type of absence",
},
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
weekdays: {
type: "integer",
description:
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
},
from: {
type: "string",
format: "time",
description: "Start time for REGULAR absences",
},
to: {
type: "string",
format: "time",
description: "End time for REGULAR absences",
},
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
},
},
required: ["message", "absence"],
@@ -199,14 +236,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "GET", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
type: {
type: "string",
enum: ["ONE_TIME", "REGULAR"],
description: "Type of absence",
},
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
weekdays: {
type: "integer",
description:
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
},
from: {
type: "string",
format: "time",
description: "Start time for REGULAR absences",
},
to: {
type: "string",
format: "time",
description: "End time for REGULAR absences",
},
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
},
},
},
@@ -278,6 +334,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
absenceType: body.absenceType,
startDate: body.startDate,
endDate: body.endDate,
type: body.type,
weekdays: body.weekdays,
from: body.from,
to: body.to,
});
const agentService = await AgentService.forTenant(tenantId);
@@ -53,14 +53,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "GET
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
type: {
type: "string",
enum: ["ONE_TIME", "REGULAR"],
description: "Type of absence",
},
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
weekdays: {
type: "integer",
description:
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
},
from: {
type: "string",
format: "time",
description: "Start time for REGULAR absences",
},
to: {
type: "string",
format: "time",
description: "End time for REGULAR absences",
},
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
},
},
required: ["absence"],
@@ -162,11 +181,6 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT
description: "Optional description of the absence",
example: "Erkältung",
},
isFullDay: {
type: "boolean",
description: "Whether this is a full day absence",
example: false,
},
},
},
},
@@ -185,14 +199,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
type: {
type: "string",
enum: ["ONE_TIME", "REGULAR"],
description: "Type of absence",
},
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
weekdays: {
type: "integer",
description:
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
},
from: {
type: "string",
format: "time",
description: "Start time for REGULAR absences",
},
to: {
type: "string",
format: "time",
description: "End time for REGULAR absences",
},
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
},
},
required: ["message", "absence"],
@@ -49,13 +49,40 @@ registerOpenAPIRoute("/tenants/{id}/agents/absences", "GET", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
type: {
type: "string",
enum: ["REGULAR", "ONE_TIME"],
description: "Type of absence",
},
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
weekdays: {
type: "integer",
description: "Weekdays bitmask for regular absences",
},
from: {
type: "string",
format: "time",
description: "Start time for regular absences",
},
to: {
type: "string",
format: "time",
description: "End time for regular absences",
},
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
required: [
"id",
"type",
"agentId",
"startDate",
"endDate",
"absenceType",
"isFullDay",
],
},
},
},
@@ -0,0 +1,8 @@
CREATE TYPE "public"."absence_type" AS ENUM('ONE_TIME', 'REGULAR');--> statement-breakpoint
ALTER TABLE "agent_absence" ADD COLUMN "type" "absence_type" DEFAULT 'ONE_TIME' NOT NULL;--> statement-breakpoint
ALTER TABLE "agent_absence" ADD COLUMN "weekdays" integer;--> statement-breakpoint
ALTER TABLE "agent_absence" ADD COLUMN "from" time;--> statement-breakpoint
ALTER TABLE "agent_absence" ADD COLUMN "to" time;
-- Migrate existing absences
UPDATE "agent_absence" SET "type" = 'ONE_TIME';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -120,6 +120,13 @@
"when": 1781796561280,
"tag": "0016_mighty_gamma_corps",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1784551934887,
"tag": "0017_optimal_professor_monster",
"breakpoints": true
}
]
}