Allow shifts without a role

Allow a shift to have no role associated with it in order to simplify
conflict resolution around situations like a shift being created while
the role it was assoiated with was deleted. This also allows for shifts
that are freestanding to be created in case having a role doesn't make
sense for it.
This commit is contained in:
Hornwitser 2025-06-30 16:14:40 +02:00
parent 1d2edf7535
commit 5144bf2b37
5 changed files with 32 additions and 55 deletions

View file

@ -39,12 +39,7 @@
v-model="newShiftId" v-model="newShiftId"
/> />
</td> </td>
<td> <td></td>
<SelectSingleEntity
:entities="schedule.roles"
v-model="newShiftRoleId"
/>
</td>
<td></td> <td></td>
<td> <td>
Add at Add at
@ -139,12 +134,7 @@
v-model="newShiftId" v-model="newShiftId"
/> />
</td> </td>
<td> <td></td>
<SelectSingleEntity
:entities="schedule.roles"
v-model="newShiftRoleId"
/>
</td>
<td colspan="2"> <td colspan="2">
<button <button
type="button" type="button"
@ -244,10 +234,6 @@ const newShiftEnd = computed({
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm"); newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
}, },
}); });
const newShiftRoleId = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRoleId.value = props.roleId;
});
function endFromTime(start: DateTime, time: string) { function endFromTime(start: DateTime, time: string) {
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale })); let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
@ -291,11 +277,6 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid shift"); alert("Invalid shift");
return; return;
} }
const role = schedule.value.roles.get(newShiftRoleId.value!);
if (!role) {
alert("Invalid role");
return;
}
let start; let start;
let end; let end;
const duration = durationFromTime(newShiftDuration.value); const duration = durationFromTime(newShiftDuration.value);

View file

