Skip to content

stable version of library with reliable reservation through webview#5

Open
TimiParkinson wants to merge 6 commits into
mainfrom
feat/library
Open

stable version of library with reliable reservation through webview#5
TimiParkinson wants to merge 6 commits into
mainfrom
feat/library

Conversation

@TimiParkinson
Copy link
Copy Markdown
Collaborator

@TimiParkinson TimiParkinson commented May 12, 2026

  • fix(build): expo-route and other dependencies broken in package-lock fixed | feat(library): properly added initial version
  • removed Archive.zip
  • refactor(library): library rehaul to 3 step process [filter/search -> results/rooms -> book/reserve] | api calls to cpp libcal currently dysfunctional
  • fix(library): migrated api and data logic to backend | fixed button hidding issue | reserve api currently dysfunctional
  • fix(library): webview post logic working! | library mvp is a go
  • fix(library): injectJS into webview did not check all pages for submission; patched

Summary by CodeRabbit

  • New Features
    • Added "Library" tab to browse and filter study room availability by date, time, floor, power, and ADA accessibility.
    • Integrated automated booking flow for available rooms with manual fallback options.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

This PR adds a complete Library Rooms feature enabling users to search, filter, and book available study rooms through LibCal integration. The backend exposes availability and room metadata endpoints with robust LibCal scraping and caching; the frontend presents a filtered results view with room cards and an automated WebView-based booking flow supporting CPP SSO handoff.

Changes

Library Rooms Feature

Layer / File(s) Summary
API types and contracts
lib/api.ts
LibraryAvailabilityQuery and LibraryRoomResult types define the availability request shape and per-room result structure; BASE_URL fallback IP updated.
Backend LibCal integration
backend/src/library.ts
Comprehensive LibCal scraping with constants, GET /availability handler accepting date/time/floor/capacity/power/ADA parameters, page fetching with cookie handling, room metadata parsing from script blocks or DOM with TTL-based caching, grid slot retrieval via POST, slot-to-room matching with filter logic, and date-time utilities for duration normalization.
Backend route registration
backend/src/index.ts
Registers library router at /api/library path in Express application.
Frontend API client
lib/api.ts
getLibraryAvailability function constructs query parameters, fetches backend availability endpoint, extracts errors with fallback handling, and returns typed room results.
Frontend navigation
app/(tabs)/_layout.tsx
Adds library tab to bottom navigator with book-open icon and throttled tabPress handler propagating collapseAll state.
Frontend UI implementation
app/(tabs)/library.tsx
Complete LibraryScreen with filter inputs for date/time/floor/capacity/power/ADA, results display with room cards, and automated WebView-based booking via injected LibCal JavaScript, postMessage progress tracking, stage-specific overlay copy, and manual fallback guidance.
Dependencies and configuration
.env.example, package.json, app/(tabs)/map.tsx
Adds react-native-webview and react-native-is-edge-to-edge dependencies; updates .env.example BASE_URL IP; minor formatting cleanup in map.tsx useState.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • lebuckman/broncopath#2: Both PRs modify EXPO_PUBLIC_API_BASE_URL in .env.example with different fallback IP addresses.

Suggested reviewers

  • lebuckman

Poem

🐰 A library of rooms awaits discovery,
Through LibCal's walls, the automation's sorcery,
WebView whispers SSO secrets true,
Filtered by floor and capacity too,
Booking blooms automated anew! 📚✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: adding a complete Library feature with a stable, reliable reservation system implemented via WebView.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/library

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/api.ts (1)

38-38: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Encode route query parameters before fetch.

Line 38 interpolates from/to directly into URL; values containing spaces, &, or ? will produce malformed queries.

