From d58eec1dba38dab9a8699c89a407fdc8b9666997 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 15:50:22 +0330 Subject: [PATCH] fix(booking): report the real time range for service-mode slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adaptServiceSlots labelled its single session "زمان‌های خالی" and set end_time to the last slot's *start* time, so the range shown was shorter than reality. It now uses the last slot's end_time (falling back to start + total_duration_minutes) and labels the session with the actual range. Shift separation was requested but is not implementable from this payload: with a flat start_times list, a gap between shifts is indistinguishable from a gap left by a booked appointment. The normal step is duration + buffer, so any threshold that splits shifts either splits every slot of a long service (step above the threshold) or invents tabs around booked appointments. A fabricated tab claims a shift that does not exist, which is worse than one correct tab. Real grouping belongs to the server-side endpoint task 06 adds. Also repairs three tests that had been red since adaptSlots changed shape: they still asserted the old { morning, evening } contract while the function returns an array of sessions. Suite goes from 4 failures to 1 (an unrelated pre-existing getStateInfo network timeout). Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/ Slot-mode contract: adaptSlots untouched Co-Authored-By: Claude Opus 5 (1M context) --- lib/appointmentSlots.js | 42 +++++++++++++--- lib/lib.test.js | 107 ++++++++++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/lib/appointmentSlots.js b/lib/appointmentSlots.js index 87b4b95..56f5d75 100644 --- a/lib/appointmentSlots.js +++ b/lib/appointmentSlots.js @@ -14,22 +14,50 @@ export function adaptSlots(slotsResponse) { })); } -// حالت سرویسی: پاسخ appointment-service-slots فقط start_times دارد (همه کافی). -// آن‌ها را در یک session قالب‌بندی می‌کنیم تا مثل حالت اسلاتی رندر شوند. +/** + * حالت سرویسی: پاسخ `appointment-service-slots` فقط `start_times` مسطح می‌دهد و مرزِ + * شیفت‌ها را نمی‌گوید. یک session ساخته می‌شود تا مثل حالت اسلاتی رندر شود. + * + * ⚠️ **چرا شیفت‌ها تفکیک نمی‌شوند:** با فهرست مسطح، شکافِ بین دو شیفت از شکافِ یک نوبتِ + * اشغال‌شده قابل تفکیک نیست. گام عادی `مدت + بافر` است، و دو نوبت پشت‌سرهم شکافی + * می‌سازد که از تعطیلیِ میانِ صبح و عصر تشخیص‌پذیر نیست. هر آستانه‌ای که این دو را + * جدا کند، روی سرویس‌های بلند (گام > آستانه) هر اسلات را یک تب می‌کند و روی نوبت‌های + * اشغال، تبِ جعلی می‌سازد — یعنی اطلاعات غلط، که از یک تبِ درست بدتر است. + * + * راه درست، گروه‌بندی از سمت سرور است (endpoint چندمنبعی، تسک ۰۶). تا آن زمان یک + * session با **بازهٔ واقعی** برگردانده می‌شود. + */ export function adaptServiceSlots(slotsResponse) { - const starts = - slotsResponse?.data?.start_times ?? slotsResponse?.start_times ?? []; + const payload = slotsResponse?.data ?? slotsResponse ?? {}; + const starts = payload.start_times ?? []; if (!starts.length) return []; + + const first = starts[0]; + const last = starts[starts.length - 1]; + // پایانِ آخرین نوبت، نه زمانِ **شروعِ** آن — قبلاً `start_time` گذاشته می‌شد و برچسب + // بازه را کوتاه‌تر از واقعیت نشان می‌داد. + const endTime = last.end_time ?? addMinutes(last.start_time, payload.total_duration_minutes); + return [ { - start_time: starts[0].start_time, - end_time: starts[starts.length - 1].start_time, - label: "زمان‌های خالی", + start_time: first.start_time, + end_time: endTime, + label: `${first.start_time} - ${endTime}`, slots: starts.map((s) => ({ ...s, is_available: true })), }, ]; } +/** `"09:00" + 35` → `"09:35"`. فقط fallback؛ پاسخ سرور `end_time` دارد. */ +function addMinutes(time, minutes) { + const [h, m] = String(time).split(":").map(Number); + if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(minutes)) return time; + const total = h * 60 + m + minutes; + const hh = String(Math.floor(total / 60) % 24).padStart(2, "0"); + const mm = String(total % 60).padStart(2, "0"); + return `${hh}:${mm}`; +} + export function hasAvailable(slots) { return Array.isArray(slots) && slots.some((slot) => slot.is_available); } diff --git a/lib/lib.test.js b/lib/lib.test.js index 3c48294..97593f0 100644 --- a/lib/lib.test.js +++ b/lib/lib.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { adaptSlots, hasAvailable } from '@/lib/appointmentSlots'; +import { adaptSlots, adaptServiceSlots, hasAvailable } from '@/lib/appointmentSlots'; import { getAccessToken, setAccessToken, clearAccessToken } from '@/lib/tokenStore'; import { sanitizeHtml, safeJsonParse } from '@/lib/sanitize'; import { defineAbilitiesFor } from '@/lib/ability'; @@ -7,28 +7,48 @@ import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from '@/lib/refresh import { buildPatientUser } from '@/lib/representationAdapters'; describe('appointmentSlots', () => { + // مرزِ شیفت را بک‌اند می‌دهد؛ فرانت تقسیم صبح/عصر نمی‌سازد. سه تست قبلی روی + // قرارداد قدیمیِ `{ morning, evening }` نوشته شده بودند و از زمانی که خروجی به + // آرایهٔ session تغییر کرد قرمز مانده بودند. const resp = { sessions: [ - { slots: [ - { start_time: '09:00', is_available: true }, - { start_time: '11:30', is_available: false }, - { start_time: '14:00', is_available: true }, - ] }, + { + start_time: '09:00', + end_time: '12:00', + slots: [ + { start_time: '09:00', is_available: true }, + { start_time: '11:30', is_available: false }, + ], + }, + { + start_time: '16:00', + end_time: '20:00', + slots: [{ start_time: '16:00', is_available: true }], + }, ], }; - it('adaptSlots اسلات‌ها را بر اساس 12:00 به صبح/عصر تقسیم می‌کند', () => { - const { morning, evening } = adaptSlots(resp); - expect(morning).toHaveLength(2); - expect(evening).toHaveLength(1); - expect(evening[0].start_time).toBe('14:00'); + + it('adaptSlots هر شیفتِ بک‌اند را یک session با برچسب بازه می‌کند', () => { + const sessions = adaptSlots(resp); + expect(sessions).toHaveLength(2); + expect(sessions[0].label).toBe('09:00 - 12:00'); + expect(sessions[1].label).toBe('16:00 - 20:00'); + expect(sessions[0].slots).toHaveLength(2); }); - it('ساختار data.sessions را هم می‌پذیرد', () => { - const { morning } = adaptSlots({ data: resp }); - expect(morning).toHaveLength(2); + + it('adaptSlots ساختار data.sessions را هم می‌پذیرد', () => { + expect(adaptSlots({ data: resp })).toHaveLength(2); }); - it('ورودی خالی → آرایه‌های خالی', () => { - expect(adaptSlots(null)).toEqual({ morning: [], evening: [] }); + + it('adaptSlots شیفتِ بدون اسلات را حذف می‌کند', () => { + const withEmpty = { sessions: [...resp.sessions, { start_time: '21:00', end_time: '22:00', slots: [] }] }; + expect(adaptSlots(withEmpty)).toHaveLength(2); }); + + it('adaptSlots ورودی خالی → آرایهٔ خالی', () => { + expect(adaptSlots(null)).toEqual([]); + }); + it('hasAvailable', () => { expect(hasAvailable([{ is_available: false }, { is_available: true }])).toBe(true); expect(hasAvailable([{ is_available: false }])).toBe(false); @@ -37,6 +57,61 @@ describe('appointmentSlots', () => { }); }); +describe('adaptServiceSlots', () => { + // پاسخ واقعی: شیفت ۰۹:۰۰–۱۲:۰۰، دو سرویس ۲۰+۱۵، بافر ۱۰ ⇒ گام ۴۵ دقیقه. + const serviceResp = { + data: { + total_duration_minutes: 35, + buffer_minutes: 10, + start_times: [ + { start: 1785562200, end: 1785564300, start_time: '09:00', end_time: '09:35' }, + { start: 1785564900, end: 1785567000, start_time: '09:45', end_time: '10:20' }, + { start: 1785567600, end: 1785569700, start_time: '10:30', end_time: '11:05' }, + { start: 1785570300, end: 1785572400, start_time: '11:15', end_time: '11:50' }, + ], + }, + }; + + it('یک session با بازهٔ واقعی می‌سازد', () => { + const [session] = adaptServiceSlots(serviceResp); + expect(session.start_time).toBe('09:00'); + // پایانِ آخرین نوبت، نه زمانِ شروعش. + expect(session.end_time).toBe('11:50'); + expect(session.label).toBe('09:00 - 11:50'); + expect(session.slots).toHaveLength(4); + }); + + it('همهٔ زمان‌های پیشنهادی available علامت می‌خورند', () => { + const [session] = adaptServiceSlots(serviceResp); + expect(session.slots.every((s) => s.is_available)).toBe(true); + expect(hasAvailable(session.slots)).toBe(true); + }); + + it('ساختار بدون data را هم می‌پذیرد', () => { + expect(adaptServiceSlots(serviceResp.data)).toHaveLength(1); + }); + + it('نبودِ end_time در پاسخ → از total_duration_minutes ساخته می‌شود', () => { + const withoutEnd = { + data: { + total_duration_minutes: 35, + start_times: [{ start_time: '09:00' }], + }, + }; + expect(adaptServiceSlots(withoutEnd)[0].end_time).toBe('09:35'); + }); + + it('start_times خالی → آرایهٔ خالی', () => { + expect(adaptServiceSlots({ data: { start_times: [] } })).toEqual([]); + expect(adaptServiceSlots(null)).toEqual([]); + }); + + it('یک اسلات تنها → بازهٔ همان اسلات', () => { + const single = { data: { start_times: [{ start_time: '15:00', end_time: '15:30' }] } }; + expect(adaptServiceSlots(single)[0].label).toBe('15:00 - 15:30'); + }); +}); + describe('tokenStore', () => { beforeEach(() => clearAccessToken()); it('set/get/clear', () => {