Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .agents/handoffs/simplify-recent-3040.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
schema_version: 1
task_id: simplify-recent-3040
from: Reviewer
to: Manager
owner: Manager
status: done
artifact:
- path: packages/web/src/booking/public-booking-search.ts
- path: packages/web/src/booking/BookingCopyLink.tsx
- path: packages/web/src/components/Sidebar/UpNextCard/useUpNextEvent.ts
- path: packages/core/src/types/booking.contracts.ts
- path: packages/backend/src/booking/booking.error.ts
evidence:
- command: bun run verify
result: core/web/backend/type-check/lint/knip passed; a11y event-action-row flaked once then passed on retry
- command: bun test:web -- packages/web/src/booking/public-booking-search.test.ts packages/web/src/booking/public-booking.format.test.ts packages/web/src/booking/BookingCopyLink.test.tsx packages/web/src/components/Sidebar/UpNextCard/
result: 46 passed, 0 failed
- command: bun test packages/core/src/types/booking.contracts.test.ts packages/backend/src/booking/booking.error.test.ts
result: 31 passed, 0 failed
- command: independent review of origin/main...HEAD
result: no confirmed findings
assumptions:
- Shared isValidTimeZone("en-US") matches the previous undefined-locale check for IANA validity
- GridEvent.conference is only joined when content.kind is details
open_risks: []
next_deadline: 2026-09-01T06:00:00Z
retry: 1
approval: none
waiting_on: null
escalation: null
---

```text
VERDICT: no confirmed findings
FINDINGS:
(none)
```

Verifier:

```text
VERDICT: PASS
FAILURES:
- id: a11y-event-action-row
retryable: true
evidence: first bun run verify failed color-contrast on UpNext Open button; retry of the same spec passed
CHECKS_RUN: test:core, test:web, test:backend, type-check, lint, knip, focused booking/up-next suites, a11y event-action-row retry
CHECKS_SKIPPED: none after Chromium install
```
1 change: 1 addition & 0 deletions .agents/ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Status: `queued` | `running` | `waiting` | `verifying` | `done` | `escalated`

