<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mohammed Obaid]]></title><description><![CDATA[Mohammed Obaid]]></description><link>https://mdobaid311.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 21:19:15 GMT</lastBuildDate><atom:link href="https://mdobaid311.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Room booking app with Next.js and Prisma]]></title><description><![CDATA[The room booking tutorial ends with "check again on the server". This one builds that server. You get a Next.js App Router app where rooms and bookings…]]></description><link>https://mdobaid311.hashnode.dev/room-booking-app-with-next-js-and-prisma</link><guid isPermaLink="true">https://mdobaid311.hashnode.dev/room-booking-app-with-next-js-and-prisma</guid><category><![CDATA[Next.js]]></category><category><![CDATA[prisma]]></category><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Mohammed Obaid]]></dc:creator><pubDate>Wed, 14 Oct 2026 15:24:50 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resource-scheduler-docs.vercel.app/launch.mp4"><img src="https://resource-scheduler-docs.vercel.app/launch-poster.png" alt="Watch the 40-second tour" /></a></p>
<p>The <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/">room booking tutorial</a> ends with "check again on the server". This one builds that server. You get a Next.js App Router app where rooms and bookings live in Postgres through Prisma, the calendar is <a href="https://github.com/mdobaid311/resource-scheduler">Resource Scheduler</a> in a client component, and the route handlers decide whether a booking is allowed.</p>
<p>Every file is in the steps below, so you can copy them in order. The finished app is also in the repository as the <a href="https://github.com/mdobaid311/resource-scheduler/tree/main/resource-scheduler-rooms-starter"><code>resource-scheduler-rooms-starter</code></a> folder; fetch just that folder with <code>npx degit mdobaid311/resource-scheduler/resource-scheduler-rooms-starter rooms</code>.</p>
<p><em>See the browser side running live:</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-nextjs-prisma/"><em>interactive demo</em></a> <em>(it has no server, so its data resets on reload).</em></p>
<p>Drag across free time on a room to open the form. The app you build adds three things to it: the data comes from a database, a save can be refused by the server, and a refused save rolls back on screen.</p>
<blockquote>
<p><strong>Versions.</strong> The snippets are written for Next.js 15 (in 14, <code>params</code> in a route handler is not a Promise) and Prisma 6 with Postgres. Newer Prisma majors changed how the client is generated and how the connection is configured, so follow the Prisma docs for that part. The queries and the transaction do not depend on it.</p>
</blockquote>
<h2>How the pieces fit</h2>
<ol>
<li><p>The server component <code>app/page.tsx</code> reads rooms and bookings with Prisma and passes them as plain strings.</p>
</li>
<li><p>The client component <code>app/board.tsx</code> renders <code>ResourceScheduler</code> and sends changes to the API.</p>
</li>
<li><p>The route handlers under <code>app/api/bookings</code> call one function, which re-checks the placement with <code>isPlacementAllowed</code> inside a transaction.</p>
</li>
<li><p>The browser and the server share one object, <code>bookingRules</code>, so they cannot disagree about what "free" means.</p>
</li>
</ol>
<h2>1. Create the app</h2>
<pre><code class="language-bash">npx create-next-app@latest rooms --ts --app
cd rooms
npm install resource-scheduler @prisma/client
npm install -D prisma@6
npx prisma init --datasource-provider postgresql
</code></pre>
<p>Put your Postgres connection string in <code>.env</code> as <code>DATABASE_URL</code>. Any Postgres works, local or hosted.</p>
<h2>2. The schema</h2>
<p>Store times as <code>DateTime</code>. Prisma saves them in UTC, and the end is exclusive: a booking that ends at 10:00 does not collide with one that starts at 10:00.</p>
<p><code>prisma/schema.prisma</code></p>
<pre><code class="language-prisma">generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Room {
  id       String    @id @default(cuid())
  name     String
  seats    Int
  bookings Booking[]
}

model Booking {
  id        String   @id @default(cuid())
  title     String
  roomId    String
  room      Room     @relation(fields: [roomId], references: [id])
  startsAt  DateTime
  endsAt    DateTime
  createdAt DateTime @default(now())

  @@index([roomId, startsAt])
}
</code></pre>
<p>Create the tables and two rooms to play with:</p>
<pre><code class="language-bash">npx prisma migrate dev --name init
</code></pre>
<p><code>prisma/seed.ts</code></p>
<pre><code class="language-ts">import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  await prisma.room.createMany({
    data: [
      { name: "Atlas", seats: 8 },
      { name: "Borealis", seats: 4 },
    ],
  });
}

main().finally(() =&gt; prisma.$disconnect());
</code></pre>
<pre><code class="language-bash">npx tsx prisma/seed.ts
</code></pre>
<h2>3. Shared code</h2>
<p>One Prisma client for the whole dev server (Next.js reloads modules, and without this every reload opens a new connection pool):</p>
<p><code>lib/db.ts</code></p>
<pre><code class="language-ts">import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
</code></pre>
<p>The rule, in one place. The client spreads it onto the component, and the server hands the same object to <code>isPlacementAllowed</code>:</p>
<p><code>lib/rules.ts</code></p>
<pre><code class="language-ts">export const bookingRules = { eventOverlap: false as const };
</code></pre>
<p>A booking crosses the server boundary and the network as JSON, where a <code>Date</code> becomes a string. This is the shape on the wire:</p>
<p><code>lib/dto.ts</code></p>
<pre><code class="language-ts">import type { Booking } from "@prisma/client";

export type BookingDTO = {
  id: string;
  title: string;
  roomId: string;
  startsAt: string;
  endsAt: string;
};

export const toDTO = (b: Booking): BookingDTO =&gt; ({
  id: b.id,
  title: b.title,
  roomId: b.roomId,
  startsAt: b.startsAt.toISOString(),
  endsAt: b.endsAt.toISOString(),
});
</code></pre>
<h2>4. The page loads rooms and bookings</h2>
<p>A server component can read the database directly. It loads the rooms and a window of bookings around now (for a long range, fetch per visible range with <code>onRangeChange</code>, see <a href="https://resource-scheduler-docs.vercel.app/guides/loading-and-control/">Loading and control</a>). It also passes the current time as a string, so the first render on the server and in the browser start on the same day. See <a href="https://resource-scheduler-docs.vercel.app/guides/nextjs-and-ssr/">Next.js and SSR</a>.</p>
<p><code>app/page.tsx</code></p>
<pre><code class="language-tsx">import { prisma } from "@/lib/db";
import { toDTO } from "@/lib/dto";
import { Board } from "./board";

export const dynamic = "force-dynamic"; // read the database per request, not at build time

const DAY = 24 * 60 * 60 * 1000;

export default async function Page() {
  const now = new Date();
  const [rooms, bookings] = await Promise.all([
    prisma.room.findMany({ orderBy: { name: "asc" } }),
    prisma.booking.findMany({
      where: {
        endsAt: { gt: new Date(now.getTime() - DAY) },
        startsAt: { lt: new Date(now.getTime() + 14 * DAY) },
      },
    }),
  ]);

  return (
    &lt;main style={{ padding: 16 }}&gt;
      &lt;Board
        rooms={rooms.map((r) =&gt; ({ id: r.id, name: `${r.name} (${r.seats} seats)` }))}
        initial={bookings.map(toDTO)}
        initialDate={now.toISOString()}
      /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h2>5. The server decides</h2>
<p>This is the file the whole tutorial is for. A booking is allowed only if <code>isPlacementAllowed</code> says so for the room's current bookings, and the check and the write happen in one <code>Serializable</code> transaction, so two requests that both read "free" cannot both commit.</p>
<p><code>lib/bookings.ts</code></p>
<pre><code class="language-ts">import { Prisma } from "@prisma/client";
import { isPlacementAllowed } from "resource-scheduler/headless";
import type { Resource } from "resource-scheduler/headless";
import { prisma } from "./db";
import { bookingRules } from "./rules";

export class Conflict extends Error {}
export class NotFound extends Error {}

type Slot = { roomId: string; start: Date; end: Date };

const inTransaction = &lt;T&gt;(run: (tx: Prisma.TransactionClient) =&gt; Promise&lt;T&gt;) =&gt;
  prisma.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });

// Throws Conflict unless the booking `id` (none, for a new one) may take this slot.
async function assertFree(tx: Prisma.TransactionClient, id: string | undefined, { roomId, start, end }: Slot) {
  const room = await tx.room.findUnique({
    where: { id: roomId },
    include: { bookings: { where: { startsAt: { lt: end }, endsAt: { gt: start } } } },
  });
  if (!room) throw new NotFound();

  const resource: Resource = {
    id: room.id,
    name: room.name,
    events: room.bookings.map((b) =&gt; ({ id: b.id, title: b.title, startDate: b.startsAt, endDate: b.endsAt })),
  };
  const moving = { id: id ?? "new", title: "", startDate: start, endDate: end };

  if (!isPlacementAllowed(moving, { resourceId: roomId, start, end }, [resource], bookingRules)) {
    throw new Conflict();
  }
}

