Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8b6db5d
Merge pull request #1100 from Practitionist/prod
teetangh Aug 3, 2026
c403946
perf: cut dashboard nav TTFB via request-memoized session and slim re…
teetangh Aug 3, 2026
1c1d591
perf: stream consultant home behind Suspense boundaries (#1102)
teetangh Aug 3, 2026
8ba7e43
perf: restore dashboard SSR by scoping the Stream SDK contexts (#1103)
teetangh Aug 3, 2026
8fdcf78
perf: restore dashboard SSR by seeding the layout query and its userI…
teetangh Aug 3, 2026
1990891
perf: resolve org scope from server facts so prefetch keys match (#1108)
teetangh Aug 3, 2026
416344a
perf: trim optimizePackageImports to packages Next does not already h…
teetangh Aug 3, 2026
5bd3d68
refactor: role-keyed onboarding step registry (#1111)
teetangh Aug 3, 2026
bf1073e
fix: harden event-id validation and restore reschedule heatmap paint …
teetangh Aug 9, 2026
902f470
fix(dashboard): stop infinite white-space scroll past role shells (#1…
teetangh Aug 9, 2026
a6d0c82
perf: seed the org dashboard's role query so it stops SSR-ing nothing…
teetangh Aug 9, 2026
36d69a1
perf: real ISR for the public routes, with a build-phase guard (#1110)
teetangh Aug 9, 2026
645f81a
perf(navbar): paint the remembered auth shape before the session reso…
teetangh Aug 9, 2026
1fa94c1
perf: decompose the cold-start TTFB, and share the Netlify caching sk…
teetangh Aug 9, 2026
3970d61
fix: stop caching degraded renders, and correct the root cause of the…
teetangh Aug 9, 2026
931f4e4
fix: restore production server diagnostics and stop paying for guaran…
teetangh Aug 10, 2026
75de05b
fix: report the silent swallows that lose money, access and availabil…
teetangh Aug 10, 2026
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
242 changes: 242 additions & 0 deletions .claude/skills/nextjs-netlify-caching/SKILL.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .claude/skills/prisma-seed-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Keep `prisma/seed.ts` and `prisma/seedFiles/` compiling against the current sche
- **The configured DB is shared.** `DATABASE_URL`/`DIRECT_URL` in `.env` point at the remote Supabase pooler — NOT a local throwaway. Never run `prisma migrate reset`, `prisma db push --force-reset`, or a seed against it without the user explicitly confirming the target DB is disposable. Before any destructive command, print the masked host (`grep '^DATABASE_URL' .env | sed -E 's|//[^@]*@|//***@|'`) and ask. Prefer a Supabase branch DB or a local stack for seed runs.
- **The seed entry point is `npm run db:seed`** (`npx tsx prisma/seed.ts`). There is **no** `prisma.seed` key in package.json, so `npx prisma db seed` fails — don't use it. Sizes: `db:seed:small` / `db:seed:medium` / `db:seed:large` (SEED_MODE, parsed in `prisma/seedFiles/config.ts`); edge-case data: `db:seed:validation`.
- **No faker.** The suite uses its own helpers in `prisma/seedFiles/utils.ts` (random selection, date spreads, weighted choices) and quantity knobs in `config.ts`. Extend those; don't add a dependency.
- **Never set human-readable primary keys** on `Appointment`, `Consultation`, `Subscription`, `Webinar`, or `Class` for rows that hit real allocate/validate APIs. Those routes validate UUID/CUID via `eventIdSchema` / `isEventIdFormat` — strings like `mock0801-appt-pending` or `appt-1` 400 on save. Always omit `id` and let Prisma generate `@default(uuid())` / `@default(cuid())`. Unit-test mocks that never call those routes may still use readable ids.
- **Schema conventions** (if the task includes schema edits): enums are declared *below* the model(s) that use them; no backfill migrations (a pre-MVP DB reset is planned); comments are terse and explain *why*, referencing issues as `#N`.
- **Don't renumber existing modules.** New models get a new file in the next number band; related sub-entities share the number with a letter suffix (`15a-`, `15b-`).

Expand Down
57 changes: 57 additions & 0 deletions __tests__/booking-algorithm/event-id-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Allocate/validate routes reject non-UUID/CUID event ids via eventIdSchema.
* isEventIdFormat is the shared SSR/client gate so mock PKs fail closed
* before the Zod 400 toast.
*/

import {
EVENT_ID_INVALID_MESSAGE,
eventIdSchema,
isEventIdFormat,
} from "@/schemas/slotAllocation/validationSchemas";

describe("isEventIdFormat", () => {
it("accepts a UUID", () => {
expect(isEventIdFormat("329c0b89-0648-4f8e-82e4-25811cdea440")).toBe(true);
});

it("accepts a CUIDv1-shaped id", () => {
// 'c' + 24 alphanumeric = 25 chars
expect(isEventIdFormat("clxxxxxxxxxxxxxxxxxxxxxxx")).toBe(true);
});

it("accepts a CUIDv2-shaped id (24 chars)", () => {
expect(isEventIdFormat("a1b2c3d4e5f6g7h8i9j0k1l2")).toBe(true);
});

it("rejects mock0801-appt-pending", () => {
expect(isEventIdFormat("mock0801-appt-pending")).toBe(false);
});

it("rejects empty / nullish", () => {
expect(isEventIdFormat("")).toBe(false);
expect(isEventIdFormat(null)).toBe(false);
expect(isEventIdFormat(undefined)).toBe(false);
});

it("rejects short human-readable fixture ids", () => {
expect(isEventIdFormat("appt-1")).toBe(false);
expect(isEventIdFormat("consultation-1")).toBe(false);
});
});

describe("eventIdSchema", () => {
it("parses a valid UUID", () => {
expect(eventIdSchema.parse("329c0b89-0648-4f8e-82e4-25811cdea440")).toBe(
"329c0b89-0648-4f8e-82e4-25811cdea440",
);
});

it("surfaces the invalid-format message for mock ids", () => {
const result = eventIdSchema.safeParse("mock0801-appt-pending");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(EVENT_ID_INVALID_MESSAGE);

Check failure on line 54 in __tests__/booking-algorithm/event-id-format.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` conditionally`
}
});
});
72 changes: 72 additions & 0 deletions __tests__/booking-algorithm/org-reschedule-affordance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Org appointment detail reuses the consultee adapter but its URL has orgId,
* not consulteeId. Without an override the Reschedule overflow never appears.
*/

import { readFileSync } from "node:fs";
import path from "node:path";

const root = process.cwd();

describe("org appointment detail Reschedule affordance", () => {
it("passes SSR consulteeId into the shared adapter", () => {
const client = readFileSync(
path.join(
root,
"app/dashboard/organization/[orgId]/appointments/[appointmentId]/DetailPageClient.tsx",
),
"utf8",
);
const page = readFileSync(
path.join(
root,
"app/dashboard/organization/[orgId]/appointments/[appointmentId]/page.tsx",
),
"utf8",
);
expect(client).toContain("useConsulteeAppointmentsAdapter({ consulteeId })");
expect(page).toContain("consulteeId={profile.id}");
// Stay under org for detail navigation; reschedule still deep-links out.
expect(client).toContain("detailHref");
expect(client).toContain("/dashboard/organization/${orgId}/appointments/");
});

it("adapter accepts an optional consulteeId and falls back to params/session", () => {
const src = readFileSync(
path.join(
root,
"components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx",
),
"utf8",
);
expect(src).toContain("options?.consulteeId");
expect(src).toContain("session?.user?.consulteeProfileId");
expect(src).toContain(
"`/dashboard/consultee/${consulteeId}/appointments/${vm.appointmentId}/reschedule`",
);
});
});

describe("consultant reschedule legend wiring", () => {
it("SlotPicker sets showConsultantLegend for consultant propose, not consultee", () => {
const src = readFileSync(
path.join(root, "components/scheduling/SlotPicker.tsx"),
"utf8",
);
expect(src).toContain('policy.kind === "RESCHEDULE_CONSULTANT"');
expect(src).toContain("showConsultantLegend");
// Must not key the consultant legend solely on eventId (consultee also has it).
expect(src).not.toMatch(
/showConsultantLegend=\{\s*Boolean\(subject\.eventId\)\s*\}/,
);
});

it("SafeUnifiedCalendar honours showConsultantLegend over mode alone", () => {
const src = readFileSync(
path.join(root, "components/scheduling/SafeUnifiedCalendar.tsx"),
"utf8",
);
expect(src).toContain("showConsultantLegend");
expect(src).toContain("CONSULTANT_LEGEND_KEYS");
});
});
88 changes: 88 additions & 0 deletions __tests__/booking-algorithm/reschedule-heatmap-algorithm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Regression pins for the reschedule/timings heatmap selection rules:
* contiguous N×30 groups, same-day (ADR B9), and status precedence so
* "Being moved" does not paint as a foreign "Booked".
*/

import "./setup";

import {
findConsecutiveGroupContaining,
isCompleteCall,
} from "@/lib/scheduling/slotSelectionValidation";
import { resolveSlotStatusKey } from "@/lib/scheduling/slot-status-tokens";
import type { TimeSlot } from "@/hooks/scheduling/useCalendarData";

const slot = (hour: number, minute = 0): TimeSlot => {
const start = new Date(Date.UTC(2026, 7, 7, hour, minute, 0));
return {
startTime: start,
endTime: new Date(start.getTime() + 30 * 60 * 1000),
isAvailable: true,
isBooked: false,
};
};

describe("contiguous session groups (heatmap selection)", () => {
it("treats four half-hour atoms as one complete 2h call", () => {
const day = [slot(15, 30), slot(16, 0), slot(16, 30), slot(17, 0)];
expect(isCompleteCall(day, 4)).toBe(true);
});

it("rejects a gap inside the run", () => {
const day = [slot(15, 30), slot(16, 0), slot(17, 0), slot(17, 30)];
expect(isCompleteCall(day, 4)).toBe(false);
});

it("deselection expands to the full consecutive group containing the click", () => {
const day = [slot(15, 30), slot(16, 0), slot(16, 30), slot(17, 0)];
const group = findConsecutiveGroupContaining(day[2], day);
expect(group).toHaveLength(4);
expect(group[0].startTime.toISOString()).toBe(day[0].startTime.toISOString());
expect(group[3].startTime.toISOString()).toBe(day[3].startTime.toISOString());
});
});

describe("status precedence for Being moved vs Booked vs Selected", () => {
const base = {
isSelected: false,
isThisEventSlot: false,
isRescheduling: false,
isBookedForDisplay: false,
isPartiallyBooked: false,
isAvailable: true,
isInPast: false,
};

it("Selected wins over Being moved and This booking", () => {
expect(
resolveSlotStatusKey({
...base,
isSelected: true,
isRescheduling: true,
isThisEventSlot: true,
}),
).toBe("selected");
});

it("Being moved wins over foreign Booked paint", () => {
// Tentative slots of THIS event must not fall through to fullyBooked.
expect(
resolveSlotStatusKey({
...base,
isRescheduling: true,
isBookedForDisplay: true,
}),
).toBe("rescheduling");
});

it("This booking wins over Booked", () => {
expect(
resolveSlotStatusKey({
...base,
isThisEventSlot: true,
isBookedForDisplay: true,
}),
).toBe("thisEvent");
});
});
134 changes: 134 additions & 0 deletions __tests__/booking-algorithm/reschedule-subject-event-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Reschedule heatmaps need the real event id so fetchEventSlots can paint
* "This booking" / "Being moved". Omitting it left those cells looking like
* foreign bookings.
*/

import type { TAppointmentDetail } from "@/lib/data/appointment-detail";
import { buildRescheduleSubject } from "@/lib/scheduling/slot-picker-subject";

const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const futureEnd = new Date(futureStart.getTime() + 60 * 60 * 1000);

function consultationDetail(
overrides: Partial<{
consultationId: string;
consultantProfileId: string;
}> = {},
): TAppointmentDetail {
const consultationId =
overrides.consultationId ?? "329c0b89-0648-4f8e-82e4-25811cdea440";
const consultantProfileId = overrides.consultantProfileId ?? "cp-uuid-1";
return {
appointment: {
id: "appt-uuid-1",
appointmentType: "CONSULTATION",
slotsOfAppointment: [
{
id: "slot-1",
startsAt: futureStart,
endsAt: futureEnd,
isTentative: false,
completionStatus: "SCHEDULED",
appointmentId: "appt-uuid-1",
user: [],
meetingSession: null,
},
],
consultation: {
id: consultationId,
requestedBy: {
id: "consultee-1",
userId: "user-consultee-1",
user: { id: "user-consultee-1", name: "Buyer", image: null },
},
consultationPlan: {
title: "Career chat",
durationInHours: 2,
consultantProfile: {
id: consultantProfileId,
userId: "user-consultant-1",
user: { id: "user-consultant-1", name: "Expert", image: null },
},
},
},
subscription: null,
webinar: null,
class: null,
trialSession: null,
},
siblings: [],
} as unknown as TAppointmentDetail;
}

function subscriptionDetail(): TAppointmentDetail {
return {
appointment: {
id: "appt-sub-1",
appointmentType: "SUBSCRIPTION",
slotsOfAppointment: [
{
id: "slot-s1",
startsAt: futureStart,
endsAt: futureEnd,
isTentative: true,
completionStatus: "SCHEDULED",
appointmentId: "appt-sub-1",
user: [],
meetingSession: null,
},
],
consultation: null,
subscription: {
id: "clxxxxxxxxxxxxxxxxxxxxxxx",
requestedBy: {
id: "consultee-1",
userId: "user-consultee-1",
user: { id: "user-consultee-1", name: "Buyer", image: null },
},
subscriptionPlan: {
title: "Weekly coaching",
sessionDurationInHours: 1,
consultantProfile: {
id: "cp-uuid-1",
userId: "user-consultant-1",
user: { id: "user-consultant-1", name: "Expert", image: null },
},
},
},
webinar: null,
class: null,
trialSession: null,
},
siblings: [],
} as unknown as TAppointmentDetail;
}

describe("buildRescheduleSubject event context", () => {
it("passes consultation eventId + eventType for paint", () => {
const subject = buildRescheduleSubject(consultationDetail());
expect(subject).not.toBeNull();
expect(subject!.subject.eventType).toBe("consultation");
expect(subject!.subject.eventId).toBe(
"329c0b89-0648-4f8e-82e4-25811cdea440",
);
expect(subject!.subject.durationInHours).toBe(2);
});

it("passes subscription eventId + eventType and hasReleasedSlots when tentative", () => {
const subject = buildRescheduleSubject(subscriptionDetail());
expect(subject).not.toBeNull();
expect(subject!.subject.eventType).toBe("subscription");
expect(subject!.subject.eventId).toBe("clxxxxxxxxxxxxxxxxxxxxxxx");
expect(subject!.subject.hasReleasedSlots).toBe(true);
});

it("returns null when there is no consultant to draw a grid for", () => {
const detail = consultationDetail({ consultantProfileId: "" });
// Empty consultant id → falsy → null
(detail.appointment.consultation!.consultationPlan!.consultantProfile as {
id: string;
}).id = "";
expect(buildRescheduleSubject(detail)).toBeNull();
});
});
6 changes: 4 additions & 2 deletions __tests__/dashboard/nav-targets-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ describe("org nav targets resolve", () => {
// Guards the list above against drift: if someone adds a nav item and
// forgets to add it here, this fails rather than silently under-testing.
const layout = readFileSync(
join(APP, "organization/[orgId]/layout.tsx"),
join(APP, "organization/[orgId]/OrgDashboardShell.tsx"),
"utf8",
) as string;
// Nav lives in OrgDashboardShell; layout.tsx is the server wrapper that seeds
// the org-details query. Same split org-workspace already uses.
// Loose on purpose: items are written both multi-line and inline
// (`{ name: "Overview", icon: Home, path: "home" }`), and MOBILE_TABS
// repeats a subset — dedupe handles the overlap.
Expand Down Expand Up @@ -163,7 +165,7 @@ describe("no redundant group nesting", () => {
it.each([
["consultant", "consultant/[consultantId]/layout.tsx"],
["consultee", "consultee/[consulteeId]/layout.tsx"],
["organization", "organization/[orgId]/layout.tsx"],
["organization", "organization/[orgId]/OrgDashboardShell.tsx"],
])("%s sidebar has no label that equals a lone item name", (_name, rel) => {
const src = readFileSync(join(APP, rel), "utf8");

Expand Down
Loading
Loading