Compare commits

..

10 commits

Author SHA1 Message Date
732566a29c Use the slim base for the Docker image
All checks were successful
/ build (push) Successful in 1m35s
/ deploy (push) Has been skipped
This saves about 85% of the resulting image size.
2025-09-07 15:01:51 +02:00
5898a46a1b Add UI to edit and display event notices
Add a warning like display of event notices to the event card and the
event slot card and indicate in the timesheet that an event has a
notice.  Also includes the input controls needed to edit the notice.
2025-09-06 23:54:42 +02:00
adeef4f629 Transform the account field in sessions
When serving sessions instead of passing the ServerUser directly,
convert it to the ApiAccount format.
2025-09-06 23:53:54 +02:00
37edf122a1 Support multiline descriptions for entities
Use a textarea for editing the description and preserve linebreaks
when it's displayed in the UI using a new preWrap class for this
purpose.
2025-09-06 23:53:54 +02:00
96681bfd37 Strike through the removed part of diff entries
Communicate better that the part marked with - is the removed part by
striking out the text.
2025-09-06 23:53:54 +02:00
6d9d937c70 Render multi-line diff entries
Rework the rendering of the DiffEntry component to properly show
multiline entries as spanning multiple lines.
2025-09-06 23:53:53 +02:00
a8c62e6688 Add missing event host field to new events
Add field to input the host of the event when adding a new event to the
table of events.  This also fixes field order in the table being broken.
2025-09-06 16:24:56 +02:00
f29b1f7afd Add notice text field to events
Add a general text field for communicating extra information that
readers of the schedule should pay special attention to, for example to
highight a change made to the event.
2025-09-06 16:20:27 +02:00
9a46ea5af0 Add cancelled field to event slots
Make it possible to represent one slot out of a multi-slot event being
cancelled by adding a field for it in the slot, in addition to the
existing field on the event itself.
2025-09-06 15:54:58 +02:00
d006be251c Create a per-user admin page to inspect users
Add page to allow admins to inspect all of the details stored on the
server of a user account.  For now this is just the UserDetails, but
in the future this is planned to be expanded to also show sessions
and logs.
2025-09-06 15:16:02 +02:00
23 changed files with 363 additions and 80 deletions

View file

@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
# SPDX-License-Identifier: AGPL-3.0-or-later
# Based on Next.js's docker image example
FROM node:22 AS base
FROM node:22-slim AS base
# Install dependencies only when needed
FROM base AS deps

View file

@ -83,3 +83,7 @@ label>* {
label + label {
margin-block-start: 0.5rem;
}
.preWrap {
white-space: pre-wrap;
}

View file

@ -8,7 +8,15 @@
<p v-if=event.host>
Host: {{ event.host }}
</p>
<p>{{ event.description ?? "No description provided" }}</p>
<div v-if="event.notice" class="notice preWrap">
<div class="noticeIcon">
</div>
<p>
{{ event.notice }}
</p>
</div>
<p class="preWrap">{{ event.description ?? "No description provided" }}</p>
<p v-if="event.interested">
{{ event.interested }} interested
</p>
@ -79,6 +87,22 @@ async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
margin-block-start: 0.5rem;
}
.notice {
display: flex;
width: fit-content;
gap: 0.5rem;
padding: 0.5rem;
margin-block: 0.5rem;
border-radius: 0.25rem;
border: 1px solid color-mix(in oklab, CanvasText, orange 50%);
background-color: color-mix(in oklab, Canvas, orange 40%);
}
.noticeIcon {
flex: 0 0 auto;
align-self: center;
font-size: 1rem;
}
button {
padding-inline: 0.2em;
}

View file

@ -13,7 +13,15 @@
<p v-if=event?.host>
Host: {{ event.host }}
</p>
<p>{{ event?.description ?? "No description provided" }}</p>
<div v-if="event?.notice" class="notice preWrap">
<div class="noticeIcon">
</div>
<p>
{{ event.notice }}
</p>
</div>
<p class="preWrap">{{ event?.description ?? "No description provided" }}</p>
<p v-if="locations.length">
At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }}
</p>
@ -62,6 +70,22 @@ function formatTime(time: DateTime) {
margin-block-start: 0.5rem;
}
.notice {
display: flex;
width: fit-content;
gap: 0.5rem;
padding: 0.5rem;
margin-block: 0.5rem;
border-radius: 0.25rem;
border: 1px solid color-mix(in oklab, CanvasText, orange 50%);
background-color: color-mix(in oklab, Canvas, orange 40%);
}
.noticeIcon {
flex: 0 0 auto;
align-self: center;
font-size: 1rem;
}
button {
padding-inline: 0.2em;
}

