feature: users with treatment edit permission should now have working hours defined. the appointment grid is now being drawn based on the doctor's working hours.

This commit is contained in:
2026-06-10 19:44:09 +03:30
parent 468572a50f
commit 48ecfd05e1
22 changed files with 1571 additions and 116 deletions

View File

@@ -0,0 +1,118 @@
export const MINUTES_PER_DAY = 24 * 60;
export type WorkingHoursBlockInput = {
dayOfWeek: number;
startMinute: number;
endMinute: number;
sortOrder?: number;
};
export type WorkingHoursDayBlock = {
startMinute: number;
endMinute: number;
};
export function localDayOfWeekMondayZero(dayOfWeekJs: number): number {
return dayOfWeekJs === 0 ? 6 : dayOfWeekJs - 1;
}
export function validateWorkingHoursBlocks(blocks: WorkingHoursBlockInput[]): string | null {
if (!Array.isArray(blocks)) {
return 'Working hours blocks must be an array';
}
const byDay = new Map<number, WorkingHoursBlockInput[]>();
for (const block of blocks) {
if (!Number.isInteger(block.dayOfWeek) || block.dayOfWeek < 0 || block.dayOfWeek > 6) {
return 'dayOfWeek must be an integer from 0 (Monday) to 6 (Sunday)';
}
if (!Number.isInteger(block.startMinute) || block.startMinute < 0 || block.startMinute >= MINUTES_PER_DAY) {
return 'startMinute must be between 0 and 1439';
}
if (!Number.isInteger(block.endMinute) || block.endMinute <= 0 || block.endMinute > MINUTES_PER_DAY) {
return 'endMinute must be between 1 and 1440';
}
if (block.endMinute <= block.startMinute) {
return 'Each shift end time must be after its start time';
}
const list = byDay.get(block.dayOfWeek) ?? [];
list.push(block);
byDay.set(block.dayOfWeek, list);
}
for (const [, dayBlocks] of byDay) {
const sorted = [...dayBlocks].sort((a, b) => a.startMinute - b.startMinute);
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].startMinute < sorted[i - 1].endMinute) {
return 'Shifts on the same day cannot overlap';
}
}
}
return null;
}
export function blocksForDay(
blocks: WorkingHoursBlockInput[],
dayOfWeek: number,
): WorkingHoursDayBlock[] {
return blocks
.filter((b) => b.dayOfWeek === dayOfWeek)
.sort((a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute)
.map((b) => ({ startMinute: b.startMinute, endMinute: b.endMinute }));
}
export function isMinuteWithinWorkingBlocks(minute: number, dayBlocks: WorkingHoursDayBlock[]): boolean {
return dayBlocks.some((b) => minute >= b.startMinute && minute < b.endMinute);
}
export function isRangeWithinWorkingBlocks(
startMinute: number,
endMinute: number,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
if (endMinute <= startMinute) {
return false;
}
for (let m = startMinute; m < endMinute; m += 1) {
if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) {
return false;
}
}
return true;
}
export function dateToLocalMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
}
export function appointmentWithinWorkingHours(
startAt: Date,
endAt: Date,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
const startMinute = dateToLocalMinutes(startAt);
const endMinute = dateToLocalMinutes(endAt);
return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks);
}
export function unionDayBlockRange(dayBlocksList: WorkingHoursDayBlock[][]): {
startMinute: number;
endMinute: number;
} | null {
let startMinute: number | null = null;
let endMinute: number | null = null;
for (const dayBlocks of dayBlocksList) {
for (const block of dayBlocks) {
startMinute = startMinute == null ? block.startMinute : Math.min(startMinute, block.startMinute);
endMinute = endMinute == null ? block.endMinute : Math.max(endMinute, block.endMinute);
}
}
if (startMinute == null || endMinute == null) {
return null;
}
return { startMinute, endMinute };
}