From 61bc0d901dadfc810b3c1a66f645d81f297adffa Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:39:06 +0530 Subject: [PATCH 01/15] fix(reschedule): auto-confirm writes nothing until the allocation commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consultee's proposal could leave a booking permanently un-reschedulable. The old sequence stamped the proposed times onto the released slot rows, ran the allocator in "requested" mode so it would read them back, and restored the originals from an in-memory snapshot if validation rejected them. Two ways that broke. The finalize step runs in its own transaction. Failing it left the booking confirmed at the new times while the proposal stayed PENDING_REVIEW with openForAppointmentId still set — and that nullable-unique then blocked every future reschedule of the appointment. Nothing surfaced it; the booking just quietly stopped being movable. Worse, the originals only ever existed in RAM. A crash between the stamp and the restore left the rows holding proposed times with no allocation to justify them and no way back. Manual mode already accepts explicit times, and on a reschedule deleteExistingAppointments removes only TENTATIVE slots — exactly the released ones — so confirmed sessions elsewhere in the booking survive. Handing the allocator the times directly means nothing is written until it commits, so neither failure has anywhere to happen. That deletes the stamp transaction, the restore transaction, the snapshot and the one-to-one pairing: 97 lines out, 33 in. Pairing only ever made sense while each proposed time was written onto a specific row; the allocator takes them as a set, so two non-contiguous proposed cells now fail validation instead of passing as one moved session. Manual mode shards its Redis lock by day (#860) so same-consultant allocations on different days run in parallel, with #440's GiST constraint backstopping overlap. But GiST sees overlaps, not counts — two sharded confirmations could each pass a per-week cap on the same stale read and take a 4-session week to 5. These times were not picked per-day by a human, so auto-confirm asks for the consultant-wide lock via a new `wideLock` flag. The consultant's own UI keeps its sharding. Co-Authored-By: Claude Opus 5 (1M context) --- lib/booking/reschedule-auto-confirm.ts | 130 +++++------------- utils/slotAllocation/SlotAllocationService.ts | 21 ++- utils/slotAllocation/types.ts | 12 ++ 3 files changed, 60 insertions(+), 103 deletions(-) diff --git a/lib/booking/reschedule-auto-confirm.ts b/lib/booking/reschedule-auto-confirm.ts index cc114a715..74987f60e 100644 --- a/lib/booking/reschedule-auto-confirm.ts +++ b/lib/booking/reschedule-auto-confirm.ts @@ -60,105 +60,43 @@ export async function tryAutoConfirmProposal( if (!mayAutoConfirm(request.initiatorRole)) { return { confirmed: false, reason: "CONSULTANT_INITIATED" }; } - if (request.proposedSlots.length !== request.releasedSlotIds.length) { - return { confirmed: false, reason: "COUNT_MISMATCH" }; - } - - // Snapshot the originals in memory so a rejected proposal can be put back - // exactly as it was. A reschedule never rewrites startsAt, so these rows still - // hold the times the consultee is trying to move away from. - const originals = await prisma.slotOfAppointment.findMany({ - where: { id: { in: request.releasedSlotIds } }, - orderBy: { startsAt: "asc" }, - select: { - id: true, - startsAt: true, - endsAt: true, - isTentative: true, - completionStatus: true, - }, - }); - - if (originals.length !== request.proposedSlots.length) { - return { confirmed: false, reason: "SLOTS_MISSING" }; - } - - // Pair by chronological order: the Nth-earliest released session takes the - // Nth-earliest proposed time. - const pairs = originals.map((slot, i) => ({ - slot, - proposed: request.proposedSlots[i], - })); - - try { - await prisma.$transaction(async (tx) => { - for (const { slot, proposed } of pairs) { - await tx.slotOfAppointment.update({ - where: { id: slot.id }, - data: { - startsAt: proposed.startsAt, - endsAt: proposed.endsAt, - // Back to SCHEDULED so the requested-mode guard (which refuses to - // reuse times that are still awaiting reschedule) does not block the - // very times we just wrote. Still tentative — allocation confirms. - completionStatus: "SCHEDULED", - }, - }); - } - }); - } catch (err) { - reportSentryError(err, { - subsystem: "bookings", - op: "reschedule-auto-confirm", - extra: { - phase: "stamp", - rescheduleRequestId, - releasedSlotIds: request.releasedSlotIds, - }, - }); - throw err; - } + // Deliberately NOT comparing proposed count to released count. The allocator + // is handed the times as a set and validates them as one; pairing them + // one-to-one only ever made sense while we were writing each proposed time + // onto a specific released row, which we no longer do. const result = await SlotAllocationService.allocate({ eventType, eventId, - mode: "requested", + // The proposed times go STRAIGHT to the allocator. Nothing is written until + // it commits. + // + // This used to stamp the times onto the released slot rows, run the + // allocator in "requested" mode so it would read them back, and restore the + // originals from an in-memory snapshot if validation rejected them. Two + // ways that broke: the finalize step below ran in its own transaction, so + // failing it left a confirmed booking whose proposal never closed — + // openForAppointmentId still set, blocking every later reschedule of that + // appointment. And a crash anywhere in between left the rows holding + // proposed times with the originals only ever in RAM, unrecoverable. + // + // Manual mode already accepts explicit times, and on a reschedule + // deleteExistingAppointments removes only TENTATIVE slots — which is + // exactly the released ones — so confirmed sessions elsewhere in the + // booking survive untouched. + mode: "manual", + slots: request.proposedSlots.map((p) => p.startsAt.toISOString()), + // Consultant-wide lock: these times were not picked per-day by a human, so + // the day-sharded manual key would let two concurrent confirmations each + // pass the per-week cap on a stale count (#860 shards for throughput; GiST + // backstops overlap, but a cap is a count, not an overlap). + wideLock: true, }); if (!result.success) { - // Put the booking back the way the consultee left it and let the consultant - // decide. Nothing was confirmed, so there is no half-applied state — unless - // this restore itself fails, in which case the slots are stuck holding the - // rejected proposed times with no pending allocation to justify them. That - // is a corrupted booking with no other trace, so it always reports. - try { - await prisma.$transaction(async (tx) => { - for (const { slot } of pairs) { - await tx.slotOfAppointment.update({ - where: { id: slot.id }, - data: { - startsAt: slot.startsAt, - endsAt: slot.endsAt, - isTentative: slot.isTentative, - completionStatus: slot.completionStatus, - }, - }); - } - }); - } catch (err) { - reportSentryError(err, { - subsystem: "bookings", - op: "reschedule-auto-confirm", - level: "fatal", - extra: { - phase: "restore", - rescheduleRequestId, - releasedSlotIds: request.releasedSlotIds, - originalSlotIds: pairs.map((p) => p.slot.id), - }, - }); - throw err; - } + // Nothing was written, so there is nothing to undo. The released slots are + // untouched and still carry their original times; the request stays open + // for the consultant to answer. return { confirmed: false, reason: result.errorCode ?? "VALIDATION_FAILED", @@ -178,11 +116,9 @@ export async function tryAutoConfirmProposal( }); } catch (err) { // A lost race (the proposal was answered or expired concurrently) is the - // ordinary outcome this CAS guard models by throwing — not an error, but - // still reported at info/expected so its rate is visible (full-coverage - // policy). Anything else means the reallocation above already succeeded - // but the proposal's own bookkeeping never caught up, which is worth - // knowing at the default fault level. + // ordinary outcome this CAS guard models by throwing. Anything else means + // the reallocation above already succeeded but the proposal's bookkeeping + // never caught up — the booking is correct, its paperwork is not. const isLostRace = err instanceof IllegalTransitionError; reportSentryError(err, { subsystem: "bookings", diff --git a/utils/slotAllocation/SlotAllocationService.ts b/utils/slotAllocation/SlotAllocationService.ts index 0df3830c0..3d6016e5e 100644 --- a/utils/slotAllocation/SlotAllocationService.ts +++ b/utils/slotAllocation/SlotAllocationService.ts @@ -122,6 +122,7 @@ export class SlotAllocationService { request.slots, request.idempotencyKey, request.initialAllocation, + request.wideLock, ); case "requested": @@ -829,6 +830,7 @@ export class SlotAllocationService { slotStrings: string[], idempotencyKey?: string, initialAllocation?: boolean, + wideLock?: boolean, ): Promise { // #837 — return the prior batch on a double-submit before doing any work. const replay = await this.findIdempotentAllocation( @@ -858,12 +860,19 @@ export class SlotAllocationService { // #860 — shard the lock by the earliest target day so allocations for // different days don't serialize; same-day (the actual duplicate risk) // still shares the key. #440's GiST constraint backstops cross-day overlap. - const lockScope = slotStrings - .map((s) => new Date(s)) - .filter((d) => !Number.isNaN(d.getTime())) - .sort((a, b) => a.getTime() - b.getTime())[0] - ?.toISOString() - .slice(0, 10); + // + // wideLock opts out of the sharding: the caller is placing times nobody + // picked per-day, so parallel same-consultant allocations could each pass + // the per-week cap check on a stale count. GiST cannot see a cap, only an + // overlap. + const lockScope = wideLock + ? undefined + : slotStrings + .map((s) => new Date(s)) + .filter((d) => !Number.isNaN(d.getTime())) + .sort((a, b) => a.getTime() - b.getTime())[0] + ?.toISOString() + .slice(0, 10); const lock = await lockAutoAllocate(consultantProfileId, lockScope); // #898 follow-up — serialize on the consultee too (consultant → consultee // lock order) so one person can't be booked with two consultants at once. diff --git a/utils/slotAllocation/types.ts b/utils/slotAllocation/types.ts index d7389bba2..777c94c31 100644 --- a/utils/slotAllocation/types.ts +++ b/utils/slotAllocation/types.ts @@ -46,6 +46,18 @@ export interface AllocationRequest { // Redis lock keys (#860), so a cross-mode race from two tabs otherwise ends // in the manual path silently deleting the winner's allocation. initialAllocation?: boolean; + /** + * Manual mode only. When true the Redis lock is taken consultant-WIDE rather + * than sharded by the target day. + * + * #860 shards the manual key so allocations on different days for one + * consultant run in parallel, with #440's GiST constraint backstopping + * overlap. But GiST only prevents time OVERLAP — per-day and per-week caps + * are validated by COUNTING, so two sharded allocations can each read the + * same count and both add, taking a 4-session week to 5. Callers that place + * times a human did not pick per-day (auto-confirm) must set this. + */ + wideLock?: boolean; /** * Consultant's explicit acceptance of times outside their own published * availability. Routes must only set this for the consultant or a privileged From 079077a8fb488a306306524727359719907cdcd4 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:39:06 +0530 Subject: [PATCH 02/15] feat(reschedule): the initiator can withdraw, and withdrawing restores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to take back a reschedule you had asked for. The other party could Decline; the person who opened it could only wait for expiry. Withdrawal is the initiator's alone, whichever side they are on. The recipient already has Decline, which ends the same request with a different meaning — giving them this too would be a second Decline wearing a friendlier word. The two outcomes differ in what happens to the released slots, which is why WITHDRAWN is its own status rather than a reuse of DECLINED: withdraw the person who asked no longer wants it, so nothing should have moved — the booking returns to its original times decline the consultee still wants to move and the consultant has not agreed a time, so the slots stay released for their queue expiry same as decline Collapsing them would leave the audit trail unable to say who ended it, and force every consumer to infer intent from resolvedById. Restoring is cheap for one reason worth stating: a reschedule never rewrites startsAt. The released rows still carry their original times, so this flips two flags rather than replaying data from a snapshot. WITHDRAWN is terminal, which is what releases openForAppointmentId — miss that and a withdrawn request holds the nullable-unique forever, blocking every later reschedule of the booking. There is a test for exactly that. The CAS on the transition is the concurrency guard: if the other party answered while this was in flight it matches zero rows and throws, rather than un-releasing slots a concurrent accept has already re-confirmed. Also adds RescheduleRequest.resolutionNote — the answering party's reply. `reason` runs consultee to consultant; this is the return leg, and it is the difference between "your sessions moved" and "moved to Thursdays, I have blocked Tuesdays from September". Both schema changes are additive and already applied; migrate diff reports no drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../reschedule-withdraw.test.ts | 54 +++++++++ .../reschedule/withdraw/route.ts | 80 ++++++++++++++ lib/booking/reschedule-withdraw.ts | 104 ++++++++++++++++++ lib/booking/transitions.ts | 2 + prisma/schema.prisma | 10 ++ 5 files changed, 250 insertions(+) create mode 100644 __tests__/booking-algorithm/reschedule-withdraw.test.ts create mode 100644 app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts create mode 100644 lib/booking/reschedule-withdraw.ts diff --git a/__tests__/booking-algorithm/reschedule-withdraw.test.ts b/__tests__/booking-algorithm/reschedule-withdraw.test.ts new file mode 100644 index 000000000..8bf2569a3 --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-withdraw.test.ts @@ -0,0 +1,54 @@ +/** + * Withdrawal semantics. + * + * The asymmetry pinned here is deliberate and easy to "simplify" away later: + * a WITHDRAWAL restores the booking, a DECLINE does not. Both end the same + * request, but they mean opposite things — the initiator no longer wants the + * move, versus the other party not agreeing to it. Collapsing them would leave + * a consultee who was declined silently back where they started with no + * pending request, and would make the audit trail unable to say who ended it. + */ + +import { + RESCHEDULE_ALLOWED_FROM, + RESCHEDULE_OPEN_STATUSES, + RESCHEDULE_TERMINAL_STATUSES, +} from "@/lib/booking/transitions"; + +describe("WITHDRAWN is a first-class terminal state", () => { + it("is terminal, so it releases the appointment's reschedule lock", () => { + // transitionRescheduleRequest clears openForAppointmentId for any terminal + // status. Miss this and a withdrawn request keeps the nullable-unique held, + // permanently blocking every later reschedule of that booking. + expect(RESCHEDULE_TERMINAL_STATUSES).toContain("WITHDRAWN"); + }); + + it("is not an open state", () => { + expect(RESCHEDULE_OPEN_STATUSES).not.toContain("WITHDRAWN"); + }); + + it("is reachable only from a request still awaiting an answer", () => { + expect(RESCHEDULE_ALLOWED_FROM.WITHDRAWN).toEqual([ + "PENDING_REVIEW", + "COUNTERED", + ]); + }); + + it("cannot be reached from a request that already resolved", () => { + // The CAS guard is what stops a withdrawal un-releasing slots that a + // concurrent accept has already re-confirmed. + for (const settled of ["ACCEPTED", "AUTO_ACCEPTED", "DECLINED", "EXPIRED"]) { + expect(RESCHEDULE_ALLOWED_FROM.WITHDRAWN).not.toContain(settled); + } + }); + + it("stays distinct from DECLINED", () => { + // Same allowed-from, deliberately different status: they resolve the + // released slots differently, so they must not be collapsed into one. + expect(RESCHEDULE_ALLOWED_FROM.DECLINED).toEqual( + RESCHEDULE_ALLOWED_FROM.WITHDRAWN, + ); + expect(RESCHEDULE_TERMINAL_STATUSES).toContain("DECLINED"); + expect(RESCHEDULE_TERMINAL_STATUSES).toContain("WITHDRAWN"); + }); +}); diff --git a/app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts b/app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts new file mode 100644 index 000000000..51dcf3c94 --- /dev/null +++ b/app/api/appointments/[appointmentId]/reschedule/withdraw/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth-server"; +import prisma from "@/lib/prisma"; +import { apiError } from "@/lib/errors"; +import { withdrawRescheduleRequest } from "@/lib/booking/reschedule-withdraw"; +import { RESCHEDULE_OPEN_STATUSES } from "@/lib/booking/transitions"; + +/** + * POST /api/appointments/[appointmentId]/reschedule/withdraw + * + * The initiator takes back their own open reschedule. The other party has + * Decline, which ends the same request with a different meaning: a withdrawal + * restores the booking, a decline leaves the slots released for the consultant + * to re-place. + */ +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ appointmentId: string }> }, +) { + try { + const { appointmentId } = await params; + const session = await getSession(true); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Found via the appointment rather than by request id: the caller is acting + // on a booking they can see, and openForAppointmentId already guarantees at + // most one live reschedule per appointment, so there is nothing to choose + // between. + const open = await prisma.rescheduleRequest.findFirst({ + where: { + appointmentId, + status: { in: RESCHEDULE_OPEN_STATUSES }, + }, + select: { id: true, initiatedById: true }, + }); + + if (!open) { + return NextResponse.json( + { error: "No open reschedule request for this booking." }, + { status: 404 }, + ); + } + + // Authorization is identity, not role: whoever opened it may take it back, + // whichever side of the booking they are on. + if (open.initiatedById !== session.user.id) { + return NextResponse.json( + { + error: + "Only the person who requested this reschedule can withdraw it.", + code: "NOT_INITIATOR", + }, + { status: 403 }, + ); + } + + const result = await withdrawRescheduleRequest({ + rescheduleRequestId: open.id, + withdrawnById: session.user.id, + }); + + if (!result.withdrawn) { + // PROPOSAL_NOT_OPEN means the other party answered while this request was + // in flight — a 409, not a failure of the caller's input. + return NextResponse.json( + { error: "This reschedule can no longer be withdrawn.", code: result.reason }, + { status: result.reason === "NOT_INITIATOR" ? 403 : 409 }, + ); + } + + return NextResponse.json({ + withdrawn: true, + message: "Your reschedule request has been withdrawn.", + }); + } catch (error) { + return apiError({ tag: "[Reschedule.Withdraw]", error }); + } +} diff --git a/lib/booking/reschedule-withdraw.ts b/lib/booking/reschedule-withdraw.ts new file mode 100644 index 000000000..d1a74cdc7 --- /dev/null +++ b/lib/booking/reschedule-withdraw.ts @@ -0,0 +1,104 @@ +import prisma from "@/lib/prisma"; +import { reportSentryError } from "@/lib/observability/report"; +import { + RESCHEDULE_OPEN_STATUSES, + transitionRescheduleRequest, +} from "@/lib/booking/transitions"; + +/** + * The initiator takes their own reschedule back, and the booking returns to + * exactly what it was. + * + * ONLY withdrawal restores. Decline and expiry deliberately leave the slots + * released: in both of those the consultee still wants to move and the + * consultant simply has not agreed a time, so the booking belongs in their + * allocate queue. A withdrawal is the opposite — the person who asked no + * longer wants it, so nothing should have moved. + * + * This is cheap for one reason worth stating: a reschedule never rewrites + * `startsAt`. The released rows still carry their original times, so restoring + * is flipping two flags, not replaying data from a snapshot. (Auto-confirm is + * the only path that ever wrote proposed times onto rows, and it no longer + * does — it hands them to the allocator instead.) + */ +export async function withdrawRescheduleRequest(args: { + rescheduleRequestId: string; + /** Must be the initiator. The caller is responsible for proving that. */ + withdrawnById: string; +}): Promise<{ withdrawn: boolean; reason?: string }> { + const { rescheduleRequestId, withdrawnById } = args; + + const request = await prisma.rescheduleRequest.findUnique({ + where: { id: rescheduleRequestId }, + select: { + id: true, + status: true, + initiatedById: true, + releasedSlotIds: true, + appointmentId: true, + appointment: { + select: { + consultationId: true, + subscriptionId: true, + }, + }, + }, + }); + + if (!request) return { withdrawn: false, reason: "PROPOSAL_NOT_FOUND" }; + + // Withdrawal is the initiator's alone. The other side already has Decline, + // which ends the same request with a different meaning and a different + // outcome for the slots — giving them this too would just be a second + // Decline wearing a friendlier word. + if (request.initiatedById !== withdrawnById) { + return { withdrawn: false, reason: "NOT_INITIATOR" }; + } + if (!RESCHEDULE_OPEN_STATUSES.includes(request.status)) { + return { withdrawn: false, reason: "PROPOSAL_NOT_OPEN" }; + } + + try { + await prisma.$transaction(async (tx) => { + // The CAS is the guard: if the other party answered while we were + // deciding, this matches zero rows and throws rather than un-releasing + // slots that a concurrent accept has already re-confirmed. + await transitionRescheduleRequest(tx, { + where: { id: request.id }, + to: "WITHDRAWN", + data: { resolvedById: withdrawnById }, + }); + + // Reverses exactly what the reschedule did to these rows. + await tx.slotOfAppointment.updateMany({ + where: { + id: { in: request.releasedSlotIds }, + completionStatus: "RESCHEDULED", + }, + data: { isTentative: false, completionStatus: "SCHEDULED" }, + }); + + // A consultation reschedule sends the booking back to PENDING so it + // re-enters the consultant's queue; withdrawing has to undo that or the + // consultee is left with a confirmed-looking booking still sitting in + // someone's inbox. A subscription is deliberately NOT flipped on + // reschedule (#448 — it has no per-session status), so there is nothing + // to put back there. + if (request.appointment?.consultationId) { + await tx.consultation.updateMany({ + where: { id: request.appointment.consultationId, status: "PENDING" }, + data: { status: "APPROVED" }, + }); + } + }); + } catch (err) { + reportSentryError(err, { + subsystem: "bookings", + op: "reschedule-withdraw", + extra: { rescheduleRequestId, releasedSlotIds: request.releasedSlotIds }, + }); + throw err; + } + + return { withdrawn: true }; +} diff --git a/lib/booking/transitions.ts b/lib/booking/transitions.ts index f943d89fd..77c7c139c 100644 --- a/lib/booking/transitions.ts +++ b/lib/booking/transitions.ts @@ -195,6 +195,7 @@ export const RESCHEDULE_ALLOWED_FROM: Record< COUNTERED: ["PENDING_REVIEW"], ACCEPTED: ["PENDING_REVIEW", "COUNTERED"], DECLINED: ["PENDING_REVIEW", "COUNTERED"], + WITHDRAWN: ["PENDING_REVIEW", "COUNTERED"], EXPIRED: ["PENDING_REVIEW", "COUNTERED"], }; @@ -209,6 +210,7 @@ export const RESCHEDULE_TERMINAL_STATUSES: RescheduleRequestStatus[] = [ "AUTO_ACCEPTED", "ACCEPTED", "DECLINED", + "WITHDRAWN", "EXPIRED", ]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c03065c9a..09695da79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3613,6 +3613,10 @@ model RescheduleRequest { resolvedAt DateTime? @db.Timestamptz resolvedBy User? @relation("RescheduleResolver", fields: [resolvedById], references: [id], onDelete: SetNull) resolvedById String? + /// The answering party's reply. `reason` runs consultee -> consultant; this + /// is the return leg, and it is the difference between "your sessions moved" + /// and "moved to Thursdays, I have blocked Tuesdays from September". + resolutionNote String? /// ADR 19/23 — inherits the appointment's org-ness so notifications route and /// org dashboards scope correctly. @@ -3663,6 +3667,12 @@ enum RescheduleRequestStatus { COUNTERED ACCEPTED DECLINED + /// The INITIATOR took their own request back. Distinct from DECLINED, which + /// is the other party refusing: only a withdrawal restores the booking to its + /// original times, because nothing should have moved if the person who asked + /// no longer wants it. Collapsing the two would make the audit trail unable + /// to say who ended it. + WITHDRAWN /// Lapsed unanswered; the request falls back to consultant allocation. EXPIRED } From d8ac871a284619e6459515a1273ad5fbf0c69830 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:39:28 +0530 Subject: [PATCH 03/15] refactor(reschedule): drop the counter-round, which was never built MAX_PROPOSAL_ROUNDS and mayCounter existed, and the transition map has a PENDING_REVIEW <- COUNTERED edge. But nothing anywhere ever wrote COUNTERED: no route, no component, no job. The round-2 path was specified and never implemented. So this removes dead specification rather than a feature. Propose -> accept or decline is the whole flow, and a decline already falls back to the consultant allocating, so nothing dead-ends without it. The enum value stays. Removing it needs a migration for no benefit, and leaving it documents a path that was considered and rejected rather than forgotten. Co-Authored-By: Claude Opus 5 (1M context) --- .../reschedule-proposals.test.ts | 12 ++----- lib/booking/reschedule-proposals.ts | 34 ++++++++++++------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/__tests__/booking-algorithm/reschedule-proposals.test.ts b/__tests__/booking-algorithm/reschedule-proposals.test.ts index 8f4024f41..5057aea23 100644 --- a/__tests__/booking-algorithm/reschedule-proposals.test.ts +++ b/__tests__/booking-algorithm/reschedule-proposals.test.ts @@ -8,11 +8,9 @@ import "./setup"; import { - MAX_PROPOSAL_ROUNDS, PROPOSAL_MAX_LIFETIME_HOURS, computeProposalExpiry, mayAutoConfirm, - mayCounter, proposalCountMatches, supportsProposals, } from "../../lib/booking/reschedule-proposals"; @@ -90,11 +88,6 @@ describe("scope and shape guards", () => { expect(proposalCountMatches(2, 1)).toBe(false); expect(proposalCountMatches(2, 3)).toBe(false); }); - - it("permits exactly one counter-round", () => { - expect(mayCounter(1)).toBe(true); - expect(mayCounter(MAX_PROPOSAL_ROUNDS)).toBe(false); - }); }); describe("RESCHEDULE_ALLOWED_FROM state machine", () => { @@ -134,9 +127,8 @@ describe("RESCHEDULE_ALLOWED_FROM state machine", () => { describe("allocationRequestSchema carries override", () => { it("no longer strips the field the Override button sends", async () => { - const { allocationRequestSchema } = await import( - "../../schemas/slotAllocation/validationSchemas" - ); + const { allocationRequestSchema } = + await import("../../schemas/slotAllocation/validationSchemas"); // Before this, `override` was absent from the schema and a plain (non- // passthrough) Zod object dropped it, so "Override and Allocate" sent a diff --git a/lib/booking/reschedule-proposals.ts b/lib/booking/reschedule-proposals.ts index 4964c8826..6c02edc12 100644 --- a/lib/booking/reschedule-proposals.ts +++ b/lib/booking/reschedule-proposals.ts @@ -17,10 +17,7 @@ * database; the route owns the writes. */ -import type { - AppointmentsType, - RescheduleInitiatorRole, -} from "@prisma/client"; +import type { AppointmentsType, RescheduleInitiatorRole } from "@prisma/client"; /** Ceiling on how long an unanswered proposal may sit. */ export const PROPOSAL_MAX_LIFETIME_HOURS = 72; @@ -61,7 +58,9 @@ export function computeProposalExpiry( const mustResolveBy = new Date( earliest.getTime() - PROPOSAL_RESOLVE_BEFORE_SESSION_HOURS * HOUR_MS, ); - const lifetimeCap = new Date(now.getTime() + PROPOSAL_MAX_LIFETIME_HOURS * HOUR_MS); + const lifetimeCap = new Date( + now.getTime() + PROPOSAL_MAX_LIFETIME_HOURS * HOUR_MS, + ); const expiry = mustResolveBy < lifetimeCap ? mustResolveBy : lifetimeCap; return expiry <= now ? null : expiry; @@ -75,7 +74,9 @@ export function computeProposalExpiry( * Landing inside published availability and finding both calendars free is * necessary too, but that is the validator's job, not this predicate's. */ -export function mayAutoConfirm(initiatorRole: RescheduleInitiatorRole): boolean { +export function mayAutoConfirm( + initiatorRole: RescheduleInitiatorRole, +): boolean { return initiatorRole === "CONSULTEE"; } @@ -90,7 +91,9 @@ export function supportsProposals( // from the row and may not find one. appointmentType: AppointmentsType | null | undefined, ): boolean { - return appointmentType === "CONSULTATION" || appointmentType === "SUBSCRIPTION"; + return ( + appointmentType === "CONSULTATION" || appointmentType === "SUBSCRIPTION" + ); } /** @@ -104,9 +107,14 @@ export function proposalCountMatches( return releasedSlotCount === proposedSlotCount; } -/** 1 = the opening proposal, 2 = the single permitted counter. */ -export const MAX_PROPOSAL_ROUNDS = 2; - -export function mayCounter(currentRound: number): boolean { - return currentRound < MAX_PROPOSAL_ROUNDS; -} +/* + * There is deliberately no counter-round. + * + * MAX_PROPOSAL_ROUNDS and mayCounter lived here, and the COUNTERED status is + * still in the enum and the transition map — but nothing ever wrote it. The + * round-2 path was specified and never built, so removing it costs nothing and + * leaves one fewer half-implemented state to reason about. + * + * Propose -> accept or decline is the whole flow. A decline already falls back + * to the consultant allocating, so nothing dead-ends without it. + */ From 587dfd1208f1ecd5b595dc8cb68499caf384ecaf Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:39:28 +0530 Subject: [PATCH 04/15] feat(scheduling): one SlotPicker on three pages; both dialogs deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every defect found reviewing the reschedule and allocate dialogs traced back to width: labels overflowing their column, a legend that would not fit, a selection lost on reload. They were pages wearing modal costumes, the same shape the four planner dialogs had. Three routes now run over one component: consultee .../appointments/[appointmentId]/reschedule consultant .../appointments/[appointmentId]/reschedule (new) consultant .../requests/[requestId]/allocate The consultant reschedule route is a prerequisite, not an extra. That surface borrowed the CONSULTEE's modal, and the consultee route 403s a consultant — so deleting the dialogs without it would have left consultants unable to reschedule at all. Differences are data, not flags. A policy carries the rules (lead time, released slots shown, submit label, whether releasing without a time is allowed); a separate subject carries what is being placed (ids, durations, window, counterpart). That split removed two booleans outright: MANAGE_TIMINGS runs no consultee-conflict check because it has no counterpart, not because a flag says so, and "is this a fresh allocation" is subject data. SlotPicker never branches on which surface it is. EventTimingsCalendar is the fourth caller and now runs the same component under its own policy rather than being a fifth calendar to keep in sync. Consultants gained a times picker they never had — previously a confirm-only dialog. Safe by construction: only a CONSULTEE proposal auto-confirms, so a consultant's is always an offer the other side must accept. The sessions-then-times step machine is gone. A page can show the release picker above the grid, which was the point of moving off modals. Mobile has two gates answering different questions. CSS decides what is VISIBLE, so there is no hydration flash and the page is not client-only. matchMedia decides what is MOUNTED, so below lg the calendar subtree never mounts and the availability fetch never fires — the CSS-only version hid the grid while still fetching for it. The route segment is [requestId], not [appointmentId]: it receives the consultation/subscription id, and the Appointment row is downstream of it — it does not exist yet for a request that was never scheduled. Co-Authored-By: Claude Opus 5 (1M context) --- .../ConsultantAppointmentsAdapter.tsx | 88 +-- .../reschedule/RescheduleClient.tsx | 66 ++ .../[appointmentId]/reschedule/page.tsx | 83 +++ .../components/EventTimingsCalendar.tsx | 115 ++-- .../components/useConsultantEventActions.ts | 51 +- .../[appointmentId]/allocate/page.tsx | 71 --- .../[requestId]/allocate/AllocateClient.tsx | 53 ++ .../requests/[requestId]/allocate/page.tsx | 88 +++ .../reschedule/RescheduleClient.tsx | 65 ++ .../[appointmentId]/reschedule/page.tsx | 89 ++- .../ConsulteeAppointmentsAdapter.tsx | 43 +- .../consultee/RescheduleSessionsModal.tsx | 589 ------------------ .../appointments/consultee/useEventActions.ts | 9 +- .../requests/RequestSlotAllocationTab.tsx | 170 +---- components/scheduling/DesktopOnlyNotice.tsx | 38 +- .../scheduling/SessionReleasePicker.tsx | 272 ++++++++ components/scheduling/SlotPicker.tsx | 214 +++++++ components/scheduling/UnifiedCalendar.tsx | 2 +- components/scheduling/slot-picker-policy.ts | 180 ++++++ lib/data/allocation-request.ts | 132 ++++ lib/scheduling/slot-picker-subject.ts | 164 +++++ 21 files changed, 1542 insertions(+), 1040 deletions(-) create mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx create mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx delete mode 100644 app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx create mode 100644 app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx create mode 100644 app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx create mode 100644 app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx delete mode 100644 components/appointments/consultee/RescheduleSessionsModal.tsx create mode 100644 components/scheduling/SessionReleasePicker.tsx create mode 100644 components/scheduling/SlotPicker.tsx create mode 100644 components/scheduling/slot-picker-policy.ts create mode 100644 lib/data/allocation-request.ts create mode 100644 lib/scheduling/slot-picker-subject.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index 6ef2c4e19..d6dc31f1d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { CalendarClock, Loader2 } from "lucide-react"; import type { AppointmentActionAdapter, OverflowItem, @@ -38,29 +37,14 @@ import { import { EventTimingsCalendar } from "./components/EventTimingsCalendar"; import { useConsultantEventActions } from "./components/useConsultantEventActions"; import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; -import { RescheduleSessionsModal } from "@/components/appointments/consultee/RescheduleSessionsModal"; import { ConsultantResponseUpload } from "../documents/ConsultantResponseUpload"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; interface TimingsTarget { appointment: TAppointment | UnscheduledAppointment; groupProgress: { completedSessions: number; totalSessions: number } | null; } -type DialogKind = - | "cancel" - | "reschedule-single" - | "reschedule-multi" - | "documents"; +type DialogKind = "cancel" | "documents"; const TYPE_LABEL: Record = { CONSULTATION: "Consultation", @@ -285,8 +269,13 @@ export function useConsultantAppointmentsAdapter( items.push({ key: "reschedule", label: "Reschedule", + // The consultant's OWN reschedule route. This surface used to mount + // the consultee's dialog, which has no equivalent page a consultant + // may open: that route's consultee ownership check would 403 them. onClick: () => - openDialog(vm, vm.group ? "reschedule-multi" : "reschedule-single"), + router.push( + `/dashboard/consultant/${consultantId}/appointments/${vm.appointmentId}/reschedule`, + ), }); } @@ -356,69 +345,6 @@ export function useConsultantAppointmentsAdapter( isLoading={actions.isLoading} /> - !open && closeDialog()} - typeLabel={typeLabel} - rawSlots={rawSlots} - isLoading={actions.isLoading} - // No proposal step here: a consultant-initiated proposal never - // auto-confirms, and this surface already carries the full - // allocation calendar for picking replacement times directly. - onConfirm={({ slotIds }) => { - closeDialog(); - void actions.handleReschedule(slotIds); - }} - /> - - !open && closeDialog()} - > - - - - - Reschedule {typeLabel}? - - -
-

