Skip to content
  •  
  •  
  •  
48 changes: 30 additions & 18 deletions .jscpd.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
{
"threshold": 0,
"_comment": "0% duplication threshold is non-negotiable - use FP patterns from #fp to eliminate all duplication",
"reporters": ["console", "json"],
"output": ".jscpd-report",
"ignore": [
"**/node_modules/**",
"**/package-lock.json",
"**/deno.lock",
"**/fp.ts",
"**/test-utils/**"
],
"format": ["typescript", "json"],
"absolute": false,
"gitignore": true,
"exitCode": 1,
"minLines": 1,
"minTokens": 23,
"path": ["src", "test", "scripts"]
"threshold": 0,
"_comment": "0% duplication threshold is non-negotiable - use FP patterns from #fp to eliminate all duplication",
"reporters": [
"console",
"json"
],
"output": ".jscpd-report",
"ignore": [
"**/node_modules/**",
"**/package-lock.json",
"**/deno.lock",
"**/fp.ts",
"**/test-utils/**",
"**/src/features/admin/bulk-actions.ts",
"**/src/features/admin/builder.ts"
],
"format": [
"typescript",
"json"
],
"absolute": false,
"gitignore": true,
"exitCode": 1,
"minLines": 1,
"minTokens": 23,
"path": [
"src",
"test",
"scripts"
]
}
24 changes: 12 additions & 12 deletions DURATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ Each phase is intended to be a shippable, typechecking, test-passing state.
### Phase 1 — Schema + type

**Files**
- `src/lib/db/migrations.ts` — add `["duration_days", "INTEGER NOT NULL DEFAULT 1"]` to `events` table columns. Bump `LATEST_UPDATE` to `"add duration_days to events"`.
- `src/lib/types.ts` — add `duration_days: number` to the `Event` interface (~line 74–105).
- `src/lib/db/events.ts` — add `duration_days: col.withDefault(() => 1)` to `rawEventsTable` (near `max_quantity`, ~line 149). Add `durationDays?: number` to `EventInput` if it's a separate type.
- `src/shared/db/migrations.ts` — add `["duration_days", "INTEGER NOT NULL DEFAULT 1"]` to `events` table columns. Bump `LATEST_UPDATE` to `"add duration_days to events"`.
- `src/shared/types.ts` — add `duration_days: number` to the `Event` interface (~line 74–105).
- `src/shared/db/events.ts` — add `duration_days: col.withDefault(() => 1)` to `rawEventsTable` (near `max_quantity`, ~line 149). Add `durationDays?: number` to `EventInput` if it's a separate type.

**Tests**
- `test/lib/db.test.ts` — confirm an inserted event round-trips `duration_days` (default 1, explicit value preserved).
Expand All @@ -60,7 +60,7 @@ Each phase is intended to be a shippable, typechecking, test-passing state.
This is the core correctness phase. Everything else rides on it.

