diff --git a/programmerbar-web/src/lib/availability-periods.test.ts b/programmerbar-web/src/lib/availability-periods.test.ts index 615ec10..18c95d5 100644 --- a/programmerbar-web/src/lib/availability-periods.test.ts +++ b/programmerbar-web/src/lib/availability-periods.test.ts @@ -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); diff --git a/programmerbar-web/src/lib/availability-periods.ts b/programmerbar-web/src/lib/availability-periods.ts index bdd2195..4e0c9e2 100644 --- a/programmerbar-web/src/lib/availability-periods.ts +++ b/programmerbar-web/src/lib/availability-periods.ts @@ -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() @@ -22,13 +23,54 @@ 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. @@ -36,6 +78,14 @@ 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, now: Date @@ -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; } diff --git a/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.server.ts b/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.server.ts index 8d669cc..12a85ca 100644 --- a/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.server.ts +++ b/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.server.ts @@ -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, { @@ -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.' : ''}` diff --git a/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.svelte b/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.svelte index 87d06f7..9b4b930 100644 --- a/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.svelte +++ b/programmerbar-web/src/routes/(portal)/portal/fravaer/+page.svelte @@ -11,6 +11,8 @@ let startDate = $state(''); let endDate = $state(''); let allDay = $state(true); + let startTime = $state(''); + let endTime = $state(''); let weekdays = $state([]); let saving = $state(false); let selectedIds = $state([]); @@ -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 @@ -97,6 +106,35 @@ {/if} + + + {#if !allDay} +
+ + +
+

+ {#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} +

+ {:else if mode === 'single'} +

Alle dager i perioden tas med, også sluttdatoen.

+ {/if} {#if mode === 'recurring'}
Hvilke ukedager? @@ -114,7 +152,9 @@ {/each}
-

Gjelder hele dagen på valgte ukedager, til og med sluttdatoen.

+

+ {allDay ? 'Gjelder hele dagen på' : 'Tidsrommet gjentas på'} valgte ukedager, til og med sluttdatoen. +

{#if startDate && endDate && weekdays.length && !selection.success}

{selection.error.issues[0]?.message}

{:else if selection.success && !preview.length} @@ -125,31 +165,13 @@ {preview.length} dager valgt – se datoene
    {#each preview as period (period.startAt.getTime())}
  • - {formatDate(period.startAt)} + {allDay + ? formatDate(period.startAt) + : `${normalDate(period.startAt)} – ${normalDate(period.endAt)}`}
  • {/each}
{/if} - {:else} - - - {#if !allDay} -
- - -
-

- Fra klokkeslettet på startdatoen til klokkeslettet på sluttdatoen. Norsk tid. -

- {:else} -

Alle dager i perioden tas med, også sluttdatoen.

- {/if} {/if} {#each group.periods as absence (absence.id)}
  • - {formatDate(absence.startAt)} + {normalDate(absence.startAt)} – {normalDate(absence.endAt)}