fix(booking): report the real time range for service-mode slots

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 15:50:22 +03:30
co-authored by Claude Opus 5
parent f3659f827e
commit d58eec1dba
2 changed files with 126 additions and 23 deletions
+35 -7
View File
@@ -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);
}
+91 -16
View File
@@ -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', () => {