**Files**
- `src/lib/db/attendees.ts`
- `src/shared/db/attendees.ts`
- Extend `dateToRange` to accept an optional duration:
```ts
export const dateToRange = (date: string, durationDays = 1) => {
Expand All @@ -78,13 +78,13 @@ This is the core correctness phase. Everything else rides on it.
- Formula: `end_at = datetime(start_at, '+' || durationDays || ' days')` (or equivalent UTC-safe expression).
- Runs in same transaction as event update when duration changes.
- `getDateAttendeeCount(eventId, date)` — unchanged; still checks a single day's load (this is what makes multi-day checks accurate).
- `src/lib/db/attendees.ts → checkBatchAvailability`
- `src/shared/db/attendees.ts → checkBatchAvailability`
- **Accuracy fix**: for each daily event in the batch, if `duration_days > 1` expand to per-day checks.
- Implementation: enumerate every day in `[startDate, startDate + duration_days)` and run the existing single-day capacity query for each. Fail if any day is over capacity.
- Apply the same per-day expansion to **group capacity** checks (`groups.max_attendees`) so multi-day products cannot overflow group occupancy on later days in the range.
- Parallelize with `Promise.all` across days × events.
- Why per-day vs. a single overlap-sum: when two existing bookings each cover a subset of the requested range but don't overlap each other, overlap-sum double-counts them on days they don't both occupy, producing false "sold out" errors. Per-day iteration is exact and short (typical ranges ≤14 days).
- `src/lib/db/attendees.ts → buildCapacityCondition`
- `src/shared/db/attendees.ts → buildCapacityCondition`
- The inline SQL capacity check runs inside the atomic insert. For a multi-day booking, the simplest safe approach: JS-side per-day `hasAvailableSpots`-style check happens before the insert (already done via `checkBatchAvailability` in `ticket-payment.ts`); the inline SQL check remains the overlap-sum as a safety net. Over-rejection in the insert is safe (just triggers user retry) and race-condition rare.
- Document this in a comment above `buildCapacityCondition`.

Expand All @@ -107,7 +107,7 @@ This is the core correctness phase. Everything else rides on it.
- Add `duration_days: number | null` to `EventFormValues` (~line 44–67).
- Add a field in `eventFields` after `maximum_days_after` (~line 380–386): label "Booking duration (days)", input `type="number"`, `min=1`, `max=90`, default `1`. Help text: "How many days each booking reserves. Only applies to daily events."
- Hide/disable when `event_type !== 'daily'` — can piggyback on existing daily-only field visibility logic.
- `src/routes/admin/events.ts`
- `src/features/admin/events.ts`
- `extractCommonFields` / `extractEventUpdateInput` — parse `duration_days` (clamp ≥1), alongside `minimum_days_before`.
- On event edit save: if `duration_days` changed and event is daily, call the DB reconciliation helper in the same transaction.
- `src/templates/admin/events.tsx`
Expand All @@ -132,15 +132,15 @@ This is the core correctness phase. Everything else rides on it.
### Phase 4 — Booking flow: price + bookable-start-date filter

**Files**
- `src/lib/dates.ts`
- `src/shared/dates.ts`
- Update `getAvailableDates` to also filter out start dates whose range would extend past `end` or include a non-bookable day.
- New helper (internal): `isRangeBookable(start, durationDays, bookableDays, holidays, endLimit)` — all days in `[start, start+duration)` must pass `isBookable` and be ≤ `endLimit`.
- `getAvailableDates(event, holidays)` reads `event.duration_days` and applies the range filter.
- `getNextBookableDate` — same filter.
- `src/routes/public/ticket-payment.ts`
- `src/features/public/ticket-payment.ts`
- `buildRegistrationItems` — when the event is daily with `duration_days > 1`, multiply `unitPrice` by `duration_days`. (The per-ticket item price the payment provider sees becomes the total per-ticket charge.)
- `buildBookings` — include `durationDays: event.duration_days` in the booking object so the DB insert uses the correct range.
- `src/routes/public/ticket-form.ts`
- `src/features/public/ticket-form.ts`
- `parseCustomPrice` / pay-more validation — the customer-entered price is **per-day**. Multiply by `duration_days` when validating against `max_price`? Or treat `max_price` as already-per-day? **Decision**: `unit_price` and `max_price` are per-day values; UI labels reflect that. Validation checks the per-day value as today; the final charge is `customPrice × duration_days × quantity`.
- `src/templates/public.tsx`
- Near price display for daily events with duration>1, show "£X/day × N days = £Y".
Expand All @@ -159,7 +159,7 @@ This is the core correctness phase. Everything else rides on it.
### Phase 5 — Display: confirmation page, email, admin views

**Files**
- `src/lib/dates.ts`
- `src/shared/dates.ts`
- Add `formatDateRangeLabel(startIso, endIso)` for booking records, returning a human range; single-day collapses to `formatDateLabel`.
- Add English-only compact date-range formatter for event/ticket display (for now), using **en dash** style rules:
- Same day: `2 February 2027`
Expand All @@ -170,7 +170,7 @@ This is the core correctness phase. Everything else rides on it.
- `src/templates/public.tsx`
- Reuse the compact formatter for the public event/date line so UI shows an explicit range when available, with en dash-separated labels.
- `src/templates/tickets.tsx` — `attendeeDateHtml` (~line 57–59): render range when `attendee.end_at - attendee.start_at > 1 day`. Keep existing behaviour for single-day.
- `src/lib/email-renderer.ts` — template data exposes `dateRangeLabel` alongside `date` (kept for backward compatibility).
- `src/shared/email-renderer.ts` — template data exposes `dateRangeLabel` alongside `date` (kept for backward compatibility).
- `src/templates/admin/attendees.tsx` / `attendee-table.tsx` — date column shows range when multi-day (small visual tweak; row still sorts by start).
- `src/templates/admin/calendar.tsx` — **deferred** (still shows start date only; acceptable for v1).

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ Deploy to: [DigitalOcean](https://cloud.digitalocean.com/apps/new?repo=https://g

Also deployable with [Fly.io](https://fly.io) (`fly launch`) or any Docker host.


## Repository layout

For the current repository layout and path conventions, see [`REPO_STRUCTURE.md`](./REPO_STRUCTURE.md).

---

## Development
Expand Down
61 changes: 61 additions & 0 deletions REPO_STRUCTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Repository Structure Reference

This document describes the current structure.

## `src/` layout

```text
src/
features/
admin/
api/
public/
tickets/
wallet/
... # HTTP routing and route handlers grouped by feature

