From a8a8d1e0455b321409103f9629f584002c13f272 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Thu, 8 Oct 2026 11:01:32 +0100 Subject: [PATCH] Updated Calendard The calendar is now react-big-calendar. --- docs/calendar.md | 55 ++ package-lock.json | 259 +++++++- package.json | 3 + src/app/(owner)/owner/calendar/page.tsx | 8 +- src/app/(staff)/staff/page.tsx | 2 +- src/features/staff/actions/calendar-move.ts | 55 ++ .../__tests__/calendar-time.test.ts | 26 + .../staff/components/calendar-time.ts | 34 + .../staff/components/day-calendar.css | 158 +++++ .../staff/components/day-calendar.tsx | 586 +++++++++++------- 10 files changed, 956 insertions(+), 230 deletions(-) create mode 100644 docs/calendar.md create mode 100644 src/features/staff/actions/calendar-move.ts create mode 100644 src/features/staff/components/__tests__/calendar-time.test.ts create mode 100644 src/features/staff/components/calendar-time.ts create mode 100644 src/features/staff/components/day-calendar.css diff --git a/docs/calendar.md b/docs/calendar.md new file mode 100644 index 0000000..fe971fa --- /dev/null +++ b/docs/calendar.md @@ -0,0 +1,55 @@ +# Calendar + +A living reference for the day calendar on `/staff` (a barber's own day) and +`/owner/calendar` (everyone, one column per barber). Both render +`src/features/staff/components/day-calendar.tsx`. + +## What it's built on + +[react-big-calendar](https://github.com/jquense/react-big-calendar) (MIT) with +its drag-and-drop addon, restyled to match the app in `day-calendar.css` +(scoped under `.day-cal`). It was chosen over DayPilot Lite mainly because +per-barber columns, a current-time line and sideways scrolling on a phone all +work out of the box; FullCalendar and Schedule-X put per-resource columns +behind a paid licence. + +Only the day view is used. The agenda (list) view is a separate component. + +## Time zones — read this before touching it + +The library works in JavaScript `Date`s, which carry the **browser's** time +zone; the app's scheduling data is **business-local minutes since midnight on +a business-local date**. So the calendar never sees a real instant: times go in +as "floating" dates (minutes laid onto one fixed day — `calendar-time.ts`) and +a drop comes back out as plain minutes. The real date and the business time +zone are applied on the server in `moveCalendarAppointmentAction`. + +## Drag to reschedule + +Turned on per page with the `reschedule` prop (`"owner"` or `"staff"`). + +- A drop **never saves on its own.** It opens a confirmation ("From 11:00 AM + with Leo → To 1:00 PM with Leo… the customer will be notified"), because a + reschedule emails the customer. +- `"owner"`: any appointment in the business, and it can be dropped into + another barber's column (a reassignment). `"staff"`: their own bookings, along + the time axis only. +- Only `pending_approval`, `confirmed` and `arrived` bookings can be moved — + the same statuses `rescheduleBooking` accepts. Breaks, time off, and finished + or in-progress bookings are not draggable. Resizing (changing the length) is + off: duration comes from the services. +- **The server is the gate.** `moveCalendarAppointmentAction` delegates to the + existing `ownerRescheduleAppointmentAction` / `rescheduleStaffAppointmentAction`, + so working hours, breaks, overlaps, the staff member's services, the business's + minimum-notice rule, the audit log, notifications, reminders and Google + Calendar sync all behave exactly as they do from the appointment screen. A + rejected move shows the reason inside the dialog and nothing is saved. +- The calendar itself does not stop you dropping onto a break or outside + someone's hours — it just fails the server check. + +## Phones + +Columns keep a 190px minimum and the grid scrolls sideways, with the hour +labels pinned on the left and the header row scrolling with it. Dragging was +verified with a mouse only; the library has touch handlers but this has not been +tried on a real touch screen. The appointment screen's Reschedule always works. diff --git a/package-lock.json b/package-lock.json index acd12f6..f9d1d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "image-size": "^2.0.2", "jszip": "^3.10.1", "lucide-react": "^1.28.0", @@ -31,6 +32,7 @@ "next-auth": "^5.0.0-beta.32", "nodemailer": "^8.0.11", "react": "^19.2.8", + "react-big-calendar": "^1.20.0", "react-dom": "^19.2.8", "react-hook-form": "^7.84.0", "server-only": "^0.0.1", @@ -46,6 +48,7 @@ "@types/node": "^26.1.2", "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", + "@types/react-big-calendar": "^1.16.3", "@types/react-dom": "^19.2.4", "@types/web-push": "^3.6.4", "dotenv": "^17.4.2", @@ -327,6 +330,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -2108,6 +2120,16 @@ "node": ">=20" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@prisma/adapter-mariadb": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/@prisma/adapter-mariadb/-/adapter-mariadb-7.9.1.tgz", @@ -3102,6 +3124,18 @@ } } }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -3845,6 +3879,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/date-arithmetic": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz", + "integrity": "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3912,16 +3953,34 @@ "@types/node": "*" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-big-calendar": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-1.16.3.tgz", + "integrity": "sha512-CR+5BKMhlr/wPgsp+sXOeNKNkoU1h/+6H1XoWuL7xnurvzGRQv/EnM8jPS9yxxBvXI8pjQBaJcI7RTSGiewG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/date-arithmetic": "*", + "@types/prop-types": "*", + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", @@ -3932,6 +3991,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/warning": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", + "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", + "license": "MIT" + }, "node_modules/@types/web-push": { "version": "3.6.4", "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", @@ -5445,6 +5510,11 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cldrjs": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz", + "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA==" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -5526,7 +5596,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -5728,6 +5797,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-arithmetic": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", + "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5824,6 +5915,15 @@ "node": ">=0.10" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -5854,6 +5954,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz", + "integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "csstype": "^3.1.3" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -7114,6 +7224,15 @@ "node": ">=10.13.0" } }, + "node_modules/globalize": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/globalize/-/globalize-1.7.1.tgz", + "integrity": "sha512-PFymRL0PtitFOlSniuwwwNfkooi3cLQJo9Uke1+j1DsGfUkkHkwneImqVtGcqKI0TuzhAlHt7hAcgK324902HA==", + "license": "MIT", + "dependencies": { + "cldrjs": "^0.5.4" + } + }, "node_modules/globals": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", @@ -7413,6 +7532,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7893,7 +8021,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8361,7 +8488,12 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -8382,7 +8514,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -8480,6 +8611,12 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8532,6 +8669,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8782,7 +8940,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9349,7 +9506,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -9444,6 +9600,34 @@ "node": ">=0.10.0" } }, + "node_modules/react-big-calendar": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.20.0.tgz", + "integrity": "sha512-Lp1mvG34l/9xtb/2LsBb4UAF3iPcUkmDqbmpsvDHzp/n8GA5gtn3+nf8BsULWw08opKDgv38nQi75dQlOOqzkg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "clsx": "^2.1.1", + "date-arithmetic": "^4.1.0", + "dayjs": "^1.11.21", + "dom-helpers": "^6.0.1", + "globalize": "^1.7.1", + "invariant": "^2.2.4", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "luxon": "^3.7.2", + "memoize-one": "^6.0.0", + "moment": "^2.29.4", + "moment-timezone": "^0.5.48", + "prop-types": "^15.8.1", + "react-overlays": "^5.2.1", + "uncontrollable": "^7.2.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17 || ^18 || ^19", + "react-dom": "^16.14.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-dom": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", @@ -9476,9 +9660,44 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-overlays": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", + "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.8", + "@popperjs/core": "^2.11.6", + "@restart/hooks": "^0.4.7", + "@types/warning": "^3.0.0", + "dom-helpers": "^5.2.0", + "prop-types": "^15.7.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-overlays/node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -10664,6 +10883,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -11244,6 +11478,15 @@ "dev": true, "license": "MIT" }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", diff --git a/package.json b/package.json index 7322b79..3c2d63e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "image-size": "^2.0.2", "jszip": "^3.10.1", "lucide-react": "^1.28.0", @@ -49,6 +50,7 @@ "next-auth": "^5.0.0-beta.32", "nodemailer": "^8.0.11", "react": "^19.2.8", + "react-big-calendar": "^1.20.0", "react-dom": "^19.2.8", "react-hook-form": "^7.84.0", "server-only": "^0.0.1", @@ -64,6 +66,7 @@ "@types/node": "^26.1.2", "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", + "@types/react-big-calendar": "^1.16.3", "@types/react-dom": "^19.2.4", "@types/web-push": "^3.6.4", "dotenv": "^17.4.2", diff --git a/src/app/(owner)/owner/calendar/page.tsx b/src/app/(owner)/owner/calendar/page.tsx index 157d37c..2171db9 100644 --- a/src/app/(owner)/owner/calendar/page.tsx +++ b/src/app/(owner)/owner/calendar/page.tsx @@ -244,7 +244,13 @@ export default async function OwnerCalendarPage({ ) : ( - + ) ) : appointments.length === 0 ? (
diff --git a/src/app/(staff)/staff/page.tsx b/src/app/(staff)/staff/page.tsx index 7c34ebb..ae9aa25 100644 --- a/src/app/(staff)/staff/page.tsx +++ b/src/app/(staff)/staff/page.tsx @@ -187,7 +187,7 @@ export default async function StaffCalendarPage({ description={wholeDayOff.reason ?? "Approved time off."} /> ) : view === "day" ? ( - + ) : calendarAppointments.length === 0 ? (
{ + const parsed = moveSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "That move isn't valid." }; + + const context = await getActingBusinessContext(); + if (!context) return { success: false, error: "You must be signed in." }; + + const { appointmentId, staffId, date, startMinutes, mode } = parsed.data; + const startAt = wallClockToInstant(date, startMinutes, context.businessTimezone).toISOString(); + + const result = + mode === "owner" + ? await ownerRescheduleAppointmentAction({ appointmentId, staffId, startAt }) + : // A barber moves their own bookings in time only — never to someone else. + await rescheduleStaffAppointmentAction({ appointmentId, startAt }); + + if (result.success) { + revalidatePath("/owner/calendar"); + revalidatePath("/staff"); + } + return result; +} diff --git a/src/features/staff/components/__tests__/calendar-time.test.ts b/src/features/staff/components/__tests__/calendar-time.test.ts new file mode 100644 index 0000000..672627e --- /dev/null +++ b/src/features/staff/components/__tests__/calendar-time.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + floatingDateToMinutes, + floatingDisplayDate, + floatingNotToday, + minutesToFloatingDate, +} from "../calendar-time"; + +describe("calendar floating time", () => { + it("round-trips every quarter hour of the day", () => { + for (let minutes = 0; minutes < 24 * 60; minutes += 15) { + expect(floatingDateToMinutes(minutesToFloatingDate(minutes))).toBe(minutes); + } + }); + + it("puts every time on the same single calendar day, regardless of the minute", () => { + const day = (m: number) => minutesToFloatingDate(m).toDateString(); + expect(day(0)).toBe(day(7 * 60)); + expect(day(0)).toBe(day(23 * 60 + 45)); + expect(day(0)).toBe(floatingDisplayDate().toDateString()); + }); + + it("'not today' is a different day than the displayed one", () => { + expect(floatingNotToday().toDateString()).not.toBe(floatingDisplayDate().toDateString()); + }); +}); diff --git a/src/features/staff/components/calendar-time.ts b/src/features/staff/components/calendar-time.ts new file mode 100644 index 0000000..71cd5b8 --- /dev/null +++ b/src/features/staff/components/calendar-time.ts @@ -0,0 +1,34 @@ +/** + * The calendar library works in JavaScript `Date`s, which carry the + * BROWSER's time zone — but this app's scheduling data is in business-local + * minutes-since-midnight on a business-local calendar date (see + * lib/time/business-time.ts). Mixing the two is how a barbershop in + * London shows a 10:00 booking at 05:00 to someone viewing from New York. + * + * So the calendar never sees a real instant. It's handed "floating" dates — + * minutes laid onto one fixed, arbitrary day — and reports a drop back as + * plain minutes-since-midnight; the actual date and the business time zone + * are applied on the server (see features/staff/actions/calendar-move.ts). + * The day is a mid-winter Monday so no time zone's DST change can land on it. + */ +const BASE_YEAR = 2000; +const BASE_MONTH = 0; +const BASE_DAY = 3; + +export function minutesToFloatingDate(minutes: number): Date { + return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY, Math.floor(minutes / 60), minutes % 60, 0, 0); +} + +export function floatingDateToMinutes(date: Date): number { + return date.getHours() * 60 + date.getMinutes(); +} + +/** The day the calendar displays — any date on the floating day. */ +export function floatingDisplayDate(): Date { + return minutesToFloatingDate(0); +} + +/** A moment guaranteed to fall on a different day than the displayed one — passed as "now" when the viewed day isn't today, so the library draws no current-time line. */ +export function floatingNotToday(): Date { + return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY - 2, 12, 0, 0, 0); +} diff --git a/src/features/staff/components/day-calendar.css b/src/features/staff/components/day-calendar.css new file mode 100644 index 0000000..538b9de --- /dev/null +++ b/src/features/staff/components/day-calendar.css @@ -0,0 +1,158 @@ +/* + * Restyles react-big-calendar (see day-calendar.tsx) to match the app: the + * library draws its own box, colours and event chrome, so everything here + * either removes that or swaps in the app's tokens. Scoped under .day-cal so + * nothing leaks to any other page. Colours use the theme variables with a + * literal fallback in case Tailwind hasn't emitted one. + */ +.day-cal { + --dc-line: var(--color-line, #e1e7e2); + --dc-muted: var(--color-muted, #5c6961); + --dc-danger: var(--color-danger, #ef4444); +} + +/* Dragging a booking must never highlight the text around it. */ +.day-cal .rbc-calendar { + user-select: none; + -webkit-user-select: none; +} + +.day-cal .rbc-calendar, +.day-cal .rbc-time-view { + background: transparent; + border: 0; + font-family: inherit; +} + +/* Header row: the app's avatar + name + hours, no library chrome. */ +.day-cal .rbc-time-header { + border: 0; + margin-right: 0 !important; /* the library reserves scrollbar width; the page scrolls instead */ +} +/* Stays in view under the page's sticky top bar (81px tall) so, halfway down + the day, you can still see whose column you're looking at. */ +.day-cal .rbc-time-header { + position: sticky; + top: 81px; + z-index: 25; + background: var(--color-stone, #f4f6f4); +} +.day-cal .rbc-time-header-content { + border-left: 1px solid var(--dc-line); +} +.day-cal .rbc-header { + border-bottom: 0; + border-left: 1px solid var(--dc-line); + padding: 0; + font-weight: inherit; + overflow: visible; +} +.day-cal .rbc-header:first-child { + border-left: 0; +} +/* No all-day row — appointments always have a time. */ +.day-cal .rbc-allday-cell, +.day-cal .rbc-time-header-cell + .rbc-row { + display: none; +} + +/* Grid. */ +.day-cal .rbc-time-content { + border-top: 1px solid var(--dc-line); + overflow-y: visible; /* full-height grid; the page owns the scrollbar */ +} +.day-cal .rbc-time-content > * + * > * { + border-left: 1px solid var(--dc-line); +} +.day-cal .rbc-timeslot-group { + min-height: 112px; /* 4 x 28px quarter-hour rows == the app's 112px per hour */ + border-bottom: 1px solid var(--dc-line); +} +.day-cal .rbc-time-slot { + border-top: 0; +} +.day-cal .rbc-day-slot .rbc-time-slot { + border-top: 1px solid rgba(0, 0, 0, 0.04); +} +.day-cal .rbc-day-slot .rbc-time-slot:first-child { + border-top: 0; +} +.day-cal .rbc-today { + background: transparent; +} +/* Wide enough to read a booking; narrower screens scroll sideways with the + hour labels pinned. The header cells must match the column width exactly or + a name stops lining up with its column once the row overflows. */ +.day-cal .rbc-time-view-resources .rbc-day-slot, +.day-cal .rbc-time-view-resources .rbc-header, +.day-cal .rbc-time-view-resources .rbc-day-bg { + min-width: 190px; /* a minimum, not a width — wider screens still stretch the columns to fill */ +} + +/* Hour labels. */ +.day-cal .rbc-time-gutter .rbc-timeslot-group { + border-bottom: 0; +} +.day-cal .rbc-label { + padding: 0 8px 0 0; + color: var(--dc-muted); + font-size: 10px; + font-weight: 700; +} +.day-cal .rbc-time-gutter, +.day-cal .rbc-time-header-gutter { + background: var(--color-stone, #f4f6f4); + z-index: 5; +} + +/* Current time. */ +.day-cal .rbc-current-time-indicator { + height: 2px; + background-color: var(--dc-danger); + z-index: 30; +} + +/* Outside the working window: the same diagonal hatch as breaks/time off. */ +.day-cal .day-cal-offhours { + background-color: rgba(225, 231, 226, 0.15); + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, rgba(0, 0, 0, 0.05) 6px, rgba(0, 0, 0, 0.05) 12px); +} + +/* Events: the library's coloured box is replaced by the app's own block (EventContent). */ +.day-cal .rbc-event, +.day-cal .rbc-background-event { + background: transparent; + border: 0; + padding: 0; + box-shadow: none; + border-radius: 0; + outline: none; +} +.day-cal .rbc-event .rbc-event-content { + height: 100%; +} +.day-cal .rbc-event.rbc-selected { + background: transparent; +} +.day-cal .rbc-background-event { + z-index: 1; + opacity: 1; + pointer-events: none; +} +.day-cal .rbc-event:focus-visible > .rbc-event-content > div { + outline: 2px solid var(--color-ink, #111b15); + outline-offset: 1px; +} +/* The library's own time label is hidden — EventContent draws a compact one. */ +.day-cal .rbc-event-label { + display: none; +} +.day-cal .day-cal-movable { + cursor: grab; +} +.day-cal .day-cal-movable:active { + cursor: grabbing; +} +.day-cal .rbc-addons-dnd-drag-preview { + opacity: 0.85; +} diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx index 8baa6d1..23df8fd 100644 --- a/src/features/staff/components/day-calendar.tsx +++ b/src/features/staff/components/day-calendar.tsx @@ -1,42 +1,36 @@ +"use client"; + import Image from "next/image"; -import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { Calendar, dateFnsLocalizer } from "react-big-calendar"; +import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"; +import type { EventInteractionArgs } from "react-big-calendar/lib/addons/dragAndDrop"; +import { format, getDay, parse, startOfWeek } from "date-fns"; +import { enGB } from "date-fns/locale"; import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import "react-big-calendar/lib/css/react-big-calendar.css"; +import "react-big-calendar/lib/addons/dragAndDrop/styles.css"; +import "./day-calendar.css"; +import { Button } from "@/components/ui/button"; +import { Modal } from "@/components/ui/modal"; import { cn } from "@/lib/utils/cn"; import type { AppointmentStatus } from "@/types/domain"; +import { moveCalendarAppointmentAction } from "../actions/calendar-move"; +import { + floatingDateToMinutes, + floatingDisplayDate, + floatingNotToday, + minutesToFloatingDate, +} from "./calendar-time"; const START_HOUR = 7; const END_HOUR = 21; const PX_PER_HOUR = 112; -const MINUTES_PER_HOUR = 60; -const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR; -const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR; const HEADER_HEIGHT = 56; - -function topFor(minutes: number): number { - return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * PX_PER_HOUR; -} -function clampToRange(minutes: number): number { - return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes)); -} -function heightFor(startMinutes: number, endMinutes: number): number { - return Math.max(24, topFor(endMinutes) - topFor(startMinutes)); -} -/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */ -function formatCompactTime(minutes: number): string { - const h = Math.floor(minutes / MINUTES_PER_HOUR); - const m = minutes % MINUTES_PER_HOUR; - const displayHour = h % 12 === 0 ? 12 : h % 12; - return `${displayHour}:${String(m).padStart(2, "0")}`; -} -/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */ -function formatCurrentTimeLabel(minutes: number): string { - const h = Math.floor(minutes / MINUTES_PER_HOUR); - const m = minutes % MINUTES_PER_HOUR; - const period = h >= 12 ? "pm" : "am"; - const displayHour = h % 12 === 0 ? 12 : h % 12; - return `${displayHour}:${String(m).padStart(2, "0")} ${period}`; -} +/** The grid is drawn full height and the PAGE scrolls — one scroll area, not a scrollbox inside the page. */ +const CALENDAR_HEIGHT = HEADER_HEIGHT + (END_HOUR - START_HOUR) * PX_PER_HOUR + 8; export interface CalendarAppointment { id: string; @@ -70,6 +64,30 @@ export interface CalendarColumn { workingWindow: { startMinutes: number; endMinutes: number } | null; } +const localizer = dateFnsLocalizer({ + format, + parse, + getDay, + startOfWeek: (date: Date) => startOfWeek(date, { weekStartsOn: 1 }), + locales: { "en-GB": enGB }, +}); +const EMPTY_MOVES: Record = {}; +const DnDCalendar = withDragAndDrop(Calendar); + +interface CalendarResource { + id: string; + column: CalendarColumn; +} + +type CalendarEvent = + | { kind: "appointment"; id: string; resourceId: string; start: Date; end: Date; title: string; appointment: CalendarAppointment } + | { kind: "block"; id: string; resourceId: string; start: Date; end: Date; title: string; block: CalendarBlock }; + +type AppointmentEvent = Extract; + +/** Statuses the booking service will actually reschedule (see NON_RESCHEDULABLE_STATUSES in booking.service.ts). */ +const MOVABLE_STATUSES: AppointmentStatus[] = ["pending_approval", "confirmed", "arrived"]; + const STATUS_BADGE_ICON: Record = { pending_approval: Clock, confirmed: CheckCheck, @@ -80,222 +98,350 @@ const STATUS_BADGE_ICON: Record = { no_show: UserX, }; -/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */ -const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1"; - /** - * A shared-axis, multi-column, swipeable day grid — one time axis pinned to - * the left edge (via `sticky`, so it never scrolls out of view), one column - * per staff member snapping into place as the row is swiped horizontally - * (CSS scroll-snap — no touch/gesture JS needed, matching this app's - * "reliable tap/scroll over fragile custom gestures" preference). A single - * staff member's own view (staff/page.tsx) is just the one-column case of - * the same component. Deliberately no drag-and-drop — every interactive - * element is a plain Link (open details / start a reschedule from there). - * Pure presentation: every minute value here is already computed by the - * page from real WorkingHours/Break/TimeOff/Appointment rows. + * The day grid for one date — one time axis, one column per staff member. + * A single staff member's own view is just the one-column case. + * + * Built on react-big-calendar, but everything it draws is restyled to match + * the app (see day-calendar.css) and it never sees a real time zone: times + * are handed over as "floating" dates (see calendar-time.ts). + * + * `reschedule` turns on drag-to-move. A drop never saves by itself — it opens + * a confirmation first, because a reschedule emails the customer. "owner" + * lets a booking be dragged to another barber's column (a reassignment); + * "staff" only along the time axis of their own column. The server re-checks + * everything (see moveCalendarAppointmentAction), so this is a convenience, + * not the gate. Omit it for a read-only calendar. */ export function DayCalendar({ columns, + date, currentTimeMinutes, appointmentHrefBase = "/staff/appointments", + reschedule, }: { columns: CalendarColumn[]; + /** The business-local calendar date being shown ("YYYY-MM-DD") — needed only to turn a drop back into a real instant on the server. */ + date: string; /** Minutes-from-midnight right now, only passed when the viewed day is today. */ currentTimeMinutes: number | null; - /** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */ + /** Owner's all-staff calendar links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar". */ appointmentHrefBase?: string; + reschedule?: "owner" | "staff"; }) { - const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i); - const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR; - const showCurrentTime = - currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES; + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const rootRef = useRef(null); + const [pendingMove, setPendingMove] = useState<{ + event: AppointmentEvent; + resourceId: string; + startMinutes: number; + } | null>(null); + const [moveError, setMoveError] = useState(null); + /** + * Moves that already succeeded, shown in place until the refreshed server + * data arrives. Tied to the `columns` they were made against, so a new + * `columns` (the refresh landing) discards them with no effect needed. + */ + const [movedState, setMovedState] = useState<{ + forColumns: CalendarColumn[]; + positions: Record; + }>({ forColumns: columns, positions: {} }); + const moved = movedState.forColumns === columns ? movedState.positions : EMPTY_MOVES; + + const { events, backgroundEvents, resources } = useMemo(() => { + const events: CalendarEvent[] = []; + const backgroundEvents: CalendarEvent[] = []; + for (const column of columns) { + for (const appointment of column.appointments) { + const override = moved[appointment.id]; + const start = override ? override.startMinutes : appointment.startMinutes; + const length = appointment.endMinutes - appointment.startMinutes; + events.push({ + kind: "appointment", + id: appointment.id, + resourceId: override ? override.resourceId : column.id, + start: minutesToFloatingDate(start), + end: minutesToFloatingDate(start + length), + title: appointment.customerName, + appointment, + }); + } + for (const block of column.blocks) { + backgroundEvents.push({ + kind: "block", + id: block.id, + resourceId: column.id, + start: minutesToFloatingDate(block.startMinutes), + end: minutesToFloatingDate(block.endMinutes), + title: block.label, + block, + }); + } + } + return { + events, + backgroundEvents, + resources: columns.map((column) => ({ id: column.id, column })), + }; + }, [columns, moved]); + + const columnById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]); + + // On today's view, bring the current time into view — the grid is a full + // 14 hours tall and the page itself scrolls, so without this a barber + // opening the calendar at 3pm lands on 7am. + useEffect(() => { + if (currentTimeMinutes === null) return; + // The library draws the line a moment after mount, so wait for it. + const timer = window.setTimeout(() => { + rootRef.current?.querySelector(".rbc-current-time-indicator")?.scrollIntoView({ block: "center" }); + }, 150); + return () => window.clearTimeout(timer); + // Once, on first render of the day — not on every refresh after a move. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const canMove = (event: CalendarEvent) => + !!reschedule && event.kind === "appointment" && MOVABLE_STATUSES.includes(event.appointment.status); + + const handleDrop = ({ event, start, resourceId }: EventInteractionArgs) => { + if (event.kind !== "appointment") return; + const startMinutes = floatingDateToMinutes(new Date(start)); + // A barber moves their own booking in time only. + const targetResourceId = reschedule === "owner" && typeof resourceId === "string" ? resourceId : event.resourceId; + if (startMinutes === event.appointment.startMinutes && targetResourceId === event.resourceId) return; + setMoveError(null); + setPendingMove({ event, resourceId: targetResourceId, startMinutes }); + }; + + const confirmMove = () => { + if (!pendingMove || !reschedule) return; + const { event, resourceId, startMinutes } = pendingMove; + startTransition(async () => { + const result = await moveCalendarAppointmentAction({ + appointmentId: event.id, + staffId: resourceId, + date, + startMinutes, + mode: reschedule, + }); + if (!result.success) { + setMoveError(result.error ?? "Couldn't move this appointment."); + return; + } + setMovedState({ forColumns: columns, positions: { ...moved, [event.id]: { startMinutes, resourceId } } }); + setPendingMove(null); + router.refresh(); + }); + }; + + const fromName = pendingMove ? columnById.get(pendingMove.event.resourceId)?.name : undefined; + const toName = pendingMove ? columnById.get(pendingMove.resourceId)?.name : undefined; + const reassigning = !!pendingMove && pendingMove.resourceId !== pendingMove.event.resourceId; + + const scrollMinutes = Math.max(START_HOUR * 60, (currentTimeMinutes ?? 9 * 60) - 60); return ( -
- {/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */} -
-