Suggested patch
 export async function getRoutes(from: string, to: string): Promise<RouteOption[]> {
-  const response = await fetch(`${BASE_URL}/api/routes?from=${from}&to=${to}`);
+  const params = new URLSearchParams({ from, to });
+  const response = await fetch(`${BASE_URL}/api/routes?${params.toString()}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/api.ts` at line 38, The URL is built by interpolating raw from and to
values into `${BASE_URL}/api/routes?from=${from}&to=${to}`, which can break when
those values contain spaces or reserved characters; update the request to
properly encode the query parameters (e.g., call encodeURIComponent on from and
to or build the query via URLSearchParams) before passing to fetch so the final
URL is valid and safe; adjust the expression that creates the URL (the line
assigning response and using BASE_URL, from, to) accordingly.
🧹 Nitpick comments (1)
.env.example (1)

1-1: ⚡ Quick win

Use a neutral example value instead of a team-local LAN host.

Using a specific internal IP in .env.example makes setup brittle for anyone outside that network. Prefer a placeholder or localhost-style default with a README note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example at line 1, Replace the team-local IP value for
EXPO_PUBLIC_API_BASE_URL in .env.example with a neutral placeholder (e.g.,
http://localhost:3000 or https://api.example.com) so the example is usable by
external contributors; update the EXPO_PUBLIC_API_BASE_URL entry and, if
helpful, add a short README note explaining contributors should set their own
host when running locally or in CI.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(tabs)/_layout.tsx:
- Around line 96-100: The Library tab is incorrectly reusing the Rooms debounce
ref (lastRoomsPress), causing cross-tab debounce collisions; create and use a
distinct ref (e.g., lastLibraryPress) for the Library tab handler instead of
lastRoomsPress, update the handler that compares timestamps and sets
navigation.setParams({ collapseAll: now }) to read/write
lastLibraryPress.current, and ensure lastRoomsPress remains unchanged so Rooms
and Library debounces are independent.

In `@app/`(tabs)/library.tsx:
- Line 1809: Replace the raw hex used for the shadow color by switching the
shadowColor value from "#000" to the appropriate theme token (e.g.,
Colors.black) and ensure the Colors export from constants/colors.ts is imported
into this module; locate the component/style object containing the shadowColor
property and update the value to Colors.<desiredToken> and add or update the
import statement for Colors at the top of the file.
- Around line 1103-2417: The screen uses inline layout/spacing styles throughout
(e.g., in FilterStep, ResultsStep, BookingStep, ReservationPrepOverlay,
FlyingFormCard, Chip, Toggle, RoomCard, ActionButton, etc.) which breaks the
NativeWind v4 contract; refactor these components to move all layout/spacing
rules (flex, padding, margin, gap, width/height, borderRadius, flexDirection,
alignItems, justifyContent, overflow, position, etc.) into className strings and
keep only dynamic colors and fontFamily in the style prop (e.g., Colors.* and
fontFamily: Fonts.*). Update usages like className="p-4 flex-row items-center"
for layout while preserving existing Colors and fontFamily inline (style={{
color: Colors.text, fontFamily: Fonts.body }}), and do this consistently for
top-level containers and reused components (FilterStep, Chip, Toggle, RoomCard,
FlyingFormCard, ReservationPrepOverlay, ActionButton, SectionLabel, HScroll,
FilterLabel, StepperBtn, AttributeBadge). Ensure no layout/spacing remains
inline so styling follows the repo guideline.

In `@backend/src/library.ts`:
- Around line 196-203: fetchLibCalPage (and the other upstream fetch at lines
434-438) currently call fetch with no timeout and can hang; add an
AbortController for each request, pass controller.signal into fetch, and start a
setTimeout that calls controller.abort() after a reasonable timeout (e.g.
8–10s); ensure you clearTimeout on success (or in finally), and update error
handling in fetchLibCalPage and the other function to detect an AbortError and
surface a clear timeout-specific message (include the URL/date in the message)
so callers can distinguish timeouts from other network errors.
- Around line 176-180: The validateDate function currently only checks
YYYY-MM-DD format and allows impossible dates (e.g., 2026-13-40); update
validateDate to parse the year, month, and day from the string (using the
existing regex capture or string split), construct a UTC Date (or Date object)
and then verify that date.getUTCFullYear(), date.getUTCMonth()+1, and
date.getUTCDate() exactly match the parsed values; if they do not match, throw
the same Error("Invalid date; expected YYYY-MM-DD"). Keep the format check but
add this semantic/calendar validation in validateDate so impossible month/day
combinations are rejected.
- Around line 123-127: The catch currently treats all errors as server/upstream
failures and returns 502; change this so validation failures from validateDate
return 400. Update validateDate to throw a specific client error (e.g., create a
new class ClientError or set error.name = "ClientError") when input is invalid,
then in the catch block of the /rooms handler check for that client error (error
instanceof ClientError or error.name === "ClientError") and call
res.status(400).json({ error: message }); otherwise keep res.status(502) for
other errors; reference validateDate and the catch block around "Library room
metadata failed".

---

Outside diff comments:
In `@lib/api.ts`:
- Line 38: The URL is built by interpolating raw from and to values into
`${BASE_URL}/api/routes?from=${from}&to=${to}`, which can break when those
values contain spaces or reserved characters; update the request to properly
encode the query parameters (e.g., call encodeURIComponent on from and to or
build the query via URLSearchParams) before passing to fetch so the final URL is
valid and safe; adjust the expression that creates the URL (the line assigning
response and using BASE_URL, from, to) accordingly.

---

Nitpick comments:
In @.env.example:
- Line 1: Replace the team-local IP value for EXPO_PUBLIC_API_BASE_URL in
.env.example with a neutral placeholder (e.g., http://localhost:3000 or
https://api.example.com) so the example is usable by external contributors;
update the EXPO_PUBLIC_API_BASE_URL entry and, if helpful, add a short README
note explaining contributors should set their own host when running locally or
in CI.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa74d018-2133-43b6-a730-b411171fcb13

📥 Commits

Reviewing files that changed from the base of the PR and between 67b5f7e and 2883ab1.

⛔ Files ignored due to path filters (2)
  • Archive.zip is excluded by !**/*.zip
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • .env.example
  • app/(tabs)/_layout.tsx
  • app/(tabs)/library.tsx
  • app/(tabs)/map.tsx
  • backend/src/index.ts
  • backend/src/library.ts
  • lib/api.ts
  • package.json

Comment thread app/(tabs)/_layout.tsx
Comment on lines +96 to +100
if (now - lastRoomsPress.current < 300) {
navigation.setParams({ collapseAll: now });
}
lastRoomsPress.current = now;
},
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 12, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Library tab is sharing debounce state with Rooms tab.

Line 96 reuses lastRoomsPress, so rapid presses across different tabs can trigger the wrong debounce behavior. Use a dedicated lastLibraryPress ref.

Suggested patch
   const lastHomePress = useRef(0);
   const lastMapPress = useRef(0);
   const lastRoomsPress = useRef(0);
+  const lastLibraryPress = useRef(0);
-            if (now - lastRoomsPress.current < 300) {
+            if (now - lastLibraryPress.current < 300) {
               navigation.setParams({ collapseAll: now });
             }
-            lastRoomsPress.current = now;
+            lastLibraryPress.current = now;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (now - lastRoomsPress.current < 300) {
navigation.setParams({ collapseAll: now });
}
lastRoomsPress.current = now;
},
if (now - lastLibraryPress.current < 300) {
navigation.setParams({ collapseAll: now });
}
lastLibraryPress.current = now;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(tabs)/_layout.tsx around lines 96 - 100, The Library tab is incorrectly
reusing the Rooms debounce ref (lastRoomsPress), causing cross-tab debounce
collisions; create and use a distinct ref (e.g., lastLibraryPress) for the
Library tab handler instead of lastRoomsPress, update the handler that compares
timestamps and sets navigation.setParams({ collapseAll: now }) to read/write
lastLibraryPress.current, and ensure lastRoomsPress remains unchanged so Rooms
and Library debounces are independent.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

library debounce shared with rooms: 3 line fix, worth doing to avoid weird edge cases; or test to see if any performance issues occur

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@lebuckman Agreed — it's a safe, zero-cost fix since useRef is just a plain mutable object with no re-render overhead. Here's the complete change:

// Add alongside the other refs (around line 7)
const lastLibraryPress = useRef(0);
// In the library Tabs.Screen listeners (lines 96–100)
listeners={({ navigation }) => ({
  tabPress: () => {
    const now = Date.now();
    if (now - lastLibraryPress.current < 300) {
      navigation.setParams({ collapseAll: now });
    }
    lastLibraryPress.current = now;
  },
})}

This keeps the Rooms and Library debounces fully independent, so quickly switching between the two tabs won't accidentally fire collapseAll on either screen.

Comment thread app/(tabs)/library.tsx
Comment on lines +1103 to +2417
<SafeAreaView
edges={["top"]}
style={{ flex: 1, backgroundColor: Colors.bg }}
>
{showChromeHeader && (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
borderBottomColor: Colors.border,
borderBottomWidth: 1,
}}
>
<Pressable onPress={goBack} hitSlop={8} style={{ marginRight: 12 }}>
<Feather name="arrow-left" size={20} color={Colors.text} />
</Pressable>
<Text style={{ color: Colors.text, fontFamily: Fonts.display, fontSize: 20, flex: 1 }}>
{step === "results"
? "Available Rooms"
: bookingRoom?.name ?? "Reserve Room"}
</Text>
{step === "results" && (
<Text style={{ color: Colors.muted, fontFamily: Fonts.mono, fontSize: 11 }}>
{results.filter((r) => r.isAvailable).length} open
</Text>
)}
</View>
)}

<Animated.View style={{ flex: 1, transform: [{ translateY: slideAnim }] }}>
{step === "filter" && (
<FilterStep
days={days}
filters={filters}
onChange={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
onSearch={handleSearch}
/>
)}

{step === "results" && (
<ResultsStep
loading={loading}
error={apiError}
results={results}
filters={filters}
onBook={openBooking}
onOpenDirect={() => Linking.openURL(buildLibCalDirectUrl(filters.date))}
/>
)}

{step === "booking" && bookingRoom && (
<BookingStep
url={bookingUrl}
injectJS={buildInjectJS(filters.date, filters.startTime, filters.duration, bookingRoom)}
room={bookingRoom}
filters={filters}
automationStage={automationStage}
automationDetail={automationDetail}
webViewVisible={webViewVisible}
onMessage={handleLibCalMessage}
onNavigationStateChange={handleBookingNavigation}
onCancel={goBack}
/>
)}
</Animated.View>
</SafeAreaView>
);
}

type FilterStepProps = {
days: { value: string; label: string }[];
filters: Filters;
onChange: (patch: Partial<Filters>) => void;
onSearch: () => void;
};

function FilterStep({ days, filters, onChange, onSearch }: FilterStepProps) {
const endTime = addMins(filters.startTime, filters.duration);
const [searchPressed, setSearchPressed] = useState(false);

return (
<ScrollView
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 20, paddingBottom: 44 }}
showsVerticalScrollIndicator={false}
>
<Text style={{ color: Colors.text, fontFamily: Fonts.display, fontSize: 28, marginBottom: 4 }}>
Library Rooms
</Text>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 12, marginBottom: 24, lineHeight: 18 }}>
Pick a date, time, capacity, and room specs. BroncoPath checks availability; CPP handles SSO.
</Text>

<FilterLabel icon="calendar">Date</FilterLabel>
<HScroll>
{days.map((d) => (
<Chip
key={d.value}
label={d.label}
selected={filters.date === d.value}
onPress={() => onChange({ date: d.value })}
/>
))}
</HScroll>

<FilterLabel icon="clock">Start time</FilterLabel>
<HScroll>
{TIME_SLOTS.map((t) => (
<Chip
key={t}
label={to12h(t)}
selected={filters.startTime === t}
onPress={() => onChange({ startTime: t })}
/>
))}
</HScroll>

<FilterLabel icon="watch">Duration</FilterLabel>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 4 }}>
{DURATIONS.map((d) => (
<Chip
key={d.value}
label={d.label}
selected={filters.duration === d.value}
onPress={() => onChange({ duration: d.value })}
/>
))}
</View>

<Text style={{
color: Colors.muted,
fontFamily: Fonts.body,
fontSize: 11,
marginTop: 6,
marginBottom: 20,
}}>
{to12h(filters.startTime)} – {to12h(endTime)}
</Text>

<FilterLabel icon="users">Group size</FilterLabel>
<View style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: Colors.card,
borderColor: Colors.border,
borderWidth: 1,
borderRadius: 16,
alignSelf: "flex-start",
marginBottom: 20,
overflow: "hidden",
}}>
<StepperBtn
icon="minus"
disabled={filters.groupSize <= 2}
onPress={() => onChange({ groupSize: Math.max(2, filters.groupSize - 1) })}
/>
<View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
<Text style={{ color: Colors.text, fontFamily: Fonts.bodySemiBold, fontSize: 18 }}>
{filters.groupSize}
</Text>
</View>
<StepperBtn
icon="plus"
disabled={filters.groupSize >= 9}
onPress={() => onChange({ groupSize: Math.min(9, filters.groupSize + 1) })}
/>
</View>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, marginTop: -14, marginBottom: 20 }}>
CPP group rooms require 2 – 9 students
</Text>

<FilterLabel icon="layers">Preferred floor</FilterLabel>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 20 }}>
{FLOORS.map((f) => (
<Chip
key={f.value}
label={f.label}
selected={filters.floor === f.value}
onPress={() => onChange({ floor: f.value })}
/>
))}
</View>

<FilterLabel icon="sliders">Preferences</FilterLabel>
<Toggle
label="Power outlet"
icon="zap"
value={filters.needsPower}
onPress={() => onChange({ needsPower: !filters.needsPower })}
/>
<Toggle
label="ADA accessible"
icon="check-circle"
value={filters.needsADA}
onPress={() => onChange({ needsADA: !filters.needsADA })}
/>

<Pressable
onPress={onSearch}
onPressIn={() => setSearchPressed(true)}
onPressOut={() => setSearchPressed(false)}
style={{
marginTop: 28,
backgroundColor: searchPressed ? Colors.accentDim : Colors.accent,
borderRadius: 16,
paddingVertical: 16,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Feather name="search" size={16} color={Colors.bg} />
<Text style={{ color: Colors.bg, fontFamily: Fonts.bodySemiBold, fontSize: 15 }}>
Find Available Rooms
</Text>
</Pressable>
</ScrollView>
);
}

type ResultsStepProps = {
loading: boolean;
error: string | null;
results: RoomWithSlots[];
filters: Filters;
onBook: (room: LibraryRoomResult) => void;
onOpenDirect: () => void;
};

function ResultsStep({
loading,
error,
results,
filters,
onBook,
onOpenDirect,
}: ResultsStepProps) {
const endTime = addMins(filters.startTime, filters.duration);
const available = results.filter((r) => r.isAvailable);
const unavailable = results.filter((r) => !r.isAvailable);

if (loading) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator size="large" color={Colors.accent} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 13, marginTop: 14 }}>
Checking LibCal through BroncoPath…
</Text>
</View>
);
}

if (error) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 32 }}>
<Feather name="wifi-off" size={36} color={Colors.muted} style={{ marginBottom: 14 }} />
<Text style={{
color: Colors.text,
fontFamily: Fonts.bodyMedium,
fontSize: 16,
textAlign: "center",
marginBottom: 8,
}}>
Couldn't read LibCal availability
</Text>
<Text style={{
color: Colors.muted,
fontFamily: Fonts.body,
fontSize: 12,
textAlign: "center",
marginBottom: 28,
lineHeight: 18,
}}>
The backend could not reach or parse LibCal. Open LibCal directly to continue with CPP's booking page.
</Text>
<ActionButton
icon="external-link"
label="Open LibCal Directly"
onPress={onOpenDirect}
/>
<Text style={{ color: Colors.muted, fontFamily: Fonts.mono, fontSize: 10, marginTop: 18, textAlign: "center" }}>
{error}
</Text>
</View>
);
}

const summaryParts = [
`${to12h(filters.startTime)} – ${to12h(endTime)}`,
`${filters.groupSize} people`,
filters.floor !== "any" ? `Floor ${filters.floor}` : null,
filters.needsPower ? "Power" : null,
filters.needsADA ? "ADA" : null,
].filter(Boolean).join(" · ");

return (
<ScrollView
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 16, paddingBottom: 44 }}
showsVerticalScrollIndicator={false}
>
<View style={{
backgroundColor: Colors.card,
borderColor: Colors.border,
borderWidth: 1,
borderRadius: 14,
padding: 14,
marginBottom: 20,
flexDirection: "row",
alignItems: "center",
gap: 10,
}}>
<Feather name="filter" size={13} color={Colors.muted} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, flex: 1 }}>
{summaryParts}
</Text>
</View>

{available.length === 0 && unavailable.length === 0 && (
<EmptyResults onOpenDirect={onOpenDirect} />
)}

{available.length > 0 && (
<>
<SectionLabel>Available ({available.length})</SectionLabel>
{available.map(({ room }) => (
<RoomCard
key={room.eid}
room={room}
available
onBook={() => onBook(room)}
/>
))}
</>
)}

{unavailable.length > 0 && (
<>
<SectionLabel style={{ marginTop: available.length > 0 ? 24 : 0 }}>
Matching specs, unavailable now ({unavailable.length})
</SectionLabel>
{unavailable.map(({ room }) => (
<RoomCard
key={room.eid}
room={room}
available={false}
onBook={() => onBook(room)}
/>
))}
</>
)}

<Pressable
onPress={onOpenDirect}
style={{ marginTop: 24, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 6 }}
>
<Feather name="external-link" size={12} color={Colors.muted} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11 }}>
Browse full LibCal calendar
</Text>
</Pressable>
</ScrollView>
);
}

function EmptyResults({ onOpenDirect }: { onOpenDirect: () => void }) {
return (
<View style={{ alignItems: "center", paddingTop: 32 }}>
<Feather name="inbox" size={34} color={Colors.muted} style={{ marginBottom: 12 }} />
<Text style={{ color: Colors.text, fontFamily: Fonts.bodyMedium, fontSize: 15, marginBottom: 4 }}>
No rooms matched
</Text>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 12, textAlign: "center", lineHeight: 18 }}>
Try another floor, group size, time, or preference combination.
</Text>
<Pressable onPress={onOpenDirect} style={{ marginTop: 20, flexDirection: "row", alignItems: "center", gap: 6 }}>
<Feather name="external-link" size={13} color={Colors.accent} />
<Text style={{ color: Colors.accent, fontFamily: Fonts.bodySemiBold, fontSize: 12 }}>
Open LibCal directly
</Text>
</Pressable>
</View>
);
}

type BookingStepProps = {
url: string;
injectJS: string;
room: LibraryRoomResult;
filters: Filters;
automationStage: AutomationStage;
automationDetail: string | null;
webViewVisible: boolean;
onMessage: (event: { nativeEvent: { data: string } }) => void;
onNavigationStateChange: (nav: WebViewNavigation) => void;
onCancel: () => void;
};

function BookingStep({
url,
injectJS,
room,
filters,
automationStage,
automationDetail,
webViewVisible,
onMessage,
onNavigationStateChange,
onCancel,
}: BookingStepProps) {
const endTime = addMins(filters.startTime, filters.duration);

return (
<View style={{ flex: 1 }}>
{webViewVisible && (
<View style={{
backgroundColor: Colors.surface,
borderBottomColor: Colors.border,
borderBottomWidth: 1,
paddingHorizontal: 20,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 10,
}}>
<View style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: room.isAvailable ? Colors.accent : Colors.med,
}} />
<Text style={{ color: Colors.text, fontFamily: Fonts.bodyMedium, fontSize: 13, flex: 1 }} numberOfLines={1}>
{room.name}
</Text>
<Text style={{ color: Colors.muted, fontFamily: Fonts.mono, fontSize: 11 }}>
{to12h(filters.startTime)}-{to12h(endTime)}
</Text>
</View>
)}

<View style={{ flex: 1 }}>
<View
pointerEvents={webViewVisible ? "auto" : "none"}
style={{ flex: 1, opacity: webViewVisible ? 1 : 0 }}
>
<WebView
source={{ uri: url }}
injectedJavaScript={injectJS}
javaScriptEnabled
domStorageEnabled
style={{ flex: 1, backgroundColor: Colors.bg }}
onMessage={onMessage}
onNavigationStateChange={onNavigationStateChange}
/>
</View>

{!webViewVisible && (
<ReservationPrepOverlay
room={room}
filters={filters}
stage={automationStage}
detail={automationDetail}
onCancel={onCancel}
/>
)}
</View>

{webViewVisible && (
<View style={{
backgroundColor: Colors.surface,
borderTopColor: Colors.border,
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}>
<Feather
name={automationStage === "failed" ? "alert-triangle" : "lock"}
size={12}
color={automationStage === "failed" ? Colors.med : Colors.muted}
/>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, flex: 1, lineHeight: 16 }}>
{automationStage === "failed"
? "Automation could not finish. Complete the selection manually in LibCal; no reservation has been made yet."
: "Continue in CPP SSO or LibCal. The room is not reserved until you submit the final LibCal form."}
</Text>
</View>
)}
</View>
);
}

function ReservationPrepOverlay({
room,
filters,
stage,
detail,
onCancel,
}: {
room: LibraryRoomResult;
filters: Filters;
stage: AutomationStage;
detail: string | null;
onCancel: () => void;
}) {
const progress = useRef(new Animated.Value(0)).current;
const press = useRef(new Animated.Value(0)).current;
const endTime = addMins(filters.startTime, filters.duration);
const activeIndex = prepActiveIndex(stage);
const cards = [
{
index: 0,
icon: "home" as const,
prompt: "Which study room?",
answer: room.name,
hint: room.grouping,
},
{
index: 1,
icon: "calendar" as const,
prompt: "When should it start?",
answer: `${to12h(filters.startTime)}`,
hint: filters.date,
},
{
index: 2,
icon: "watch" as const,
prompt: "How long should it last?",
answer: `${to12h(filters.startTime)} - ${to12h(endTime)}`,
hint: `${filters.duration} minutes`,
},
{
index: 3,
icon: "send" as const,
prompt: "Ready for CPP sign-in?",
answer: "Submit Times",
hint: "LibCal will hand this to CPP SSO",
},
];
const visualCards = [...cards, { ...cards[0], index: 4 }];

useEffect(() => {
progress.setValue(0);
press.setValue(0);

function clickBeat() {
return Animated.sequence([
Animated.delay(stage === "submitting-times" || stage === "handoff" ? 260 : 520),
Animated.timing(press, {
toValue: 1,
duration: 105,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(press, {
toValue: 0,
duration: 155,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.delay(stage === "submitting-times" || stage === "handoff" ? 420 : 700),
]);
}

function slideTo(index: number) {
return Animated.timing(progress, {
toValue: index,
duration: 460,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
});
}

const flowAnimation = Animated.loop(
Animated.sequence([
Animated.timing(progress, {
toValue: 0,
duration: 1,
easing: Easing.linear,
useNativeDriver: true,
}),
clickBeat(),
slideTo(1),
clickBeat(),
slideTo(2),
clickBeat(),
slideTo(3),
clickBeat(),
slideTo(4),
clickBeat(),
]),
);

flowAnimation.start();

return () => {
flowAnimation.stop();
};
}, [press, progress, stage]);

const pointerY = progress.interpolate({
inputRange: [0, 1, 2, 3, 4],
outputRange: [50, 72, 94, 114, 50],
});
const pointerScale = press.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.82],
});

return (
<View style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: Colors.bg,
paddingHorizontal: 24,
paddingTop: 18,
paddingBottom: 24,
zIndex: 20,
}}>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
<Pressable
onPress={onCancel}
hitSlop={10}
style={{
width: 38,
height: 38,
borderRadius: 19,
alignItems: "center",
justifyContent: "center",
backgroundColor: Colors.surface,
borderColor: Colors.border,
borderWidth: 1,
}}
>
<Feather name="x" size={17} color={Colors.muted} />
</Pressable>

<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<View style={{
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: stage === "failed" ? Colors.med : Colors.accent,
}} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.mono, fontSize: 11 }}>
LibCal handoff
</Text>
</View>
</View>

<View style={{ flex: 1, justifyContent: "center" }}>
<View style={{ marginBottom: 28 }}>
<Text style={{
color: Colors.accent,
fontFamily: Fonts.mono,
fontSize: 12,
marginBottom: 12,
}}>
1 → preparing your reservation
</Text>
<Text style={{ color: Colors.text, fontFamily: Fonts.display, fontSize: 34, lineHeight: 40 }}>
Filling LibCal for you.
</Text>
<Text style={{
color: Colors.muted,
fontFamily: Fonts.body,
fontSize: 13,
lineHeight: 20,
marginTop: 12,
maxWidth: 330,
}}>
{prepStageCopy(stage)}
</Text>
</View>

<View style={{
height: 292,
justifyContent: "center",
overflow: "hidden",
}}>
{visualCards.map((card) => (
<FlyingFormCard
key={`${card.index}-${card.prompt}`}
card={card}
progress={progress}
press={press}
active={activeIndex === (card.index === 4 ? 0 : card.index)}
isSubmit={card.index === 3}
/>
))}

<Animated.View style={{
position: "absolute",
right: 94,
top: 96,
transform: [
{ translateY: pointerY },
{ scale: pointerScale },
],
shadowColor: "#000",
shadowOpacity: 0.26,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
}}>
<Feather name="mouse-pointer" size={24} color={Colors.accent} />
</Animated.View>
</View>

<View style={{
marginTop: 26,
backgroundColor: stage === "failed" ? Colors.medBg : Colors.surface,
borderColor: stage === "failed" ? Colors.medBorder : Colors.border,
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 14,
paddingVertical: 13,
flexDirection: "row",
alignItems: "center",
gap: 10,
}}>
{stage === "failed" ? (
<Feather name="alert-triangle" size={15} color={Colors.med} />
) : (
<ActivityIndicator size="small" color={Colors.accent} />
)}
<View style={{ flex: 1 }}>
<Text style={{ color: stage === "failed" ? Colors.med : Colors.text, fontFamily: Fonts.bodySemiBold, fontSize: 12 }}>
{prepStageLabel(stage)}
</Text>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, marginTop: 2 }} numberOfLines={2}>
{detail ?? prepStageDetail(stage)}
</Text>
</View>
</View>
</View>
</View>
);
}

function FlyingFormCard({
card,
progress,
press,
active,
isSubmit,
}: {
card: {
index: number;
icon: keyof typeof Feather.glyphMap;
prompt: string;
answer: string;
hint: string;
};
progress: Animated.Value;
press: Animated.Value;
active: boolean;
isSubmit: boolean;
}) {
const translateY = progress.interpolate({
inputRange: [card.index - 0.75, card.index, card.index + 0.75],
outputRange: [104, 0, -104],
extrapolate: "clamp",
});

const opacity = progress.interpolate({
inputRange: [card.index - 0.9, card.index - 0.2, card.index, card.index + 0.32, card.index + 0.9],
outputRange: [0, 0.42, 1, 0.58, 0],
extrapolate: "clamp",
});
const scale = progress.interpolate({
inputRange: [card.index - 0.75, card.index, card.index + 0.75],
outputRange: [0.94, 1, 0.94],
extrapolate: "clamp",
});
const submitScale = press.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.96],
});
const displayIndex = card.index === 4 ? 0 : card.index;

return (
<Animated.View style={{
position: "absolute",
left: 0,
right: 0,
opacity,
transform: [{ translateY }, { scale }],
}}>
<View style={{
backgroundColor: Colors.card,
borderColor: active ? Colors.accentBorder : Colors.borderMd,
borderWidth: 1,
borderRadius: 24,
padding: 20,
}}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 18 }}>
<Text style={{ color: active ? Colors.accent : Colors.muted, fontFamily: Fonts.mono, fontSize: 12 }}>
{displayIndex + 1} →
</Text>
<Text style={{ color: Colors.text, fontFamily: Fonts.bodyMedium, fontSize: 15, flex: 1 }}>
{card.prompt}
</Text>
</View>

{isSubmit ? (
<Animated.View style={{
transform: [{ scale: submitScale }],
backgroundColor: active ? Colors.accent : Colors.accentBg,
borderColor: Colors.accentBorder,
borderWidth: 1,
borderRadius: 16,
paddingVertical: 15,
paddingHorizontal: 18,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 9,
}}>
<Feather name="send" size={15} color={active ? Colors.bg : Colors.accent} />
<Text style={{ color: active ? Colors.bg : Colors.accent, fontFamily: Fonts.bodySemiBold, fontSize: 14 }}>
{card.answer}
</Text>
</Animated.View>
) : (
<View style={{
minHeight: 70,
backgroundColor: active ? Colors.accentBg : Colors.surface,
borderColor: active ? Colors.accentBorder : Colors.border,
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 16,
paddingVertical: 14,
flexDirection: "row",
alignItems: "center",
gap: 12,
}}>
<View style={{
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: Colors.bg,
alignItems: "center",
justifyContent: "center",
borderColor: active ? Colors.accentBorder : Colors.border,
borderWidth: 1,
}}>
<Feather name={card.icon} size={15} color={active ? Colors.accent : Colors.muted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: active ? Colors.accent : Colors.text, fontFamily: Fonts.bodySemiBold, fontSize: 15 }} numberOfLines={1}>
{card.answer}
</Text>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, marginTop: 4 }} numberOfLines={1}>
{card.hint}
</Text>
</View>
</View>
)}

{isSubmit && (
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, lineHeight: 16, marginTop: 12, textAlign: "center" }}>
{card.hint}
</Text>
)}
</View>
</Animated.View>
);
}

function prepActiveIndex(stage: AutomationStage): number {
if (stage === "selecting-slot") return 1;
if (stage === "setting-duration" || stage === "slot-prepared") return 2;
if (stage === "submitting-times" || stage === "handoff") return 3;
return 0;
}

function prepStageLabel(stage: AutomationStage): string {
switch (stage) {
case "loading":
return "Opening LibCal";
case "preparing":
return "Waiting for LibCal scripts";
case "scanning-date-pages":
return "Checking date and room pages";
case "selecting-slot":
return "Choosing the matching room slot";
case "setting-duration":
return "Setting the reservation length";
case "slot-prepared":
return "Slot prepared";
case "submitting-times":
return "Pressing Submit Times";
case "handoff":
return "Opening CPP SSO";
case "ready-for-user":
return "Ready for sign-in";
case "failed":
return "Manual completion needed";
default:
return "Preparing LibCal";
}
}

function prepStageDetail(stage: AutomationStage): string {
switch (stage) {
case "scanning-date-pages":
return "Trying the exact date, surrounding date windows, and all LibCal room pages.";
case "selecting-slot":
return "Using LibCal's slot checksum instead of clicking the visible grid only.";
case "setting-duration":
return "Updating the pending booking with LibCal's own end-time checksum.";
case "submitting-times":
return "The selected time is being handed to LibCal's SSO route.";
case "handoff":
return "CPP sign-in is loading. The WebView will appear next.";
case "failed":
return "The WebView will open so you can finish on LibCal.";
default:
return "Keep this screen open while BroncoPath prepares the LibCal form.";
}
}

function prepStageCopy(stage: AutomationStage): string {
switch (stage) {
case "scanning-date-pages":
return "BroncoPath is checking the selected room across LibCal's date window and room pages, not just the visible grid.";
case "submitting-times":
case "handoff":
return "The slot is selected. BroncoPath is pressing LibCal's Submit Times button so CPP can take over sign-in.";
case "failed":
return "BroncoPath could not finish the handoff. The WebView will be shown so you can complete it manually.";
default:
return "The room, start time, and duration are being selected inside LibCal. You will see CPP SSO when it needs your login.";
}
}


function FilterLabel({
icon,
children,
}: {
icon: keyof typeof Feather.glyphMap;
children: string;
}) {
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 7, marginBottom: 10, marginTop: 4 }}>
<Feather name={icon} size={13} color={Colors.accent} />
<Text style={{
color: Colors.muted,
fontFamily: Fonts.bodySemiBold,
fontSize: 11,
letterSpacing: 0.9,
textTransform: "uppercase",
}}>
{children}
</Text>
</View>
);
}

function HScroll({ children }: { children: ReactNode }) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingRight: 4, marginBottom: 20 }}
>
{children}
</ScrollView>
);
}

function Chip({
label,
selected,
onPress,
}: {
label: string;
selected: boolean;
onPress: () => void;
}) {
const [pressed, setPressed] = useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
style={{
backgroundColor: selected
? Colors.accentBg
: pressed
? Colors.cardHover
: Colors.card,
borderColor: selected ? Colors.accentBorder : Colors.border,
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 9,
}}
>
<Text style={{
color: selected ? Colors.accent : Colors.text,
fontFamily: selected ? Fonts.bodySemiBold : Fonts.body,
fontSize: 12,
}}>
{label}
</Text>
</Pressable>
);
}

function StepperBtn({
icon,
disabled,
onPress,
}: {
icon: "plus" | "minus";
disabled: boolean;
onPress: () => void;
}) {
const [pressed, setPressed] = useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
disabled={disabled}
style={{
paddingHorizontal: 18,
paddingVertical: 14,
backgroundColor: pressed ? Colors.cardHover : "transparent",
opacity: disabled ? 0.35 : 1,
}}
>
<Feather name={icon} size={16} color={Colors.text} />
</Pressable>
);
}

function Toggle({
label,
icon,
value,
onPress,
}: {
label: string;
icon: keyof typeof Feather.glyphMap;
value: boolean;
onPress: () => void;
}) {
const [pressed, setPressed] = useState(false);
return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
style={{
backgroundColor: value
? Colors.accentBg
: pressed
? Colors.cardHover
: Colors.card,
borderColor: value ? Colors.accentBorder : Colors.border,
borderWidth: 1,
borderRadius: 14,
padding: 14,
marginBottom: 10,
flexDirection: "row",
alignItems: "center",
gap: 10,
}}
>
<Feather
name={icon}
size={15}
color={value ? Colors.accent : Colors.muted}
/>
<Text style={{
flex: 1,
color: value ? Colors.accent : Colors.text,
fontFamily: value ? Fonts.bodySemiBold : Fonts.body,
fontSize: 13,
}}>
{label}
</Text>
<View style={{
width: 28,
height: 17,
borderRadius: 9,
backgroundColor: value ? Colors.accent : Colors.border,
justifyContent: "center",
paddingHorizontal: 2,
}}>
<View style={{
width: 13,
height: 13,
borderRadius: 7,
backgroundColor: Colors.bg,
alignSelf: value ? "flex-end" : "flex-start",
}} />
</View>
</Pressable>
);
}

function SectionLabel({ children, style }: { children: ReactNode; style?: object }) {
return (
<Text style={[{
color: Colors.muted,
fontFamily: Fonts.bodySemiBold,
fontSize: 11,
letterSpacing: 0.9,
textTransform: "uppercase",
marginBottom: 10,
}, style]}>
{children}
</Text>
);
}

function RoomCard({
room,
available,
onBook,
}: {
room: LibraryRoomResult;
available: boolean;
onBook: () => void;
}) {
const [pressed, setPressed] = useState(false);
const nextAvailable = slotKeyTo12h(room.nextAvailableStart);

return (
<View style={{
backgroundColor: Colors.card,
borderColor: available ? Colors.accentBorder : Colors.border,
borderWidth: 1,
borderRadius: 18,
marginBottom: 10,
overflow: "hidden",
}}>
<View style={{
height: 3,
backgroundColor: available ? Colors.accent : Colors.border,
}} />

<View style={{ padding: 16 }}>
<View style={{ flexDirection: "row", alignItems: "flex-start", marginBottom: 8 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: Colors.text, fontFamily: Fonts.bodySemiBold, fontSize: 15, marginBottom: 3 }}>
{room.name}
</Text>
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11, marginBottom: 8 }} numberOfLines={1}>
{room.grouping}
</Text>
<View style={{ flexDirection: "row", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
{room.floor && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Feather name="layers" size={11} color={Colors.muted} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11 }}>
Floor {room.floor}
</Text>
</View>
)}
{room.capacity > 0 && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Feather name="users" size={11} color={Colors.muted} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11 }}>
Up to {room.capacity}
</Text>
</View>
)}
{!available && nextAvailable && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Feather name="clock" size={11} color={Colors.med} />
<Text style={{ color: Colors.med, fontFamily: Fonts.body, fontSize: 11 }}>
Next {nextAvailable}
</Text>
</View>
)}
</View>
</View>

<View style={{
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 99,
backgroundColor: available ? Colors.accentBg : Colors.surface,
borderColor: available ? Colors.accentBorder : Colors.border,
borderWidth: 1,
}}>
<Text style={{
color: available ? Colors.accent : Colors.muted,
fontFamily: Fonts.bodySemiBold,
fontSize: 10,
textTransform: "uppercase",
letterSpacing: 0.6,
}}>
{available ? "Open" : "Taken"}
</Text>
</View>
</View>

{(room.hasPower || room.isADA) && (
<View style={{ flexDirection: "row", gap: 8, marginBottom: 14 }}>
{room.hasPower && <AttributeBadge icon="zap" label="Power" color={Colors.med} />}
{room.isADA && <AttributeBadge icon="check-circle" label="ADA" color={Colors.low} />}
</View>
)}

<Pressable
onPress={onBook}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
style={{
backgroundColor: available
? pressed ? Colors.accentDim : Colors.accent
: pressed ? Colors.cardHover : Colors.surface,
borderRadius: 12,
paddingVertical: 11,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 7,
borderColor: available ? "transparent" : Colors.border,
borderWidth: available ? 0 : 1,
}}
>
<Feather
name="external-link"
size={14}
color={available ? Colors.bg : Colors.muted}
/>
<Text style={{
color: available ? Colors.bg : Colors.muted,
fontFamily: Fonts.bodySemiBold,
fontSize: 13,
}}>
{available ? "Reserve via LibCal" : "Check on LibCal"}
</Text>
</Pressable>
</View>
</View>
);
}

function AttributeBadge({
icon,
label,
color,
}: {
icon: keyof typeof Feather.glyphMap;
label: string;
color: string;
}) {
return (
<View style={{
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: Colors.surface,
borderRadius: 8,
paddingHorizontal: 9,
paddingVertical: 5,
borderColor: Colors.border,
borderWidth: 1,
}}>
<Feather name={icon} size={11} color={color} />
<Text style={{ color: Colors.muted, fontFamily: Fonts.body, fontSize: 11 }}>{label}</Text>
</View>
);
}

function ActionButton({
icon,
label,
onPress,
}: {
icon: keyof typeof Feather.glyphMap;
label: string;
onPress: () => void;
}) {
const [pressed, setPressed] = useState(false);

return (
<Pressable
onPress={onPress}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
style={{
backgroundColor: pressed ? Colors.cardHover : Colors.accentBg,
borderColor: Colors.accentBorder,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 24,
paddingVertical: 14,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Feather name={icon} size={15} color={Colors.accent} />
<Text style={{ color: Colors.accent, fontFamily: Fonts.bodySemiBold, fontSize: 13 }}>
{label}
</Text>
</Pressable>
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

NativeWind v4 styling contract is not followed in this screen.

The new screen is built with inline layout/spacing styles instead of className, which conflicts with the repo’s NativeWind v4 rule set and makes this screen inconsistent with the codebase styling model.

As per coding guidelines, “In NativeWind v4 components, use className props… Place dynamic colors (from Colors.*) and fontFamily in the style prop; place layout/spacing in className.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(tabs)/library.tsx around lines 1103 - 2417, The screen uses inline
layout/spacing styles throughout (e.g., in FilterStep, ResultsStep, BookingStep,
ReservationPrepOverlay, FlyingFormCard, Chip, Toggle, RoomCard, ActionButton,
etc.) which breaks the NativeWind v4 contract; refactor these components to move
all layout/spacing rules (flex, padding, margin, gap, width/height,
borderRadius, flexDirection, alignItems, justifyContent, overflow, position,
etc.) into className strings and keep only dynamic colors and fontFamily in the
style prop (e.g., Colors.* and fontFamily: Fonts.*). Update usages like
className="p-4 flex-row items-center" for layout while preserving existing
Colors and fontFamily inline (style={{ color: Colors.text, fontFamily:
Fonts.body }}), and do this consistently for top-level containers and reused
components (FilterStep, Chip, Toggle, RoomCard, FlyingFormCard,
ReservationPrepOverlay, ActionButton, SectionLabel, HScroll, FilterLabel,
StepperBtn, AttributeBadge). Ensure no layout/spacing remains inline so styling
follows the repo guideline.

Comment thread app/(tabs)/library.tsx
{ translateY: pointerY },
{ scale: pointerScale },
],
shadowColor: "#000",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace raw hex color with palette constant.

Line 1809 uses "#000" directly; use a Colors.* token to stay within the theming contract.

As per coding guidelines, “Never use raw hex strings in components — always import colors from constants/colors.ts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(tabs)/library.tsx at line 1809, Replace the raw hex used for the shadow
color by switching the shadowColor value from "#000" to the appropriate theme
token (e.g., Colors.black) and ensure the Colors export from constants/colors.ts
is imported into this module; locate the component/style object containing the
shadowColor property and update the value to Colors.<desiredToken> and add or
update the import statement for Colors at the top of the file.

Comment thread backend/src/library.ts
Comment on lines +123 to +127
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown library rooms error";
console.error("Library room metadata failed:", message);
res.status(502).json({ error: message });
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return 400 for invalid /rooms query input, not 502.

validateDate(date) failures are client input errors, but Line 126 currently always returns 502. This misclassifies bad requests as upstream failures.

Suggested patch
 } catch (error) {
   const message = error instanceof Error ? error.message : "Unknown library rooms error";
   console.error("Library room metadata failed:", message);
-  res.status(502).json({ error: message });
+  const status = message.startsWith("Invalid ") || message.includes("required") ? 400 : 502;
+  res.status(status).json({ error: message });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/library.ts` around lines 123 - 127, The catch currently treats
all errors as server/upstream failures and returns 502; change this so
validation failures from validateDate return 400. Update validateDate to throw a
specific client error (e.g., create a new class ClientError or set error.name =
"ClientError") when input is invalid, then in the catch block of the /rooms
handler check for that client error (error instanceof ClientError or error.name
=== "ClientError") and call res.status(400).json({ error: message }); otherwise
keep res.status(502) for other errors; reference validateDate and the catch
block around "Library room metadata failed".

Comment thread backend/src/library.ts
Comment on lines +176 to +180
function validateDate(date: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error("Invalid date; expected YYYY-MM-DD");
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Date validation is format-only and accepts impossible calendar dates.

Line 177 regex validation accepts values like 2026-13-40, which later get normalized by Date.UTC and can return wrong-day results instead of rejecting input.

Suggested patch
 function validateDate(date: string): void {
   if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
     throw new Error("Invalid date; expected YYYY-MM-DD");
   }
+
+  const [y, m, d] = date.split("-").map(Number);
+  const dt = new Date(Date.UTC(y, m - 1, d));
+  const normalized = dt.toISOString().slice(0, 10);
+  if (normalized !== date) {
+    throw new Error("Invalid date; expected a real calendar date");
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/library.ts` around lines 176 - 180, The validateDate function
currently only checks YYYY-MM-DD format and allows impossible dates (e.g.,
2026-13-40); update validateDate to parse the year, month, and day from the
string (using the existing regex capture or string split), construct a UTC Date
(or Date object) and then verify that date.getUTCFullYear(),
date.getUTCMonth()+1, and date.getUTCDate() exactly match the parsed values; if
they do not match, throw the same Error("Invalid date; expected YYYY-MM-DD").
Keep the format check but add this semantic/calendar validation in validateDate
so impossible month/day combinations are rejected.

Comment thread backend/src/library.ts
Comment on lines +196 to +203
async function fetchLibCalPage(date: string): Promise<LibCalPageSession> {
const referer = `${LIBCAL_BASE}${LIBCAL_PAGE_PATH}?lid=${LID}&gid=${DEFAULT_GROUP_ID}&dt=${date}`;
const response = await fetch(referer, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent": USER_AGENT,
},
});
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 12, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add request timeouts for upstream LibCal fetches.

Both upstream network calls have no timeout, so slow/hung LibCal responses can pin Node request handlers and degrade backend reliability.

Suggested patch
 async function fetchLibCalPage(date: string): Promise<LibCalPageSession> {
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), 10_000);
   const referer = `${LIBCAL_BASE}${LIBCAL_PAGE_PATH}?lid=${LID}&gid=${DEFAULT_GROUP_ID}&dt=${date}`;
-  const response = await fetch(referer, {
-    headers: {
-      Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
-      "User-Agent": USER_AGENT,
-    },
-  });
+  const response = await fetch(referer, {
+    signal: controller.signal,
+    headers: {
+      Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+      "User-Agent": USER_AGENT,
+    },
+  }).finally(() => clearTimeout(timeout));
-  const response = await fetch(`${LIBCAL_BASE}/spaces/availability/grid`, {
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), 10_000);
+  const response = await fetch(`${LIBCAL_BASE}/spaces/availability/grid`, {
     method: "POST",
+    signal: controller.signal,
     headers,
     body,
-  });
+  }).finally(() => clearTimeout(timeout));

Also applies to: 434-438

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/library.ts` around lines 196 - 203, fetchLibCalPage (and the
other upstream fetch at lines 434-438) currently call fetch with no timeout and
can hang; add an AbortController for each request, pass controller.signal into
fetch, and start a setTimeout that calls controller.abort() after a reasonable
timeout (e.g. 8–10s); ensure you clearTimeout on success (or in finally), and
update error handling in fetchLibCalPage and the other function to detect an
AbortError and surface a clear timeout-specific message (include the URL/date in
the message) so callers can distinguish timeouts from other network errors.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

code rabbit called you out fr (verify behavior and check out suggested patch for comment abt the missing request timeouts for LibCal fetches)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the fetchLibCalPage function and the grid fetch call
sed -n '190,240p' backend/src/library.ts
echo "---"
sed -n '425,450p' backend/src/library.ts
echo "---"
# Also check error handling in the route handlers
sed -n '87,130p' backend/src/library.ts

Length of output: 4185


@lebuckman — confirmed, both issues are real and still unfixed. Neither fetchLibCalPage nor the grid fetch have any timeout, so a slow/hung LibCal response will hold the Node handler open indefinitely.

The original suggested patch is correct. Below is an enhanced version that also detects AbortError and surfaces a clear, context-rich message so callers (and logs) can distinguish a timeout from a regular network error:

fetchLibCalPage (Line 196):

async function fetchLibCalPage(date: string): Promise<LibCalPageSession> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);
  const referer = `${LIBCAL_BASE}${LIBCAL_PAGE_PATH}?lid=${LID}&gid=${DEFAULT_GROUP_ID}&dt=${date}`;

  let response: Response;
  try {
    response = await fetch(referer, {
      signal: controller.signal,
      headers: {
        Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "User-Agent": USER_AGENT,
      },
    });
  } catch (err) {
    if (err instanceof Error && err.name === "AbortError") {
      throw new Error(`LibCal page fetch timed out for date ${date} (${referer})`);
    }
    throw err;
  } finally {
    clearTimeout(timeout);
  }

  if (!response.ok) {
    throw new Error(`LibCal page returned ${response.status}`);
  }

  const html = await response.text();
  const cookie = collectCookies(response.headers);
  return { html, cookie, referer };
}

Grid fetch (Line 434):

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  let response: Response;
  try {
    response = await fetch(`${LIBCAL_BASE}/spaces/availability/grid`, {
      method: "POST",
      signal: controller.signal,
      headers,
      body,
    });
  } catch (err) {
    if (err instanceof Error && err.name === "AbortError") {
      throw new Error(`LibCal grid fetch timed out for date ${body} (${LIBCAL_BASE}/spaces/availability/grid)`);
    }
    throw err;
  } finally {
    clearTimeout(timeout);
  }

Why try/catch instead of .finally? The .finally approach in the original patch does clear the timer correctly, but it won't let you intercept and rethrow with a descriptive timeout message — the AbortError just propagates as-is. Using a try/catch/finally block gives you both the cleanup and the human-readable error surfacing that makes debugging much easier in production logs.

@lebuckman lebuckman self-requested a review May 12, 2026 08:50
Copy link
Copy Markdown
Owner

@lebuckman lebuckman left a comment

Choose a reason for hiding this comment

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

i'd look at the CR comments, specifically the missing request timeout one for LibCal

otherwise lgtm

Comment thread backend/src/library.ts
Comment on lines +196 to +203
async function fetchLibCalPage(date: string): Promise<LibCalPageSession> {
const referer = `${LIBCAL_BASE}${LIBCAL_PAGE_PATH}?lid=${LID}&gid=${DEFAULT_GROUP_ID}&dt=${date}`;
const response = await fetch(referer, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent": USER_AGENT,
},
});
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

code rabbit called you out fr (verify behavior and check out suggested patch for comment abt the missing request timeouts for LibCal fetches)

Comment thread app/(tabs)/_layout.tsx
Comment on lines +96 to +100
if (now - lastRoomsPress.current < 300) {
navigation.setParams({ collapseAll: now });
}
lastRoomsPress.current = now;
},
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

library debounce shared with rooms: 3 line fix, worth doing to avoid weird edge cases; or test to see if any performance issues occur

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants