diff --git a/eval/fixtures/sveltekit-pms/.env.example b/eval/fixtures/sveltekit-pms/.env.example new file mode 100644 index 0000000..6040d0d --- /dev/null +++ b/eval/fixtures/sveltekit-pms/.env.example @@ -0,0 +1,2 @@ +# Copy to .env and fill in. The .env file is gitignored — never commit a real key. +SEAM_API_KEY= diff --git a/eval/fixtures/sveltekit-pms/.gitignore b/eval/fixtures/sveltekit-pms/.gitignore new file mode 100644 index 0000000..5029de1 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/.gitignore @@ -0,0 +1,23 @@ +# dependencies +node_modules + +# sveltekit +.svelte-kit +/build + +# production output +/dist + +# local sqlite dev database (auto-created on first run) +*.db +*.db-shm +*.db-wal + +# env files — never commit real secrets; keep the example +.env +.env.* +!.env.example + +# misc +.DS_Store +*.tsbuildinfo diff --git a/eval/fixtures/sveltekit-pms/README.md b/eval/fixtures/sveltekit-pms/README.md new file mode 100644 index 0000000..de3f71d --- /dev/null +++ b/eval/fixtures/sveltekit-pms/README.md @@ -0,0 +1,41 @@ +# SvelteKit PMS + +A tiny property-management app built with [SvelteKit](https://svelte.dev/docs/kit) +(Svelte 5 runes), `better-sqlite3`, and Zod. It manages **spaces** (bookable +rooms, suites, cabins…), takes **reservations** against them, and lists the +**guests** who have booked. It is the SvelteKit counterpart of the `nextjs-pms` +sample, with the same domain so the two exercise the same integration. + +## Getting started + +```bash +npm install +cp .env.example .env # then fill in SEAM_API_KEY +npm run dev +``` + +Open [http://localhost:5173](http://localhost:5173). The SQLite database +(`dev.db`) is created and seeded automatically on first run. + +## Layout + +- `src/routes/+page.*` — the public booking form (home). +- `src/routes/reservations/+page.*` — the front desk: every reservation with its + guest, status, dates, and assigned space. +- `src/routes/spaces/+page.*` — the inventory: add spaces, edit, archive/restore. +- `src/routes/guests/+page.*` — the guest directory (deduped by email). +- `src/routes/+layout.svelte` — the shared header and nav. +- `src/lib/server/db.ts` — the `better-sqlite3` handle; the schema is created and + seeded on import. +- `src/lib/server/availability.ts` — the overlap / capacity checks shared by + booking and the front desk. +- `src/lib/server/queries.ts` — the read queries behind each page's `load`. +- `src/lib/server/actions.ts` — the write path called from each `+page.server.ts` + form action. +- `src/lib/schemas.ts` — the Zod schemas that validate every form post. +- `src/lib/space-kinds.ts`, `src/lib/format.ts` — shared, client-safe helpers. + +Everything under `src/lib/server/` is server-only: SvelteKit keeps it (and the +SQLite driver) out of the client bundle. Pages load their data in `+page.server.ts` +`load` functions and mutate through form `actions`, progressively enhanced with +`use:enhance`. diff --git a/eval/fixtures/sveltekit-pms/fixture.json b/eval/fixtures/sveltekit-pms/fixture.json new file mode 100644 index 0000000..39a2daf --- /dev/null +++ b/eval/fixtures/sveltekit-pms/fixture.json @@ -0,0 +1,5 @@ +{ + "name": "sveltekit-pms", + "sdk": "javascript", + "framework": "SvelteKit" +} diff --git a/eval/fixtures/sveltekit-pms/package.json b/eval/fixtures/sveltekit-pms/package.json new file mode 100644 index 0000000..49ddc9d --- /dev/null +++ b/eval/fixtures/sveltekit-pms/package.json @@ -0,0 +1,27 @@ +{ + "name": "sveltekit-pms", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@sveltejs/adapter-auto": "^3.3.1", + "@sveltejs/kit": "^2.16.0", + "better-sqlite3": "^12.6.2", + "seam": "^1.231.0", + "svelte": "^5.19.0", + "vite": "^5.4.11", + "zod": "^4.3.6" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.4", + "@types/better-sqlite3": "^7.6.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3" + } +} diff --git a/eval/fixtures/sveltekit-pms/src/app.css b/eval/fixtures/sveltekit-pms/src/app.css new file mode 100644 index 0000000..e2f6ea2 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/app.css @@ -0,0 +1,443 @@ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap'); + +:root { + --sea-ink: #173a40; + --sea-ink-soft: #416166; + --lagoon: #4fb8b2; + --lagoon-deep: #328f97; + --palm: #2f6a4a; + --sand: #e7f0e8; + --foam: #f3faf5; + --surface: rgba(255, 255, 255, 0.74); + --surface-strong: rgba(255, 255, 255, 0.9); + --line: rgba(23, 58, 64, 0.14); + --inset-glint: rgba(255, 255, 255, 0.82); + --kicker: rgba(47, 106, 74, 0.9); + --bg-base: #e7f3ec; + --header-bg: rgba(251, 255, 248, 0.84); + --chip-bg: rgba(255, 255, 255, 0.8); + --chip-line: rgba(47, 106, 74, 0.18); + --hero-a: rgba(79, 184, 178, 0.36); + --hero-b: rgba(47, 106, 74, 0.2); + --destructive: #c2413a; + --font-sans: 'Manrope', ui-sans-serif, system-ui, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + color: var(--sea-ink); + font-family: var(--font-sans); + background-color: var(--bg-base); + background: + radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%), + radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%), + radial-gradient( + 720px 380px at 50% 115%, + rgba(79, 184, 178, 0.1), + transparent 68% + ), + linear-gradient(180deg, #eef6ef 0%, var(--foam) 44%, var(--bg-base) 100%); + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: var(--lagoon-deep); + text-decoration-color: rgba(50, 143, 151, 0.4); + text-underline-offset: 2px; +} + +a:hover { + color: #246f76; +} + +h1, +h2 { + margin: 0; +} + +/* Layout ------------------------------------------------------------------ */ + +.page-wrap { + width: min(1080px, calc(100% - 2rem)); + margin-inline: auto; +} + +.app-shell { + display: flex; + min-height: 100vh; + flex-direction: column; +} + +.site-header { + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid var(--line); + background: var(--header-bg); + backdrop-filter: blur(6px); +} + +.site-header .page-wrap { + display: flex; + align-items: center; + justify-content: space-between; + padding-block: 1rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; +} + +.brand-mark { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.75rem; + background: var(--lagoon-deep); + color: white; + font-size: 1.1rem; + font-weight: 700; +} + +.brand-name { + font-family: 'Fraunces', Georgia, serif; + font-size: 1.25rem; + font-weight: 700; + color: var(--sea-ink); +} + +main { + flex: 1; +} + +.site-footer { + margin-top: 4rem; + border-top: 1px solid var(--line); + background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%); +} + +.site-footer .page-wrap { + padding-block: 1.5rem; + font-size: 0.875rem; + color: var(--sea-ink-soft); +} + +/* Type helpers ------------------------------------------------------------ */ + +.display-title { + font-family: 'Fraunces', Georgia, serif; +} + +.island-kicker { + letter-spacing: 0.16em; + text-transform: uppercase; + font-weight: 700; + font-size: 0.69rem; + color: var(--kicker); +} + +.muted { + color: var(--sea-ink-soft); +} + +.error-text { + font-size: 0.875rem; + font-weight: 500; + color: var(--destructive); +} + +/* Surfaces ---------------------------------------------------------------- */ + +.island-shell { + border: 1px solid var(--line); + border-radius: 1rem; + background: linear-gradient(165deg, var(--surface-strong), var(--surface)); + box-shadow: + 0 1px 0 var(--inset-glint) inset, + 0 22px 44px rgba(30, 90, 72, 0.1), + 0 6px 18px rgba(23, 58, 64, 0.08); + backdrop-filter: blur(4px); +} + +.feature-card { + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1.25rem; + background: linear-gradient(165deg, var(--surface-strong), var(--surface)); + box-shadow: + 0 1px 0 var(--inset-glint) inset, + 0 18px 34px rgba(30, 90, 72, 0.1), + 0 4px 14px rgba(23, 58, 64, 0.06); + transition: + transform 180ms ease, + border-color 180ms ease; +} + +.feature-card:hover { + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line)); +} + +/* Navigation -------------------------------------------------------------- */ + +.nav { + display: flex; + align-items: center; + gap: 1.5rem; + font-size: 0.875rem; + font-weight: 600; +} + +.nav-link { + position: relative; + text-decoration: none; + color: var(--sea-ink-soft); +} + +.nav-link::after { + content: ''; + position: absolute; + left: 0; + bottom: -8px; + width: 100%; + height: 2px; + transform: scaleX(0); + transform-origin: left; + background: linear-gradient(90deg, var(--lagoon), #7ed3bf); + transition: transform 170ms ease; +} + +.nav-link:hover, +.nav-link.is-active { + color: var(--sea-ink); +} + +.nav-link:hover::after, +.nav-link.is-active::after { + transform: scaleX(1); +} + +/* Forms ------------------------------------------------------------------- */ + +.field { + display: block; +} + +.field-label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--sea-ink); +} + +.input { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.8); + padding: 0.625rem 0.875rem; + color: var(--sea-ink); + font: inherit; + outline: none; +} + +.input:focus { + border-color: var(--lagoon-deep); + box-shadow: 0 0 0 2px rgba(79, 184, 178, 0.3); +} + +textarea.input { + min-height: 5rem; + resize: vertical; +} + +.grid { + display: grid; + gap: 1.25rem; +} + +@media (min-width: 640px) { + .grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.stack > * + * { + margin-top: 1.25rem; +} + +/* Buttons ----------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid transparent; + border-radius: 0.75rem; + padding: 0.5rem 1.25rem; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: + background-color 160ms ease, + opacity 160ms ease, + border-color 160ms ease; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--lagoon-deep); + color: white; +} + +.btn-primary:hover { + opacity: 0.9; + color: white; +} + +.btn-ghost { + border-color: var(--line); + background: rgba(255, 255, 255, 0.7); + color: var(--sea-ink); +} + +.btn-ghost:hover { + background: white; + color: var(--sea-ink); +} + +.btn-block { + width: 100%; + padding-block: 0.75rem; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + border-radius: 0.5rem; +} + +.btn-confirm { + border-color: #a7f3d0; + background: #ecfdf5; + color: #065f46; +} + +.btn-danger { + color: var(--destructive); + background: transparent; +} + +.btn-danger:hover { + background: rgba(194, 65, 58, 0.08); +} + +/* Badges ------------------------------------------------------------------ */ + +.badge { + display: inline-block; + border: 1px solid var(--chip-line); + border-radius: 999px; + padding: 0.1rem 0.6rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge-kind { + background: var(--chip-bg); + color: var(--kicker); +} + +.badge-pending { + background: #fef3c7; + border-color: #fde68a; + color: #92400e; +} + +.badge-confirmed { + background: #d1fae5; + border-color: #a7f3d0; + color: #065f46; +} + +.badge-cancelled { + background: #ffe4e6; + border-color: #fecdd3; + color: #9f1239; +} + +.badge-archived { + border-color: var(--line); + color: var(--sea-ink-soft); +} + +/* Tables ------------------------------------------------------------------ */ + +.data-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.875rem; +} + +.data-table th { + padding: 0.75rem 1.25rem; + font-weight: 600; + color: var(--sea-ink-soft); + border-bottom: 1px solid var(--line); +} + +.data-table td { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--line); +} + +.data-table tr:last-child td { + border-bottom: 0; +} + +/* Motion ------------------------------------------------------------------ */ + +.rise-in { + animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/eval/fixtures/sveltekit-pms/src/app.html b/eval/fixtures/sveltekit-pms/src/app.html new file mode 100644 index 0000000..84ffad1 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/eval/fixtures/sveltekit-pms/src/lib/SpaceForm.svelte b/eval/fixtures/sveltekit-pms/src/lib/SpaceForm.svelte new file mode 100644 index 0000000..902c477 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/SpaceForm.svelte @@ -0,0 +1,163 @@ + + +
{ + saving = true + return async ({ update, result }) => { + await update() + saving = false + if (result.type === 'success') { + if (space === null) reset() + oncancel?.() + } + } + }} +> + {#if space} + + {/if} + +

{title}

+ +
+ + +
+ +
+ + +
+ + + + {#if formError} +

{formError}

+ {/if} + +
+ + {#if oncancel} + + {/if} +
+
+ + diff --git a/eval/fixtures/sveltekit-pms/src/lib/format.ts b/eval/fixtures/sveltekit-pms/src/lib/format.ts new file mode 100644 index 0000000..c384ad8 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/format.ts @@ -0,0 +1,50 @@ +/** + * Nightly rate for display. Deterministic (no `Intl`/locale) so the + * server-rendered markup matches the client on hydration. + */ +export function formatRate(rateCents: number | null): string | null { + if (rateCents === null) return null + const whole = Math.floor(rateCents / 100) + const cents = rateCents % 100 + const grouped = String(whole).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return cents === 0 + ? `$${grouped}` + : `$${grouped}.${String(cents).padStart(2, '0')}` +} + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + +/** + * Format an ISO `YYYY-MM-DD` date without `new Date`/locale so the + * server-rendered markup matches the client on hydration. + */ +export function formatDate(iso: string): string { + const [year, month, day] = iso.split('-').map(Number) + if (!year || !month || !day) return iso + return `${MONTHS[month - 1]} ${day}, ${year}` +} + +/** Whole nights between two ISO dates (half-open interval). */ +export function nights(checkIn: string, checkOut: string): number { + const toUtc = (iso: string) => { + const [year, month, day] = iso.split('-').map(Number) + return Date.UTC(year, month - 1, day) + } + return Math.max( + 0, + Math.round((toUtc(checkOut) - toUtc(checkIn)) / 86_400_000), + ) +} diff --git a/eval/fixtures/sveltekit-pms/src/lib/schemas.ts b/eval/fixtures/sveltekit-pms/src/lib/schemas.ts new file mode 100644 index 0000000..5150b5c --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/schemas.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' + +import { SPACE_KINDS } from '$lib/space-kinds' + +/** + * Shared validation schemas. Kept free of any database import so they can be + * used both to shape `+page.server.ts` form actions and to re-validate the + * payloads those actions build from `FormData`. + */ + +/** Shape of the public booking form. */ +export const bookingInput = z + .object({ + guestName: z.string().trim().min(1, 'Please enter your name'), + email: z.string().trim().email('Enter a valid email'), + phone: z.string().trim().min(5, 'Enter a valid phone number'), + checkIn: z.string().min(1, 'Choose a check-in date'), + checkOut: z.string().min(1, 'Choose a check-out date'), + partySize: z.coerce.number().int().min(1).max(20), + notes: z.string().trim().max(1000).optional(), + /** null = let the front desk assign a space later. */ + spaceId: z.number().int().positive().nullable().default(null), + }) + .refine((value) => value.checkOut > value.checkIn, { + message: 'Check-out must be after check-in', + path: ['checkOut'], + }) + +export type BookingInput = z.infer + +export const statusInput = z.object({ + id: z.number().int().positive(), + status: z.enum(['pending', 'confirmed', 'cancelled']), +}) + +export const assignInput = z.object({ + id: z.number().int().positive(), + spaceId: z.number().int().positive().nullable(), +}) + +export const idInput = z.object({ id: z.number().int().positive() }) + +/** Shape of the space create / edit form. */ +export const spaceInput = z.object({ + name: z.string().trim().min(1, 'Give the space a name').max(80), + kind: z.enum(SPACE_KINDS), + capacity: z.coerce.number().int().min(1, 'At least 1').max(40), + /** Nightly rate in whole currency units; blank means "no rate set". */ + rate: z.coerce.number().min(0).max(1_000_000).nullable().default(null), + notes: z.string().trim().max(500).nullable().default(null), +}) + +export type SpaceInput = z.infer + +export const spaceUpdateInput = spaceInput.extend({ + id: z.number().int().positive(), +}) + +export const setSpaceStatusInput = z.object({ + id: z.number().int().positive(), + status: z.enum(['active', 'archived']), +}) + +export const availabilityInput = z.object({ + checkIn: z.string().min(1), + checkOut: z.string().min(1), + partySize: z.coerce.number().int().min(1), +}) diff --git a/eval/fixtures/sveltekit-pms/src/lib/server/actions.ts b/eval/fixtures/sveltekit-pms/src/lib/server/actions.ts new file mode 100644 index 0000000..0b5121d --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/server/actions.ts @@ -0,0 +1,231 @@ +import { db } from '$lib/server/db' +import type { Reservation, Space } from '$lib/server/db' +import { assertSpaceBookable, bookedSpaceIds } from '$lib/server/availability' +import { + assignInput, + availabilityInput, + bookingInput, + idInput, + setSpaceStatusInput, + spaceInput, + spaceUpdateInput, + statusInput, +} from '$lib/schemas' +import type { BookingInput, SpaceInput } from '$lib/schemas' + +/** + * The write path (plus the availability read that booking depends on). Each + * `+page.server.ts` form action builds a plain object from `FormData` and hands + * it here; these functions re-validate and mutate the database. Keeping them in + * `$lib/server/` means the DB never leaks into the client bundle. + */ + +const SPACE_COLUMNS = ` + id, name, kind, capacity, rate_cents AS rateCents, status, notes, + created_at AS createdAt +` + +const RESERVATION_COLUMNS = ` + id, guest_name AS guestName, email, phone, check_in AS checkIn, + check_out AS checkOut, party_size AS partySize, notes, space_id AS spaceId, + status, created_at AS createdAt +` + +export type SpaceAvailability = Space & { + available: boolean + /** Why it can't be booked, when `available` is false. */ + reason: string | null +} + +/** Active spaces annotated with whether they can take the given stay. */ +export function listSpaceAvailability(input: { + checkIn: string + checkOut: string + partySize: number +}): Array { + const data = availabilityInput.parse(input) + + const active = db + .prepare( + `SELECT ${SPACE_COLUMNS} FROM spaces WHERE status = 'active' ORDER BY name ASC`, + ) + .all() as Array + const booked = bookedSpaceIds(data.checkIn, data.checkOut) + + return active.map((space) => { + if (booked.has(space.id)) + return { ...space, available: false, reason: 'Booked for these dates' } + if (data.partySize > space.capacity) + return { ...space, available: false, reason: `Sleeps ${space.capacity}` } + return { ...space, available: true, reason: null } + }) +} + +/** Create a reservation from the public booking form. */ +export function createReservation(input: BookingInput): Reservation { + const data = bookingInput.parse(input) + + if (data.spaceId !== null) { + assertSpaceBookable({ + spaceId: data.spaceId, + checkIn: data.checkIn, + checkOut: data.checkOut, + partySize: data.partySize, + }) + } + + return db + .prepare( + `INSERT INTO reservations + (guest_name, email, phone, check_in, check_out, party_size, notes, space_id) + VALUES (@guestName, @email, @phone, @checkIn, @checkOut, @partySize, @notes, @spaceId) + RETURNING ${RESERVATION_COLUMNS}`, + ) + .get({ + guestName: data.guestName, + email: data.email, + phone: data.phone, + checkIn: data.checkIn, + checkOut: data.checkOut, + partySize: data.partySize, + notes: data.notes || null, + spaceId: data.spaceId, + }) as Reservation +} + +/** Update a reservation's status (front desk). */ +export function updateReservationStatus(input: { + id: number + status: 'pending' | 'confirmed' | 'cancelled' +}): Reservation { + const data = statusInput.parse(input) + + const current = db + .prepare(`SELECT ${RESERVATION_COLUMNS} FROM reservations WHERE id = ?`) + .get(data.id) as Reservation | undefined + if (!current) throw new Error('That reservation no longer exists.') + + // Cancelling releases the space, so reviving a cancelled stay has to win its + // space back — someone else may have taken it in the meantime. + if ( + current.status === 'cancelled' && + data.status !== 'cancelled' && + current.spaceId !== null + ) { + assertSpaceBookable({ + spaceId: current.spaceId, + checkIn: current.checkIn, + checkOut: current.checkOut, + partySize: current.partySize, + excludeReservationId: current.id, + }) + } + + return db + .prepare( + `UPDATE reservations SET status = ? WHERE id = ? RETURNING ${RESERVATION_COLUMNS}`, + ) + .get(data.status, data.id) as Reservation +} + +/** Assign, move, or clear a reservation's space (front desk). */ +export function assignReservationSpace(input: { + id: number + spaceId: number | null +}): Reservation { + const data = assignInput.parse(input) + + const current = db + .prepare(`SELECT ${RESERVATION_COLUMNS} FROM reservations WHERE id = ?`) + .get(data.id) as Reservation | undefined + if (!current) throw new Error('That reservation no longer exists.') + + if (data.spaceId !== null) { + assertSpaceBookable({ + spaceId: data.spaceId, + checkIn: current.checkIn, + checkOut: current.checkOut, + partySize: current.partySize, + excludeReservationId: current.id, + }) + } + + return db + .prepare( + `UPDATE reservations SET space_id = ? WHERE id = ? RETURNING ${RESERVATION_COLUMNS}`, + ) + .get(data.spaceId, data.id) as Reservation +} + +/** Delete a reservation (front desk). */ +export function deleteReservation(input: { id: number }): { id: number } { + const data = idInput.parse(input) + db.prepare('DELETE FROM reservations WHERE id = ?').run(data.id) + return { id: data.id } +} + +export function createSpace(input: SpaceInput): Space { + const data = spaceInput.parse(input) + try { + return db + .prepare( + `INSERT INTO spaces (name, kind, capacity, rate_cents, notes) + VALUES (@name, @kind, @capacity, @rateCents, @notes) + RETURNING ${SPACE_COLUMNS}`, + ) + .get(toRow(data)) as Space + } catch (err) { + rethrowNameCollision(err, data.name) + } +} + +export function updateSpace(input: SpaceInput & { id: number }): Space { + const { id, ...rest } = spaceUpdateInput.parse(input) + try { + return db + .prepare( + `UPDATE spaces + SET name = @name, kind = @kind, capacity = @capacity, + rate_cents = @rateCents, notes = @notes + WHERE id = @id + RETURNING ${SPACE_COLUMNS}`, + ) + .get({ ...toRow(rest), id }) as Space + } catch (err) { + rethrowNameCollision(err, rest.name) + } +} + +/** + * Archive or restore a space. Archiving keeps it out of the booking picker + * without touching the reservations that already reference it. + */ +export function setSpaceStatus(input: { + id: number + status: 'active' | 'archived' +}): Space { + const data = setSpaceStatusInput.parse(input) + return db + .prepare( + `UPDATE spaces SET status = ? WHERE id = ? RETURNING ${SPACE_COLUMNS}`, + ) + .get(data.status, data.id) as Space +} + +function toRow(data: SpaceInput) { + return { + name: data.name, + kind: data.kind, + capacity: data.capacity, + rateCents: data.rate === null ? null : Math.round(data.rate * 100), + notes: data.notes || null, + } +} + +/** Names are unique, so surface the collision instead of a raw SQLite error. */ +function rethrowNameCollision(err: unknown, name: string): never { + const message = err instanceof Error ? err.message : '' + if (message.includes('UNIQUE') && message.includes('name')) + throw new Error(`A space named “${name}” already exists.`) + throw err instanceof Error ? err : new Error(String(err)) +} diff --git a/eval/fixtures/sveltekit-pms/src/lib/server/availability.ts b/eval/fixtures/sveltekit-pms/src/lib/server/availability.ts new file mode 100644 index 0000000..832dec9 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/server/availability.ts @@ -0,0 +1,76 @@ +import { db } from '$lib/server/db' +import type { Space } from '$lib/server/db' + +/** + * Availability helpers shared by booking and the front desk. + * + * Reservations hold a space for the half-open interval [checkIn, checkOut), so + * a same-day turnover (one guest out, the next in) is not a conflict. Cancelled + * reservations release the space. ISO `YYYY-MM-DD` dates sort lexicographically, + * so a text compare is a date compare. + */ + +/** Space ids already held for the given range, excluding one reservation. */ +export function bookedSpaceIds( + checkIn: string, + checkOut: string, + excludeReservationId?: number, +): Set { + let sql = ` + SELECT DISTINCT space_id AS spaceId + FROM reservations + WHERE space_id IS NOT NULL + AND status != 'cancelled' + AND check_in < ? + AND check_out > ? + ` + const params: Array = [checkOut, checkIn] + + if (excludeReservationId !== undefined) { + sql += ' AND id != ?' + params.push(excludeReservationId) + } + + const held = db.prepare(sql).all(...params) as Array<{ spaceId: number }> + return new Set(held.map((row) => row.spaceId)) +} + +/** + * Assert a space can take a stay, throwing a guest-readable message if not. + * Used by both booking and front-desk reassignment. + */ +export function assertSpaceBookable({ + spaceId, + checkIn, + checkOut, + partySize, + excludeReservationId, +}: { + spaceId: number + checkIn: string + checkOut: string + partySize: number + excludeReservationId?: number +}): Space { + const space = db + .prepare( + `SELECT id, name, kind, capacity, rate_cents AS rateCents, status, notes, + created_at AS createdAt + FROM spaces WHERE id = ?`, + ) + .get(spaceId) as Space | undefined + + if (!space) throw new Error('That space no longer exists.') + if (space.status !== 'active') + throw new Error(`${space.name} is archived and can't be booked.`) + if (partySize > space.capacity) + throw new Error( + `${space.name} sleeps ${space.capacity}, but this stay is for ${partySize}.`, + ) + + const booked = bookedSpaceIds(checkIn, checkOut, excludeReservationId) + if (booked.has(spaceId)) + throw new Error(`${space.name} is already booked for those dates.`) + + return space +} diff --git a/eval/fixtures/sveltekit-pms/src/lib/server/db.ts b/eval/fixtures/sveltekit-pms/src/lib/server/db.ts new file mode 100644 index 0000000..ab57a0a --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/server/db.ts @@ -0,0 +1,94 @@ +import Database from 'better-sqlite3' +import { env } from '$env/dynamic/private' + +import type { SpaceKind } from '$lib/space-kinds' + +/** + * The single better-sqlite3 handle for the app. Everything that touches the + * database lives under `$lib/server/`, so SvelteKit keeps it out of the client + * bundle. The schema is created on import (see below) — a fresh clone can run + * `npm run dev` with no separate migrate step. + */ + +export type SpaceStatus = 'active' | 'archived' +export type ReservationStatus = 'pending' | 'confirmed' | 'cancelled' + +/** A bookable space. Column names are aliased to camelCase in every query. */ +export type Space = { + id: number + name: string + kind: SpaceKind + capacity: number + rateCents: number | null + status: SpaceStatus + notes: string | null + createdAt: number | null +} + +/** A single reservation held against (optionally) a space. */ +export type Reservation = { + id: number + guestName: string + email: string + phone: string + checkIn: string + checkOut: string + partySize: number + notes: string | null + spaceId: number | null + status: ReservationStatus + createdAt: number | null +} + +const databasePath = env.DATABASE_URL ?? 'dev.db' + +export const db = new Database(databasePath) +db.pragma('journal_mode = WAL') +db.pragma('foreign_keys = ON') +db.pragma('busy_timeout = 10000') + +/** + * Spaces are archived rather than deleted so past reservations keep pointing at + * something real; a reservation's space is nullable (ON DELETE SET NULL) so a + * stay can be taken before the front desk has decided which space it gets. + */ +db.exec(` + CREATE TABLE IF NOT EXISTS spaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL DEFAULT 'room', + capacity INTEGER NOT NULL DEFAULT 2, + rate_cents INTEGER, + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + created_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guest_name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT NOT NULL, + check_in TEXT NOT NULL, + check_out TEXT NOT NULL, + party_size INTEGER NOT NULL DEFAULT 1, + notes TEXT, + space_id INTEGER REFERENCES spaces(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at INTEGER DEFAULT (unixepoch()) + ); +`) + +// Seed a few spaces the first time so the booking form's availability picker +// isn't empty on a fresh clone. +const seeded = db.prepare('SELECT count(*) AS count FROM spaces').get() as { + count: number +} +if (seeded.count === 0) { + const insert = db.prepare( + 'INSERT INTO spaces (name, kind, capacity, rate_cents, notes) VALUES (?, ?, ?, ?, ?)', + ) + insert.run('Seagrass Suite', 'suite', 4, 24_000, 'Ocean view, walk-in shower') + insert.run('Dune Cabin', 'cabin', 2, 16_000, null) + insert.run('Harbor Room 101', 'room', 2, 12_000, null) +} diff --git a/eval/fixtures/sveltekit-pms/src/lib/server/queries.ts b/eval/fixtures/sveltekit-pms/src/lib/server/queries.ts new file mode 100644 index 0000000..56a038b --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/server/queries.ts @@ -0,0 +1,91 @@ +import { db } from '$lib/server/db' +import type { Reservation, Space } from '$lib/server/db' +import type { SpaceKind } from '$lib/space-kinds' + +/** + * Server-only read queries, called from each route's `+page.server.ts` load. + */ + +const SPACE_COLUMNS = ` + id, name, kind, capacity, rate_cents AS rateCents, status, notes, + created_at AS createdAt +` + +const RESERVATION_COLUMNS = ` + id, guest_name AS guestName, email, phone, check_in AS checkIn, + check_out AS checkOut, party_size AS partySize, notes, space_id AS spaceId, + status, created_at AS createdAt +` + +/** A reservation plus the display fields of its assigned space. */ +export type ReservationRow = Reservation & { + spaceName: string | null + spaceKind: SpaceKind | null +} + +/** All reservations, newest first, with the assigned space joined in. */ +export function listReservations(): Array { + return db + .prepare( + `SELECT + reservations.id AS id, + reservations.guest_name AS guestName, + reservations.email AS email, + reservations.phone AS phone, + reservations.check_in AS checkIn, + reservations.check_out AS checkOut, + reservations.party_size AS partySize, + reservations.notes AS notes, + reservations.space_id AS spaceId, + reservations.status AS status, + reservations.created_at AS createdAt, + spaces.name AS spaceName, + spaces.kind AS spaceKind + FROM reservations + LEFT JOIN spaces ON spaces.id = reservations.space_id + ORDER BY reservations.created_at DESC, reservations.id DESC`, + ) + .all() as Array +} + +export type Guest = { + name: string + email: string + phone: string + reservationCount: number +} + +/** Unique guests (deduped by email), with how many reservations each has. */ +export function listGuests(): Array { + const all = db + .prepare( + `SELECT ${RESERVATION_COLUMNS} FROM reservations + ORDER BY created_at DESC, id DESC`, + ) + .all() as Array + + const byEmail = new Map() + for (const reservation of all) { + const key = reservation.email.trim().toLowerCase() + const existing = byEmail.get(key) + if (existing) { + existing.reservationCount += 1 + } else { + // rows are newest-first, so the first hit is the guest's latest details + byEmail.set(key, { + name: reservation.guestName, + email: reservation.email, + phone: reservation.phone, + reservationCount: 1, + }) + } + } + return [...byEmail.values()] +} + +/** Every space, active first then alphabetical. */ +export function listSpaces(): Array { + return db + .prepare(`SELECT ${SPACE_COLUMNS} FROM spaces ORDER BY status ASC, name ASC`) + .all() as Array +} diff --git a/eval/fixtures/sveltekit-pms/src/lib/space-kinds.ts b/eval/fixtures/sveltekit-pms/src/lib/space-kinds.ts new file mode 100644 index 0000000..0e30160 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/lib/space-kinds.ts @@ -0,0 +1,25 @@ +/** + * The kinds of bookable space a property can offer, plus their labels. + * + * Kept free of any database import so both `+page.svelte` views and the + * server-only query layer can share it. + */ +export const SPACE_KINDS = [ + 'room', + 'suite', + 'cabin', + 'villa', + 'tent', + 'other', +] as const + +export type SpaceKind = (typeof SPACE_KINDS)[number] + +export const SPACE_KIND_LABELS: Record = { + room: 'Room', + suite: 'Suite', + cabin: 'Cabin', + villa: 'Villa', + tent: 'Tent', + other: 'Space', +} diff --git a/eval/fixtures/sveltekit-pms/src/routes/+layout.svelte b/eval/fixtures/sveltekit-pms/src/routes/+layout.svelte new file mode 100644 index 0000000..812a6c9 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/+layout.svelte @@ -0,0 +1,48 @@ + + +
+ + +
+ {@render children()} +
+ +
+
+ Harbor PMS — a minimal property reservation manager. +
+
+
diff --git a/eval/fixtures/sveltekit-pms/src/routes/+page.server.ts b/eval/fixtures/sveltekit-pms/src/routes/+page.server.ts new file mode 100644 index 0000000..f104c63 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/+page.server.ts @@ -0,0 +1,93 @@ +import { fail } from '@sveltejs/kit' + +import { createReservation, listSpaceAvailability } from '$lib/server/actions' +import { listSpaces } from '$lib/server/queries' +import { bookingInput } from '$lib/schemas' +import type { Actions, PageServerLoad } from './$types' + +/** + * The booking form's availability picker depends on the chosen dates + party + * size, which the page keeps in the URL query. The load re-runs whenever they + * change, so availability is always computed on the server. + */ +export const load: PageServerLoad = ({ url }) => { + const checkIn = url.searchParams.get('check_in') ?? '' + const checkOut = url.searchParams.get('check_out') ?? '' + const partySize = Number(url.searchParams.get('party_size') ?? '1') || 1 + + const datesReady = checkIn !== '' && checkOut !== '' && checkOut > checkIn + + return { + checkIn, + checkOut, + partySize, + datesReady, + spaces: datesReady + ? listSpaceAvailability({ checkIn, checkOut, partySize }) + : [], + } +} + +export const actions: Actions = { + default: async ({ request }) => { + const form = await request.formData() + const values = { + guestName: String(form.get('guestName') ?? ''), + email: String(form.get('email') ?? ''), + phone: String(form.get('phone') ?? ''), + checkIn: String(form.get('checkIn') ?? ''), + checkOut: String(form.get('checkOut') ?? ''), + partySize: String(form.get('partySize') ?? '1'), + notes: String(form.get('notes') ?? ''), + spaceId: String(form.get('spaceId') ?? ''), + } + + const parsed = bookingInput.safeParse({ + ...values, + spaceId: values.spaceId === '' ? null : Number(values.spaceId), + }) + if (!parsed.success) { + return fail(400, { values, errors: fieldErrors(parsed.error.issues) }) + } + + try { + const reservation = createReservation(parsed.data) + const spaceName = + reservation.spaceId === null + ? null + : (listSpaces().find((space) => space.id === reservation.spaceId) + ?.name ?? null) + + return { + success: true, + reservation: { + id: reservation.id, + guestName: reservation.guestName, + spaceName, + }, + } + } catch (err) { + // Availability is re-checked here, so a race (someone booked the space + // first) surfaces with a usable message. + return fail(400, { + values, + formError: + err instanceof Error && err.message + ? err.message + : 'Something went wrong. Please try again.', + }) + } + }, +} + +/** Collapse Zod issues to the first message per field. */ +function fieldErrors( + issues: Array<{ path: Array; message: string }>, +): Record { + const out: Record = {} + for (const issue of issues) { + const key = issue.path[0] + if (typeof key === 'string' && !out[key]) out[key] = issue.message + } + return out +} diff --git a/eval/fixtures/sveltekit-pms/src/routes/+page.svelte b/eval/fixtures/sveltekit-pms/src/routes/+page.svelte new file mode 100644 index 0000000..77e3954 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/+page.svelte @@ -0,0 +1,377 @@ + + +{#if form?.success} +
+
+
+

Reservation received

+

+ Thanks, {form.reservation.guestName}! +

+

+ Your reservation + #{form.reservation.id} + is pending confirmation. + {#if form.reservation.spaceName} + We're holding {form.reservation.spaceName} for you. + {/if} + We'll be in touch by email shortly. +

+ + Book another stay + +
+
+{:else} +
+
+

Reserve your stay

+

Book a reservation

+

+ Tell us who's coming and when. No account needed — just your details. +

+ +
{ + submitting = true + return async ({ update }) => { + await update() + submitting = false + } + }} + > + + +
+ + +
+ +
+ + + +
+ +
+ + Space + + {#if datesReady && data.spaces.length > 0} + — {openCount} of {data.spaces.length} open + {:else} + (optional) + {/if} + + + + {#if !datesReady} +

Pick your dates to see what's available.

+ {:else if data.spaces.length === 0} +

+ No spaces are set up yet — we'll assign one and confirm by email. +

+ {:else} +
+ {#each data.spaces as space (space.id)} + {@const rate = formatRate(space.rateCents)} + + {/each} + + +
+ {/if} +
+ + + + {#if form?.formError} +

{form.formError}

+ {/if} + + +
+
+
+{/if} + + diff --git a/eval/fixtures/sveltekit-pms/src/routes/guests/+page.server.ts b/eval/fixtures/sveltekit-pms/src/routes/guests/+page.server.ts new file mode 100644 index 0000000..2d7aa79 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/guests/+page.server.ts @@ -0,0 +1,6 @@ +import { listGuests } from '$lib/server/queries' +import type { PageServerLoad } from './$types' + +export const load: PageServerLoad = () => ({ + guests: listGuests(), +}) diff --git a/eval/fixtures/sveltekit-pms/src/routes/guests/+page.svelte b/eval/fixtures/sveltekit-pms/src/routes/guests/+page.svelte new file mode 100644 index 0000000..dd1c86b --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/guests/+page.svelte @@ -0,0 +1,77 @@ + + +
+
+
+

Directory

+

Guests

+

+ {data.guests.length} unique guest{data.guests.length === 1 ? '' : 's'} +

+
+ View reservations +
+ + {#if data.guests.length === 0} +
+ No guests yet. They'll appear here after the first booking. +
+ {:else} +
+ + + + + + + + + + + {#each data.guests as guest (guest.email)} + + + + + + + {/each} + +
NameEmailPhoneStays
+ {guest.name} + {guest.email}{guest.phone} + {guest.reservationCount} +
+
+ {/if} +
+ + diff --git a/eval/fixtures/sveltekit-pms/src/routes/reservations/+page.server.ts b/eval/fixtures/sveltekit-pms/src/routes/reservations/+page.server.ts new file mode 100644 index 0000000..44e0132 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/reservations/+page.server.ts @@ -0,0 +1,79 @@ +import { fail } from '@sveltejs/kit' + +import { + assignReservationSpace, + deleteReservation, + updateReservationStatus, +} from '$lib/server/actions' +import { listReservations, listSpaces } from '$lib/server/queries' +import { assignInput, idInput, statusInput } from '$lib/schemas' +import type { Actions, PageServerLoad } from './$types' + +export const load: PageServerLoad = () => ({ + reservations: listReservations(), + spaces: listSpaces(), +}) + +export const actions: Actions = { + updateStatus: async ({ request }) => { + const form = await request.formData() + const id = Number(form.get('id')) + const parsed = statusInput.safeParse({ + id, + status: String(form.get('status') ?? ''), + }) + if (!parsed.success) + return fail(400, { errorId: id, message: 'Invalid status change.' }) + + try { + updateReservationStatus(parsed.data) + return { ok: true } + } catch (err) { + return rowError(id, err) + } + }, + + assignSpace: async ({ request }) => { + const form = await request.formData() + const id = Number(form.get('id')) + const raw = String(form.get('spaceId') ?? '') + const parsed = assignInput.safeParse({ + id, + spaceId: raw === '' ? null : Number(raw), + }) + if (!parsed.success) + return fail(400, { errorId: id, message: 'Invalid space selection.' }) + + try { + assignReservationSpace(parsed.data) + return { ok: true } + } catch (err) { + return rowError(id, err) + } + }, + + delete: async ({ request }) => { + const form = await request.formData() + const id = Number(form.get('id')) + const parsed = idInput.safeParse({ id }) + if (!parsed.success) + return fail(400, { errorId: id, message: 'Invalid reservation.' }) + + try { + deleteReservation(parsed.data) + return { ok: true } + } catch (err) { + return rowError(id, err) + } + }, +} + +function rowError(id: number, err: unknown) { + return fail(400, { + errorId: id, + message: + err instanceof Error && err.message + ? err.message + : 'Something went wrong. Please try again.', + }) +} diff --git a/eval/fixtures/sveltekit-pms/src/routes/reservations/+page.svelte b/eval/fixtures/sveltekit-pms/src/routes/reservations/+page.svelte new file mode 100644 index 0000000..cc69c8c --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/reservations/+page.svelte @@ -0,0 +1,313 @@ + + +
+
+
+

Front desk

+

Reservations

+

+ {data.reservations.length} total · {upcoming} active{unassigned + ? ` · ${unassigned} awaiting a space` + : ''} +

+
+ +
+ + {#if data.reservations.length === 0} +
+ No reservations yet. Once guests book, they'll show up here. +
+ {:else} +
+ {#each data.reservations as reservation (reservation.id)} +
+
+
+
+

{reservation.guestName}

+ + {STATUS_LABEL[reservation.status]} + + #{reservation.id} +
+ +
+ {reservation.email} + {reservation.phone} + + {reservation.partySize} guest{reservation.partySize === 1 + ? '' + : 's'} + +
+ +

+ {formatDate(reservation.checkIn)} → {formatDate( + reservation.checkOut, + )} + + ({nights(reservation.checkIn, reservation.checkOut)} night{nights( + reservation.checkIn, + reservation.checkOut, + ) === 1 + ? '' + : 's'}) + +

+ + {#if reservation.notes} +

"{reservation.notes}"

+ {/if} + + +
+ + Space + + {#if reservation.spaceId === null} + Not assigned yet + {/if} +
+ + {#if form?.errorId === reservation.id && form.message} +

+ {form.message} +

+ {/if} +
+ +
+ {#if reservation.status !== 'confirmed'} +
+ + + +
+ {/if} + {#if reservation.status !== 'cancelled'} +
+ + + +
+ {/if} +
{ + if ( + !confirm( + `Delete reservation #${reservation.id}? This cannot be undone.`, + ) + ) + event.preventDefault() + }} + > + + +
+
+
+
+ {/each} +
+ {/if} +
+ + diff --git a/eval/fixtures/sveltekit-pms/src/routes/spaces/+page.server.ts b/eval/fixtures/sveltekit-pms/src/routes/spaces/+page.server.ts new file mode 100644 index 0000000..e837964 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/spaces/+page.server.ts @@ -0,0 +1,94 @@ +import { fail } from '@sveltejs/kit' + +import { createSpace, setSpaceStatus, updateSpace } from '$lib/server/actions' +import { listSpaces } from '$lib/server/queries' +import { setSpaceStatusInput, spaceInput, spaceUpdateInput } from '$lib/schemas' +import type { Actions, PageServerLoad } from './$types' + +export const load: PageServerLoad = () => ({ + spaces: listSpaces(), +}) + +export const actions: Actions = { + create: async ({ request }) => { + const form = await request.formData() + const values = spaceValues(form) + const parsed = spaceInput.safeParse(values) + if (!parsed.success) + return fail(400, { + form: 'create', + values, + errors: fieldErrors(parsed.error.issues), + }) + + try { + createSpace(parsed.data) + return { ok: true } + } catch (err) { + return fail(400, { form: 'create', values, formError: message(err) }) + } + }, + + update: async ({ request }) => { + const form = await request.formData() + const id = Number(form.get('id')) + const values = spaceValues(form) + const parsed = spaceUpdateInput.safeParse({ ...values, id }) + if (!parsed.success) + return fail(400, { + form: 'update', + id, + values, + errors: fieldErrors(parsed.error.issues), + }) + + try { + updateSpace(parsed.data) + return { ok: true } + } catch (err) { + return fail(400, { form: 'update', id, values, formError: message(err) }) + } + }, + + setStatus: async ({ request }) => { + const form = await request.formData() + const parsed = setSpaceStatusInput.safeParse({ + id: Number(form.get('id')), + status: String(form.get('status') ?? ''), + }) + if (!parsed.success) return fail(400, { formError: 'Invalid status change.' }) + + setSpaceStatus(parsed.data) + return { ok: true } + }, +} + +/** Pull the space form fields off `FormData` into the shape `spaceInput` wants. */ +function spaceValues(form: FormData) { + const rate = String(form.get('rate') ?? '').trim() + const notes = String(form.get('notes') ?? '').trim() + return { + name: String(form.get('name') ?? ''), + kind: String(form.get('kind') ?? ''), + capacity: String(form.get('capacity') ?? ''), + rate: rate === '' ? null : rate, + notes: notes === '' ? null : notes, + } +} + +function message(err: unknown): string { + return err instanceof Error && err.message + ? err.message + : 'Something went wrong. Please try again.' +} + +function fieldErrors( + issues: Array<{ path: Array; message: string }>, +): Record { + const out: Record = {} + for (const issue of issues) { + const key = issue.path[0] + if (typeof key === 'string' && !out[key]) out[key] = issue.message + } + return out +} diff --git a/eval/fixtures/sveltekit-pms/src/routes/spaces/+page.svelte b/eval/fixtures/sveltekit-pms/src/routes/spaces/+page.svelte new file mode 100644 index 0000000..557dd74 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/src/routes/spaces/+page.svelte @@ -0,0 +1,204 @@ + + +
+
+
+

Inventory

+

Spaces

+

+ {active.length} bookable · sleeps {beds}{archived.length + ? ` · ${archived.length} archived` + : ''} +

+
+ View reservations +
+ + + + {#if data.spaces.length === 0} +
+ No spaces yet. Add your first room above — guests can't be assigned one + until you do. +
+ {:else} +
+ {#each data.spaces as space (space.id)} + {#if editingId === space.id} + (editingId = null)} + /> + {:else} + {@const rate = formatRate(space.rateCents)} +
+
+
+
+

{space.name}

+ + {SPACE_KIND_LABELS[space.kind]} + + {#if space.status === 'archived'} + Archived + {/if} +
+
+ + Sleeps {space.capacity} guest{space.capacity === 1 ? '' : 's'} + + {rate ? `${rate} / night` : 'No rate set'} +
+ {#if space.notes} +

{space.notes}

+ {/if} +
+ +
+ +
{ + busyId = space.id + return async ({ update }) => { + await update() + busyId = null + } + }} + > + + + +
+
+
+
+ {/if} + {/each} +
+ {/if} +
+ + diff --git a/eval/fixtures/sveltekit-pms/tsconfig.json b/eval/fixtures/sveltekit-pms/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/eval/fixtures/sveltekit-pms/vite.config.ts b/eval/fixtures/sveltekit-pms/vite.config.ts new file mode 100644 index 0000000..dd1b8b7 --- /dev/null +++ b/eval/fixtures/sveltekit-pms/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [sveltekit()], +})