Render overnight blocks across midnight in the day view

This commit is contained in:
NeilClack
2026-09-11 18:24:54 -05:00
parent 177b7bb13a
commit 46d7cf66b8
3 changed files with 66 additions and 24 deletions
+13 -2
View File
@@ -2,7 +2,13 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte'; import ProclamationBrowser from './ProclamationBrowser.svelte';
import { MINUTE_STEP, formatTime, validateBlock, type BlockSibling } from '$lib/schedule-utils'; import {
MINUTE_STEP,
formatTime,
isOvernight,
validateBlock,
type BlockSibling
} from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string }; type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string }; type BrowserGroup = { id: number; name: string };
@@ -77,6 +83,9 @@
const startMinutes = $derived((Number(startHour) * 60 + Number(startMinute)) | 0); const startMinutes = $derived((Number(startHour) * 60 + Number(startMinute)) | 0);
const endMinutes = $derived((Number(endHour) * 60 + Number(endMinute)) | 0); const endMinutes = $derived((Number(endHour) * 60 + Number(endMinute)) | 0);
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId)); const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
@@ -276,7 +285,9 @@
> >
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3> <h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400"> <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)} {formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (next day)'
: ''}
</p> </p>
{#if selectedTitles.length === 0} {#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400"> <p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
@@ -74,6 +74,12 @@ describe('BlockModal', () => {
await expect.element(page.getByText('Peace')).toBeInTheDocument(); await expect.element(page.getByText('Peace')).toBeInTheDocument();
}); });
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => { it('rejects an overlapping time range with the specific error', async () => {
mount({ mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }], existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
+47 -22
View File
@@ -1,5 +1,12 @@
<script lang="ts"> <script lang="ts">
import { MINUTES_PER_DAY, SNAP_MINUTES, formatTime, snapMinutes } from '$lib/schedule-utils'; import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = { type ScheduleBlockView = {
id: string; id: string;
@@ -75,6 +82,18 @@
height: ((end - start) / MINUTES_PER_DAY) * 100 height: ((end - start) / MINUTES_PER_DAY) * 100
}; };
}); });
// Overnight blocks render as two segments: start→midnight and midnight→end.
const blockViews = $derived(
blocks.map((block) => ({
block,
overnight: isOvernight(block),
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
top: (segmentStart / MINUTES_PER_DAY) * 100,
height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100
}))
}))
);
</script> </script>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -113,27 +132,33 @@
></div> ></div>
{/if} {/if}
{#each blocks as block (block.id)} {#each blockViews as view (view.block.id)}
{@const top = (block.startMinutes / MINUTES_PER_DAY) * 100} {#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
{@const height = ((block.endMinutes - block.startMinutes) / MINUTES_PER_DAY) * 100} <button
<button type="button"
type="button" onpointerdown={(event) => event.stopPropagation()}
onpointerdown={(event) => event.stopPropagation()} onclick={(event) => {
onclick={(event) => { event.stopPropagation();
event.stopPropagation(); onEdit?.(view.block);
onEdit?.(block); }}
}} aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
aria-label={`Block ${formatTime(block.startMinutes)} to ${formatTime(block.endMinutes)}`} class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900" style={`top:${segment.top}%;height:${segment.height}%`}
style={`top:${top}%;height:${height}%`} >
> {#if segmentIndex === 0}
<span class="block font-semibold"> <span class="block font-semibold">
{formatTime(block.startMinutes)} – {formatTime(block.endMinutes)} {formatTime(view.block.startMinutes)} – {formatTime(
</span> view.block.endMinutes
<span class="block truncate"> )}{view.overnight ? ' (next day)' : ''}
{block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'} </span>
</span> {:else}
</button> <span class="block font-semibold">↳ continues</span>
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
{/each} {/each}
{#if blocks.length === 0} {#if blocks.length === 0}