Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/companion/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
],
"expo-font",
"expo-web-browser",
"expo-notifications",
"expo-quick-actions",
"./plugins/swift-concurrency-fix",
"@sentry/react-native"
Expand Down
26 changes: 26 additions & 0 deletions apps/companion/app/(tabs)/bookings/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type CheckinDisposition,
} from "@/lib/api";
import { useOrg } from "@/lib/org-context";
import { cancelBookingReminders, syncBookingReminders } from "@/lib/reminders";
import {
fontSize,
spacing,
Expand Down Expand Up @@ -244,6 +245,11 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// Now ONGOING — schedule due-back reminders (interactive: a
// direct user action, so the OS permission prompt may show).
void syncBookingReminders(booking.id, currentOrg.id, {
interactive: true,
});
Alert.alert("Checked Out", `"${booking.name}" is now ongoing.`, [
{ text: "OK", onPress: () => fetchBooking() },
]);
Expand Down Expand Up @@ -277,6 +283,8 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// Fully returned — drop the due-back reminders immediately.
void cancelBookingReminders(booking.id);
Alert.alert("Complete", `"${booking.name}" is now complete.`, [
{
text: "OK",
Expand Down Expand Up @@ -317,6 +325,11 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// Fully returned → drop reminders now; partial → gear is still out, and
// if the due time is unchanged the reminders remain correct as-is.
if (data?.isComplete) {
void cancelBookingReminders(booking.id);
}
const msg = data?.isComplete
? `All assets checked in. "${booking.name}" is now complete.`
: `${data?.checkedInCount ?? "Some"} checked in, ${
Expand Down Expand Up @@ -395,6 +408,12 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// The first partial checkout flips RESERVED → ONGOING; sync re-fetches
// and only schedules when the booking is genuinely out, so calling it
// on every partial success is safe and idempotent.
void syncBookingReminders(booking.id, currentOrg.id, {
interactive: true,
});
const msg = data?.isComplete
? `All assets are now checked out for "${booking.name}".`
: `${data?.checkedOutCount ?? "Some"} checked out, ${
Expand Down Expand Up @@ -546,6 +565,8 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// Cancelled bookings have nothing due back.
void cancelBookingReminders(booking.id);
fetchBooking();
},
},
Expand Down Expand Up @@ -576,6 +597,8 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// Archived = administratively closed; stop reminding.
void cancelBookingReminders(booking.id);
fetchBooking();
},
},
Expand Down Expand Up @@ -607,6 +630,9 @@ export default function BookingDetailScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Mutation changed this booking — force the list to refetch.
markBookingsListDirty();
// Gone from the server — reminders must go with it (this is why
// cancel is fetch-free: a sync would 404 here).
void cancelBookingReminders(booking.id);
// The booking no longer exists — leave the detail screen.
router.back();
},
Expand Down
14 changes: 14 additions & 0 deletions apps/companion/app/(tabs)/scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Ionicons } from "@expo/vector-icons";
import { api } from "@/lib/api";
import { useOrg } from "@/lib/org-context";
import { openShelfWebUrl, pushIntoTab } from "@/lib/navigation";
import { cancelBookingReminders, syncBookingReminders } from "@/lib/reminders";
import { TeamMemberPicker } from "@/components/team-member-picker";
import { LocationPicker } from "@/components/location-picker";
import type { TeamMember, Location as LocationType } from "@/lib/api";
Expand Down Expand Up @@ -1642,6 +1643,13 @@ function ScannerContent() {
return;
}

