Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions appointment-booking/app/api/timeslots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { getApiBaseUrl } from '../runtime-config'

// Start and end time shown in the booking UI.
export type TimeSlot = {
startTime: string
endTime: string
}

// Available times grouped by date (YYYY-MM-DD).
export type AvailableTimeSlots = Record<string, TimeSlot[]>

// One slot as returned by the office slots API.
type ApiTimeSlot = {
start_time: string
end_time: string
no_of_slots: number
}

// The API groups slots by MM/DD/YYYY dates.
type ApiAvailableTimeSlots = Record<string, ApiTimeSlot[]>

// Convert MM/DD/YYYY to YYYY-MM-DD. Skip bad dates instead of failing the whole load.
function toIsoDate(date: string): string | null {
const [month, day, year] = date.split('/')
if (!month || !day || !year) {
return null
}
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
}

// Load bookable times for the selected office and service.
// Throws when the request fails so the page can show an error, not "no times available".
export async function getAvailableTimeSlots(
officeId: number,
serviceId: number,
): Promise<AvailableTimeSlots> {
const url = `${await getApiBaseUrl()}/offices/${officeId}/slots/?service_id=${serviceId}`

let body: ApiAvailableTimeSlots
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Failed to load time slots')
}
body = (await response.json()) as ApiAvailableTimeSlots
} catch {
throw new Error('Failed to load time slots')
}

if (!body || typeof body !== 'object') {
throw new Error('Failed to load time slots')
}

const availableTimeSlots: AvailableTimeSlots = {}

for (const [date, slots] of Object.entries(body)) {
const isoDate = toIsoDate(date)
if (!isoDate) {
continue
}

// Skip slots with no openings left, in case the API includes them.
const availableSlots = slots
.filter((slot) => slot.no_of_slots > 0)
.map((slot) => ({
startTime: slot.start_time,
endTime: slot.end_time,
}))

// Leave empty days out so the calendar can mark them unavailable.
if (availableSlots.length > 0) {
availableTimeSlots[isoDate] = availableSlots
}
}

return availableTimeSlots
}
128 changes: 128 additions & 0 deletions appointment-booking/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ p {
overflow-x: auto;
width: 100%;
max-width: 100%;
}

.services-table-wrapper,
.datetime-selection-panel {
box-sizing: border-box;
background: var(--surface-color-background-white);
border: var(--layout-border-width-small) solid var(--surface-color-border-default);
Expand Down Expand Up @@ -566,6 +570,130 @@ p {
margin-bottom: var(--layout-margin-large);
}

.datetime-status {
margin-top: var(--layout-margin-large);
}

/* Same gold top bar used on the services and locations tables. */
.datetime-selection-panel {
margin-top: var(--layout-margin-large);
padding: var(--layout-padding-xlarge);
}

.datetime-selection {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--layout-padding-xlarge);
}

.datetime-selection h2 {
margin-top: 0;
font: var(--typography-bold-h3);
}

.datetime-selected-day {
margin: 0 0 var(--layout-margin-medium);
padding: var(--layout-padding-small) var(--layout-padding-medium);
width: fit-content;
max-width: 100%;
box-sizing: border-box;
border-radius: var(--layout-border-radius-medium);
background: var(--theme-blue-10);
font: var(--typography-regular-small-body);
color: var(--typography-color-secondary);
text-align: center;
}

.datetime-time-panel {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}

.datetime-calendar-legend {
margin: var(--layout-margin-small) 0 0;
padding: 0 0 0 1.25rem;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
list-style-position: outside;
text-align: left;
font: var(--typography-regular-small-body);
color: var(--typography-color-secondary);
}

.datetime-calendar-legend-unavailable {
color: var(--typography-color-danger);
text-decoration: line-through;
}

.datetime-calendar-legend-disabled {
color: var(--typography-color-disabled);
}

.datetime-time-slots {
width: 100%;
max-width: 28rem;
}

/* Stretch the select so the full "9:00 a.m. – 9:15 a.m." label fits on one line. */
.datetime-time-slots .bcds-react-aria-Select {
width: 100%;
}

.datetime-time-slots .bcds-react-aria-Select--Button {
width: 100%;
}

/* Make the calendar wider on desktop so it fills its column. */
@media (min-width: 48rem) {
.datetime-selection .bcds-react-aria-Calendar {
display: flex;
width: 100%;
max-width: 26rem;
}

.datetime-selection .bcds-react-aria-Calendar--GridContainer,
.datetime-selection .bcds-react-aria-Calendar--Header,
.datetime-selection .bcds-react-aria-Calendar--Grid {
width: 100%;
}

.datetime-selection .bcds-react-aria-Calendar--GridContainer {
flex: 1 1 auto;
}

.datetime-selection .bcds-react-aria-Calendar--Cell {
width: 2.75rem;
height: 2.75rem;
}
}

@media (max-width: 48rem) {
.datetime-selection {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}

.datetime-selection section {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}

.datetime-time-panel {
align-items: center;
}

.datetime-calendar-legend {
margin-left: auto;
margin-right: auto;
}
}