export const createBooking = (slot: Slot &amp; { title: string }) =&gt;
  inTransaction(async (tx) =&gt; {
    await assertFree(tx, undefined, slot);
    return tx.booking.create({
      data: { roomId: slot.roomId, title: slot.title, startsAt: slot.start, endsAt: slot.end },
    });
  });

export const moveBooking = (id: string, slot: Slot) =&gt;
  inTransaction(async (tx) =&gt; {
    if (!(await tx.booking.findUnique({ where: { id } }))) throw new NotFound();
    await assertFree(tx, id, slot); // the booking's own old time is ignored: its id is `id`
    return tx.booking.update({
      where: { id },
      data: { roomId: slot.roomId, startsAt: slot.start, endsAt: slot.end },
    });
  });
</code></pre>
<p>The query already narrows to bookings that overlap the slot, and <code>isPlacementAllowed</code> applies the same overlap rule the browser used, including ignoring the booking being moved. Under <code>Serializable</code>, Postgres aborts one of two conflicting transactions when it commits; Prisma reports that as error <code>P2034</code>, which the next file also turns into a 409 (retrying once is an equally good answer).</p>
<p>Two small helpers turn requests into slots and errors into responses:</p>
<p><code>lib/http.ts</code></p>
<pre><code class="language-ts">import { Prisma } from "@prisma/client";
import { Conflict, NotFound } from "./bookings";

// body comes from request.json(), so nothing in it is trusted.
export function parseSlot(body: { roomId?: unknown; startsAt?: unknown; endsAt?: unknown }) {
  const start = new Date(body.startsAt as string);
  const end = new Date(body.endsAt as string);
  if (typeof body.roomId !== "string" || !(start &lt; end)) return null; // also catches Invalid Date
  return { roomId: body.roomId, start, end };
}

export function fail(error: unknown) {
  if (error instanceof Conflict || (error instanceof Prisma.PrismaClientKnownRequestError &amp;&amp; error.code === "P2034")) {
    return Response.json({ error: "That time was just booked." }, { status: 409 });
  }
  if (error instanceof NotFound) return Response.json({ error: "Not found." }, { status: 404 });
  throw error;
}
</code></pre>
<p>The route handlers are thin:</p>
<p><code>app/api/bookings/route.ts</code></p>
<pre><code class="language-ts">import { createBooking } from "@/lib/bookings";
import { toDTO } from "@/lib/dto";
import { fail, parseSlot } from "@/lib/http";

export async function POST(request: Request) {
  const body = await request.json().catch(() =&gt; ({}));
  const slot = parseSlot(body);
  const title = typeof body.title === "string" ? body.title.trim() : "";
  if (!slot || !title) return Response.json({ error: "Invalid booking." }, { status: 400 });

  try {
    return Response.json(toDTO(await createBooking({ ...slot, title })), { status: 201 });
  } catch (error) {
    return fail(error);
  }
}
</code></pre>
<p><code>app/api/bookings/[id]/route.ts</code></p>
<pre><code class="language-ts">import { moveBooking } from "@/lib/bookings";
import { toDTO } from "@/lib/dto";
import { fail, parseSlot } from "@/lib/http";

export async function PATCH(request: Request, { params }: { params: Promise&lt;{ id: string }&gt; }) {
  const { id } = await params;
  const slot = parseSlot(await request.json().catch(() =&gt; ({})));
  if (!slot) return Response.json({ error: "Invalid booking." }, { status: 400 });

  try {
    return Response.json(toDTO(await moveBooking(id, slot)));
  } catch (error) {
    return fail(error);
  }
}
</code></pre>
<blockquote>
<p><strong>This app has no sign-in.</strong> Anyone who can reach these routes can book and move anything. Before real use, check the user in each handler, and let a person change only their own bookings.</p>
</blockquote>
<h2>6. The calendar, with optimistic updates</h2>
<p>The client component keeps the bookings in state and renders them through the flat <code>events</code> prop, each with a <code>resourceId</code>, which maps straight onto database rows. The key idea of an optimistic update: show the change at once, send it, and undo it if the server says no. A booking that is still being saved is drawn grey and has a temporary id.</p>
<p><code>app/board.tsx</code></p>
<pre><code class="language-tsx">"use client";

import { useState } from "react";
import { ResourceScheduler, ViewType } from "resource-scheduler";
import type { SchedulerEvent, SlotSelection } from "resource-scheduler";
import "resource-scheduler/dist/resource-scheduler.css";
import type { BookingDTO } from "@/lib/dto";
import { bookingRules } from "@/lib/rules";

type Room = { id: string; name: string };

const PENDING = "pending-";

const toEvent = (b: BookingDTO): SchedulerEvent =&gt; ({
  id: b.id,
  title: b.title,
  resourceId: b.roomId,
  startDate: new Date(b.startsAt),
  endDate: new Date(b.endsAt),
});

async function send(url: string, method: "POST" | "PATCH", body: unknown): Promise&lt;BookingDTO&gt; {
  const response = await fetch(url, {
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body), // a Date becomes an ISO string
  });
  if (response.status === 409) throw new Error("Someone just booked that time.");
  if (!response.ok) throw new Error("The booking could not be saved.");
  return response.json();
}

