stable version of library with reliable reservation through webview#5
stable version of library with reliable reservation through webview#5TimiParkinson wants to merge 6 commits into
Conversation
…fixed | feat(library): properly added initial version
… results/rooms -> book/reserve] | api calls to cpp libcal currently dysfunctional
…idding issue | reserve api currently dysfunctional
📝 WalkthroughWalkthroughThis 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. ChangesLibrary Rooms Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winEncode route query parameters before fetch.
Line 38 interpolates
from/todirectly 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 winUse a neutral example value instead of a team-local LAN host.
Using a specific internal IP in
.env.examplemakes 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
⛔ Files ignored due to path filters (2)
Archive.zipis excluded by!**/*.zippackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (8)
.env.exampleapp/(tabs)/_layout.tsxapp/(tabs)/library.tsxapp/(tabs)/map.tsxbackend/src/index.tsbackend/src/library.tslib/api.tspackage.json
| if (now - lastRoomsPress.current < 300) { | ||
| navigation.setParams({ collapseAll: now }); | ||
| } | ||
| lastRoomsPress.current = now; | ||
| }, |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
library debounce shared with rooms: 3 line fix, worth doing to avoid weird edge cases; or test to see if any performance issues occur
There was a problem hiding this comment.
@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.
| <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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| { translateY: pointerY }, | ||
| { scale: pointerScale }, | ||
| ], | ||
| shadowColor: "#000", |
There was a problem hiding this comment.
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.
| } 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 }); | ||
| } |
There was a problem hiding this comment.
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".
| function validateDate(date: string): void { | ||
| if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { | ||
| throw new Error("Invalid date; expected YYYY-MM-DD"); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
code rabbit called you out fr (verify behavior and check out suggested patch for comment abt the missing request timeouts for LibCal fetches)
There was a problem hiding this comment.
🧩 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/catchinstead of.finally? The.finallyapproach in the original patch does clear the timer correctly, but it won't let you intercept and rethrow with a descriptive timeout message — theAbortErrorjust propagates as-is. Using atry/catch/finallyblock gives you both the cleanup and the human-readable error surfacing that makes debugging much easier in production logs.
lebuckman
left a comment
There was a problem hiding this comment.
i'd look at the CR comments, specifically the missing request timeout one for LibCal
otherwise lgtm
| 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, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
code rabbit called you out fr (verify behavior and check out suggested patch for comment abt the missing request timeouts for LibCal fetches)
| if (now - lastRoomsPress.current < 300) { | ||
| navigation.setParams({ collapseAll: now }); | ||
| } | ||
| lastRoomsPress.current = now; | ||
| }, |
There was a problem hiding this comment.
library debounce shared with rooms: 3 line fix, worth doing to avoid weird edge cases; or test to see if any performance issues occur
Summary by CodeRabbit