Merge branch 'dev'
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 3m23s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 41s

This commit is contained in:
NeilClack
2026-09-11 19:07:21 -05:00
32 changed files with 7951 additions and 8 deletions
@@ -0,0 +1,437 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import {
MINUTE_STEP,
formatTime,
isOvernight,
validateBlock,
type BlockSibling
} from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = false,
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
let startHour = $state('9');
let startMinute = $state('0');
let startPeriod = $state<'AM' | 'PM'>('AM');
let endHour = $state('10');
let endMinute = $state('0');
let endPeriod = $state<'AM' | 'PM'>('AM');
let selectedIds = $state<string[]>([]);
let dynamicAudio = $state(false);
let error = $state('');
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM'];
function minuteOptions(current: string): string[] {
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
if (base.includes(current)) return base;
return [...base, current].sort((a, b) => Number(a) - Number(b));
}
function to12Hour(totalMinutes: number): { hour: number; minute: number; period: 'AM' | 'PM' } {
const hours24 = Math.floor(totalMinutes / 60) % 24;
return {
hour: hours24 % 12 === 0 ? 12 : hours24 % 12,
minute: totalMinutes % 60,
period: hours24 < 12 ? 'AM' : 'PM'
};
}
function to24Hour(hour12: string, minute: string, period: 'AM' | 'PM'): number {
const hours = (Number(hour12) % 12) + (period === 'PM' ? 12 : 0);
return (hours * 60 + Number(minute)) | 0;
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
const start = to12Hour(initialStartMinutes);
startHour = String(start.hour);
startMinute = String(start.minute);
startPeriod = start.period;
const end = to12Hour(initialEndMinutes);
endHour = String(end.hour);
endMinute = String(end.minute);
endPeriod = end.period;
selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = '';
step = 'details';
});
const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod));
const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod));
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Start time</span
>
<div class="flex items-center gap-2">
<select
aria-label="Start hour"
onchange={(e) => (startHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={startHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="Start minute"
onchange={(e) => (startMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(startMinute) as minute (minute)}
<option value={minute} selected={startMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="Start period"
onchange={(e) => (startPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each periodOptions as period (period)}
<option value={period} selected={startPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>End time</span
>
<div class="flex items-center gap-2">
<select
aria-label="End hour"
onchange={(e) => (endHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={endHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="End minute"
onchange={(e) => (endMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(endMinute) as minute (minute)}
<option value={minute} selected={endMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="End period"
onchange={(e) => (endPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each periodOptions as period (period)}
<option value={period} selected={endPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
>
<input
type="checkbox"
checked={dynamicAudio}
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
Enable Dynamic Audio
</label>
<span class="group relative inline-flex">
<button
type="button"
aria-label="What is Dynamic Audio?"
aria-describedby="dynamic-audio-help"
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
>
?
</button>
<span
id="dynamic-audio-help"
role="tooltip"
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
>
Dynamic audio will be enabled for this block, while this block is playing.
This will override any audio selections you have made on your device while
this block is active.
</span>
</span>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<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">
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div class="flex items-center gap-3">
{#if step === 'details' && mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
</div>
<div class="flex items-center gap-3">
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Accept
</button>
{:else}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,193 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
describe('BlockModal', () => {
it('uses separate hour and minute selects for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Start minute$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toBeInTheDocument();
});
it('pre-fills the selects from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('7');
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('15');
});
it('uses a 12-hour clock with an AM/PM select for each time', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('12');
await expect.element(page.getByRole('combobox', { name: /^End period$/ })).toHaveValue('AM');
// No 24-hour option exists any more.
await expect
.element(page.getByRole('combobox', { name: /^Start hour$/ }))
.not.toHaveValue('21');
});
it('recomputes the time when the AM/PM switch changes', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
const period = (await page
.getByRole('combobox', { name: /^End period$/ })
.element()) as HTMLSelectElement;
period.value = 'PM';
period.dispatchEvent(new Event('change', { bubbles: true }));
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).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 () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('shows Cancel with Accept in the browse step, and Accept returns to the details', async () => {
mount();
const browse = page.getByRole('button', { name: 'Browse proclamations' });
await expect.element(browse).toBeInTheDocument();
(browse.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const accept = page.getByRole('button', { name: 'Accept' });
await expect.element(accept).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Block' }))
.not.toBeInTheDocument();
(accept.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
});
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
await expect
.element(page.getByText(/Dynamic audio will be enabled for this block/))
.toBeInTheDocument();
});
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
mount({ initialDynamicAudio: true });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('toggles the dynamic audio flag in the submitted payload', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('true');
});
});
@@ -0,0 +1,173 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
top: (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>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{String(hour).padStart(2, '0')}:00
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blockViews as view (view.block.id)}
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.(view.block);
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.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"
style={`top:${segment.top}%;height:${segment.height}%`}
>
{#if segmentIndex === 0}
<span class="block font-semibold">
{formatTime(view.block.startMinutes)} – {formatTime(
view.block.endMinutes
)}{view.overnight ? ' (next day)' : ''}
</span>
{:else}
<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}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});
+15
View File
@@ -97,3 +97,18 @@ export type ColorPalette = Prisma.ColorPaletteModel
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
+15
View File
@@ -121,3 +121,18 @@ export type ColorPalette = Prisma.ColorPaletteModel
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
File diff suppressed because one or more lines are too long
@@ -399,7 +399,10 @@ export const ModelName = {
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog'
AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -415,7 +418,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog"
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog" | "schedule" | "block" | "blockProclamations"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1603,6 +1606,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
Schedule: {
payload: Prisma.$SchedulePayload<ExtArgs>
fields: Prisma.ScheduleFieldRefs
operations: {
findUnique: {
args: Prisma.ScheduleFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findUniqueOrThrow: {
args: Prisma.ScheduleFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findFirst: {
args: Prisma.ScheduleFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findFirstOrThrow: {
args: Prisma.ScheduleFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findMany: {
args: Prisma.ScheduleFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
create: {
args: Prisma.ScheduleCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
createMany: {
args: Prisma.ScheduleCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ScheduleCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
delete: {
args: Prisma.ScheduleDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
update: {
args: Prisma.ScheduleUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
deleteMany: {
args: Prisma.ScheduleDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ScheduleUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ScheduleUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
upsert: {
args: Prisma.ScheduleUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
aggregate: {
args: Prisma.ScheduleAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSchedule>
}
groupBy: {
args: Prisma.ScheduleGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleGroupByOutputType>[]
}
count: {
args: Prisma.ScheduleCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleCountAggregateOutputType> | number
}
}
}
Block: {
payload: Prisma.$BlockPayload<ExtArgs>
fields: Prisma.BlockFieldRefs
operations: {
findUnique: {
args: Prisma.BlockFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findFirst: {
args: Prisma.BlockFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findMany: {
args: Prisma.BlockFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
create: {
args: Prisma.BlockCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
createMany: {
args: Prisma.BlockCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
delete: {
args: Prisma.BlockDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
update: {
args: Prisma.BlockUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
deleteMany: {
args: Prisma.BlockDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
upsert: {
args: Prisma.BlockUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
aggregate: {
args: Prisma.BlockAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlock>
}
groupBy: {
args: Prisma.BlockGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockGroupByOutputType>[]
}
count: {
args: Prisma.BlockCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockCountAggregateOutputType> | number
}
}
}
BlockProclamations: {
payload: Prisma.$BlockProclamationsPayload<ExtArgs>
fields: Prisma.BlockProclamationsFieldRefs
operations: {
findUnique: {
args: Prisma.BlockProclamationsFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockProclamationsFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findFirst: {
args: Prisma.BlockProclamationsFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockProclamationsFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findMany: {
args: Prisma.BlockProclamationsFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
create: {
args: Prisma.BlockProclamationsCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
createMany: {
args: Prisma.BlockProclamationsCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockProclamationsCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
delete: {
args: Prisma.BlockProclamationsDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
update: {
args: Prisma.BlockProclamationsUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
deleteMany: {
args: Prisma.BlockProclamationsDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockProclamationsUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockProclamationsUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
upsert: {
args: Prisma.BlockProclamationsUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
aggregate: {
args: Prisma.BlockProclamationsAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlockProclamations>
}
groupBy: {
args: Prisma.BlockProclamationsGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsGroupByOutputType>[]
}
count: {
args: Prisma.BlockProclamationsCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1840,6 +2065,40 @@ export const AuditLogScalarFieldEnum = {
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -2096,6 +2355,9 @@ export type GlobalOmitConfig = {
apiKey?: Prisma.ApiKeyOmit
colorPalette?: Prisma.ColorPaletteOmit
auditLog?: Prisma.AuditLogOmit
schedule?: Prisma.ScheduleOmit
block?: Prisma.BlockOmit
blockProclamations?: Prisma.BlockProclamationsOmit
}
/* Types for Logging */
@@ -66,7 +66,10 @@ export const ModelName = {
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog'
AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -283,6 +286,40 @@ export const AuditLogScalarFieldEnum = {
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
+3
View File
@@ -24,4 +24,7 @@ export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'
export type * from './models/ColorPalette.ts'
export type * from './models/AuditLog.ts'
export type * from './models/Schedule.ts'
export type * from './models/Block.ts'
export type * from './models/BlockProclamations.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -236,6 +236,7 @@ export type ProclamationWhereInput = {
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
}
export type ProclamationOrderByWithRelationInput = {
@@ -249,6 +250,7 @@ export type ProclamationOrderByWithRelationInput = {
category?: Prisma.ProclamationCategoryOrderByWithRelationInput
group?: Prisma.ProclamationGroupOrderByWithRelationInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
blockProclamations?: Prisma.BlockProclamationsOrderByRelationAggregateInput
}
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
@@ -265,6 +267,7 @@ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
}, "id" | "name">
export type ProclamationOrderByWithAggregationInput = {
@@ -301,6 +304,7 @@ export type ProclamationCreateInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateInput = {
@@ -311,6 +315,7 @@ export type ProclamationUncheckedCreateInput = {
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationUpdateInput = {
@@ -321,6 +326,7 @@ export type ProclamationUpdateInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateInput = {
@@ -331,6 +337,7 @@ export type ProclamationUncheckedUpdateInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManyInput = {
@@ -551,6 +558,20 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryNestedInput = {
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
}
export type ProclamationCreateNestedOneWithoutBlockProclamationsInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
}
export type ProclamationUpdateOneRequiredWithoutBlockProclamationsNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
upsert?: Prisma.ProclamationUpsertWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput, Prisma.ProclamationUpdateWithoutBlockProclamationsInput>, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationCreateWithoutGroupInput = {
id?: string
name: string
@@ -558,6 +579,7 @@ export type ProclamationCreateWithoutGroupInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutGroupInput = {
@@ -567,6 +589,7 @@ export type ProclamationUncheckedCreateWithoutGroupInput = {
solfeggioFrequencyId: number
categoryId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutGroupInput = {
@@ -614,6 +637,7 @@ export type ProclamationCreateWithoutUserProclamationsInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
@@ -623,6 +647,7 @@ export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
solfeggioFrequencyId: number
categoryId: number
groupId: number
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
@@ -648,6 +673,7 @@ export type ProclamationUpdateWithoutUserProclamationsInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
@@ -657,6 +683,7 @@ export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
@@ -666,6 +693,7 @@ export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
@@ -675,6 +703,7 @@ export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = {
@@ -710,6 +739,7 @@ export type ProclamationCreateWithoutCategoryInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutCategoryInput = {
@@ -719,6 +749,7 @@ export type ProclamationUncheckedCreateWithoutCategoryInput = {
solfeggioFrequencyId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutCategoryInput = {
@@ -747,6 +778,62 @@ export type ProclamationUpdateManyWithWhereWithoutCategoryInput = {
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput>
}
export type ProclamationCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutBlockProclamationsInput = {
where: Prisma.ProclamationWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
}
export type ProclamationUpsertWithoutBlockProclamationsInput = {
update: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
where?: Prisma.ProclamationWhereInput
}
export type ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput = {
where?: Prisma.ProclamationWhereInput
data: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManyGroupInput = {
id?: string
name: string
@@ -762,6 +849,7 @@ export type ProclamationUpdateWithoutGroupInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutGroupInput = {
@@ -771,6 +859,7 @@ export type ProclamationUncheckedUpdateWithoutGroupInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
@@ -796,6 +885,7 @@ export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
@@ -805,6 +895,7 @@ export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
@@ -830,6 +921,7 @@ export type ProclamationUpdateWithoutCategoryInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutCategoryInput = {
@@ -839,6 +931,7 @@ export type ProclamationUncheckedUpdateWithoutCategoryInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
@@ -856,10 +949,12 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
export type ProclamationCountOutputType = {
userProclamations: number
blockProclamations: number
}
export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs
blockProclamations?: boolean | ProclamationCountOutputTypeCountBlockProclamationsArgs
}
/**
@@ -879,6 +974,13 @@ export type ProclamationCountOutputTypeCountUserProclamationsArgs<ExtArgs extend
where?: Prisma.UserProclamationsWhereInput
}
/**
* ProclamationCountOutputType without action
*/
export type ProclamationCountOutputTypeCountBlockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.BlockProclamationsWhereInput
}
export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -891,6 +993,7 @@ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.Internal
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]>
@@ -933,6 +1036,7 @@ export type ProclamationInclude<ExtArgs extends runtime.Types.Extensions.Interna
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}
export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -953,6 +1057,7 @@ export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.Intern
category: Prisma.$ProclamationCategoryPayload<ExtArgs>
group: Prisma.$ProclamationGroupPayload<ExtArgs>
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
blockProclamations: Prisma.$BlockProclamationsPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1359,6 +1464,7 @@ export interface Prisma__ProclamationClient<T, Null = never, ExtArgs extends run
category<T extends Prisma.ProclamationCategoryDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategoryDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
group<T extends Prisma.ProclamationGroupDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationGroupDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationGroupClient<runtime.Types.Result.GetResult<Prisma.$ProclamationGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
userProclamations<T extends Prisma.Proclamation$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
blockProclamations<T extends Prisma.Proclamation$blockProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$blockProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1818,6 +1924,30 @@ export type Proclamation$userProclamationsArgs<ExtArgs extends runtime.Types.Ext
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
}
/**
* Proclamation.blockProclamations
*/
export type Proclamation$blockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the BlockProclamations
*/
select?: Prisma.BlockProclamationsSelect<ExtArgs> | null
/**
* Omit specific fields from the BlockProclamations
*/
omit?: Prisma.BlockProclamationsOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.BlockProclamationsInclude<ExtArgs> | null
where?: Prisma.BlockProclamationsWhereInput
orderBy?: Prisma.BlockProclamationsOrderByWithRelationInput | Prisma.BlockProclamationsOrderByWithRelationInput[]
cursor?: Prisma.BlockProclamationsWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.BlockProclamationsScalarFieldEnum | Prisma.BlockProclamationsScalarFieldEnum[]
}
/**
* Proclamation without action
*/
File diff suppressed because it is too large Load Diff
+148
View File
@@ -246,6 +246,7 @@ export type UserWhereInput = {
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}
export type UserOrderByWithRelationInput = {
@@ -263,6 +264,7 @@ export type UserOrderByWithRelationInput = {
profiles?: Prisma.ProfileOrderByRelationAggregateInput
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
schedule?: Prisma.ScheduleOrderByWithRelationInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -283,6 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -329,6 +332,7 @@ export type UserCreateInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -345,6 +349,7 @@ export type UserUncheckedCreateInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserUpdateInput = {
@@ -361,6 +366,7 @@ export type UserUpdateInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -612,6 +619,20 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
}
export type UserCreateNestedOneWithoutScheduleInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutScheduleNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
upsert?: Prisma.UserUpsertWithoutScheduleInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutScheduleInput, Prisma.UserUpdateWithoutScheduleInput>, Prisma.UserUncheckedUpdateWithoutScheduleInput>
}
export type UserCreateWithoutProfilesInput = {
id: string
name: string
@@ -625,6 +646,7 @@ export type UserCreateWithoutProfilesInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutProfilesInput = {
@@ -640,6 +662,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutProfilesInput = {
@@ -671,6 +694,7 @@ export type UserUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutProfilesInput = {
@@ -686,6 +710,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutSessionsInput = {
@@ -701,6 +726,7 @@ export type UserCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutSessionsInput = {
@@ -716,6 +742,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutSessionsInput = {
@@ -747,6 +774,7 @@ export type UserUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -762,6 +790,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutAccountsInput = {
@@ -777,6 +806,7 @@ export type UserCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutAccountsInput = {
@@ -792,6 +822,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutAccountsInput = {
@@ -823,6 +854,7 @@ export type UserUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutAccountsInput = {
@@ -838,6 +870,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutRoleInput = {
@@ -853,6 +886,7 @@ export type UserCreateWithoutRoleInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutRoleInput = {
@@ -868,6 +902,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutRoleInput = {
@@ -923,6 +958,7 @@ export type UserCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutUserProclamationsInput = {
@@ -938,6 +974,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutUserProclamationsInput = {
@@ -969,6 +1006,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
@@ -984,6 +1022,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutLicenseKeysInput = {
@@ -999,6 +1038,7 @@ export type UserCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutLicenseKeysInput = {
@@ -1014,6 +1054,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutLicenseKeysInput = {
@@ -1045,6 +1086,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
@@ -1060,6 +1102,87 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutScheduleInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutScheduleInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutScheduleInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
}
export type UserUpsertWithoutScheduleInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutScheduleInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
}
export type UserUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyRoleInput = {
@@ -1085,6 +1208,7 @@ export type UserUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutRoleInput = {
@@ -1100,6 +1224,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateManyWithoutRoleInput = {
@@ -1194,6 +1319,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -1240,6 +1366,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1258,6 +1385,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles: Prisma.$ProfilePayload<ExtArgs>[]
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
schedule: Prisma.$SchedulePayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1668,6 +1796,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
schedule<T extends Prisma.User$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$scheduleArgs<ExtArgs>>): Prisma.Prisma__ScheduleClient<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -2244,6 +2373,25 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
}
/**
* User.schedule
*/
export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Schedule
*/
select?: Prisma.ScheduleSelect<ExtArgs> | null
/**
* Omit specific fields from the Schedule
*/
omit?: Prisma.ScheduleOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ScheduleInclude<ExtArgs> | null
where?: Prisma.ScheduleWhereInput
}
/**
* User without action
*/
+235
View File
@@ -0,0 +1,235 @@
import { describe, it, expect } from 'vitest';
import {
blockDuration,
blockSegments,
findOverlap,
formatTime,
isOvernight,
snapMinutes,
toHm,
validateBlock,
type BlockInput,
type BlockSibling
} from './schedule-utils';
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1'],
...overrides
});
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
id,
startMinutes,
endMinutes
});
describe('formatTime', () => {
it('formats 12-hour clock labels', () => {
expect(formatTime(0)).toBe('12:00 AM');
expect(formatTime(6 * 60)).toBe('6:00 AM');
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
expect(formatTime(12 * 60)).toBe('12:00 PM');
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
});
});
describe('toHm', () => {
it('formats 24-hour HH:MM strings', () => {
expect(toHm(0)).toBe('00:00');
expect(toHm(6 * 60)).toBe('06:00');
expect(toHm(23 * 60 + 59)).toBe('23:59');
});
});
describe('snapMinutes', () => {
it('rounds to the nearest 15 minutes and clamps to the day', () => {
expect(snapMinutes(0)).toBe(0);
expect(snapMinutes(7)).toBe(0);
expect(snapMinutes(8)).toBe(15);
expect(snapMinutes(370)).toBe(375);
expect(snapMinutes(-10)).toBe(0);
expect(snapMinutes(2000)).toBe(1440);
});
});
describe('isOvernight', () => {
it('is true when the end clock time is before the start', () => {
expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true);
expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true);
});
it('is false for same-day ranges and a full-day range', () => {
expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false);
expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false);
});
});
describe('blockDuration', () => {
it('counts a same-day range directly', () => {
expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120);
});
it('counts an overnight range as wrapping to the next day', () => {
expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720);
});
it('treats an end of midnight as wrapping from the start', () => {
expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120);
});
});
describe('blockSegments', () => {
it('returns a single segment for a same-day range', () => {
expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]);
});
it('splits an overnight range at midnight', () => {
expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([
[21 * 60, 1440],
[0, 9 * 60]
]);
});
it('returns no segments for a zero-length range', () => {
expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]);
});
});
describe('findOverlap', () => {
it('returns null for adjacent ranges', () => {
expect(
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
).toBeNull();
});
it('returns the sibling for a partial overlap', () => {
const other = sibling(7 * 60, 9 * 60);
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping an early-morning block', () => {
const other = sibling(8 * 60, 10 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping a late-evening block', () => {
const other = sibling(20 * 60, 22 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('allows an overnight block and its exact complement (adjacent at both ends)', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)])
).toBeNull();
});
it('allows an overnight block and a midday block it does not touch', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)])
).toBeNull();
});
});
describe('validateBlock', () => {
it('accepts a valid block with no siblings', () => {
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
});
it('rejects equal start and end times', () => {
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
expect(result).toEqual({
ok: false,
error: "Start and end time can't be the same."
});
});
it('rejects a time outside the 24-hour range', () => {
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('rejects a negative start time', () => {
expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('accepts an overnight block', () => {
expect(
validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], {
isCreate: true
})
).toEqual({ ok: true });
});
it('rejects an overnight block that overlaps another and names it', () => {
const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 8:00 AM–10:00 AM block.'
});
});
it('requires at least one proclamation', () => {
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
ok: false,
error: 'Select at least one proclamation.'
});
});
it('enforces the four-block maximum on create', () => {
const siblings = [
sibling(0, 60, 'a'),
sibling(60, 120, 'b'),
sibling(120, 180, 'c'),
sibling(180, 240, 'd')
];
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
isCreate: true
});
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
});
it('allows adjacent blocks (end exactly equals next start)', () => {
expect(
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
).toEqual({
ok: true
});
});
it('rejects an exact duplicate range and names the conflicting block', () => {
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
});
});
it('rejects a partial overlap', () => {
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result.ok).toBe(false);
});
it('rejects a block that contains another block', () => {
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
sibling(6 * 60, 8 * 60)
]);
expect(result.ok).toBe(false);
});
it('does not apply the maximum when updating (isCreate false)', () => {
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
});
});
+161
View File
@@ -0,0 +1,161 @@
// Isomorphic, dependency-free schedule helpers. Used by the server service
// layer, the v2 serializers and the client-side Block modal (for live
// pre-validation), so it must not import Prisma or any server-only code.
export const MAX_BLOCKS = 4;
export const MINUTES_PER_DAY = 24 * 60;
export const SNAP_MINUTES = 15;
export const MINUTE_STEP = 5;
export type BlockSibling = {
id?: string;
startMinutes: number;
endMinutes: number;
};
export type BlockInput = {
startMinutes: number;
endMinutes: number;
proclamationIds: string[];
/** When true the kiosk enables dynamic audio for the block's duration. */
dynamicAudio?: boolean;
};
export type ValidationResult = { ok: true } | { ok: false; error: string };
function normalizeMinutes(minutes: number): number {
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
}
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
export function formatTime(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours24 = Math.floor(normalized / 60);
const mins = normalized % 60;
const period = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
}
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
export function toHm(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours = Math.floor(normalized / 60);
const mins = normalized % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
export function snapMinutes(minutes: number): number {
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
}
/** A Block whose end clock time is at or before its start wraps past midnight. */
export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean {
return block.endMinutes <= block.startMinutes;
}
/** Block length in minutes, counting a wrap past midnight as the next day. */
export function blockDuration(block: { startMinutes: number; endMinutes: number }): number {
if (block.endMinutes > block.startMinutes) {
return block.endMinutes - block.startMinutes;
}
return block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
}
/**
* Split a Block into the linear half-open `[start, end)` segments it occupies
* within the 24h day. An overnight block produces two segments (start→midnight
* and midnight→end); a zero-length block produces none.
*/
export function blockSegments(block: {
startMinutes: number;
endMinutes: number;
}): [number, number][] {
if (block.startMinutes < block.endMinutes) {
return [[block.startMinutes, block.endMinutes]];
}
if (block.startMinutes > block.endMinutes) {
return [
[block.startMinutes, MINUTES_PER_DAY],
[0, block.endMinutes]
];
}
return [];
}
function segmentsIntersect(a: [number, number], b: [number, number]): boolean {
return a[0] < b[1] && b[0] < a[1];
}
/**
* First sibling whose occupied time intersects the input. Works for overnight
* (midnight-crossing) blocks by comparing their linear segments; ranges that
* only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap.
*/
export function findOverlap(
input: { startMinutes: number; endMinutes: number },
siblings: BlockSibling[]
): BlockSibling | null {
const inputSegments = blockSegments(input);
for (const sibling of siblings) {
const siblingSegments = blockSegments(sibling);
for (const segment of inputSegments) {
for (const other of siblingSegments) {
if (segmentsIntersect(segment, other)) return sibling;
}
}
}
return null;
}
/**
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
* the Block being edited. `isCreate` enables the 4-block maximum (an update
* never increases the count).
*/
export function validateBlock(
input: BlockInput,
siblings: BlockSibling[],
opts: { isCreate?: boolean } = {}
): ValidationResult {
const { startMinutes, endMinutes, proclamationIds } = input;
if (
!Number.isInteger(startMinutes) ||
!Number.isInteger(endMinutes) ||
startMinutes < 0 ||
startMinutes > MINUTES_PER_DAY ||
endMinutes < 0 ||
endMinutes > MINUTES_PER_DAY
) {
return { ok: false, error: 'Enter a valid start and end time.' };
}
// Equal clock times would be a zero- or full-length block; both are
// ambiguous, so reject them. Overnight ranges (end before start) are valid.
if (startMinutes === endMinutes) {
return { ok: false, error: "Start and end time can't be the same." };
}
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
return { ok: false, error: 'Select at least one proclamation.' };
}
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
}
const overlap = findOverlap(input, siblings);
if (overlap) {
return {
ok: false,
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
overlap.endMinutes
)} block.`
};
}
return { ok: true };
}
+200
View File
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
const validInput = {
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1']
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateSchedule', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
const result = await getOrCreateSchedule('u1');
expect(result).toEqual({ id: 's1', userId: 'u1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates the singleton schedule when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
await getOrCreateSchedule('u1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
});
});
describe('listBlocks', () => {
it('scopes to the user and includes proclamations with frequencies', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocks('u1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { userId: 'u1' } },
orderBy: { startMinutes: 'asc' },
include: expect.objectContaining({ proclamations: expect.anything() })
})
);
});
});
describe('createBlock', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
});
it('creates a block with deduped proclamations in order', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
const result = await createBlock('u1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
expect(result).toEqual({ ok: true, id: 'new-block' });
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
{ proclamationId: 'p2', sortOrder: 1 }
]
}
}
});
});
it('persists the dynamic audio flag', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
await createBlock('u1', { ...validInput, dynamicAudio: true });
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: true })
})
);
});
it('rejects a fifth block without creating', async () => {
blockFindMany.mockResolvedValue([
{ id: 'a', startMinutes: 0, endMinutes: 60 },
{ id: 'b', startMinutes: 60, endMinutes: 120 },
{ id: 'c', startMinutes: 120, endMinutes: 180 },
{ id: 'd', startMinutes: 180, endMinutes: 240 }
]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an overlapping block with a specific message', async () => {
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
});
expect(blockCreate).not.toHaveBeenCalled();
});
});
describe('updateBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('u1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
});
it('replaces proclamations and excludes itself from overlap checks', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
blockFindMany.mockResolvedValue([]);
blockUpdate.mockResolvedValue({ id: 'b1' });
const result = await updateBlock('u1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockFindMany).toHaveBeenCalledWith({
where: { scheduleId: 's1', id: { not: 'b1' } },
select: { id: true, startMinutes: true, endMinutes: true }
});
expect(blockUpdate).toHaveBeenCalledWith({
where: { id: 'b1' },
data: {
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
}
}
});
});
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('u1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
});
it('deletes an owned block', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('u1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
});
});
+131
View File
@@ -0,0 +1,131 @@
// Schedule service layer. Owns the database orchestration and enforcement of
// the Block business rules (at most 4 per user, at least one Proclamation,
// no overnight ranges, no overlaps). Pure validation lives in
// `$lib/schedule-utils` so the client modal can reuse it.
import { prisma } from '$lib/db';
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
export {
MAX_BLOCKS,
MINUTES_PER_DAY,
SNAP_MINUTES,
MINUTE_STEP,
formatTime,
toHm,
snapMinutes,
findOverlap,
validateBlock
} from '$lib/schedule-utils';
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
}
/** Ensure the user has a Schedule row, creating the singleton on first use. */
export async function getOrCreateSchedule(userId: string) {
const existing = await prisma.schedule.findUnique({ where: { userId } });
if (existing) return existing;
return prisma.schedule.create({ data: { userId } });
}
/**
* The user's Blocks with their selected Proclamations (and frequencies for
* canonical-id mapping), ordered by start time.
*/
export async function listBlocks(userId: string) {
return prisma.block.findMany({
where: { schedule: { userId } },
orderBy: { startMinutes: 'asc' },
include: {
proclamations: {
orderBy: { sortOrder: 'asc' },
include: {
proclamation: { include: { solfeggioFrequency: true } }
}
}
}
});
}
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
const schedule = await getOrCreateSchedule(userId);
const siblings = await prisma.block.findMany({
where: { scheduleId: schedule.id },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings, { isCreate: true });
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
const block = await prisma.block.create({
data: {
scheduleId: schedule.id,
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: {
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: block.id };
}
export async function updateBlock(
userId: string,
blockId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true, scheduleId: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
const siblings = await prisma.block.findMany({
where: { scheduleId: block.scheduleId, id: { not: blockId } },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings);
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
await prisma.block.update({
where: { id: blockId },
data: {
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: {
deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: blockId };
}
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
await prisma.block.delete({ where: { id: blockId } });
return { ok: true, id: blockId };
}
+45
View File
@@ -1,3 +1,5 @@
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
// canonical ids (1-9), never the portal DB autoincrement ids, because
@@ -138,3 +140,46 @@ export function serializeColorPalette(palette: {
}): V2ColorPalette {
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
}
export type V2ScheduleBlock = {
id: string;
startTime: string;
startMinutes: number;
endTime: string;
endMinutes: number;
overnight: boolean;
durationMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
};
export type V2Schedule = {
email: string;
blocks: V2ScheduleBlock[];
};
/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are
* returned; the kiosk fetches proclamation content separately. `overnight`
* marks a range that crosses midnight (end clock time before start), and
* `durationMinutes` counts the wrap as the next day. */
export function serializeScheduleBlock(
block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio?: boolean;
},
proclamationIds: string[]
): V2ScheduleBlock {
return {
id: block.id,
startTime: toHm(block.startMinutes),
startMinutes: block.startMinutes,
endTime: toHm(block.endMinutes),
endMinutes: block.endMinutes,
overnight: isOvernight(block),
durationMinutes: blockDuration(block),
dynamicAudio: block.dynamicAudio === true,
proclamationIds
};
}
@@ -0,0 +1,91 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
function parseInput(formData: FormData) {
const startMinutes = Number(formData.get('startMinutes'));
const endMinutes = Number(formData.get('endMinutes'));
let proclamationIds: string[] = [];
const raw = formData.get('proclamationIds');
if (typeof raw === 'string' && raw.length > 0) {
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
proclamationIds = parsed.filter((id): id is string => typeof id === 'string');
}
} catch {
proclamationIds = [];
}
}
const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
}
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const [blocks, categories, groups, proclamations] = await Promise.all([
listBlocks(locals.user.id),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
// Content is deliberately never selected: the portal only surfaces
// title/category/group.
prisma.proclamation.findMany({
select: { id: true, name: true, categoryId: true, groupId: true },
orderBy: { name: 'asc' }
})
]);
const blockViews = blocks.map((block) => ({
id: block.id,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
return { blocks: blockViews, categories, groups, proclamations };
};
export const actions: Actions = {
createBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const input = parseInput(await request.formData());
const result = await createBlock(locals.user.id, input);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
updateBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await updateBlock(locals.user.id, id, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
deleteBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const id = (await request.formData()).get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await deleteBlock(locals.user.id, id);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import type { PageProps } from './$types';
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props();
let modalOpen = $state(false);
let modalMode = $state<'create' | 'edit'>('create');
let editingId = $state<string | undefined>(undefined);
let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(false);
let successMessage = $state('');
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function openCreate(startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
initialStart = startMinutes;
initialEnd = endMinutes;
initialIds = [];
initialDynamicAudio = false;
modalOpen = true;
}
function openEdit(block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string }[];
}) {
modalMode = 'edit';
editingId = block.id;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
function handleSaved() {
modalOpen = false;
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
}
</script>
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build your daily listening schedule. Up to 4 blocks per day.
</p>
</div>
<span
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{data.blocks.length} of 4 blocks
</span>
</div>
{#if successMessage}
<div
class="mt-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
<div class="mt-6">
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
</div>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={data.blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
</div>
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isActionFailure } from '@sveltejs/kit';
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
listBlocks: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn()
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
const locals = createLocals(mockUser() as never);
beforeEach(() => {
vi.clearAllMocks();
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 2, name: 'Physical' }
]);
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
listBlocksMock.mockResolvedValue([]);
});
describe('schedule load', () => {
it('rejects unauthenticated requests', async () => {
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
status: 403
});
});
it('never selects proclamation content', async () => {
await load({ locals } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
select: { id: true, name: true, categoryId: true, groupId: true }
})
);
});
it('maps blocks to title/category/group only', async () => {
listBlocksMock.mockResolvedValue([
{
id: 'b1',
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
}
}
]
}
]);
const result = (await load({ locals } as never)) as {
blocks: {
id: string;
dynamicAudio: boolean;
proclamations: Record<string, unknown>[];
}[];
};
expect(result.blocks[0].dynamicAudio).toBe(true);
expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1
});
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
});
});
describe('schedule actions', () => {
it('createBlock rejects unauthenticated requests', async () => {
await expect(
actions.createBlock!({
request: createFormRequest({}),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('createBlock surfaces the service error', async () => {
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.status).toBe(400);
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
}
});
it('createBlock parses the payload and succeeds', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']),
dynamicAudio: 'true'
}),
locals
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2'],
dynamicAudio: true
});
});
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1'])
}),
locals
} as never);
expect(createBlockMock).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ dynamicAudio: false })
);
});
it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({
request: createFormRequest({}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Block not found.' });
}
expect(deleteBlockMock).not.toHaveBeenCalled();
});
});
+13
View File
@@ -129,6 +129,12 @@
>
Dashboard
</a>
<a
href={resolve('/schedule')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a
href={resolve('/profile')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`}
@@ -383,6 +389,13 @@
>
Dashboard
</a>
<a
href={resolve('/schedule')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a
href={resolve('/profile')}
onclick={closeMenu}
+63
View File
@@ -0,0 +1,63 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { listBlocks } from '$lib/server/schedule';
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
* `email` + `licenseKey` query parameters and validates only that the pair
* belong together — license active/entitlement is deliberately not checked
* here, since an invalid license would already lock the kiosk out.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `schedule:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const email = (url.searchParams.get('email') ?? '').trim().toLowerCase();
const licenseKey = (
url.searchParams.get('licenseKey') ??
url.searchParams.get('key') ??
''
).trim();
if (!email || !licenseKey || email.length > 254 || !EMAIL_RE.test(email)) {
throw error(400, 'not-authorized');
}
const storedKey = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
if (!storedKey) {
throw error(400, 'not-authorized');
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
throw error(400, 'not-authorized');
}
// Licenses created before accounts were linked may carry only an email.
const belongs =
storedKey.userId === user.id ||
(storedKey.userId === null && storedKey.email?.toLowerCase() === email);
if (!belongs) {
throw error(400, "this key doesn't appear to belong to you");
}
const blocks = await listBlocks(user.id);
const serialized = blocks.map((block) =>
serializeScheduleBlock(
block,
block.proclamations.map((entry) => entry.proclamation.id)
)
);
return jsonOk({ email, blocks: serialized });
};
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: { findMany: vi.fn() },
licenseKey: { findUnique: vi.fn() },
user: { findUnique: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { GET } from './+server';
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
function call(params: Record<string, string>) {
const url = new URL('http://localhost/api/v2/schedule');
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const request = new Request(url);
return GET({ request, url } as Parameters<typeof GET>[0]);
}
const KEY = 'AAAA-BBBB-CCCC-DDDD';
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
blockFindMany.mockResolvedValue([]);
});
describe('GET /api/v2/schedule auth', () => {
it('rejects a request missing parameters', async () => {
await expect(call({})).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects an unknown license key', async () => {
licenseKeyFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects an unknown user', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects a key that does not belong to the email account', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: "this key doesn't appear to belong to you" }
});
});
it('rate limits after ten requests from the same IP', async () => {
licenseKeyFindUnique.mockResolvedValue(null);
for (let i = 0; i < 10; i++) {
try {
await call({});
} catch {
// Expected: each early call rejects with a 400.
}
}
const response = await call({});
expect(response.status).toBe(429);
});
});
describe('GET /api/v2/schedule success', () => {
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
licenseKeyFindUnique.mockResolvedValue({
key: KEY,
userId: 'user-1',
email: null,
active: false
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
{
id: 'block-1',
startMinutes: 21 * 60,
endMinutes: 9 * 60,
dynamicAudio: true,
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
email: 'jane@example.com',
blocks: [
{
id: 'block-1',
startTime: '21:00',
startMinutes: 1260,
endTime: '09:00',
endMinutes: 540,
overnight: true,
durationMinutes: 720,
dynamicAudio: true,
proclamationIds: ['p1', 'p2']
}
]
});
});
it('returns IDs for every selected proclamation regardless of frequency', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
{
id: 'block-1',
startMinutes: 0,
endMinutes: 60,
proclamations: [{ proclamation: { id: 'p1' } }]
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
const body = (await response.json()) as {
blocks: { proclamationIds: string[]; overnight: boolean; durationMinutes: number }[];
};
expect(body.blocks[0]).toEqual({
id: 'block-1',
startTime: '00:00',
startMinutes: 0,
endTime: '01:00',
endMinutes: 60,
overnight: false,
durationMinutes: 60,
dynamicAudio: false,
proclamationIds: ['p1']
});
});
it('accepts the legacy `key` query alias and an email-only license', async () => {
licenseKeyFindUnique.mockResolvedValue({
key: KEY,
userId: null,
email: 'jane@example.com'
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
const response = await call({ email: 'jane@example.com', key: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
});
});