View file

@ -5,7 +5,7 @@
<template>
<section class="shift">
<h3>{{ shift.name }}</h3>
<p>{{ shift.description ?? "No description provided" }}</p>
<p class="preWrap">{{ shift.description ?? "No description provided" }}</p>
<h4>Timeslots</h4>
<ul>

View file

@ -15,9 +15,14 @@
:key="index"
:class="type"
>
<div class="symbol">
{{ { "removed": "- ", "added": "+ "}[type] }}
</div>
<div class="content">
{{ text }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
@ -33,20 +38,23 @@ defineProps<{
grid-template-columns: 5rem 1fr;
column-gap: 1rem;
}
.removed {
:is(.removed, .added) {
display: flex;
grid-column: 2 / 2;
white-space: pre-wrap;
}
:is(.removed, .added) .symbol {
display: block;
font-family: monospace;
flex: 0 0 auto;
}
.removed {
color: color-mix(in srgb, CanvasText, red 40%);
}
.removed::before {
content: "- ";
font-family: monospace;
.removed .content{
text-decoration-line: line-through;
}
.added {
grid-column: 2 / 2;
color: color-mix(in srgb, CanvasText, green 40%);
}
.added::before {
content: "+ ";
font-family: monospace;
}
</style>

View file

@ -318,6 +318,7 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
start,
end,
new Set(newEventLocationIds.value),
false,
new Set(),
0,
);

View file

@ -10,6 +10,7 @@
<th>id</th>
<th>name</th>
<th>host</th>
<th>notice</th>
<th>description</th>
<th>p</th>
<th>s</th>
@ -39,11 +40,18 @@
>
</td>
<td>
<input
type="text"
<textarea
rows="1"
:disabled="!canEdit(event)"
v-model="event.notice"
/>
</td>
<td>
<textarea
rows="1"
:disabled="!canEdit(event)"
v-model="event.description"
>
/>
</td>
<td>
<input
@ -78,9 +86,21 @@
<td>
<input
type="text"
v-model="newEventDescription"
v-model="newEventHost"
>
</td>
<td>
<textarea
rows="1"
v-model="newEventNotice"
/>
</td>
<td>
<textarea
rows="1"
v-model="newEventDescription"
/>
</td>
<td>
<input
type="checkbox"
@ -110,7 +130,8 @@
<td>{{ event.id }}</td>
<td>{{ event.name }}</td>
<td>{{ event.host }}</td>
<td>{{ event.description }}</td>
<td class="preWrap">{{ event.notice }}</td>
<td class="preWrap">{{ event.description }}</td>
<td>{{ event.crew ? "" : "Yes"}}</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
</tr>
@ -136,6 +157,8 @@ function canEdit(event: ClientScheduleEvent) {
}
const newEventName = ref("");
const newEventHost = ref("");
const newEventNotice = ref("");
const newEventDescription = ref("");
const newEventPublic = ref(false);
function eventExists(name: string) {
@ -156,8 +179,9 @@ function newEvent() {
schedule.value.nextClientId--,
newEventName.value,
!newEventPublic.value,
"",
newEventHost.value,
false,
newEventNotice.value,
newEventDescription.value,
0,
new Set(),
@ -165,6 +189,8 @@ function newEvent() {
);
schedule.value.events.add(event);
newEventName.value = "";
newEventHost.value = "";
newEventNotice.value = "";
newEventDescription.value = "";
newEventPublic.value = false;
}

View file

@ -28,10 +28,10 @@
>
</td>
<td>
<input
type="text"
<textarea
rows="1"
v-model="location.description"
>
/>
</td>
<td>
<button
@ -49,7 +49,7 @@
<template v-else>
<td>{{ location.id }}</td>
<td>{{ location.name }}</td>
<td>{{ location.description }}</td>
<td class="preWrap">{{ location.description }}</td>
</template>
</tr>
<tr v-if='edit'>
@ -63,10 +63,10 @@
>
</td>
<td>
<input
type="text"
<textarea
rows="1"
v-model="newLocationDescription"
>
/>
</td>
<td colspan="1">
<button

View file

@ -28,10 +28,10 @@
>
</td>
<td>
<input
type="text"
<textarea
rows="1"
v-model="role.description"
>
/>
</td>
<td>
<button
@ -55,10 +55,10 @@
>
</td>
<td>
<input
type="text"
<textarea
rows="1"
v-model="newRoleDescription"
>
/>
</td>
<td>
<button
@ -80,7 +80,7 @@
>
<td>{{ role.id }}</td>
<td>{{ role.name }}</td>
<td>{{ role.description }}</td>
<td class="preWrap">{{ role.description }}</td>
</tr>
</template>
</tbody>

View file

@ -37,10 +37,10 @@
</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>
<input
type="text"
<textarea
rows="1"
v-model="shift.description"
>
/>
</td>
<td>
<button
@ -71,10 +71,10 @@
</td>
<td></td>
<td>
<input
type="text"
<textarea
rows="1"
v-model="newShiftDescription"
>
/>
</td>
<td>
<button
@ -98,7 +98,7 @@
<td>{{ shift.name }}</td>
<td>{{ shift.roleId }}</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>{{ shift.description }}</td>
<td class="preWrap">{{ shift.description }}</td>
</tr>
</template>
</tbody>

View file

@ -17,7 +17,12 @@
<tr v-for="user in usersStore.users.values()">
<td>{{ user.id }}</td>
<td>
<NuxtLink :to="`/admin/users/${user.id}`">
<template v-if="user.name">
{{ user.name }}
</template>
<i v-else>(empty)</i>
</NuxtLink>
</td>
<td>
<select
@ -39,7 +44,7 @@
<button
v-if="user.isModified()"
type="button"
@click="saveUser(user);"
@click="usersStore.saveUser(user);"
>Save</button>
<button
v-if="user.isModified()"
@ -55,18 +60,6 @@
<script lang="ts" setup>
useEventSource();
const usersStore = useUsersStore();
async function saveUser(user: ClientUser) {
try {
await $fetch("/api/admin/user", {
method: "PATCH",
body: user.toApi(),
});
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
</script>
<style>

View file

@ -105,6 +105,7 @@
:class='{"event": cell.slot, "crew": cell.event?.crew }'
:title="cell.event?.name"
>
{{ cell.event?.notice ? "⚠️" : undefined }}
{{ cell.event?.name }}
</td>
</tr>

View file

@ -0,0 +1,94 @@
<template>
<main v-if="userDetails.deleted">
<h1>Deleted user {{ id }}</h1>
</main>
<main v-else>
<h1>User {{ user.name }}</h1>
<dl>
<dt>
<label for="user-type">
Type
</label>
</dt>
<dd>
<select
v-if='user.type !== "anonymous"'
v-model="user.type"
>
<option value="regular">Regular</option>
<option value="crew">Crew</option>
<option value="admin">Admin</option>
</select>
<template v-else>
{{ user.type }}
</template>
</dd>
<dt>Interested Events:</dt>
<dd>{{ userDetails.interestedEventIds }}</dd>
<dt>Interested Slots:</dt>
<dd>{{ userDetails.interestedEventSlotIds }}</dd>
<dt>Timezone:</dt>
<dd>{{ userDetails.timezone }}</dd>
<dt>Locale:</dt>
<dd>{{ userDetails.locale }}</dd>
</dl>
<button
:disabled="!user.isModified()"
type="button"
@click="usersStore.saveUser(user);"
>Save</button>
<button
:disabled="!user.isModified()"
type="button"
@click="user.discard()"
>Discard</button>
</main>
</template>
<script lang="ts" setup>
import type { ApiTombstone, ApiUserDetails } from '~/shared/types/api';
useHead({
title: "Admin",
});
useEventSource();
const route = useRoute();
const usersStore = useUsersStore();
await usersStore.fetch();
const id = computed(() => {
const id = queryToNumber(route.params.id);
if (id === undefined) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "User id required",
});
}
return id;
});
const user = computed(() => {
const user = usersStore.users.get(id.value);
if (user === undefined) {
throw createError({
statusCode: 404,
statusMessage: "Not Found",
message: "User not found",
});
}
return user;
});
const { pending, data, error } = await useFetch(() => `/api/users/${id.value}/details`);
const userDetails = data as Ref<ApiUserDetails | ApiTombstone>;
</script>
<style>
dl {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,34 @@
import { z } from "zod/v4-mini";
import { readUsers } from "~/server/database";
import { serverUserToApiDetails } from "~/server/utils/user";
const integerStringSchema = z.pipe(
z.string().check(z.regex(/^\d+/)),
z.transform(Number)
);
const detailsSchema = z.object({
id: integerStringSchema,
});
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const users = await readUsers();
const { success, error, data: params } = detailsSchema.safeParse(getRouterParams(event));
if (!success) {
throw createError({
status: 400,
statusText: "Bad Request",
message: z.prettifyError(error),
});
}
const user = users.find(user => user.id === params.id);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: "Not found",
});
}
return serverUserToApiDetails(user);
})