- Are you sure you want to reschedule{" "} - "{activeVm.title}" with{" "} - {activeVm.counterpart.name}? -

-

- The current time slot will be released and the{" "} - {typeLabel.toLowerCase()} status will revert to{" "} - Pending until a new time is allocated. -

-
-
-
- - - Keep Current Time - - { - closeDialog(); - void actions.handleReschedule(); - }} - disabled={actions.isLoading} - className="bg-primary text-primary-foreground hover:bg-primary/90" - > - {actions.isLoading ? ( - - ) : ( - - )} - Reschedule - - -
-
- {activeVm.appointmentId && dialog === "documents" && ( ) { + const router = useRouter(); + + const actions = useConsultantEventActions({ + consultantId, + appointmentId, + rawSlots: [], + title, + type: typeLabel, + }); + + const goBack = () => { + router.push(backHref); + router.refresh(); + }; + + const policy = rescheduleConsultantPolicy({ + onSubmit: async ({ slotIds, proposedSlots }) => { + const moved = await actions.handleReschedule(slotIds, proposedSlots); + if (moved) goBack(); + }, + }); + + return ( + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx new file mode 100644 index 000000000..1a912b020 --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { DashboardHeader } from "@/components/dashboard/PageScaffold"; +import { readAppointmentDetail } from "@/lib/data/appointment-detail"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { buildRescheduleSubject } from "@/lib/scheduling/slot-picker-subject"; + +import { RescheduleClient } from "./RescheduleClient"; + +/** + * The consultant's reschedule surface. + * + * Required, not extra. The consultant's appointments rows used to open the + * consultee's dialog; the consultee ROUTE checks + * `requirePersonalProfileAccess("consultee", …)`, which a consultant fails — + * so this is what keeps consultants able to reschedule once that dialog is + * gone. Same picker, same policy shape, different auth and copy. + */ +type PageProps = { + params: Promise<{ consultantId: string; appointmentId: string }>; +}; + +export default async function ConsultantReschedulePage({ + params, +}: Readonly) { + const { consultantId, appointmentId } = await params; + // Enforced here rather than in the layout: the layout is a client component, + // so its check runs only after this server render has already streamed. + await requirePersonalProfileAccess("consultant", consultantId); + + const detail = await readAppointmentDetail(appointmentId); + if (!detail) notFound(); + + // The route's consultant must own the plan or be an ACCEPTED collaborator. + // Mirrors the detail page: this binds the appointment to the URL's + // consultant, the guard above binds that consultant to the session. + const { appointment } = detail; + const planOwnerIds = [ + appointment.consultation?.consultationPlan?.consultantProfile?.id, + appointment.subscription?.subscriptionPlan?.consultantProfile?.id, + appointment.webinar?.webinarPlan?.consultantProfile?.id, + appointment.class?.classPlan?.consultantProfile?.id, + appointment.trialSession?.subscriptionPlan?.consultantProfile?.id, + ...(appointment.webinar?.webinarPlan?.collaborators ?? []).map( + (collaborator) => collaborator.consultantProfile?.id, + ), + ...(appointment.class?.classPlan?.collaborators ?? []).map( + (collaborator) => collaborator.consultantProfile?.id, + ), + ]; + if (!planOwnerIds.includes(consultantId)) notFound(); + + const resolved = buildRescheduleSubject(detail); + if (!resolved) notFound(); + + const backHref = `/dashboard/consultant/${consultantId}/appointments`; + + return ( +
+ + + + Back to appointments + + + +
+ ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx index 31c563c44..c2ec8eb1d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx @@ -9,10 +9,20 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useParams } from "next/navigation"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; +import { SlotPicker } from "@/components/scheduling/SlotPicker"; +import { manageTimingsPolicy } from "@/components/scheduling/slot-picker-policy"; import type { UnscheduledAppointment } from "../utils/unscheduledAppointments"; import { getClassPlanDefaults, type ClassPlanType } from "@/utils/classPlans"; +/** + * The consultant setting the times of their own event instance — the fourth + * caller of the shared slot-picker surface, and the one that already handled + * all four offering types and clamped to the scheduling period, so it is what + * `SlotPicker` was generalised FROM. It stays a dialog (nothing here is + * per-appointment enough to deserve a URL) and everything below the header is + * now the shared component under the MANAGE_TIMINGS policy. + */ + interface EventDetails { eventType: "consultation" | "subscription" | "webinar" | "class"; eventId: string; @@ -25,6 +35,27 @@ interface EventDetails { planType?: ClassPlanType; } +/** Only the recurring types carry a scheduling period to clamp to. */ +function getSchedulingPeriod(appointment: UnscheduledAppointment): { + start?: Date; + end?: Date; +} { + const event = + appointment.appointmentType === "SUBSCRIPTION" + ? appointment.subscription + : appointment.appointmentType === "CLASS" + ? appointment.class + : null; + return { + start: event?.schedulingPeriodStartsAt + ? new Date(event.schedulingPeriodStartsAt) + : undefined, + end: event?.schedulingPeriodEndsAt + ? new Date(event.schedulingPeriodEndsAt) + : undefined, + }; +} + interface EventTimingsCalendarProps { isOpen: boolean; onClose: () => void; @@ -115,14 +146,7 @@ export function EventTimingsCalendar({ }; const eventDetails = getEventDetails(appointment); - - // Removed debug validation logging - production code uses validation directly in calendar - - // Removed debug logging - production code validates dates server-side - - const handleAllocationComplete = () => { - onClose(); - }; + const schedulingPeriod = getSchedulingPeriod(appointment); const appendProgressText = ( baseText: string, @@ -203,53 +227,34 @@ export function EventTimingsCalendar({ )} - diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts index 7c0f9dc86..45f48028d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/useConsultantEventActions.ts @@ -47,14 +47,26 @@ export function useConsultantEventActions({ } }; - const handleReschedule = async (slotIds?: string[]) => { + /** + * Release sessions, optionally naming the times to replace them with. + * + * A consultant proposal never auto-confirms — publishing availability is + * standing consent to be booked inside it, but merely being free is not + * consent to be moved — so these times are always an offer to the consultee. + * Resolves true only when the release landed, which is what lets the + * reschedule page navigate on success and stay put on failure. + */ + const handleReschedule = async ( + slotIds?: string[], + proposedSlots?: { startsAt: string; endsAt: string }[], + ): Promise => { if (!appointmentId) { toast({ title: "Error", description: "Appointment ID is missing", variant: "destructive", }); - return; + return false; } setIsLoading(true); @@ -64,13 +76,16 @@ export function useConsultantEventActions({ ? `/api/appointments/${appointmentId}/reschedule?type=SUBSCRIPTION` : `/api/appointments/${appointmentId}/reschedule`; + const payload: Record = {}; + if (slotIds && slotIds.length > 0) payload.slotIds = slotIds; + if (proposedSlots?.length) payload.proposedSlots = proposedSlots; + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: - slotIds && slotIds.length > 0 - ? JSON.stringify({ slotIds }) - : undefined, + body: Object.keys(payload).length + ? JSON.stringify(payload) + : undefined, }); const data = await response.json(); @@ -81,15 +96,24 @@ export function useConsultantEventActions({ const sessionsAffected = data.sessionsAffected ?? data.slotsAffected ?? slotIds?.length ?? 1; - toast({ - title: "Ready to reschedule", - description: - sessionsAffected === 1 - ? "Select a new time for this session." - : `Select new times for ${sessionsAffected} sessions.`, - }); + toast( + proposedSlots?.length + ? { + title: "Times proposed", + description: + "The consultee has been asked to accept the new time.", + } + : { + title: "Ready to reschedule", + description: + sessionsAffected === 1 + ? "Select a new time for this session." + : `Select new times for ${sessionsAffected} sessions.`, + }, + ); invalidateBookingData(); + return true; } catch (error) { Sentry.captureException( error instanceof Error ? error : new Error(String(error)), @@ -103,6 +127,7 @@ export function useConsultantEventActions({ : "Failed to request reschedule", variant: "destructive", }); + return false; } finally { setIsLoading(false); } diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx deleted file mode 100644 index 02e4361ed..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/requests/[appointmentId]/allocate/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import Link from "next/link"; -import { DashboardHeader } from "@/components/dashboard/PageScaffold"; -import { DesktopOnlyNotice } from "@/components/scheduling/DesktopOnlyNotice"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; -import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; - -/** - * The consultant's slot-allocation surface. - * - * Placing N sessions across a scheduling period under per-day and per-week - * caps is a page-sized task that has been living in a dialog. The heatmap - * stays — it is the right tool here, unlike on the buyer side — it simply gets - * room to breathe, plus a URL that survives a refresh and can be linked from - * the notification that says a request is waiting. - * - * Read-only for now: the Requests table links here so the full-width grid can - * be judged against the dialog it replaces, but every allocation action still - * lives in that dialog. The next PR moves the actions across and retires it. - */ -type PageProps = { - params: Promise<{ consultantId: string; appointmentId: string }>; - searchParams: Promise<{ type?: string }>; -}; - -export default async function AllocateSlotsPage({ - params, - searchParams, -}: Readonly) { - // `appointmentId` carries the CONSULTATION/SUBSCRIPTION id, which is what the - // allocation endpoints and the calendar's event lookup are keyed by — the - // Appointment row is downstream of it and does not exist for a request that - // has never been scheduled. - const { consultantId, appointmentId } = await params; - const { type } = await searchParams; - // Enforced here rather than in the layout: the layout is a client component, - // so its check runs only after this server render has already streamed. - await requirePersonalProfileAccess("consultant", consultantId); - - // The route cannot say which product this is, and every calendar fetch is - // keyed by it. The caller already knows, so it travels in the link rather - // than costing this page a query to re-derive. - const eventType = type === "subscription" ? "subscription" : "consultation"; - - return ( -
- - - - Back to requests - - - - {/* A min-height, not `flex-1`: this page is a plain block column, so - the calendar's own `flex-1` has nothing to fill without one. */} - - -
- ); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx new file mode 100644 index 000000000..c077cca7f --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SlotPicker } from "@/components/scheduling/SlotPicker"; +import { + allocatePolicy, + type SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { toast } from "@/components/ui/use-toast"; +import { allocatedElsewhere } from "@/lib/scheduling/allocationMessages"; + +/** + * The consultant's allocation surface, replacing the dialog the requests table + * used to open. The grid owns the submit here — `useEventSlotAllocation` + * inside it POSTs the allocation — so this only decides where to go + * afterwards. + */ +export function AllocateClient({ + subject, + backHref, +}: Readonly<{ subject: SlotPickerSubject; backHref: string }>) { + const router = useRouter(); + + const goBack = () => { + router.push(backHref); + router.refresh(); + }; + + const policy = allocatePolicy({ + onSubmit: () => { + toast({ + title: "Schedule confirmed", + description: "All session times have been scheduled.", + }); + goBack(); + }, + // 409: another session allocated this request first. The list IS the + // answer — the row will simply be gone. + onConflict: () => { + toast(allocatedElsewhere()); + goBack(); + }, + }); + + return ( + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx new file mode 100644 index 000000000..8a77e1f93 --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/page.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { DashboardHeader } from "@/components/dashboard/PageScaffold"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { readAllocationRequest } from "@/lib/data/allocation-request"; + +import { AllocateClient } from "./AllocateClient"; + +/** + * The consultant's slot-allocation surface. + * + * Placing N sessions across a scheduling period under per-day and per-week + * caps is a page-sized task that was living in a dialog. The heatmap stays — + * it is the right tool here, unlike on the buyer side — it simply gets room to + * breathe, plus a URL that survives a refresh and can be linked from the + * notification that says a request is waiting. + */ +type PageProps = { + // `requestId` is the CONSULTATION/SUBSCRIPTION id, which is what the + // allocation endpoints and the grid's event lookup are keyed by. The + // `Appointment` row is downstream of it and does not exist at all for a + // request that has never been scheduled — the ordinary case here. + params: Promise<{ consultantId: string; requestId: string }>; + searchParams: Promise<{ type?: string }>; +}; + +export default async function AllocateSlotsPage({ + params, + searchParams, +}: Readonly) { + const { consultantId, requestId } = await params; + const { type } = await searchParams; + // Enforced here rather than in the layout: the layout is a client component, + // so its check runs only after this server render has already streamed. + await requirePersonalProfileAccess("consultant", consultantId); + + // The route cannot say which product this is, and every grid fetch is keyed + // by it. The caller already knows, so it travels in the link. + const eventType = type === "subscription" ? "subscription" : "consultation"; + + const request = await readAllocationRequest(requestId, eventType); + if (!request) notFound(); + // Binds the request to the URL's consultant; the guard above binds that + // consultant to the session. + if (request.consultantProfileId !== consultantId) notFound(); + + const backHref = `/dashboard/consultant/${consultantId}/requests`; + + return ( +
+ + + + Back to requests + + + +
+ ); +} diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx new file mode 100644 index 000000000..f6c15ca09 --- /dev/null +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SlotPicker } from "@/components/scheduling/SlotPicker"; +import { + rescheduleConsulteePolicy, + type SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { useEventActions } from "@/components/appointments/consultee/useEventActions"; +import type { BookingTypeLabel } from "@/lib/scheduling/slot-picker-subject"; + +/** + * The consultee half of the reschedule page: the shared picker, the consultee + * policy, and the mutation hook that already owns this API call and its cache + * invalidation. + */ +export function RescheduleClient({ + appointmentId, + title, + typeLabel, + subject, + backHref, +}: Readonly<{ + appointmentId: string; + title: string; + typeLabel: BookingTypeLabel; + subject: SlotPickerSubject; + backHref: string; +}>) { + const router = useRouter(); + + const actions = useEventActions({ + appointmentId, + rawSlots: [], + title, + consultant: "", + type: typeLabel, + }); + + const goBack = () => { + // The list this returns to was rendered before the release; refresh it or + // the row still offers "Reschedule" for a booking now awaiting a time. + router.push(backHref); + router.refresh(); + }; + + const policy = rescheduleConsulteePolicy({ + onSubmit: async ({ slotIds, proposedSlots }) => { + const moved = await actions.handleReschedule(slotIds, proposedSlots); + // A failure keeps the page (and the user's selection) so they can retry; + // the hook has already explained why in a toast. + if (moved) goBack(); + }, + }); + + return ( + + ); +} diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx index 5a92d8d38..9f5ef05e0 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -1,21 +1,21 @@ import Link from "next/link"; +import { notFound } from "next/navigation"; + import { DashboardHeader } from "@/components/dashboard/PageScaffold"; -import { DesktopOnlyNotice } from "@/components/scheduling/DesktopOnlyNotice"; +import prisma from "@/lib/prisma"; +import { readAppointmentDetail } from "@/lib/data/appointment-detail"; import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { buildRescheduleSubject } from "@/lib/scheduling/slot-picker-subject"; + +import { RescheduleClient } from "./RescheduleClient"; /** - * Skeleton for the consultee's reschedule surface. - * - * The picker lives in a modal today, and every defect found reviewing it — - * labels overflowing their column, a legend that would not fit, a selection - * lost on reload — traced back to width. A route also gives the flow a URL, - * so "pick a new time" in a notification can link straight here, and a - * half-finished choice survives a refresh. + * The consultee's reschedule surface. * - * Deliberately not wired up yet: nothing links to this route, so it cannot - * affect the working modal. The next PR moves the picker in and adds the - * navigation. What is real here is the route, the ownership check and the - * mobile gate — the parts worth settling before any UI is built on them. + * A page rather than the dialog it replaces: every defect found reviewing that + * dialog — labels overflowing their column, a legend that would not fit, a + * selection lost on reload — traced back to width. The route also gives the + * flow a URL, so "pick a new time" in a notification can link straight here. */ type PageProps = { params: Promise<{ consulteeId: string; appointmentId: string }>; @@ -29,31 +29,56 @@ export default async function RescheduleAppointmentPage({ // so its check runs only after this server render has already streamed. await requirePersonalProfileAccess("consultee", consulteeId); + const [detail, profile] = await Promise.all([ + readAppointmentDetail(appointmentId), + prisma.consulteeProfile.findUnique({ + where: { id: consulteeId }, + select: { userId: true }, + }), + ]); + if (!detail || !profile) notFound(); + + // Binds the appointment to the URL's consultee; binding that consultee to + // the SESSION is the guard above. Mirrors the detail page's check. + const { appointment } = detail; + const owns = + appointment.consultation?.requestedBy?.id === consulteeId || + appointment.subscription?.requestedBy?.id === consulteeId || + appointment.trialSession?.consulteeProfile?.id === consulteeId || + appointment.slotsOfAppointment.some((slot) => + slot.user.some((user) => user.id === profile.userId), + ); + if (!owns) notFound(); + + // The picker draws the CONSULTANT's availability, and this route's params + // carry no consultant — so it is resolved from the booking, here, rather + // than shipped to the client to look up. + const resolved = buildRescheduleSubject(detail); + if (!resolved) notFound(); + + const backHref = `/dashboard/consultee/${consulteeId}/appointments`; + return ( -
+
- -
-

- The time picker is moving here from the dialog. For now, reschedule - from the booking's menu on the appointments page. -

- - Back to appointments - -
-
- - {/* Kept in the DOM so the route's contract is visible while it is a - skeleton: this page is per-appointment, not a generic picker. */} - Appointment {appointmentId} + + Back to appointments + + +
); } diff --git a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx index d2f27ece2..28814d674 100644 --- a/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx +++ b/components/appointments/consultee/ConsulteeAppointmentsAdapter.tsx @@ -29,17 +29,11 @@ import type { SlotLike, } from "@/lib/appointments/view-model"; import { useEventActions } from "@/components/appointments/consultee/useEventActions"; -import { RescheduleSessionsModal } from "@/components/appointments/consultee/RescheduleSessionsModal"; -import { useSession } from "@/lib/auth-client"; import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; import { ReportIssueDialog } from "@/components/appointments/consultee/ReportIssueDialog"; import { DocumentUpload } from "@/components/appointments/DocumentUpload"; -type DialogKind = - | "cancel" - | "reschedule-multi" - | "report" - | "documents"; +type DialogKind = "cancel" | "report" | "documents"; const KIND_TO_TYPE: Record< AppointmentVM["kind"], @@ -70,10 +64,6 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { const client = useStreamVideoClient(); const params = useParams<{ consulteeId: string }>(); const consulteeId = params?.consulteeId; - // The viewer's USER id (not the consultee-profile id in the route) — the - // availability grid keys occupancy off slot participation, and the route's - // `isSelf` gate compares against session.user.id. - const { data: session } = useSession(); // ONE set of dialogs, keyed off the row that opened them. const [activeVm, setActiveVm] = useState(null); @@ -189,6 +179,9 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { if ( vm.appointmentId && + // The reschedule page lives under this route's consultee; off that route + // there is no id to send them to. + consulteeId && !inactive && !tentative && isApprovedStatus(vm.status) && @@ -201,12 +194,12 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { items.push({ key: "reschedule", label: "Reschedule", + // A page, not a dialog: choosing a time is a full-width task, and a + // URL means a half-finished choice survives a refresh. onClick: () => - // One surface for every reschedule. The modal skips its - // session-picker step when there is only one session, so a - // consultation opens straight on "pick a new time" — a second dialog - // for the same action is how the four planner dialogs started. - openDialog(vm, "reschedule-multi"), + router.push( + `/dashboard/consultee/${consulteeId}/appointments/${vm.appointmentId}/reschedule`, + ), }); } if (vm.appointmentId && !inactive) { @@ -279,24 +272,6 @@ export function useConsulteeAppointmentsAdapter(): AppointmentActionAdapter { isPendingPayment={isPendingPayment} /> - !open && closeDialog()} - typeLabel={typeLabel} - rawSlots={activeVm.raw.rawSlots ?? []} - isLoading={actions.isLoading} - consultantProfileId={activeVm.consultantProfileId} - consulteeUserId={session?.user?.id} - sessionDurationInHours={ - activeVm.raw.appointment?.subscription?.subscriptionPlan - ?.sessionDurationInHours ?? undefined - } - onConfirm={({ slotIds, proposedSlots }) => { - closeDialog(); - void actions.handleReschedule(slotIds, proposedSlots); - }} - /> - {activeVm.appointmentId && dialog === "report" && ( void; - /** "Subscription" | "Class" — copy only. */ - typeLabel: string; - /** Future/ongoing slots of the program (useEventActions rawSlots shape). */ - rawSlots: SlotLike[]; - isLoading: boolean; - /** - * The consultant delivering this program. When present the dialog offers a - * second step where the consultee picks the times they actually want; without - * it the dialog degrades to release-only, which is the old behaviour. - */ - consultantProfileId?: string | null; - /** The viewer's own user id. The picker greys out times they are ALREADY - * booked at with any consultant — allocation rejects those, so painting - * them green would only fail at submit. */ - consulteeUserId?: string; - /** Session length in hours, so the picker asks for the right block size. */ - sessionDurationInHours?: number; - /** - * Called with the selected slot ids (undefined = the entire program) and, - * optionally, the times the consultee would like instead. - */ - onConfirm: (args: { - slotIds?: string[]; - proposedSlots?: { startsAt: string; endsAt: string }[]; - }) => void; -} - -function formatSlotDate(date: Date | string): string { - return format(toDate(date), "EEE, d MMM yyyy"); -} - -function formatSlotTime(date: Date | string): string { - return format(toDate(date), "h:mm a"); -} - -type SessionWithProps = { - slots: SlotLike[]; - isWithin24Hours: boolean; - startTime: Date; - endTime: Date; -}; - -/** - * The which-sessions picker. - * - * Extracted because the modal now carries a second step, and holding both the - * step machinery and this list in one function put it past the complexity - * budget — the list is self-contained, so it moves rather than the step logic - * being contorted around it. - */ -function SessionSelector({ - rescheduleType, - sessions, - selectedSlotIds, - selectedSessionCount, - onToggle, -}: Readonly<{ - rescheduleType: "individual" | "multiple"; - sessions: SessionWithProps[]; - selectedSlotIds: string[]; - selectedSessionCount: number; - onToggle: React.Dispatch>; -}>) { - return ( -
- -
- {sessions.map((session, sessionIndex) => { - const sessionSlotIds = session.slots.map((s) => s.id); - const isSelected = sessionSlotIds.every((id) => - selectedSlotIds.includes(id), - ); - - const handleSessionClick = () => { - if (session.isWithin24Hours) return; - if (rescheduleType === "individual") { - onToggle(sessionSlotIds); - } else if (isSelected) { - onToggle((prev) => - prev.filter((id) => !sessionSlotIds.includes(id)), - ); - } else { - onToggle((prev) => - Array.from(new Set([...prev, ...sessionSlotIds])), - ); - } - }; - - return ( - - ); - })} -
- {rescheduleType === "multiple" && selectedSlotIds.length > 0 && ( -

- {selectedSessionCount} session - {selectedSessionCount > 1 ? "s" : ""} selected -

- )} -
- ); -} - -export function RescheduleSessionsModal({ - open, - onOpenChange, - typeLabel, - rawSlots, - isLoading, - consultantProfileId, - consulteeUserId, - sessionDurationInHours, - onConfirm, -}: Readonly) { - // Two steps: which sessions to move, then (optionally) when to move them to. - const [step, setStep] = React.useState<"sessions" | "times">("sessions"); - const [proposedSlots, setProposedSlots] = React.useState< - { startsAt: string; endsAt: string }[] - >([]); - const [rescheduleType, setRescheduleType] = React.useState< - "individual" | "multiple" | "entire" - >("entire"); - const [selectedSlotIds, setSelectedSlotIds] = React.useState([]); - - // The dedicated reschedule page is per-appointment and lives in the consultee - // tree; the consultant adapter mounts this same modal and has neither id, so - // both being present is also the check that we are on the right surface. - const routeParams = useParams(); - const routeConsulteeId = routeParams.consulteeId as string | undefined; - const pageAppointmentId = rawSlots.find((slot) => slot.appointmentId) - ?.appointmentId; - - // Group slots by appointmentId — one session can span multiple slots. - const groupedSessions = React.useMemo(() => { - const nonTentative = rawSlots.filter((slot) => !slot.isTentative); - const groups = new Map(); - for (const slot of nonTentative) { - const key = slot.appointmentId ?? slot.id; - const bucket = groups.get(key); - if (bucket) bucket.push(slot); - else groups.set(key, [slot]); - } - return Array.from(groups.values()) - .map((slots) => { - const sorted = [...slots].sort( - (a, b) => toDate(a.startsAt).getTime() - toDate(b.startsAt).getTime(), - ); - const last = sorted[sorted.length - 1]; - return { - slots: sorted, - startTime: toDate(sorted[0].startsAt), - endTime: toDate(last.endsAt ?? last.startsAt), - }; - }) - .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); - }, [rawSlots]); - - const sessionsWithDynamicProps = groupedSessions.map((session) => ({ - ...session, - isWithin24Hours: - (session.startTime.getTime() - Date.now()) / (1000 * 60 * 60) < 24, - })); - - const selectedSessionCount = React.useMemo(() => { - return groupedSessions.filter((session) => - session.slots.every((slot) => selectedSlotIds.includes(slot.id)), - ).length; - }, [groupedSessions, selectedSlotIds]); - - /** "individual" and "multiple" both pick specific sessions; "entire" does not. */ - const picksSpecificSessions = - rescheduleType === "individual" || rescheduleType === "multiple"; - - /** Nothing to submit: a picking mode is active but no session is ticked. */ - const selectionIncomplete = - picksSpecificSessions && selectedSlotIds.length === 0; - - const releasedSlotIds = - picksSpecificSessions && selectedSlotIds.length > 0 - ? selectedSlotIds - : undefined; - - /** How many sessions the consultee is moving, so the picker asks for that many. */ - const sessionsBeingMoved = - releasedSlotIds === undefined - ? groupedSessions.length - : Math.max(selectedSessionCount, 1); - - const canProposeTimes = !!consultantProfileId; - - /** - * A one-session booking has nothing to choose between, so the "which - * sessions" step is noise: there is only ever one answer. Skipping it is what - * lets this modal serve consultations too, replacing the separate - * confirm-only dialog they used to get — which offered no way to propose a - * time at all. - */ - const isSingleSession = groupedSessions.length <= 1; - - - // Reset whenever the dialog (re)opens, and start on the step that actually - // has a question to answer: a one-session booking has nothing to choose - // between, so it opens straight on "when would you like it instead". - React.useEffect(() => { - if (open) { - setRescheduleType("entire"); - setSelectedSlotIds([]); - setStep(isSingleSession && canProposeTimes ? "times" : "sessions"); - setProposedSlots([]); - } - }, [open, isSingleSession, canProposeTimes]); - - /** - * Releasing without naming a time is still valid — it hands the consultant a - * request with no stated preference, which is what every reschedule used to - * do — so the secondary button says so rather than reading like a cancel. - */ - const submitLabel = (() => { - if (step === "times") return "Request this time"; - if (canProposeTimes) return "Any time works"; - return "Confirm Reschedule"; - })(); - - const submit = (withTimes: boolean) => { - onConfirm({ - slotIds: releasedSlotIds, - proposedSlots: - withTimes && proposedSlots.length ? proposedSlots : undefined, - }); - }; - - return ( - - - - - - {isSingleSession ? "Reschedule Session" : "Reschedule Options"} - - - {isSingleSession - ? `Pick a new time for your ${typeLabel.toLowerCase()}.` - : `Choose how you'd like to reschedule your ${typeLabel.toLowerCase()} sessions.`} - - - -
- {step === "sessions" && ( - <> - {/* Every session-selection control hangs off this ONE check. - Gating the step but not its chrome is how a one-session - consultation ended up being asked to "Reschedule Multiple - Sessions" and warned that "All 1 sessions will be released". */} - {!isSingleSession && ( - <> - { - setRescheduleType( - value as "individual" | "multiple" | "entire", - ); - if (value === "entire") { - setSelectedSlotIds([]); - } else if (value === "individual") { - // Auto-select the session containing the first selected slot - const firstSelectedId = selectedSlotIds[0]; - const sessionWithFirst = groupedSessions.find((session) => - session.slots.some((s) => s.id === firstSelectedId), - ); - const first = sessionWithFirst - ? sessionWithFirst.slots.map((s) => s.id) - : groupedSessions.length > 0 - ? groupedSessions[0].slots.map((s) => s.id) - : []; - setSelectedSlotIds(first); - } - }} - className="space-y-3" - > -
- - -
- -
- - -
- -
- - -
-
- - {picksSpecificSessions && ( - - )} - - )} - -
-

- Note: Sessions cannot be rescheduled within - 24 hours of the start time. No refunds are provided for - rescheduling. -

-
- - )} - - {step === "times" && consultantProfileId && ( -
-

- Pick{" "} - {sessionsBeingMoved === 1 - ? "a time" - : `${sessionsBeingMoved} times`}{" "} - that suit you. Green means the consultant is free — choosing one - of those confirms straight away. Anything else is sent to them - to approve. -

- - - setProposedSlots( - slots.map((slot) => ({ - startsAt: slot.startTime.toISOString(), - endsAt: slot.endTime.toISOString(), - })), - ) - } - /> - - {proposedSlots.length > 0 && ( -

- {proposedSlots.length} slot - {proposedSlots.length === 1 ? "" : "s"} selected. -

- )} -
- )} -
- - - {/* Preview of the full-width picker this modal moves to next; the - modal stays authoritative until it does. */} - {routeConsulteeId && pageAppointmentId && ( - - )} - - - - {step === "sessions" && canProposeTimes && !isSingleSession && ( - - )} - - {/* Naming a time is an OPTION, never a requirement. A one-session - booking opens straight on the picker and has no sessions step to - go back to, so without this its only submit is "Request this - time" — disabled until a time is picked. That removed the - release-only path the old confirm-only dialog always offered, and - releasing without a preference is still perfectly valid: it hands - the consultant a request to allocate, which is what every - reschedule did before proposals existed. */} - {step === "times" && isSingleSession && ( - - )} - - - -
-
- ); -} diff --git a/components/appointments/consultee/useEventActions.ts b/components/appointments/consultee/useEventActions.ts index 789f032a9..be6e4fa1b 100644 --- a/components/appointments/consultee/useEventActions.ts +++ b/components/appointments/consultee/useEventActions.ts @@ -109,17 +109,20 @@ export function useEventActions({ } }; + /** Resolves true only when the release actually landed — the reschedule page + * navigates away on that, and must stay put (with the selection) on a + * failure the user can retry. */ const handleReschedule = async ( slotIds?: string[], proposedSlots?: { startsAt: string; endsAt: string }[], - ) => { + ): Promise => { if (!appointmentId) { toast({ title: "Error", description: "Appointment ID is missing", variant: "destructive", }); - return; + return false; } setIsLoading(true); @@ -163,6 +166,7 @@ export function useEventActions({ ); invalidateBookingData(); + return true; } catch (error) { Sentry.captureException( error instanceof Error ? error : new Error(String(error)), @@ -177,6 +181,7 @@ export function useEventActions({ : "Failed to request reschedule", variant: "destructive", }); + return false; } finally { setIsLoading(false); } diff --git a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx index d4c22ab0d..dc3296d11 100644 --- a/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx +++ b/components/dashboard/shared/requests/RequestSlotAllocationTab.tsx @@ -8,13 +8,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - ResponsiveModal, - ResponsiveModalContent, - ResponsiveModalDescription, - ResponsiveModalHeader, - ResponsiveModalTitle, -} from "@/components/ui/responsive-modal"; import { ResponsiveTable, type ResponsiveColumn, @@ -27,12 +20,10 @@ import { CheckCircle2, RefreshCw, } from "lucide-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { RequestedSlotsDialog } from "./components/RequestedSlotsDialog"; import { PaymentRequiredBadge } from "./components/PaymentRequiredBadge"; -import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; import { ConsultationApiResponse, RequestedBy, @@ -399,18 +390,18 @@ export function RequestSlotAllocationTab({ orgScope = "personal", }: RequestSlotAllocationTabProps) { const params = useParams(); - // Only the consultant tree has this param, and the dedicated allocate page - // lives inside that tree behind a personal-profile check — so an org admin - // viewing someone else's requests must not be offered a link that 403s. + const router = useRouter(); const routeConsultantId = params.consultantId as string | undefined; + // The allocate page lives in the consultant tree behind a personal-profile + // check, and this id passes it on both mount points: the consultant route + // supplies its own, and the org route supplies the VIEWER's own consultant + // profile (allocation is a delivery act — only the deliverer allocates). const consultantId = consultantProfileId ?? (routeConsultantId as string); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [requests, setRequests] = useState([]); /** When the rows on screen were last successfully read. */ const [lastUpdated, setLastUpdated] = useState(null); - const [selectedRequest, setSelectedRequest] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); const [requestedSlotsDialogOpen, setRequestedSlotsDialogOpen] = useState(false); const [selectedRequestForDialog, setSelectedRequestForDialog] = @@ -649,8 +640,6 @@ export function RequestSlotAllocationTab({ const handleConflict = useCallback( (requestId?: string) => { toast(allocatedElsewhere()); - setDialogOpen(false); - setSelectedRequest(null); setRequestedSlotsDialogOpen(false); setSelectedRequestForDialog(null); if (requestId) { @@ -773,25 +762,6 @@ export function RequestSlotAllocationTab({ } }; - // Handle allocation complete from UnifiedCalendar - const handleAllocationComplete = async () => { - toast({ - title: "Schedule confirmed", - description: "All session times have been scheduled.", - variant: "default", - }); - - // Close dialog and reset state - setDialogOpen(false); - setSelectedRequest(null); - - // Remove request from list - setRequests((prev) => prev.filter((r) => r.id !== selectedRequest?.id)); - - // Notify parent - onUpdate(); - }; - // No heading here: both mount points already render a "Requests" page header, // so anything this component titles itself is the second copy of it. if (loading) { @@ -954,13 +924,17 @@ export function RequestSlotAllocationTab({

) : ( <> + {/* A page, not a dialog: placing N sessions across a + scheduling period under per-day and per-week caps needs + the width, and a URL the notification can link to. */} @@ -986,23 +960,6 @@ export function RequestSlotAllocationTab({ )} )} - {/* Preview of the full-width allocation surface that replaces this - dialog next. Read-only, so it sits beside the real actions - rather than competing with them. */} - {routeConsultantId && ( - - )} {/* Quiet by design: declining is the rarer branch, and nothing is destroyed until the request is actually rejected. */} {request.type === AppointmentsType.CONSULTATION && ( @@ -1075,105 +1032,6 @@ export function RequestSlotAllocationTab({ /> )} - {/* Single Allocation Dialog - moved outside map loop to prevent multiple dialogs */} - - - - Allocate Slots - - {selectedRequest && ( -
-

- Choose {selectedRequest.requiredSlots} slots for{" "} - {selectedRequest.type.toLowerCase()} -

- {selectedRequest.type === "SUBSCRIPTION" && - selectedRequest.sessionDurationInHours && ( -

- Each call is{" "} - {selectedRequest.sessionDurationInHours === 1 - ? "1 hour" - : `${selectedRequest.sessionDurationInHours} hours`}{" "} - ( - {Math.ceil( - selectedRequest.sessionDurationInHours / 0.5, - )}{" "} - consecutive slots per call) -

- )} - {selectedRequest.type === "CONSULTATION" && - selectedRequest.durationInHours && ( -

- Consultation is{" "} - {selectedRequest.durationInHours === 1 - ? "1 hour" - : `${selectedRequest.durationInHours} hours`}{" "} - ({Math.ceil(selectedRequest.durationInHours / 0.5)}{" "} - consecutive slots) -

- )} - {selectedRequest.startDate && selectedRequest.endDate && ( -

- Scheduling period:{" "} - {selectedRequest.startDate.toLocaleDateString()} -{" "} - {selectedRequest.endDate.toLocaleDateString()} -

- )} -
- )} -
-
- {selectedRequest && ( - handleConflict(selectedRequest.id)} - initialAllocation={ - (selectedRequest.tentativeSlotCount ?? 0) === 0 - } - showAllocationButtons={true} - durationInMonths={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.durationInMonths - : undefined - } - durationInHours={ - selectedRequest.type === "CONSULTATION" - ? selectedRequest.durationInHours - : undefined - } - sessionsPerWeek={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.sessionsPerWeek - : undefined - } - sessionDurationInHours={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.sessionDurationInHours - : undefined - } - allowedStart={selectedRequest.startDate} - allowedEnd={selectedRequest.endDate} - schedulingTimezone={selectedRequest.schedulingTimezone} - totalSessions={ - selectedRequest.type === "SUBSCRIPTION" - ? selectedRequest.totalSessions - : undefined - } - /> - )} -
-
- ) { + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + // Tailwind's `lg`. Kept in sync by hand: a media query string cannot read + // the theme, and the alternative is measuring on every resize. + const query = window.matchMedia("(min-width: 1024px)"); + const sync = () => setIsDesktop(query.matches); + sync(); + query.addEventListener("change", sync); + return () => query.removeEventListener("change", sync); + }, []); + return ( <>
@@ -27,7 +49,11 @@ export function DesktopOnlyNotice({

-
{children}
+ {/* Flex, not block: the calendar sizes itself with `flex-1`, which needs + a flex parent to fill. */} +
+ {isDesktop ? children : null} +
); } diff --git a/components/scheduling/SessionReleasePicker.tsx b/components/scheduling/SessionReleasePicker.tsx new file mode 100644 index 000000000..53bf8bfe2 --- /dev/null +++ b/components/scheduling/SessionReleasePicker.tsx @@ -0,0 +1,272 @@ +"use client"; + +import React from "react"; +import { format } from "date-fns"; +import { CalendarClock, CalendarRange, Check, CheckSquare } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { toDate, type SlotLike } from "@/lib/appointments/view-model"; +import { cn } from "@/utils/tailwind"; + +/** + * Which sessions of a booking are being released, for the reschedule + * surfaces. Lifted out of the reschedule modal it replaced, unchanged in behaviour; + * `SlotPicker` shows it beside the grid rather than as a first step, because a + * page has the width the dialog did not. + */ + +export type ReleaseMode = "individual" | "multiple" | "entire"; + +export interface ReleasableSession { + slots: SlotLike[]; + startTime: Date; + endTime: Date; +} + +/** + * Sessions of a booking, newest last. One session can span several 30-minute + * slots (ADR B1), so they group by owning appointment; a slot with no + * appointment id stands alone. + */ +export function groupReleasableSessions( + slots: readonly SlotLike[], +): ReleasableSession[] { + const groups = new Map(); + for (const slot of slots) { + if (slot.isTentative) continue; + const key = slot.appointmentId ?? slot.id; + const bucket = groups.get(key); + if (bucket) bucket.push(slot); + else groups.set(key, [slot]); + } + return Array.from(groups.values()) + .map((sessionSlots) => { + const sorted = [...sessionSlots].sort( + (a, b) => toDate(a.startsAt).getTime() - toDate(b.startsAt).getTime(), + ); + const last = sorted[sorted.length - 1]; + return { + slots: sorted, + startTime: toDate(sorted[0].startsAt), + endTime: toDate(last.endsAt ?? last.startsAt), + }; + }) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); +} + +function isWithinLeadTime(session: ReleasableSession, minLeadHours: number) { + if (minLeadHours <= 0) return false; + return ( + (session.startTime.getTime() - Date.now()) / (1000 * 60 * 60) < + minLeadHours + ); +} + +function SessionList({ + mode, + sessions, + minLeadHours, + selectedSlotIds, + onChange, +}: Readonly<{ + mode: Exclude; + sessions: ReleasableSession[]; + minLeadHours: number; + selectedSlotIds: string[]; + onChange: React.Dispatch>; +}>) { + return ( +
+ {sessions.map((session, index) => { + const sessionSlotIds = session.slots.map((s) => s.id); + const isSelected = sessionSlotIds.every((id) => + selectedSlotIds.includes(id), + ); + const locked = isWithinLeadTime(session, minLeadHours); + + const toggle = () => { + if (locked) return; + if (mode === "individual") { + onChange(sessionSlotIds); + } else if (isSelected) { + onChange((prev) => + prev.filter((id) => !sessionSlotIds.includes(id)), + ); + } else { + onChange((prev) => + Array.from(new Set([...prev, ...sessionSlotIds])), + ); + } + }; + + return ( + + ); + })} +
+ ); +} + +const MODE_OPTIONS: { + value: ReleaseMode; + icon: typeof CalendarClock; + label: string; + hint: (sessionCount: number) => string; +}[] = [ + { + value: "individual", + icon: CalendarClock, + label: "One session", + hint: () => "Only the session you pick moves.", + }, + { + value: "multiple", + icon: CheckSquare, + label: "Several sessions", + hint: () => "Pick exactly which sessions move.", + }, + { + value: "entire", + icon: CalendarRange, + label: "Every session", + hint: (count) => `All ${count} sessions are released.`, + }, +]; + +export function SessionReleasePicker({ + sessions, + minLeadHours, + mode, + onModeChange, + selectedSlotIds, + onSelectionChange, +}: Readonly<{ + sessions: ReleasableSession[]; + minLeadHours: number; + mode: ReleaseMode; + onModeChange: (mode: ReleaseMode) => void; + selectedSlotIds: string[]; + onSelectionChange: React.Dispatch>; +}>) { + const handleModeChange = (next: ReleaseMode) => { + onModeChange(next); + if (next === "entire") { + onSelectionChange([]); + return; + } + if (next === "individual") { + // Narrowing from "several" must leave exactly one session ticked, or the + // submit sits disabled with every box still visibly checked. + const alreadyPicked = sessions.find((session) => + session.slots.some((slot) => selectedSlotIds.includes(slot.id)), + ); + const target = alreadyPicked ?? sessions[0]; + onSelectionChange(target ? target.slots.map((s) => s.id) : []); + } + }; + + return ( +
+ + + handleModeChange(value as ReleaseMode)} + className="grid gap-2 sm:grid-cols-3" + > + {MODE_OPTIONS.map((option) => ( +
+ + +
+ ))} +
+ + {mode !== "entire" && ( + + )} +
+ ); +} diff --git a/components/scheduling/SlotPicker.tsx b/components/scheduling/SlotPicker.tsx new file mode 100644 index 000000000..c4f1936ac --- /dev/null +++ b/components/scheduling/SlotPicker.tsx @@ -0,0 +1,214 @@ +"use client"; + +import React from "react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DesktopOnlyNotice } from "@/components/scheduling/DesktopOnlyNotice"; +import { SafeUnifiedCalendar } from "@/components/scheduling/SafeUnifiedCalendar"; +import { + groupReleasableSessions, + SessionReleasePicker, + type ReleaseMode, +} from "@/components/scheduling/SessionReleasePicker"; +import type { + SlotPickerPolicy, + SlotPickerSubject, +} from "@/components/scheduling/slot-picker-policy"; +import { cn } from "@/utils/tailwind"; + +/** + * The one surface for choosing times, shared by allocation, both reschedule + * roles and a consultant's own event timings. + * + * It takes a policy object rather than a prop per difference. The four callers + * disagree about lead time, whether anything is being released and who owns + * the submit; all of that is data in `slot-picker-policy.ts`, so nothing here + * branches on which caller it is. + */ + +export interface SlotPickerProps { + policy: SlotPickerPolicy; + subject: SlotPickerSubject; + /** A submit is in flight; the policy's owner knows, this component does not. */ + isSubmitting?: boolean; + /** Back out. Also wired to the allocate grid's own Cancel button. */ + onCancel?: () => void; + className?: string; +} + +export function SlotPicker({ + policy, + subject, + isSubmitting = false, + onCancel, + className, +}: Readonly) { + const sessions = React.useMemo( + () => groupReleasableSessions(subject.slots ?? []), + [subject.slots], + ); + + const [releaseMode, setReleaseMode] = React.useState("entire"); + const [selectedSlotIds, setSelectedSlotIds] = React.useState([]); + const [proposedSlots, setProposedSlots] = React.useState< + { startsAt: string; endsAt: string }[] + >([]); + + /** A one-session booking has nothing to choose between. */ + const showReleaseStep = policy.showReleasedSlots && sessions.length > 1; + const picksSpecificSessions = showReleaseStep && releaseMode !== "entire"; + + /** A picking mode is active but nothing is ticked — there is no request yet. */ + const selectionIncomplete = + picksSpecificSessions && selectedSlotIds.length === 0; + + const releasedSlotIds = picksSpecificSessions + ? selectedSlotIds + : /* Every session; the API reads an absent list as "all of them". */ + undefined; + + const selectedSessionCount = React.useMemo( + () => + sessions.filter((session) => + session.slots.every((slot) => selectedSlotIds.includes(slot.id)), + ).length, + [sessions, selectedSlotIds], + ); + + /** How many times to ask for, so the hint matches what is actually moving. */ + const sessionsBeingMoved = picksSpecificSessions + ? Math.max(selectedSessionCount, 1) + : Math.max(sessions.length, 1); + + const submit = (withTimes: boolean) => { + void policy.onSubmit({ + slotIds: releasedSlotIds, + proposedSlots: + withTimes && proposedSlots.length > 0 ? proposedSlots : undefined, + }); + }; + + const isSelectMode = policy.calendarMode === "select"; + + return ( + + {policy.minLeadHours > 0 && ( +
+

+ Note: sessions cannot be moved within{" "} + {policy.minLeadHours} hours of their start time, and rescheduling + is not refunded. +

+
+ )} + + {showReleaseStep && ( +
+ +
+ )} + +

+ {isSelectMode && ( + + {sessionsBeingMoved === 1 + ? "Pick a time. " + : `Pick ${sessionsBeingMoved} times. `} + + )} + {policy.pickerHint} +

+ + + setProposedSlots( + slots.map((slot) => ({ + startsAt: slot.startTime.toISOString(), + endsAt: slot.endTime.toISOString(), + })), + ) + } + onAllocationComplete={() => void policy.onSubmit({})} + onAllocationConflict={policy.onConflict} + onClose={onCancel} + /> + + {/* Only "select" needs a footer — the allocate grid renders its own. */} + {isSelectMode && ( +
+ {proposedSlots.length > 0 && ( +

+ {proposedSlots.length} slot + {proposedSlots.length === 1 ? "" : "s"} selected. +

+ )} + + {onCancel && ( + + )} + + {/* Naming a time is an OPTION, never a requirement. Releasing without + one hands the counterparty a request to place, which is how every + reschedule worked before proposals existed — so this stays on + every reschedule surface and at every session count. */} + {policy.allowReleaseWithoutTime && ( + + )} + + +
+ )} +
+ ); +} diff --git a/components/scheduling/UnifiedCalendar.tsx b/components/scheduling/UnifiedCalendar.tsx index cefc13a51..69205489d 100644 --- a/components/scheduling/UnifiedCalendar.tsx +++ b/components/scheduling/UnifiedCalendar.tsx @@ -539,7 +539,7 @@ export function UnifiedCalendar({ // re-renders. // // The callback is held in a ref and kept out of the dependency array on - // purpose. RescheduleSessionsModal passes an inline arrow, so depending on + // purpose. SlotPicker passes an inline arrow, so depending on // it meant a new identity every render: effect fires, parent setState, // re-render, new identity, fire again, forever — React error #185. Nothing // caught it because "select" is the consultee picker's mode and the picker diff --git a/components/scheduling/slot-picker-policy.ts b/components/scheduling/slot-picker-policy.ts new file mode 100644 index 000000000..2f9d9ceee --- /dev/null +++ b/components/scheduling/slot-picker-policy.ts @@ -0,0 +1,180 @@ +import type { SlotLike } from "@/lib/appointments/view-model"; + +/** + * The four surfaces that place slots on a consultant's calendar, expressed as + * data. + * + * They differ in how much notice a session needs, whether anything is being + * released, and who owns the submit — not in structure. Threading those as + * boolean props through one component is how `EventPlanner` reached 3,890 + * lines, so the differences live here and `SlotPicker` only ever reads fields. + */ + +export type SlotPickerPolicyKind = + | "ALLOCATE" + | "RESCHEDULE_CONSULTEE" + | "RESCHEDULE_CONSULTANT" + | "MANAGE_TIMINGS"; + +export interface SlotPickerSubmission { + /** Sessions being released. Undefined = every session of the booking. */ + slotIds?: string[]; + /** Times asked for. Absent = "any time works". */ + proposedSlots?: { startsAt: string; endsAt: string }[]; +} + +export interface SlotPickerPolicy { + kind: SlotPickerPolicyKind; + /** + * Notice a session needs before it may be moved. Zero is not "no rule": a + * pending request was never scheduled and a consultant's own event instance + * strands nobody, so neither has a counterparty to give notice to. + */ + minLeadHours: number; + /** + * Whether this surface releases sessions that already exist. Showing + * released slots and offering a release step are the same condition, so it + * is one field rather than two that can disagree. + */ + showReleasedSlots: boolean; + /** + * Primary submit copy. Read only when `calendarMode` is "select" — the + * allocate grid ships its own footer and labels its own buttons. + */ + submitLabel: string; + /** + * Release WITHOUT naming a time. Predates proposals and is still the whole + * contract when the counterparty should be the one choosing, so it survives + * on every reschedule surface and at every session count. + */ + allowReleaseWithoutTime: boolean; + /** + * "allocate" hands the submit to the grid's own buttons, which POST the + * allocation themselves; "select" collects times and leaves the POST here. + */ + calendarMode: "select" | "allocate"; + /** + * Refuse the allocation when the event already holds confirmed slots + * (#837). Fresh allocations only — a partial reschedule legitimately has + * some, which is why the subject supplies the "is it fresh" half. + */ + appliesInitialAllocationGuard: boolean; + /** One line above the grid; the only place the two roles read differently. */ + pickerHint: string; + onSubmit: (submission: SlotPickerSubmission) => void | Promise; + /** The grid reported a 409 — someone else allocated this first. */ + onConflict?: () => void; +} + +/** + * What is being placed. Pure scheduling data: the surrounding page or dialog + * owns its own title and description, so no copy lives here. + */ +export interface SlotPickerSubject { + /** Whose availability grid is drawn. */ + consultantProfileId: string; + eventType: "consultation" | "subscription" | "webinar" | "class"; + /** Consultation/Subscription/Webinar/Class id — what the grid keys on. */ + eventId?: string; + /** + * The other party, so the grid greys out times they are already booked at: + * allocation rejects those, and painting them green only fails at submit. + * Absent under MANAGE_TIMINGS — a consultant's own instance has no + * counterparty to conflict with. + */ + counterpartUserId?: string; + /** Consultations and webinars: the length of the one session. */ + durationInHours?: number; + /** Subscriptions and classes: the length of each session. */ + sessionDurationInHours?: number; + sessionsPerWeek?: number; + durationInMonths?: number; + totalSessions?: number; + /** Defines the limit day/week buckets (ADR B9). */ + schedulingTimezone?: string; + /** The plan's scheduling period; the grid clamps selection to it. */ + allowedStart?: Date; + allowedEnd?: Date; + /** Existing sessions, for the release step. */ + slots?: SlotLike[]; + /** A session is already released and awaiting a new time. */ + hasReleasedSlots?: boolean; +} + +/** Lead time the reschedule API enforces server-side; mirrored for the UI. */ +const RESCHEDULE_MIN_LEAD_HOURS = 24; + +type PolicyHandlers = Pick & + Partial>; + +/** A consultant placing a pending request that has never been scheduled. */ +export function allocatePolicy(handlers: PolicyHandlers): SlotPickerPolicy { + return { + kind: "ALLOCATE", + minLeadHours: 0, + showReleasedSlots: false, + submitLabel: "Allocate slots", + allowReleaseWithoutTime: false, + calendarMode: "allocate", + appliesInitialAllocationGuard: true, + pickerHint: + "Choose the times for this booking. Green is free for both of you; anything else is already taken.", + ...handlers, + }; +} + +/** A consultee moving a live booking of theirs. */ +export function rescheduleConsulteePolicy( + handlers: PolicyHandlers, +): SlotPickerPolicy { + return { + kind: "RESCHEDULE_CONSULTEE", + minLeadHours: RESCHEDULE_MIN_LEAD_HOURS, + showReleasedSlots: true, + submitLabel: "Request this time", + allowReleaseWithoutTime: true, + calendarMode: "select", + appliesInitialAllocationGuard: false, + pickerHint: + "Green means your consultant is free — choosing one of those confirms straight away. Anything else is sent to them to approve.", + ...handlers, + }; +} + +/** A consultant moving a booking they deliver. */ +export function rescheduleConsultantPolicy( + handlers: PolicyHandlers, +): SlotPickerPolicy { + return { + kind: "RESCHEDULE_CONSULTANT", + minLeadHours: RESCHEDULE_MIN_LEAD_HOURS, + showReleasedSlots: true, + submitLabel: "Propose this time", + allowReleaseWithoutTime: true, + calendarMode: "select", + appliesInitialAllocationGuard: false, + // Never auto-confirms: publishing availability is standing consent to be + // booked inside it, but merely being free is not consent to be moved. + pickerHint: + "Green means you are free. Whatever you propose is sent to the consultee to accept.", + ...handlers, + }; +} + +/** A consultant setting the times of their own event instance. */ +export function manageTimingsPolicy( + handlers: PolicyHandlers, +): SlotPickerPolicy { + return { + kind: "MANAGE_TIMINGS", + minLeadHours: 0, + showReleasedSlots: false, + submitLabel: "Save timings", + allowReleaseWithoutTime: false, + calendarMode: "allocate", + appliesInitialAllocationGuard: false, + pickerHint: + "Choose when this runs. Green is free on your calendar; anything else is already taken.", + ...handlers, + }; +} diff --git a/lib/data/allocation-request.ts b/lib/data/allocation-request.ts new file mode 100644 index 000000000..dd1085968 --- /dev/null +++ b/lib/data/allocation-request.ts @@ -0,0 +1,132 @@ +import prisma from "@/lib/prisma"; +import { toPlain } from "@/lib/data/serialize"; +import type { AppointmentStatus } from "@prisma/client"; + +/** + * The one pending request the allocate page is placing. + * + * Keyed by the CONSULTATION/SUBSCRIPTION id, not an appointment id: the + * `Appointment` row is downstream of the request and does not exist at all for + * one that has never been scheduled, which is the ordinary case here. + */ + +export type AllocationEventType = "consultation" | "subscription"; + +export interface AllocationRequest { + id: string; + eventType: AllocationEventType; + title: string; + status: AppointmentStatus; + /** Owner of the plan — the page's ownership check. */ + consultantProfileId: string; + /** The buyer, so the grid greys out times they are already booked at. */ + consulteeUserId?: string; + consulteeName?: string; + durationInHours?: number; + sessionDurationInHours?: number; + sessionsPerWeek?: number; + durationInMonths?: number; + totalSessions?: number; + schedulingTimezone?: string; + allowedStart?: Date; + allowedEnd?: Date; + /** + * Some slot is released and awaiting a new time, i.e. this is a partial + * reschedule rather than a fresh allocation — which is exactly when the + * `initialAllocation` guard must NOT fire (#837). + */ + hasReleasedSlots: boolean; +} + +const requestedBySelect = { + select: { userId: true, user: { select: { name: true } } }, +} as const; + +export async function readAllocationRequest( + requestId: string, + eventType: AllocationEventType, +): Promise { + if (eventType === "subscription") { + const subscription = await prisma.subscription.findUnique({ + where: { id: requestId }, + select: { + id: true, + status: true, + schedulingPeriodStartsAt: true, + schedulingPeriodEndsAt: true, + schedulingTimezone: true, + requestedBy: requestedBySelect, + subscriptionPlan: { + select: { + title: true, + consultantProfileId: true, + sessionsPerWeek: true, + sessionDurationInHours: true, + durationInMonths: true, + totalSessions: true, + }, + }, + appointments: { + select: { slotsOfAppointment: { select: { isTentative: true } } }, + }, + }, + }); + if (!subscription?.subscriptionPlan) return null; + + const plan = subscription.subscriptionPlan; + return toPlain({ + id: subscription.id, + eventType: "subscription", + title: plan.title, + status: subscription.status, + consultantProfileId: plan.consultantProfileId, + consulteeUserId: subscription.requestedBy?.userId, + consulteeName: subscription.requestedBy?.user?.name, + sessionDurationInHours: plan.sessionDurationInHours, + sessionsPerWeek: plan.sessionsPerWeek, + durationInMonths: plan.durationInMonths, + totalSessions: plan.totalSessions, + schedulingTimezone: subscription.schedulingTimezone, + allowedStart: subscription.schedulingPeriodStartsAt, + allowedEnd: subscription.schedulingPeriodEndsAt, + hasReleasedSlots: subscription.appointments.some((appointment) => + appointment.slotsOfAppointment.some((slot) => slot.isTentative), + ), + }); + } + + const consultation = await prisma.consultation.findUnique({ + where: { id: requestId }, + select: { + id: true, + status: true, + requestedBy: requestedBySelect, + consultationPlan: { + select: { + title: true, + consultantProfileId: true, + durationInHours: true, + }, + }, + appointment: { + select: { slotsOfAppointment: { select: { isTentative: true } } }, + }, + }, + }); + if (!consultation?.consultationPlan) return null; + + const plan = consultation.consultationPlan; + return toPlain({ + id: consultation.id, + eventType: "consultation", + title: plan.title, + status: consultation.status, + consultantProfileId: plan.consultantProfileId, + consulteeUserId: consultation.requestedBy?.userId, + consulteeName: consultation.requestedBy?.user?.name, + durationInHours: plan.durationInHours, + hasReleasedSlots: ( + consultation.appointment?.slotsOfAppointment ?? [] + ).some((slot) => slot.isTentative), + }); +} diff --git a/lib/scheduling/slot-picker-subject.ts b/lib/scheduling/slot-picker-subject.ts new file mode 100644 index 000000000..9bf11c466 --- /dev/null +++ b/lib/scheduling/slot-picker-subject.ts @@ -0,0 +1,164 @@ +import type { SlotPickerSubject } from "@/components/scheduling/slot-picker-policy"; +import type { TAppointmentDetail } from "@/lib/data/appointment-detail"; +import type { SlotLike } from "@/lib/appointments/view-model"; + +/** + * Turns one appointment into everything a reschedule page needs. + * + * The consultee's route carries `[consulteeId]` and `[appointmentId]` and + * nothing else, so the consultant whose grid the picker draws has to be + * resolved from the booking — this is where that happens, server-side, before + * anything reaches the client. + */ + +export type BookingTypeLabel = + | "Consultation" + | "Subscription" + | "Webinar" + | "Class" + | "Trial"; + +export interface RescheduleSubject { + subject: SlotPickerSubject; + /** Plan title, for the page header. */ + title: string; + /** Drives copy and the reschedule endpoint's `?type=` discriminator. */ + typeLabel: BookingTypeLabel; + /** Owner of the grid — the answer the consultee's route params cannot give. */ + consultantProfileId: string; + /** The booking's consultee, for the page's ownership check. */ + consulteeProfileId?: string; + consulteeUserId?: string; +} + +/** Slots that a reschedule could still act on: ahead of now, and not already dead. */ +function liveFutureSlots(detail: TAppointmentDetail): SlotLike[] { + const { appointment, siblings } = detail; + // Program-wide. A subscription or class session is one Appointment among + // many, and "every session" has to mean all of them — the reschedule API + // reads the whole program off a single appointment id for the same reason. + const all = [ + ...appointment.slotsOfAppointment, + ...siblings.flatMap((sibling) => sibling.slotsOfAppointment), + ]; + const now = Date.now(); + return all + .filter( + (slot) => + new Date(slot.endsAt).getTime() >= now && + slot.completionStatus !== "CANCELLED" && + slot.completionStatus !== "RESCHEDULED", + ) + .map((slot) => ({ + id: slot.id, + appointmentId: slot.appointmentId, + startsAt: slot.startsAt, + endsAt: slot.endsAt, + isTentative: slot.isTentative, + completionStatus: slot.completionStatus, + })); +} + +/** + * Consultant, consultee, session length and copy for one booking. + * + * Returns null when the booking has no consultant to draw a grid for, which + * the caller should treat as a 404 rather than render an empty calendar. + */ +export function buildRescheduleSubject( + detail: TAppointmentDetail, +): RescheduleSubject | null { + const { appointment } = detail; + + const resolved = ((): { + consultantProfileId?: string; + consulteeProfileId?: string; + consulteeUserId?: string; + title: string; + typeLabel: BookingTypeLabel; + sessionDurationInHours?: number; + } | null => { + switch (appointment.appointmentType) { + case "CONSULTATION": { + const plan = appointment.consultation?.consultationPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consulteeProfileId: appointment.consultation?.requestedBy?.id, + consulteeUserId: appointment.consultation?.requestedBy?.userId, + title: plan?.title ?? "Consultation", + typeLabel: "Consultation", + sessionDurationInHours: plan?.durationInHours, + }; + } + case "SUBSCRIPTION": { + const plan = appointment.subscription?.subscriptionPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consulteeProfileId: appointment.subscription?.requestedBy?.id, + consulteeUserId: appointment.subscription?.requestedBy?.userId, + title: plan?.title ?? "Subscription", + typeLabel: "Subscription", + sessionDurationInHours: plan?.sessionDurationInHours, + }; + } + case "WEBINAR": { + const plan = appointment.webinar?.webinarPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + title: plan?.title ?? "Webinar", + typeLabel: "Webinar", + sessionDurationInHours: plan?.durationInHours, + }; + } + case "CLASS": { + const plan = appointment.class?.classPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + title: plan?.title ?? "Class", + typeLabel: "Class", + sessionDurationInHours: plan?.sessionDurationInHours ?? undefined, + }; + } + case "TRIAL": { + const plan = appointment.trialSession?.subscriptionPlan; + return { + consultantProfileId: plan?.consultantProfile?.id, + consulteeProfileId: appointment.trialSession?.consulteeProfile?.id, + consulteeUserId: appointment.trialSession?.consulteeProfile?.userId, + title: plan?.title ?? "Trial session", + typeLabel: "Trial", + sessionDurationInHours: plan?.sessionDurationInHours, + }; + } + default: + return null; + } + })(); + + if (!resolved?.consultantProfileId) return null; + + return { + title: resolved.title, + typeLabel: resolved.typeLabel, + consultantProfileId: resolved.consultantProfileId, + consulteeProfileId: resolved.consulteeProfileId, + consulteeUserId: resolved.consulteeUserId, + subject: { + consultantProfileId: resolved.consultantProfileId, + // Availability only, keyed to no event: a reschedule asks "when is this + // consultant free", not "how does this plan allocate". The picker has + // drawn it this way since it lived in the dialog; the event-shaped + // fetches belong to the allocate surface. + eventType: "consultation", + counterpartUserId: resolved.consulteeUserId, + // Both, deliberately: the grid reads `durationInHours` for the + // consultation shape it is drawing and `sessionDurationInHours` for the + // block size, and for a session being MOVED those are the same number. + // Leaving the first unset is what made the picker warn "duration not + // configured" and fall back to an hour. + durationInHours: resolved.sessionDurationInHours, + sessionDurationInHours: resolved.sessionDurationInHours, + slots: liveFutureSlots(detail), + }, + }; +} From c62b51e892136bd2057c662494c1b611e2fb9caa Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:39:53 +0530 Subject: [PATCH 05/15] fix(scheduling): stop the week-navigation flicker and the org-admin 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing bugs on dev. FLICKER. Two causes, of the three suspected. weeklySlotCount was a dependency of the very effect that fetches the data it derives from, so every navigation double-fetched: navigate, fetch, state updates, dependency changes, fetch again. And nothing tracked which window a response belonged to, so a slow week-N reply could land after the user had moved on and repaint the week they had left. Requests are now stamped and stale replies dropped. The third suspected cause did not hold and is worth recording: there was no clear-to-empty on navigation. The hook keeps the previous week rendered until new data arrives, and the full-grid spinner only fires on the initial load. No keep-previous-data shim was needed. ORG-ADMIN 403. The requests tab mounts the calendar in allocate mode, which asks for appointment details; the route authorised that on consultant ownership or isPrivileged — which is platform ADMIN/STAFF, not org admins. An org admin allocating for a member consultant lost the ENTIRE calendar, because the route 403s rather than downgrading. They are now neither authorised nor refused. The detail payload carries plan titles and participant names, including for the consultant's personal bookings with unrelated consultees — ADR 20 gives an org metadata, not content. So an org OWNER or MAINTAINER (checked against an ACTIVE membership in an org where the consultant is an ACTIVE EXPERT) gets the busy/free grid a buyer gets, and allocation works. The 403 was costing them the whole calendar over a tooltip they must not see anyway. Ordinary org members are deliberately excluded: only the two governance roles. Co-Authored-By: Claude Opus 5 (1M context) --- .../[consultantId]/route.ts | 60 +++++++++++++++++-- hooks/scheduling/useCalendarData.ts | 30 +++++++--- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/app/api/slots/availability-with-allocation/[consultantId]/route.ts b/app/api/slots/availability-with-allocation/[consultantId]/route.ts index 8a2983b55..39cbbec57 100644 --- a/app/api/slots/availability-with-allocation/[consultantId]/route.ts +++ b/app/api/slots/availability-with-allocation/[consultantId]/route.ts @@ -66,6 +66,39 @@ function isValidOvernightSlot(startTime: Date, endTime: Date): boolean { return false; // Invalid slot } +// An org OWNER/MAINTAINER acting for a member consultant (RequestSlotAllocationTab +// mounts mode="allocate" for org admins allocating on a consultant's behalf) +// is authorized the same as the owning consultant. isPrivileged only covers +// PLATFORM staff, so without this an org admin 403s and loses the whole +// calendar rather than just the tooltip detail. EXPERT/other org roles are +// deliberately excluded — same Membership shape requireOrgAccess/catalog +// route use elsewhere (consultantProfileId + role: "EXPERT" identifies the +// org the consultant belongs to; isAtLeastRole's MAINTAINER floor is the +// "admin" rank used throughout app/api/organizations/**). +async function isOrgAdminOfConsultant( + userId: string, + consultantId: string, +): Promise { + const membership = await prisma.membership.findFirst({ + where: { + userId, + status: "ACTIVE", + role: { in: ["OWNER", "MAINTAINER"] }, + organization: { + memberships: { + some: { + consultantProfileId: consultantId, + role: "EXPERT", + status: "ACTIVE", + }, + }, + }, + }, + select: { id: true }, + }); + return !!membership; +} + export async function GET( req: NextRequest, { params }: { params: Promise<{ consultantId: string }> }, @@ -104,16 +137,33 @@ export async function GET( let includeAppointmentDetails = false; if (includeAppointmentDetailsRequested) { - if ( - !session?.user?.id || - (!isOwningConsultant && !isPrivileged(session.user.role)) - ) { + // Three outcomes, not two. + // + // The detail payload carries plan titles and participant names. The + // consultant may see their own; platform staff may see anyone's. An org + // admin allocating for a member consultant needs the CALENDAR — which + // cells are taken — but not what each block is: the consultant's personal + // bookings with unrelated consultees are content, and ADR 20 gives an org + // metadata, not content. + // + // So they are neither authorized nor refused. They get the same + // busy/free grid a buyer gets, and the allocate surface works. 403ing + // them cost the whole calendar over a tooltip they must not see anyway. + const maySeeDetails = + !!session?.user?.id && + (isOwningConsultant || isPrivileged(session.user.role)); + const maySeeCalendar = + maySeeDetails || + (!!session?.user?.id && + (await isOrgAdminOfConsultant(session.user.id, consultantId))); + + if (!maySeeCalendar) { return NextResponse.json( { error: "Forbidden: appointment details require consultant ownership" }, { status: 403 }, ); } - includeAppointmentDetails = true; + includeAppointmentDetails = maySeeDetails; } // The allocator treats the CONSULTEE's bookings with ANY consultant as diff --git a/hooks/scheduling/useCalendarData.ts b/hooks/scheduling/useCalendarData.ts index 4ed4c248b..8714e07e8 100644 --- a/hooks/scheduling/useCalendarData.ts +++ b/hooks/scheduling/useCalendarData.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { reportSentryError } from "@/lib/observability/report"; import { startOfWeek, @@ -242,6 +242,11 @@ export function useCalendarData( >({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Flicker fix — a fetch for week N can resolve after the user has already + // navigated to week N+1 (no AbortController here). Only the response + // matching the MOST RECENTLY issued request may commit state; a stale one + // is silently dropped instead of repainting the wrong week. + const availabilityRequestIdRef = useRef(0); // PERFORMANCE: Computed available slots from raw data using useMemo const availableSlots = useMemo((): TimeSlot[] => { @@ -290,6 +295,8 @@ export function useCalendarData( const fetchAvailabilitySlots = useCallback(async (): Promise => { if (!consultantId) return; + const requestId = ++availabilityRequestIdRef.current; + try { // Always start from the view's natural start so pre-period weeks have // availability data (allows "Outside Period" label on consultant's actual @@ -317,6 +324,10 @@ export function useCalendarData( consulteeUserId, ); + // A newer request was issued (user moved on) while this one was in + // flight — its result is stale, discard rather than repaint. + if (requestId !== availabilityRequestIdRef.current) return; + // Defensive: Validate data structure before using if (!data || typeof data !== "object") { console.warn( @@ -354,6 +365,10 @@ export function useCalendarData( setRawAvailabilitySlots(validatedData); } catch (error) { + // A stale request's failure must not clobber the error state of + // whatever the user has since navigated to. + if (requestId !== availabilityRequestIdRef.current) return; + console.error("Error fetching availability slots:", error); // Not captured here — AllocationService.fetchAvailabilitySlots already // reports this exact error (see fetchConsultantDetails above). @@ -720,13 +735,12 @@ export function useCalendarData( setLoading(false); }); } - }, [ - autoLoad, - consultantId, - consultantDetails, - weeklySlotCount, - fetchAvailabilitySlots, - ]); + // consultantDetails/weeklySlotCount deliberately excluded: both are SET + // BY this effect's own fetch, so listing them re-fires it every time the + // fetch it just ran completes — a self-triggering refetch loop on every + // week navigation (read via closure for isInitialLoad, not as triggers). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoLoad, consultantId, fetchAvailabilitySlots]); // ENHANCEMENT: Individual refetch functions for granular control const refetchConsultant = useCallback(async (): Promise => { From c29251ec421c32707907e053ba6995e66a4d3ecc Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:36:14 +0530 Subject: [PATCH 06/15] fix(scheduling): org admins were 403'd on the consultee gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Forbidden: cannot access other user's calendar" on Allocate Slots. The org-admin arm was added to the appointment-details gate and not to the consulteeUserId gate immediately below it, so an org admin cleared the first check and was refused by the second — which made the allocate surface unusable for them, the exact case that arm was added to fix. The parameter belongs to them. It only marks cells BUSY, carrying no titles or names, so it is metadata rather than content and ADR 20 allows it. Allocation is wrong without it: the grid paints cells green that validation then rejects. The check is now resolved once and shared, rather than duplicated and drifting. Co-Authored-By: Claude Opus 5 (1M context) --- .../[consultantId]/route.ts | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/app/api/slots/availability-with-allocation/[consultantId]/route.ts b/app/api/slots/availability-with-allocation/[consultantId]/route.ts index 39cbbec57..dce18f7a1 100644 --- a/app/api/slots/availability-with-allocation/[consultantId]/route.ts +++ b/app/api/slots/availability-with-allocation/[consultantId]/route.ts @@ -132,8 +132,31 @@ export async function GET( includeAppointmentDetailsRequested || requestedConsulteeUserId ? await getSession(true) : null; + // Ownership is a fact about the database, not about the session. + // + // The session field is a snapshot from when the session was minted, so a + // consultant whose profile link changed since then fails this check while + // requirePersonalProfileAccess — which re-reads the user — lets them onto + // the page. The result was a page that rendered and then 403'd its own + // calendar. Session first because it is free and almost always right; the + // read only happens when it disagrees. const isOwningConsultant = - session?.user?.consultantProfileId === consultantId; + !!session?.user?.id && + (session.user.consultantProfileId === consultantId || + (await prisma.consultantProfile.count({ + where: { id: consultantId, userId: session.user.id }, + })) > 0); + + // Resolved lazily and once: BOTH gates below need it, and the second used + // not to know about it at all — an org admin cleared the details gate and + // was then refused by the consultee gate with "cannot read another user's + // calendar", which made Allocate Slots unusable for them. + let orgAdminCheck: Promise | null = null; + const isOrgAdmin = () => { + if (!session?.user?.id) return Promise.resolve(false); + orgAdminCheck ??= isOrgAdminOfConsultant(session.user.id, consultantId); + return orgAdminCheck; + }; let includeAppointmentDetails = false; if (includeAppointmentDetailsRequested) { @@ -152,10 +175,7 @@ export async function GET( const maySeeDetails = !!session?.user?.id && (isOwningConsultant || isPrivileged(session.user.role)); - const maySeeCalendar = - maySeeDetails || - (!!session?.user?.id && - (await isOrgAdminOfConsultant(session.user.id, consultantId))); + const maySeeCalendar = maySeeDetails || (await isOrgAdmin()); if (!maySeeCalendar) { return NextResponse.json( @@ -176,8 +196,17 @@ export async function GET( // consultant (and staff) may ask about a consultee booking with them. let consulteeUserId: string | null = null; if (requestedConsulteeUserId) { + // The org-admin arm belongs here too. This parameter only marks cells + // BUSY — it carries no titles or names — so it is metadata, which ADR 20 + // does allow an org to see, and allocation is wrong without it: the grid + // would paint cells green that validation then rejects. const isSelf = session?.user?.id === requestedConsulteeUserId; - if (!isSelf && !isOwningConsultant && !isPrivileged(session?.user?.role)) { + if ( + !isSelf && + !isOwningConsultant && + !isPrivileged(session?.user?.role) && + !(await isOrgAdmin()) + ) { return NextResponse.json( { error: "Forbidden: cannot read another user's calendar" }, { status: 403 }, From cc393617b9debc9bb7c6fa1acf92e541c5d7abe4 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:36:15 +0530 Subject: [PATCH 07/15] fix(styles): Tailwind never scanned lib/ or utils/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is why slot cells were ABSENT rather than faint, and why the first palette migration had to be reverted. Tailwind only emits a utility it has SEEN in a scanned file. `content` listed components/ and app/ but not lib/ or utils/ — so every class string defined in lib/scheduling/slot-status-tokens.ts produced NO CSS AT ALL unless the same class happened to appear under a scanned path too. Cells painted from those tokens had no fill and no border: not a faint cell, an invisible one. Past cells kept rendering because their classes were hardcoded inside the component, which was scanned. Two rounds of reasoning about opacity and border-colour precedence never found this, because the CSS was not losing a specificity contest — it did not exist. The calendar is not the only casualty. Seven files under lib/ and utils/ carry class strings: appointment status badges, org and session labels, document icons, auth provider buttons, support ticket UI. All were silently dropping whichever classes no scanned file happened to duplicate. Adding these paths means those previously-dead classes now emit, so expect those surfaces to change appearance — toward what they were always written to look like. Co-Authored-By: Claude Opus 5 (1M context) --- tailwind.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tailwind.config.ts b/tailwind.config.ts index 311a9f724..611c3dbd8 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,9 +2,18 @@ import type { Config } from "tailwindcss"; const config: Config = { darkMode: ["class"], + // `lib/` and `utils/` hold class strings too — the slot palette, appointment + // status badges, session and org labels, document icons. Tailwind only emits + // a utility it has SEEN in a scanned file, so every one of those was being + // dropped from the stylesheet unless the same class happened to appear under + // components/ or app/ as well. That is what made grid cells painted from + // `lib/scheduling/slot-status-tokens` render with no fill and no border at + // all — not faint, absent (#1064). content: [ "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./lib/**/*.{js,ts,jsx,tsx,mdx}", + "./utils/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { From d2f2ade34ee3907a6cf1659034ab3838c3c06f3b Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:36:18 +0530 Subject: [PATCH 08/15] feat(scheduling): one palette, booking identity on each page, expandable notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE PALETTE. Cells and legend now render from SLOT_STATUS_TOKENS, so the legend finally describes the grid. The token shape changed to separate fill / border / text / hover fields rather than one className string, which makes the old failure structurally impossible: the base cell class carries `border` (width only) and exactly one token supplies the colour, so two border-colour utilities can never land on the same element again. `unavailable` goes back to slate-200. The reverted attempt used slate-100, which on a white card is close enough to the background that a sparse week read as an empty grid. `fullyBooked` moves to slate-300 so the two stay distinguishable now that unavailable is visible again — "nobody offered this" and "someone has this" are different answers. A test asserts the cell and the legend swatch resolve to the SAME utilities for every state, and scans the calendar source for the retired hardcoded classes. The existing test only asserted the legend COVERED every state, which is precisely how the two drifted apart unnoticed. PAGE IDENTITY. The three pages said only "Allocate slots" or "Reschedule", with no indication of which booking. They now show the offering title with the counterpart's name, and carry a generateMetadata tab title. The names come from data the pages already fetch — no extra query. REQUEST NOTES. The Note column clamps to three lines, which is right, but had no way to read the rest. A Read more toggle now appears ONLY when the text is actually clipped, measured against scrollHeight and re-measured on resize. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/plans/offering-manifests.test.ts | 4 + __tests__/schedule/slot-palette.test.ts | 177 ++++++++++++++++++ .../[appointmentId]/reschedule/page.tsx | 33 +++- .../requests/[requestId]/allocate/page.tsx | 38 +++- .../[appointmentId]/reschedule/page.tsx | 34 +++- .../requests/RequestSlotAllocationTab.tsx | 74 +++++++- components/scheduling/SlotStatusLegend.tsx | 5 +- components/scheduling/UnifiedCalendar.tsx | 100 +++++----- lib/scheduling/slot-picker-subject.ts | 16 ++ lib/scheduling/slot-status-tokens.ts | 174 ++++++++++++++--- 10 files changed, 578 insertions(+), 77 deletions(-) create mode 100644 __tests__/schedule/slot-palette.test.ts diff --git a/__tests__/plans/offering-manifests.test.ts b/__tests__/plans/offering-manifests.test.ts index 8c03d60bd..5dfb59cf8 100644 --- a/__tests__/plans/offering-manifests.test.ts +++ b/__tests__/plans/offering-manifests.test.ts @@ -212,4 +212,8 @@ describe("slot status tokens", () => { Object.keys(SLOT_STATUS_TOKENS).sort(), ); }); + + // The cell-vs-legend colour-equality and border-conflict regression tests + // for #1064 live in __tests__/schedule/slot-palette.test.ts, alongside a + // source scan for the retired hardcoded classes. }); diff --git a/__tests__/schedule/slot-palette.test.ts b/__tests__/schedule/slot-palette.test.ts new file mode 100644 index 000000000..9d5c52e20 --- /dev/null +++ b/__tests__/schedule/slot-palette.test.ts @@ -0,0 +1,177 @@ +/** + * #1064 — the legend and the grid must paint the same colours. + * + * The pre-existing legend test asserted only that the consultant legend + * COVERS every state the grid can render. It did, the whole time the grid was + * hardcoded to green-300 / yellow-400 / slate-400 / black while the legend + * rendered from the emerald / amber / slate tokens. Coverage was never the + * property that mattered; sameness was. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { + SLOT_CELL_BASE_CLASS, + SLOT_STATUS_TOKENS, + CONSULTANT_LEGEND_KEYS, + resolveSlotStatusKey, + slotCellClassName, + type SlotStatusKey, +} from "@/lib/scheduling/slot-status-tokens"; + +const KEYS = Object.keys(SLOT_STATUS_TOKENS) as SlotStatusKey[]; + +const classesOf = (value: string) => value.split(/\s+/).filter(Boolean); + +/** Every colour utility on an element, ignoring variants like `hover:`. */ +const colourUtilities = (value: string, prefix: string) => + classesOf(value).filter( + (name) => !name.includes(":") && name.startsWith(prefix), + ); + +describe("slot palette — cells and legend render the same colours", () => { + it.each(KEYS)("%s: the cell and its legend swatch share one fill", (key) => { + const token = SLOT_STATUS_TOKENS[key]; + + expect(colourUtilities(slotCellClassName(key), "bg-")).toEqual([ + token.fill, + ]); + expect(colourUtilities(token.swatchClassName, "bg-")).toEqual([token.fill]); + }); + + it.each(KEYS)( + "%s: the cell and its legend swatch share one border", + (key) => { + const token = SLOT_STATUS_TOKENS[key]; + + expect(colourUtilities(slotCellClassName(key), "border-")).toEqual([ + token.border, + ]); + expect(colourUtilities(token.swatchClassName, "border-")).toEqual([ + token.border, + ]); + }, + ); + + it("no two states are painted the same colour", () => { + const fills = CONSULTANT_LEGEND_KEYS.map( + (key) => SLOT_STATUS_TOKENS[key].fill, + ); + expect(new Set(fills).size).toBe(fills.length); + }); +}); + +describe("slot palette — the base cell string cannot fight the token", () => { + /** + * The reverted attempt (49973623) left `border-transparent` in the base + * string and appended `border-slate-200` from the token. Two same-specificity + * border-color utilities on one element resolve by stylesheet order, and + * Tailwind emits `.border-transparent` last — so the token's border was + * always discarded and unavailable cells rendered with no outline at all. + */ + it("carries a border width but no border colour", () => { + expect(classesOf(SLOT_CELL_BASE_CLASS)).toContain("border"); + expect(colourUtilities(SLOT_CELL_BASE_CLASS, "border-")).toEqual([]); + }); + + it("carries no background of its own", () => { + expect(colourUtilities(SLOT_CELL_BASE_CLASS, "bg-")).toEqual([]); + }); +}); + +describe("slot palette — Tailwind can see where the tokens live", () => { + /** + * The tokens sit in `lib/`, and `content` used to list only `components/` + * and `app/`. Tailwind emits a utility only if it has SEEN the class in a + * scanned file, so every token colour that did not coincidentally appear + * under those two trees was absent from the stylesheet entirely — cells with + * no fill and no border, which is how the reverted attempt produced a grid + * whose future days looked blank while past days (painted from a literal in + * the component) still showed (#1064). + */ + it("scans the directory the tokens are defined in", () => { + const config = readFileSync( + path.join(process.cwd(), "tailwind.config.ts"), + "utf8", + ); + expect(config).toContain('"./lib/**/*.{js,ts,jsx,tsx,mdx}"'); + }); +}); + +describe("slot palette — unavailable stays visible", () => { + /** + * bg-slate-100 on a white card is what made a sparse week read as an empty + * grid instead of a full one with little availability. Whatever this token + * becomes, it may not go back to near-white. + */ + it("is not a near-white fill", () => { + expect([ + "bg-white", + "bg-transparent", + "bg-slate-50", + "bg-slate-100", + ]).not.toContain(SLOT_STATUS_TOKENS.unavailable.fill); + }); + + it("is lighter than a booked slot but darker than nothing", () => { + expect(SLOT_STATUS_TOKENS.unavailable.fill).not.toBe( + SLOT_STATUS_TOKENS.fullyBooked.fill, + ); + expect(SLOT_STATUS_TOKENS.unavailable.border).toBeTruthy(); + }); +}); + +describe("slot palette — the grid resolves states through the tokens", () => { + const flags = { + isSelected: false, + isThisEventSlot: false, + isRescheduling: false, + isBookedForDisplay: false, + isPartiallyBooked: false, + isAvailable: false, + isInPast: false, + }; + + it.each([ + ["selected" as const, { isSelected: true }], + ["thisEvent" as const, { isThisEventSlot: true }], + ["rescheduling" as const, { isRescheduling: true }], + ["fullyBooked" as const, { isBookedForDisplay: true }], + ["partiallyBooked" as const, { isPartiallyBooked: true }], + ["available" as const, { isAvailable: true }], + ["unavailable" as const, { isAvailable: true, isInPast: true }], + ["unavailable" as const, {}], + ])("resolves to %s", (expected, overrides) => { + expect(resolveSlotStatusKey({ ...flags, ...overrides })).toBe(expected); + }); + + /** + * A source scan, deliberately: the drift this file exists to catch was + * `renderTimeCell` writing colours by hand, and no assertion about the + * tokens alone can see that happen again. + */ + it("UnifiedCalendar hardcodes none of the retired slot colours", () => { + const source = readFileSync( + path.join( + process.cwd(), + "components", + "scheduling", + "UnifiedCalendar.tsx", + ), + "utf8", + ); + + for (const retired of [ + "bg-green-300", + "bg-green-700", + "bg-yellow-400", + "bg-slate-400", + "bg-gray-300", + "bg-amber-400", + "bg-black", + "border border-transparent", + ]) { + expect(source).not.toContain(retired); + } + }); +}); diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx index 1a912b020..4ad7fd9ba 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -1,3 +1,5 @@ +import { cache } from "react"; +import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -21,6 +23,25 @@ type PageProps = { params: Promise<{ consultantId: string; appointmentId: string }>; }; +// React.cache so generateMetadata() and the page body share one query per request. +const loadDetail = cache(readAppointmentDetail); + +/** + * Names the booking, not the task. A consultant moving sessions for several + * clients has identical "Reschedule" tabs otherwise (#1064). + */ +export async function generateMetadata({ + params, +}: Readonly): Promise { + const { appointmentId } = await params; + const detail = await loadDetail(appointmentId).catch(() => null); + const resolved = detail ? buildRescheduleSubject(detail) : null; + if (!resolved) return { title: "Reschedule — Familiarise" }; + + const who = resolved.consulteeName ? ` · ${resolved.consulteeName}` : ""; + return { title: `Reschedule: ${resolved.title}${who} — Familiarise` }; +} + export default async function ConsultantReschedulePage({ params, }: Readonly) { @@ -29,7 +50,7 @@ export default async function ConsultantReschedulePage({ // so its check runs only after this server render has already streamed. await requirePersonalProfileAccess("consultant", consultantId); - const detail = await readAppointmentDetail(appointmentId); + const detail = await loadDetail(appointmentId); if (!detail) notFound(); // The route's consultant must own the plan or be an ACCEPTED collaborator. @@ -58,9 +79,15 @@ export default async function ConsultantReschedulePage({ return (
+ {/* The BOOKING is the h1, the task is the line under it — see the + consultee's twin (#1064). */} ; }; +// React.cache so generateMetadata() and the page body share one query per request. +const loadRequest = cache(readAllocationRequest); + +/** + * Names the booking, not the task. A consultant working three requests has + * three of these tabs open, and "Allocate slots" on all of them tells them + * nothing (#1064). + */ +export async function generateMetadata({ + params, + searchParams, +}: Readonly): Promise { + const { requestId } = await params; + const { type } = await searchParams; + const request = await loadRequest( + requestId, + type === "subscription" ? "subscription" : "consultation", + ).catch(() => null); + if (!request) return { title: "Allocate slots — Familiarise" }; + + const who = request.consulteeName ? ` · ${request.consulteeName}` : ""; + return { title: `Allocate: ${request.title}${who} — Familiarise` }; +} + export default async function AllocateSlotsPage({ params, searchParams, @@ -39,7 +65,7 @@ export default async function AllocateSlotsPage({ // by it. The caller already knows, so it travels in the link. const eventType = type === "subscription" ? "subscription" : "consultation"; - const request = await readAllocationRequest(requestId, eventType); + const request = await loadRequest(requestId, eventType); if (!request) notFound(); // Binds the request to the URL's consultant; the guard above binds that // consultant to the session. @@ -49,12 +75,16 @@ export default async function AllocateSlotsPage({ return (
+ {/* The BOOKING is the h1, the task is the line under it. Both routes + reached from the requests table look identical otherwise, and a + consultant with several open could not tell which one they were + allocating (#1064). */} diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx index 9f5ef05e0..99ee21feb 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/page.tsx @@ -1,3 +1,5 @@ +import { cache } from "react"; +import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -21,6 +23,25 @@ type PageProps = { params: Promise<{ consulteeId: string; appointmentId: string }>; }; +// React.cache so generateMetadata() and the page body share one query per request. +const loadDetail = cache(readAppointmentDetail); + +/** + * Names the booking, not the task. "Reschedule" on a backgrounded tab does not + * say which of a consultee's sessions is being moved (#1064). + */ +export async function generateMetadata({ + params, +}: Readonly): Promise { + const { appointmentId } = await params; + const detail = await loadDetail(appointmentId).catch(() => null); + const resolved = detail ? buildRescheduleSubject(detail) : null; + if (!resolved) return { title: "Reschedule — Familiarise" }; + + const who = resolved.consultantName ? ` with ${resolved.consultantName}` : ""; + return { title: `Reschedule: ${resolved.title}${who} — Familiarise` }; +} + export default async function RescheduleAppointmentPage({ params, }: Readonly) { @@ -30,7 +51,7 @@ export default async function RescheduleAppointmentPage({ await requirePersonalProfileAccess("consultee", consulteeId); const [detail, profile] = await Promise.all([ - readAppointmentDetail(appointmentId), + loadDetail(appointmentId), prisma.consulteeProfile.findUnique({ where: { id: consulteeId }, select: { userId: true }, @@ -60,9 +81,16 @@ export default async function RescheduleAppointmentPage({ return (
+ {/* The BOOKING is the h1, the task is the line under it — every + reschedule page rendered an identical "Reschedule" heading, so the + consultee could not tell which session they were moving (#1064). */} (null); + const [isClamped, setIsClamped] = useState(false); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + const el = textRef.current; + // Measured only while COLLAPSED. Expanding drops the clamp, so an expanded + // paragraph always reports scrollHeight === clientHeight; re-measuring + // then would decide it no longer overflows and remove the only control + // that collapses it again. + if (!el || expanded) return; + + // +1 absorbs the subpixel rounding between scrollHeight and clientHeight + // that would otherwise flag an exactly-3-line note as clipped. + const measure = () => setIsClamped(el.scrollHeight > el.clientHeight + 1); + measure(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [notes, expanded]); + + return ( +
+

+ “{notes}” +

+ {isClamped && ( + + )} +
+ ); +} + /** * The live offer: who wants what, and until when. * @@ -885,9 +955,7 @@ export function RequestSlotAllocationTab({ className: "align-top", cell: (request) => request.requestNotes?.trim() ? ( -

- “{request.requestNotes.trim()}” -

+ ) : ( // An em dash rather than blank: "they said nothing" and "we failed to // load it" should not look identical. diff --git a/components/scheduling/SlotStatusLegend.tsx b/components/scheduling/SlotStatusLegend.tsx index 55f87c18d..4dd301261 100644 --- a/components/scheduling/SlotStatusLegend.tsx +++ b/components/scheduling/SlotStatusLegend.tsx @@ -37,10 +37,13 @@ export function SlotStatusLegend({ className="flex items-center gap-1.5 text-xs text-muted-foreground" title={token.hint} > + {/* No border-COLOUR of its own: `swatchClassName` carries the + cell's, and a hardcoded one here would win or lose by + stylesheet order rather than by intent (#1064). */} diff --git a/components/scheduling/UnifiedCalendar.tsx b/components/scheduling/UnifiedCalendar.tsx index 69205489d..57f3cdce0 100644 --- a/components/scheduling/UnifiedCalendar.tsx +++ b/components/scheduling/UnifiedCalendar.tsx @@ -56,6 +56,11 @@ import { notEnoughConsecutive, } from "@/lib/scheduling/allocationMessages"; import { useToast } from "@/hooks/use-toast"; +import { + SLOT_STATUS_TOKENS, + resolveSlotStatusKey, + slotCellClassName, +} from "@/lib/scheduling/slot-status-tokens"; /** * Small pure helpers for clarity and reuse. These do not cause side effects. @@ -440,7 +445,10 @@ export function UnifiedCalendar({ // allocated slots appear correctly. await Promise.all([refetchEventSlots(), refetchAvailability()]); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "client" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "client" } }, + ); console.error( "Error refetching calendar data after allocation:", error, @@ -899,17 +907,33 @@ export function UnifiedCalendar({ (allowedStart && intervalEnd <= allowedStart) || (allowedEnd && intervalStart >= allowedEnd); - // Fast-exit: avoid rendering a clickable button for cells that have no - // availability **and** are disabled (e.g. past date). Rendering a - // lightweight placeholder saves performance. + // Fast-exit: a cell with nothing published, nothing booked and already + // past is never interactive, so it renders as a plain block instead of a + // button. It paints from the same `unavailable` token as every other + // dead cell — it used to be its own gray-100/gray-200 pair, which is how + // past days and future days ended up disagreeing about what "nothing + // here" looks like (#1064). if (!status.isAvailable && !status.isBooked && status.isInPast) { return ( -
+
); } - let cellClassName = - "h-8 w-full relative transition-colors duration-75 ease-in-out border border-transparent rounded-sm text-[10px] leading-tight px-1 py-0.5 disabled:pointer-events-none disabled:opacity-50"; + const statusKey = resolveSlotStatusKey({ + isSelected: isCurrentlySelected, + isThisEventSlot: isCurrentEventSlot, + isRescheduling: isCurrentEventTentative, + isBookedForDisplay: status.isBookedForDisplay, + isPartiallyBooked: status.isPartiallyBooked, + isAvailable: status.isAvailable, + isInPast: status.isInPast, + }); + // ONE token, appended once, on top of a base string that carries no + // border-COLOUR. Every branch below adds cursor/opacity only — a second + // border-color utility on this element is structurally impossible now, + // which is what the reverted attempt got wrong (#1064). + let cellClassName = slotCellClassName(statusKey); + let buttonText = ""; const showTooltip = ((status.isBookedForDisplay || status.isPartiallyBooked) && @@ -917,62 +941,51 @@ export function UnifiedCalendar({ isCurrentEventSlot; if (isCurrentlySelected) { - // Dark green for manually selected slots - cellClassName += - " bg-green-700 text-white hover:bg-green-800 border-green-900"; - buttonText = "Selected"; + cellClassName += " cursor-pointer"; + buttonText = SLOT_STATUS_TOKENS.selected.label; } else if (isCurrentEventSlot) { - // Black for this event's already booked slots - cellClassName += - " bg-black text-white cursor-pointer hover:bg-gray-900 border-gray-800"; + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-60" : ""; - buttonText = status.isInPast ? "Past Session" : "This Event"; + buttonText = status.isInPast + ? "Past session" + : SLOT_STATUS_TOKENS.thisEvent.label; } else if (isCurrentEventTentative) { - // Amber for THIS event's slot being rescheduled — distinct from the gray - // "Booked" used for foreign appointments. It's the consultant's own slot - // pending a new time, not someone else's booking. - cellClassName += - " bg-amber-400 text-amber-900 cursor-pointer hover:bg-amber-500 border-amber-500"; + // THIS event's slot being rescheduled — distinct from the "Booked" + // state used for foreign appointments below. + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-50" : ""; - buttonText = "Rescheduling"; + buttonText = SLOT_STATUS_TOKENS.rescheduling.label; } else if (status.isBookedForDisplay) { - // Grey for other appointments - cellClassName += - " bg-slate-400 text-slate-800 cursor-pointer hover:bg-slate-500"; + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-50" : ""; - buttonText = "Booked"; + buttonText = SLOT_STATUS_TOKENS.fullyBooked.label; } else if (status.isPartiallyBooked) { - cellClassName += - " bg-yellow-400 text-yellow-900 cursor-pointer hover:bg-yellow-500"; + cellClassName += " cursor-pointer"; cellClassName += status.isInPast ? " opacity-50" : ""; - buttonText = "Partially Booked"; + buttonText = SLOT_STATUS_TOKENS.partiallyBooked.label; } else if (status.isAvailable) { - // Available slot - check if past for fading if (status.isInPast) { // A past slot is NOT available, whatever the consultant published. // It used to render green and say "Available", differing from a real // opening only by opacity — so a picker opened on the current week // offered times that had already happened. Still clickable, because // the toast explains why; it just no longer claims to be bookable. - cellClassName += - " bg-slate-100 text-slate-400 border-slate-200 cursor-pointer hover:bg-slate-200"; + cellClassName += " cursor-pointer"; buttonText = isOutsideAllowedRange ? "Outside Period" : "Past"; } else { - // Future available slot - unfaded, clickable - cellClassName += - " bg-green-300 text-green-950 cursor-pointer hover:bg-green-400 border-green-400"; + cellClassName += " cursor-pointer"; if (eventType === "consultation") { cellClassName += " hover:shadow-md"; } - buttonText = isOutsideAllowedRange ? "Outside Period" : "Available"; + buttonText = isOutsideAllowedRange + ? "Outside Period" + : SLOT_STATUS_TOKENS.available.label; } } else { - if (status.isInPast) { - cellClassName += - " bg-gray-300 text-gray-700 cursor-not-allowed opacity-70"; - } else { - cellClassName += " bg-slate-200 cursor-not-allowed"; - } + // Genuinely unpublished interval — never carried button text, before + // or after this change; only the colour source moved. + cellClassName += " cursor-not-allowed"; + cellClassName += status.isInPast ? " opacity-70" : ""; } // Only disable in view mode or if no availability at all (gray slots) @@ -1414,7 +1427,10 @@ export function UnifiedCalendar({ return `${formatDurationLabel(totalMinutes)} selected`; return `${formatDurationLabel(chosenMinutes)} of ${formatDurationLabel(totalMinutes)} selected`; } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "client" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "client" } }, + ); console.error("Error calculating footer stats:", error); if (error instanceof Error) { return error.message; diff --git a/lib/scheduling/slot-picker-subject.ts b/lib/scheduling/slot-picker-subject.ts index 9bf11c466..a67c46865 100644 --- a/lib/scheduling/slot-picker-subject.ts +++ b/lib/scheduling/slot-picker-subject.ts @@ -29,6 +29,10 @@ export interface RescheduleSubject { /** The booking's consultee, for the page's ownership check. */ consulteeProfileId?: string; consulteeUserId?: string; + /** Counterpart names for the page header — already on the plan/requestedBy + * includes `readAppointmentDetail` selects, so no second query. */ + consultantName?: string; + consulteeName?: string; } /** Slots that a reschedule could still act on: ahead of now, and not already dead. */ @@ -74,6 +78,8 @@ export function buildRescheduleSubject( consultantProfileId?: string; consulteeProfileId?: string; consulteeUserId?: string; + consultantName?: string; + consulteeName?: string; title: string; typeLabel: BookingTypeLabel; sessionDurationInHours?: number; @@ -85,6 +91,8 @@ export function buildRescheduleSubject( consultantProfileId: plan?.consultantProfile?.id, consulteeProfileId: appointment.consultation?.requestedBy?.id, consulteeUserId: appointment.consultation?.requestedBy?.userId, + consultantName: plan?.consultantProfile?.user?.name, + consulteeName: appointment.consultation?.requestedBy?.user?.name, title: plan?.title ?? "Consultation", typeLabel: "Consultation", sessionDurationInHours: plan?.durationInHours, @@ -96,6 +104,8 @@ export function buildRescheduleSubject( consultantProfileId: plan?.consultantProfile?.id, consulteeProfileId: appointment.subscription?.requestedBy?.id, consulteeUserId: appointment.subscription?.requestedBy?.userId, + consultantName: plan?.consultantProfile?.user?.name, + consulteeName: appointment.subscription?.requestedBy?.user?.name, title: plan?.title ?? "Subscription", typeLabel: "Subscription", sessionDurationInHours: plan?.sessionDurationInHours, @@ -105,6 +115,7 @@ export function buildRescheduleSubject( const plan = appointment.webinar?.webinarPlan; return { consultantProfileId: plan?.consultantProfile?.id, + consultantName: plan?.consultantProfile?.user?.name, title: plan?.title ?? "Webinar", typeLabel: "Webinar", sessionDurationInHours: plan?.durationInHours, @@ -114,6 +125,7 @@ export function buildRescheduleSubject( const plan = appointment.class?.classPlan; return { consultantProfileId: plan?.consultantProfile?.id, + consultantName: plan?.consultantProfile?.user?.name, title: plan?.title ?? "Class", typeLabel: "Class", sessionDurationInHours: plan?.sessionDurationInHours ?? undefined, @@ -125,6 +137,8 @@ export function buildRescheduleSubject( consultantProfileId: plan?.consultantProfile?.id, consulteeProfileId: appointment.trialSession?.consulteeProfile?.id, consulteeUserId: appointment.trialSession?.consulteeProfile?.userId, + consultantName: plan?.consultantProfile?.user?.name, + consulteeName: appointment.trialSession?.consulteeProfile?.user?.name, title: plan?.title ?? "Trial session", typeLabel: "Trial", sessionDurationInHours: plan?.sessionDurationInHours, @@ -143,6 +157,8 @@ export function buildRescheduleSubject( consultantProfileId: resolved.consultantProfileId, consulteeProfileId: resolved.consulteeProfileId, consulteeUserId: resolved.consulteeUserId, + consultantName: resolved.consultantName, + consulteeName: resolved.consulteeName, subject: { consultantProfileId: resolved.consultantProfileId, // Availability only, keyed to no event: a reschedule asks "when is this diff --git a/lib/scheduling/slot-status-tokens.ts b/lib/scheduling/slot-status-tokens.ts index 52e51a1bb..9aad5f55c 100644 --- a/lib/scheduling/slot-status-tokens.ts +++ b/lib/scheduling/slot-status-tokens.ts @@ -12,8 +12,10 @@ * reading two different languages for the same information. Worse, none of the * three surfaces carried a legend, so the vocabulary was never stated anywhere. * - * These tokens are the single source. `SlotStatusLegend` renders them, so a new - * state cannot be added without the legend gaining an entry. + * These tokens are the single source. Cells and legend swatches are both + * DERIVED from one `fill`/`border` pair per state, so a swatch cannot document + * a colour the grid does not paint — which is exactly what happened while the + * two were written out by hand (#1064). */ export type SlotStatusKey = @@ -25,63 +27,193 @@ export type SlotStatusKey = | "rescheduling" | "unavailable"; +/** The hand-authored half of a token: one colour per state, written once. */ +interface SlotStatusPaint { + label: string; + hint: string; + /** Background. Shared by the cell and its legend swatch. */ + fill: string; + /** Border colour. Shared likewise — see `SLOT_CELL_BASE_CLASS` on why the + * token owns this and the base string must not. */ + border: string; + /** Cell label colour. Swatches carry no text. */ + text: string; + /** Cells only, and only where the cell does something when clicked. */ + hover?: string; +} + export interface SlotStatusToken { /** Shown in the legend and in cell tooltips. */ label: string; + /** What the state means, for the legend's title attribute. */ + hint: string; + /** The state's background. Asserted equal across cell and swatch in tests. */ + fill: string; + /** The state's border colour. */ + border: string; /** Cell classes on a light surface. */ className: string; - /** Legend swatch — a solid block, so it reads at 12px. */ + /** Legend swatch — the cell's own fill and border at 12px. */ swatchClassName: string; - /** What the state means, for the legend's title attribute. */ - hint: string; } -export const SLOT_STATUS_TOKENS: Record = { +const SLOT_STATUS_PAINT: Record = { available: { label: "Available", - className: - "bg-emerald-100 text-emerald-900 border-emerald-300 hover:bg-emerald-200", - swatchClassName: "bg-emerald-300", hint: "Free to book.", + // Deliberately the most saturated light tone on the grid. Free time is the + // one thing a consultant scans for, and at emerald-100 it came out paler + // than the slate greys around it — the useful cells receding behind the + // useless ones (#1064). + fill: "bg-emerald-200", + border: "border-emerald-500", + text: "text-emerald-900", + hover: "hover:bg-emerald-300", }, partiallyBooked: { label: "Partly booked", - className: "bg-amber-100 text-amber-900 border-amber-300", - swatchClassName: "bg-amber-300", hint: "Some of this interval is taken; a full session may not fit.", + fill: "bg-amber-200", + border: "border-amber-400", + text: "text-amber-900", + hover: "hover:bg-amber-300", }, fullyBooked: { label: "Booked", - className: "bg-slate-200 text-slate-600 border-slate-300", - swatchClassName: "bg-slate-400", hint: "Already taken.", + // One shade darker than `unavailable` on purpose — a taken slot is a + // stronger signal ("someone has this") than an unpublished one ("nobody + // offered this"), and the same grey on both would collapse the two states + // once `unavailable` was darkened for visibility (#1064). + fill: "bg-slate-300", + border: "border-slate-400", + text: "text-slate-700", + hover: "hover:bg-slate-400", }, selected: { label: "Selected", - className: "bg-emerald-700 text-white border-emerald-800", - swatchClassName: "bg-emerald-700", hint: "You have chosen this time.", + fill: "bg-emerald-700", + border: "border-emerald-800", + text: "text-white", + hover: "hover:bg-emerald-800", }, thisEvent: { label: "This booking", - className: "bg-zinc-900 text-white border-zinc-900", - swatchClassName: "bg-zinc-900", hint: "Belongs to the booking you are editing.", + fill: "bg-zinc-900", + border: "border-zinc-900", + text: "text-white", + hover: "hover:bg-zinc-800", }, rescheduling: { label: "Being moved", - className: "bg-amber-400 text-amber-950 border-amber-500", - swatchClassName: "bg-amber-400", hint: "Released by a reschedule and awaiting a new time.", + fill: "bg-amber-400", + border: "border-amber-500", + text: "text-amber-950", + hover: "hover:bg-amber-500", }, unavailable: { label: "Unavailable", - className: "bg-slate-100 text-slate-400 border-slate-200", - swatchClassName: "bg-slate-200", hint: "Outside the consultant's published availability.", + // Was slate-100. On a white card that is nearly indistinguishable from the + // background, so a sparse week — mostly unavailable cells — read as an + // EMPTY grid rather than a full one with little availability, which is why + // the first migration was reverted (#1064, 49973623 / 6b78274e). + fill: "bg-slate-200", + border: "border-slate-300", + text: "text-slate-500", }, }; +export const SLOT_STATUS_TOKENS: Record = ( + Object.keys(SLOT_STATUS_PAINT) as SlotStatusKey[] +).reduce( + (tokens, key) => { + const paint = SLOT_STATUS_PAINT[key]; + tokens[key] = { + label: paint.label, + hint: paint.hint, + fill: paint.fill, + border: paint.border, + className: [paint.fill, paint.text, paint.border, paint.hover] + .filter(Boolean) + .join(" "), + // Same fill, same border, no text colour and no hover: a swatch is a + // 12px block, not a control. + swatchClassName: `${paint.fill} ${paint.border}`, + }; + return tokens; + }, + {} as Record, +); + +/** + * Everything a slot cell needs EXCEPT its colour. + * + * No border-COLOUR here, only the width. `border-transparent` used to sit in + * this string and silently beat the token's `border-emerald-500`: Tailwind + * resolves two same-specificity utilities by their order in the generated + * stylesheet, where `.border-transparent` is emitted last, not by the order + * they were concatenated. Unavailable cells therefore lost their outline + * altogether and read as absent rather than merely faint (#1064). + */ +export const SLOT_CELL_BASE_CLASS = + "h-8 w-full relative transition-colors duration-75 ease-in-out border rounded-sm text-[10px] leading-tight px-1 py-0.5 disabled:pointer-events-none disabled:opacity-50"; + +/** + * The full class string for one grid cell. + * + * Callers pass a state, never a colour — that is what keeps the grid and + * `SlotStatusLegend` on one palette. + */ +export function slotCellClassName( + key: SlotStatusKey, + options?: { faded?: boolean; className?: string }, +): string { + return [ + SLOT_CELL_BASE_CLASS, + SLOT_STATUS_TOKENS[key].className, + options?.faded ? "opacity-60" : "", + options?.className ?? "", + ] + .filter(Boolean) + .join(" "); +} + +/** The booleans a cell resolves through, in the precedence they apply. */ +export interface SlotVisualFlags { + isSelected: boolean; + isThisEventSlot: boolean; + isRescheduling: boolean; + isBookedForDisplay: boolean; + isPartiallyBooked: boolean; + isAvailable: boolean; + isInPast: boolean; +} + +/** + * Which token a cell paints from. The ONE place that decision is made — + * `UnifiedCalendar.renderTimeCell` calls this rather than hand-rolling its own + * class string, which is what let the grid and `SlotStatusLegend` drift onto + * two different palettes in the first place (#1064; reverted attempt + * 49973623/6b78274e). + * + * A past-but-available slot resolves to `unavailable`: it is a real interval, + * just no longer bookable, and the muted palette says that without inventing + * a fifth colour family. + */ +export function resolveSlotStatusKey(flags: SlotVisualFlags): SlotStatusKey { + if (flags.isSelected) return "selected"; + if (flags.isThisEventSlot) return "thisEvent"; + if (flags.isRescheduling) return "rescheduling"; + if (flags.isBookedForDisplay) return "fullyBooked"; + if (flags.isPartiallyBooked) return "partiallyBooked"; + if (flags.isAvailable) return flags.isInPast ? "unavailable" : "available"; + return "unavailable"; +} + /** The states worth explaining on a read-only grid. */ export const BUYER_LEGEND_KEYS: SlotStatusKey[] = [ "available", From 0584b3662d580cedf5936833f6f039f134217168 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:28:14 +0530 Subject: [PATCH 09/15] feat(scheduling): Manage Timings becomes the fourth page; no dialogs left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last cramped calendar. Its body already ran SlotPicker, but it kept a dialog wrapper, so the consultant scheduling their own webinar or class was still doing it at modal width while every other slot surface had a page. The route resolves two shapes of id, the same convention the appointments list already used client-side: a real Appointment id when the offering is scheduled, or the synthetic unscheduled-class- / unscheduled-webinar- when there is no Appointment row yet — which is exactly the case this page exists to fix. Deleting the dialog orphaned two files. utils/unscheduledAppointments.ts built its input; appointments/utils/appointmentTimingHelpers.ts exported two predicates nothing else called. Both are gone. Dead state after a deletion never lives in the file you deleted, it lives in what fed it. getEventDetails survives as lib/scheduling/manage-timings-subject.ts, now over a structural type rather than a client-only one, so a server page can call it. RequestedSlotsDialog was assessed and deliberately left alone. It mounts no calendar — it is a read-only validate-and-confirm summary of times the consultee already named, and its height cap exists because that list can be long, not because it wants a calendar's width. Genuinely dialog-shaped. ALSO IN THIS COMMIT — two fixes the user hit while testing, folded in rather than given a message of their own: An empty array is truthy, so a consultant with no org memberships still got the full switcher chrome in the sidebar — a chevron opening a menu containing nothing. The chip now becomes a dropdown only when there is at least one real destination; labels and separators are not somewhere to switch TO. Fixed in CollapsibleSidebar so consultee and anything added later inherit it. An affordance that opens an empty menu is worse than no affordance, which is why it hides rather than saying "no organizations". And the requests list no longer refetches on window focus. This went interval -> focus -> neither, and the last step mattered most: focus fires on every alt-tab, far MORE often than the timer it replaced for anyone actually working. It also reached the calendar, because that tab used to host it — a repaint mid-selection caused by data nobody asked for. Leaving it stale is safe, and that is the argument: the grid is a hint. Allocation re-validates server-side under a Redis lock against SlotValidationService and the btree_gist exclusion constraint, so a stale view cannot double-book — at worst a submit is rejected with a clear message. The Refresh button and the "Updated" label carry it instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../ConsultantAppointmentsAdapter.tsx | 75 +---- .../timings/ManageTimingsClient.tsx | 37 +++ .../[appointmentId]/timings/page.tsx | 111 +++++++ .../components/EventTimingsCalendar.tsx | 262 ----------------- .../utils/appointmentTimingHelpers.ts | 127 -------- .../utils/unscheduledAppointments.ts | 51 ---- components/dashboard/CollapsibleSidebar.tsx | 20 +- .../requests/RequestSlotAllocationTab.tsx | 29 +- lib/data/manage-timings-target.ts | 166 +++++++++++ lib/scheduling/manage-timings-subject.ts | 277 ++++++++++++++++++ 10 files changed, 637 insertions(+), 518 deletions(-) create mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx create mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx delete mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx delete mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts delete mode 100644 app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts create mode 100644 lib/data/manage-timings-target.ts create mode 100644 lib/scheduling/manage-timings-subject.ts diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx index d6dc31f1d..143ca555b 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/ConsultantAppointmentsAdapter.tsx @@ -17,33 +17,16 @@ import { isInactiveStatus, } from "@/lib/appointments/status"; import type { AppointmentVM } from "@/lib/appointments/view-model"; -import type { - ConsultantTrialLike, - UnscheduledClassLike, - UnscheduledWebinarLike, -} from "@/lib/appointments/map-consultant"; -import type { TAppointment } from "@/types/appointment"; -import type { UnscheduledClass, UnscheduledWebinar } from "../../types"; +import type { ConsultantTrialLike } from "@/lib/appointments/map-consultant"; import { useLazyJoinMeeting } from "@/hooks/scheduling/useLazyJoinMeeting"; -import { - buildUnscheduledClassAppointment, - buildUnscheduledWebinarAppointment, - type UnscheduledAppointment, -} from "./utils/unscheduledAppointments"; import { getParticipantManagementUrl, supportsParticipantManagement, } from "./utils/participantHelpers"; -import { EventTimingsCalendar } from "./components/EventTimingsCalendar"; import { useConsultantEventActions } from "./components/useConsultantEventActions"; import { CancelConfirmationDialog } from "@/components/appointments/consultee/CancelConfirmationDialog"; import { ConsultantResponseUpload } from "../documents/ConsultantResponseUpload"; -interface TimingsTarget { - appointment: TAppointment | UnscheduledAppointment; - groupProgress: { completedSessions: number; totalSessions: number } | null; -} - type DialogKind = "cancel" | "documents"; const TYPE_LABEL: Record = { @@ -85,9 +68,6 @@ export function useConsultantAppointmentsAdapter( ): AppointmentActionAdapter { const router = useRouter(); const joinMeeting = useLazyJoinMeeting(); - const [timingsTarget, setTimingsTarget] = useState( - null, - ); const [joiningId, setJoiningId] = useState(null); const [activeVm, setActiveVm] = useState(null); const [dialog, setDialog] = useState(null); @@ -159,35 +139,22 @@ export function useConsultantAppointmentsAdapter( if (!navigating) setJoiningId(null); }; + /** + * `vm.id` already carries the `unscheduled-class-`/`unscheduled-webinar-` + * prefix for an offering with no `Appointment` row yet; a scheduled one + * routes on the real appointment id. The timings page resolves either + * shape itself (lib/data/manage-timings-target.ts). + */ const openTimings = (vm: AppointmentVM) => { - if (vm.id.startsWith("unscheduled-class-")) { - setTimingsTarget({ - appointment: buildUnscheduledClassAppointment( - vm.raw.source as UnscheduledClassLike as UnscheduledClass, - ), - groupProgress: null, - }); - return; - } - if (vm.id.startsWith("unscheduled-webinar-")) { - setTimingsTarget({ - appointment: buildUnscheduledWebinarAppointment( - vm.raw.source as UnscheduledWebinarLike as UnscheduledWebinar, - ), - groupProgress: null, - }); - return; - } - if (!vm.raw.appointment) return; - setTimingsTarget({ - appointment: vm.raw.appointment, - groupProgress: vm.group - ? { - completedSessions: vm.group.completed, - totalSessions: vm.group.total, - } - : null, - }); + const targetId = + vm.id.startsWith("unscheduled-class-") || + vm.id.startsWith("unscheduled-webinar-") + ? vm.id + : vm.appointmentId; + if (!targetId) return; + router.push( + `/dashboard/consultant/${consultantId}/appointments/${targetId}/timings`, + ); }; const trialJoinable = (vm: AppointmentVM) => { @@ -320,16 +287,6 @@ export function useConsultantAppointmentsAdapter( const renderDialogs = () => ( <> - {timingsTarget && ( - setTimingsTarget(null)} - appointment={timingsTarget.appointment} - completedSessions={timingsTarget.groupProgress?.completedSessions} - groupTotalSessions={timingsTarget.groupProgress?.totalSessions} - /> - )} - {activeVm && ( <> ) { + const router = useRouter(); + + const goBack = () => { + router.push(backHref); + router.refresh(); + }; + + const policy = manageTimingsPolicy({ onSubmit: goBack }); + + return ( + + ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx new file mode 100644 index 000000000..f10c1272a --- /dev/null +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/page.tsx @@ -0,0 +1,111 @@ +import { cache } from "react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { DashboardHeader } from "@/components/dashboard/PageScaffold"; +import { Badge } from "@/components/ui/badge"; +import { readManageTimingsTarget } from "@/lib/data/manage-timings-target"; +import { requirePersonalProfileAccess } from "@/lib/auth/personal-dashboard-access"; +import { buildManageTimingsSubject } from "@/lib/scheduling/manage-timings-subject"; + +import { ManageTimingsClient } from "./ManageTimingsClient"; + +/** + * The consultant setting the times of their own event instance — the fourth + * caller of the shared slot-picker surface, and the one `SlotPicker` was + * generalised FROM. It stayed a dialog the longest because nothing here felt + * per-appointment enough to deserve a URL; cramped on a real calendar grid + * regardless, so it gets the same page treatment as the other three. + * + * `[appointmentId]` carries two different id shapes here, same as the list + * that links here: a real `Appointment` row's id once one exists, or the + * `unscheduled-class-` / `unscheduled-webinar-` synthetic id for an + * offering that has never been scheduled — see manage-timings-target.ts. + */ +type PageProps = { + params: Promise<{ consultantId: string; appointmentId: string }>; +}; + +// React.cache so generateMetadata() and the page body share one query per request. +const loadTarget = cache(readManageTimingsTarget); + +/** + * Names the offering, not the task. A consultant with several unscheduled + * classes had identical "Manage timings" tabs otherwise (#1064). + */ +export async function generateMetadata({ + params, +}: Readonly): Promise { + const { consultantId, appointmentId } = await params; + const target = await loadTarget(appointmentId).catch(() => null); + if (!target) return { title: "Manage timings — Familiarise" }; + + const resolved = buildManageTimingsSubject(consultantId, target.appointment); + return { title: `Manage timings: ${resolved.title} — Familiarise` }; +} + +export default async function ManageTimingsPage({ + params, +}: Readonly) { + const { consultantId, appointmentId } = await params; + // Enforced here rather than in the layout: the layout is a client component, + // so its check runs only after this server render has already streamed. + await requirePersonalProfileAccess("consultant", consultantId); + + const target = await loadTarget(appointmentId); + if (!target) notFound(); + + // The route's consultant must own the plan or be an ACCEPTED collaborator. + // Binds the offering to the URL's consultant; the guard above binds that + // consultant to the session. + if (!target.planOwnerIds.includes(consultantId)) notFound(); + + const resolved = buildManageTimingsSubject( + consultantId, + target.appointment, + target.completedSessions, + target.groupTotalSessions, + ); + + const backHref = `/dashboard/consultant/${consultantId}/appointments`; + + return ( +
+ {/* The OFFERING is the h1, the task is the line under it — see the + reschedule/allocate pages (#1064). */} + + + + Back to appointments + + + {resolved.classInfo && ( +
+ Plan: {resolved.classInfo.planType} + + {resolved.classInfo.sessionsPerWeek} meetings/week ·{" "} + {resolved.classInfo.durationInMonths} month + {resolved.classInfo.durationInMonths !== 1 ? "s" : ""} ·{" "} + {resolved.classInfo.durationInHours}h/session + +
+ )} + + {resolved.classInfo && ( +
+ Tip: Each class is{" "} + {Math.ceil(resolved.classInfo.durationInHours / 0.5)} consecutive + 30-min slots. Complete an in-progress class before starting another. + Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly + limit applies. +
+ )} + + +
+ ); +} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx deleted file mode 100644 index c2ec8eb1d..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/components/EventTimingsCalendar.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useParams } from "next/navigation"; -import { SlotPicker } from "@/components/scheduling/SlotPicker"; -import { manageTimingsPolicy } from "@/components/scheduling/slot-picker-policy"; -import type { UnscheduledAppointment } from "../utils/unscheduledAppointments"; -import { getClassPlanDefaults, type ClassPlanType } from "@/utils/classPlans"; - -/** - * The consultant setting the times of their own event instance — the fourth - * caller of the shared slot-picker surface, and the one that already handled - * all four offering types and clamped to the scheduling period, so it is what - * `SlotPicker` was generalised FROM. It stays a dialog (nothing here is - * per-appointment enough to deserve a URL) and everything below the header is - * now the shared component under the MANAGE_TIMINGS policy. - */ - -interface EventDetails { - eventType: "consultation" | "subscription" | "webinar" | "class"; - eventId: string; - sessionsPerWeek?: number; - durationInMonths: number; - durationInHours: number; - sessionDurationInHours?: number; - totalSessions?: number; - title: string; - planType?: ClassPlanType; -} - -/** Only the recurring types carry a scheduling period to clamp to. */ -function getSchedulingPeriod(appointment: UnscheduledAppointment): { - start?: Date; - end?: Date; -} { - const event = - appointment.appointmentType === "SUBSCRIPTION" - ? appointment.subscription - : appointment.appointmentType === "CLASS" - ? appointment.class - : null; - return { - start: event?.schedulingPeriodStartsAt - ? new Date(event.schedulingPeriodStartsAt) - : undefined, - end: event?.schedulingPeriodEndsAt - ? new Date(event.schedulingPeriodEndsAt) - : undefined, - }; -} - -interface EventTimingsCalendarProps { - isOpen: boolean; - onClose: () => void; - appointment: UnscheduledAppointment; - completedSessions?: number; - groupTotalSessions?: number; -} - -export function EventTimingsCalendar({ - isOpen, - onClose, - appointment, - completedSessions, - groupTotalSessions, -}: EventTimingsCalendarProps) { - const params = useParams(); - - const consultantId = params.consultantId?.toString() || ""; - const getEventDetails = (appointment: UnscheduledAppointment): EventDetails => { - switch (appointment.appointmentType) { - case "CONSULTATION": - return { - eventType: "consultation", - eventId: appointment.consultation?.id || "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: - appointment.consultation?.consultationPlan?.durationInHours || 1, - title: - appointment.consultation?.consultationPlan?.title || "Consultation", - }; - case "SUBSCRIPTION": - return { - eventType: "subscription", - eventId: appointment.subscription?.id || "", - sessionsPerWeek: - appointment.subscription?.subscriptionPlan?.sessionsPerWeek || 1, - durationInMonths: - appointment.subscription?.subscriptionPlan?.durationInMonths || 1, - durationInHours: - appointment.subscription?.subscriptionPlan - ?.sessionDurationInHours || 1, - sessionDurationInHours: - appointment.subscription?.subscriptionPlan - ?.sessionDurationInHours || 1, - totalSessions: - appointment.subscription?.subscriptionPlan?.totalSessions ?? - undefined, - title: - appointment.subscription?.subscriptionPlan?.title || "Subscription", - }; - case "WEBINAR": - return { - eventType: "webinar", - eventId: appointment.webinar?.id || "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: - appointment.webinar?.webinarPlan?.durationInHours || 1, - title: appointment.webinar?.webinarPlan?.title || "Webinar", - }; - case "CLASS": { - const classPlan = appointment.class?.classPlan; - const defaults = getClassPlanDefaults(classPlan ?? {}); - return { - eventType: "class", - eventId: appointment.class?.id || "", - sessionsPerWeek: defaults.classesPerWeek, - durationInMonths: defaults.durationInMonths, - durationInHours: - classPlan?.sessionDurationInHours ?? - defaults.sessionDurationInHours, - totalSessions: classPlan?.totalSessions ?? undefined, - title: classPlan?.title || "Class", - planType: defaults.type, - }; - } - default: - return { - eventType: "consultation", - eventId: "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: 1, - title: "Event", - }; - } - }; - - const eventDetails = getEventDetails(appointment); - const schedulingPeriod = getSchedulingPeriod(appointment); - - const appendProgressText = ( - baseText: string, - completed?: number, - total?: number, - ): string => { - if (completed && completed > 0 && total) { - const remaining = total - completed; - return `${baseText} ${completed} of ${total} sessions completed — select times for the remaining ${remaining}.`; - } - return baseText; - }; - - const getDescriptionText = () => { - switch (appointment.appointmentType) { - case "CONSULTATION": - return "Select consecutive time slots for your consultation. All slots must be on the same day."; - case "SUBSCRIPTION": { - const baseText = `Schedule ${eventDetails.sessionsPerWeek} session${eventDetails.sessionsPerWeek !== 1 ? "s" : ""} per week for ${eventDetails.durationInMonths} month${eventDetails.durationInMonths !== 1 ? "s" : ""}. Each session is ${eventDetails.sessionDurationInHours || 1} hour${(eventDetails.sessionDurationInHours || 1) > 1 ? "s" : ""}.`; - return appendProgressText( - baseText, - completedSessions, - groupTotalSessions, - ); - } - case "WEBINAR": - return "Select consecutive time slots for your webinar session."; - case "CLASS": { - const sessionDuration = eventDetails.durationInHours || 1; - const durationText = - sessionDuration === 1 ? "1 hour" : `${sessionDuration} hours`; - const sessionsPerWeek = eventDetails.sessionsPerWeek || 1; - const classBaseText = `Schedule ${sessionsPerWeek} session${sessionsPerWeek !== 1 ? "s" : ""} per week. Each session is ${durationText}.`; - return appendProgressText( - classBaseText, - completedSessions, - groupTotalSessions, - ); - } - default: - return "Select time slots for your event."; - } - }; - - return ( - - - - - {appointment.appointmentType === "CLASS" - ? "Manage Class Timings" - : `Manage ${eventDetails.title} Timings`} - - {getDescriptionText()} - {appointment.appointmentType === "CLASS" && ( -
- - Plan: {eventDetails.planType || "Custom"} - - - {eventDetails.sessionsPerWeek} meetings/week ·{" "} - {eventDetails.durationInMonths} month - {eventDetails.durationInMonths !== 1 ? "s" : ""} ·{" "} - {eventDetails.durationInHours || 1}h/session - -
- )} -
- - {/* Guidance prompt for class rules */} - {appointment.appointmentType === "CLASS" && ( -
- Tip: Each class is{" "} - {Math.ceil((eventDetails.durationInHours || 1) / 0.5)} consecutive - 30‑min slots. Complete an in‑progress class before starting another. - Max {eventDetails.sessionsPerWeek || 2} classes per day; weekly - limit applies. -
- )} - - -
-
- ); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts deleted file mode 100644 index 043116242..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/appointmentTimingHelpers.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { TAppointment } from "@/types/appointment"; - -interface AppointmentTimingDetails { - eventType: "consultation" | "subscription" | "webinar" | "class"; - eventId: string; - /** Sessions per week; 1 for the one-off event types. */ - sessionsPerWeek: number; - durationInMonths: number; - durationInHours: number; - title: string; - canManageTimings: boolean; -} - -/** - * Extracts timing details from an appointment for use with EventTimingsCalendar - */ -function getAppointmentTimingDetails( - appointment: TAppointment, -): AppointmentTimingDetails { - switch (appointment.appointmentType) { - case "CONSULTATION": - return { - eventType: "consultation", - eventId: appointment.consultation?.id || "", - sessionsPerWeek: 1, // Consultations are one-time events - durationInMonths: 1, // Consultations are one-time events - durationInHours: - appointment.consultation?.consultationPlan?.durationInHours || 1, - title: - appointment.consultation?.consultationPlan?.title || "Consultation", - canManageTimings: !!appointment.consultation?.id, - }; - case "SUBSCRIPTION": { - const sessionsPerWeek = - appointment.subscription?.subscriptionPlan?.sessionsPerWeek || 1; - return { - eventType: "subscription", - eventId: appointment.subscription?.id || "", - sessionsPerWeek, - durationInMonths: - appointment.subscription?.subscriptionPlan?.durationInMonths || 1, - durationInHours: - appointment.subscription?.subscriptionPlan?.sessionDurationInHours || - 1, - title: - appointment.subscription?.subscriptionPlan?.title || "Subscription", - canManageTimings: !!appointment.subscription?.id, - }; - } - case "WEBINAR": - return { - eventType: "webinar", - eventId: appointment.webinar?.id || "", - sessionsPerWeek: 1, // Webinars are one-time events - durationInMonths: 1, // Webinars are one-time events - durationInHours: appointment.webinar?.webinarPlan?.durationInHours || 1, - title: appointment.webinar?.webinarPlan?.title || "Webinar", - canManageTimings: !!appointment.webinar?.id, - }; - case "CLASS": { - // Calculate session duration for classes - const classPlan = appointment.class?.classPlan; - const sessionsPerWeek = classPlan?.sessionsPerWeek || 1; - const durationInMonths = classPlan?.durationInMonths || 1; - - // Use a reasonable default session duration - // Since classContents is not available in TAppointment type, use smart defaults - let sessionDurationInHours = 1.5; // Default for classes - - // For classes, we can estimate based on duration and frequency - if (durationInMonths > 1 && sessionsPerWeek > 1) { - // For longer, more frequent classes, use slightly longer sessions - sessionDurationInHours = 2.0; - } else if (durationInMonths > 6) { - // For very long courses, use standard session length - sessionDurationInHours = 1.5; - } - - return { - eventType: "class", - eventId: appointment.class?.id || "", - sessionsPerWeek, - durationInMonths, - durationInHours: sessionDurationInHours, - title: appointment.class?.classPlan?.title || "Class", - canManageTimings: !!appointment.class?.id, - }; - } - default: - return { - eventType: "consultation", - eventId: "", - sessionsPerWeek: 1, - durationInMonths: 1, - durationInHours: 1, - title: "Event", - canManageTimings: false, - }; - } -} - -/** - * Checks if an appointment supports timing management - */ -export function canManageAppointmentTimings( - appointment: TAppointment, -): boolean { - const details = getAppointmentTimingDetails(appointment); - return details.canManageTimings; -} - -/** - * Checks if a group of appointments supports timing management - * Used for recurring events (subscriptions, classes) to show timing management at group level - */ -export function canManageGroupTimings( - groupAppointments: TAppointment[], -): boolean { - if (!groupAppointments || groupAppointments.length === 0) { - return false; - } - - // Check if any appointment in the group supports timing management - // Use the first appointment as representative of the group - const firstAppointment = groupAppointments[0]; - return canManageAppointmentTimings(firstAppointment); -} diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts b/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts deleted file mode 100644 index a63bdf0fd..000000000 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/utils/unscheduledAppointments.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Builders for partial appointment objects used to open EventTimingsCalendar - * for events that exist in the DB but have no Appointment rows yet. - * - * Returns Partial with the required id + appointmentType fields. - * EventTimingsCalendar accesses all fields via optional chaining, so partial - * data is safe. - */ - -import type { TAppointment } from "@/types/appointment"; -import { UnscheduledClass, UnscheduledWebinar } from "../../../types"; - -/** Minimum required fields for EventTimingsCalendar */ -export type UnscheduledAppointment = Partial & - Pick; - -export function buildUnscheduledClassAppointment( - classEvent: UnscheduledClass, -): UnscheduledAppointment { - return { - id: `unscheduled-class-${classEvent.id}`, - appointmentType: "CLASS", - class: { - id: classEvent.id, - status: classEvent.status, - schedulingPeriodStartsAt: classEvent.schedulingPeriodStartsAt - ? new Date(classEvent.schedulingPeriodStartsAt) - : null, - schedulingPeriodEndsAt: classEvent.schedulingPeriodEndsAt - ? new Date(classEvent.schedulingPeriodEndsAt) - : null, - classPlan: classEvent.classPlan, - } as TAppointment["class"], - slotsOfAppointment: [], - }; -} - -export function buildUnscheduledWebinarAppointment( - webinarEvent: UnscheduledWebinar, -): UnscheduledAppointment { - return { - id: `unscheduled-webinar-${webinarEvent.id}`, - appointmentType: "WEBINAR", - webinar: { - id: webinarEvent.id, - status: webinarEvent.status, - webinarPlan: webinarEvent.webinarPlan, - } as TAppointment["webinar"], - slotsOfAppointment: [], - }; -} diff --git a/components/dashboard/CollapsibleSidebar.tsx b/components/dashboard/CollapsibleSidebar.tsx index a542fe284..51002281a 100644 --- a/components/dashboard/CollapsibleSidebar.tsx +++ b/components/dashboard/CollapsibleSidebar.tsx @@ -157,6 +157,14 @@ export function CollapsibleSidebar({ const router = useRouter(); const [collapsed, setCollapsed] = useState(false); + // An EMPTY array is truthy, so a consultant with no org memberships still got + // the switcher chrome — a chevron opening a menu containing nothing. Labels + // and separators alone are not something to switch TO either, so the chip + // only becomes a dropdown when there is at least one real destination in it. + const hasChipActions = !!bottomUserChipActions?.some( + (action) => action.type === "item", + ); + // Per-group collapsed map, keyed by the group's label. Defaults to // each group's `defaultCollapsed` on first render so callers can // hint "Operations" closed for OWNER while leaving People + Commerce @@ -472,12 +480,12 @@ export function CollapsibleSidebar({ - {/* Footer */} +{/* Footer */}
- {/* Personal chip — clickable dropdown when `bottomUserChipActions` - is provided (org switcher lives here), otherwise a static strip. - Sign Out remains as a separate standalone red button below. */} - {bottomUserChip && bottomUserChipActions ? ( + {/* Personal chip — a dropdown only when there is somewhere to go (the + org switcher lives here), otherwise a static strip. Sign Out remains + a separate standalone red button below. */} + {bottomUserChip && hasChipActions ? (