.header-account {
display: flex;
align-items: center;
Expand Down
2 changes: 2 additions & 0 deletions appointment-booking/app/auth/session-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const SessionKeys = {
// Booking selections survive the IdP redirect round-trip.
BookingSelectedService: 'BOOKING_SELECTED_SERVICE',
BookingSelectedLocation: 'BOOKING_SELECTED_LOCATION',
BookingSelectedSlot: 'BOOKING_SELECTED_SLOT',
} as const

// Cleared on logout and when discarding a bad/disallowed auth session.
Expand All @@ -28,6 +29,7 @@ export const AUTH_SESSION_KEYS = [
export const BOOKING_SESSION_KEYS = [
SessionKeys.BookingSelectedService,
SessionKeys.BookingSelectedLocation,
SessionKeys.BookingSelectedSlot,
] as const

export const IdpHint = {
Expand Down
40 changes: 26 additions & 14 deletions appointment-booking/app/booking/booking-context.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,54 @@
// Shared service + location selection across booking steps.
// Also saved to sessionStorage so selections survive the BCSC redirect.
// Shared service, location, and appointment time across booking steps.
// Also saved in the browser so choices survive the sign-in redirect.
import { useContext, useEffect, useState, type ReactNode } from 'react'

import type { Location } from '../api/locations'
import type { Service } from '../api/services'
import { addJsonToSession, getJsonFromSession, removeFromSession } from '../auth/session'
import { SessionKeys } from '../auth/session-keys'
import { BookingContext } from './booking-store'
import { BookingContext, type BookingSlot } from './booking-store'

function persistJson(key: string, value: unknown) {
if (value) {
addJsonToSession(key, value)
} else {
removeFromSession(key)
}
}
Comment on lines +11 to +17

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice simple extraction :-)


export function BookingProvider({ children }: { children: ReactNode }) {
const [isReady, setIsReady] = useState(false)
const [selectedService, setSelectedServiceState] = useState<Service | null>(null)
const [selectedLocation, setSelectedLocationState] = useState<Location | null>(null)
const [selectedSlot, setSelectedSlotState] = useState<BookingSlot | null>(null)

useEffect(() => {
// sessionStorage is browser-only, so restore it after the initial render.
// Restore saved choices after the page first loads in the browser.
const id = window.setTimeout(() => {
setSelectedServiceState(getJsonFromSession<Service>(SessionKeys.BookingSelectedService))
setSelectedLocationState(getJsonFromSession<Location>(SessionKeys.BookingSelectedLocation))
setSelectedSlotState(getJsonFromSession<BookingSlot>(SessionKeys.BookingSelectedSlot))
setIsReady(true)
}, 0)
return () => window.clearTimeout(id)
}, [])

// Changing service or location clears any time already chosen.
function setSelectedService(service: Service | null) {
setSelectedServiceState(service)
if (service) {
addJsonToSession(SessionKeys.BookingSelectedService, service)
} else {
removeFromSession(SessionKeys.BookingSelectedService)
}
setSelectedSlot(null)
persistJson(SessionKeys.BookingSelectedService, service)
}

function setSelectedLocation(location: Location | null) {
setSelectedLocationState(location)
if (location) {
addJsonToSession(SessionKeys.BookingSelectedLocation, location)
} else {
removeFromSession(SessionKeys.BookingSelectedLocation)
}
setSelectedSlot(null)
persistJson(SessionKeys.BookingSelectedLocation, location)
}

function setSelectedSlot(slot: BookingSlot | null) {
setSelectedSlotState(slot)
persistJson(SessionKeys.BookingSelectedSlot, slot)
}

return (
Expand All @@ -49,6 +59,8 @@ export function BookingProvider({ children }: { children: ReactNode }) {
setSelectedService,
selectedLocation,
setSelectedLocation,
selectedSlot,
setSelectedSlot,
}}
>
{children}
Expand Down
9 changes: 9 additions & 0 deletions appointment-booking/app/booking/booking-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@ import { createContext } from 'react'
import type { Location } from '../api/locations'
import type { Service } from '../api/services'

// The chosen appointment date and time. Date is YYYY-MM-DD; times are HH:MM.
export type BookingSlot = {
date: string
startTime: string
endTime: string
}

export type BookingContextValue = {
isReady: boolean
selectedService: Service | null
setSelectedService: (service: Service | null) => void
selectedLocation: Location | null
setSelectedLocation: (location: Location | null) => void
selectedSlot: BookingSlot | null
setSelectedSlot: (slot: BookingSlot | null) => void
}

// Isolated from component exports so Vite/Fast Refresh cannot duplicate this context.
Expand Down
1 change: 1 addition & 0 deletions appointment-booking/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export default [
route('service-locations', 'routes/service-locations.tsx'),
route('signin/:idpHint', 'routes/signin.$idpHint.tsx'),
route('login', 'routes/login.tsx'),
route('datetime', 'routes/datetime.tsx'),
route('locations', 'routes/locations.tsx'),
] satisfies RouteConfig
Loading
Loading