fix(booking): weekly availability rows mean the consultant's local day everywhere, and day segments are half-open (#1343, #1342, #1326, #1348, #1415, #1416) - #1512
Conversation
…y everywhere, and day segments are half-open (#1343, #1342, #1326, #1348, #1415, #1416) `SlotOfAvailabilityWeekly.startDay` had four readings and no owner. The settings save path shifted it to the UTC day the converted instant landed on, onboarding stored the consultant's local day, the validator and the allocator assumed the local day, and the calendar grid matched rows against the VIEWER's weekday. For an Asia/Kolkata consultant every row starting before 05:30 local therefore walked back one weekday on each save — Monday 01:00 saved as Sunday, reloaded into Sunday's form row, saved again as Saturday — and an overseas customer was shown those rows a day away from the day checkout would accept a booking on. This establishes one rule: `startDay` is the day the consultant published, in their own calendar, and the UTC weekday is always derived from it through the row's own frozen `utcOffsetMinutes`. That derivation lived in three copies and was missing from the grid entirely; it now lives once in `utils/schedule/weekly-projection.ts`, which also owns the overnight-aware duration and `weeklyRowOccurrencesInRange`, the single generator the grid, the allocator's next-occurrence and day-match helpers, and the pin all share. The two save-shape builders in `utils/schedule/formatting.ts` collapse into one, so they cannot disagree again. The offset itself was equally unowned: onboarding and the per-row PATCH defaulted a consultant with no profile timezone to UTC 0 rather than to the launch offset, and nothing checked a caller who sent an offset of their own. `lib/scheduling/weeklyUtcOffset.ts` is now the only answer — derived from `User.timezone`, 330 when the profile has no usable zone (ADR 17), a 400 carrying `UTC_OFFSET_CONFLICT` when a caller contradicts it, and one Sentry warning per write when the consultant publishes from outside Asia/Kolkata, which is the signal that the #872 reader work has become due. All four weekly write paths now also dual-write the five DST columns from the same resolver; nothing reads them, and the coalesce pass that deletes and recreates rows carries them across so they are not silently unwritten. Onboarding additionally stamped the offset after `mergeAdjacentWeeklyRows` rather than before it, so that fold's cross-offset guard was comparing undefined with undefined. Two boundary bugs fall out of the same area. `splitSlotsByDay` cut day segments at 23:59:59.999 instead of the next day's midnight, and a 23:30–23:59:59.999 remainder is not a thirty-minute atom, so a consultant who published up to local midnight lost their last bookable slot on every surface. The expert page's display merge carried a 60-second tolerance where booking requires exact adjacency, so the availability card advertised windows whose seam no row publishes and checkout's per-atom union coverage then refused them. No data repair: the rows the old settings path wrote are pre-MVP mock data and the reset wipes them, per the no-backfill rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…n and the DST dual-write ADR B4 was written as a storage decision and left the meaning of the day enum unstated, which is how four surfaces came to disagree about it; it now states the rule, the history that produced it and the alternative that was rejected, and its status changes from wholly superseded to live with only its grouping half superseded by B9. `03-slot-math-and-calculations.md` gains a section on projecting a weekly row onto real dates that carries the formula, the shared generator, the half-open segmentation rule and the exact-adjacency merge rule. `02-event-types-and-validation.md` says LOCAL where it used to say only "source of truth", and names the helper. `19-dst-and-timezone-posture.md` is corrected where it now contradicted the code: the local columns are dual-written rather than unwritten, the drift warning it asked for exists, and rule 1 becomes "never read them, and carry them through any path that recreates rows". The booking skill's availability reference gets the same three facts, because it is what a future agent loads before touching this code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Warning Review limit reachedNext included review available in 6 minutes. View limit detailsLimit details: You’ve used the included review currently available. Your 89 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (21)
Comment |
|
@coderabbitai full review |
|
|
…nance, no-show cancels write history, and the doctrine text matches the sweeps (#1506) (#1516) ## Summary 1. **`expire-stale-requests` joins `FINANCIAL_JOB_NAMES`.** Its `expirePaymentPendingRequests`/`expireApprovedUnallocatedSubscriptions` passes call `refundPaymentsForExpired`, a refund front-door caller like every other job already in the set, so DEGRADED maintenance now holds it with the rest. `detect-consultant-no-shows` was already there via #1505. 2. **A registry pin gates every future refund-front-door caller, not just today's two.** `__tests__/maintenance/cron-lock-registry.test.ts` gains one assertion that greps `scripts/**/*.ts` for callers of `refundBookingPayment(`, `refundWholeEventPayments(`, `refundRemovedAttendeeSeat(`, and `refundPaymentsForExpired(`, and asserts each caller's `withCronLock` name is in `FINANCIAL_JOB_NAMES`. 3. **Two more money-twin routes drop their `status: () => 200` override.** `app/api/cleanup/process-payouts/route.ts` and `.../sweep-abandoned-overage-charges/route.ts` now fall through to `cleanup-route.ts`'s default `statusFor`, which reads `result.success`, mirroring `release-earnings`/`sync-payment-earnings` under #1390. 4. **The no-show cancel writes a `BookingStatusHistory` row.** `claimConsultantNoShow` in `scripts/appointments/detect-consultant-no-shows.ts` now runs the CANCELLED transition through `transitionConsultationRequest` inside `prisma.$transaction`, instead of a bare `consultation.updateMany`. The zero-row "someone else moved it" outcome is preserved via `IllegalTransitionError` catch. Candidate query, grace/handoff constants, refund call and notifications are untouched. 5. **Doctrine text corrected in `.claude/skills/booking/SKILL.md`.** Rule 2 no longer claims `expire-stale-requests.ts`/`cleanup-tentative-slots.ts` hard-delete tentative holds (fixed by #1380/#1424's soft-cancel via `transitionSlotCompletion`). Rule 5 no longer names `expirePaymentPendingRequests` as the doctrine's counter-example (fixed by #1423's CAS + money-predicate rewrite). 6. **`docs/booking/18-state-machines.md`'s reschedule section corrected.** `COUNTERED` is noted as an unreachable enum edge with no writer (the counter-round was removed per `lib/booking/reschedule-proposals.ts`), and `AUTO_ACCEPTED` is documented as the second terminal-acceptance state. 7. **Glossary linked.** `docs/booking/README.md` links `docs/enterprise/00-foundations/07-slots-sessions-glossary.md` under Core Concepts. 8. **DEGRADED gate noted in both cron references.** `docs/booking/13-cron-jobs-and-background-tasks.md`'s Safety paragraphs and `docs/maintenance/04-cron-jobs-reference.md`'s table rows for both jobs now say they are held during DEGRADED as well as OFFLINE. 9. **Consolidated train changelog.** One new `## Changelog: 2026-09-05 — booking closure train` section in `docs/booking/05-troubleshooting-and-changelog.md`, with one subsection per train PR (#1512, #1513, #1514, #1515, this PR), written from each PR's merged/open body. Also fixes the stale "only surviving `MANUAL_REVIEW` path" sentence that #1513 obsoletes. ## Files - `lib/maintenance-cron.ts` - `app/api/cleanup/process-payouts/route.ts` - `app/api/cleanup/sweep-abandoned-overage-charges/route.ts` - `scripts/appointments/detect-consultant-no-shows.ts` - `__tests__/maintenance/cron-lock-registry.test.ts` - `__tests__/booking/no-show-refund-front-door.test.ts` - `__tests__/maintenance/no-show-auto-complete-handoff.test.ts` - `.claude/skills/booking/SKILL.md` - `docs/booking/18-state-machines.md` - `docs/booking/README.md` - `docs/booking/13-cron-jobs-and-background-tasks.md` - `docs/maintenance/04-cron-jobs-reference.md` - `docs/booking/05-troubleshooting-and-changelog.md` ## Verification | Check | Result | | --- | --- | | `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` (after rebase onto `01a377342`) | exit 0, no errors | | `npx eslint` on all 7 changed/new code files | 0 problems | | `npx prettier --check` on all 7 changed/new code files | clean | | `npx prettier --check` on the 6 changed docs files | 5 clean; `docs/booking/18-state-machines.md` was already Prettier-dirty on `origin/dev` (unpadded tables and a wrapped bullet outside the section I touched) and is left as-is per the existing project pattern (see #1514's note on the same posture); the lines I added are themselves Prettier-clean | | `npx jest __tests__/maintenance __tests__/booking __tests__/appointments` (after rebase) | exit 0 — **87 suites, 1348 tests passed** | | Two suites mocked Prisma without `$transaction` (`__tests__/booking/no-show-refund-front-door.test.ts`, `__tests__/maintenance/no-show-auto-complete-handoff.test.ts`) | extended the mocks with `$transaction`, `consultation.findUnique`, and `bookingStatusHistory.create` rather than weakening any assertion | ## Not done - None of the six numbered spec items were skipped. Closes #1506 Part of #1338 Part of #1493 Part of #1420 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
|
Sentry finding from the branch deploy (poller, 2026-09-05 23:45Z, release 363e074, consultant profile e7f961b1…, trace 0a33363d): saving weekly availability fails for a consultant whose profile timezone is not Asia/Kolkata (this one is Africa/Mogadishu, +180).
Net effect on this branch: any non-IST consultant cannot save weekly availability and gets no actionable message. Two ways to close it before merge: have the client derive the offset from the server-pinned value (or omit |
…1523) ## Summary Release of the 2026-09-05/06 booking backlog closure train plus the fixes found by the end-to-end pass on the dev deploy. Base: prod at 576b97c; head: dev at 9b45a82. ## Schema Additive only, already pushed to the shared database on 2026-09-06 with sidecars applied and asserted (45 constraints / 6 indexes / 3 triggers): `CancellationPolicy`, `CancellationPolicyTier`, `Appointment.cancellationPolicyId` (+ index), enum `CancellationPolicyStatus`. The platform default policy row is provisioned. Backup: `familiarise-pre-schema-push-20260906-0423.dump`. No push is needed for this release. ## Commits (dev not in prod) - 9b45a82 fix(booking): allocation-lock deserialization, reschedule auto-confirm diagnostics, Razorpay CSP, planner class dates (#1520) - ea46cd9 fix(booking): the initial-allocation advisory lock runs through $executeRaw so nothing deserialises a void column (#1518) (#1519) - 363e074 chore(booking): the two refunding sweeps are gated in DEGRADED maintenance, no-show cancels write history, and the doctrine text matches the sweeps (#1506) (#1516) - eee250e feat(booking): cancellation terms are typed versioned rows with per-org tiers, and a credit-funded partial cancel restores the credit in full (#1499, #1500, #1372) (#1513) - a800874 fix(booking): the allocator no longer declines the reschedule proposal it is confirming, and accept serialises on the appointment lock (#1340) (#1515) - 7a0bf0a fix(booking): the cancel and reschedule routes move status through the CAS helpers, and cancelled slots are tombstoned (#1383) - 01a3773 fix(booking): weekly availability rows mean the consultant's local day everywhere, and day segments are half-open (#1343, #1342, #1326, #1348, #1415, #1416) (#1512) - 73c0d9f fix(dashboard): the consultant Home badge counts personal requests like the card below it, class cards show their first session, and a trial's Pay Now lands on the branded checkout (#1345, #1346, #1429) (#1514) - a0d6c09 fix(booking): auto-complete hands an unattended consultation to the no-show detector instead of closing it first (#1504) (#1505) - a7c36ec fix(content): honest public stats, and a plan title instead of a UUID in the payment-success notification (#1484, #1485) (#1489) - fe0c476 chore(skills,agents): reorganise Claude Code skills and agents into a domain hierarchy (#1483) - b760943 chore(ci): make Claude review/mention workflows on-demand, not a red check (#1480) - 9dc665a feat(plans): sole-owner archive/restore for consultation, subscription, webinar and class plans (#1507) ## Verification - Every PR merged green (Lint, TypeScript/Tests/Build, SonarCloud gate OK, CodeRabbit threads triaged and resolved). - End-to-end on the dev branch deploy (Chrome DevTools + Supabase MCP): availability day anchoring, Home badge parity, org tier editor incl. negative cases, org-funded checkout stamping the org policy version and refunding 100% on the INTERNAL rail, personal booking stamping the platform row, DEGRADED-gate probe 401, no console errors or 5xx. - Post-fix re-verification on the same deploy (9b45a82): planner class cards show the first session (payload `firstSessionAt` 2026-09-10T10:00Z, card 3:30 PM IST); a consultee proposal on a green slot auto-confirms (`autoConfirmed: true`, request `AUTO_ACCEPTED`, slots moved, history rows PENDING → RESCHEDULED ×2 → APPROVED → AUTO_ACCEPTED). ## Owner items unchanged by this release - Novu: production uses the Development environment key and the account's workflow limit blocks creating the two missing workflows (#1511). - Resend key (#1298), Razorpay LIVE keys (#1377), load gate (#874). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
|
Correction to my comment above: the 400 |




Summary
The one rule this PR establishes:
SlotOfAvailabilityWeekly.startDayis the day the consultant published, in their own local calendar, and the UTC weekday is always derived from it through the row's own frozenutcOffsetMinutes. Four surfaces had each answered that question for themselves, and the disagreements are the six issues below.shiftDayOfWeekand thestartDayOffsetblock are gone.utils/schedule/formatting.tsnow has one builder,weeklySlotForSave, that both save paths use;formatWeeklySlotis a thin adapter over it and keeps the #1125 all-or-nothing throw contract.utcOffsetMinutes, so an overseas customer was shown an IST pre-05:30 row a day away from the day checkout would accept.utils/schedule/weekly-projection.tsownsutcStartDayIndex,weeklyRowDurationMinutesandweeklyRowOccurrencesInRange— the single generator the grid,isMinuteWithinWeeklySlot,getNextOccurrenceWeeklyandmatchWeeklySlotToDayall share. The grid endpoint carries the stored columns through instead of flattening them onto a 1970 reference date, andprocessWeeklySlotsloses itstimezoneparameter because display zoning belongs downstream insplitSlotsByDay.lib/scheduling/weeklyUtcOffset.tsis the only answer: derived fromUser.timezone, 330 when the profile carries no usable zone (ADR 17),WeeklyOffsetConflictError→ 400UTC_OFFSET_CONFLICTwhen a caller contradicts it, and one Sentry warning per write (never per row) when the consultant publishes from outside Asia/Kolkata.PIN_TO_LAUNCH_OFFSETis the one-line switch to hard-pin every row to IST. Onboarding also stamped the offset aftermergeAdjacentWeeklyRows, so that fold's cross-offset guard was comparingundefinedwithundefined; it is stamped before the fold now.splitSlotsByDaycut day segments atendOfDay(23:59:59.999). A 23:30–23:59:59.999 remainder is not a thirty-minute atom, so a block published up to local midnight silently lost its last bookable slot on every surface.mergeConsecutiveSlotsForDisplayrequires exact adjacency, and its docblock now states the truth: the only difference frommergeConsecutiveSlotsis which atoms are eligible (same-status versus available-only).Per user decision 6 / #872, all four weekly write paths additionally dual-write the five DST columns (
timezone,localStartMinutes,localEndMinutes,localStartDay,localEndDay) from the same resolver, computed after the merge so they describe the row actually stored. Nothing reads them.coalesceConsultantWeeklyRowsdeletes and recreates rows, so it recomputes them for the merged row rather than losing them on the next coalesce. Theschema.prismadoc-comment changes from "unwritten" to "written from 2026-09-05, read by nothing until the reader flip" — comment only, no schema change, nodb pushneeded.No data repair. The rows the old settings path wrote are pre-MVP mock data; the no-backfill rule applies and the pre-MVP reset wipes them.
Rebands worth recording
availability-window-merge.test.ts:240-245already proves a two-hour block yields one two-hour window across rows. The real residual was theendOfDaybound described above, and that is what this PR fixes.mergeAdjacentWeeklyRowscross-offset bullet is addressed (by the ordering fix above); see follow-ups.Files touched
New:
utils/schedule/weekly-projection.ts,lib/scheduling/weeklyUtcOffset.ts,__tests__/booking-algorithm/weekly-day-semantics.test.ts.Changed:
utils/schedule/formatting.ts,utils/timeSlotsProcessing.ts,utils/slotAllocation/slotTimeUtils.ts,utils/slotAllocation/SlotAllocationService.ts,utils/slotAllocation/mergeAdjacentWeeklyRows.ts,utils/onboarding-server.ts,app/api/slots/availability-with-allocation/[consultantId]/route.ts,app/api/slots/availability/weekly/route.ts,app/api/slots/availability/weekly/[id]/route.ts,app/api/user/consultants/[id]/route.ts,app/explore/experts/[consultantId]/utils/mergeSlots.ts,prisma/schema.prisma(comment only),__tests__/schedule/format-slots-for-api-throws.test.ts.Docs:
docs/booking/00-architecture-decisions.md(ADR B4 rewritten, register row and status updated),docs/booking/02-event-types-and-validation.md,docs/booking/03-slot-math-and-calculations.md(new section "Projecting a weekly row onto real dates"),docs/booking/19-dst-and-timezone-posture.md(rules 1–4 corrected — the local columns are written now and the drift warning exists),.claude/skills/booking/references/availability.md§1/§2/§4.docs/booking/05-troubleshooting-and-changelog.mdis deliberately untouched; PR-D writes one consolidated section.Verification
rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmitnpx prisma generatedeliberately not run — a sibling worktree owns the shared client.npx eslintover all 15 changed/new TypeScript filesno-explicit-anyatSlotAllocationService.ts:3445and:3498(verified identical onorigin/devat lines 3446/3499, only shifted by edits above them). Nothing new introduced.npx prettier --checkover all 21 changed filesnpx jest __tests__/booking-algorithm __tests__/booking __tests__/schedule{MONDAY, 1170–1410, offset 330}), assertingweeklyRowOccurrencesInRangecoverage ⇔isMinuteWithinWeeklySlot.ATOMS=336 GRID=8 VALIDATOR=8 MISMATCHES=0— the eight atoms are the four hours the row publishes, and the grid and the validator agree on every one of the 336.The committed pin,
__tests__/booking-algorithm/weekly-day-semantics.test.ts, is table-driven, node-environment and Prisma-free, and covers all six required areas: the two save paths agreeing onstartDay(including re-save idempotency), the offset resolver's table plus a source assertion that the four write paths callresolveWeeklyUtcOffsetMinutesand no longer callgetTimezoneOffsetMinutesdirectly, grid-equals-validator for the IST pre-dawn row seen from both anAsia/Kolkataand anAmerica/New_Yorkviewer, the midnight block keeping its 23:30 window, display-merge-equals-booking-merge over gaps of 0/1,000/60,000 ms, and the dual-written local columns.slotTimeUtils.test.ts,availability-window-merge.test.ts,availability-grid-conditional-get.test.ts,availability-window-scan.test.tsandslot-session-fix-pins.test.tswere run and are green, and were not edited.Not done / follow-ups
validateTimeIncrementsis out of scope. It is a private helper on a separate rejection surface, so the server write paths still do not run it and a minute-level seam remains creatable through the API even though neither merge function will now advertise one.fix/explore-booking-responsive) overlaps this area textually only. Per the guardrail,ConsultationPricingToggle.tsx,ExpertPricing.tsx,WeeklyAvailability.tsxandCustomAvailability.tsxare untouched; the only explore file changed here isutils/mergeSlots.ts.19-dst-and-timezone-posture.md.Closes #1343
Closes #1342
Closes #1326
Closes #1348
Closes #1415
Closes #1416
Part of #872
Part of #1433
🤖 Generated with Claude Code
https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1