Skip to content

Commit f12904f

Browse files
evangauerclaude
andcommitted
Suggest open times in portal booking
Connects the availability engine to a real surface: the portal booking form now fetches open times for the chosen date (public portal.availableSlots query, practice-wide busy intervals) and shows them as tappable chips that fill the time field — instead of asking clients to guess a time blind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7ae97c3 commit f12904f

2 files changed

Lines changed: 74 additions & 0 deletions

File tree

apps/web/app/portal/[token]/book/page.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export default function BookAppointmentPage() {
1919
const [preferredTime, setPreferredTime] = useState("");
2020
const [reason, setReason] = useState("");
2121

22+
// Suggested open times for the chosen date.
23+
const slots = trpc.portal.availableSlots.useQuery(
24+
{ token, date: preferredDate, durationMinutes: 30 },
25+
{ enabled: !!preferredDate }
26+
);
27+
2228
const pets = client.data?.patients ?? [];
2329
const canSubmit =
2430
patientId && preferredDate && preferredTime && reason.trim() && !request.isPending;
@@ -132,6 +138,30 @@ export default function BookAppointmentPage() {
132138
</div>
133139
</div>
134140

141+
{preferredDate && (slots.data?.length ?? 0) > 0 && (
142+
<div>
143+
<p className="text-xs font-medium text-gray-500 mb-2">
144+
Open times on {preferredDate} (tap to pick)
145+
</p>
146+
<div className="flex flex-wrap gap-2">
147+
{slots.data!.map((s) => (
148+
<button
149+
key={s.iso}
150+
type="button"
151+
onClick={() => setPreferredTime(s.time)}
152+
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
153+
preferredTime === s.time
154+
? "border-teal-500 bg-teal-50 text-teal-700"
155+
: "border-gray-200 text-gray-600 hover:border-teal-300"
156+
}`}
157+
>
158+
{s.time}
159+
</button>
160+
))}
161+
</div>
162+
</div>
163+
)}
164+
135165
<div>
136166
<label className="block text-sm font-medium text-gray-700 mb-1.5">
137167
Anything we should know?

apps/web/server/routers/portal.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { users } from "@openpims/db";
1818
import { rateLimit } from "@/lib/rate-limit";
1919
import { dispatchWebhookEvent } from "@/lib/webhook-dispatcher";
2020
import { buildRequestedSlot } from "@/lib/portal/booking";
21+
import { findOpenSlots } from "@/lib/scheduling/availability";
22+
import { not, inArray, lt, gt } from "drizzle-orm";
2123

2224
async function getClientByToken(db: any, token: string) {
2325
const [client] = await db
@@ -237,6 +239,48 @@ export const portalRouter = createRouter({
237239
.orderBy(appointmentTypes.name);
238240
}),
239241

242+
/** Suggested open times on a date for the booking form (practice-wide). */
243+
availableSlots: publicProcedure
244+
.input(
245+
z.object({
246+
token: z.string().min(1),
247+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
248+
durationMinutes: z.number().int().min(10).max(120).default(30),
249+
})
250+
)
251+
.query(async ({ ctx, input }) => {
252+
const client = await getClientByToken(ctx.db, input.token);
253+
const dayStart = new Date(`${input.date}T08:00:00`);
254+
const dayEnd = new Date(`${input.date}T18:00:00`);
255+
256+
const busy = await ctx.db
257+
.select({
258+
startTime: appointments.startTime,
259+
endTime: appointments.endTime,
260+
})
261+
.from(appointments)
262+
.where(
263+
and(
264+
eq(appointments.practiceId, client.practiceId),
265+
isNull(appointments.deletedAt),
266+
not(inArray(appointments.status, ["cancelled", "no_show"])),
267+
lt(appointments.startTime, dayEnd),
268+
gt(appointments.endTime, dayStart)
269+
)
270+
);
271+
272+
return findOpenSlots({
273+
dayStart,
274+
dayEnd,
275+
slotMinutes: input.durationMinutes,
276+
busy,
277+
}).map((s) => ({
278+
// 24h HH:MM in the server's local time, matching the booking form input.
279+
time: `${String(s.start.getHours()).padStart(2, "0")}:${String(s.start.getMinutes()).padStart(2, "0")}`,
280+
iso: s.start.toISOString(),
281+
}));
282+
}),
283+
240284
requestAppointment: publicProcedure
241285
.input(
242286
z.object({

0 commit comments

Comments
 (0)