diff --git a/app.json b/app.json
index ab97ff7..b4daaa1 100644
--- a/app.json
+++ b/app.json
@@ -10,7 +10,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
- "buildNumber": "23",
+ "buildNumber": "26",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx
index 3836ab5..80a5234 100644
--- a/app/(app)/index.tsx
+++ b/app/(app)/index.tsx
@@ -48,7 +48,7 @@ export default function DashboardScreen() {
return ;
}
- if (statsQuery.error) {
+ if (statsQuery.error && !statsQuery.data) {
return (
diff --git a/assets/beenvoice.icon/Assets/5x5-solid-lines-grid(1).svg b/assets/beenvoice.icon/Assets/5x5-solid-lines-grid(1).svg
new file mode 100644
index 0000000..21d82ea
--- /dev/null
+++ b/assets/beenvoice.icon/Assets/5x5-solid-lines-grid(1).svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/assets/beenvoice.icon/icon.json b/assets/beenvoice.icon/icon.json
index ec0270e..976ea87 100644
--- a/assets/beenvoice.icon/icon.json
+++ b/assets/beenvoice.icon/icon.json
@@ -1,6 +1,9 @@
{
"fill" : {
- "automatic-gradient" : "gray:0.75000,1.00000",
+ "linear-gradient" : [
+ "display-p3:0.75855,0.75855,0.75855,1.00000",
+ "display-p3:0.47359,0.47359,0.47359,1.00000"
+ ],
"orientation" : {
"start" : {
"x" : 0.5,
@@ -19,13 +22,26 @@
"fill-specializations" : [
{
"value" : {
- "solid" : "extended-gray:0.00000,1.00000"
+ "linear-gradient" : [
+ "display-p3:0.36520,0.36520,0.36520,1.00000",
+ "extended-gray:0.00000,1.00000"
+ ],
+ "orientation" : {
+ "start" : {
+ "x" : 0.4999999999999998,
+ "y" : 0
+ },
+ "stop" : {
+ "x" : 0.4999999999999998,
+ "y" : 0.5617755083064717
+ }
+ }
}
},
{
"appearance" : "dark",
"value" : {
- "solid" : "extended-gray:1.00000,1.00000"
+ "solid" : "extended-gray:0.75000,1.00000"
}
},
{
@@ -45,6 +61,35 @@
0
]
}
+ },
+ {
+ "blend-mode" : "normal",
+ "fill" : {
+ "linear-gradient" : [
+ "display-p3:0.59424,0.59424,0.59424,1.00000",
+ "display-p3:0.33555,0.33555,0.33555,1.00000"
+ ],
+ "orientation" : {
+ "start" : {
+ "x" : 0.5,
+ "y" : 0
+ },
+ "stop" : {
+ "x" : 0.5,
+ "y" : 0.7
+ }
+ }
+ },
+ "glass" : false,
+ "image-name" : "5x5-solid-lines-grid(1).svg",
+ "name" : "5x5-solid-lines-grid(1)",
+ "position" : {
+ "scale" : 1.25,
+ "translation-in-points" : [
+ 0,
+ 0
+ ]
+ }
}
],
"shadow" : {
diff --git a/contexts/AccountsContext.tsx b/contexts/AccountsContext.tsx
index ea36121..a5f55bc 100644
--- a/contexts/AccountsContext.tsx
+++ b/contexts/AccountsContext.tsx
@@ -22,6 +22,7 @@ import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL, invalidServerUrlMessage }
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import { migrateStoredOfficialUrls } from "@/lib/official-url-migration";
+import { clearOfflineQueryCacheForAccount } from "@/lib/offline-cache";
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
export type RemoveAccountResult = {
@@ -159,6 +160,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
const wasActive = activeAccountId === accountId;
await clearAuthStorage(authStoragePrefix(accountId));
+ await clearOfflineQueryCacheForAccount(accountId);
await clearTimeClockPrefsForAccount(accountId);
const nextAccounts = accounts.filter((account) => account.id !== accountId);
diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx
index 195f7e8..4399070 100644
--- a/contexts/AuthContext.tsx
+++ b/contexts/AuthContext.tsx
@@ -11,16 +11,39 @@ import {
type AuthClient = ReturnType;
+async function authFetch(input: RequestInfo | URL, init?: RequestInit) {
+ try {
+ return await fetch(input, init);
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw error;
+ }
+
+ return new Response(
+ JSON.stringify({
+ message: "Could not connect to the server.",
+ code: "NETWORK_ERROR",
+ }),
+ {
+ status: 503,
+ statusText: "Service Unavailable",
+ headers: { "content-type": "application/json" },
+ },
+ );
+ }
+}
+
function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient {
return createAuthClient({
baseURL: apiUrl,
+ fetchOptions: {
+ customFetchImpl: authFetch,
+ },
plugins: [
expoClient({
scheme: "beenvoice",
storagePrefix,
storage: SecureStore,
- // Avoid showing a cached session when cookies have already expired.
- disableCache: true,
}),
genericOAuthClient(),
],
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 7c1116a..e4f4ed9 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -126,6 +126,16 @@ httpBatchLink({
Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`.
+## Offline mode
+
+Read queries are cached per account + API URL in AsyncStorage (`lib/offline-cache.ts`). `TRPCProvider` restores that cache before mounting app screens, then persists successful query data for up to 7 days. SuperJSON is used for persistence so API payloads keep Date values and other transformed types.
+
+`lib/network-status.ts` connects `expo-network` to TanStack Query's `onlineManager`. When the device is offline, queries pause instead of repeatedly failing; when connectivity returns, React Query reconnect behavior refreshes stale data. The better-auth Expo client also keeps its SecureStore session cache enabled so a previously signed-in account can get through app boot while offline, then reconcile with the server when connectivity returns.
+
+Account removal clears that account's persisted query cache alongside SecureStore auth and time-clock preferences.
+
+Current offline scope: previously loaded dashboard, invoices, clients, businesses, expenses, reports, recurring invoices, and time entries can be viewed offline from cache. Mutations still require the API; timer actions, invoice edits/status changes, receipt uploads, reminders, emails, and onboarding writes are not persisted as an offline queue yet because they need conflict and side-effect rules.
+
## App lock (per account)
`lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`:
diff --git a/lib/network-status.ts b/lib/network-status.ts
new file mode 100644
index 0000000..715f05c
--- /dev/null
+++ b/lib/network-status.ts
@@ -0,0 +1,34 @@
+import * as Network from "expo-network";
+import { onlineManager } from "@tanstack/react-query";
+
+function isOnline({
+ isConnected,
+ isInternetReachable,
+}: Pick) {
+ if (isConnected === false) return false;
+ if (isInternetReachable === false) return false;
+ return true;
+}
+
+let configured = false;
+
+export function configureReactQueryOnlineManager() {
+ if (configured) return;
+ configured = true;
+
+ void Network.getNetworkStateAsync()
+ .then((state) => {
+ onlineManager.setOnline(isOnline(state));
+ })
+ .catch(() => {
+ onlineManager.setOnline(true);
+ });
+
+ onlineManager.setEventListener((setOnline) => {
+ const subscription = Network.addNetworkStateListener((state) => {
+ setOnline(isOnline(state));
+ });
+
+ return () => subscription.remove();
+ });
+}
diff --git a/lib/offline-cache.ts b/lib/offline-cache.ts
new file mode 100644
index 0000000..3f3f986
--- /dev/null
+++ b/lib/offline-cache.ts
@@ -0,0 +1,132 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import {
+ dehydrate,
+ hydrate,
+ type DehydratedState,
+ type QueryClient,
+} from "@tanstack/react-query";
+import SuperJSON from "superjson";
+
+const CACHE_PREFIX = "beenvoice:query-cache:v1";
+const CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7;
+const SAVE_DEBOUNCE_MS = 1000;
+
+type PersistedQueryCache = {
+ timestamp: number;
+ state: DehydratedState;
+};
+
+const SKIPPED_QUERY_KEY_PARTS = ["previewPdf"];
+
+function cacheScopeKey(accountId: string | null, apiUrl: string) {
+ const scope = `${accountId ?? "guest"}:${apiUrl}`;
+ return encodeURIComponent(scope).replace(/[!'()*]/g, (char) =>
+ `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+}
+
+export function offlineQueryCacheKey(accountId: string | null, apiUrl: string) {
+ return `${CACHE_PREFIX}:${cacheScopeKey(accountId, apiUrl)}`;
+}
+
+export async function restoreOfflineQueryCache({
+ queryClient,
+ accountId,
+ apiUrl,
+}: {
+ queryClient: QueryClient;
+ accountId: string | null;
+ apiUrl: string;
+}) {
+ const key = offlineQueryCacheKey(accountId, apiUrl);
+ const cached = await AsyncStorage.getItem(key);
+ if (!cached) return;
+
+ try {
+ const persisted = SuperJSON.parse(cached);
+ if (Date.now() - persisted.timestamp > CACHE_MAX_AGE_MS) {
+ await AsyncStorage.removeItem(key);
+ return;
+ }
+
+ hydrate(queryClient, persisted.state);
+ } catch {
+ await AsyncStorage.removeItem(key);
+ }
+}
+
+export function persistOfflineQueryCache({
+ queryClient,
+ accountId,
+ apiUrl,
+}: {
+ queryClient: QueryClient;
+ accountId: string | null;
+ apiUrl: string;
+}) {
+ const key = offlineQueryCacheKey(accountId, apiUrl);
+ let timeout: ReturnType | null = null;
+
+ const save = async () => {
+ const state = dehydrate(queryClient, {
+ shouldDehydrateQuery: (query) =>
+ query.state.status === "success" &&
+ query.state.fetchStatus === "idle" &&
+ query.state.fetchFailureCount === 0 &&
+ !SKIPPED_QUERY_KEY_PARTS.some((part) =>
+ JSON.stringify(query.queryKey).includes(part),
+ ),
+ });
+ if (state.queries.length === 0) return;
+
+ const existing = await AsyncStorage.getItem(key).then((cached) => {
+ if (!cached) return null;
+ try {
+ return SuperJSON.parse(cached);
+ } catch {
+ return null;
+ }
+ });
+
+ const queriesByHash = new Map(
+ existing?.state.queries.map((query) => [query.queryHash, query]),
+ );
+ for (const query of state.queries) {
+ queriesByHash.set(query.queryHash, query);
+ }
+
+ const persisted: PersistedQueryCache = {
+ timestamp: Date.now(),
+ state: {
+ mutations: state.mutations,
+ queries: Array.from(queriesByHash.values()),
+ },
+ };
+
+ await AsyncStorage.setItem(key, SuperJSON.stringify(persisted));
+ };
+
+ const scheduleSave = () => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(save, SAVE_DEBOUNCE_MS);
+ };
+
+ const unsubscribe = queryClient.getQueryCache().subscribe(scheduleSave);
+
+ return () => {
+ unsubscribe();
+ if (timeout) {
+ clearTimeout(timeout);
+ void save();
+ }
+ };
+}
+
+export async function clearOfflineQueryCacheForAccount(accountId: string) {
+ const keys = await AsyncStorage.getAllKeys();
+ const accountPrefix = `${CACHE_PREFIX}:${encodeURIComponent(`${accountId}:`)}`;
+ const matchingKeys = keys.filter((key) => key.startsWith(accountPrefix));
+ if (matchingKeys.length > 0) {
+ await AsyncStorage.multiRemove(matchingKeys);
+ }
+}
diff --git a/lib/query-client.ts b/lib/query-client.ts
index 21e8b36..faf27de 100644
--- a/lib/query-client.ts
+++ b/lib/query-client.ts
@@ -15,6 +15,8 @@ export function createAppQueryClient(onUnauthorized: () => void) {
defaultOptions: {
queries: {
staleTime: 30_000,
+ gcTime: 1000 * 60 * 60 * 24 * 7,
+ refetchOnReconnect: true,
retry: (failureCount, error) => {
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
return failureCount < 1;
diff --git a/lib/trpc.tsx b/lib/trpc.tsx
index 073cd02..8a53fc0 100644
--- a/lib/trpc.tsx
+++ b/lib/trpc.tsx
@@ -9,9 +9,15 @@ import {
} from "react";
import SuperJSON from "superjson";
+import { LoadingScreen } from "@/components/LoadingScreen";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
+import { configureReactQueryOnlineManager } from "@/lib/network-status";
+import {
+ persistOfflineQueryCache,
+ restoreOfflineQueryCache,
+} from "@/lib/offline-cache";
import { performAuthReset } from "@/lib/auth-session";
import { createAppQueryClient } from "@/lib/query-client";
import type { AppRouter } from "beenvoice/server/api/root";
@@ -31,6 +37,7 @@ export function TRPCProvider({
const { refetch } = useSession();
const authStoragePrefixRef = useRef(authStoragePrefix);
authStoragePrefixRef.current = authStoragePrefix;
+ const [cacheReady, setCacheReady] = useState(false);
const mountedRef = useRef(true);
const resettingRef = useRef(false);
@@ -87,6 +94,43 @@ export function TRPCProvider({
}),
);
+ useEffect(() => {
+ configureReactQueryOnlineManager();
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setCacheReady(false);
+
+ restoreOfflineQueryCache({
+ queryClient,
+ accountId: activeAccountId,
+ apiUrl,
+ }).finally(() => {
+ if (!cancelled && mountedRef.current) {
+ setCacheReady(true);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [activeAccountId, apiUrl, queryClient]);
+
+ useEffect(() => {
+ if (!cacheReady) return;
+
+ return persistOfflineQueryCache({
+ queryClient,
+ accountId: activeAccountId,
+ apiUrl,
+ });
+ }, [activeAccountId, apiUrl, cacheReady, queryClient]);
+
+ if (!cacheReady) {
+ return ;
+ }
+
return (
{children}
diff --git a/widgets/TimeClockActivity.tsx b/widgets/TimeClockActivity.tsx
index cd4f494..9b19963 100644
--- a/widgets/TimeClockActivity.tsx
+++ b/widgets/TimeClockActivity.tsx
@@ -8,6 +8,7 @@ import {
minimumScaleFactor,
monospacedDigit,
padding,
+ truncationMode,
widgetAccentedRenderingMode,
} from "@expo/ui/swift-ui/modifiers";
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
@@ -30,20 +31,24 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
layoutPriority(2),
frame({ width: 100, alignment: "center" }),
];
+ // A live timer Text reserves a large ideal width (for the H:MM:SS format at
+ // the Dynamic Island's font), so with no width cap it inflates the compact
+ // pill to full width. Pin it to a snug fixed width sized for the common
+ // MM:SS case; minimumScaleFactor lets the occasional H:MM:SS scale down to
+ // fit instead of widening the pill. This keeps the pill tight with no gap.
const compactTimerMods = [
font({ design: "monospaced", weight: "semibold", size: 11 }),
monospacedDigit(),
foregroundStyle(island),
lineLimit(1),
- minimumScaleFactor(0.75),
- frame({ minWidth: 38, alignment: "center" }),
- layoutPriority(2),
+ minimumScaleFactor(0.6),
+ frame({ width: 40, alignment: "center" }),
];
const clientMods = [
font({ weight: "bold", size: 17 }),
foregroundStyle({ type: "hierarchical", style: "primary" }),
lineLimit(1),
- minimumScaleFactor(0.75),
+ truncationMode("tail"),
];
// Avoid greedy layout primitives here: the live timerInterval Text can
// collapse when paired with Spacer/maxWidth. Fixed centered boxes keep the
@@ -60,8 +65,26 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
frame({ maxWidth: Infinity, alignment: "center" }),
];
- const bannerTimer = {props.elapsedShort};
- const compactTimer = {props.elapsedShort};
+ const startedAt = new Date(props.startedAtMs);
+ // Bounded to 24h so SwiftUI reserves width for the H:MM:SS format only.
+ // (An open-ended `.timer`/`.date` style reserves width for a huge duration,
+ // which blows the compact Dynamic Island pill out to full width.)
+ const timerRange = {
+ lower: startedAt,
+ upper: new Date(props.startedAtMs + 24 * 60 * 60 * 1000),
+ };
+
+ // Banner (Notification Center / Lock Screen): timerInterval inside a fixed
+ // 100pt box, which absorbs the reserved width.
+ const bannerTimer = (
+
+ );
+ // Dynamic Island (compact): same bounded timerInterval, sized intrinsically —
+ // no fixed/minWidth frame or layoutPriority, so the pill hugs the reserved
+ // H:MM:SS width instead of being padded out or stretched.
+ const compactTimer = (
+
+ );
const logoLarge = (
);
+
+ // Apple Watch / CarPlay (`bannerSmall`). The strip is only ~150-180pt wide,
+ // so it cannot use the iPhone banner's fixed 100/140/100 columns (those
+ // overflow and push the timer off-screen, leaving just its first digit).
+ // Instead: fixed-size logo, a flexible client name that truncates to fill
+ // the leftover space, and a compact fixed-width timer that stays fully
+ // visible (fixed width — not maxWidth — so the live timer text doesn't
+ // collapse).
+ const watchRowMods = [
+ padding({ horizontal: 10, vertical: 6 }),
+ frame({ maxWidth: Infinity, alignment: "center" }),
+ ];
+ const watchTitleMods = [
+ font({ weight: "semibold", size: 14 }),
+ foregroundStyle({ type: "hierarchical", style: "primary" }),
+ lineLimit(1),
+ truncationMode("tail"),
+ frame({ maxWidth: Infinity, alignment: "leading" }),
+ ];
+ const watchTimerMods = [
+ font({ design: "monospaced", weight: "bold", size: 14 }),
+ monospacedDigit(),
+ foregroundStyle({ type: "hierarchical", style: "primary" }),
+ lineLimit(1),
+ minimumScaleFactor(0.6),
+ frame({ width: 58, alignment: "trailing" }),
+ ];
+ const watchLogo = (
+
+ );
+ const watchTimer = (
+
+ );
return {
banner: (
@@ -95,14 +160,10 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
),
bannerSmall: (
-
-
- {logoLarge}
-
-
- {title}
-
- {bannerTimer}
+
+ {watchLogo}
+ {title}
+ {watchTimer}
),
compactLeading: logoSmall,