// Booking is now ONGOING — schedule its due-back reminders.
// Interactive: this is a direct user action, so the OS
// permission prompt may show here (first checkout only).
void syncBookingReminders(bookingId, currentOrg.id, {
interactive: true,
});

Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
playScanSound();
Alert.alert(
Expand Down Expand Up @@ -1753,6 +1761,12 @@ function ScannerContent() {
}
: prev
);
// Fully returned → the booking left ONGOING; drop its due-back
// reminders immediately (no fetch — we know it closed here).
// Partial check-ins keep them: gear is still out.
if (result?.isComplete) {
void cancelBookingReminders(bookingId);
}
const msg = result?.isComplete
? `All assets checked in! "${
bookingName || "Booking"
Expand Down
60 changes: 59 additions & 1 deletion apps/companion/app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Text,
TouchableOpacity,
Alert,
Linking,
ScrollView,
Switch,
} from "react-native";
Expand All @@ -28,6 +29,7 @@ import {
setScanSoundEnabled,
playScanSound,
} from "@/lib/scan-sound";
import { loadRemindersPreference, setRemindersEnabled } from "@/lib/reminders";

const appVersion =
Constants.expoConfig?.version ??
Expand All @@ -52,11 +54,13 @@ export default function SettingsScreen() {

const [startPage, setStartPageState] = useState<StartPage>("assets");
const [scanSoundOn, setScanSoundOn] = useState(true);
const [remindersOn, setRemindersOn] = useState(true);

// Load persisted start page and scan sound preference on mount
// Load persisted start page, scan sound, and reminders preferences on mount
useEffect(() => {
getStartPage().then(setStartPageState);
loadScanSoundPreference().then(setScanSoundOn);
loadRemindersPreference().then(setRemindersOn);
}, []);

const handleStartPageChange = (page: StartPage) => {
Expand Down Expand Up @@ -301,6 +305,60 @@ export default function SettingsScreen() {
</View>
</View>

{/* Booking reminders toggle */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Reminders</Text>
<View style={styles.card}>
<View style={styles.settingRow}>
<View style={styles.settingLeft}>
<Ionicons
name="notifications-outline"
size={20}
color={colors.foreground}
/>
<View>
<Text style={styles.settingLabel}>Booking reminders</Text>
<Text style={styles.settingHint}>
Notify when checked-out gear is due back
</Text>
</View>
</View>
<Switch
value={remindersOn}
onValueChange={async (value) => {
Haptics.selectionAsync();
setRemindersOn(value);
// Persists the choice, cancels/reschedules accordingly, and
// reports whether the OS-level permission is granted.
const granted = await setRemindersEnabled(value);
if (value && !granted) {
Alert.alert(
"Notifications are off",
"Shelf can't show reminders until notifications are allowed in your device settings.",
[
{ text: "Not now", style: "cancel" },
{
text: "Open Settings",
onPress: () => {
void Linking.openSettings();
},
},
]
);
}
}}
trackColor={{
false: colors.borderLight,
true: colors.primary + "60",
}}
thumbColor={remindersOn ? colors.primary : colors.mutedLight}
accessibilityLabel="Toggle booking reminders"
accessibilityRole="switch"
/>
</View>
</View>
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.card}>
Expand Down
5 changes: 5 additions & 0 deletions apps/companion/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { OfflineBanner } from "@/components/offline-banner";
import AnimatedSplash from "@/components/animated-splash";
import { useDeepLinkHandler } from "@/lib/deep-links";
import { useQuickActions } from "@/lib/quick-actions";
import { useBookingReminders } from "@/lib/reminders";
import { getStartPage, getStartPageRoute } from "@/lib/start-page";
import { preloadScanSound } from "@/lib/scan-sound";
import { initSentry } from "@/lib/sentry";
Expand Down Expand Up @@ -57,6 +58,10 @@ function RootLayoutNav() {
// Register 3D Touch / long-press quick actions (home screen shortcuts)
useQuickActions();

// Booking due-back reminders: init presentation, reconcile on foreground,
// deep-link reminder taps to their booking.
useBookingReminders();

// Redirect after splash finishes and auth state is known.
useEffect(() => {
if (isLoading || !splashComplete) return;
Expand Down
23 changes: 19 additions & 4 deletions apps/companion/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,16 @@ export type ApiFetchOptions = RequestInit & { retry?: boolean };
* - Returns structured { data, error } -- never throws.
* - Detects 401/session-expired and notifies global auth listeners.
* - Enforces a request timeout to avoid hanging on slow networks.
* - `status` carries the HTTP status when a response was received at all,
* letting callers tell an authoritative 404/403 ("gone") apart from a
* network/timeout failure (`status` undefined). Additive — existing
* `{ data, error }` destructuring is unaffected.
*/
export async function apiFetch<T>(
path: string,
options: ApiFetchOptions = {},
_retryCount = 0
): Promise<{ data: T | null; error: string | null }> {
): Promise<{ data: T | null; error: string | null; status?: number }> {
// Declared outside try so catch block can read it
let timedOut = false;

Expand Down Expand Up @@ -134,9 +138,17 @@ export async function apiFetch<T>(
json = text ? JSON.parse(text) : null;
} catch {
if (!response.ok) {
return { data: null, error: `Server error (${response.status})` };
return {
data: null,
error: `Server error (${response.status})`,
status: response.status,
};
}
return { data: null, error: "Invalid response from server" };
return {
data: null,
error: "Invalid response from server",
status: response.status,
};
}

if (!response.ok) {
Expand All @@ -146,6 +158,7 @@ export async function apiFetch<T>(
return {
data: null,
error: "Session expired. Please sign in again.",
status: response.status,
};
}
// 403 = forbidden → user lacks permission, but session is valid
Expand All @@ -155,15 +168,17 @@ export async function apiFetch<T>(
error:
json?.error?.message ||
"You don't have permission to perform this action.",
status: response.status,
};
}
return {
data: null,
error: json?.error?.message || `Request failed (${response.status})`,
status: response.status,
};
}

return { data: json as T, error: null };
return { data: json as T, error: null, status: response.status };
} catch (err) {
// Navigation/cleanup abort — silently return null (not an error)
if (err instanceof Error && err.name === "AbortError" && !timedOut) {
Expand Down
44 changes: 38 additions & 6 deletions apps/companion/lib/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { Session, User } from "@supabase/supabase-js";
// why: deep import — the ./reminders barrel pulls in the org-context-using
// hook, and org-context imports this file; going straight to the service
// (which only touches the api client) avoids a require cycle.
import { clearAllBookingReminders } from "./reminders/service";
import { supabase } from "./supabase";

type AuthState = {
Expand All @@ -26,11 +31,40 @@ const AuthContext = createContext<AuthState | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Tracks whether we last saw an authenticated session, so ANY transition
// to signed-out (expiry, revocation, refresh failure — not only the
// explicit signOut button) clears the previous account's scheduled
// booking reminders. After such a transition every reconcile would 401
// forever, and a later user of the device must not receive the previous
// account's booking names.
const hadSessionRef = useRef(false);

useEffect(() => {
// Listener FIRST, then the initial read: registering after getSession()
// leaves a window where a sign-out/refresh event lands before the stale
// initial session resolves and overwrites it (and the signed-out
// transition — and its reminder cleanup — would be missed). Supabase
// also emits INITIAL_SESSION on subscribe, which this ordering catches.
let receivedAuthEvent = false;
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
receivedAuthEvent = true;
if (hadSessionRef.current && session === null) {
void clearAllBookingReminders();
}
hadSessionRef.current = session !== null;
setSession(session);
setIsLoading(false);
});

supabase.auth
.getSession()
.then(({ data: { session } }) => {
// An auth event is always fresher than this snapshot — never let a
// stale restored session clobber it.
if (receivedAuthEvent) return;
hadSessionRef.current = session !== null;
setSession(session);
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.catch((err) => {
Expand All @@ -40,12 +74,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setIsLoading(false);
});

const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});

return () => subscription.unsubscribe();
}, []);

Expand All @@ -58,6 +86,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);

const signOut = useCallback(async () => {
// Scheduled booking reminders belong to this account's session. After
// sign-out every reconcile fetch would 401 forever, so the tracked
// records could never heal — cancel and forget them all up front.
await clearAllBookingReminders();
Comment thread
carlosvirreira marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await supabase.auth.signOut();
}, []);

Expand Down
Loading
Loading