Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions programmerbar-web/src/lib/availability-periods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,85 @@ import {
wholeDayPeriod
} from './availability-periods';
import { toLocalDateTimeString } from './utils/date';
import { overlaps } from './shift-planning';

describe('repeating availability', () => {
it('repeats only the selected hours and permits a later shift', () => {
const input = recurringAvailabilitySchema.parse({
startDate: '2026-10-09',
endDate: '2026-10-30',
weekdays: [5],
allDay: false,
startTime: '16:00',
endTime: '18:00'
});
const periods = recurringPeriods(input, new Date('2026-10-01'));
expect(periods).toHaveLength(4);
for (const period of periods) {
expect(toLocalDateTimeString(period.startAt).slice(11)).toBe('16:00');
expect(toLocalDateTimeString(period.endAt).slice(11)).toBe('18:00');
}
expect(periods[0].startAt.toISOString()).toBe('2026-10-09T14:00:00.000Z');
expect(periods[3].startAt.toISOString()).toBe('2026-10-30T15:00:00.000Z');
expect(
overlaps(periods[0], {
startAt: new Date('2026-10-09T18:00Z'),
endAt: new Date('2026-10-09T22:00Z')
})
).toBe(false);
expect(
overlaps(periods[0], {
startAt: new Date('2026-10-09T15:00Z'),
endAt: new Date('2026-10-09T19:00Z')
})
).toBe(true);
});
it('allows midnight or next-day endings on the last selected date', () => {
for (const endTime of ['00:00', '02:00']) {
const input = recurringAvailabilitySchema.parse({
startDate: '2026-10-09',
endDate: '2026-10-09',
weekdays: [5],
allDay: false,
startTime: '20:00',
endTime
});
const [period] = recurringPeriods(input, new Date('2026-10-01'));
expect(toLocalDateTimeString(period.endAt)).toBe(`2026-10-10T${endTime}`);
}
});
it('rejects missing, invalid, equal and nonexistent local times', () => {
const input = {
startDate: '2026-03-29',
endDate: '2026-03-29',
weekdays: [0],
allDay: false,
startTime: '16:00',
endTime: '18:00'
};
for (const change of [
{ startTime: '' },
{ endTime: undefined },
{ endTime: '24:00' },
{ endTime: '16:00' },
{ startTime: '02:30', endTime: '04:00' }
]) {
expect(recurringAvailabilitySchema.safeParse({ ...input, ...change }).success).toBe(false);
}
});
it('omits a timed occurrence that has already ended today', () => {
const input = recurringAvailabilitySchema.parse({
startDate: '2026-10-09',
endDate: '2026-10-16',
weekdays: [5],
allDay: false,
startTime: '16:00',
endTime: '18:00'
});
const periods = recurringPeriods(input, new Date('2026-10-09T17:00Z'));
expect(periods).toHaveLength(1);
expect(toLocalDateTimeString(periods[0].startAt)).toBe('2026-10-16T16:00');
});
it('includes both date boundaries and permits a single selected day', () => {
const input = { startDate: '2026-09-01', endDate: '2026-09-01', weekdays: [2] };
expect(recurringAvailabilitySchema.safeParse(input).success).toBe(true);
Expand Down
60 changes: 57 additions & 3 deletions programmerbar-web/src/lib/availability-periods.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { parseDateTimeLocal, toLocalDateTimeString } from './utils/date';
import type { Interval } from './shift-planning';
import { availabilitySchema } from './validators/availability';

const calendarDate = z
.string()
Expand All @@ -22,20 +23,69 @@ export const dateRangeSchema = z

export const recurringAvailabilitySchema = dateRangeSchema
.safeExtend({
weekdays: z.array(z.coerce.number().int().min(0).max(6)).min(1, 'Velg minst én ukedag.').max(7)
weekdays: z.array(z.coerce.number().int().min(0).max(6)).min(1, 'Velg minst én ukedag.').max(7),
allDay: z.boolean().optional(),
startTime: z.string().optional(),
endTime: z.string().optional()
})
.refine(
(value) =>
new Date(value.endDate).getTime() - new Date(value.startDate).getTime() < 366 * 86400000,
'Velg maksimalt ett år om gangen.'
);
)
.superRefine((input, ctx) => {
if (input.allDay !== false) return;
const clock = /^([01]\d|2[0-3]):[0-5]\d$/;
if (
!clock.test(input.startTime ?? '') ||
!clock.test(input.endTime ?? '') ||
input.startTime === input.endTime
) {
ctx.addIssue({
code: 'custom',
message: 'Velg ulike og gyldige klokkeslett for start og slutt.'
});
return;
}
if (
!dateRangeSchema.safeParse(input).success ||
new Date(input.endDate).getTime() - new Date(input.startDate).getTime() >= 366 * 86400000
)
return;
for (
let day = new Date(`${input.startDate}T00:00:00Z`);
day <= new Date(`${input.endDate}T00:00:00Z`);
day = nextDay(day)
) {
if (
input.weekdays.includes(day.getUTCDay()) &&
!availabilitySchema.safeParse(timedPeriodStrings(day, input.startTime!, input.endTime!))
.success
) {
ctx.addIssue({
code: 'custom',
message:
'Klokkeslettet finnes ikke på en av datoene på grunn av overgangen til sommertid. Velg et annet klokkeslett eller del opp perioden.'
});
return;
}
}
});

// UTC dates are calendar cursors only. Each boundary is converted separately to Oslo time,
// so a whole day remains midnight-to-midnight across daylight saving changes.
const localMidnight = (date: Date) =>
parseDateTimeLocal(`${date.toISOString().slice(0, 10)}T00:00`);
const nextDay = (date: Date) => new Date(date.getTime() + 24 * 60 * 60 * 1000);

function timedPeriodStrings(day: Date, startTime: string, endTime: string) {
const endDay = endTime < startTime ? nextDay(day) : day;
return {
startAt: `${day.toISOString().slice(0, 10)}T${startTime}`,
endAt: `${endDay.toISOString().slice(0, 10)}T${endTime}`
};
}

export function recurringPeriods(
input: z.infer<typeof recurringAvailabilitySchema>,
now: Date
Expand All @@ -45,7 +95,11 @@ export function recurringPeriods(
const periods: Interval[] = [];
while (cursor <= end) {
const tomorrow = nextDay(cursor);
const period = { startAt: localMidnight(cursor), endAt: localMidnight(tomorrow) };
const times =
input.allDay === false ? timedPeriodStrings(cursor, input.startTime!, input.endTime!) : null;
const period = times
? { startAt: parseDateTimeLocal(times.startAt), endAt: parseDateTimeLocal(times.endAt) }
: { startAt: localMidnight(cursor), endAt: localMidnight(tomorrow) };
if (input.weekdays.includes(cursor.getUTCDay()) && period.endAt > now) periods.push(period);
cursor = tomorrow;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export const actions: Actions = {
const parsed = recurringAvailabilitySchema.safeParse({
startDate: data.get('startDate'),
endDate: data.get('endDate'),
weekdays: data.getAll('weekdays')
weekdays: data.getAll('weekdays'),
allDay: data.get('allDay') === 'true',
startTime: data.get('startTime') ?? undefined,
endTime: data.get('endTime') ?? undefined
});
if (!parsed.success)
return fail(400, {
Expand All @@ -68,7 +71,11 @@ export const actions: Actions = {
.sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7))
.map((day) => weekdayNames[day])
.join(', ');
const groupLabel = `${weekdayLabel} · ${formatDate(parseDateTimeLocal(`${parsed.data.startDate}T00:00`))}–${formatDate(parseDateTimeLocal(`${parsed.data.endDate}T00:00`))}`;
const timeLabel =
parsed.data.allDay === false
? ` kl. ${parsed.data.startTime}–${parsed.data.endTime}${parsed.data.endTime! < parsed.data.startTime! ? ' neste dag' : ''}`
: '';
const groupLabel = `${weekdayLabel}${timeLabel} · ${formatDate(parseDateTimeLocal(`${parsed.data.startDate}T00:00`))}–${formatDate(parseDateTimeLocal(`${parsed.data.endDate}T00:00`))}`;
const conflict = await locals.shiftService.addAbsences(locals.user.id, periods, groupLabel);
return {
message: `${periods.length} perioder er lagret.${conflict ? ' Du har allerede vakt i en av periodene. Kontakt frivilligansvarlig for å bytte. Vaktene er ikke fjernet.' : ''}`
Expand Down
70 changes: 46 additions & 24 deletions programmerbar-web/src/routes/(portal)/portal/fravaer/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
let startDate = $state('');
let endDate = $state('');
let allDay = $state(true);
let startTime = $state('');
let endTime = $state('');
let weekdays = $state<string[]>([]);
let saving = $state(false);
let selectedIds = $state<string[]>([]);
Expand All @@ -29,7 +31,14 @@
{ value: '0', label: 'Søndag' }
];
const selection = $derived(
recurringAvailabilitySchema.safeParse({ startDate, endDate, weekdays })
recurringAvailabilitySchema.safeParse({
startDate,
endDate,
weekdays,
allDay,
startTime,
endTime
})
);
const preview = $derived(
selection.success
Expand Down Expand Up @@ -97,6 +106,35 @@
{/if}</label
>
</div>
<label class="flex items-center gap-2"
><input type="checkbox" class={checkboxClass} bind:checked={allDay} />Hele dager</label
>
<input type="hidden" name="allDay" value={String(allDay)} />
{#if !allDay}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-2"
>Fra klokken<Input
type="time"
name="startTime"
bind:value={startTime}
required
/></label
>
<label class="flex flex-col gap-2"
>Til klokken<Input type="time" name="endTime" bind:value={endTime} required /></label
>
</div>
<p class="text-sm">
{#if mode === 'recurring'}
Samme klokkeslett hver valgt ukedag. Slutt før start betyr neste dag; bruk 00:00 for
midnatt. Norsk tid.
{:else}
Fra klokkeslettet på startdatoen til klokkeslettet på sluttdatoen. Norsk tid.
{/if}
</p>
{:else if mode === 'single'}
<p class="text-sm">Alle dager i perioden tas med, også sluttdatoen.</p>
{/if}
{#if mode === 'recurring'}
<fieldset class="space-y-2">
<legend class="mb-2 font-medium">Hvilke ukedager?</legend>
Expand All @@ -114,7 +152,9 @@
{/each}
</div>
</fieldset>
<p class="text-sm">Gjelder hele dagen på valgte ukedager, til og med sluttdatoen.</p>
<p class="text-sm">
{allDay ? 'Gjelder hele dagen på' : 'Tidsrommet gjentas på'} valgte ukedager, til og med sluttdatoen.
</p>
{#if startDate && endDate && weekdays.length && !selection.success}
<p role="status" class="text-sm">{selection.error.issues[0]?.message}</p>
{:else if selection.success && !preview.length}
Expand All @@ -125,31 +165,13 @@
<summary class="cursor-pointer">{preview.length} dager valgt – se datoene</summary>
<ul class="mt-2 max-h-48 overflow-y-auto">
{#each preview as period (period.startAt.getTime())}<li>
{formatDate(period.startAt)}
{allDay
? formatDate(period.startAt)
: `${normalDate(period.startAt)} – ${normalDate(period.endAt)}`}
</li>{/each}
</ul>
</details>
{/if}
{:else}
<label class="flex items-center gap-2"
><input type="checkbox" class={checkboxClass} bind:checked={allDay} />Hele dager</label
>
<input type="hidden" name="allDay" value={String(allDay)} />
{#if !allDay}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-2"
>Fra klokken<Input type="time" name="startTime" required /></label
>
<label class="flex flex-col gap-2"
>Til klokken<Input type="time" name="endTime" required /></label
>
</div>
<p class="text-sm">
Fra klokkeslettet på startdatoen til klokkeslettet på sluttdatoen. Norsk tid.
</p>
{:else}
<p class="text-sm">Alle dager i perioden tas med, også sluttdatoen.</p>
{/if}
{/if}
<Button type="submit" disabled={saving || (mode === 'recurring' && !preview.length)}
>{saving ? 'Lagrer …' : 'Lagre'}</Button
Expand Down Expand Up @@ -187,7 +209,7 @@
<ul class="divide-portal-border mt-2 divide-y">
{#each group.periods as absence (absence.id)}
<li class="flex flex-wrap items-center justify-between gap-3 py-3">
<span>{formatDate(absence.startAt)}</span>
<span>{normalDate(absence.startAt)} – {normalDate(absence.endAt)}</span>
<form method="post" action="?/delete" use:enhance>
<input type="hidden" name="id" value={absence.id} />
<Button
Expand Down
Loading