Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
21 changes: 21 additions & 0 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,19 @@ 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(() => {
supabase.auth
.getSession()
.then(({ data: { session } }) => {
hadSessionRef.current = session !== null;
setSession(session);
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.catch((err) => {
Expand All @@ -43,6 +56,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (hadSessionRef.current && session === null) {
void clearAllBookingReminders();
}
hadSessionRef.current = session !== null;
setSession(session);
});

Expand All @@ -58,6 +75,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
26 changes: 26 additions & 0 deletions apps/companion/lib/reminders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Booking reminders — public surface (consumed exports only).
*
* Local due-back notifications for bookings checked out from this device:
* scheduled on checkout, cancelled on check-in/cancel/archive/delete and
* sign-out, and re-derived from the server on every app foreground so they
* can never nag about gear that is already back.
*
* The pure planner and the reconcile/init internals are deliberately NOT
* re-exported here — the hook consumes them directly from ./service and
* ./plan, and an unused public surface is just dead API to maintain.
*
* @see {@link file://./plan.ts} pure planner (what fires, when)
* @see {@link file://./service.ts} runtime (schedule/cancel/reconcile)
* @see {@link file://./use-booking-reminders.ts} app wiring hook
*/
export {
cancelBookingReminders,
loadRemindersPreference,
setRemindersEnabled,
syncBookingReminders,
} from "./service";
export { useBookingReminders } from "./use-booking-reminders";
// NOTE: clearAllBookingReminders is deliberately not re-exported here — its
// one consumer (auth-context) deep-imports ./service to avoid a require
// cycle through the org-context-using hook this barrel pulls in.
Loading
Loading