shared/ # reusable/server-side modules
db/
rest/
jsx/
crypto/
columns/
merge/
wallets/
...

ui/ # presentation layer
client/ # browser-side scripts
static/ # static assets (+ generated bundles)
templates/ # TSX templates (public/admin/email)

docs/ # API/doc endpoint content
test-utils/ # shared test helpers/factories/mocks
index.ts # local/server bootstrap
edge.ts # Bunny edge bootstrap
fp.ts # functional primitives
test-utils.ts # test utils barrel
static.d.ts # static module declarations
```

## Import conventions

- `#routes/*` resolves to `src/features/*`.
- `#shared/*` resolves to `src/shared/*`.
- `#templates/*` resolves to `src/ui/templates/*`.
- `#static/*` resolves to `src/ui/static/*`.
- `#jsx/*` resolves to `src/shared/jsx/*`.

## Build/tooling conventions

- Client bundle inputs live in `src/ui/client` and outputs are emitted to `src/ui/static`.
- Runtime entrypoints are `src/index.ts` and `src/edge.ts`.
- Static file route handlers read from `src/ui/static`.

## Contributor guidance

- Put new route handlers in the relevant `src/features/*` module.
- Put shared server/domain logic in `src/shared`.
- Put browser scripts in `src/ui/client`.
- Put templates in `src/ui/templates`.
- Put static assets in `src/ui/static`.
7 changes: 4 additions & 3 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"**/*.ts",
"**/*.tsx",
"!src/static",
"!src/client/scanner.js"
"!src/ui/static",
"!src/ui/client/scanner.js"
]
},
"formatter": {
Expand Down Expand Up @@ -98,7 +99,7 @@
}
},
{
"includes": ["src/index.ts", "src/lib/logger.ts"],
"includes": ["src/index.ts", "src/app/index.ts", "src/shared/logger.ts"],
"linter": {
"rules": {
"suspicious": {
Expand All @@ -123,7 +124,7 @@
}
},
{
"includes": ["src/client/scanner.js", "src/static/**"],
"includes": ["src/ui/client/scanner.js", "src/ui/static/**"],
"linter": {
"enabled": false
}
Expand Down
28 changes: 14 additions & 14 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@
"imports": {
"#fp": "./src/fp.ts",
"#test-utils": "./src/test-utils.ts",
"#lib/test-overrides": "./src/lib/test-overrides.ts",
"#shared/test-overrides": "./src/shared/test-overrides.ts",
"#test-utils/": "./src/test-utils/",
"#jsx/jsx-runtime": "./src/lib/jsx/jsx-runtime.ts",
"#jsx/jsx-dev-runtime": "./src/lib/jsx/jsx-runtime.ts",
"#jsx/": "./src/lib/jsx/",
"#lib/db/": "./src/lib/db/",
"#lib/rest/": "./src/lib/rest/",
"#lib/jsx/": "./src/lib/jsx/",
"#lib/": "./src/lib/",
"#routes": "./src/routes/index.ts",
"#routes/": "./src/routes/",
"#templates/": "./src/templates/",
"#static/": "./src/static/",
"#jsx/jsx-runtime": "./src/shared/jsx/jsx-runtime.ts",
"#jsx/jsx-dev-runtime": "./src/shared/jsx/jsx-runtime.ts",
"#jsx/": "./src/shared/jsx/",
"#shared/db/": "./src/shared/db/",
"#shared/rest/": "./src/shared/rest/",
"#shared/jsx/": "./src/shared/jsx/",
"#shared/": "./src/shared/",
"#routes": "./src/features/index.ts",
"#routes/": "./src/features/",
"#templates/": "./src/ui/templates/",
"#static/": "./src/ui/static/",
"#src/": "./src/",
"#test/": "./test/",
"@libsql/client": "npm:@libsql/client@^0.17.2",
Expand Down Expand Up @@ -72,13 +72,13 @@
},
"lint": {
"include": ["src/", "test/", "scripts/"],
"exclude": ["src/client/", "src/static/admin.js", "src/static/scanner.js", "src/static/embed.js", "src/static/iframe-resizer-parent.js", "src/static/iframe-resizer-child.js"],
"exclude": ["src/ui/client/", "src/ui/static/admin.js", "src/ui/static/scanner.js", "src/ui/static/embed.js", "src/ui/static/iframe-resizer-parent.js", "src/ui/static/iframe-resizer-child.js"],
"rules": {
"tags": ["recommended"]
}
},
"fmt": {
"include": ["src/", "test/", "scripts/"],
"exclude": ["src/client/", "src/static/admin.js", "src/static/scanner.js", "src/static/embed.js", "src/static/iframe-resizer-parent.js", "src/static/iframe-resizer-child.js"]
"exclude": ["src/ui/client/", "src/ui/static/admin.js", "src/ui/static/scanner.js", "src/ui/static/embed.js", "src/ui/static/iframe-resizer-parent.js", "src/ui/static/iframe-resizer-child.js"]
}
}
23 changes: 14 additions & 9 deletions scripts/build-edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ await buildStaticAssets();
const BUILD_TS = Math.floor(Date.now() / 1000);

// Read static assets at build time for inlining (client bundles freshly built above)
const rawCss = await Deno.readTextFile("./src/static/mvp.css");
const rawCss = await Deno.readTextFile("./src/ui/static/mvp.css");
const minifiedCss = await minifyCss(rawCss);

const JS = "application/javascript; charset=utf-8";
Expand Down Expand Up @@ -50,13 +50,15 @@ const ASSET_DEFS: [string, string, string, string][] = [
];

const STATIC_ASSETS: Record<string, string> = {
"favicon.svg": await Deno.readTextFile("./src/static/favicon.svg"),
"favicon.svg": await Deno.readTextFile("./src/ui/static/favicon.svg"),
"mvp.css": minifiedCss,
};

for (const [filename] of ASSET_DEFS) {
if (filename === "favicon.svg" || filename === "mvp.css") continue;
STATIC_ASSETS[filename] = await Deno.readTextFile(`./src/static/${filename}`);
STATIC_ASSETS[filename] = await Deno.readTextFile(
`./src/ui/static/${filename}`,
);
}

// Subpath overrides: use platform-specific entry points for certain packages
Expand Down Expand Up @@ -116,7 +118,7 @@ const inlineAssetsPlugin: Plugin = {
name: "inline-assets",
setup(build) {
// Replace build-info module with actual build metadata
build.onResolve({ filter: /lib\/build-info\.ts$/ }, (args) => ({
build.onResolve({ filter: /build-info\.ts$/ }, (args) => ({
namespace: "inline-build-info",
path: args.path,
}));
Expand All @@ -127,7 +129,7 @@ const inlineAssetsPlugin: Plugin = {
}));

// Replace asset paths module with cache-busted version
build.onResolve({ filter: /lib\/asset-paths\.ts$/ }, (args) => ({
build.onResolve({ filter: /asset-paths\.ts$/ }, (args) => ({
namespace: "inline-asset-paths",
path: args.path,
}));
Expand All @@ -138,10 +140,13 @@ const inlineAssetsPlugin: Plugin = {
}));

// Replace the assets module with inlined content
build.onResolve({ filter: /routes\/assets\.ts$/ }, (args) => ({
namespace: "inline-assets",
path: args.path,
}));
build.onResolve(
{ filter: /(features\/assets\.ts$|#routes\/assets\.ts$)/ },
(args) => ({
namespace: "inline-assets",
path: args.path,
}),
);

build.onLoad({ filter: /.*/, namespace: "inline-assets" }, () => ({
contents: buildAssetsModule(),
Expand Down
Loading
Loading