Files
codexbar-mobile/lib/notifications.ts
T
Space-Banane 2c63dcfd75
CodexBar Mobile Build / build-android (push) Failing after 3m21s
CodexBar Mobile Build / release (push) Has been skipped
fix: guard expo-notifications against Expo Go crash on import
SDK 53+ removed push notification support from Expo Go, causing the
module to throw at require-time. The static import in notifications.ts
propagated the error to _layout.tsx and both tab screens, making expo-
router report missing default exports and crash the app.

Switch to a try/catch require() so the module initialises safely in
Expo Go (Notifications stays null, all functions become no-ops) while
still working correctly in EAS / development builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:47:44 +02:00

161 lines
4.4 KiB
TypeScript

import type * as NotificationsType from "expo-notifications";
import {
loadNotifSettings,
loadNotifThresholdLastFired,
saveNotifThresholdLastFired,
} from "@/lib/storage";
import type { ClaudeUsageResponse } from "@/types/claude";
import type { CodexUsageResponse } from "@/types/codex";
// expo-notifications push notifications were removed from Expo Go in SDK 53.
// Use require() so the initialization error is caught gracefully in Expo Go.
let Notifications: typeof NotificationsType | null = null;
try {
Notifications = require("expo-notifications") as typeof NotificationsType;
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
} catch {
// Running in Expo Go or native module unavailable — notifications disabled.
}
export async function requestPermissions(): Promise<boolean> {
if (!Notifications) return false;
const { status } = await Notifications.requestPermissionsAsync();
return status === "granted";
}
export async function getPermissionStatus(): Promise<string> {
if (!Notifications) return "undetermined";
const { status } = await Notifications.getPermissionsAsync();
return status;
}
const DAILY_ID = "codexbar-daily-digest";
export async function scheduleDailyDigest(
hour: number,
minute: number,
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): Promise<void> {
if (!Notifications) return;
try {
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
} catch {}
const parts: string[] = [];
if (claudeUsage) {
parts.push(`Claude 7d: ${Math.round(claudeUsage.seven_day.utilization)}% used`);
}
if (codexUsage) {
parts.push(
`Codex: ${Math.round(codexUsage.rate_limit.secondary_window.used_percent)}% used`
);
}
await Notifications.scheduleNotificationAsync({
identifier: DAILY_ID,
content: {
title: "Usage Digest",
body:
parts.length > 0
? parts.join(" · ")
: "Open to check your usage limits",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
hour,
minute,
repeats: true,
},
});
}
export async function cancelDailyDigest(): Promise<void> {
if (!Notifications) return;
try {
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
} catch {}
}
interface ThresholdAlert {
service: string;
window: string;
remaining: number;
}
function buildThresholdAlerts(
thresholdPct: number,
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): ThresholdAlert[] {
const alerts: ThresholdAlert[] = [];
if (claudeUsage) {
const remaining = 100 - claudeUsage.seven_day.utilization;
if (remaining <= thresholdPct) {
alerts.push({ service: "Claude", window: "7-day", remaining });
}
}
if (codexUsage) {
const remaining = 100 - codexUsage.rate_limit.secondary_window.used_percent;
if (remaining <= thresholdPct) {
alerts.push({ service: "Codex", window: "weekly", remaining });
}
}
return alerts;
}
/** Call this after every successful usage fetch. Reschedules daily digest and fires threshold alerts. */
export async function onUsageDataLoaded(
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): Promise<void> {
const settings = await loadNotifSettings();
if (settings.dailyEnabled) {
await scheduleDailyDigest(
settings.dailyHour,
settings.dailyMinute,
claudeUsage,
codexUsage
);
}
if (settings.thresholdEnabled) {
const today = new Date().toISOString().slice(0, 10);
const lastFired = await loadNotifThresholdLastFired();
if (lastFired !== today) {
const alerts = buildThresholdAlerts(
settings.thresholdPct,
claudeUsage,
codexUsage
);
if (alerts.length > 0 && Notifications) {
await Notifications.scheduleNotificationAsync({
content: {
title: "Low quota warning",
body: alerts
.map(
(a) =>
`${a.service} ${a.window}: ${Math.round(a.remaining)}% remaining`
)
.join("\n"),
},
trigger: null,
});
await saveNotifThresholdLastFired(today);
}
}
}
}