diff --git a/.env.example b/.env.example index d9cac3f..51f242d 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -EXPO_PUBLIC_API_BASE_URL=http://10.110.212.218:3000 \ No newline at end of file +EXPO_PUBLIC_API_BASE_URL=http://10.110.129.176:3000 \ No newline at end of file diff --git a/Archive.zip b/Archive.zip index 64fe733..c455f28 100644 Binary files a/Archive.zip and b/Archive.zip differ diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index d688195..9d79d07 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -82,6 +82,25 @@ export default function TabsLayout() { }, })} /> + + ( + + ), + }} + listeners={({ navigation }) => ({ + tabPress: () => { + const now = Date.now(); + if (now - lastRoomsPress.current < 300) { + navigation.setParams({ collapseAll: now }); + } + lastRoomsPress.current = now; + }, + })} + /> + ); } diff --git a/app/(tabs)/library.tsx b/app/(tabs)/library.tsx new file mode 100644 index 0000000..868b8c4 --- /dev/null +++ b/app/(tabs)/library.tsx @@ -0,0 +1,2417 @@ +/** + * app/(tabs)/library.tsx + * + * Native filter/results UI backed by BroncoPath's Express adapter. + * Final booking stays in LibCal WebView so CPP SSO is handled by CPP. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { + ActivityIndicator, + Animated, + Easing, + Linking, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { WebView, type WebViewNavigation } from "react-native-webview"; +import { Colors } from "../../constants/colors"; +import { Fonts } from "../../constants/fonts"; +import { + getLibraryAvailability, + type LibraryRoomResult, +} from "../../lib/api"; + +const LIBCAL_BASE = "https://cpp.libcal.com"; +const LID = 8262; + +const TIME_SLOTS = [ + "07:30", "08:00", "08:30", "09:00", "09:30", + "10:00", "10:30", "11:00", "11:30", + "12:00", "12:30", "13:00", "13:30", + "14:00", "14:30", "15:00", "15:30", + "16:00", "16:30", "17:00", "17:30", + "18:00", "18:30", "19:00", "19:30", + "20:00", "20:30", "21:00", +]; + +const DURATIONS = [ + { label: "30 min", value: 30 }, + { label: "1 hr", value: 60 }, + { label: "1.5 hr", value: 90 }, + { label: "2 hr", value: 120 }, + { label: "3 hr", value: 180 }, +]; + +const FLOORS = [ + { label: "Any", value: "any" }, + { label: "Floor 2", value: "2" }, + { label: "Floor 3", value: "3" }, + { label: "Floor 4", value: "4" }, + { label: "Floor 5", value: "5" }, + { label: "Floor 6", value: "6" }, +]; + +type Filters = { + date: string; + startTime: string; + duration: number; + groupSize: number; + floor: string; + needsPower: boolean; + needsADA: boolean; +}; + +type RoomWithSlots = { + room: LibraryRoomResult; + isAvailable: boolean; +}; + +type Step = "filter" | "results" | "booking"; + +type AutomationStage = + | "loading" + | "preparing" + | "scanning-date-pages" + | "selecting-slot" + | "setting-duration" + | "slot-prepared" + | "submitting-times" + | "handoff" + | "ready-for-user" + | "failed"; + +type LibCalAutomationMessage = { + type?: string; + stage?: string; + details?: unknown; +}; + +function getDays() { + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(); + d.setDate(d.getDate() + i); + return { + value: formatDateValue(d), + label: + i === 0 + ? "Today" + : d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }), + }; + }); +} + +function formatDateValue(date: Date): string { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-"); +} + +function to12h(time24: string): string { + const [hStr = "0", mStr = "00"] = time24.split(":"); + const h = parseInt(hStr, 10); + const period = h >= 12 ? "PM" : "AM"; + const hour = h % 12 || 12; + return `${hour}:${mStr} ${period}`; +} + +function toLibCalTimeLabel(time24: string): string { + return to12h(time24).replace(" ", "").toLowerCase(); +} + +function addMins(time24: string, minutes: number): string { + const [h = 0, m = 0] = time24.split(":").map(Number); + const total = h * 60 + m + minutes; + return `${String(Math.floor(total / 60) % 24).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; +} + +function addDateTime(date: string, time24: string, minutes: number): { date: string; time: string } { + const [yearText, monthText, dayText] = date.split("-"); + const [hourText, minuteText] = time24.split(":"); + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const total = hour * 60 + minute + minutes; + const dayOffset = Math.floor(total / 1440); + const minuteOfDay = ((total % 1440) + 1440) % 1440; + const adjusted = new Date(Date.UTC(year, month - 1, day + dayOffset)); + + return { + date: adjusted.toISOString().slice(0, 10), + time: `${String(Math.floor(minuteOfDay / 60)).padStart(2, "0")}:${String(minuteOfDay % 60).padStart(2, "0")}`, + }; +} + +function slotKeyTo12h(slotKey: string | null): string | null { + const time = slotKey?.split("T")[1]; + return time ? to12h(time) : null; +} + +function buildLibCalDirectUrl(date: string): string { + return `${LIBCAL_BASE}/reserve/study-rooms?lid=${LID}&gid=0&dt=${date}`; +} + +function buildInjectJS( + date: string, + startTime: string, + duration: number, + room: LibraryRoomResult, +): string { + const end = addDateTime(date, startTime, duration); + const config = { + date, + eid: room.eid, + gid: room.gid, + lid: room.lid, + roomName: room.name, + startIso: `${date}T${startTime}:00`, + startMinute: `${date}T${startTime}`, + startValueSpace: `${date} ${startTime}:00`, + startLabel: toLibCalTimeLabel(startTime), + endIso: `${end.date}T${end.time}:00`, + endMinute: `${end.date}T${end.time}`, + endValueSpace: `${end.date} ${end.time}:00`, + endLabel: toLibCalTimeLabel(end.time), + duration, + }; + + return ` +(function() { + if (window.__broncoPathLibCalStarted) return true; + window.__broncoPathLibCalStarted = true; + + var config = ${JSON.stringify(config)}; + var maxAttempts = 80; + var attempts = 0; + + function log(stage, details) { + var payload = { + type: 'broncoPathLibCal', + stage: stage, + details: details || null, + at: new Date().toISOString() + }; + + try { + console.log('[BroncoPath LibCal]', stage, details || null); + } catch (e) {} + + try { + if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { + window.ReactNativeWebView.postMessage(JSON.stringify(payload)); + } + } catch (e) {} + } + + function summarizeSlot(slot) { + if (!slot) return null; + + return { + itemId: slot.itemId, + eid: slot.eid, + id: slot.id, + start: slot.start, + normalizedStart: normalizeSlotStart(slot.start), + status: slot.status, + className: slot.className, + classNames: slot.classNames, + hasChecksum: !!slot.checksum, + checksumPrefix: slot.checksum ? String(slot.checksum).slice(0, 8) : null + }; + } + + function summarizeSlots(slots, limit) { + var output = []; + var max = Math.min(slots.length, limit || 10); + + for (var i = 0; i < max; i++) { + output.push(summarizeSlot(slots[i])); + } + + return output; + } + + function normalize(value) { + return String(value || '').toLowerCase().replace(/\s+/g, '').trim(); + } + + function normalizeSlotStart(value) { + var text = String(value || '').trim(); + + // Accept both: + // 2026-05-15 10:00:00 + // 2026-05-15T10:00:00 + text = text.replace(' ', 'T'); + + var datePart = text.slice(0, 10); + var timePart = text.slice(11, 16); + + if ( + datePart.length === 10 && + timePart.length === 5 && + datePart.charAt(4) === '-' && + datePart.charAt(7) === '-' && + timePart.charAt(2) === ':' + ) { + return datePart + 'T' + timePart; + } + + return ''; + } + + function addDays(dateText, days) { + var parts = dateText.split('-').map(Number); + var d = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2] + days)); + return d.toISOString().slice(0, 10); + } + + function phpDateTime(value) { + if (window.moment && window.springSpace && springSpace.phpDateTimeFormat) { + return moment(value).format(springSpace.phpDateTimeFormat); + } + return String(value || '').replace('T', ' '); + } + + function viewStartDate() { + try { + var timeline = window.getCurrentTimelineInstance && getCurrentTimelineInstance(config.lid); + if (timeline && timeline.view && timeline.view.activeStart && window.moment) { + return moment(timeline.view.activeStart).format('YYYY-MM-DD'); + } + } catch (e) {} + return config.date; + } + + function viewEndDate() { + try { + var timeline = window.getCurrentTimelineInstance && getCurrentTimelineInstance(config.lid); + if (timeline && timeline.view && timeline.view.activeEnd && window.moment) { + return moment(timeline.view.activeEnd).format('YYYY-MM-DD'); + } + } catch (e) {} + return addDays(config.date, 1); + } + + function currentBookings() { + try { + if (window.preparePendingBookingsPayload) return preparePendingBookingsPayload(); + } catch (e) {} + return []; + } + + function postJson(url, payload) { + return new Promise(function(resolve, reject) { + if (window.jQuery && jQuery.ajax) { + jQuery.ajax({ + type: 'post', + url: url, + data: payload, + dataType: 'json' + }).done(resolve).fail(function(xhr) { + reject(new Error((xhr && (xhr.responseText || xhr.statusText || xhr.status)) || 'LibCal AJAX request failed')); + }); + return; + } + + reject(new Error('jQuery is not available on LibCal page')); + }); + } + + function findMatchingSlotInData(data, pageIndex) { + var slots = Array.isArray(data && data.slots) ? data.slots : []; + + log('grid-response-summary', { + pageIndex: pageIndex, + slotCount: slots.length, + keys: data ? Object.keys(data) : [], + windowEnd: data && data.windowEnd, + isPreCreatedBooking: data && data.isPreCreatedBooking, + firstSlots: summarizeSlots(slots, 8) + }); + + var sawSameRoom = false; + var sawSameTime = false; + var sawSameRoomAndTime = false; + + for (var i = 0; i < slots.length; i++) { + var slot = slots[i]; + + var itemId = Number(slot.itemId || slot.eid || slot.id); + var expectedEid = Number(config.eid); + var normalizedStart = normalizeSlotStart(slot.start); + + var sameRoom = itemId === expectedEid; + var sameTime = normalizedStart === config.startMinute; + + if (sameRoom) sawSameRoom = true; + if (sameTime) sawSameTime = true; + if (sameRoom && sameTime) sawSameRoomAndTime = true; + + if (!sameRoom || !sameTime) { + continue; + } + + var classText = normalize( + [slot.className, slot.classNames].flat + ? [slot.className, slot.classNames].flat().join(' ') + : String(slot.className || '') + ' ' + String(slot.classNames || '') + ); + + var unavailable = classText.indexOf('checkout') !== -1 || + classText.indexOf('unavailable') !== -1 || + classText.indexOf('padding') !== -1 || + classText.indexOf('booked') !== -1 || + classText.indexOf('pending') !== -1 || + slot.status === 1 || + slot.status === '1'; + + log('matching-room-time-slot-found', { + pageIndex: pageIndex, + slot: summarizeSlot(slot), + classText: classText, + unavailable: unavailable, + hasChecksum: !!slot.checksum + }); + + if (!unavailable && slot.checksum) { + return slot; + } + + log('matching-slot-rejected', { + pageIndex: pageIndex, + reason: unavailable + ? 'slot-was-marked-unavailable' + : 'slot-had-no-checksum', + slot: summarizeSlot(slot), + classText: classText + }); + } + + log('no-matching-slot-on-page', { + pageIndex: pageIndex, + expectedEid: config.eid, + expectedStartMinute: config.startMinute, + sawSameRoom: sawSameRoom, + sawSameTime: sawSameTime, + sawSameRoomAndTime: sawSameRoomAndTime, + slotCount: slots.length + }); + + return null; + } + + function fetchSlotFromLibCal() { + var pageSize = Number((window.springyPage && springyPage.pageSize) || 18); + var resourceRows = Number((window.springyPage && (springyPage.resourceRows || springyPage.resourceCount)) || 0); + + var totalPages = resourceRows > 0 + ? Math.ceil(resourceRows / pageSize) + : 3; + + totalPages = Math.max(1, Math.min(totalPages, 3)); + + var pageIndexes = []; + for (var p = 0; p < totalPages; p++) { + pageIndexes.push(p); + } + + log('grid-pagination-plan', { + pageIndexes: pageIndexes, + pageSize: pageSize, + resourceRows: resourceRows, + springyPage: window.springyPage ? { + pageIndex: springyPage.pageIndex, + pageSize: springyPage.pageSize, + resourceRows: springyPage.resourceRows, + resourceCount: springyPage.resourceCount, + locationId: springyPage.locationId, + groupId: springyPage.groupId, + itemId: springyPage.itemId, + isSeatBooking: springyPage.isSeatBooking, + seatId: springyPage.seatId, + zoneId: springyPage.zoneId, + filterIds: springyPage.filterIds + } : null, + config: { + eid: config.eid, + gid: config.gid, + lid: config.lid, + roomName: config.roomName, + date: config.date, + startMinute: config.startMinute, + startIso: config.startIso, + endIso: config.endIso + } + }); + + var chain = Promise.resolve(null); + + pageIndexes.forEach(function(pageIndex) { + chain = chain.then(function(foundSlot) { + if (foundSlot) return foundSlot; + + var payload = { + lid: config.lid, + + // Important: keep your target gid/eid. + gid: config.gid, + eid: config.eid, + + seat: window.springyPage ? springyPage.isSeatBooking : 0, + seatId: window.springyPage ? springyPage.seatId : 0, + zone: window.springyPage ? springyPage.zoneId : 0, + filters: window.springyPage ? springyPage.filterIds : '', + + start: config.date, + end: addDays(config.date, 1), + bookings: currentBookings(), + pageIndex: pageIndex, + pageSize: pageSize + }; + + log('grid-request-start', { + pageIndex: pageIndex, + payload: payload + }); + + return postJson('/spaces/availability/grid', payload) + .then(function(data) { + log('grid-request-success', { + pageIndex: pageIndex, + hasData: !!data, + keys: data ? Object.keys(data) : [], + slotCount: Array.isArray(data && data.slots) ? data.slots.length : null + }); + + var slot = findMatchingSlotInData(data, pageIndex); + + if (slot) { + log('found-slot-on-page', { + pageIndex: pageIndex, + slot: summarizeSlot(slot) + }); + + return slot; + } + + return null; + }) + .catch(function(error) { + log('grid-request-failed', { + pageIndex: pageIndex, + reason: error && error.message ? error.message : String(error) + }); + + return null; + }); + }); + }); + + return chain.then(function(foundSlot) { + if (foundSlot) return foundSlot; + + throw new Error('Matching LibCal slot was not returned by /spaces/availability/grid on any pageIndex'); + }); + } + + function applyBookingAdd(slot) { + var payload = { + add: { + eid: config.eid, + seat_id: 0, + gid: config.gid, + lid: config.lid, + start: phpDateTime(config.startIso), + checksum: slot.checksum + }, + lid: config.lid, + gid: 0, + start: config.date, + end: addDays(config.date, 1), + bookings: currentBookings() + }; + + log('booking-add-request', { + payload: { + add: { + eid: payload.add.eid, + seat_id: payload.add.seat_id, + gid: payload.add.gid, + lid: payload.add.lid, + start: payload.add.start, + checksumPrefix: payload.add.checksum ? String(payload.add.checksum).slice(0, 8) : null + }, + lid: payload.lid, + gid: payload.gid, + start: payload.start, + end: payload.end, + bookingsCount: Array.isArray(payload.bookings) ? payload.bookings.length : null + }, + slot: summarizeSlot(slot) + }); + + return postJson('/spaces/availability/booking/add', payload).then(function(data) { + log('booking-add-response', { + hasData: !!data, + keys: data ? Object.keys(data) : [], + error: data && data.error, + limitIssues: data && data.limitIssues, + bookingsCount: Array.isArray(data && data.bookings) ? data.bookings.length : null, + hasGridUpdateData: !!(data && data.gridUpdateData) + }); + + if (data && data.error) throw new Error(data.error); + + try { + if (typeof pendingBookingsLimitIssues !== 'undefined') { + pendingBookingsLimitIssues = data.limitIssues || []; + } + if (window.updatePendingBookingsFromData && data && data.bookings) { + updatePendingBookingsFromData(data.bookings); + } + if (window.renderPendingRoomBookings) { + renderPendingRoomBookings(); + } + + log('booking-add-rendered-pending-bookings', { + pendingBookingsCount: typeof pendingRoomBookings !== 'undefined' + ? pendingRoomBookings.length + : null, + endSelectCount: document.querySelectorAll('select.b-end-date').length, + submitButtonExists: !!document.querySelector('#submit_times') + }); + } catch (e) { + log('booking-add-render-failed', { + reason: e.message || String(e) + }); + + throw new Error('LibCal accepted the slot, but BroncoPath could not render the pending booking: ' + e.message); + } + + return data; + }); + } + + function waitForEndSelect() { + return new Promise(function(resolve, reject) { + var tries = 0; + var timer = setInterval(function() { + tries += 1; + + var selects = Array.prototype.slice.call(document.querySelectorAll('select.b-end-date')); + + if (tries === 1 || tries % 5 === 0 || selects.length > 0) { + log('waiting-for-end-select', { + tries: tries, + selectCount: selects.length, + duration: config.duration, + formBoxVisible: !!document.querySelector('#s-lc-eq-form-box'), + pendingBookingEls: document.querySelectorAll('.s-lc-pending-booking').length, + submitButtonExists: !!document.querySelector('#submit_times') + }); + } + + if (selects.length > 0 || config.duration === 180) { + clearInterval(timer); + log('end-select-ready', { + tries: tries, + selectCount: selects.length, + duration: config.duration + }); + resolve(selects); + return; + } + + if (tries > 40) { + clearInterval(timer); + reject(new Error('LibCal end-time dropdown did not appear')); + } + }, 250); + }); + } + + function selectEndTime(selects) { + if (!selects || selects.length === 0) { + log('select-end-time-skipped', { + reason: 'no-selects', + duration: config.duration + }); + return Promise.resolve(); + } + + var targetEnd = normalizeSlotStart(config.endIso); + + log('select-end-time-started', { + targetEnd: targetEnd, + endIso: config.endIso, + endValueSpace: config.endValueSpace, + endLabel: config.endLabel, + selectCount: selects.length + }); + + for (var i = 0; i < selects.length; i++) { + var select = selects[i]; + var options = Array.prototype.slice.call(select.options || []); + + log('end-select-options', { + selectIndex: i, + optionCount: options.length, + options: options.slice(0, 12).map(function(option) { + return { + value: option.value, + normalizedValue: normalizeSlotStart(option.value), + text: option.textContent + }; + }) + }); + + for (var j = 0; j < options.length; j++) { + var option = options[j]; + var value = String(option.value || ''); + var label = normalize(option.textContent || ''); + + if ( + normalizeSlotStart(value) === targetEnd || + value.indexOf(config.endValueSpace) !== -1 || + label.indexOf(normalize(config.endLabel)) !== -1 + ) { + select.value = option.value; + select.dispatchEvent(new Event('change', { bubbles: true })); + + log('selected-end-time', { + value: option.value, + text: option.textContent, + normalizedValue: normalizeSlotStart(option.value) + }); + + return new Promise(function(resolve) { + setTimeout(resolve, 1200); + }); + } + } + } + + log('select-end-time-failed', { + targetEnd: targetEnd, + endIso: config.endIso, + endValueSpace: config.endValueSpace, + endLabel: config.endLabel + }); + + throw new Error('Requested end time was not available in LibCal dropdown'); + } + + function delayThenSubmitTimes() { + return new Promise(function(resolve) { + setTimeout(function() { + submitTimes(); + log('submit-times-invoked'); + resolve(); + }, 500); + }); + } + + function submitTimes() { + log('submit-times-started', { + submitFunctionExists: typeof window.submitPendingTimes === 'function', + formExists: !!document.querySelector('#s-lc-eq-form-times'), + buttonExists: !!document.querySelector('#submit_times'), + buttonDisabled: !!(document.querySelector('#submit_times') && document.querySelector('#submit_times').disabled), + pendingBookingsCount: typeof pendingRoomBookings !== 'undefined' ? pendingRoomBookings.length : null + }); + + if (typeof window.submitPendingTimes === 'function') { + log('submitting-times-via-libcal-function'); + window.submitPendingTimes(); + return; + } + + var form = document.querySelector('#s-lc-eq-form-times'); + if (form) { + log('submitting-times-via-form-submit-event'); + + var event; + if (typeof Event === 'function') { + event = new Event('submit', { bubbles: true, cancelable: true }); + } else { + event = document.createEvent('Event'); + event.initEvent('submit', true, true); + } + + form.dispatchEvent(event); + return; + } + + var button = document.querySelector('#submit_times'); + if (!button || button.disabled) { + throw new Error('Submit Times button is unavailable'); + } + + log('submitting-times-via-button'); + button.scrollIntoView({ block: 'center' }); + button.click(); + } + + function datePhrase() { + var d = new Date(config.date + 'T00:00:00'); + return d.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }); + } + + function findMatchingDomSlot() { + var wantedRoom = normalize(config.roomName); + var wantedTime = normalize(config.startLabel); + var wantedDate = normalize(datePhrase()); + var candidates = Array.prototype.slice.call( + document.querySelectorAll('a.s-lc-eq-avail[title], a.s-lc-eq-avail[aria-label]') + ); + + for (var i = 0; i < candidates.length; i++) { + var el = candidates[i]; + var text = normalize(el.getAttribute('title') || el.getAttribute('aria-label') || el.textContent || ''); + if (text.indexOf(wantedRoom) !== -1 && text.indexOf(wantedTime) !== -1 && text.indexOf(wantedDate) !== -1) { + return el; + } + } + + return null; + } + + function domClickFallback() { + log('dom-click-fallback-started'); + var timer = setInterval(function() { + attempts += 1; + var slot = findMatchingDomSlot(); + if (!slot) { + if (attempts > maxAttempts) { + clearInterval(timer); + log('failed', { reason: 'Matching visible DOM slot not found' }); + } + return; + } + + clearInterval(timer); + slot.scrollIntoView({ block: 'center', inline: 'center' }); + slot.click(); + waitForEndSelect() + .then(selectEndTime) + .then(delayThenSubmitTimes) + .catch(function(error) { log('failed', { reason: error.message || String(error) }); }); + }, 250); + } + + function start() { + if (location.hostname !== 'cpp.libcal.com' || location.pathname.indexOf('/reserve/') === -1) { + return; + } + + if (!window.jQuery || !window.moment || !window.springyPage) { + attempts += 1; + if (attempts > maxAttempts) { + log('failed', { reason: 'LibCal scripts did not finish loading' }); + return; + } + setTimeout(start, 250); + return; + } + + log('direct-libcal-flow-started', config); + + fetchSlotFromLibCal() + .then(function(slot) { + log('found-slot-checksum'); + return applyBookingAdd(slot); + }) + .then(waitForEndSelect) + .then(selectEndTime) + .then(function() { + setTimeout(submitTimes, 500); + }) + .catch(function(error) { + log('direct-flow-failed', { reason: error.message || String(error) }); + domClickFallback(); + }); + } + + start(); +})(); +true; + `.trim(); +} + + +function stageFromLibCalMessage(stage: string | undefined): AutomationStage { + switch (stage) { + case "direct-libcal-flow-started": + return "preparing"; + case "syncing-date-page": + case "scanning-date-pages": + return "scanning-date-pages"; + case "fetching-slot": + case "found-slot-checksum": + case "adding-pending-booking": + case "dom-click-fallback-started": + return "selecting-slot"; + case "selecting-end-time": + case "selected-end-time": + return "setting-duration"; + case "slot-prepared": + return "slot-prepared"; + case "submitting-times": + return "submitting-times"; + case "sso-redirect-ready": + return "handoff"; + case "booking-form-ready": + return "ready-for-user"; + case "manual-needed": + case "failed": + return "failed"; + default: + return "preparing"; + } +} + +function detailToText(details: unknown): string | null { + if (!details || typeof details !== "object") return null; + const maybeReason = (details as { reason?: unknown }).reason; + if (typeof maybeReason === "string" && maybeReason.trim()) return maybeReason; + return null; +} + +function isLikelySsoOrCheckoutUrl(url: string): boolean { + const lower = url.toLowerCase(); + if (!lower) return false; + + if ( + lower.includes("libauth") || + lower.includes("sso") || + lower.includes("saml") || + lower.includes("shibboleth") || + lower.includes("/login") || + lower.includes("/auth") || + lower.includes("idp") || + lower.includes("okta") + ) { + return true; + } + + try { + const parsed = new URL(url); + return parsed.hostname.length > 0 && parsed.hostname !== "cpp.libcal.com"; + } catch { + return false; + } +} + +export default function LibraryScreen() { + const days = useMemo(() => getDays(), []); + + const [step, setStep] = useState("filter"); + const [filters, setFilters] = useState({ + date: days[0]?.value ?? formatDateValue(new Date()), + startTime: "10:00", + duration: 60, + groupSize: 2, + floor: "any", + needsPower: false, + needsADA: false, + }); + + const [loading, setLoading] = useState(false); + const [apiError, setApiError] = useState(null); + const [results, setResults] = useState([]); + const [bookingRoom, setBookingRoom] = useState(null); + const [automationStage, setAutomationStage] = useState("loading"); + const [automationDetail, setAutomationDetail] = useState(null); + const [webViewVisible, setWebViewVisible] = useState(false); + + const slideAnim = useRef(new Animated.Value(0)).current; + const revealTimerRef = useRef | null>(null); + const hardRevealTimerRef = useRef | null>(null); + + const animateTo = useCallback((direction: 1 | -1) => { + slideAnim.setValue(direction * 40); + Animated.spring(slideAnim, { + toValue: 0, + useNativeDriver: true, + tension: 120, + friction: 14, + }).start(); + }, [slideAnim]); + + const clearRevealTimer = useCallback(() => { + if (revealTimerRef.current) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + }, []); + + const clearHardRevealTimer = useCallback(() => { + if (hardRevealTimerRef.current) { + clearTimeout(hardRevealTimerRef.current); + hardRevealTimerRef.current = null; + } + }, []); + + const revealWebViewForUser = useCallback((stage: AutomationStage = "ready-for-user", detail?: string) => { + clearRevealTimer(); + clearHardRevealTimer(); + if (detail) setAutomationDetail(detail); + setAutomationStage(stage); + setWebViewVisible(true); + }, [clearHardRevealTimer, clearRevealTimer]); + + const scheduleWebViewReveal = useCallback((delayMs: number) => { + clearRevealTimer(); + revealTimerRef.current = setTimeout(() => { + revealWebViewForUser("ready-for-user"); + }, delayMs); + }, [clearRevealTimer, revealWebViewForUser]); + + const scheduleHardReveal = useCallback((delayMs: number, detail: string) => { + clearHardRevealTimer(); + hardRevealTimerRef.current = setTimeout(() => { + revealWebViewForUser("failed", detail); + }, delayMs); + }, [clearHardRevealTimer, revealWebViewForUser]); + + useEffect(() => () => { + clearRevealTimer(); + clearHardRevealTimer(); + }, [clearHardRevealTimer, clearRevealTimer]); + + async function handleSearch() { + setStep("results"); + setLoading(true); + setApiError(null); + setResults([]); + animateTo(1); + + try { + console.log("Searching for library rooms with filters:", filters); + const rooms = await getLibraryAvailability(filters); + console.log("Received library availability results:", rooms); + setResults(rooms.map((room) => ({ room, isAvailable: room.isAvailable }))); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown LibCal error"; + setApiError(message); + } finally { + setLoading(false); + } + } + + function openBooking(room: LibraryRoomResult) { + clearRevealTimer(); + clearHardRevealTimer(); + setBookingRoom(room); + setAutomationStage("loading"); + setAutomationDetail(null); + setWebViewVisible(false); + animateTo(1); + setStep("booking"); + scheduleHardReveal(30000, "LibCal did not finish the automatic handoff. The WebView is open so you can complete it manually."); + } + + function goBack() { + animateTo(-1); + if (step === "booking") { + clearRevealTimer(); + clearHardRevealTimer(); + setStep("results"); + setBookingRoom(null); + setAutomationStage("loading"); + setAutomationDetail(null); + setWebViewVisible(false); + } else { + setStep("filter"); + } + } + + function handleLibCalMessage(event: { nativeEvent: { data: string } }) { + let message: LibCalAutomationMessage; + try { + message = JSON.parse(event.nativeEvent.data) as LibCalAutomationMessage; + } catch { + return; + } + + if (message.type !== "broncoPathLibCal") return; + + const nextStage = stageFromLibCalMessage(message.stage); + setAutomationDetail(detailToText(message.details)); + + if (message.stage === "slot-prepared") { + setAutomationStage("slot-prepared"); + scheduleHardReveal(20000, "The slot was selected, but LibCal did not finish opening CPP SSO. Continue manually in the WebView."); + return; + } + + if (message.stage === "submitting-times") { + setAutomationStage("submitting-times"); + scheduleHardReveal(20000, "LibCal did not return an SSO handoff. The WebView is open so you can press Submit Times manually."); + return; + } + + if (message.stage === "sso-redirect-ready") { + clearHardRevealTimer(); + setAutomationStage("handoff"); + scheduleWebViewReveal(1500); + return; + } + + if (message.stage === "booking-form-ready") { + revealWebViewForUser("ready-for-user"); + return; + } + + if (nextStage === "failed") { + clearHardRevealTimer(); + revealWebViewForUser("failed"); + return; + } + + setAutomationStage(nextStage); + } + + function handleBookingNavigation(nav: WebViewNavigation) { + if (isLikelySsoOrCheckoutUrl(nav.url)) { + revealWebViewForUser("ready-for-user"); + } + } + + const bookingUrl = bookingRoom?.bookingUrl ?? ""; + const showChromeHeader = step !== "filter" && !(step === "booking" && !webViewVisible); + + return ( + + {showChromeHeader && ( + + + + + + {step === "results" + ? "Available Rooms" + : bookingRoom?.name ?? "Reserve Room"} + + {step === "results" && ( + + {results.filter((r) => r.isAvailable).length} open + + )} + + )} + + + {step === "filter" && ( + setFilters((prev) => ({ ...prev, ...patch }))} + onSearch={handleSearch} + /> + )} + + {step === "results" && ( + Linking.openURL(buildLibCalDirectUrl(filters.date))} + /> + )} + + {step === "booking" && bookingRoom && ( + + )} + + + ); +} + +type FilterStepProps = { + days: { value: string; label: string }[]; + filters: Filters; + onChange: (patch: Partial) => void; + onSearch: () => void; +}; + +function FilterStep({ days, filters, onChange, onSearch }: FilterStepProps) { + const endTime = addMins(filters.startTime, filters.duration); + const [searchPressed, setSearchPressed] = useState(false); + + return ( + + + Library Rooms + + + Pick a date, time, capacity, and room specs. BroncoPath checks availability; CPP handles SSO. + + + Date + + {days.map((d) => ( + onChange({ date: d.value })} + /> + ))} + + + Start time + + {TIME_SLOTS.map((t) => ( + onChange({ startTime: t })} + /> + ))} + + + Duration + + {DURATIONS.map((d) => ( + onChange({ duration: d.value })} + /> + ))} + + + + {to12h(filters.startTime)} – {to12h(endTime)} + + + Group size + + onChange({ groupSize: Math.max(2, filters.groupSize - 1) })} + /> + + + {filters.groupSize} + + + = 9} + onPress={() => onChange({ groupSize: Math.min(9, filters.groupSize + 1) })} + /> + + + CPP group rooms require 2 – 9 students + + + Preferred floor + + {FLOORS.map((f) => ( + onChange({ floor: f.value })} + /> + ))} + + + Preferences + onChange({ needsPower: !filters.needsPower })} + /> + onChange({ needsADA: !filters.needsADA })} + /> + + 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, + }} + > + + + Find Available Rooms + + + + ); +} + +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 ( + + + + Checking LibCal through BroncoPath… + + + ); + } + + if (error) { + return ( + + + + Couldn't read LibCal availability + + + The backend could not reach or parse LibCal. Open LibCal directly to continue with CPP's booking page. + + + + {error} + + + ); + } + + 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 ( + + + + + {summaryParts} + + + + {available.length === 0 && unavailable.length === 0 && ( + + )} + + {available.length > 0 && ( + <> + Available ({available.length}) + {available.map(({ room }) => ( + onBook(room)} + /> + ))} + + )} + + {unavailable.length > 0 && ( + <> + 0 ? 24 : 0 }}> + Matching specs, unavailable now ({unavailable.length}) + + {unavailable.map(({ room }) => ( + onBook(room)} + /> + ))} + + )} + + + + + Browse full LibCal calendar + + + + ); +} + +function EmptyResults({ onOpenDirect }: { onOpenDirect: () => void }) { + return ( + + + + No rooms matched + + + Try another floor, group size, time, or preference combination. + + + + + Open LibCal directly + + + + ); +} + +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 ( + + {webViewVisible && ( + + + + {room.name} + + + {to12h(filters.startTime)}-{to12h(endTime)} + + + )} + + + + + + + {!webViewVisible && ( + + )} + + + {webViewVisible && ( + + + + {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."} + + + )} + + ); +} + +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 ( + + + + + + + + + + LibCal handoff + + + + + + + + 1 → preparing your reservation + + + Filling LibCal for you. + + + {prepStageCopy(stage)} + + + + + {visualCards.map((card) => ( + + ))} + + + + + + + + {stage === "failed" ? ( + + ) : ( + + )} + + + {prepStageLabel(stage)} + + + {detail ?? prepStageDetail(stage)} + + + + + + ); +} + +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 ( + + + + + {displayIndex + 1} → + + + {card.prompt} + + + + {isSubmit ? ( + + + + {card.answer} + + + ) : ( + + + + + + + {card.answer} + + + {card.hint} + + + + )} + + {isSubmit && ( + + {card.hint} + + )} + + + ); +} + +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 ( + + + + {children} + + + ); +} + +function HScroll({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Chip({ + label, + selected, + onPress, +}: { + label: string; + selected: boolean; + onPress: () => void; +}) { + const [pressed, setPressed] = useState(false); + return ( + 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, + }} + > + + {label} + + + ); +} + +function StepperBtn({ + icon, + disabled, + onPress, +}: { + icon: "plus" | "minus"; + disabled: boolean; + onPress: () => void; +}) { + const [pressed, setPressed] = useState(false); + return ( + setPressed(true)} + onPressOut={() => setPressed(false)} + disabled={disabled} + style={{ + paddingHorizontal: 18, + paddingVertical: 14, + backgroundColor: pressed ? Colors.cardHover : "transparent", + opacity: disabled ? 0.35 : 1, + }} + > + + + ); +} + +function Toggle({ + label, + icon, + value, + onPress, +}: { + label: string; + icon: keyof typeof Feather.glyphMap; + value: boolean; + onPress: () => void; +}) { + const [pressed, setPressed] = useState(false); + return ( + 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, + }} + > + + + {label} + + + + + + ); +} + +function SectionLabel({ children, style }: { children: ReactNode; style?: object }) { + return ( + + {children} + + ); +} + +function RoomCard({ + room, + available, + onBook, +}: { + room: LibraryRoomResult; + available: boolean; + onBook: () => void; +}) { + const [pressed, setPressed] = useState(false); + const nextAvailable = slotKeyTo12h(room.nextAvailableStart); + + return ( + + + + + + + + {room.name} + + + {room.grouping} + + + {room.floor && ( + + + + Floor {room.floor} + + + )} + {room.capacity > 0 && ( + + + + Up to {room.capacity} + + + )} + {!available && nextAvailable && ( + + + + Next {nextAvailable} + + + )} + + + + + + {available ? "Open" : "Taken"} + + + + + {(room.hasPower || room.isADA) && ( + + {room.hasPower && } + {room.isADA && } + + )} + + 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, + }} + > + + + {available ? "Reserve via LibCal" : "Check on LibCal"} + + + + + ); +} + +function AttributeBadge({ + icon, + label, + color, +}: { + icon: keyof typeof Feather.glyphMap; + label: string; + color: string; +}) { + return ( + + + {label} + + ); +} + +function ActionButton({ + icon, + label, + onPress, +}: { + icon: keyof typeof Feather.glyphMap; + label: string; + onPress: () => void; +}) { + const [pressed, setPressed] = useState(false); + + return ( + 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, + }} + > + + + {label} + + + ); +} diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index fbd2226..13ddbf4 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -58,8 +58,7 @@ function areSameRoute(a: RoutingGraphEdge[], b: RoutingGraphEdge[]) { export default function MapScreen() { const userLocation = useUserLocation(3); - const [useCurrentLocationAsStart, setUseCurrentLocationAsStart] = - useState(true); + const [useCurrentLocationAsStart, setUseCurrentLocationAsStart] = useState(true); const [routeSeedLocation, setRouteSeedLocation] = useState< [number, number] | null >(null); diff --git a/backend/src/index.ts b/backend/src/index.ts index efd0e80..d92f8b1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,7 @@ config({ path: '.env.local', override: true }); import buildingsRouter from './buildings.ts'; import campusGraphRouter from "./campusGraph.ts"; import schedulesRouter from "./schedules.ts"; +import libraryRouter from "./library.ts"; const app = express(); const PORT = process.env.PORT ?? 3000; @@ -23,6 +24,8 @@ app.use("/api/campus-graph", campusGraphRouter); app.use("/api/schedules", schedulesRouter); +app.use("/api/library", libraryRouter); + app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); \ No newline at end of file diff --git a/backend/src/library.ts b/backend/src/library.ts new file mode 100644 index 0000000..cbd57a4 --- /dev/null +++ b/backend/src/library.ts @@ -0,0 +1,608 @@ +import { Router } from "express"; +import * as cheerio from "cheerio"; + +const router = Router(); + +const LIBCAL_BASE = "https://cpp.libcal.com"; +const LIBCAL_PAGE_PATH = "/reserve/study-rooms"; +const LID = 8262; +const DEFAULT_GROUP_ID = 0; +const DEFAULT_ITEM_ID = -1; +const DEFAULT_SEAT = 0; +const DEFAULT_SEAT_ID = 0; +const DEFAULT_ZONE_ID = 0; +const DEFAULT_PAGE_SIZE = 18; +const METADATA_TTL_MS = 30 * 60 * 1000; +const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/17.0 Safari/605.1.15"; + +const ADA_FILTER_ID = 1021; +const POWER_FILTER_ID = 1022; + +const GROUP_ID_BY_GROUPING: Record = { + "Second Floor Study Rooms": 14784, + "Third Floor Study Rooms": 14797, + "Fourth Floor Study Rooms": 14785, + "Fifth Floor Study Rooms": 14786, + "Sixth Floor Study Rooms": 14795, +}; + +type LibraryAvailabilityQuery = { + date: string; + startTime: string; + duration: number; + groupSize: number; + floor: string; + needsPower: boolean; + needsADA: boolean; +}; + +type LibCalPageSession = { + html: string; + cookie: string; + referer: string; +}; + +type LibCalMetadata = { + rooms: LibCalRoom[]; + resourceRows: number; + pageSize: number; + fetchedAt: number; +}; + +type LibCalRoom = { + id: number; + eid: number; + gid: number; + lid: number; + name: string; + title: string; + url: string; + grouping: string; + capacity: number; + floor: string | null; + hasPower: boolean; + isADA: boolean; + pageIndex: number; +}; + +type LibCalGridSlot = { + itemId?: number | string; + start?: string; + end?: string; + className?: string | string[] | null; + classNames?: string[] | string | null; + status?: number | string; + checksum?: string; +}; + +type LibraryRoomResult = LibCalRoom & { + isAvailable: boolean; + availableStarts: string[]; + nextAvailableStart: string | null; + bookingUrl: string; +}; + +let metadataCache: LibCalMetadata | null = null; + +router.get("/availability", async (req, res) => { + console.log("Received library availability request with query:", req.query); + try { + const query = parseAvailabilityQuery(req.query as Record); + console.log("Library availability query:", query); + const session = await fetchLibCalPage(query.date); + console.log("Fetched LibCal page for date", query.date); + const metadata = getMetadata(session.html); + console.log("Parsed LibCal metadata with", metadata.rooms.length, "rooms and", metadata.resourceRows, "resource rows"); + const slots = await fetchAllGridSlots(session, metadata, query.date); + console.log("Fetched LibCal grid with", slots.length, "slots for date", query.date); + const results = buildResults(metadata.rooms, slots, query); + + res.setHeader("Cache-Control", "no-store"); + res.json(results); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown library availability error"; + const status = message.startsWith("Invalid ") || message.includes("required") ? 400 : 502; + + console.error("Library availability failed:", message); + res.status(status).json({ error: message }); + } +}); + +router.get("/rooms", async (req, res) => { + try { + const date = readString(req.query.date) ?? formatDateLocal(new Date()); + validateDate(date); + + const session = await fetchLibCalPage(date); + const metadata = getMetadata(session.html); + + res.setHeader("Cache-Control", "public, max-age=1800"); + res.json(metadata.rooms); + } 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 }); + } +}); + +function parseAvailabilityQuery(raw: Record): LibraryAvailabilityQuery { + const date = readString(raw.date) ?? ""; + const startTime = readString(raw.startTime) ?? ""; + const duration = readNumber(raw.duration, 60); + const groupSize = readNumber(raw.groupSize, 2); + const floor = readString(raw.floor) ?? "any"; + const needsPower = readBoolean(raw.needsPower); + const needsADA = readBoolean(raw.needsADA); + + validateDate(date); + validateStartTime(startTime); + + if (!Number.isInteger(duration) || duration < 30 || duration > 180 || duration % 30 !== 0) { + throw new Error("Invalid duration; expected 30-180 minutes in 30-minute increments"); + } + + if (!Number.isInteger(groupSize) || groupSize < 2 || groupSize > 9) { + throw new Error("Invalid groupSize; expected 2-9"); + } + + if (floor !== "any" && !/^[2-6]$/.test(floor)) { + throw new Error("Invalid floor; expected any or 2-6"); + } + + return { date, startTime, duration, groupSize, floor, needsPower, needsADA }; +} + +function readString(value: unknown): string | undefined { + if (Array.isArray(value)) { + return readString(value[0]); + } + return typeof value === "string" ? value.trim() : undefined; +} + +function readNumber(value: unknown, fallback: number): number { + const text = readString(value); + if (!text) return fallback; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function readBoolean(value: unknown): boolean { + const text = readString(value)?.toLowerCase(); + return text === "true" || text === "1" || text === "yes"; +} + +function validateDate(date: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error("Invalid date; expected YYYY-MM-DD"); + } +} + +function validateStartTime(startTime: string): void { + if (!/^\d{2}:\d{2}$/.test(startTime)) { + throw new Error("Invalid startTime; expected HH:MM"); + } + + const [hourText, minuteText] = startTime.split(":"); + const hour = Number(hourText); + const minute = Number(minuteText); + + if (hour < 0 || hour > 23 || (minute !== 0 && minute !== 30)) { + throw new Error("Invalid startTime; expected a half-hour HH:MM value"); + } +} + +async function fetchLibCalPage(date: string): Promise { + 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, + }, + }); + + 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 }; +} + +function collectCookies(headers: Headers): string { + const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }; + const cookies = withGetSetCookie.getSetCookie?.() ?? []; + const fallback = headers.get("set-cookie"); + + if (cookies.length > 0) { + return cookies.map((cookie) => cookie.split(";")[0]).filter(Boolean).join("; "); + } + + return fallback ? fallback.split(/,(?=[^;]+?=)/).map((cookie) => cookie.split(";")[0]).filter(Boolean).join("; ") : ""; +} + +function getMetadata(html: string): LibCalMetadata { + const now = Date.now(); + + if (metadataCache && now - metadataCache.fetchedAt < METADATA_TTL_MS) { + return metadataCache; + } + + const parsed = parseMetadata(html); + + if (parsed.rooms.length === 0) { + throw new Error("LibCal room metadata was not found in the public page"); + } + + const nextCache = { ...parsed, fetchedAt: now }; + metadataCache = nextCache; + return nextCache; +} + +function parseMetadata(html: string): Omit { + const parsedPageSize = numberFromPattern(html, /pageSize:\s*(\d+)/) ?? DEFAULT_PAGE_SIZE; + const pageSize = parsedPageSize > 0 ? parsedPageSize : DEFAULT_PAGE_SIZE; + const resourceRows = numberFromPattern(html, /resourceRows:\s*(\d+)/) ?? 0; + const scriptRooms = parseRoomsFromResourceScript(html, pageSize); + const rooms = scriptRooms.length > 0 ? scriptRooms : parseRoomsFromRenderedDom(html, pageSize); + + return { + rooms, + resourceRows: resourceRows || rooms.length, + pageSize, + }; +} + +function parseRoomsFromResourceScript(html: string, pageSize: number): LibCalRoom[] { + const rooms: LibCalRoom[] = []; + const resourcePattern = /resources\.push\(\s*\{([\s\S]*?)\}\s*\);/g; + let match: RegExpExecArray | null; + + while ((match = resourcePattern.exec(html)) !== null) { + const block = match[1] ?? ""; + const eid = numberField(block, "eid"); + const gid = numberField(block, "gid"); + const lid = numberField(block, "lid") ?? LID; + const capacity = numberField(block, "capacity") ?? 0; + const title = stringField(block, "title") ?? "Study Room"; + const name = title.replace(/\s*\(Capacity\s+\d+\)\s*$/i, "").trim(); + const filterIds = numberArrayField(block, "filterIds"); + const grouping = stringField(block, "grouping") ?? groupingForFloor(extractFloor(name)); + const url = stringField(block, "url") ?? `/space/${eid ?? ""}`; + + if (eid === null || gid === null) continue; + + rooms.push({ + id: eid, + eid, + gid, + lid, + name, + title, + url, + grouping, + capacity, + floor: extractFloor(name), + hasPower: filterIds.includes(POWER_FILTER_ID), + isADA: filterIds.includes(ADA_FILTER_ID), + pageIndex: 0, + }); + } + + return assignGroupPageIndexes(rooms, pageSize); +} + +function parseRoomsFromRenderedDom(html: string, pageSize: number): LibCalRoom[] { + const $ = cheerio.load(html); + const rooms: LibCalRoom[] = []; + let currentGrouping = "Group Study Rooms"; + + $("table.fc-datagrid-body tbody tr").each((_index: number, row: unknown) => { + const groupText = $(row).find(".fc-resource-group .fc-datagrid-cell-main").text().trim(); + if (groupText) { + currentGrouping = groupText; + return; + } + + const resourceCell = $(row).find("td.fc-resource[data-resource-id]").first(); + if (resourceCell.length === 0) return; + + const resourceId = String(resourceCell.attr("data-resource-id") ?? ""); + const eidMatch = resourceId.match(/eid_(\d+)/); + const eid = eidMatch ? Number(eidMatch[1]) : NaN; + if (!Number.isFinite(eid)) return; + + const title = resourceCell.find(".fc-cell-text").text().replace(/\s+/g, " ").trim(); + const capacityMatch = title.match(/Capacity\s+(\d+)/i); + const capacity = capacityMatch ? Number(capacityMatch[1]) : 0; + const name = title.replace(/\s*\(Capacity\s+\d+\)\s*$/i, "").trim(); + const className = resourceCell.find(".fc-cell-text").attr("class") ?? ""; + const gid = GROUP_ID_BY_GROUPING[currentGrouping] ?? DEFAULT_GROUP_ID; + + rooms.push({ + id: eid, + eid, + gid, + lid: LID, + name, + title, + url: `/space/${eid}`, + grouping: currentGrouping, + capacity, + floor: extractFloor(name), + hasPower: className.includes(`s-lc-filter-${POWER_FILTER_ID}`), + isADA: className.includes(`s-lc-filter-${ADA_FILTER_ID}`), + pageIndex: 0, + }); + }); + + return assignGroupPageIndexes(rooms, pageSize); +} + +function assignGroupPageIndexes(rooms: LibCalRoom[], pageSize: number): LibCalRoom[] { + return rooms.map((room, index) => ({ + ...room, + pageIndex: Math.floor(index / pageSize), + })); +} + +function numberField(block: string, field: string): number | null { + const match = block.match(new RegExp(`${field}:\\s*(-?\\d+)`)); + return match?.[1] ? Number(match[1]) : null; +} + +function stringField(block: string, field: string): string | null { + const match = block.match(new RegExp(`${field}:\\s*"((?:\\\\.|[^"\\\\])*)"`)); + return match?.[1] ? decodeJsString(match[1]) : null; +} + +function numberArrayField(block: string, field: string): number[] { + const match = block.match(new RegExp(`${field}:\\s*\\[([^\\]]*)\\]`)); + if (!match?.[1]) return []; + + return match[1] + .split(",") + .map((item) => Number(item.trim())) + .filter((item) => Number.isFinite(item)); +} + +function decodeJsString(value: string): string { + try { + return JSON.parse(`"${value.replace(/"/g, '\\"')}"`) as string; + } catch { + return value.replace(/\\u0020/g, " ").replace(/\\\//g, "/"); + } +} + +function numberFromPattern(source: string, pattern: RegExp): number | null { + const match = source.match(pattern); + return match?.[1] ? Number(match[1]) : null; +} + +async function fetchAllGridSlots( + session: LibCalPageSession, + metadata: LibCalMetadata, + date: string, +): Promise { + const pageCount = Math.max(1, Math.ceil(metadata.resourceRows / metadata.pageSize)); + const slots: LibCalGridSlot[] = []; + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + const pageSlots = await fetchGridPage(session, date, pageIndex, metadata.pageSize); + slots.push(...pageSlots); + } + + return slots; +} + +async function fetchGridPage( + session: LibCalPageSession, + date: string, + pageIndex: number, + pageSize: number, +): Promise { + const body = new URLSearchParams({ + lid: String(LID), + gid: String(DEFAULT_GROUP_ID), + eid: String(DEFAULT_ITEM_ID), + seat: String(DEFAULT_SEAT), + seatId: String(DEFAULT_SEAT_ID), + zone: String(DEFAULT_ZONE_ID), + filters: "", + start: date, + end: addDays(date, 1), + pageIndex: String(pageIndex), + pageSize: String(pageSize), + }); + + const headers: Record = { + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "User-Agent": USER_AGENT, + Origin: LIBCAL_BASE, + Referer: session.referer, + "X-Requested-With": "XMLHttpRequest", + }; + + if (session.cookie) { + headers.Cookie = session.cookie; + } + + const response = await fetch(`${LIBCAL_BASE}/spaces/availability/grid`, { + method: "POST", + headers, + body, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`LibCal grid returned ${response.status}${text ? `: ${text.slice(0, 180)}` : ""}`); + } + + const data = (await response.json()) as { slots?: unknown }; + return Array.isArray(data.slots) ? (data.slots as LibCalGridSlot[]) : []; +} + +function buildResults( + rooms: LibCalRoom[], + slots: LibCalGridSlot[], + query: LibraryAvailabilityQuery, +): LibraryRoomResult[] { + const availableStartMap = buildAvailableStartMap(slots, query.date); + const requiredKeys = buildRequiredSlotKeys(query.date, query.startTime, query.duration); + + return rooms + .filter((room) => roomMatchesFilters(room, query)) + .map((room) => { + const starts = [...(availableStartMap.get(room.eid) ?? new Set())].sort(); + const startSet = new Set(starts); + const isAvailable = requiredKeys.every((key) => startSet.has(key)); + + return { + ...room, + isAvailable, + availableStarts: starts, + nextAvailableStart: starts.find((start) => start >= `${query.date}T${query.startTime}`) ?? starts[0] ?? null, + bookingUrl: `${LIBCAL_BASE}${LIBCAL_PAGE_PATH}?lid=${LID}&gid=${room.gid}&eid=${room.eid}&dt=${query.date}`, + }; + }) + .sort((a, b) => { + if (a.isAvailable !== b.isAvailable) return a.isAvailable ? -1 : 1; + const floorCompare = (a.floor ?? "9").localeCompare(b.floor ?? "9"); + if (floorCompare !== 0) return floorCompare; + return naturalRoomCompare(a.name, b.name); + }); +} + +function roomMatchesFilters(room: LibCalRoom, query: LibraryAvailabilityQuery): boolean { + if (query.floor !== "any" && room.floor !== query.floor) return false; + if (room.capacity > 0 && query.groupSize > room.capacity) return false; + if (query.needsPower && !room.hasPower) return false; + if (query.needsADA && !room.isADA) return false; + return true; +} + +function buildAvailableStartMap(slots: LibCalGridSlot[], date: string): Map> { + const result = new Map>(); + + for (const slot of slots) { + const itemId = Number(slot.itemId); + if (!Number.isFinite(itemId) || !isSlotAvailable(slot)) continue; + + const key = normalizeSlotStart(slot.start); + if (!key || !key.startsWith(`${date}T`)) continue; + + const existing = result.get(itemId) ?? new Set(); + existing.add(key); + result.set(itemId, existing); + } + + return result; +} + +function isSlotAvailable(slot: LibCalGridSlot): boolean { + const classText = [slot.className, slot.classNames] + .flat() + .filter((item): item is string => typeof item === "string") + .join(" ") + .toLowerCase(); + + if ( + classText.includes("checkout") || + classText.includes("unavailable") || + classText.includes("padding") || + classText.includes("booked") || + classText.includes("pending") + ) { + return false; + } + + if (slot.status === 1 || slot.status === "1") return false; + if (slot.status === 0 || slot.status === "0") return true; + if (classText.includes("s-lc-eq-avail") || classText.includes("available")) return true; + + return classText.length === 0; +} + +function normalizeSlotStart(start: unknown): string | null { + if (typeof start !== "string") return null; + + const match = start.match(/(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/); + if (!match?.[1] || !match[2]) return null; + + return `${match[1]}T${match[2]}`; +} + +function buildRequiredSlotKeys(date: string, startTime: string, duration: number): string[] { + const keys: string[] = []; + + for (let offset = 0; offset < duration; offset += 30) { + keys.push(addMinutesToDateTime(date, startTime, offset)); + } + + return keys; +} + +function addMinutesToDateTime(date: string, time: string, minutes: number): string { + const [yearText, monthText, dayText] = date.split("-"); + const [hourText, minuteText] = time.split(":"); + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const totalMinutes = hour * 60 + minute + minutes; + const dayOffset = Math.floor(totalMinutes / 1440); + const minuteOfDay = ((totalMinutes % 1440) + 1440) % 1440; + const adjustedDate = new Date(Date.UTC(year, month - 1, day + dayOffset)); + const adjustedDateText = adjustedDate.toISOString().slice(0, 10); + const adjustedHour = Math.floor(minuteOfDay / 60); + const adjustedMinute = minuteOfDay % 60; + + return `${adjustedDateText}T${String(adjustedHour).padStart(2, "0")}:${String(adjustedMinute).padStart(2, "0")}`; +} + +function addDays(date: string, days: number): string { + const [yearText, monthText, dayText] = date.split("-"); + const adjustedDate = new Date(Date.UTC(Number(yearText), Number(monthText) - 1, Number(dayText) + days)); + return adjustedDate.toISOString().slice(0, 10); +} + +function formatDateLocal(date: Date): string { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-"); +} + +function extractFloor(roomName: string): string | null { + const match = roomName.match(/^([2-6])/); + return match?.[1] ?? null; +} + +function groupingForFloor(floor: string | null): string { + switch (floor) { + case "2": + return "Second Floor Study Rooms"; + case "3": + return "Third Floor Study Rooms"; + case "4": + return "Fourth Floor Study Rooms"; + case "5": + return "Fifth Floor Study Rooms"; + case "6": + return "Sixth Floor Study Rooms"; + default: + return "Group Study Rooms"; + } +} + +function naturalRoomCompare(a: string, b: string): number { + return a.localeCompare(b, "en-US", { numeric: true, sensitivity: "base" }); +} + +export default router; diff --git a/lib/api.ts b/lib/api.ts index bd529ed..fa3c259 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,6 +1,6 @@ import type { Building, Room, RouteOption } from '../constants/mockData'; -const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://10.110.212.218:3000'; +const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://10.110.129.176:3000'; export async function getBuildings(): Promise { const response = await fetch(`${BASE_URL}/api/buildings`); @@ -78,6 +78,54 @@ export async function fetchClassSchedule(): Promise { return res.json(); } + +export async function getLibraryAvailability(query: LibraryAvailabilityQuery): Promise { + const params = new URLSearchParams({ + date: query.date, + startTime: query.startTime, + duration: String(query.duration), + groupSize: String(query.groupSize), + floor: query.floor, + needsPower: String(query.needsPower), + needsADA: String(query.needsADA), + }); + + console.log("Fetching library availability with params:", params.toString()); + const response = await fetch(`${BASE_URL}/api/library/availability?${params.toString()}`); + console.log("Library availability response status:", response.status); + + if (!response.ok) { + let message = `Failed to fetch library availability: ${response.status}`; + + try { + console.warn("Attempting to parse error response from library availability API"); + const data = await response.json(); + console.warn("Parsed error response data:", data); + if (data && typeof data.error === "string") { + message = data.error; + } + } catch { + // keep the status-based message + console.warn("Failed to parse error response from library availability API"); + let text = await response.text(); + console.warn("Error response text:", text); + } + + throw new Error(message); + } + + console.log("Successfully fetched library availability"); + const data = await response.json(); + console.log("Library availability data:", data); + + if (!Array.isArray(data)) { + throw new Error("Invalid library availability response: expected array"); + } + + return data; +} + + export type ClassScheduleEntry = { id: string; roomId: string; @@ -126,4 +174,33 @@ export type CampusGraphResponse = { version: CampusGraphVersion; nodes: CampusGraphNode[]; edges: CampusGraphEdge[]; +}; +export type LibraryAvailabilityQuery = { + date: string; + startTime: string; + duration: number; + groupSize: number; + floor: string; + needsPower: boolean; + needsADA: boolean; +}; + +export type LibraryRoomResult = { + id: number; + eid: number; + gid: number; + lid: number; + name: string; + title: string; + url: string; + grouping: string; + capacity: number; + floor: string | null; + hasPower: boolean; + isADA: boolean; + pageIndex: number; + isAvailable: boolean; + availableStarts: string[]; + nextAvailableStart: string | null; + bookingUrl: string; }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2481574..4d80d11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,17 +29,19 @@ "expo-router": "~55.0.14", "expo-splash-screen": "~55.0.20", "expo-status-bar": "~55.0.6", - "express": "^5.2.1", "expo-system-ui": "~55.0.17", + "express": "^5.2.1", "nativewind": "^4.2.2", "postgres": "^3.4.9", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.6", + "react-native-is-edge-to-edge": "^1.3.1", "react-native-maps": "1.27.2", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", + "react-native-webview": "13.16.0", "react-native-worklets": "^0.7.4", "tailwindcss": "^3.4.19" }, @@ -3914,6 +3916,69 @@ "node": ">= 20.19.4" } }, + "node_modules/@react-native/dev-middleware/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/@react-native/dev-middleware/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/@react-native/gradle-plugin": { "version": "0.83.6", "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.6.tgz", @@ -4818,18 +4883,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -5292,12 +5345,60 @@ "ms": "2.0.0" } }, + "node_modules/connect/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/connect/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/connect/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -5710,9 +5811,9 @@ "license": "MIT" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -6453,6 +6554,15 @@ "node": ">=8" } }, + "node_modules/expo/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expo/node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6521,6 +6631,45 @@ "node": ">=10" } }, + "node_modules/expo/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expo/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/expo/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/expo/node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -6591,111 +6740,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6811,38 +6855,26 @@ } }, "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -6878,12 +6910,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs.realpath": { @@ -7181,15 +7213,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -8799,9 +8822,9 @@ } }, "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -9993,6 +10016,20 @@ "react-native": "*" } }, + "node_modules/react-native-webview": { + "version": "13.16.0", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz", + "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.4.tgz", @@ -10538,72 +10575,29 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" + "node": ">= 18" }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serialize-error": { @@ -10616,27 +10610,22 @@ } }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/server-only": { @@ -10913,12 +10902,12 @@ } }, "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/stream-buffers": { diff --git a/package.json b/package.json index 80926b2..6bc4a40 100644 --- a/package.json +++ b/package.json @@ -30,17 +30,19 @@ "expo-router": "~55.0.14", "expo-splash-screen": "~55.0.20", "expo-status-bar": "~55.0.6", - "express": "^5.2.1", "expo-system-ui": "~55.0.17", + "express": "^5.2.1", "nativewind": "^4.2.2", "postgres": "^3.4.9", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.6", + "react-native-is-edge-to-edge": "^1.3.1", "react-native-maps": "1.27.2", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", + "react-native-webview": "13.16.0", "react-native-worklets": "^0.7.4", "tailwindcss": "^3.4.19" },