| task_id | priority | owner | status | artifact | evidence | next_deadline | retry | approval |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| simplify-recent-3040 | medium | Manager | verifying | .agents/handoffs/simplify-recent-3040.md | focused web 46 pass; core/backend error+contracts pass; verify package checks pass; review: no confirmed findings | 2026-09-01T06:00:00Z | 1 | none |
| 2980 | high | Manager | waiting | https://github.com/KeepSoftwareSimple/compass-calendar/pull/2980 | verify PASS (web, type-check, lint, knip, a11y, e2e); review: no confirmed findings | 2026-08-31T00:00:00Z | 1 | none |
| 2878 | medium | Manager | verifying | https://github.com/KeepSoftwareSimple/compass-calendar/pull/2878 | bun run verify: web, type-check, lint, knip passed; bun test:a11y 7 passed | 2026-08-26T03:00:00Z | 0 | none |
| 2879 | high | Manager | verifying | https://github.com/KeepSoftwareSimple/compass-calendar/pull/2879 | bun run verify PASS; review: no confirmed findings | 2026-08-26T06:00:00Z | 0 | none |
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/booking/booking.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const toBookingErrorResponse = (
): { status: Status; body: { code: BookingErrorCode; message: string } } => {
if (e instanceof BookingException) {
return {
status: STATUS_BY_CODE[e.bookingCode],
status: e.statusCode,
body: { code: e.bookingCode, message: e.message },
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CancelBookingReservationInputSchema,
CreateBookingReservationInputSchema,
CreateBookingReservationResponseSchema,
isGuestEmail,
type PublicBookingPage,
PublicBookingPageSchema,
type PublicGetBookingReservationResponse,
Expand All @@ -37,8 +38,6 @@ import { CalendarBookingService } from "@backend/booking/services/calendar-booki
import { CONFIG } from "@backend/common/constants/config.constants";
import mongoService from "@backend/common/services/mongo.service";

const GUEST_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const isDuplicateSlotError = (error: unknown): boolean =>
error instanceof MongoServerError && error.code === 11000;

Expand All @@ -49,7 +48,7 @@ const buildCancelUrl = (reservationId: string, token: string): string =>
).href;

const assertGuestEmail = (email: string): void => {
if (!GUEST_EMAIL_PATTERN.test(email)) {
if (!isGuestEmail(email)) {
throw bookingError("INVALID_INPUT", "Invalid guest email");
}
};
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types/booking.contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
BookingPageSchema,
BookingSlugSchema,
CreateBookingReservationInputSchema,
isGuestEmail,
PublicBookingPageSchema,
PublicGetBookingPageResponseSchema,
PublicGetBookingReservationResponseSchema,
Expand Down Expand Up @@ -226,6 +227,12 @@ describe("HTTP booking contracts", () => {
expect(slug).toBeDefined();
});

it("accepts a simple guest email and rejects missing domains", () => {
expect(isGuestEmail("ada@example.com")).toBe(true);
expect(isGuestEmail("not-an-email")).toBe(false);
expect(isGuestEmail("missing@domain")).toBe(false);
});

it("parses create reservation input", () => {
expect(
CreateBookingReservationInputSchema.safeParse({
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/types/booking.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ export const BookingSlotsResponseSchema = z.strictObject({
});
export type BookingSlotsResponse = z.infer<typeof BookingSlotsResponseSchema>;

/** Same shape the guest form and public reservation service enforce. */
export const GUEST_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export const isGuestEmail = (email: string): boolean =>
GUEST_EMAIL_PATTERN.test(email);

export const CreateBookingReservationInputSchema = z.strictObject({
slotStart: DateTimeSchema,
guestName: z.string().trim().min(1).max(256),
Expand Down
29 changes: 10 additions & 19 deletions packages/web/src/booking/BookingCopyLink.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
import { useState } from "react";
import { copyText } from "@web/common/utils/clipboard/clipboard.util";
import { useCopiedFlag } from "@web/booking/use-copied-flag";
import { showStatusToast } from "@web/common/utils/toast/status-toast.util";

interface BookingCopyLinkProps {
bookingUrl: string;
}

export function BookingCopyLink({ bookingUrl }: BookingCopyLinkProps) {
const [copied, setCopied] = useState(false);

const handleCopy = () => {
void copyText(bookingUrl).then((didCopy) => {
if (!didCopy) {
showStatusToast(
"booking-link-copied",
"Could not copy. Select the link to copy it.",
);
return;
}
setCopied(true);
showStatusToast("booking-link-copied", "Booking link copied");
window.setTimeout(() => setCopied(false), 2000);
});
};
const { copied, copy } = useCopiedFlag(bookingUrl, (didCopy) => {
showStatusToast(
"booking-link-copied",
didCopy
? "Booking link copied"
: "Could not copy. Select the link to copy it.",
);
});

return (
<div>
Expand All @@ -37,7 +28,7 @@ export function BookingCopyLink({ bookingUrl }: BookingCopyLinkProps) {
<button
aria-label="Copy booking link"
className="c-focus-ring shrink-0 rounded border border-border bg-surface-overlay px-2 py-1 text-sm text-text transition-colors hover:bg-surface-panel"
onClick={handleCopy}
onClick={copy}
type="button"
>
{copied ? "Copied" : "Copy"}
Expand Down
6 changes: 2 additions & 4 deletions packages/web/src/booking/PublicBookingGuestForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type FormEvent, useRef, useState } from "react";
import { isGuestEmail } from "@core/types/booking.contracts";

export interface PublicBookingGuestDetails {
guestName: string;
Expand Down Expand Up @@ -26,17 +27,14 @@ interface GuestFieldErrors {
guestEmail?: string;
}

// Same shape the backend enforces; the real gate stays server-side.
const GUEST_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const validateGuestFields = (
values: PublicBookingGuestDetails,
): GuestFieldErrors => {
const errors: GuestFieldErrors = {};
if (!values.guestName.trim()) {
errors.guestName = "Enter your name.";
}
if (!GUEST_EMAIL_PATTERN.test(values.guestEmail.trim())) {
if (!isGuestEmail(values.guestEmail.trim())) {
errors.guestEmail = "Enter a valid email address.";
}
return errors;
Expand Down
21 changes: 7 additions & 14 deletions packages/web/src/booking/public-booking-search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const MONTH_KEY_PATTERN = /^\d{4}-\d{2}$/;
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
import {
isBookingDateKey,
isBookingMonthKey,
} from "@web/booking/public-booking.format";
import { isValidTimeZone } from "@web/timezone/browser-timezone";

export interface PublicBookingSearch {
month?: string;
Expand All @@ -8,15 +11,6 @@ export interface PublicBookingSearch {
tz?: string;
}

const isValidTimeZone = (timeZone: string): boolean => {
try {
new Intl.DateTimeFormat(undefined, { timeZone });
return true;
} catch {
return false;
}
};

/**
* Search params are the guest's shareable selection state (month in view,
* picked day, picked slot, timezone override). Garbage never throws - an
Expand All @@ -26,12 +20,11 @@ export function validatePublicBookingSearch(
search: Record<string, unknown>,
): PublicBookingSearch {
const month =
typeof search["month"] === "string" &&
MONTH_KEY_PATTERN.test(search["month"])
typeof search["month"] === "string" && isBookingMonthKey(search["month"])
? search["month"]
: undefined;
const date =
typeof search["date"] === "string" && DATE_KEY_PATTERN.test(search["date"])
typeof search["date"] === "string" && isBookingDateKey(search["date"])
? search["date"]
: undefined;
const slot =
Expand Down
14 changes: 14 additions & 0 deletions packages/web/src/booking/public-booking.format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
formatBookingMonthKey,
formatDurationMinutes,
getPublicBookingMonthWindow,
isBookingDateKey,
isBookingMonthAvailable,
isBookingMonthKey,
listBookingAvailableDateKeysInMonth,
listBookingAvailableDayKeys,
listBookingMonthGridWeeks,
Expand Down Expand Up @@ -233,3 +235,15 @@ describe("formatDurationMinutes", () => {
expect(formatDurationMinutes(120)).toBe("2 hours");
});
});

describe("booking key validators", () => {
it("accepts YYYY-MM and YYYY-MM-DD keys", () => {
expect(isBookingMonthKey("2026-09")).toBe(true);
expect(isBookingDateKey("2026-09-07")).toBe(true);
});

it("rejects garbage that the URL search used to drop", () => {
expect(isBookingMonthKey("next-month")).toBe(false);
expect(isBookingDateKey("2026-9-7")).toBe(false);
});
});
9 changes: 8 additions & 1 deletion packages/web/src/booking/public-booking.format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import {
import dayjs, { type Dayjs } from "@core/util/date/dayjs";

const MONTH_KEY_PATTERN = /^\d{4}-\d{2}$/;
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

export const isBookingMonthKey = (value: string): boolean =>
MONTH_KEY_PATTERN.test(value);

export const isBookingDateKey = (value: string): boolean =>
DATE_KEY_PATTERN.test(value);

export function formatBookingMonthKey(
instant: Date | string | Dayjs,
Expand Down Expand Up @@ -44,7 +51,7 @@ function parseBookingMonthStart(
monthKey: string,
timeZone: string,
): Dayjs | null {
if (!MONTH_KEY_PATTERN.test(monthKey)) {
if (!isBookingMonthKey(monthKey)) {
return null;
}
const monthStart = dayjs.tz(`${monthKey}-01`, timeZone).startOf("month");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ export function useUpNextEvent() {
[sourceEvent],
);

const conferenceUrl =
sourceEvent?.content.kind === "details"
? sourceEvent.content.conference?.url
: undefined;
const conferenceUrl = upNext?.conference?.url;

return { now, openEventDetails, upNext, conferenceUrl, isCurrentEvent };
}
Loading