@ -131,10 +131,6 @@ function newShift() {
alert(`Shift ${newShiftName.value} already exists`); alert(`Shift ${newShiftName.value} already exists`);
return; return;
} }
if (newShiftRoleId.value === undefined) {
alert(`Invalid role`);
return;
}
const zone = Info.normalizeZone(accountStore.activeTimezone); const zone = Info.normalizeZone(accountStore.activeTimezone);
const locale = accountStore.activeLocale; const locale = accountStore.activeLocale;
const shift = ClientScheduleShift.create( const shift = ClientScheduleShift.create(

View file

@ -105,22 +105,21 @@
</td> </td>
</tr> </tr>
</template> </template>
<template v-if="schedule.roles.size"> <template v-if="roleGroups.size">
<tr> <tr>
<th>Shifts</th> <th>Shifts</th>
<td :colSpan="totalColumns"></td> <td :colSpan="totalColumns"></td>
</tr> </tr>
<template v-for="role in schedule.roles.values()" :key="role.id"> <template v-for="[id, roleGroup] in roleGroups" :key="id">
<tr <tr
v-if="roleGroups.has(role.id)" v-for="row, index in roleGroup"
v-for="row, index in roleGroups.get(role.id)"
:key="index" :key="index"
> >
<th <th
v-if="index === 0" v-if="index === 0"
:rowSpan="roleGroups.get(role.id)!.length" :rowSpan="roleGroup.length"
> >
{{ role.name }} {{ schedule.roles.get(id!)?.name }}
</th> </th>
<td <td
v-for="cell, index in row" v-for="cell, index in row"
@ -152,7 +151,7 @@ const oneMinMs = 60 * 1000;
/** Point in time where a time slots starts or ends. */ /** Point in time where a time slots starts or ends. */
type Edge = type Edge =
| { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot } | { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ClientScheduleShiftSlot } | { type: "start" | "end", source: "shift", roleId?: Id, slot: ClientScheduleShiftSlot }
; ;
/** Point in time where multiple edges meet. */ /** Point in time where multiple edges meet. */
@ -163,7 +162,7 @@ type Span = {
start: Junction; start: Junction;
end: Junction, end: Junction,
locations: Map<number, Set<ClientScheduleEventSlot>>, locations: Map<number, Set<ClientScheduleEventSlot>>,
roles: Map<number, Set<ClientScheduleShiftSlot>>, roles: Map<number | undefined, Set<ClientScheduleShiftSlot>>,
}; };
/** /**
@ -238,9 +237,10 @@ function* spansFromJunctions(
const activeLocations = new Map( const activeLocations = new Map(
[...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()]) [...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()])
); );
const activeRoles = new Map( const activeRoles = new Map<number | undefined, Set<ClientScheduleShiftSlot>>(
[...roles.keys()].map(id => [id, new Set<ClientScheduleShiftSlot>()]) [...roles.keys()].map(id => [id, new Set()]),
); );
activeRoles.set(undefined, new Set());
for (const [start, end] of pairs(junctions)) { for (const [start, end] of pairs(junctions)) {
for (const edge of start.edges) { for (const edge of start.edges) {
if (edge.type === "start") { if (edge.type === "start") {
@ -249,7 +249,7 @@ function* spansFromJunctions(
activeLocations.get(locationId)?.add(edge.slot) activeLocations.get(locationId)?.add(edge.slot)
} }
} else if (edge.source === "shift") { } else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.add(edge.slot) activeRoles.get(edge.roleId)?.add(edge.slot);
} }
} }
} }
@ -403,7 +403,8 @@ function tableElementsFromStretches(
const dayHeaders: DayHead[] = []; const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= []; const hourHeaders: HourHead[]= [];
const locationGroups = new Map<number, LocationRow[]>([...locations.keys()].map(id => [id, []])); const locationGroups = new Map<number, LocationRow[]>([...locations.keys()].map(id => [id, []]));
const roleGroups = new Map<number, RoleRow[]>([...roles.keys()].map(id => [id, []])); const roleGroups = new Map<number | undefined, RoleRow[]>([...roles.keys()].map(id => [id, []]));
roleGroups.set(undefined, []);
const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event]))); const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event])));
const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift]))); const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift])));
let totalColumns = 0; let totalColumns = 0;
@ -443,7 +444,7 @@ function tableElementsFromStretches(
row.push({ span: 0, isBreak, slot, event: eventBySlotId.get(slot.id) }); row.push({ span: 0, isBreak, slot, event: eventBySlotId.get(slot.id) });
} }
} }
function startRole(id: number, isBreak: boolean, newSlots = new Set<ClientScheduleShiftSlot>()) { function startRole(id: number | undefined, isBreak: boolean, newSlots = new Set<ClientScheduleShiftSlot>()) {
const group = roleGroups.get(id)!; const group = roleGroups.get(id)!;
// Remove all slots that are no longer in the new slots. // Remove all slots that are no longer in the new slots.
for (const row of group) { for (const row of group) {
@ -481,9 +482,8 @@ function tableElementsFromStretches(
row[row.length - 1].span += 1; row[row.length - 1].span += 1;
} }
} }
for(const role of roles.values()) { for (const roleGroup of roleGroups.values()) {
const group = roleGroups.get(role.id)!; for (const row of roleGroup) {
for (const row of group) {
row[row.length - 1].span += 1; row[row.length - 1].span += 1;
} }
} }
@ -500,8 +500,8 @@ function tableElementsFromStretches(
for (const location of locations.values()) { for (const location of locations.values()) {
startLocation(location.id, false); startLocation(location.id, false);
} }
for (const role of roles.values()) { for (const roleId of roleGroups.keys()) {
startRole(role.id, false); startRole(roleId, false);
} }
} else { } else {
startColumnGroup(lastStretch.end, stretch.start, 1, true); startColumnGroup(lastStretch.end, stretch.start, 1, true);
@ -514,8 +514,8 @@ function tableElementsFromStretches(
for(const location of locations.values()) { for(const location of locations.values()) {
startLocation(location.id, true); startLocation(location.id, true);
} }
for(const role of roles.values()) { for (const roleId of roleGroups.keys()) {
startRole(role.id, true); startRole(roleId, true);
} }
pushColumn(); pushColumn();
@ -526,8 +526,8 @@ function tableElementsFromStretches(
for(const location of locations.values()) { for(const location of locations.values()) {
startLocation(location.id, false); startLocation(location.id, false);
} }
for(const role of roles.values()) { for (const roleId of roleGroups.keys()) {
startRole(role.id, false); startRole(roleId, false);
} }
} }
@ -544,12 +544,12 @@ function tableElementsFromStretches(
startLocation(location.id, false, slots) startLocation(location.id, false, slots)
} }
} }
for (const role of roles.values()) { for (const roleId of roleGroups.keys()) {
const slots = cutSpan.roles.get(role.id) ?? new Set(); const slots = cutSpan.roles.get(roleId) ?? new Set();
const group = roleGroups.get(role.id)!; const group = roleGroups.get(roleId)!;
const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot)); const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
if (!setEquals(slots, existing)) { if (!setEquals(slots, existing)) {
startRole(role.id, false, slots); startRole(roleId, false, slots);
} }
} }

View file

@ -111,7 +111,7 @@ export const apiScheduleShiftSlotSchema = z.object({
export type ApiScheduleShiftSlot = z.infer<typeof apiScheduleShiftSlotSchema>; export type ApiScheduleShiftSlot = z.infer<typeof apiScheduleShiftSlotSchema>;
export const apiScheduleShiftSchema = defineApiEntity({ export const apiScheduleShiftSchema = defineApiEntity({
roleId: idSchema, roleId: z.optional(idSchema),
name: z.string(), name: z.string(),
description: z.optional(z.string()), description: z.optional(z.string()),
slots: z.array(apiScheduleShiftSlotSchema), slots: z.array(apiScheduleShiftSlotSchema),

View file

@ -515,7 +515,7 @@ export class ClientScheduleRole extends ClientEntity<ApiScheduleRole> {
export class ClientScheduleShift extends ClientEntity<ApiScheduleShift> { export class ClientScheduleShift extends ClientEntity<ApiScheduleShift> {
schedule!: ClientSchedule; schedule!: ClientSchedule;
serverRoleId: Id; serverRoleId: Id | undefined;
serverName: string; serverName: string;
serverDescription: string; serverDescription: string;
serverSlotIds: Set<Id>; serverSlotIds: Set<Id>;
@ -524,7 +524,7 @@ export class ClientScheduleShift extends ClientEntity<ApiScheduleShift> {
id: Id, id: Id,
updatedAt: DateTime, updatedAt: DateTime,
deleted: boolean, deleted: boolean,
public roleId: Id, public roleId: Id | undefined,
public name: string, public name: string,
public description: string, public description: string,
public slotIds: Set<Id>, public slotIds: Set<Id>,
@ -572,7 +572,7 @@ export class ClientScheduleShift extends ClientEntity<ApiScheduleShift> {
static create( static create(
schedule: ClientSchedule, schedule: ClientSchedule,
id: Id, id: Id,
roleId: Id, roleId: Id | undefined,
name: string, name: string,
description: string, description: string,
slotIds: Set<Id>, slotIds: Set<Id>,