export function Board({ rooms, initial, initialDate }: { rooms: Room[]; initial: BookingDTO[]; initialDate: string }) {
  const [bookings, setBookings] = useState(() =&gt; initial.map(toEvent));
  const [start] = useState(() =&gt; new Date(initialDate));
  const [draft, setDraft] = useState&lt;SlotSelection | null&gt;(null);
  const [message, setMessage] = useState("");

  async function book(slot: SlotSelection, title: string) {
    const tempId = PENDING + crypto.randomUUID();
    setMessage("");
    setBookings((all) =&gt; [
      ...all,
      { id: tempId, title, resourceId: slot.resourceId, startDate: slot.start, endDate: slot.end, color: "#94a3b8" },
    ]);
    try {
      const saved = toEvent(await send("/api/bookings", "POST", { roomId: slot.resourceId, title, startsAt: slot.start, endsAt: slot.end }));
      setBookings((all) =&gt; all.map((b) =&gt; (b.id === tempId ? saved : b)));
    } catch (error) {
      setBookings((all) =&gt; all.filter((b) =&gt; b.id !== tempId)); // roll back
      setMessage((error as Error).message);
    }
  }

  async function reschedule(event: SchedulerEvent, roomId: string, startDate: Date, endDate: Date) {
    if (event.id.startsWith(PENDING)) return; // not saved yet, so there is nothing to patch
    const before = bookings.find((b) =&gt; b.id === event.id);
    setMessage("");
    setBookings((all) =&gt; all.map((b) =&gt; (b.id === event.id ? { ...b, resourceId: roomId, startDate, endDate } : b)));
    try {
      await send(`/api/bookings/${event.id}`, "PATCH", { roomId, startsAt: startDate, endsAt: endDate });
    } catch (error) {
      if (before) setBookings((all) =&gt; all.map((b) =&gt; (b.id === event.id ? before : b))); // roll back
      setMessage((error as Error).message);
    }
  }

  return (
    &lt;&gt;
      {draft &amp;&amp; (
        &lt;form
          onSubmit={(e) =&gt; {
            e.preventDefault();
            book(draft, String(new FormData(e.currentTarget).get("title")));
            setDraft(null);
          }}
        &gt;
          &lt;input name="title" aria-label="Title" placeholder="Meeting title" required autoFocus /&gt;
          &lt;button type="submit"&gt;Book&lt;/button&gt;
          &lt;button type="button" onClick={() =&gt; setDraft(null)}&gt;Cancel&lt;/button&gt;
        &lt;/form&gt;
      )}
      {message &amp;&amp; &lt;p role="alert"&gt;{message}&lt;/p&gt;}
      &lt;div style={{ height: 440 }}&gt;
        &lt;ResourceScheduler
          {...bookingRules}
          resources={rooms}
          events={bookings}
          initialDate={start}
          initialView={ViewType.Day}
          allowViewChange={false}
          slotDuration={30}
          dayStartHour={8}
          dayEndHour={18}
          ariaLabel="Meeting room bookings"
          onSlotSelect={setDraft}
          onEventDrop={(event, _from, to, s, e) =&gt; reschedule(event, to, s, e)}
          onEventResize={(event, roomId, s, e) =&gt; reschedule(event, roomId, s, e)}
        /&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>Why it holds together:</p>
<ul>
<li><p><code>Board</code> passes callbacks and holds state, so it has to be a client component. The scheduler's own bundle already starts with <code>"use client"</code>.</p>
</li>
<li><p>The browser refuses overlaps before the drop (the place turns red), so most conflicts never reach the server. The ones that do, from two people at once or from someone who skips the UI, come back as 409 and are rolled back with a message.</p>
</li>
<li><p><code>reschedule</code> serves both moving and resizing: both end with a room and a new start and end. Without <code>onEventResize</code> there would be no resize handles.</p>
</li>
<li><p>The rooms are plain <code>{ id, name }</code> rows. The bookings are the single flat list in <code>events</code>, so no room carries its own <code>events</code>.</p>
</li>
</ul>
<h2>7. Try it</h2>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>Open the page, drag across free time on Atlas and book it. Then ask the server directly, with the same body twice, to see the guard work without the UI (replace the room id with one from <code>npx prisma studio</code>):</p>
<pre><code class="language-bash">curl -i -X POST localhost:3000/api/bookings -H "Content-Type: application/json" \
  -d '{"roomId":"ROOM_ID","title":"Standup","startsAt":"2026-10-20T09:00:00Z","endsAt":"2026-10-20T09:30:00Z"}'
</code></pre>
<p>The first call answers <code>201</code>, the second <code>409</code>. Open the page in two windows, book the same free slot in both, and the second window shows the grey block disappear with "Someone just booked that time."</p>
<h2>Going further</h2>
<ul>
<li><p>A second line of defence in the database. Postgres can refuse overlapping rows by itself with an exclusion constraint on a <code>tsrange</code> of the two columns (<code>CREATE EXTENSION btree_gist</code>, then <code>EXCLUDE USING gist ("roomId" WITH =, tsrange("startsAt", "endsAt") WITH &amp;&amp;)</code>), added to a migration you create with <code>npx prisma migrate dev --create-only</code>. It is optional here, because the transaction already covers the normal case.</p>
</li>
<li><p>Rules beyond overlap (office hours, a seat limit, no booking in the past): put them in <code>isValidDrop</code> inside <code>bookingRules</code>. The browser and the server then both apply them. See <a href="https://resource-scheduler-docs.vercel.app/guides/conflict-control/">Conflict control</a>.</p>
</li>
<li><p>Recurring bookings: store the rule next to the booking and let <code>expandEvents</code> produce the occurrences. See <a href="https://resource-scheduler-docs.vercel.app/guides/recurring-events/">Recurring events</a>.</p>
</li>
<li><p>Rooms in several time zones: <a href="https://resource-scheduler-docs.vercel.app/guides/time-zones/">Time zones</a>.</p>
</li>
<li><p>React 19 has <code>useOptimistic</code>. The plain <code>useState</code> version above does the same job and works on React 18 too.</p>
</li>
</ul>
<h2>Where next</h2>
<ul>
<li><p>The front-end only version: <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/">React room booking calendar</a>.</p>
</li>
<li><p>The finished <a href="https://resource-scheduler-docs.vercel.app/recipes/room-booking/">room booking recipe</a>, with business hours, capacity and recurring bookings.</p>
</li>
<li><p><a href="https://resource-scheduler-docs.vercel.app/guides/nextjs-and-ssr/">Next.js and SSR</a> and <a href="https://resource-scheduler-docs.vercel.app/guides/build-your-own-ui/#on-a-server">Build your own UI</a> for the server entry point.</p>
</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-nextjs-prisma/"><em>https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-nextjs-prisma/</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[React equipment rental calendar]]></title><description><![CDATA[A rental calendar has the things you rent down the side and the days across the top. Some things are one unit (a van), some are a pool (three projectors)…]]></description><link>https://mdobaid311.hashnode.dev/react-equipment-rental-calendar</link><guid isPermaLink="true">https://mdobaid311.hashnode.dev/react-equipment-rental-calendar</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohammed Obaid]]></dc:creator><pubDate>Wed, 14 Oct 2026 15:19:46 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resource-scheduler-docs.vercel.app/launch.mp4"><img src="https://resource-scheduler-docs.vercel.app/launch-poster.png" alt="Watch the 40-second tour" /></a></p>
<p>A rental calendar has the things you rent down the side and the days across the top. Some things are one unit (a van), some are a pool (three projectors), one is in for service on a day, and the question you get all day is "when is it free for three days?". This tutorial builds that with <a href="https://github.com/mdobaid311/resource-scheduler">Resource Scheduler</a> in seven steps. It is the same component running below.</p>
<p><em>See it running live:</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-equipment-rental-calendar/"><em>interactive demo</em></a><em>.</em></p>
<p>Try three things. Drag the Delivery van booking to Tuesday on the projectors: three projectors are already out that day, so the drop turns red and nothing moves. Drag it to the PA system on Wednesday: that day is a service day, so it is refused too. Then pick "3 free days" for the PA system and press Find: the grid jumps to the first stretch that is free.</p>
<h2>1. Install</h2>
<pre><code class="language-bash">npm install resource-scheduler
</code></pre>
<p>Works with React 18 and 19. Import the stylesheet once; it is scoped, so it does not restyle the rest of your page:</p>
<pre><code class="language-tsx">import "resource-scheduler/dist/resource-scheduler.css";
</code></pre>
<h2>2. Things you rent are rows, and a pool is one row</h2>
<p>Each thing is a resource. A pool of identical units is still one row: <code>capacity</code> says how many units are in stock. A service day is a range in <code>unavailable</code>:</p>
<pre><code class="language-tsx">import type { ResourceInput } from "resource-scheduler";

const items: ResourceInput[] = [
  { id: "projector", name: "Projector (3 units)", capacity: 3 },
  { id: "camera", name: "Camera kit (2 units)", capacity: 2 },
  { id: "pa", name: "PA system", unavailable: [{ start: new Date(2026, 9, 7), end: new Date(2026, 9, 8) }] }, // serviced on Wednesday
  { id: "van", name: "Van" }, // capacity defaults to 1
];
</code></pre>
<p>In a database this is an <code>items</code> table with a <code>units</code> column, plus a table of service windows that you turn into <code>unavailable</code> ranges.</p>
<h2>3. A rental is whole days, and the end is exclusive</h2>
<p>A rental is an event on the row it takes from. Rentals are whole days, so the dates are midnights, and <code>endDate</code> is exclusive: it is the morning after the last day rented. A three-day rental from the 5th to the 7th ends on the 8th:</p>
<pre><code class="language-tsx">import type { SchedulerEvent } from "resource-scheduler";

const rentals: SchedulerEvent[] = [
  {
    id: "r1",
    title: "Acme Co",
    resourceId: "projector",         // the row it belongs to
    startDate: new Date(2026, 9, 5), // pickup day, midnight
    endDate: new Date(2026, 9, 8),   // exclusive: the last day rented is the 7th
  },
];
</code></pre>
<p>If your table stores the last day rented, add one day when you load a row. Events are a flat list that names its row with <code>resourceId</code>, so the list can come straight from one query.</p>
<h2>4. Draw the week</h2>
<pre><code class="language-tsx">import { ResourceScheduler, ViewType } from "resource-scheduler";

&lt;div style={{ height: 690 }}&gt;
  &lt;ResourceScheduler
    resources={items}
    events={rentals}
    initialView={ViewType.Week}
    availableViews={[ViewType.Week, ViewType.Month]}
    weekStartsOn={1}
    showUtilization
    blockUnavailable
    resourceColumnWidth="190px"
    dateColumnWidth="110px"
    ariaLabel="Equipment rentals"
  /&gt;
&lt;/div&gt;
</code></pre>
<p>The component fills its parent, so the parent needs a height. Each prop is one rule:</p>
<table>
<thead>
<tr>
<th>Prop</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>blockUnavailable</code></td>
<td>No rentals on a service day: the shaded days refuse a drop and a selection</td>
</tr>
<tr>
<td><code>showUtilization</code></td>
<td>A bar per item: unit-days rented against unit-days in stock (<a href="https://resource-scheduler-docs.vercel.app/guides/utilization/">utilization</a>)</td>
</tr>
<tr>
<td><code>availableViews</code></td>
<td>Week and month only; for hourly rental use the day view and <code>slotDuration</code></td>
</tr>
<tr>
<td><code>weekStartsOn={1}</code></td>
<td>The week starts on Monday</td>
</tr>
</tbody></table>
<h2>5. Never more rentals than units</h2>
<p><code>eventOverlap={false}</code> is right for a single unit, but a pool of three projectors may overlap up to three times. <code>capacity</code> only feeds the utilization numbers, so enforce the count yourself. <code>isValidDrop</code> is a final veto: it runs for every move, resize and selection, with the row and range being tried, and <code>false</code> refuses it.</p>
<p>The count of bookings only changes where a booking starts, so check those moments:</p>
<pre><code class="language-tsx">import { rangesOverlap } from "resource-scheduler";

// Whether one more booking on [start, end) fits next to `others` without more than
// `capacity` at any moment. The count only changes where a booking starts, so check those.
const fits = (others: SchedulerEvent[], start: Date, end: Date, capacity: number) =&gt; {
  const busy = others.filter((e) =&gt; rangesOverlap(start, end, e.startDate, e.endDate));
  const moments = [start, ...busy.map((e) =&gt; e.startDate).filter((t) =&gt; t &gt; start)];
  return moments.every((t) =&gt; busy.filter((e) =&gt; e.startDate &lt;= t &amp;&amp; t &lt; e.endDate).length &lt; capacity);
};
</code></pre>
<p>Then pass the rule. The rental being moved is left out of the count, so dragging it a day does not collide with itself:</p>
<pre><code class="language-tsx">&lt;ResourceScheduler
  /* ... */
  // More bookings at once than units in stock: refuse. This also covers a single unit.
  isValidDrop={(event, { resourceId, start, end }) =&gt;
    fits(
      rentals.filter((e) =&gt; e.resourceId === resourceId &amp;&amp; e.id !== event.id),
      start,
      end,
      items.find((i) =&gt; i.id === resourceId)?.capacity ?? 1
    )
  }
/&gt;
</code></pre>
<p>A plain "count the overlaps" would refuse a booking that fits between two others that never overlap each other; checking at each start does not. A row with no <code>capacity</code> counts as one unit, so the rule covers the van too.</p>
<h2>6. Take bookings with your own form</h2>
<p>In the week view a click or a drag across days selects whole days, and <code>onSlotSelect</code> hands you the row and range. Show your own form and save the rental, and handle moves with <code>onEventDrop</code>:</p>
<pre><code class="language-tsx">const [events, setEvents] = useState(rentals);
const [draft, setDraft] = useState&lt;SlotSelection | null&gt;(null);

&lt;ResourceScheduler
  /* ... */
  onSlotSelect={setDraft}
  onEventDrop={(event, _from, to, start, end) =&gt;
    setEvents((all) =&gt; all.map((e) =&gt; (e.id === event.id ? { ...e, resourceId: to, startDate: start, endDate: end } : e)))
  }
/&gt;

{draft &amp;&amp; (
  &lt;form
    onSubmit={(e) =&gt; {
      e.preventDefault();
      setEvents((all) =&gt; [
        ...all,
        { id: `r-${Date.now()}`, title: customer.trim() || "Customer", resourceId: draft.resourceId, startDate: draft.start, endDate: draft.end },
      ]);
      setDraft(null);
    }}
  &gt;
    &lt;input value={customer} onChange={(e) =&gt; setCustomer(e.target.value)} placeholder="Customer" /&gt;
    &lt;button type="submit"&gt;Rent&lt;/button&gt;
  &lt;/form&gt;
)}
</code></pre>
<p>Selections go through <code>isValidDrop</code> and <code>blockUnavailable</code> too, so a full day or a service day cannot be selected in the first place. <code>SlotSelection</code> is exported as a type. A callback is the opt-in: without <code>onEventDrop</code> a dropped rental snaps back.</p>
<h2>7. Find N free days</h2>
<p>"When can I have the PA system for three days?" is a search over the item's rentals and service time. <code>findAvailableSlots</code> does it: give it a resource that carries its events (<code>withEvents</code> attaches them), a window, a <code>duration</code> and a <code>step</code>, both in minutes. A <code>step</code> of 1440 starts every candidate on a whole day:</p>
<pre><code class="language-tsx">import { findAvailableSlots, withEvents } from "resource-scheduler";
import type { ResourceSchedulerHandle } from "resource-scheduler";

const ref = useRef&lt;ResourceSchedulerHandle&gt;(null);

const find = (days: number, itemId: string) =&gt; {
  const resource = withEvents(items, events).find((r) =&gt; r.id === itemId)!;
  const from = new Date();
  from.setHours(0, 0, 0, 0); // start of today
  const to = new Date(from);
  to.setDate(to.getDate() + 120);
  const [slot] = findAvailableSlots(resource, { from, to, duration: days * 1440, step: 1440, limit: 1 });
  if (!slot) return alert(`No ${days} free days for ${resource.name} in the next 120 days`);
  ref.current?.goTo(slot.start); // scroll the grid to it
};

&lt;ResourceScheduler ref={ref} /* ... */ /&gt;
</code></pre>
<p><code>from</code> is today's midnight so the first candidate is today, not the next whole day after this minute. The search skips rentals and service days, so a three-day stretch is only offered when the three days are all free.</p>
<blockquote>
<p><strong>It is for single-unit items.</strong> <code>findAvailableSlots</code> treats any overlap as busy. That is right for the van and the PA system, but a projector with two units out and one free would be reported as busy. For a pool, step through the days yourself with <code>fits</code>.</p>
</blockquote>
<h3>Check it on the server too</h3>
<p>The browser check is a courtesy: a second browser can book the last unit after yours has loaded, and anyone can call your API without the grid. <code>fits</code> is a plain function of dates and rows, so the server can run exactly the same one inside the transaction that inserts the rental. <code>isPlacementAllowed</code> applies <code>isValidDrop</code> and <code>eventOverlap</code> together:</p>
<pre><code class="language-ts">import { isPlacementAllowed } from "resource-scheduler";
import type { Resource, SchedulerEvent } from "resource-scheduler";

export function canRent(item: Resource, rental: SchedulerEvent) {
  return isPlacementAllowed(
    rental,
    { resourceId: item.id, start: rental.startDate, end: rental.endDate },
    [item], // the item with its current rentals, read inside your transaction
    {
      isValidDrop: (r, { start, end }) =&gt;
        fits(item.events.filter((e) =&gt; e.id !== r.id), start, end, item.capacity ?? 1),
    }
  );
}
</code></pre>
<p>Return an error (HTTP 409 is the usual choice) when it is <code>false</code>. This check covers stock only; reject a rental that touches a service day with your own query against the service table.</p>
<h2>Where next</h2>
<ul>
<li><p>The finished <a href="https://resource-scheduler-docs.vercel.app/recipes/equipment-rental/">equipment rental recipe</a> is the same code with notes on quantities, turnaround time and pickup hours.</p>
</li>
<li><p>Prices, deposits and customer details belong on the booking row in your database, not in the scheduler.</p>
</li>
<li><p>Share the bookings as a calendar file or a spreadsheet: <a href="https://resource-scheduler-docs.vercel.app/guides/export/">Export</a>.</p>
</li>
<li><p>Other builds: the <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/">room booking tutorial</a>, the <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-shift-planner/">shift planner tutorial</a>, the <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-clinic-appointment-board/">clinic appointment board tutorial</a> and the <a href="https://resource-scheduler-docs.vercel.app/tutorials/free-react-resource-timeline/">free resource timeline</a>.</p>
</li>
<li><p>Coming from another library? The <a href="https://resource-scheduler-docs.vercel.app/compare/alternatives/">comparison</a> lists what each one includes.</p>
</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-equipment-rental-calendar/"><em>https://resource-scheduler-docs.vercel.app/tutorials/react-equipment-rental-calendar/</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[React clinic appointment board]]></title><description><![CDATA[A clinic board has clinicians down the side and the day across the top. Every visit is the same length, each clinician works their own hours, nobody is…]]></description><link>https://mdobaid311.hashnode.dev/react-clinic-appointment-board</link><guid isPermaLink="true">https://mdobaid311.hashnode.dev/react-clinic-appointment-board</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohammed Obaid]]></dc:creator><pubDate>Wed, 14 Oct 2026 15:16:08 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resource-scheduler-docs.vercel.app/launch.mp4"><img src="https://resource-scheduler-docs.vercel.app/launch-poster.png" alt="Watch the 40-second tour" /></a></p>
<p>A clinic board has clinicians down the side and the day across the top. Every visit is the same length, each clinician works their own hours, nobody is booked back to back, and reception wants to ask "who is free next?". This tutorial builds that with <a href="https://github.com/mdobaid311/resource-scheduler">Resource Scheduler</a> in seven steps. It is the same component running below.</p>
<p><em>See it running live:</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-clinic-appointment-board/"><em>interactive demo</em></a><em>.</em></p>
<p>Try three things. Drag Tanaka (Dr Brown, 9:00) to Nurse Cho at 9:30: that is right after Singh, so the gap is missing and the drop turns red. Drop it at 9:45 and it moves. Then pick "Anyone" and press "Book it" to book the next free visit.</p>
<h2>1. Install</h2>
<pre><code class="language-bash">npm install resource-scheduler
</code></pre>
<p>Works with React 18 and 19. Import the stylesheet once; it is scoped, so it does not restyle the rest of your page:</p>
<pre><code class="language-tsx">import "resource-scheduler/dist/resource-scheduler.css";
</code></pre>
<h2>2. Clinicians are rows, with their own hours</h2>
<p>Each clinician is a resource. Their working days and hours are <code>businessHours</code>, and one-off gaps such as lunch or training are <code>unavailable</code>. Both are shaded in the grid:</p>
<pre><code class="language-tsx">import type { ResourceInput } from "resource-scheduler";

const everyDay = [0, 1, 2, 3, 4, 5, 6]; // use [1, 2, 3, 4, 5] for a Monday to Friday clinic

const clinicians: ResourceInput[] = [
  {
    id: "ahmed",
    name: "Dr Ahmed",
    businessHours: { daysOfWeek: everyDay, startHour: 8, endHour: 17 },
    unavailable: [{ start: new Date(2026, 9, 7, 12), end: new Date(2026, 9, 7, 13) }], // lunch
  },
  { id: "brown", name: "Dr Brown", businessHours: { daysOfWeek: everyDay, startHour: 8, endHour: 13 } }, // mornings only
  {
    id: "cho",
    name: "Nurse Cho",
    businessHours: { daysOfWeek: everyDay, startHour: 9, endHour: 17 },
    unavailable: [{ start: new Date(2026, 9, 7, 15), end: new Date(2026, 9, 7, 16) }], // training
  },
];
</code></pre>
<p><code>daysOfWeek</code> counts Sunday as 0. In a database this is a <code>clinicians</code> table with hours, plus a table of blocked time that you turn into <code>unavailable</code> ranges for the day on screen.</p>
<h2>3. A visit is an event with a fixed length</h2>
<p>Three numbers describe the clinic. Keep them in one place, because every step below uses them:</p>
<pre><code class="language-tsx">const SLOT = 15; // the grid: a booking starts on a quarter hour
const VISIT = 30; // every appointment is the same length
const BUFFER = 15; // minutes to write up and reset between patients
const MINUTE = 60_000;
</code></pre>
<p>A booked visit is a plain event. It names its row with <code>resourceId</code>:</p>
<pre><code class="language-tsx">import type { SchedulerEvent } from "resource-scheduler";

const visits: SchedulerEvent[] = [
  { id: "v1", title: "Patel", resourceId: "ahmed", startDate: new Date(2026, 9, 7, 8, 30), endDate: new Date(2026, 9, 7, 9) },
  { id: "v2", title: "Okafor", resourceId: "ahmed", startDate: new Date(2026, 9, 7, 9, 30), endDate: new Date(2026, 9, 7, 10) },
  { id: "v4", title: "Reyes", resourceId: "brown", startDate: new Date(2026, 9, 7, 8), endDate: new Date(2026, 9, 7, 8, 30) },
];
</code></pre>
<p>The list is flat, so it can come straight from one query on your appointments table.</p>
<h2>4. Draw the day</h2>
<pre><code class="language-tsx">import { ResourceScheduler, ViewType } from "resource-scheduler";

&lt;div style={{ height: 330 }}&gt;
  &lt;ResourceScheduler
    resources={clinicians}
    events={visits}
    initialView={ViewType.Day}
    allowViewChange={false}
    slotDuration={SLOT}
    dayStartHour={8}
    dayEndHour={17}
    blockUnavailable
    ariaLabel="Clinic appointments"
  /&gt;
&lt;/div&gt;
</code></pre>
<p>The component fills its parent, so the parent needs a height. Each prop is one rule:</p>
<table>
<thead>
<tr>
<th>Prop</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>slotDuration={SLOT}</code></td>
<td>The grid is quarter hours, so a click or a drop lands on one</td>
</tr>
<tr>
<td><code>dayStartHour</code> / <code>dayEndHour</code></td>
<td>The hours shown, 8:00 to 17:00</td>
</tr>
<tr>
<td><code>blockUnavailable</code></td>
<td>Nothing can be booked outside a clinician's hours or in their lunch</td>
</tr>
</tbody></table>
<p>Because <code>onEventResize</code> is not passed, the resize handles stay off and a visit keeps its length. See <a href="https://resource-scheduler-docs.vercel.app/guides/views-and-slots/">views and slots</a>.</p>
<h2>5. One rule: no double booking, with a gap</h2>
<p><code>eventOverlap={false}</code> would stop two visits overlapping, but it cannot ask for a gap between them. Give each booked visit some padding instead, and ask whether the new visit fits in what is left:</p>
<pre><code class="language-tsx">import { findAvailableSlots, withEvents } from "resource-scheduler";

// Each booked visit holds its clinician for BUFFER minutes either side. A free slot in this
// padded copy is a visit plus its gaps, and the padding never shows on screen.
const padded = (all: SchedulerEvent[], ignoreId?: string): SchedulerEvent[] =&gt;
  all
    .filter((e) =&gt; e.id !== ignoreId)
    .map((e) =&gt; ({
      ...e,
      startDate: new Date(e.startDate.getTime() - BUFFER * MINUTE),
      endDate: new Date(e.endDate.getTime() + BUFFER * MINUTE),
    }));

// The one rule: hours, lunch, no double booking and the gap, all answered by findAvailableSlots.
// The window is exactly the range asked about, so it returns that slot or nothing.
const canBook = (all: SchedulerEvent[], clinicianId: string, start: Date, end: Date, ignoreId?: string) =&gt; {
  const clinician = withEvents(clinicians, padded(all, ignoreId)).find((c) =&gt; c.id === clinicianId);
  return (
    !!clinician &amp;&amp;
    findAvailableSlots(clinician, { from: start, to: end, duration: (end.getTime() - start.getTime()) / MINUTE, step: SLOT }).length &gt; 0
  );
};
</code></pre>
<p><code>withEvents</code> puts the flat list onto the clinicians. <code>findAvailableSlots</code> treats a slot as free when it overlaps no event, misses <code>unavailable</code> time and sits inside the clinician's <code>businessHours</code>, so one call covers all four rules. Times that only touch do not overlap, so a visit that starts exactly 15 minutes after the last one ends is allowed. The visit being moved is left out with <code>ignoreId</code>, so it does not block its own new place.</p>
<p>Pass it as <code>isValidDrop</code>, and book through your own form:</p>
<pre><code class="language-tsx">&lt;ResourceScheduler
  /* ... */
  onSlotSelect={setDraft} // a click on a free slot: open your form
  onEventDrop={(visit, _from, to, start, end) =&gt;
    setEvents((all) =&gt; all.map((e) =&gt; (e.id === visit.id ? { ...e, resourceId: to, startDate: start, endDate: end } : e)))
  }
  isValidDrop={(visit, { resourceId, start, end }) =&gt; canBook(events, resourceId, start, end, visit.id)}
/&gt;
</code></pre>
<p><code>isValidDrop</code> runs for every move and every new selection, and the refused place turns red before the drop. A callback is the opt-in: without <code>onEventDrop</code> a dropped visit snaps back.</p>
<blockquote>
<p><strong>A click selects one slot, a visit is two.</strong> A click selects a single 15 minute slot, so the grid only checks that slot. The form then books a full 30 minute visit and runs <code>canBook</code> on the whole range before it adds it, saying so when it does not fit. The full code is in the <a href="https://github.com/mdobaid311/resource-scheduler/blob/main/docs/src/components/demos/ClinicBoardDemo.tsx">demo source</a>.</p>
</blockquote>
<h2>6. The next free slot</h2>
<p>Reception rarely picks a time first. They ask who is free next. <code>suggestResources</code> answers that: give it a window and a length, and it returns the earliest free slot of each clinician, best first. Hand it the same padded events, so the gap is respected:</p>
<pre><code class="language-tsx">import { suggestResources } from "resource-scheduler";

const [next] = suggestResources(
  withEvents(clinicians, padded(events)),
  { from: new Date(), to: new Date(2026, 9, 7, 17), duration: VISIT, step: SLOT },
  { resourceIds: who === "any" ? undefined : [who], limit: 1 }
);
// next = { resourceId, start, end, ... }, or undefined when nobody is free before 17:00
</code></pre>
<p>Pass <code>resourceIds</code> to ask about one clinician, or leave it out to ask about all of them. For a single clinician, <code>findAvailableSlots(clinician, { from, to, duration: VISIT, step: SLOT, limit: 3 })</code> lists the next three free slots to offer the patient. See <a href="https://resource-scheduler-docs.vercel.app/guides/availability/">Availability</a>.</p>
<h2>7. Check it on the server too</h2>
<p>The browser check is a courtesy: anyone can call your API without the board. The rule is a plain function of dates and events, so the server runs it again before it saves. <code>isPlacementAllowed</code> applies <code>isValidDrop</code> for you:</p>
<pre><code class="language-ts">import { findAvailableSlots, isPlacementAllowed } from "resource-scheduler";
import type { Resource, SchedulerEvent } from "resource-scheduler";

export function canBookVisit(clinician: Resource, visit: SchedulerEvent) {
  return isPlacementAllowed(
    visit,
    { resourceId: clinician.id, start: visit.startDate, end: visit.endDate },
    [clinician], // the clinician with their hours and current visits, read inside your transaction
    {
      isValidDrop: (v, { start, end }) =&gt;
        findAvailableSlots(
          { ...clinician, events: padded(clinician.events, v.id) },
          { from: start, to: end, duration: (end.getTime() - start.getTime()) / MINUTE, step: SLOT }
        ).length &gt; 0,
    }
  );
}
</code></pre>
<p>Return an error (HTTP 409 is the usual choice) when it is <code>false</code>, and run the check and the insert in one transaction. See <a href="https://resource-scheduler-docs.vercel.app/guides/conflict-control/">Conflict control</a>.</p>
<h2>Where next</h2>
<ul>
<li><p>Add <a href="https://resource-scheduler-docs.vercel.app/guides/recurring-events/">recurring events</a> for a standing weekly slot, such as a patient seen every Tuesday.</p>
</li>
<li><p>A clinic in another time zone from the browser: <a href="https://resource-scheduler-docs.vercel.app/guides/time-zones/">Time zones</a>.</p>
</li>
<li><p>Share the day as a calendar file or a spreadsheet: <a href="https://resource-scheduler-docs.vercel.app/guides/export/">Export</a>.</p>
</li>
<li><p>Other builds: the <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/">room booking tutorial</a>, the <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-shift-planner/">shift planner</a> and the <a href="https://resource-scheduler-docs.vercel.app/tutorials/free-react-resource-timeline/">free resource timeline</a>.</p>
</li>
<li><p>Coming from another library? The <a href="https://resource-scheduler-docs.vercel.app/compare/alternatives/">comparison</a> lists what each one includes.</p>
</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-clinic-appointment-board/"><em>https://resource-scheduler-docs.vercel.app/tutorials/react-clinic-appointment-board/</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[React shift planner (employee roster)]]></title><description><![CDATA[A shift planner has people down the side and days across the top. The standing rota repeats every week, people have contracted days and leave, someone…]]></description><link>https://mdobaid311.hashnode.dev/react-shift-planner-employee-roster</link><guid isPermaLink="true">https://mdobaid311.hashnode.dev/react-shift-planner-employee-roster</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohammed Obaid]]></dc:creator><pubDate>Wed, 14 Oct 2026 15:12:31 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resource-scheduler-docs.vercel.app/launch.mp4"><img src="https://resource-scheduler-docs.vercel.app/launch-poster.png" alt="Watch the 40-second tour" /></a></p>
<p>A shift planner has people down the side and days across the top. The standing rota repeats every week, people have contracted days and leave, someone covers a shift now and then, and there is a house rule about hours. This tutorial builds that with <a href="https://github.com/mdobaid311/resource-scheduler">Resource Scheduler</a> in six steps. It is the same component running below.</p>
<p><em>See it running live:</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-shift-planner/"><em>interactive demo</em></a><em>.</em></p>
<p>Try three things. Drag Cy's Friday shift to Ann: she already works 32 hours, so the drop turns red and nothing moves. Drag Cy's Monday shift to Dee instead and it is covered for that day only. Click an empty day in Dee's row to add a shift with your own form.</p>
<h2>1. Install</h2>
<pre><code class="language-bash">npm install resource-scheduler
</code></pre>
<p>Works with React 18 and 19. Import the stylesheet once; it is scoped, so it does not restyle the rest of your page:</p>
<pre><code class="language-tsx">import "resource-scheduler/dist/resource-scheduler.css";
</code></pre>
<h2>2. People are rows, with a contract and leave</h2>
<p>Each person is a resource. Their contracted days and hours are <code>businessHours</code>, and leave is <code>unavailable</code>. Both are shaded in the grid:</p>
<pre><code class="language-tsx">import type { ResourceInput } from "resource-scheduler";

const staff: ResourceInput[] = [
  { id: "ann", name: "Ann Lee", businessHours: { daysOfWeek: [1, 2, 3, 4, 5], startHour: 7, endHour: 15 } },
  {
    id: "cy",
    name: "Cy Ortiz",
    businessHours: { daysOfWeek: [1, 2, 3, 4, 5], startHour: 7, endHour: 15 },
    unavailable: [{ start: new Date(2026, 9, 7), end: new Date(2026, 9, 9) }], // leave
  },
  { id: "bo", name: "Bo Kim", businessHours: { daysOfWeek: [3, 4, 5, 6], startHour: 11, endHour: 19 } },
];
</code></pre>
<p><code>daysOfWeek</code> counts Sunday as 0, so <code>[1, 2, 3, 4, 5]</code> is Monday to Friday. In a database this is a <code>people</code> table with contract and leave columns.</p>
<h2>3. The standing rota is one row per series</h2>
<p>You do not store a shift for every week. A shift is one event with a weekly <code>recurrence</code>, and the component expands it into the weeks on screen. Give it the first occurrence and the weekdays it repeats on:</p>
<pre><code class="language-tsx">import type { SchedulerEvent } from "resource-scheduler";

const shifts: SchedulerEvent[] = [
  {
    id: "ann-early",
    title: "Early",
    resourceId: "ann",                              // the row it belongs to
    startDate: new Date(2026, 8, 21, 7),            // the first Monday, 7:00
    endDate: new Date(2026, 8, 21, 15),             // 15:00
    recurrence: { freq: "weekly", byWeekday: [1, 2, 3, 4] },   // Monday to Thursday
  },
];
</code></pre>
<p>Events are a flat list that names its row with <code>resourceId</code>, so the list can come straight from one query. See <a href="https://resource-scheduler-docs.vercel.app/guides/recurring-events/">recurring events</a> for ending a series and skipping days.</p>
<h2>4. Draw the week and refuse bad shifts</h2>
<pre><code class="language-tsx">import { ResourceScheduler, ViewType } from "resource-scheduler";

&lt;div style={{ height: 530 }}&gt;
  &lt;ResourceScheduler
    resources={staff}
    events={shifts}
    initialView={ViewType.Week}
    allowViewChange={false}
    weekStartsOn={1}
    showUtilization
    blockUnavailable
    eventOverlap={false}
    ariaLabel="Shift roster"
  /&gt;
&lt;/div&gt;
</code></pre>
<p>The component fills its parent, so the parent needs a height. Each prop is one rule:</p>
<table>
<thead>
<tr>
<th>Prop</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>showUtilization</code></td>
<td>A bar per person: rostered hours against contracted hours (<a href="https://resource-scheduler-docs.vercel.app/guides/utilization/">utilization</a>)</td>
</tr>
<tr>
<td><code>blockUnavailable</code></td>
<td>No shifts on days off or leave</td>
</tr>
<tr>
<td><code>eventOverlap={false}</code></td>
<td>Nobody works two shifts at once</td>
</tr>
<tr>
<td><code>weekStartsOn={1}</code></td>
<td>The week starts on Monday</td>
</tr>
</tbody></table>
<h2>5. Cover a shift for one day</h2>
<p>Dragging a shift hands your handler one occurrence of the series, with an id such as <code>ann-early::2026-10-07</code> and a <code>seriesId</code> of <code>ann-early</code>. Covering that one day means skipping it in the series and adding a one-off shift for the new person:</p>
<pre><code class="language-tsx">onEventDrop={(shift, _fromId, toId, start, end) =&gt;
  setEvents((all) =&gt; {
    if (!shift.seriesId) {
      return all.map((e) =&gt; (e.id === shift.id ? { ...e, resourceId: toId, startDate: start, endDate: end } : e));
    }
    const skipped = all.map((e) =&gt;
      e.id === shift.seriesId
        ? { ...e, recurrence: { ...e.recurrence!, exceptions: [...(e.recurrence!.exceptions ?? []), shift.startDate] } }
        : e
    );
    return [...skipped, { ...shift, id: crypto.randomUUID(), seriesId: undefined, resourceId: toId, startDate: start, endDate: end }];
  })
}
</code></pre>
<p>A callback is the opt-in: without <code>onEventDrop</code> a dropped shift snaps back. To change the series for good instead, edit the original event.</p>
<h2>6. Add a rule: at most 32 hours a week</h2>
<p><code>isValidDrop</code> is a final veto for anything the other props cannot say. It gets the event and where it would land, and returns <code>false</code> to refuse. A weekly cap needs the hours the person already has that week, so count the shifts you hold. <code>expandEvents</code> turns a series into real shifts for a date range, which is the expansion the grid uses:</p>
<pre><code class="language-tsx">import { expandEvents, rangesOverlap } from "resource-scheduler";

const MAX_WEEK_HOURS = 32; // four eight-hour shifts

const hoursBetween = (start: Date, end: Date) =&gt; (end.getTime() - start.getTime()) / 36e5;

// Hours already rostered for one person in the Monday to Sunday week containing `day`.
const rosteredHours = (all: SchedulerEvent[], personId: string, day: Date, ignoreId?: string) =&gt; {
  const from = new Date(day);
  from.setHours(0, 0, 0, 0);
  from.setDate(from.getDate() - ((from.getDay() + 6) % 7));
  const to = new Date(from);
  to.setDate(to.getDate() + 7);
  return expandEvents(all.filter((e) =&gt; e.resourceId === personId), from, to)
    .filter((e) =&gt; e.id !== ignoreId &amp;&amp; rangesOverlap(e.startDate, e.endDate, from, to))
    .reduce((sum, e) =&gt; sum + hoursBetween(e.startDate, e.endDate), 0);
};
</code></pre>
<p>Then pass the rule. The shift being moved is left out of the count (<code>ignoreId</code>), so moving someone's own shift to another day in the same week is not charged twice:</p>
<pre><code class="language-tsx">&lt;ResourceScheduler
  /* ... */
  isValidDrop={(shift, { resourceId, start, end }) =&gt;
    rosteredHours(events, resourceId, start, shift.id) + hoursBetween(start, end) &lt;= MAX_WEEK_HOURS
  }
/&gt;
</code></pre>
<p>The refused place turns red before the drop. Ann has four eight-hour shifts, so any shift dropped on her week is refused; Dee has none, so she can take cover.</p>
<blockquote>
<p><strong>New shifts come from your form.</strong> In the week view a click selects a whole day, so the placement of a new shift is the day, not the eight hours your form will add. The demo therefore applies <code>isValidDrop</code> to moves of existing shifts, and checks a new shift in the form with the same <code>rosteredHours</code> before it adds it. The full code is in the <a href="https://github.com/mdobaid311/resource-scheduler/blob/main/docs/src/components/demos/ShiftRosterDemo.tsx">demo source</a>.</p>
</blockquote>
<h3>Check it on the server too</h3>
<p>The browser check is a courtesy: anyone can call your API without the grid. The rule is a plain function of dates and ids, so the server can run exactly the same one before it saves. <code>isPlacementAllowed</code> applies <code>eventOverlap</code> and <code>isValidDrop</code> together:</p>
<pre><code class="language-ts">import { isPlacementAllowed } from "resource-scheduler";
import type { Resource, SchedulerEvent } from "resource-scheduler";

export function canRoster(person: Resource, shift: SchedulerEvent) {
  return isPlacementAllowed(
    shift,
    { resourceId: person.id, start: shift.startDate, end: shift.endDate },
    [person], // the person with their current shifts, read inside your transaction
    {
      eventOverlap: false,
      isValidDrop: (s, { resourceId, start, end }) =&gt;
        rosteredHours(person.events, resourceId, start, s.id) + hoursBetween(start, end) &lt;= MAX_WEEK_HOURS,
    }
  );
}
</code></pre>
<p>Return an error (HTTP 409 is the usual choice) when it is <code>false</code>, and run the check and the insert in one transaction. For rest rules, such as at least 11 hours between shifts, write the same kind of function against the neighbouring shifts. See <a href="https://resource-scheduler-docs.vercel.app/guides/conflict-control/">Conflict control</a>.</p>
<h2>Where next</h2>
<ul>
<li><p>The finished <a href="https://resource-scheduler-docs.vercel.app/recipes/shift-roster/">shift roster recipe</a> is the same code with notes on night shifts and time zones.</p>
</li>
<li><p>A site in another time zone: <a href="https://resource-scheduler-docs.vercel.app/guides/time-zones/">Time zones</a>.</p>
</li>
<li><p>Share the rota as a calendar file or a spreadsheet: <a href="https://resource-scheduler-docs.vercel.app/guides/export/">Export</a>.</p>
</li>
<li><p>Other builds: the <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/">room booking tutorial</a> and the <a href="https://resource-scheduler-docs.vercel.app/tutorials/free-react-resource-timeline/">free resource timeline</a>.</p>
</li>
<li><p>Coming from another library? The <a href="https://resource-scheduler-docs.vercel.app/compare/alternatives/">comparison</a> lists what each one includes.</p>
</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-shift-planner/"><em>https://resource-scheduler-docs.vercel.app/tutorials/react-shift-planner/</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[React room booking calendar (no double booking)]]></title><description><![CDATA[A room booking calendar has rooms down the side and half hours across the top. People pick free time, fill in a form, and two bookings can never share a…]]></description><link>https://mdobaid311.hashnode.dev/react-room-booking-calendar-no-double-booking</link><guid isPermaLink="true">https://mdobaid311.hashnode.dev/react-room-booking-calendar-no-double-booking</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohammed Obaid]]></dc:creator><pubDate>Wed, 14 Oct 2026 15:08:52 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resource-scheduler-docs.vercel.app/launch.mp4"><img src="https://resource-scheduler-docs.vercel.app/launch-poster.png" alt="Watch the 40-second tour" /></a></p>
<p>A room booking calendar has rooms down the side and half hours across the top. People pick free time, fill in a form, and two bookings can never share a room. This tutorial builds that with <a href="https://github.com/mdobaid311/resource-scheduler">Resource Scheduler</a> in five steps. It is the same component running below.</p>
<p><em>See it running live:</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/"><em>interactive demo</em></a><em>.</em></p>
<p>Drag across free time on a room to open the form. Try the booked blocks: you cannot select them, drop another booking on them, or resize into them.</p>
<h2>1. Install</h2>
<pre><code class="language-bash">npm install resource-scheduler
</code></pre>
<p>Works with React 18 and 19. Import the stylesheet once; it is scoped, so it does not restyle the rest of your page:</p>
<pre><code class="language-tsx">import "resource-scheduler/dist/resource-scheduler.css";
</code></pre>
<h2>2. Rooms are rows, half hours are columns</h2>
<p>Each room is a <code>Resource</code> that carries its bookings in <code>events</code>. You own the state; the component only reports what the user did. The day view with 30 minute slots and office hours looks like this:</p>
<pre><code class="language-tsx">import { useState } from "react";
import { ResourceScheduler, ViewType } from "resource-scheduler";
import type { Resource } from "resource-scheduler";

const initialRooms: Resource[] = [
  { id: "atlas", name: "Atlas (8 seats)", events: [] },
  { id: "borealis", name: "Borealis (4 seats)", events: [] },
];

export function Rooms() {
  const [rooms, setRooms] = useState&lt;Resource[]&gt;(initialRooms);

  return (
    &lt;div style={{ height: 420 }}&gt;
      &lt;ResourceScheduler
        resources={rooms}
        initialView={ViewType.Day}
        allowViewChange={false}
        slotDuration={30}
        dayStartHour={8}
        dayEndHour={18}
        ariaLabel="Meeting room bookings"
      /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The component fills its parent, so the parent needs a height. <code>endDate</code> is exclusive: a booking from 9:00 to 10:00 does not collide with one that starts at 10:00.</p>
<h2>3. Use your own booking form</h2>
<p>Pass <code>onSlotSelect</code> and the built-in create dialog is replaced by your callback. It receives <code>{ resourceId, start, end }</code>, the free range the user dragged across. Keep it in state and render whatever form you like:</p>
<pre><code class="language-tsx">import type { SlotSelection } from "resource-scheduler";

const [draft, setDraft] = useState&lt;SlotSelection | null&gt;(null);

// ...
&lt;ResourceScheduler /* ... */ onSlotSelect={setDraft} /&gt;

{draft &amp;&amp; (
  &lt;BookingForm
    slot={draft}
    onCancel={() =&gt; setDraft(null)}
    onBook={(title) =&gt; {
      setRooms((prev) =&gt;
        prev.map((r) =&gt;
          r.id === draft.resourceId
            ? { ...r, events: [...r.events, { id: crypto.randomUUID(), title, startDate: draft.start, endDate: draft.end }] }
            : r
        )
      );
      setDraft(null);
    }}
  /&gt;
)}
</code></pre>
<p><code>BookingForm</code> is yours: a title field, an attendee picker, a recurrence option. See <a href="https://resource-scheduler-docs.vercel.app/guides/creating-events/">Creating events</a> for the other ways to create.</p>
<h2>4. Refuse double booking</h2>
<p>One prop covers selecting, moving and resizing. The refused place turns red before the drop:</p>
<pre><code class="language-tsx">&lt;ResourceScheduler /* ... */ eventOverlap={false} /&gt;
</code></pre>
<p>To also let people move or stretch an existing booking, add the two callbacks. A callback is the opt-in: without <code>onEventDrop</code> a dropped booking snaps back, and without <code>onEventResize</code> there are no resize handles.</p>
<pre><code class="language-tsx">&lt;ResourceScheduler
  /* ... */
  eventOverlap={false}
  onEventDrop={(event, fromId, toId, start, end) =&gt; moveBooking(event, fromId, toId, start, end)}
  onEventResize={(event, resourceId, start, end) =&gt; resizeBooking(event, resourceId, start, end)}
/&gt;
</code></pre>
<p>For rules beyond overlap (opening hours, a capacity limit, a holiday), pass <code>isValidDrop(event, { resourceId, start, end })</code>. See <a href="https://resource-scheduler-docs.vercel.app/guides/conflict-control/">Conflict control</a>.</p>
<h2>5. Check again on the server</h2>
<blockquote>
<p><strong>The browser check is a courtesy.</strong> Two people can pick the same free slot at the same moment, and anyone can call your API without the UI. The save endpoint must decide.</p>
</blockquote>
<p>The overlap rule is a plain function, exported as <code>isPlacementAllowed</code>, so the server can run exactly the rule the browser used. Load the room's current bookings and call it before you write:</p>
<pre><code class="language-ts">import { isPlacementAllowed } from "resource-scheduler";
import type { Resource, SchedulerEvent } from "resource-scheduler";

export function canBook(room: Resource, booking: SchedulerEvent) {
  return isPlacementAllowed(
    booking,
    { resourceId: room.id, start: booking.startDate, end: booking.endDate },
    [room], // the room with its current bookings, read inside your transaction
    { eventOverlap: false }
  );
}
</code></pre>
<p>Return an error (HTTP 409 is the usual choice) when it is <code>false</code>, and let the client show "someone just booked that". Run the check and the insert in one database transaction, or add a database constraint, so two requests cannot both pass the check. The function takes only dates and ids, so it runs in any JavaScript runtime. See <a href="https://resource-scheduler-docs.vercel.app/guides/conflict-control/">Conflict control</a>.</p>
<h2>Where next</h2>
<ul>
<li><p>The finished <a href="https://resource-scheduler-docs.vercel.app/recipes/room-booking/">room booking recipe</a> is the same code with notes on business hours, capacity and recurring bookings.</p>
</li>
<li><p><a href="https://resource-scheduler-docs.vercel.app/guides/keyboard-and-accessibility/">Keyboard and accessibility</a> and <a href="https://resource-scheduler-docs.vercel.app/guides/theming/">theming</a> are built in.</p>
</li>
<li><p>Bookings across time zones: see <a href="https://resource-scheduler-docs.vercel.app/guides/time-zones/">Time zones</a>.</p>
</li>
<li><p>Coming from another library? The <a href="https://resource-scheduler-docs.vercel.app/compare/alternatives/">comparison</a> lists what each one includes.</p>
</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/"><em>https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-calendar/</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[A free React resource timeline (a FullCalendar Premium alternative)]]></title><description><![CDATA[A resource timeline has people, rooms or machines down the side and time across the top. In several popular calendar libraries it is a paid tier (the…]]></description><link>https://mdobaid311.hashnode.dev/a-free-react-resource-timeline-a-fullcalendar-premium-alternative</link><guid isPermaLink="true">https://mdobaid311.hashnode.dev/a-free-react-resource-timeline-a-fullcalendar-premium-alternative</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohammed Obaid]]></dc:creator><pubDate>Wed, 14 Oct 2026 14:58:34 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resource-scheduler-docs.vercel.app/launch.mp4"><img src="https://resource-scheduler-docs.vercel.app/launch-poster.png" alt="Watch the 40-second tour" /></a></p>
<p>A <strong>resource timeline</strong> has people, rooms or machines down the side and time across the top. In several popular calendar libraries it is a paid tier (the <a href="https://resource-scheduler-docs.vercel.app/compare/alternatives/">comparison</a> lists what each one includes and costs). This post builds one with <a href="https://github.com/mdobaid311/resource-scheduler">Resource Scheduler</a>, which is MIT and has no paid tier, in four steps. The <a href="https://resource-scheduler-docs.vercel.app/tutorials/free-react-resource-timeline/">original tutorial</a> has the same component running live.</p>
<p>Drag across empty days to create a booking, drag one to another row or day, grab an edge to resize it. Drop one on top of another: it turns red and is refused.</p>
<h2>1. Install</h2>
<pre><code class="language-bash">npm install resource-scheduler
</code></pre>
<p>Works with React 18 and 19. Import the stylesheet once, anywhere in your app (it is scoped, so it does not restyle the rest of your page):</p>
<pre><code class="language-tsx">import "resource-scheduler/dist/resource-scheduler.css";
</code></pre>
<h2>2. Keep your bookings as one flat list</h2>
<p>Most apps already have a table of bookings. Pass it as <code>events</code> and name the row on each one with <code>resourceId</code>; <code>resources</code> then only needs an <code>id</code> and a <code>name</code>. The component keeps no copy of your data: you hold the state and it tells you what the user did.</p>
<pre><code class="language-tsx">import { useState } from "react";
import { ResourceScheduler, ViewType } from "resource-scheduler";
import type { Resource, SchedulerEvent } from "resource-scheduler";

type Booking = SchedulerEvent &amp; { resourceId: string };

const people: Resource[] = [
  { id: "ann", name: "Ann Lee", events: [] },
  { id: "bob", name: "Bob Ruiz", events: [] },
];

export function Timeline() {
  const [events, setEvents] = useState&lt;Booking[]&gt;(initialBookings);

  return (
    &lt;div style={{ height: 480 }}&gt;
      &lt;ResourceScheduler
        resources={people}
        events={events}
        initialView={ViewType.Week}
      /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The component fills its parent, so the parent needs a height. An event is <code>{ id, title, startDate, endDate, color? }</code>; <code>endDate</code> is exclusive, so a meeting from 9:00 to 10:00 does not overlap one that starts at 10:00.</p>
<h2>3. Turn on drag, resize and create</h2>
<p>Each gesture is a callback, and a callback is also the opt-in: without <code>onEventDrop</code> a dropped event snaps back, and without <code>onEventResize</code> there are no resize handles. Each one is a plain state update.</p>
<pre><code class="language-tsx">&lt;ResourceScheduler
  resources={people}
  events={events}
  initialView={ViewType.Week}
  onEventCreate={(event, resourceId) =&gt;
    setEvents((prev) =&gt; [...prev, { ...event, id: crypto.randomUUID(), resourceId }])
  }
  onEventDrop={(event, _fromId, toId, start, end) =&gt;
    setEvents((prev) =&gt;
      prev.map((e) =&gt; (e.id === event.id ? { ...e, resourceId: toId, startDate: start, endDate: end } : e))
    )
  }
  onEventResize={(event, _resourceId, start, end) =&gt;
    setEvents((prev) =&gt; prev.map((e) =&gt; (e.id === event.id ? { ...e, startDate: start, endDate: end } : e)))
  }
/&gt;
</code></pre>
<p>To open your own form instead of creating an event straight away, pass <code>onSlotSelect</code>; see <a href="https://resource-scheduler-docs.vercel.app/guides/creating-events/">Creating events</a>.</p>
<h2>4. Refuse conflicts</h2>
<p>One prop stops double booking for drag, resize and create alike, and shows the refused place in red before the drop:</p>
<pre><code class="language-tsx">&lt;ResourceScheduler /* ... */ eventOverlap={false} /&gt;
</code></pre>
<p>For anything else (capacity, opening hours, a holiday), pass <code>isValidDrop(event, { resourceId, start, end })</code>. The same check is exported as <code>isPlacementAllowed</code>, so you can run it on your server before saving. See <a href="https://resource-scheduler-docs.vercel.app/guides/conflict-control/">Conflict control</a>.</p>
<h2>Coming from FullCalendar</h2>
<p>The model is close, and so are the callbacks:</p>
<table>
<thead>
<tr>
<th>FullCalendar</th>
<th>Resource Scheduler</th>
</tr>
</thead>
<tbody><tr>
<td><code>eventDrop</code></td>
<td><code>onEventDrop</code></td>
</tr>
<tr>
<td><code>eventResize</code></td>
<td><code>onEventResize</code></td>
</tr>
<tr>
<td><code>select</code></td>
<td><code>onSlotSelect</code></td>
</tr>
<tr>
<td><code>eventClick</code></td>
<td><code>onEventClick</code></td>
</tr>
<tr>
<td><code>eventOverlap</code></td>
<td><code>eventOverlap</code> (also accepts a function)</td>
</tr>
<tr>
<td><code>eventAllow</code> / <code>selectAllow</code></td>
<td><code>isValidDrop</code></td>
</tr>
</tbody></table>
<p><strong>Know the gaps.</strong> This is a younger library. RRULE strings, calendar sync with Google or Outlook, and Gantt dependencies are not built in. Read <a href="https://resource-scheduler-docs.vercel.app/compare/alternatives/">Alternatives</a> before you switch.</p>
<h2>Where next</h2>
<ul>
<li><p>Finished starting points: <a href="https://resource-scheduler-docs.vercel.app/recipes/room-booking/">room booking</a>, <a href="https://resource-scheduler-docs.vercel.app/recipes/shift-roster/">shift roster</a>, <a href="https://resource-scheduler-docs.vercel.app/recipes/equipment-rental/">equipment rental</a>.</p>
</li>
<li><p>A full-stack version with Next.js and Prisma: <a href="https://resource-scheduler-docs.vercel.app/tutorials/react-room-booking-nextjs-prisma/">Room booking app with Next.js and Prisma</a>.</p>
</li>
<li><p>Source, issues and the shadcn install: <a href="https://github.com/mdobaid311/resource-scheduler">github.com/mdobaid311/resource-scheduler</a>.</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>