316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import {
|
|
ScrollView,
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
RefreshControl,
|
|
ActivityIndicator,
|
|
} from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
import { router, useFocusEffect } from "expo-router";
|
|
import { useCallback, useEffect } from "react";
|
|
import { MaterialIcons } from "@expo/vector-icons";
|
|
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
|
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
|
import { onUsageDataLoaded } from "@/lib/notifications";
|
|
import { ProgressBar } from "@/components/ProgressBar";
|
|
import { ResetCountdown } from "@/components/ResetCountdown";
|
|
import { windowLabel } from "@/lib/timeUtils";
|
|
|
|
function UsageRow({
|
|
label,
|
|
percent,
|
|
resetAtSeconds,
|
|
resetAtISO,
|
|
}: {
|
|
label: string;
|
|
percent: number;
|
|
resetAtSeconds?: number;
|
|
resetAtISO?: string;
|
|
}) {
|
|
const color =
|
|
percent >= 90 ? "#ef4444" : percent >= 70 ? "#f59e0b" : "#10a37f";
|
|
return (
|
|
<View className="mb-4">
|
|
<View className="flex-row justify-between items-baseline mb-1.5">
|
|
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
{label}
|
|
</Text>
|
|
<Text className="text-sm font-bold" style={{ color }}>
|
|
{Math.round(percent)}% used
|
|
</Text>
|
|
</View>
|
|
<ProgressBar percent={percent} />
|
|
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SetupPrompt({ label }: { label: string }) {
|
|
return (
|
|
<View>
|
|
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
|
|
{label}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => router.navigate("/(tabs)/settings")}
|
|
className="flex-row items-center self-start gap-x-1.5 px-3 py-2 rounded-xl border border-neutral-200 dark:border-neutral-700"
|
|
>
|
|
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
|
Set up in Settings
|
|
</Text>
|
|
<MaterialIcons name="arrow-forward" size={12} color="#737373" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function DashboardTab() {
|
|
const codex = useCodexUsage();
|
|
const claude = useClaudeUsage();
|
|
|
|
// Fire daily digest reschedule + threshold check whenever fresh data arrives
|
|
useEffect(() => {
|
|
if (codex.status === "success" || claude.status === "success") {
|
|
void onUsageDataLoaded(
|
|
claude.status === "success" ? claude.usage : null,
|
|
codex.status === "success" ? codex.usage : null
|
|
);
|
|
}
|
|
}, [codex.status, claude.status]);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
codex.reload();
|
|
claude.reload();
|
|
}, [])
|
|
);
|
|
|
|
const isRefreshing =
|
|
codex.status === "loading" || claude.status === "loading";
|
|
|
|
const handleRefresh = () => {
|
|
codex.refresh();
|
|
claude.refresh();
|
|
};
|
|
|
|
const connectedCount = [codex.auth, claude.auth].filter(Boolean).length;
|
|
|
|
return (
|
|
<SafeAreaView
|
|
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
|
|
edges={["top"]}
|
|
>
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor="#10a37f"
|
|
/>
|
|
}
|
|
>
|
|
{/* Header */}
|
|
<View className="flex-row items-center justify-between pt-6 pb-5">
|
|
<View className="flex-row items-center gap-x-3">
|
|
<View className="w-10 h-10 rounded-2xl bg-emerald-500 items-center justify-center">
|
|
<MaterialIcons name="bar-chart" size={20} color="white" />
|
|
</View>
|
|
<View>
|
|
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
|
Codexbar
|
|
</Text>
|
|
<Text className="text-xs text-neutral-400 mt-0.5">
|
|
{connectedCount === 0
|
|
? "No services connected"
|
|
: connectedCount === 2
|
|
? "2 services connected"
|
|
: "1 of 2 services connected"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={handleRefresh}
|
|
className="w-9 h-9 rounded-xl bg-neutral-100 dark:bg-neutral-800 items-center justify-center"
|
|
>
|
|
<MaterialIcons name="refresh" size={18} color="#10a37f" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Codex card */}
|
|
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
|
<View style={{ height: 3, backgroundColor: "#10a37f" }} />
|
|
<View className="p-4">
|
|
<View className="flex-row items-center justify-between mb-4">
|
|
<View className="flex-row items-center gap-x-2.5">
|
|
<View
|
|
className="w-8 h-8 rounded-xl items-center justify-center"
|
|
style={{ backgroundColor: "#10a37f18" }}
|
|
>
|
|
<MaterialIcons name="auto-awesome" size={16} color="#10a37f" />
|
|
</View>
|
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
|
Codex CLI
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row items-center gap-x-2">
|
|
{codex.usage?.plan_type && (
|
|
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
|
|
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
|
{codex.usage.plan_type}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{codex.auth && codex.status === "error" && (
|
|
<View className="w-2 h-2 rounded-full bg-red-500" />
|
|
)}
|
|
{codex.status === "success" && !codex.usage?.plan_type && (
|
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{!codex.auth && (
|
|
<SetupPrompt label="Import auth.json to see your Codex rate limits." />
|
|
)}
|
|
{codex.auth && codex.status === "loading" && (
|
|
<ActivityIndicator
|
|
color="#10a37f"
|
|
style={{ marginVertical: 16 }}
|
|
/>
|
|
)}
|
|
{codex.auth && codex.status === "error" && (
|
|
<View className="flex-row items-center gap-x-2 py-1">
|
|
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
|
<Text className="text-sm text-red-500 dark:text-red-400">
|
|
Failed to fetch usage
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{codex.status === "success" && codex.usage && (
|
|
<View>
|
|
<UsageRow
|
|
label={`${windowLabel(codex.usage.rate_limit.primary_window.limit_window_seconds)} window`}
|
|
percent={codex.usage.rate_limit.primary_window.used_percent}
|
|
resetAtSeconds={codex.usage.rate_limit.primary_window.reset_at}
|
|
/>
|
|
<UsageRow
|
|
label={`${windowLabel(codex.usage.rate_limit.secondary_window.limit_window_seconds)} window`}
|
|
percent={
|
|
codex.usage.rate_limit.secondary_window.used_percent
|
|
}
|
|
resetAtSeconds={
|
|
codex.usage.rate_limit.secondary_window.reset_at
|
|
}
|
|
/>
|
|
{!codex.usage.credits.unlimited &&
|
|
codex.usage.credits.has_credits && (
|
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
|
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
|
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
${Number(codex.usage.credits.balance).toFixed(2)}{" "}
|
|
credits remaining
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{codex.usage.credits.unlimited && (
|
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
|
|
<MaterialIcons
|
|
name="all-inclusive"
|
|
size={14}
|
|
color="#10a37f"
|
|
/>
|
|
<Text className="text-xs text-green-600 dark:text-green-400">
|
|
Unlimited credits
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Claude card */}
|
|
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
|
|
<View style={{ height: 3, backgroundColor: "#d97706" }} />
|
|
<View className="p-4">
|
|
<View className="flex-row items-center justify-between mb-4">
|
|
<View className="flex-row items-center gap-x-2.5">
|
|
<View
|
|
className="w-8 h-8 rounded-xl items-center justify-center"
|
|
style={{ backgroundColor: "#d9770618" }}
|
|
>
|
|
<MaterialIcons name="psychology" size={16} color="#d97706" />
|
|
</View>
|
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
|
Claude.ai
|
|
</Text>
|
|
</View>
|
|
{claude.auth && claude.status === "error" && (
|
|
<View className="w-2 h-2 rounded-full bg-red-500" />
|
|
)}
|
|
{claude.status === "success" && (
|
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
|
)}
|
|
</View>
|
|
|
|
{!claude.auth && (
|
|
<SetupPrompt label="Add your session key to see Claude usage windows." />
|
|
)}
|
|
{claude.auth && claude.status === "loading" && (
|
|
<ActivityIndicator
|
|
color="#d97706"
|
|
style={{ marginVertical: 16 }}
|
|
/>
|
|
)}
|
|
{claude.auth && claude.status === "error" && (
|
|
<View className="flex-row items-center gap-x-2 py-1">
|
|
<MaterialIcons name="error-outline" size={16} color="#ef4444" />
|
|
<Text className="text-sm text-red-500 dark:text-red-400">
|
|
Failed to fetch usage
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{claude.status === "success" && claude.usage && (
|
|
<View>
|
|
<UsageRow
|
|
label="5-hour window"
|
|
percent={claude.usage.five_hour.utilization}
|
|
resetAtISO={claude.usage.five_hour.resets_at}
|
|
/>
|
|
<UsageRow
|
|
label="7-day window"
|
|
percent={claude.usage.seven_day.utilization}
|
|
resetAtISO={claude.usage.seven_day.resets_at}
|
|
/>
|
|
{(claude.usage.seven_day_sonnet ||
|
|
claude.usage.seven_day_opus) && (
|
|
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800">
|
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
|
|
By Model (7-day)
|
|
</Text>
|
|
{claude.usage.seven_day_sonnet && (
|
|
<UsageRow
|
|
label="Sonnet"
|
|
percent={claude.usage.seven_day_sonnet.utilization}
|
|
/>
|
|
)}
|
|
{claude.usage.seven_day_opus && (
|
|
<UsageRow
|
|
label="Opus"
|
|
percent={claude.usage.seven_day_opus.utilization}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|