View file

@ -9,7 +9,7 @@ const locations = [
{
id: 1,
name: "Stage",
description: "Inside the main building.",
description: "Inside the main building.\n\nMind the gap.",
updatedAt: "d-1 18:21",
},
{
@ -43,7 +43,8 @@ let eventId = 1;
const events = [
{
name: "Arcade",
description: "Play retro games!",
notice: "No food or drinks allowed!\n\nClosed drinking containers are okay.",
description: "Play retro games!\n\nWe have anything from C64 to PS5.",
slots: [
"d1 12:00 4h clubhouse",
"d2 12:00 4h clubhouse",
@ -158,13 +159,14 @@ const events = [
const idMedic = 1;
const idSecurity = 2;
const roles = [
{ id: idMedic, name: "Medic", updatedAt: "d-2 12:34" },
{ id: idSecurity, name: "Security", updatedAt: "d-2 12:39" },
{ id: idMedic, name: "Medic", description: "Helping those in need.\n\nAsk lead if in doubt.", updatedAt: "d-2 12:34" },
{ id: idSecurity, name: "Security", description: "Keeping the con safe", updatedAt: "d-2 12:39" },
]
const shifts = [
{
name: "Medic Early",
description: "Not much happens early.\n\nPrefer strolling outside to be visible.",
roleId: idMedic,
slots: [
"d1 12:00 4h",
@ -338,10 +340,11 @@ export function generateDemoSchedule(): ApiSchedule {
id: 111,
updatedAt: toIso(toDate(origin, "d-2", "10:01")),
events: events.map(
({ id, name, crew, description, slots }) => ({
({ id, name, crew, notice, description, slots }) => ({
id,
name,
crew,
notice,
description,
interested: eventCounts.get(id),
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
@ -357,16 +360,18 @@ export function generateDemoSchedule(): ApiSchedule {
})
),
roles: roles.map(
({ id, name, updatedAt }) => ({
({ id, name, description, updatedAt }) => ({
id,
name,
description,
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
})
),
shifts: shifts.map(
({ id, name, roleId, slots }) => ({
({ id, name, description, roleId, slots }) => ({
id,
name,
description,
roleId,
slots: slots.map(shorthand => toShift(origin, shorthand, shiftSlotIdToAssigned)),
updatedAt: toIso(toDate(origin, "d-1", "13:23")),

View file

@ -15,6 +15,7 @@ import {
} from "~/server/database";
import { broadcastEvent } from "../streams";
import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
import { serverUserToApiAccount } from "./user";
async function removeSessionSubscription(sessionId: number) {
const subscriptions = await readSubscriptions();
@ -182,7 +183,7 @@ export async function serverSessionToApi(event: H3Event, session: ServerSession)
return {
id: session.id,
account,
account: serverUserToApiAccount(account),
authenticationProvider: session.authenticationProvider,
authenticationName: session.authenticationName,
push,

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ServerUser } from "~/server/database"
import type { ApiTombstone, ApiUser } from "~/shared/types/api";
import type { ApiAccount, ApiTombstone, ApiUser, ApiUserDetails } from "~/shared/types/api";
export function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone {
if (user.deleted) {
@ -20,3 +20,37 @@ export function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone {
name: user.name,
}
}
export function serverUserToApiDetails(user: ServerUser): ApiUserDetails | ApiTombstone {
if (user.deleted) {
return {
id: user.id,
updatedAt: user.updatedAt,
deleted: true,
}
}
return {
id: user.id,
updatedAt: user.updatedAt,
interestedEventIds: user.interestedEventIds,
interestedEventSlotIds: user.interestedEventSlotIds,
timezone: user.timezone,
locale: user.locale,
}
}
export function serverUserToApiAccount(user?: ServerUser): ApiAccount | undefined {
if (!user || user.deleted) {
return undefined;
}
return {
id: user.id,
updatedAt: user.updatedAt,
type: user.type,
name: user.name,
interestedEventIds: user.interestedEventIds,
interestedEventSlotIds: user.interestedEventSlotIds,
timezone: user.timezone,
locale: user.locale,
}
}

View file

@ -35,17 +35,7 @@ export const apiUserTypeSchema = z.union([
])
export type ApiUserType = z.infer<typeof apiUserTypeSchema>;
export interface ApiAccount {
id: Id,
updatedAt: string,
type: ApiUserType,
/** Name of the account. Not present on anonymous accounts */
name?: string,
interestedEventIds?: number[],
interestedEventSlotIds?: number[],
timezone?: string,
locale?: string,
}
export type ApiAccount = ApiUser & ApiUserDetails
export const apiAccountPatchSchema = z.object({
name: z.optional(z.string()),
@ -91,6 +81,7 @@ export const apiScheduleEventSlotSchema = z.object({
start: z.string(),
end: z.string(),
locationIds: z.array(idSchema),
cancelled: z.optional(z.boolean()),
assigned: z.optional(z.array(z.number())),
interested: z.optional(z.number()),
});
@ -101,6 +92,7 @@ export const apiScheduleEventSchema = defineApiEntity({
crew: z.optional(z.boolean()),
host: z.optional(z.string()),
cancelled: z.optional(z.boolean()),
notice: z.optional(z.string()),
description: z.optional(z.string()),
interested: z.optional(z.number()),
slots: z.array(apiScheduleEventSlotSchema),
@ -151,6 +143,16 @@ export const apiUserPatchSchema = z.object({
});
export type ApiUserPatch = z.infer<typeof apiUserPatchSchema>;
export interface ApiUserDetails {
id: Id,
updatedAt: string,
deleted?: false,
interestedEventIds?: number[],
interestedEventSlotIds?: number[],
timezone?: string,
locale?: string,
}
export interface ApiAccountUpdate {
type: "account-update",
data: ApiAccount,

View file

@ -62,6 +62,17 @@ export const useUsersStore = defineStore("users", () => {
state.fetched.value = false;
await actions.fetch();
},
async saveUser(user: ClientUser) {
try {
await $fetch("/api/admin/user", {
method: "PATCH",
body: user.toApi(),
});
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
},
}
appEventSource?.addEventListener("update", (event) => {

View file

@ -21,15 +21,15 @@ function fixtureClientSchedule(multiSlot = false) {
const events = [
new ClientScheduleEvent(
1, now, false, "Up", false, "", false, "What's Up?", 0, new Set(multiSlot ? [1, 2] : [1]),
1, now, false, "Up", false, "", false, "", "What's Up?", 0, new Set(multiSlot ? [1, 2] : [1]),
),
new ClientScheduleEvent(
2, now, false, "Down", false, "", false, "", 0, new Set(multiSlot ? [] : [2]),
2, now, false, "Down", false, "", false, "", "", 0, new Set(multiSlot ? [] : [2]),
),
];
const eventSlots = idMap([
new ClientScheduleEventSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set([left.id]), new Set(), 0),
new ClientScheduleEventSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set([right.id]), new Set(), 0),
new ClientScheduleEventSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set([left.id]), false, new Set(), 0),
new ClientScheduleEventSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set([right.id]), false, new Set(), 0),
]);
const red = new ClientScheduleRole(1, now, false, "Red", "Is a color.");
@ -174,7 +174,7 @@ describe("class ClientSchedule", () => {
],
[
"event",
(schedule) => ClientScheduleEvent.create(schedule, 3, "New location", false, "", false, "", 0, new Set(), { zone, locale })
(schedule) => ClientScheduleEvent.create(schedule, 3, "New location", false, "", false, "", "", 0, new Set(), { zone, locale })
],
[
"role",
@ -277,6 +277,7 @@ describe("class ClientSchedule", () => {
now,
now.plus({ hours: 3 }),
new Set(),
false,
new Set(),
0,
);

View file

@ -136,6 +136,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
serverCrew: boolean;
serverHost: string;
serverCancelled: boolean;
serverNotice: string;
serverDescription: string;
serverInterested: number;
serverSlotIds: Set<Id>;
@ -148,6 +149,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
public crew: boolean,
public host: string,
public cancelled: boolean,
public notice: string,
public description: string,
public interested: number,
public slotIds: Set<Id>,
@ -157,6 +159,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
this.serverCrew = crew;
this.serverHost = host;
this.serverCancelled = cancelled;
this.serverNotice = notice;
this.serverDescription = description;
this.serverInterested = interested;
this.serverSlotIds = new Set(slotIds);
@ -173,6 +176,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
|| this.crew !== this.serverCrew
|| this.host !== this.serverHost
|| this.cancelled !== this.serverCancelled
|| this.notice !== this.serverNotice
|| this.description !== this.serverDescription
|| this.interested !== this.serverInterested
|| !setEquals(this.slotIds, this.serverSlotIds)
@ -190,6 +194,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
this.crew = this.serverCrew;
this.host = this.serverHost;
this.cancelled = this.serverCancelled;
this.notice = this.serverNotice;
this.description = this.serverDescription;
this.interested = this.serverInterested;
for (const id of this.serverSlotIds) {
@ -208,6 +213,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
crew: boolean,
host: string,
cancelled: boolean,
notice: string,
description: string,
interested: number,
slotIds: Set<Id>,
@ -221,6 +227,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
crew,
host,
cancelled,
notice,
description,
interested,
slotIds,
@ -241,6 +248,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
api.crew ?? false,
api.host ?? "",
api.cancelled ?? false,
api.notice ?? "",
api.description ?? "",
api.interested ?? 0,
new Set(api.slots.map(slot => slot.id)),
@ -258,6 +266,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
this.serverCrew = api.crew ?? false;
this.serverHost = api.host ?? "";
this.serverCancelled = api.cancelled ?? false;
this.serverNotice = api.notice ?? "";
this.serverDescription = api.description ?? "";
this.serverInterested = api.interested ?? 0;
this.serverSlotIds = new Set(api.slots.map(slot => slot.id));
@ -281,6 +290,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
crew: this.crew || undefined,
host: this.host || undefined,
cancelled: this.cancelled || undefined,
notice: this.notice || undefined,
description: this.description || undefined,
interested: this.interested || undefined,
slots: [...this.slots.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()),
@ -295,6 +305,7 @@ export class ClientScheduleEventSlot {
serverStart: DateTime;
serverEnd: DateTime;
serverLocationIds: Set<Id>;
serverCancelled: boolean;
serverAssigned: Set<Id>;
serverInterested: number;
@ -306,6 +317,7 @@ export class ClientScheduleEventSlot {
public start: DateTime,
public end: DateTime,
public locationIds: Set<Id>,
public cancelled: boolean,
public assigned: Set<Id>,
public interested: number,
) {
@ -314,6 +326,7 @@ export class ClientScheduleEventSlot {
this.serverStart = start;
this.serverEnd = end;
this.serverLocationIds = new Set(locationIds);
this.serverCancelled = cancelled;
this.serverAssigned = new Set(assigned);
this.serverInterested = interested;
}
@ -327,6 +340,7 @@ export class ClientScheduleEventSlot {
|| this.start.toMillis() !== this.serverStart.toMillis()
|| this.end.toMillis() !== this.serverEnd.toMillis()
|| !setEquals(this.locationIds, this.serverLocationIds)
|| this.cancelled !== this.serverCancelled
|| !setEquals(this.assigned, this.serverAssigned)
|| this.interested !== this.serverInterested
);
@ -349,6 +363,7 @@ export class ClientScheduleEventSlot {
this.start = this.serverStart;
this.end = this.serverEnd;
this.locationIds = new Set(this.serverLocationIds);
this.cancelled = this.serverCancelled;
this.assigned = new Set(this.serverAssigned);
this.interested = this.serverInterested;
}
@ -360,6 +375,7 @@ export class ClientScheduleEventSlot {
start: DateTime,
end: DateTime,
locationIds: Set<Id>,
cancelled: boolean,
assigned: Set<Id>,
interested: number,
) {
@ -371,6 +387,7 @@ export class ClientScheduleEventSlot {
start,
end,
locationIds,
cancelled,
assigned,
interested,
);
@ -391,6 +408,7 @@ export class ClientScheduleEventSlot {
DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts),
new Set(api.locationIds),
api.cancelled ?? false,
new Set(api.assigned),
api.interested ?? 0,
);
@ -408,6 +426,7 @@ export class ClientScheduleEventSlot {
this.serverStart = DateTime.fromISO(api.start, opts);
this.serverEnd = DateTime.fromISO(api.end, opts);
this.serverLocationIds = new Set(api.locationIds);
this.serverCancelled = api.cancelled ?? false;
this.serverAssigned = new Set(api.assigned);
this.serverInterested = api.interested ?? 0;
if (!wasModified || !this.isModified()) {
@ -424,6 +443,7 @@ export class ClientScheduleEventSlot {
start: toIso(this.start),
end: toIso(this.end),
locationIds: [...this.locationIds],
cancelled: this.cancelled || undefined,
assigned: this.assigned.size ? [...this.assigned] : undefined,
interested: this.interested || undefined,
}