Convert Beenvoice to a Turborepo monorepo

This commit is contained in:
2026-08-16 22:13:42 -04:00
parent d057ba208d
commit 6c74436092
54 changed files with 4140 additions and 4147 deletions
+5 -4
View File
@@ -1,4 +1,4 @@
# beenvoice-app — agent notes
# Beenvoice mobile — agent notes
Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config.
@@ -10,7 +10,8 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
## Conventions
- **Package manager**: Bun only
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice-web/src/*`)
- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../web/src/*`)
- **Shared domain**: import platform-neutral behavior from `@beenvoice/domain`
- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts`
- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`)
- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`)
@@ -28,6 +29,6 @@ Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) b
| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` |
| Time clock | `components/time-clock/TimeClockPanel.tsx` |
## Server repo
## Server workspace
Sibling `../beenvoice-web` — run `bun run dev` on :3000 before mobile dev.
Sibling `../web` — run `bun run dev` at the repository root to start both apps.
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10 -10
View File
@@ -1,20 +1,20 @@
# beenvoice Mobile
Expo companion for [beenvoice-web](../beenvoice-web) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API** and **better-auth** sessions as the web app.
Expo companion for the [Beenvoice web app](../web/README.md) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API**, Better Auth sessions, and platform-neutral domain package.
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
## Prerequisites
- [Bun](https://bun.sh) 1.3+
- beenvoice API running ([setup](../beenvoice-web/README.md))
- Beenvoice API running ([setup](../web/README.md))
- Xcode + iOS Simulator (or device) for native dev build
- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client`
## Setup
```bash
cd beenvoice-app
cd apps/mobile
bun install
cp .env.example .env
```
@@ -36,11 +36,11 @@ Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `been
## Run
```bash
# Terminal 1 — API
cd ../beenvoice-web && bun run dev
# Terminal 1, from the repository root — API + Metro
bun run dev
# Terminal 2 — mobile (builds native app if needed)
cd beenvoice-app && bun run ios
# Terminal 2, from the repository root — native app
bun run --filter @beenvoice/mobile ios
```
Metro uses port **8082** (avoids other Expo projects on 8081).
@@ -145,6 +145,6 @@ widgets/ # iOS Live Activity (TimeClockActivity)
## Related
- [beenvoice-web README](../beenvoice-web/README.md)
- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md)
- [Workspace root README](../README.md)
- [Web README](../web/README.md)
- [Web architecture](../web/docs/ARCHITECTURE.md)
- [Workspace root README](../../README.md)
-1516
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -247,7 +247,7 @@ Optional: iPad 12.9" if `supportsTablet: true` — use iPad simulator or “Run
See **[IOS_LOCAL_RELEASE.md](./IOS_LOCAL_RELEASE.md)** for the full guide.
```bash
cd beenvoice-app
cd apps/mobile
cp .ios-release.env.example .ios-release.env # once — add Team ID + API key
bun run ios:release:upload # archive + upload to TestFlight
```
@@ -257,7 +257,7 @@ Requires Xcode on macOS, Apple Developer membership, and an App Store Connect AP
### Option B — EAS (Expo cloud build)
```bash
cd beenvoice-app
cd apps/mobile
# Production iOS build (auto-increments build number)
eas build --platform ios --profile production
+5 -4
View File
@@ -1,4 +1,4 @@
# beenvoice-app architecture
# Beenvoice mobile architecture
Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tRPC + better-auth. Requires a **development build** (not Expo Go) for widgets, SecureStore auth, and biometrics.
@@ -10,7 +10,8 @@ Dense reference for the Expo 57 mobile companion. Talks to **beenvoice** over tR
| UI | React Native 0.85, `@expo/ui` (SwiftUI widgets) |
| API | tRPC 11 + TanStack Query, SuperJSON |
| Auth | better-auth + `@better-auth/expo``expo-secure-store` |
| Types | `AppRouter` imported from `../beenvoice-web/src/server/api/root` |
| Types | `AppRouter` imported type-only from sibling `apps/web` |
| Shared domain | `@beenvoice/domain` workspace package |
## Boot sequence
@@ -271,6 +272,6 @@ Requires beenvoice with:
- `@better-auth/expo` in `src/lib/auth.ts`
- `trustedOrigins` including `beenvoice://` and `exp://`
- Postgres running (`docker compose -f docker-compose.dev.yml up -d db`)
- Postgres running (`bun run --filter @beenvoice/web docker:up` from the repository root)
See [beenvoice-web/docs/ARCHITECTURE.md](../../beenvoice-web/docs/ARCHITECTURE.md).
See the [web architecture](../../web/docs/ARCHITECTURE.md).
+1 -1
View File
@@ -13,7 +13,7 @@ Archive and upload **beenvoice** to App Store Connect using Xcode on your Mac
## One-time setup
```bash
cd beenvoice-app
cd apps/mobile
cp .ios-release.env.example .ios-release.env
```
+3 -3
View File
@@ -1,4 +1,4 @@
# beenvoice-app documentation
# Beenvoice mobile documentation
| Document | Description |
|----------|-------------|
@@ -8,5 +8,5 @@
## Related
- [beenvoice-web docs](../../beenvoice-web/docs/README.md) — server API and web app
- [Workspace README](../../README.md) — full-stack layout
- [Web docs](../../web/docs/README.md) — server API and web app
- [Workspace README](../../../README.md) — full-stack layout
+4 -11
View File
@@ -1,11 +1,4 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
export {
EXPENSE_CATEGORIES,
type ExpenseCategory,
} from "@beenvoice/domain/expense-categories";
+8 -11
View File
@@ -1,19 +1,16 @@
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue";
import { getEffectiveInvoiceStatus } from "@beenvoice/domain/invoice-status";
import type { EffectiveInvoiceStatus } from "@beenvoice/domain/invoice-status";
export type InvoiceStatus = EffectiveInvoiceStatus;
export function getInvoiceStatus(invoice: {
status: string;
dueDate: Date | string;
}): InvoiceStatus {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
const today = new Date();
const due = new Date(invoice.dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
if (due < today) return "overdue";
return "sent";
if (invoice.status === "paid" || invoice.status === "draft") {
return invoice.status;
}
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
}
export const statusLabels: Record<InvoiceStatus, string> = {
+5 -128
View File
@@ -1,128 +1,5 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
subtotal: number | null;
tax: number | null;
vendor: string | null;
items: ReceiptLineItem[];
rawLines: string[];
};
export type ReceiptLineItem = {
id: string;
name: string;
amount: number;
rawLine: string;
};
const AMOUNT_PATTERNS = [
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
];
const DATE_PATTERNS = [
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
];
const SUBTOTAL_PATTERNS = [
/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const TAX_PATTERNS = [
/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
];
const NON_ITEM_LINE =
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
function parseAmount(text: string): number | null {
for (const pattern of AMOUNT_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value > 0) return value;
}
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((m) => Number(m[1]!.replace(/,/g, "")))
.filter((n) => Number.isFinite(n) && n > 0);
return amounts.length > 0 ? Math.max(...amounts) : null;
}
function parseFirstMatchingAmount(
text: string,
patterns: RegExp[],
): number | null {
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const value = Number(match[1].replace(/,/g, ""));
if (Number.isFinite(value) && value >= 0) return value;
}
return null;
}
function parseDate(text: string): Date | null {
for (const pattern of DATE_PATTERNS) {
const match = text.match(pattern);
if (!match?.[1]) continue;
const parsed = new Date(match[1]);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function parseVendor(lines: string[]): string | null {
const candidate = lines.find((line) => line.trim().length >= 3);
return candidate?.trim().slice(0, 120) ?? null;
}
function parseLineItems(lines: string[]): ReceiptLineItem[] {
const items: ReceiptLineItem[] = [];
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/\s+/g, " ").trim();
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
if (!match?.[1] || !match[2]) continue;
const amount = Number(match[2].replace(/,/g, ""));
const name = match[1]
.replace(/^\d+\s*[xX]\s+/, "")
.replace(/\s+\d+\s*[xX]\s*$/, "")
.trim();
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
items.push({
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
name: name.slice(0, 80),
amount,
rawLine,
});
}
return items.slice(0, 30);
}
export function parseReceiptText(text: string): ReceiptParseResult {
const normalized = text.replace(/\r/g, "\n").trim();
const rawLines = normalized
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return {
amount: parseAmount(normalized),
date: parseDate(normalized),
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
vendor: parseVendor(rawLines),
items: parseLineItems(rawLines),
rawLines,
};
}
export {
parseReceiptText,
type ReceiptLineItem,
type ReceiptParseResult,
} from "@beenvoice/domain/receipt-parse";
+12 -23
View File
@@ -1,13 +1,16 @@
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
import {
DEFAULT_CLOCK_DESCRIPTION,
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
} from "@beenvoice/domain/time-clock";
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export {
DEFAULT_CLOCK_DESCRIPTION,
formatElapsedHoursMinutes,
formatElapsedSeconds,
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
type ClockOutOutcome,
} from "@beenvoice/domain/time-clock";
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
@@ -26,20 +29,6 @@ export function formatRunningTimerLabel(description?: string | null): string {
return trimmed;
}
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
}
/** Hours and minutes only — for Live Activity / compact displays. */
export function formatElapsedHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
export function resolveEffectiveHourlyRate(
rateText: string,
clientDefaultRate?: number | null,
+52 -42
View File
@@ -1,67 +1,77 @@
{
"name": "beenvoice-app",
"name": "@beenvoice/mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@better-auth/expo": "^1.6.19",
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/playfair-display": "^0.4.2",
"@expo/ui": "~57.0.11",
"@expo/vector-icons": "^15.1.1",
"@beenvoice/domain": "workspace:*",
"@better-auth/expo": "1.6.19",
"@expo-google-fonts/inter": "0.4.2",
"@expo-google-fonts/playfair-display": "0.4.2",
"@expo/ui": "57.0.11",
"@expo/vector-icons": "15.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0",
"@react-native-picker/picker": "^2.11.4",
"@tanstack/react-query": "^5.101.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"better-auth": "^1.6.19",
"expo": "^57.0.9",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.11",
"expo-constants": "~57.0.11",
"expo-dev-client": "~57.0.12",
"expo-file-system": "~57.0.4",
"expo-font": "~57.0.1",
"expo-image": "~57.0.3",
"expo-image-picker": "~57.0.10",
"expo-linear-gradient": "~57.0.1",
"expo-linking": "~57.0.6",
"expo-local-authentication": "~57.0.2",
"expo-mlkit-ocr": "^0.2.7",
"expo-network": "~57.0.1",
"expo-notifications": "~57.0.11",
"expo-router": "~57.0.13",
"expo-secure-store": "~57.0.1",
"expo-sharing": "~57.0.12",
"expo-splash-screen": "~57.0.6",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-web-browser": "~57.0.2",
"expo-widgets": "~57.0.10",
"@react-native-picker/picker": "2.11.4",
"@tanstack/react-query": "5.101.0",
"@trpc/client": "11.17.0",
"@trpc/react-query": "11.17.0",
"@trpc/server": "11.17.0",
"better-auth": "1.6.19",
"expo": "57.0.13",
"expo-blur": "57.0.2",
"expo-build-properties": "57.0.11",
"expo-constants": "57.0.11",
"expo-dev-client": "57.0.12",
"expo-file-system": "57.0.4",
"expo-font": "57.0.1",
"expo-image": "57.0.3",
"expo-image-picker": "57.0.10",
"expo-linear-gradient": "57.0.1",
"expo-linking": "57.0.6",
"expo-local-authentication": "57.0.2",
"expo-mlkit-ocr": "0.2.7",
"expo-modules-core": "57.0.11",
"expo-network": "57.0.1",
"expo-notifications": "57.0.11",
"expo-router": "57.0.13",
"expo-secure-store": "57.0.1",
"expo-sharing": "57.0.12",
"expo-splash-screen": "57.0.6",
"expo-status-bar": "57.0.1",
"expo-symbols": "57.0.2",
"expo-web-browser": "57.0.2",
"expo-widgets": "57.0.10",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-gesture-handler": "3.2.1",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-web": "0.21.2",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"superjson": "^2.2.6"
"superjson": "2.2.6"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/react": "~19.2.2",
"react-native-svg-transformer": "^1.5.3",
"typescript": "~6.0.3"
"@expo/config-plugins": "57.0.8",
"@types/bun": "1.3.14",
"@types/react": "19.2.17",
"react-native-svg-transformer": "1.5.3",
"typescript": "6.0.3",
"xcode": "3.0.1"
},
"scripts": {
"dev": "expo start --dev-client --port 8082",
"start": "expo start --dev-client --port 8082",
"android": "expo run:android --port 8082",
"ios": "expo run:ios --port 8082",
"ios:release": "bash scripts/ios-release.sh",
"ios:release:upload": "bash scripts/ios-release.sh --upload",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "bun test",
"web": "expo start --web"
},
@@ -1,18 +1,20 @@
/// <reference types="bun" />
import { afterEach, describe, expect, test } from "bun:test";
import {
EXPENSE_CATEGORIES as domainExpenseCategories,
formatElapsedSeconds as formatDomainElapsedSeconds,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
import { fetchAuthCapabilities } from "../lib/auth-capabilities";
import { EXPENSE_CATEGORIES as appExpenseCategories } from "../lib/expense-categories";
import { getInvoiceStatus } from "../lib/invoice-status";
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { EXPENSE_CATEGORIES as webExpenseCategories } from "../../beenvoice-web/src/lib/expense-categories";
import { getEffectiveInvoiceStatus } from "../../beenvoice-web/src/lib/invoice-status";
import { safeCallbackPath } from "../../beenvoice-web/src/lib/safe-callback-url";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import {
formatElapsedSeconds as formatWebElapsedSeconds,
normalizeOptionalId,
} from "../../beenvoice-web/src/lib/time-clock";
} from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch;
@@ -85,7 +87,7 @@ describe("timer parity", () => {
test("elapsed time formatting is identical", () => {
for (const seconds of [0, 59, 60, 3_661, 86_399]) {
expect(formatAppElapsedSeconds(seconds)).toBe(
formatWebElapsedSeconds(seconds),
formatDomainElapsedSeconds(seconds),
);
}
});
@@ -99,6 +101,6 @@ describe("timer parity", () => {
describe("expense parity", () => {
test("web and mobile expose the same category vocabulary", () => {
expect([...appExpenseCategories]).toEqual([...webExpenseCategories]);
expect([...appExpenseCategories]).toEqual([...domainExpenseCategories]);
});
});
+3 -3
View File
@@ -5,9 +5,9 @@
"skipLibCheck": true,
"paths": {
"@/*": ["./*"],
"~/*": ["../beenvoice-web/src/*"],
"src/*": ["../beenvoice-web/src/*"],
"beenvoice/*": ["../beenvoice-web/src/*"]
"~/*": ["../web/src/*"],
"src/*": ["../web/src/*"],
"beenvoice/*": ["../web/src/*"]
}
},
"include": [