@openmrs/esm-appointments-app 11.1.1-pre.8975 → 11.1.1-pre.8982

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,4 +5,4 @@
5
5
  │ 6973.js (315.022 KiB)
6
6
  
7
7
 
8
- Rspack compiled with 1 warning in 24.66 s
8
+ Rspack compiled with 1 warning in 38.18 s
package/dist/routes.json CHANGED
@@ -1 +1 @@
1
- {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":">=2.2.0","appointments":">=2.0.0"},"extensions":[{"name":"home-appointments","slot":"homepage-widgets-slot","component":"homeAppointments","order":1},{"name":"clinical-appointments-dashboard-link","slot":"homepage-dashboard-slot","component":"appointmentsDashboardLink","meta":{"name":"appointments","slot":"clinical-appointments-dashboard-slot","title":"Appointments"},"order":2},{"component":"root","name":"clinical-appointments-dashboard","slot":"clinical-appointments-dashboard-slot"},{"name":"appointments-dashboard","slot":"appointments-dashboard-slot","component":"appointmentsDashboard"},{"name":"appointments-calendar-dashboard-link","slot":"calendar-dashboard-slot","component":"appointmentsCalendarDashboardLink"},{"name":"todays-appointments-dashboard","slot":"todays-appointment-slot","component":"homeAppointments"},{"name":"early-appointments-panel","component":"earlyAppointments"},{"name":"patient-appointments-summary-dashboard","component":"patientAppointmentsSummaryDashboardLink","slot":"patient-chart-dashboard-slot","order":11,"meta":{"columns":1,"columnSpan":1,"hideDashboardTitle":true,"path":"appointments","slot":"patient-chart-appointments-dashboard-slot","title":"Appointments"}},{"name":"appointments-details-widget","component":"patientAppointmentsDetailedSummary","slot":"patient-chart-appointments-dashboard-slot","meta":{"columnSpan":1}},{"name":"patient-upcoming-appointment-widget","component":"patientUpcomingAppointmentsWidget","slot":"visit-form-top-slot"},{"name":"home-appointments-tile","slot":"home-metrics-tiles-slot","component":"homeAppointmentsTile"},{"name":"metrics-card-scheduled-appointments","component":"metricsCardScheduledAppointments","slot":"appointments-metrics-slot","order":1},{"name":"metrics-card-highest-volume-service","component":"metricsCardHighestVolumeService","slot":"appointments-metrics-slot","order":2},{"name":"metrics-card-providers-booked","component":"metricsCardProvidersBooked","slot":"appointments-metrics-slot","order":3}],"modals":[{"name":"end-appointment-modal","component":"endAppointmentModal"},{"name":"cancel-appointment-modal","component":"cancelAppointmentModal"},{"name":"batch-change-appointments-statuses-modal","component":"batchChangeAppointmentStatusesModal"}],"workspaceGroups2":[{"name":"appointments-group","scopePattern":"/home/appointments"}],"workspaceWindows2":[{"name":"appointments-window","group":"appointments-group"}],"workspaces2":[{"name":"appointments-form-workspace","component":"appointmentsFormWorkspace","window":"appointments-window"},{"name":"appointments-patient-search-workspace","component":"@openmrs/esm-patient-search-app#patientSearchWorkspace2","window":"appointments-window"},{"name":"appointments-start-visit-workspace","component":"@openmrs/esm-patient-chart-app#exportedVisitForm","window":"appointments-window"}],"version":"11.1.1-pre.8975"}
1
+ {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":">=2.2.0","appointments":">=2.0.0"},"extensions":[{"name":"home-appointments","slot":"homepage-widgets-slot","component":"homeAppointments","order":1},{"name":"clinical-appointments-dashboard-link","slot":"homepage-dashboard-slot","component":"appointmentsDashboardLink","meta":{"name":"appointments","slot":"clinical-appointments-dashboard-slot","title":"Appointments"},"order":2},{"component":"root","name":"clinical-appointments-dashboard","slot":"clinical-appointments-dashboard-slot"},{"name":"appointments-dashboard","slot":"appointments-dashboard-slot","component":"appointmentsDashboard"},{"name":"appointments-calendar-dashboard-link","slot":"calendar-dashboard-slot","component":"appointmentsCalendarDashboardLink"},{"name":"todays-appointments-dashboard","slot":"todays-appointment-slot","component":"homeAppointments"},{"name":"early-appointments-panel","component":"earlyAppointments"},{"name":"patient-appointments-summary-dashboard","component":"patientAppointmentsSummaryDashboardLink","slot":"patient-chart-dashboard-slot","order":11,"meta":{"columns":1,"columnSpan":1,"hideDashboardTitle":true,"path":"appointments","slot":"patient-chart-appointments-dashboard-slot","title":"Appointments"}},{"name":"appointments-details-widget","component":"patientAppointmentsDetailedSummary","slot":"patient-chart-appointments-dashboard-slot","meta":{"columnSpan":1}},{"name":"patient-upcoming-appointment-widget","component":"patientUpcomingAppointmentsWidget","slot":"visit-form-top-slot"},{"name":"home-appointments-tile","slot":"home-metrics-tiles-slot","component":"homeAppointmentsTile"},{"name":"metrics-card-scheduled-appointments","component":"metricsCardScheduledAppointments","slot":"appointments-metrics-slot","order":1},{"name":"metrics-card-highest-volume-service","component":"metricsCardHighestVolumeService","slot":"appointments-metrics-slot","order":2},{"name":"metrics-card-providers-booked","component":"metricsCardProvidersBooked","slot":"appointments-metrics-slot","order":3}],"modals":[{"name":"end-appointment-modal","component":"endAppointmentModal"},{"name":"cancel-appointment-modal","component":"cancelAppointmentModal"},{"name":"batch-change-appointments-statuses-modal","component":"batchChangeAppointmentStatusesModal"}],"workspaceGroups2":[{"name":"appointments-group","scopePattern":"/home/appointments"}],"workspaceWindows2":[{"name":"appointments-window","group":"appointments-group"}],"workspaces2":[{"name":"appointments-form-workspace","component":"appointmentsFormWorkspace","window":"appointments-window"},{"name":"appointments-patient-search-workspace","component":"@openmrs/esm-patient-search-app#patientSearchWorkspace2","window":"appointments-window"},{"name":"appointments-start-visit-workspace","component":"@openmrs/esm-patient-chart-app#exportedVisitForm","window":"appointments-window"}],"version":"11.1.1-pre.8982"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-appointments-app",
3
- "version": "11.1.1-pre.8975",
3
+ "version": "11.1.1-pre.8982",
4
4
  "description": "Appointments front-end module for O3",
5
5
  "browser": "dist/openmrs-esm-appointments-app.js",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,53 @@
1
+ export type CarbonTagType = 'blue' | 'cyan' | 'green' | 'gray' | 'magenta' | 'purple' | 'red' | 'teal' | 'warm-gray';
2
+
3
+ export const STATUS_TAG_TYPES: Readonly<Record<string, CarbonTagType>> = {
4
+ Scheduled: 'blue',
5
+ CheckedIn: 'teal',
6
+ Completed: 'green',
7
+ Missed: 'red',
8
+ Cancelled: 'warm-gray',
9
+ Requested: 'magenta',
10
+ } as const;
11
+
12
+ export const DEFAULT_STATUS_TAG_TYPE: CarbonTagType = 'gray';
13
+
14
+ const SERVICE_COLOR_PALETTE: ReadonlyArray<string> = [
15
+ 'var(--cds-blue-60)',
16
+ 'var(--cds-purple-60)',
17
+ 'var(--cds-teal-60)',
18
+ 'var(--cds-magenta-60)',
19
+ 'var(--cds-cyan-60)',
20
+ 'var(--cds-green-60)',
21
+ 'var(--cds-orange-60)',
22
+ 'var(--cds-cool-gray-60)',
23
+ ] as const;
24
+
25
+ export function getServiceColor(serviceName: string): string {
26
+ let hash = 0;
27
+ for (let i = 0; i < serviceName.length; i++) {
28
+ hash = (hash << 5) - hash + serviceName.charCodeAt(i);
29
+ hash |= 0;
30
+ }
31
+ return SERVICE_COLOR_PALETTE[Math.abs(hash) % SERVICE_COLOR_PALETTE.length];
32
+ }
33
+
34
+ export const CALENDAR_HOURS: ReadonlyArray<number> = Array.from({ length: 24 }, (_, i) => i) as ReadonlyArray<number>;
35
+
36
+ export function formatHourLabel(hour: number): string {
37
+ const h = hour % 12 || 12;
38
+ const period = hour < 12 ? 'AM' : 'PM';
39
+ return `${h} ${period}`;
40
+ }
41
+
42
+ export interface TimeBlock {
43
+ label: string;
44
+ startHour: number;
45
+ endHour: number;
46
+ }
47
+
48
+ export const TIME_BLOCKS: ReadonlyArray<TimeBlock> = [
49
+ { label: '12 AM – 6 AM', startHour: 0, endHour: 6 },
50
+ { label: '6 AM – 12 PM', startHour: 6, endHour: 12 },
51
+ { label: '12 PM – 6 PM', startHour: 12, endHour: 18 },
52
+ { label: '6 PM – 12 AM', startHour: 18, endHour: 24 },
53
+ ];
@@ -0,0 +1,137 @@
1
+ import React, { useMemo } from 'react';
2
+ import { type Dayjs } from 'dayjs';
3
+ import {
4
+ parseDate,
5
+ toCalendar,
6
+ getDayOfWeek,
7
+ startOfWeek,
8
+ today,
9
+ getLocalTimeZone,
10
+ GregorianCalendar,
11
+ EthiopicCalendar,
12
+ IslamicCivilCalendar,
13
+ PersianCalendar,
14
+ type Calendar,
15
+ } from '@internationalized/date';
16
+ import { useAppointmentsByDate } from '../../hooks/useAppointmentsByDate';
17
+ import { TIME_BLOCKS } from '../utils/calendar-colors';
18
+ import WeeklySlotCell from './weekly-slot-cell.component';
19
+ import styles from './weekly-calendar-view.scss';
20
+
21
+ const LOCALE_MAP: Record<string, string> = {
22
+ gregory: 'en-US',
23
+ ethiopic: 'am-ET',
24
+ islamic: 'ar-SA',
25
+ persian: 'fa-IR',
26
+ };
27
+
28
+ const DAY_OF_WEEK_KEY: Record<number, 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'> = {
29
+ 0: 'sun',
30
+ 1: 'mon',
31
+ 2: 'tue',
32
+ 3: 'wed',
33
+ 4: 'thu',
34
+ 5: 'fri',
35
+ 6: 'sat',
36
+ };
37
+
38
+ function getCalendar(calKey: string): Calendar {
39
+ switch (calKey) {
40
+ case 'ethiopic':
41
+ return new EthiopicCalendar();
42
+ case 'islamic':
43
+ return new IslamicCivilCalendar();
44
+ case 'persian':
45
+ return new PersianCalendar();
46
+ default:
47
+ return new GregorianCalendar();
48
+ }
49
+ }
50
+
51
+ interface WeekDay {
52
+ iso: string;
53
+ day: number;
54
+ month: number;
55
+ year: number;
56
+ dow: number;
57
+ }
58
+
59
+ interface WeeklyCalendarViewProps {
60
+ calKey: string;
61
+ calendarSelectedDate: Dayjs;
62
+ onSelectDate: (isoDate: string, startHour: number, endHour: number) => void;
63
+ }
64
+
65
+ const WeeklyCalendarView: React.FC<WeeklyCalendarViewProps> = ({ calKey, calendarSelectedDate, onSelectDate }) => {
66
+ const todayISO = today(getLocalTimeZone()).toString();
67
+ const isoDate = calendarSelectedDate.format('YYYY-MM-DD');
68
+ const locale = LOCALE_MAP[calKey] ?? 'en-US';
69
+ const cal = useMemo(() => getCalendar(calKey), [calKey]);
70
+
71
+ const weekDays: ReadonlyArray<WeekDay> = useMemo(() => {
72
+ const pivot = parseDate(isoDate);
73
+ const firstDay = calKey === 'persian' ? 6 : 0;
74
+ const weekStart = startOfWeek(pivot, 'en-US', DAY_OF_WEEK_KEY[firstDay]);
75
+ return Array.from({ length: 7 }, (_, i) => {
76
+ const gregDay = weekStart.add({ days: i });
77
+ const calDay = toCalendar(gregDay, cal);
78
+ return {
79
+ iso: gregDay.toString(),
80
+ day: calDay.day,
81
+ month: calDay.month,
82
+ year: calDay.year,
83
+ dow: getDayOfWeek(gregDay, 'en-US', 'sun'),
84
+ };
85
+ });
86
+ }, [isoDate, cal, calKey]);
87
+
88
+ const day0 = useAppointmentsByDate(weekDays[0].iso);
89
+ const day1 = useAppointmentsByDate(weekDays[1].iso);
90
+ const day2 = useAppointmentsByDate(weekDays[2].iso);
91
+ const day3 = useAppointmentsByDate(weekDays[3].iso);
92
+ const day4 = useAppointmentsByDate(weekDays[4].iso);
93
+ const day5 = useAppointmentsByDate(weekDays[5].iso);
94
+ const day6 = useAppointmentsByDate(weekDays[6].iso);
95
+ const dayData = [day0, day1, day2, day3, day4, day5, day6];
96
+
97
+ return (
98
+ <div className={styles.container}>
99
+ <div className={styles.grid}>
100
+ <div className={styles.cornerCell} />
101
+ {weekDays.map((wd) => {
102
+ const isToday = wd.iso === todayISO;
103
+ const [y, m, d] = wd.iso.split('-').map(Number);
104
+ const jsDate = new Date(y, m - 1, d);
105
+ const dowLabel = new Intl.DateTimeFormat(locale, { weekday: 'short', calendar: calKey }).format(jsDate);
106
+ const monthName = new Intl.DateTimeFormat('en-US', { month: 'short', calendar: calKey }).format(jsDate);
107
+ return (
108
+ <div key={wd.iso} className={`${styles.dayHeader} ${isToday ? styles.dayHeaderToday : ''}`}>
109
+ <div className={styles.dowLabel}>{dowLabel}</div>
110
+ <div className={`${styles.dayNum} ${isToday ? styles.dayNumToday : ''}`}>{wd.day}</div>
111
+ <div className={styles.monthLabel}>{monthName}</div>
112
+ </div>
113
+ );
114
+ })}
115
+
116
+ {TIME_BLOCKS.map((block) => (
117
+ <React.Fragment key={block.label}>
118
+ <div className={styles.timeLabel}>{block.label}</div>
119
+ {weekDays.map((wd, dayIdx) => (
120
+ <WeeklySlotCell
121
+ key={wd.iso}
122
+ appointments={dayData[dayIdx].appointments}
123
+ isoDate={wd.iso}
124
+ startHour={block.startHour}
125
+ endHour={block.endHour}
126
+ isToday={wd.iso === todayISO}
127
+ onSelectDate={onSelectDate}
128
+ />
129
+ ))}
130
+ </React.Fragment>
131
+ ))}
132
+ </div>
133
+ </div>
134
+ );
135
+ };
136
+
137
+ export default WeeklyCalendarView;
@@ -0,0 +1,184 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+
5
+ .container {
6
+ margin: layout.$spacing-05;
7
+ padding-bottom: layout.$spacing-10;
8
+ overflow-x: auto;
9
+ }
10
+
11
+ .grid {
12
+ display: grid;
13
+ grid-template-columns: 6.5rem repeat(7, 1fr);
14
+ grid-auto-rows: minmax(6rem, 1fr);
15
+ }
16
+
17
+ .cornerCell {
18
+ border-top: 1px solid colors.$gray-20;
19
+ border-bottom: 1px solid colors.$gray-20;
20
+ border-right: 1px solid colors.$gray-20;
21
+ border-left: 1px solid colors.$gray-20;
22
+ background: colors.$white;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ padding: layout.$spacing-02;
27
+ }
28
+
29
+ .cornerLabel {
30
+ @include type.type-style('label-01');
31
+ color: colors.$gray-50;
32
+ font-size: 0.6875rem;
33
+ font-weight: 600;
34
+ text-transform: uppercase;
35
+ letter-spacing: 0.02em;
36
+ }
37
+
38
+ .dayHeader {
39
+ display: flex;
40
+ flex-direction: column;
41
+ align-items: center;
42
+ justify-content: center;
43
+ padding: layout.$spacing-02;
44
+ border-top: 1px solid colors.$gray-20;
45
+ border-bottom: 1px solid colors.$gray-20;
46
+ border-right: 1px solid colors.$gray-20;
47
+ background: colors.$white;
48
+ min-height: 4rem;
49
+
50
+ &:first-of-type {
51
+ border-left: 1px solid colors.$gray-20;
52
+ }
53
+ }
54
+
55
+ .dayHeaderToday {
56
+ background: colors.$blue-10;
57
+ border-bottom-color: colors.$blue-50;
58
+ }
59
+
60
+ .dowLabel {
61
+ @include type.type-style('label-01');
62
+ color: colors.$gray-70;
63
+ }
64
+
65
+ .dayNum {
66
+ @include type.type-style('heading-03');
67
+ color: colors.$gray-100;
68
+ line-height: 1.2;
69
+ }
70
+
71
+ .dayNumToday {
72
+ color: colors.$blue-60;
73
+ background: colors.$blue-20;
74
+ border-radius: 50%;
75
+ width: 1.625rem;
76
+ height: 1.625rem;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ }
81
+
82
+ .monthLabel {
83
+ @include type.type-style('label-01');
84
+ color: colors.$gray-50;
85
+ }
86
+
87
+ .timeLabel {
88
+ display: flex;
89
+ align-items: flex-start;
90
+ justify-content: flex-end;
91
+ padding: layout.$spacing-02 layout.$spacing-03 0 0;
92
+ border-left: 1px solid colors.$gray-20;
93
+ border-right: 1px solid colors.$gray-20;
94
+ border-bottom: 1px solid colors.$gray-20;
95
+ background: colors.$white;
96
+ @include type.type-style('label-01');
97
+ color: colors.$gray-60;
98
+ font-size: 0.6875rem;
99
+ white-space: nowrap;
100
+ }
101
+
102
+ .slotCell {
103
+ border-left: 1px solid colors.$gray-20;
104
+ border-bottom: 1px solid colors.$gray-20;
105
+ padding: layout.$spacing-02;
106
+ min-height: 2.5rem;
107
+ overflow: hidden;
108
+ cursor: default;
109
+ display: flex;
110
+ flex-direction: column;
111
+ justify-content: flex-start;
112
+ background: colors.$white;
113
+ }
114
+
115
+ .grid > :nth-child(8n) {
116
+ border-right: 1px solid colors.$gray-20;
117
+ }
118
+
119
+ .slotCellToday {
120
+ background: rgba(colors.$blue-10, 0.4);
121
+ }
122
+
123
+ .slotCellHasAppts {
124
+ cursor: pointer;
125
+
126
+ &:hover {
127
+ background: colors.$gray-10;
128
+ }
129
+ }
130
+
131
+ .previewList {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: layout.$spacing-01;
135
+ overflow: hidden;
136
+ width: 100%;
137
+ }
138
+
139
+ .previewRow {
140
+ display: flex;
141
+ align-items: baseline;
142
+ gap: layout.$spacing-02;
143
+ overflow: hidden;
144
+ white-space: nowrap;
145
+ }
146
+
147
+ .previewTime {
148
+ @include type.type-style('label-01');
149
+ color: colors.$gray-60;
150
+ flex-shrink: 0;
151
+ }
152
+
153
+ .previewName {
154
+ @include type.type-style('body-compact-01');
155
+ color: colors.$gray-100;
156
+ overflow: hidden;
157
+ text-overflow: ellipsis;
158
+ white-space: nowrap;
159
+ }
160
+
161
+ .moreLink {
162
+ @include type.type-style('label-01');
163
+ background: none;
164
+ border: none;
165
+ color: colors.$blue-60;
166
+ cursor: pointer;
167
+ padding: layout.$spacing-01 0 0;
168
+ border-top: 1px solid colors.$gray-20;
169
+ text-align: left;
170
+ width: 100%;
171
+
172
+ &:hover {
173
+ color: colors.$blue-70;
174
+ text-decoration: underline;
175
+ }
176
+ }
177
+
178
+ .emptyWeek {
179
+ grid-column: 1 / -1;
180
+ padding: layout.$spacing-05;
181
+ text-align: center;
182
+ @include type.type-style('body-compact-01');
183
+ color: colors.$gray-50;
184
+ }
@@ -0,0 +1,306 @@
1
+ import React from 'react';
2
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import dayjs from 'dayjs';
6
+ import { SWRConfig } from 'swr';
7
+ import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework';
8
+ import { AppointmentKind, AppointmentStatus } from './../../types';
9
+ import WeeklyCalendarView from './weekly-calendar-view.component';
10
+
11
+ const mockOpenmrsFetch = vi.mocked(openmrsFetch);
12
+
13
+ const june09WeekStart = dayjs('2026-06-09').startOf('week');
14
+
15
+ function isoDay(offset: number): string {
16
+ return june09WeekStart.add(offset, 'day').format('YYYY-MM-DD');
17
+ }
18
+
19
+ function ts(dayOffset: number, hour: number, minute = 0): number {
20
+ return june09WeekStart.add(dayOffset, 'day').hour(hour).minute(minute).valueOf();
21
+ }
22
+
23
+ let apptCounter = 0;
24
+ function nextApptUuid(): string {
25
+ apptCounter += 1;
26
+ const hex = apptCounter.toString(16).padStart(4, '0');
27
+ return `7cd38a6d-377e-491b-8284-b04cf8b8${hex}`;
28
+ }
29
+
30
+ const mockAppointment = (overrides = {}) => ({
31
+ uuid: nextApptUuid(),
32
+ appointmentNumber: '0000',
33
+ appointmentKind: AppointmentKind.SCHEDULED,
34
+ comments: '',
35
+ dateAppointmentScheduled: null,
36
+ endDateTime: null,
37
+ location: { uuid: 'b1a8b05e-3542-4037-bbd3-998ee9c40574', name: 'Inpatient Ward' },
38
+ patient: { identifier: '100GEJ', name: 'John Wilson', uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e' },
39
+ provider: { uuid: 'f9badd80-ab76-11e2-9e96-0800200c9a66', display: 'Dr James Cook' },
40
+ providers: [{ uuid: 'f9badd80-ab76-11e2-9e96-0800200c9a66', display: 'Dr James Cook' }],
41
+ recurring: false,
42
+ service: {
43
+ appointmentServiceId: 1,
44
+ name: 'Outpatient',
45
+ uuid: 'e2ec9cf0-ec38-4d2b-af6c-59c82fa30b90',
46
+ description: 'Outpatient service',
47
+ creatorName: null,
48
+ startTime: '08:00',
49
+ endTime: '17:00',
50
+ maxAppointmentsLimit: null,
51
+ initialAppointmentStatus: AppointmentStatus.SCHEDULED,
52
+ durationMins: 15,
53
+ },
54
+ startDateTime: ts(0, 9),
55
+ status: AppointmentStatus.SCHEDULED,
56
+ voided: false,
57
+ extensions: {},
58
+ teleconsultationLink: null,
59
+ ...overrides,
60
+ });
61
+
62
+ function mockAppointmentsForDay(dayOffset: number, appointments: ReturnType<typeof mockAppointment>[]) {
63
+ mockOpenmrsFetch.mockImplementation(async (url: string) => {
64
+ const match = (url as string).match(/forDate=(\d{4}-\d{2}-\d{2})/);
65
+ if (!match) return { data: [] } as FetchResponse;
66
+ const requestedDate = match[1];
67
+ if (requestedDate === isoDay(dayOffset)) {
68
+ return { data: appointments } as FetchResponse;
69
+ }
70
+ return { data: [] } as FetchResponse;
71
+ });
72
+ }
73
+
74
+ function renderWeekly(props = {}) {
75
+ return render(
76
+ <SWRConfig
77
+ value={{
78
+ dedupingInterval: 0,
79
+ provider: () => new Map(),
80
+ revalidateOnFocus: false,
81
+ shouldRetryOnError: false,
82
+ }}>
83
+ <WeeklyCalendarView
84
+ calKey="gregory"
85
+ calendarSelectedDate={dayjs('2026-06-09')}
86
+ onSelectDate={vi.fn()}
87
+ {...props}
88
+ />
89
+ </SWRConfig>,
90
+ );
91
+ }
92
+
93
+ describe('WeeklyCalendarView', () => {
94
+ beforeEach(() => {
95
+ apptCounter = 0;
96
+ mockOpenmrsFetch.mockReset();
97
+ mockOpenmrsFetch.mockResolvedValue({ data: [] } as FetchResponse);
98
+ });
99
+
100
+ it('renders the 7 day column headers', async () => {
101
+ renderWeekly();
102
+ await screen.findByText('Sun');
103
+ await screen.findByText('Mon');
104
+ await screen.findByText('Tue');
105
+ await screen.findByText('Wed');
106
+ await screen.findByText('Thu');
107
+ await screen.findByText('Fri');
108
+ await screen.findByText('Sat');
109
+ });
110
+
111
+ it('renders all 4 time-block labels regardless of appointments', async () => {
112
+ renderWeekly();
113
+ await screen.findByText('12 AM – 6 AM');
114
+ await screen.findByText('6 AM – 12 PM');
115
+ await screen.findByText('12 PM – 6 PM');
116
+ await screen.findByText('6 PM – 12 AM');
117
+ });
118
+
119
+ it('renders appointment previews with time and patient name', async () => {
120
+ mockAppointmentsForDay(1, [
121
+ mockAppointment({
122
+ startDateTime: ts(1, 9, 14),
123
+ patient: { identifier: '100GEJ', name: 'Agnes Adams', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
124
+ }),
125
+ mockAppointment({
126
+ startDateTime: ts(1, 10, 42),
127
+ patient: { identifier: '100GEK', name: 'Kevin Brown', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
128
+ }),
129
+ ]);
130
+
131
+ renderWeekly();
132
+
133
+ await screen.findByText('9:14 AM');
134
+ await screen.findByText('Agnes Adams');
135
+ await screen.findByText('10:42 AM');
136
+ await screen.findByText('Kevin Brown');
137
+ });
138
+
139
+ it('shows +N more link when more than 2 appointments in a block', async () => {
140
+ mockAppointmentsForDay(1, [
141
+ mockAppointment({
142
+ startDateTime: ts(1, 9, 0),
143
+ patient: { identifier: '100GEJ', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
144
+ }),
145
+ mockAppointment({
146
+ startDateTime: ts(1, 10, 0),
147
+ patient: { identifier: '100GEK', name: 'Bob', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
148
+ }),
149
+ mockAppointment({
150
+ startDateTime: ts(1, 11, 0),
151
+ patient: { identifier: '100GEL', name: 'Carol', uuid: 'c73b920a-4f12-4d8e-91c5-6e7d8f3a2b01' },
152
+ }),
153
+ mockAppointment({
154
+ startDateTime: ts(1, 11, 30),
155
+ patient: { identifier: '100GEM', name: 'Dave', uuid: 'd94e031b-5023-5e9f-a2d6-7f8e9a4b3c12' },
156
+ }),
157
+ ]);
158
+
159
+ renderWeekly();
160
+
161
+ await screen.findByText('Alice');
162
+ await screen.findByText('Bob');
163
+
164
+ expect(screen.getByText('+2 more')).toBeInTheDocument();
165
+ expect(screen.queryByText('Carol')).not.toBeInTheDocument();
166
+ expect(screen.queryByText('Dave')).not.toBeInTheDocument();
167
+ });
168
+
169
+ it('does not show +N more link when exactly 2 or fewer appointments', async () => {
170
+ mockAppointmentsForDay(1, [
171
+ mockAppointment({
172
+ startDateTime: ts(1, 9, 0),
173
+ patient: { identifier: '100GEJ', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
174
+ }),
175
+ mockAppointment({
176
+ startDateTime: ts(1, 10, 0),
177
+ patient: { identifier: '100GEK', name: 'Bob', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
178
+ }),
179
+ ]);
180
+
181
+ renderWeekly();
182
+
183
+ await screen.findByText('Alice');
184
+ await screen.findByText('Bob');
185
+
186
+ expect(screen.queryByText(/\+.*more/)).not.toBeInTheDocument();
187
+ });
188
+
189
+ it('calls onSelectDate with correct args when a cell is clicked', async () => {
190
+ const user = userEvent.setup();
191
+ const onSelectDate = vi.fn();
192
+
193
+ mockAppointmentsForDay(1, [
194
+ mockAppointment({
195
+ startDateTime: ts(1, 9, 0),
196
+ patient: { identifier: '100GEJ', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
197
+ }),
198
+ ]);
199
+
200
+ renderWeekly({ onSelectDate });
201
+
202
+ await screen.findByText('Alice');
203
+
204
+ await user.click(screen.getByRole('button'));
205
+ expect(onSelectDate).toHaveBeenCalledWith(isoDay(1), 6, 12);
206
+ });
207
+
208
+ it('handles Enter key on a cell with appointments', async () => {
209
+ const user = userEvent.setup();
210
+ const onSelectDate = vi.fn();
211
+
212
+ mockAppointmentsForDay(1, [
213
+ mockAppointment({
214
+ startDateTime: ts(1, 9, 0),
215
+ patient: { identifier: '100GEJ', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
216
+ }),
217
+ ]);
218
+
219
+ renderWeekly({ onSelectDate });
220
+
221
+ await screen.findByText('Alice');
222
+
223
+ screen.getByRole('button').focus();
224
+ await user.keyboard('{Enter}');
225
+ expect(onSelectDate).toHaveBeenCalledWith(isoDay(1), 6, 12);
226
+ });
227
+
228
+ it('handles Space key on a cell with appointments', async () => {
229
+ const user = userEvent.setup();
230
+ const onSelectDate = vi.fn();
231
+
232
+ mockAppointmentsForDay(1, [
233
+ mockAppointment({
234
+ startDateTime: ts(1, 9, 0),
235
+ patient: { identifier: '100GEJ', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
236
+ }),
237
+ ]);
238
+
239
+ renderWeekly({ onSelectDate });
240
+
241
+ await screen.findByText('Alice');
242
+
243
+ screen.getByRole('button').focus();
244
+ await user.keyboard(' ');
245
+ expect(onSelectDate).toHaveBeenCalledWith(isoDay(1), 6, 12);
246
+ });
247
+
248
+ it('calls onSelectDate when +N more link is clicked', async () => {
249
+ const user = userEvent.setup();
250
+ const onSelectDate = vi.fn();
251
+
252
+ mockAppointmentsForDay(1, [
253
+ mockAppointment({ startDateTime: ts(1, 9, 0) }),
254
+ mockAppointment({ startDateTime: ts(1, 10, 0) }),
255
+ mockAppointment({ startDateTime: ts(1, 11, 0) }),
256
+ ]);
257
+
258
+ renderWeekly({ onSelectDate });
259
+
260
+ await screen.findByText('+1 more');
261
+
262
+ await user.click(screen.getByText('+1 more'));
263
+ expect(onSelectDate).toHaveBeenCalledWith(isoDay(1), 6, 12);
264
+ });
265
+
266
+ it('renders empty cells without content for blocks with no appointments', async () => {
267
+ mockAppointmentsForDay(1, [
268
+ mockAppointment({
269
+ startDateTime: ts(1, 9, 0),
270
+ patient: { identifier: '100GEJ', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
271
+ }),
272
+ ]);
273
+
274
+ renderWeekly();
275
+
276
+ await screen.findByText('Alice');
277
+
278
+ expect(screen.getAllByText('Alice').length).toBe(1);
279
+ });
280
+
281
+ it('renders without crashing (smoke test)', async () => {
282
+ mockAppointmentsForDay(0, [
283
+ mockAppointment({
284
+ startDateTime: ts(0, 9, 0),
285
+ patient: { identifier: '100GEJ', name: 'John Wilson', uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e' },
286
+ }),
287
+ ]);
288
+
289
+ renderWeekly();
290
+
291
+ await screen.findByText('John Wilson');
292
+ });
293
+
294
+ it('supports different calendar keys without crashing', async () => {
295
+ mockAppointmentsForDay(0, [
296
+ mockAppointment({
297
+ startDateTime: ts(0, 9, 0),
298
+ patient: { identifier: '100GEJ', name: 'John Wilson', uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e' },
299
+ }),
300
+ ]);
301
+
302
+ renderWeekly({ calKey: 'ethiopic' });
303
+
304
+ await screen.findByText('John Wilson');
305
+ });
306
+ });
@@ -0,0 +1,88 @@
1
+ import React, { useMemo } from 'react';
2
+ import { type Appointment } from './../../types';
3
+ import { formatAMPM } from './../../helpers/functions';
4
+ import styles from './weekly-calendar-view.scss';
5
+
6
+ interface WeeklySlotCellProps {
7
+ appointments: ReadonlyArray<Appointment>;
8
+ isoDate: string;
9
+ startHour: number;
10
+ endHour: number;
11
+ isToday: boolean;
12
+ onSelectDate: (isoDate: string, startHour: number, endHour: number) => void;
13
+ }
14
+
15
+ const WeeklySlotCell: React.FC<WeeklySlotCellProps> = ({
16
+ appointments,
17
+ isoDate,
18
+ startHour,
19
+ endHour,
20
+ isToday,
21
+ onSelectDate,
22
+ }) => {
23
+ const blockAppointments = useMemo(() => {
24
+ return appointments
25
+ .filter((a) => {
26
+ if (a.startDateTime == null) return false;
27
+ const h = new Date(a.startDateTime).getHours();
28
+ return h >= startHour && h < endHour;
29
+ })
30
+ .sort((a, b) => {
31
+ if (a.startDateTime == null || b.startDateTime == null) return 0;
32
+ return new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime();
33
+ });
34
+ }, [appointments, startHour, endHour]);
35
+
36
+ const total = blockAppointments.length;
37
+ const hasAppts = total > 0;
38
+
39
+ if (!hasAppts) {
40
+ return <div data-testid="slot-cell" className={`${styles.slotCell} ${isToday ? styles.slotCellToday : ''}`} />;
41
+ }
42
+
43
+ const visible = blockAppointments.slice(0, 2);
44
+ const overflow = total - 2;
45
+
46
+ return (
47
+ <div
48
+ data-testid="slot-cell"
49
+ className={`${styles.slotCell} ${isToday ? styles.slotCellToday : ''} ${styles.slotCellHasAppts}`}
50
+ role="button"
51
+ tabIndex={0}
52
+ onClick={() => onSelectDate(isoDate, startHour, endHour)}
53
+ onKeyDown={(e) => {
54
+ if (e.key === 'Enter' || e.key === ' ') {
55
+ e.preventDefault();
56
+ onSelectDate(isoDate, startHour, endHour);
57
+ }
58
+ }}>
59
+ <div className={styles.previewList}>
60
+ {visible.map((a) => {
61
+ const time = a.startDateTime != null ? formatAMPM(new Date(a.startDateTime)) : '—';
62
+ const name = a.patient?.name ?? '—';
63
+ return (
64
+ <div key={a.uuid} className={styles.previewRow}>
65
+ <span className={styles.previewTime}>{time}</span>
66
+ <span className={styles.previewName} title={name}>
67
+ {name}
68
+ </span>
69
+ </div>
70
+ );
71
+ })}
72
+ {overflow > 0 && (
73
+ <button
74
+ type="button"
75
+ className={styles.moreLink}
76
+ onClick={(e) => {
77
+ e.stopPropagation();
78
+ onSelectDate(isoDate, startHour, endHour);
79
+ }}>
80
+ +{overflow} more
81
+ </button>
82
+ )}
83
+ </div>
84
+ </div>
85
+ );
86
+ };
87
+
88
+ export default WeeklySlotCell;
@@ -0,0 +1,419 @@
1
+ import React from 'react';
2
+ import { vi, describe, it, expect } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+ import { type Appointment, AppointmentKind, AppointmentStatus } from './../../types';
5
+ import WeeklySlotCell from './weekly-slot-cell.component';
6
+
7
+ let apptCounter = 0;
8
+ function nextApptUuid(): string {
9
+ apptCounter += 1;
10
+ const hex = apptCounter.toString(16).padStart(4, '0');
11
+ return `7cd38a6d-377e-491b-8284-b04cf8b8${hex}`;
12
+ }
13
+
14
+ const baseAppointment: Appointment = {
15
+ uuid: '7cd38a6d-377e-491b-8284-b04cf8b8c6d8',
16
+ appointmentKind: AppointmentKind.SCHEDULED,
17
+ appointmentNumber: '0000',
18
+ comments: '',
19
+ dateAppointmentScheduled: null,
20
+ endDateTime: null,
21
+ location: { uuid: 'b1a8b05e-3542-4037-bbd3-998ee9c40574', name: 'Inpatient Ward' },
22
+ patient: { identifier: '100GEJ', name: 'John Wilson', uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e' },
23
+ provider: { uuid: 'f9badd80-ab76-11e2-9e96-0800200c9a66', display: 'Dr James Cook' },
24
+ providers: [{ uuid: 'f9badd80-ab76-11e2-9e96-0800200c9a66', display: 'Dr James Cook' }],
25
+ recurring: false,
26
+ service: {
27
+ appointmentServiceId: 1,
28
+ name: 'Outpatient',
29
+ uuid: 'e2ec9cf0-ec38-4d2b-af6c-59c82fa30b90',
30
+ description: 'Outpatient service',
31
+ creatorName: null,
32
+ startTime: '08:00',
33
+ endTime: '17:00',
34
+ maxAppointmentsLimit: null,
35
+ initialAppointmentStatus: AppointmentStatus.SCHEDULED,
36
+ durationMins: 15,
37
+ },
38
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
39
+ status: AppointmentStatus.SCHEDULED,
40
+ voided: false,
41
+ extensions: {},
42
+ teleconsultationLink: null,
43
+ };
44
+
45
+ function makeAppt(overrides: Partial<Appointment> = {}): Appointment {
46
+ return { ...baseAppointment, uuid: nextApptUuid(), ...overrides };
47
+ }
48
+
49
+ function renderCell(props: Partial<React.ComponentProps<typeof WeeklySlotCell>> = {}) {
50
+ return render(
51
+ <WeeklySlotCell
52
+ appointments={props.appointments ?? []}
53
+ isoDate={props.isoDate ?? '2026-06-09'}
54
+ startHour={props.startHour ?? 6}
55
+ endHour={props.endHour ?? 12}
56
+ isToday={props.isToday ?? false}
57
+ onSelectDate={props.onSelectDate ?? vi.fn()}
58
+ />,
59
+ );
60
+ }
61
+
62
+ describe('WeeklySlotCell — empty states', () => {
63
+ it('renders an empty cell when appointments array is empty', () => {
64
+ renderCell({ appointments: [] });
65
+ const cell = screen.getByTestId('slot-cell');
66
+ expect(cell).toHaveClass('slotCell');
67
+ expect(cell).not.toHaveClass('slotCellHasAppts');
68
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
69
+ expect(screen.queryByText(/.+/)).not.toBeInTheDocument();
70
+ });
71
+
72
+ it('renders empty when no appointments fall in the time block', () => {
73
+ const appt = makeAppt({ startDateTime: new Date('2026-06-09T14:00:00').getTime() });
74
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
75
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
76
+ expect(screen.queryByText(/.+/)).not.toBeInTheDocument();
77
+ });
78
+
79
+ it('adds today class to empty cell when isToday is true', () => {
80
+ renderCell({ isToday: true, appointments: [] });
81
+ expect(screen.getByTestId('slot-cell')).toHaveClass('slotCellToday');
82
+ });
83
+
84
+ it('does not add today class when isToday is false', () => {
85
+ renderCell({ isToday: false, appointments: [] });
86
+ expect(screen.getByTestId('slot-cell')).not.toHaveClass('slotCellToday');
87
+ });
88
+ });
89
+
90
+ describe('WeeklySlotCell — preview rendering', () => {
91
+ it('renders time and patient name for a single appointment', () => {
92
+ const appt = makeAppt({
93
+ startDateTime: new Date('2026-06-09T09:14:00').getTime(),
94
+ patient: { identifier: 'P1', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
95
+ });
96
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
97
+
98
+ expect(screen.getByText('9:14 AM')).toBeInTheDocument();
99
+ expect(screen.getByText('Alice')).toBeInTheDocument();
100
+ });
101
+
102
+ it('renders two appointments when exactly 2 fall in the block', () => {
103
+ const appts = [
104
+ makeAppt({
105
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
106
+ patient: { identifier: 'P1', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
107
+ }),
108
+ makeAppt({
109
+ startDateTime: new Date('2026-06-09T10:00:00').getTime(),
110
+ patient: { identifier: 'P2', name: 'Bob', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
111
+ }),
112
+ ];
113
+ renderCell({ appointments: appts, startHour: 6, endHour: 12 });
114
+
115
+ expect(screen.getByText('Alice')).toBeInTheDocument();
116
+ expect(screen.getByText('Bob')).toBeInTheDocument();
117
+ expect(screen.queryByText(/\+.*more/)).not.toBeInTheDocument();
118
+ });
119
+
120
+ it('shows only first 2 appointments with +N more for overflow', () => {
121
+ const appts = [
122
+ makeAppt({
123
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
124
+ patient: { identifier: 'P1', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
125
+ }),
126
+ makeAppt({
127
+ startDateTime: new Date('2026-06-09T10:00:00').getTime(),
128
+ patient: { identifier: 'P2', name: 'Bob', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
129
+ }),
130
+ makeAppt({
131
+ startDateTime: new Date('2026-06-09T11:00:00').getTime(),
132
+ patient: { identifier: 'P3', name: 'Carol', uuid: 'c73b920a-4f12-4d8e-91c5-6e7d8f3a2b01' },
133
+ }),
134
+ makeAppt({
135
+ startDateTime: new Date('2026-06-09T11:30:00').getTime(),
136
+ patient: { identifier: 'P4', name: 'Dave', uuid: 'd94e031b-5023-5e9f-a2d6-7f8e9a4b3c12' },
137
+ }),
138
+ makeAppt({
139
+ startDateTime: new Date('2026-06-09T11:45:00').getTime(),
140
+ patient: { identifier: 'P5', name: 'Eve', uuid: 'e05f142c-6134-6fa0-b3e7-809fab5c4d23' },
141
+ }),
142
+ ];
143
+ renderCell({ appointments: appts, startHour: 6, endHour: 12 });
144
+
145
+ expect(screen.getByText('Alice')).toBeInTheDocument();
146
+ expect(screen.getByText('Bob')).toBeInTheDocument();
147
+ expect(screen.getByText('+3 more')).toBeInTheDocument();
148
+ expect(screen.queryByText('Carol')).not.toBeInTheDocument();
149
+ expect(screen.queryByText('Dave')).not.toBeInTheDocument();
150
+ expect(screen.queryByText('Eve')).not.toBeInTheDocument();
151
+ });
152
+
153
+ it('renders +1 more when exactly 3 appointments', () => {
154
+ const appts = [
155
+ makeAppt({
156
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
157
+ patient: { identifier: 'P1', name: 'A', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
158
+ }),
159
+ makeAppt({
160
+ startDateTime: new Date('2026-06-09T10:00:00').getTime(),
161
+ patient: { identifier: 'P2', name: 'B', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
162
+ }),
163
+ makeAppt({
164
+ startDateTime: new Date('2026-06-09T11:00:00').getTime(),
165
+ patient: { identifier: 'P3', name: 'C', uuid: 'c73b920a-4f12-4d8e-91c5-6e7d8f3a2b01' },
166
+ }),
167
+ ];
168
+ renderCell({ appointments: appts, startHour: 6, endHour: 12 });
169
+
170
+ expect(screen.getByText('+1 more')).toBeInTheDocument();
171
+ });
172
+
173
+ it('renders +2 more when exactly 4 appointments', () => {
174
+ const patientUuids = [
175
+ '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7',
176
+ '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69',
177
+ 'c73b920a-4f12-4d8e-91c5-6e7d8f3a2b01',
178
+ 'd94e031b-5023-5e9f-a2d6-7f8e9a4b3c12',
179
+ ];
180
+ const appts = Array.from({ length: 4 }, (_, i) =>
181
+ makeAppt({
182
+ startDateTime: new Date(`2026-06-09T${String(7 + i).padStart(2, '0')}:00:00`).getTime(),
183
+ patient: { identifier: `P${i}`, name: `Patient${i}`, uuid: patientUuids[i] },
184
+ }),
185
+ );
186
+ renderCell({ appointments: appts, startHour: 6, endHour: 12 });
187
+
188
+ expect(screen.getByText('+2 more')).toBeInTheDocument();
189
+ });
190
+
191
+ it('sorts appointments by startDateTime ascending', () => {
192
+ const appts = [
193
+ makeAppt({
194
+ startDateTime: new Date('2026-06-09T11:00:00').getTime(),
195
+ patient: { identifier: 'P3', name: 'Third', uuid: 'c73b920a-4f12-4d8e-91c5-6e7d8f3a2b01' },
196
+ }),
197
+ makeAppt({
198
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
199
+ patient: { identifier: 'P1', name: 'First', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
200
+ }),
201
+ makeAppt({
202
+ startDateTime: new Date('2026-06-09T10:00:00').getTime(),
203
+ patient: { identifier: 'P2', name: 'Second', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
204
+ }),
205
+ ];
206
+ renderCell({ appointments: appts, startHour: 6, endHour: 12 });
207
+
208
+ const names = screen.getAllByText(/^(First|Second)$/);
209
+ expect(names.map((el) => el.textContent)).toEqual(['First', 'Second']);
210
+ expect(screen.getByText('+1 more')).toBeInTheDocument();
211
+ });
212
+
213
+ it('renders "—" for appointment with missing patient name', () => {
214
+ const appt = makeAppt({
215
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
216
+ patient: { identifier: 'P1', name: null as unknown as string, uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
217
+ });
218
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
219
+
220
+ expect(screen.getByText('9:00 AM')).toBeInTheDocument();
221
+ expect(screen.getByText('—')).toBeInTheDocument();
222
+ });
223
+
224
+ it('renders PM times correctly', () => {
225
+ const appt = makeAppt({
226
+ startDateTime: new Date('2026-06-09T14:30:00').getTime(),
227
+ patient: { identifier: 'P1', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
228
+ });
229
+ renderCell({ appointments: [appt], startHour: 12, endHour: 18 });
230
+
231
+ expect(screen.getByText('2:30 PM')).toBeInTheDocument();
232
+ });
233
+
234
+ it('renders midnight as 12:00 AM', () => {
235
+ const appt = makeAppt({
236
+ startDateTime: new Date('2026-06-09T00:30:00').getTime(),
237
+ patient: { identifier: 'P1', name: 'Late', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
238
+ });
239
+ renderCell({ appointments: [appt], startHour: 0, endHour: 6 });
240
+
241
+ expect(screen.getByText('12:30 AM')).toBeInTheDocument();
242
+ });
243
+
244
+ it('renders noon as 12:00 PM', () => {
245
+ const appt = makeAppt({
246
+ startDateTime: new Date('2026-06-09T12:00:00').getTime(),
247
+ patient: { identifier: 'P1', name: 'Noon', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
248
+ });
249
+ renderCell({ appointments: [appt], startHour: 12, endHour: 18 });
250
+
251
+ expect(screen.getByText('12:00 PM')).toBeInTheDocument();
252
+ });
253
+ });
254
+
255
+ describe('WeeklySlotCell — interactions', () => {
256
+ it('calls onSelectDate with correct args when cell is clicked', () => {
257
+ const onSelectDate = vi.fn();
258
+ const appt = makeAppt({
259
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
260
+ patient: { identifier: 'P1', name: 'Alice', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
261
+ });
262
+ renderCell({
263
+ appointments: [appt],
264
+ isoDate: '2026-06-09',
265
+ startHour: 6,
266
+ endHour: 12,
267
+ onSelectDate,
268
+ });
269
+
270
+ fireEvent.click(screen.getByRole('button'));
271
+
272
+ expect(onSelectDate).toHaveBeenCalledTimes(1);
273
+ expect(onSelectDate).toHaveBeenCalledWith('2026-06-09', 6, 12);
274
+ });
275
+
276
+ it('fires onSelectDate on Enter key', () => {
277
+ const onSelectDate = vi.fn();
278
+ const appt = makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() });
279
+ renderCell({ appointments: [appt], isoDate: '2026-06-09', startHour: 6, endHour: 12, onSelectDate });
280
+
281
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
282
+
283
+ expect(onSelectDate).toHaveBeenCalledWith('2026-06-09', 6, 12);
284
+ });
285
+
286
+ it('fires onSelectDate on Space key', () => {
287
+ const onSelectDate = vi.fn();
288
+ const appt = makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() });
289
+ renderCell({ appointments: [appt], isoDate: '2026-06-09', startHour: 6, endHour: 12, onSelectDate });
290
+
291
+ fireEvent.keyDown(screen.getByRole('button'), { key: ' ' });
292
+
293
+ expect(onSelectDate).toHaveBeenCalledWith('2026-06-09', 6, 12);
294
+ });
295
+
296
+ it('ignores non-Enter/Space keys', () => {
297
+ const onSelectDate = vi.fn();
298
+ const appt = makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() });
299
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12, onSelectDate });
300
+
301
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Escape' });
302
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Tab' });
303
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
304
+
305
+ expect(onSelectDate).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it('calls onSelectDate when +N more link is clicked', () => {
309
+ const onSelectDate = vi.fn();
310
+ const appts = [
311
+ makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() }),
312
+ makeAppt({ startDateTime: new Date('2026-06-09T10:00:00').getTime() }),
313
+ makeAppt({ startDateTime: new Date('2026-06-09T11:00:00').getTime() }),
314
+ ];
315
+ renderCell({ appointments: appts, isoDate: '2026-06-09', startHour: 6, endHour: 12, onSelectDate });
316
+
317
+ fireEvent.click(screen.getByText('+1 more'));
318
+
319
+ expect(onSelectDate).toHaveBeenCalledWith('2026-06-09', 6, 12);
320
+ });
321
+
322
+ it('+N more click does not trigger parent cell click (stopPropagation)', () => {
323
+ const onSelectDate = vi.fn();
324
+ const appts = [
325
+ makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() }),
326
+ makeAppt({ startDateTime: new Date('2026-06-09T10:00:00').getTime() }),
327
+ makeAppt({ startDateTime: new Date('2026-06-09T11:00:00').getTime() }),
328
+ ];
329
+ renderCell({ appointments: appts, startHour: 6, endHour: 12, onSelectDate });
330
+
331
+ fireEvent.click(screen.getByText('+1 more'));
332
+
333
+ expect(onSelectDate).toHaveBeenCalledTimes(1);
334
+ });
335
+ });
336
+
337
+ describe('WeeklySlotCell — time-block filtering', () => {
338
+ it('includes appointment at the start hour boundary (inclusive)', () => {
339
+ const appt = makeAppt({
340
+ startDateTime: new Date('2026-06-09T06:00:00').getTime(),
341
+ patient: { identifier: 'P1', name: 'Edge', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
342
+ });
343
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
344
+
345
+ expect(screen.getByText('Edge')).toBeInTheDocument();
346
+ });
347
+
348
+ it('excludes appointment at the end hour boundary (exclusive)', () => {
349
+ const appt = makeAppt({
350
+ startDateTime: new Date('2026-06-09T12:00:00').getTime(),
351
+ patient: { identifier: 'P1', name: 'Noon', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
352
+ });
353
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
354
+
355
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
356
+ expect(screen.queryByText('Noon')).not.toBeInTheDocument();
357
+ });
358
+
359
+ it('excludes appointments with null startDateTime from the block', () => {
360
+ const appt = makeAppt({
361
+ startDateTime: null,
362
+ patient: { identifier: 'P1', name: 'NullTime', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
363
+ });
364
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
365
+
366
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
367
+ expect(screen.queryByText('NullTime')).not.toBeInTheDocument();
368
+ });
369
+
370
+ it('filters across all 4 time blocks correctly', () => {
371
+ const appts = [
372
+ makeAppt({
373
+ startDateTime: new Date('2026-06-09T03:00:00').getTime(),
374
+ patient: { identifier: 'P0', name: '12-6 AM', uuid: 'a16f253d-7241-7fb1-c4f8-91afbc6d5e34' },
375
+ }),
376
+ makeAppt({
377
+ startDateTime: new Date('2026-06-09T09:00:00').getTime(),
378
+ patient: { identifier: 'P1', name: '6-12 AM', uuid: '3d13f51a-1eaf-4b62-b329-4c21cf95b9a7' },
379
+ }),
380
+ makeAppt({
381
+ startDateTime: new Date('2026-06-09T15:00:00').getTime(),
382
+ patient: { identifier: 'P2', name: '12-6 PM', uuid: '5a1c0ab4-9b22-4c6f-84e5-b3e2c3fa5d69' },
383
+ }),
384
+ makeAppt({
385
+ startDateTime: new Date('2026-06-09T21:00:00').getTime(),
386
+ patient: { identifier: 'P3', name: '6-12 PM', uuid: 'c73b920a-4f12-4d8e-91c5-6e7d8f3a2b01' },
387
+ }),
388
+ ];
389
+
390
+ renderCell({ appointments: appts, startHour: 6, endHour: 12 });
391
+
392
+ expect(screen.getByText('6-12 AM')).toBeInTheDocument();
393
+ expect(screen.queryByText('12-6 AM')).not.toBeInTheDocument();
394
+ expect(screen.queryByText('12-6 PM')).not.toBeInTheDocument();
395
+ expect(screen.queryByText('6-12 PM')).not.toBeInTheDocument();
396
+ });
397
+ });
398
+
399
+ describe('WeeklySlotCell — accessibility', () => {
400
+ it('has role="button" when appointments exist', () => {
401
+ const appt = makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() });
402
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
403
+
404
+ expect(screen.getByRole('button')).toBeInTheDocument();
405
+ });
406
+
407
+ it('has tabIndex={0} when appointments exist', () => {
408
+ const appt = makeAppt({ startDateTime: new Date('2026-06-09T09:00:00').getTime() });
409
+ renderCell({ appointments: [appt], startHour: 6, endHour: 12 });
410
+
411
+ expect(screen.getByRole('button')).toHaveAttribute('tabindex', '0');
412
+ });
413
+
414
+ it('does not have role="button" when empty', () => {
415
+ renderCell({ appointments: [], startHour: 6, endHour: 12 });
416
+
417
+ expect(screen.getByTestId('slot-cell').getAttribute('role')).not.toBe('button');
418
+ });
419
+ });