refactor: move into monorepo
20
apps/api/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@money/api",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.5",
|
||||
"@money/shared": "workspace:*",
|
||||
"better-auth": "^1.3.27",
|
||||
"hono": "^4.9.12",
|
||||
"plaid": "^39.0.0",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
}
|
||||
42
apps/api/src/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { expo } from "@better-auth/expo";
|
||||
import { drizzleSchema } from "@money/shared/db";
|
||||
import { db } from "./db";
|
||||
import { BASE_URL, HOST } from "@money/shared";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
schema: drizzleSchema,
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
trustedOrigins: [
|
||||
"http://localhost:8081",
|
||||
`exp://${HOST}:8081`,
|
||||
`${BASE_URL}:8081`,
|
||||
"https://money.koon.us",
|
||||
"money://",
|
||||
],
|
||||
advanced: {
|
||||
crossSubDomainCookies: {
|
||||
enabled: process.env.NODE_ENV == 'production',
|
||||
domain: "koon.us",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
expo(),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'koon-family',
|
||||
clientId: process.env.OAUTH_CLIENT_ID!,
|
||||
clientSecret: process.env.OAUTH_CLIENT_SECRET!,
|
||||
discoveryUrl: process.env.OAUTH_DISCOVERY_URL!,
|
||||
scopes: ["profile", "email"],
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
9
apps/api/src/db.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getDb } from "@money/shared/db";
|
||||
|
||||
if (!process.env.ZERO_UPSTREAM_DB) {
|
||||
throw new Error("ZERO_UPSTREAM_DB is not set");
|
||||
}
|
||||
|
||||
export const db = getDb({
|
||||
connectionString: process.env.ZERO_UPSTREAM_DB,
|
||||
});
|
||||
5
apps/api/src/hono.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AuthData } from "@money/shared/auth";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const getHono = () =>
|
||||
new Hono<{ Variables: { auth: AuthData | null } }>();
|
||||
56
apps/api/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { authDataSchema } from "@money/shared/auth";
|
||||
import { BASE_URL } from "@money/shared";
|
||||
import { cors } from "hono/cors";
|
||||
import { auth } from "./auth";
|
||||
import { getHono } from "./hono";
|
||||
import { zero } from "./zero";
|
||||
|
||||
const app = getHono();
|
||||
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: ['https://money.koon.us', `${BASE_URL}:8081`],
|
||||
allowMethods: ["POST", "GET", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
const authHeader = c.req.raw.headers.get("Authorization");
|
||||
const cookie = authHeader?.split("Bearer ")[1];
|
||||
|
||||
const newHeaders = new Headers(c.req.raw.headers);
|
||||
|
||||
if (cookie) {
|
||||
newHeaders.set("Cookie", cookie);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({ headers: newHeaders });
|
||||
|
||||
if (!session) {
|
||||
c.set("auth", null);
|
||||
return next();
|
||||
}
|
||||
c.set("auth", authDataSchema.parse(session));
|
||||
return next();
|
||||
});
|
||||
|
||||
app.route("/api/zero", zero);
|
||||
|
||||
app.get("/api", (c) => c.text("OK"));
|
||||
app.get("/", (c) => c.text("OK"));
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
||||
},
|
||||
(info) => {
|
||||
console.log(`Server is running on ${info.address}:${info.port}`);
|
||||
},
|
||||
);
|
||||
216
apps/api/src/zero.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
type ReadonlyJSONValue,
|
||||
type Transaction,
|
||||
withValidation,
|
||||
} from "@rocicorp/zero";
|
||||
import {
|
||||
handleGetQueriesRequest,
|
||||
PushProcessor,
|
||||
ZQLDatabase,
|
||||
} from "@rocicorp/zero/server";
|
||||
import { PostgresJSConnection } from '@rocicorp/zero/pg';
|
||||
import postgres from 'postgres';
|
||||
import {
|
||||
createMutators as createMutatorsShared,
|
||||
isLoggedIn,
|
||||
queries,
|
||||
schema,
|
||||
type Mutators,
|
||||
type Schema,
|
||||
} from "@money/shared";
|
||||
import type { AuthData } from "@money/shared/auth";
|
||||
import { getHono } from "./hono";
|
||||
import { Configuration, CountryCode, PlaidApi, PlaidEnvironments, Products } from "plaid";
|
||||
import { randomUUID } from "crypto";
|
||||
import { db } from "./db";
|
||||
import { balance, plaidAccessTokens, plaidLink, transaction } from "@money/shared/db";
|
||||
import { eq, inArray, sql, type InferInsertModel } from "drizzle-orm";
|
||||
|
||||
|
||||
const configuration = new Configuration({
|
||||
basePath: process.env.PLAID_ENV == 'production' ? PlaidEnvironments.production : PlaidEnvironments.sandbox,
|
||||
baseOptions: {
|
||||
headers: {
|
||||
'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
|
||||
'PLAID-SECRET': process.env.PLAID_SECRET,
|
||||
}
|
||||
}
|
||||
});
|
||||
const plaidClient = new PlaidApi(configuration);
|
||||
|
||||
|
||||
const processor = new PushProcessor(
|
||||
new ZQLDatabase(
|
||||
new PostgresJSConnection(postgres(process.env.ZERO_UPSTREAM_DB! as string)),
|
||||
schema,
|
||||
),
|
||||
);
|
||||
|
||||
type Tx = Transaction<Schema>;
|
||||
|
||||
const createMutators = (authData: AuthData | null) => {
|
||||
const mutators = createMutatorsShared(authData);
|
||||
return {
|
||||
...mutators,
|
||||
link: {
|
||||
...mutators.link,
|
||||
async create() {
|
||||
isLoggedIn(authData);
|
||||
console.log("Creating Link token");
|
||||
const r = await plaidClient.linkTokenCreate({
|
||||
user: {
|
||||
client_user_id: authData.user.id,
|
||||
},
|
||||
client_name: "Koon Money",
|
||||
language: "en",
|
||||
products: [Products.Transactions],
|
||||
country_codes: [CountryCode.Us],
|
||||
hosted_link: {}
|
||||
});
|
||||
console.log("Result", r);
|
||||
const { link_token, hosted_link_url } = r.data;
|
||||
|
||||
if (!hosted_link_url) throw Error("No link in response");
|
||||
|
||||
await db.insert(plaidLink).values({
|
||||
id: randomUUID() as string,
|
||||
user_id: authData.user.id,
|
||||
link: hosted_link_url,
|
||||
token: link_token,
|
||||
});
|
||||
},
|
||||
|
||||
async get(_, { link_token }) {
|
||||
isLoggedIn(authData);
|
||||
|
||||
const linkResp = await plaidClient.linkTokenGet({
|
||||
link_token,
|
||||
});
|
||||
if (!linkResp) throw Error("No link respo");
|
||||
console.log(JSON.stringify(linkResp.data, null, 4));
|
||||
const publicToken = linkResp.data.link_sessions?.at(0)?.results?.item_add_results.at(0)?.public_token;
|
||||
|
||||
if (!publicToken) throw Error("No public token");
|
||||
const { data } = await plaidClient.itemPublicTokenExchange({
|
||||
public_token: publicToken,
|
||||
})
|
||||
|
||||
await db.insert(plaidAccessTokens).values({
|
||||
id: randomUUID(),
|
||||
userId: authData.user.id,
|
||||
token: data.access_token,
|
||||
});
|
||||
},
|
||||
|
||||
async updateTransactions() {
|
||||
isLoggedIn(authData);
|
||||
const accounts = await db.query.plaidAccessTokens.findMany({
|
||||
where: eq(plaidAccessTokens.userId, authData.user.id),
|
||||
});
|
||||
if (accounts.length == 0) {
|
||||
console.error("No accounts");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
const { data } = await plaidClient.transactionsGet({
|
||||
access_token: account.token,
|
||||
start_date: "2025-10-01",
|
||||
end_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
|
||||
const transactions = data.transactions.map(tx => ({
|
||||
id: randomUUID(),
|
||||
user_id: authData.user.id,
|
||||
plaid_id: tx.transaction_id,
|
||||
account_id: tx.account_id,
|
||||
name: tx.name,
|
||||
amount: tx.amount as any,
|
||||
datetime: tx.datetime ? new Date(tx.datetime) : new Date(tx.date),
|
||||
authorized_datetime: tx.authorized_datetime ? new Date(tx.authorized_datetime) : undefined,
|
||||
json: JSON.stringify(tx),
|
||||
} satisfies InferInsertModel<typeof transaction>));
|
||||
|
||||
await db.insert(transaction).values(transactions).onConflictDoNothing({
|
||||
target: transaction.plaid_id,
|
||||
});
|
||||
|
||||
const txReplacingPendingIds = data.transactions
|
||||
.filter(t => t.pending_transaction_id)
|
||||
.map(t => t.pending_transaction_id!);
|
||||
|
||||
await db.delete(transaction)
|
||||
.where(inArray(transaction.plaid_id, txReplacingPendingIds));
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
async updateBalences() {
|
||||
isLoggedIn(authData);
|
||||
const accounts = await db.query.plaidAccessTokens.findMany({
|
||||
where: eq(plaidAccessTokens.userId, authData.user.id),
|
||||
});
|
||||
if (accounts.length == 0) {
|
||||
console.error("No accounts");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
const { data } = await plaidClient.accountsBalanceGet({
|
||||
access_token: account.token
|
||||
});
|
||||
await db.insert(balance).values(data.accounts.map(bal => ({
|
||||
id: randomUUID(),
|
||||
user_id: authData.user.id,
|
||||
plaid_id: bal.account_id,
|
||||
avaliable: bal.balances.available as any,
|
||||
current: bal.balances.current as any,
|
||||
name: bal.name,
|
||||
}))).onConflictDoUpdate({
|
||||
target: balance.plaid_id,
|
||||
set: { current: sql.raw(`excluded.${balance.current.name}`), avaliable: sql.raw(`excluded.${balance.avaliable.name}`) }
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
} as const satisfies Mutators;
|
||||
}
|
||||
|
||||
const zero = getHono()
|
||||
.post("/mutate", async (c) => {
|
||||
const authData = c.get("auth");
|
||||
|
||||
const result = await processor.process(createMutators(authData), c.req.raw);
|
||||
|
||||
return c.json(result);
|
||||
})
|
||||
.post("/get-queries", async (c) => {
|
||||
const authData = c.get("auth");
|
||||
|
||||
const result = await handleGetQueriesRequest(
|
||||
(name, args) => ({ query: getQuery(authData, name, args) }),
|
||||
schema,
|
||||
c.req.raw,
|
||||
);
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
const validatedQueries = Object.fromEntries(
|
||||
Object.values(queries).map((q) => [q.queryName, withValidation(q)]),
|
||||
);
|
||||
|
||||
function getQuery(
|
||||
authData: AuthData | null,
|
||||
name: string,
|
||||
args: readonly ReadonlyJSONValue[],
|
||||
) {
|
||||
if (name in validatedQueries) {
|
||||
const q = validatedQueries[name];
|
||||
return q(authData, ...args);
|
||||
}
|
||||
throw new Error(`Unknown query: ${name}`);
|
||||
}
|
||||
|
||||
export { zero };
|
||||
14
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
6
apps/expo/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
48
apps/expo/app.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "money",
|
||||
"slug": "money",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "money",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-sqlite"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
56
apps/expo/app/_layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { ZeroProvider } from '@rocicorp/zero/react';
|
||||
import { useMemo } from 'react';
|
||||
import { authDataSchema } from '@money/shared/auth';
|
||||
import { Platform } from 'react-native';
|
||||
import type { ZeroOptions } from '@rocicorp/zero';
|
||||
import { schema, type Schema, createMutators, type Mutators, BASE_URL } from '@money/shared';
|
||||
import { expoSQLiteStoreProvider } from "@rocicorp/zero/react-native";
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: 'index',
|
||||
};
|
||||
|
||||
const kvStore = Platform.OS === "web" ? undefined : expoSQLiteStoreProvider();
|
||||
|
||||
export default function RootLayout() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
const authData = useMemo(() => {
|
||||
const result = authDataSchema.safeParse(session);
|
||||
return result.success ? result.data : null;
|
||||
}, [session]);
|
||||
|
||||
const cookie = useMemo(() => {
|
||||
return Platform.OS == 'web' ? undefined : authClient.getCookie();
|
||||
}, [session, isPending]);
|
||||
|
||||
const zeroProps = useMemo(() => {
|
||||
return {
|
||||
storageKey: 'money',
|
||||
kvStore,
|
||||
server: process.env.NODE_ENV == 'production' ? 'https://zero.koon.us' : `${BASE_URL}:4848`,
|
||||
userID: authData?.user.id ?? "anon",
|
||||
schema,
|
||||
mutators: createMutators(authData),
|
||||
auth: cookie,
|
||||
} as const satisfies ZeroOptions<Schema, Mutators>;
|
||||
}, [authData, cookie]);
|
||||
|
||||
return (
|
||||
<ZeroProvider {...zeroProps}>
|
||||
<Stack>
|
||||
<Stack.Protected guard={!isPending && !!session}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!isPending && !session}>
|
||||
<Stack.Screen name="auth" />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</ZeroProvider>
|
||||
);
|
||||
}
|
||||
18
apps/expo/app/auth.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Button, View } from "react-native";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { BASE_URL } from "@money/shared";
|
||||
|
||||
export default function Auth() {
|
||||
const onLogin = () => {
|
||||
authClient.signIn.oauth2({
|
||||
providerId: "koon-family",
|
||||
callbackURL: process.env.NODE_ENV == 'production' ? 'https://money.koon.us' : `${BASE_URL}:8081`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button onPress={onLogin} title="Login with Koon Family" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
35
apps/expo/app/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { RefreshControl, ScrollView, StatusBar, Text, View } from 'react-native';
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import { queries, type Mutators, type Schema } from '@money/shared';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const [balances] = useQuery(queries.getBalances(session));
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
// simulate async work
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView contentContainerStyle={{ paddingTop: StatusBar.currentHeight, flexGrow: 1 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />} style={{ paddingHorizontal: 10 }}>
|
||||
{balances.map(balance => <Balance key={balance.id} balance={balance} />)}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Balance({ balance }: { balance: { name: string, current: number, avaliable: number } }) {
|
||||
return <View style={{ backgroundColor: "#eee", borderColor: "#ddd", borderWidth: 1, marginBottom: 10, borderRadius: 10 }}>
|
||||
<Text style={{ fontSize: 15, textAlign: "center" }}>{balance.name}</Text>
|
||||
<Text style={{ fontSize: 30, textAlign: "center" }}>{balance.current}</Text>
|
||||
</View>
|
||||
}
|
||||
98
apps/expo/app/index.web.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, Image, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import { queries, type Mutators, type Schema } from '@money/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'expo-router';
|
||||
import Header from '@/components/Header';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const z = useZero<Schema, Mutators>();
|
||||
const [transactions] = useQuery(queries.allTransactions(session));
|
||||
const [balances] = useQuery(queries.getBalances(session));
|
||||
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [accountIdx, setAccountIdx] = useState(0);
|
||||
|
||||
const account = balances.at(accountIdx)!;
|
||||
|
||||
const filteredTransactions = transactions
|
||||
.filter(t => t.account_id == account.plaid_id)
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS != 'web') return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "j") {
|
||||
setIdx((prevIdx) => {
|
||||
if (prevIdx + 1 == filteredTransactions.length) return prevIdx;
|
||||
return prevIdx + 1
|
||||
});
|
||||
} else if (event.key === "k") {
|
||||
setIdx((prevIdx) => prevIdx == 0 ? 0 : prevIdx - 1);
|
||||
} else if (event.key == 'g') {
|
||||
setIdx(0);
|
||||
} else if (event.key == "G") {
|
||||
setIdx(transactions.length - 1);
|
||||
} else if (event.key == 'R') {
|
||||
z.mutate.link.updateTransactions();
|
||||
z.mutate.link.updateBalences();
|
||||
} else if (event.key == 'h') {
|
||||
setAccountIdx((prevIdx) => prevIdx == 0 ? 0 : prevIdx - 1);
|
||||
} else if (event.key == 'l') {
|
||||
setAccountIdx((prevIdx) => {
|
||||
if (prevIdx + 1 == balances.length) return prevIdx;
|
||||
return prevIdx + 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [filteredTransactions, balances]);
|
||||
|
||||
function lpad(n: number): string {
|
||||
const LEN = 9;
|
||||
const nstr = n.toFixed(2).toLocaleString();
|
||||
return Array.from({ length: LEN - nstr.length }).join(" ") + nstr;
|
||||
}
|
||||
|
||||
function uuu(t: typeof filteredTransactions[number]): string | undefined {
|
||||
if (!t.json) return;
|
||||
const j = JSON.parse(t.json);
|
||||
return j.counterparties.filter((c: any) => !!c.logo_url).at(0)?.logo_url || j.personal_finance_category_icon_url;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Header />
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<View style={{ backgroundColor: '' }}>
|
||||
{balances.map((bal, i) => <View key={bal.id} style={{ backgroundColor: i == accountIdx ? 'black' : undefined}}>
|
||||
<Text style={{ fontFamily: 'mono', color: i == accountIdx ? 'white' : undefined }}>{bal.name}: {bal.current} ({bal.avaliable})</Text>
|
||||
</View>)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
{filteredTransactions.map((t, i) => <Pressable onHoverIn={() => {
|
||||
setIdx(i);
|
||||
}} style={{ backgroundColor: i == idx ? 'black' : undefined, cursor: 'default' as 'auto' }} key={t.id}>
|
||||
<Text style={{ fontFamily: 'mono', color: i == idx ? 'white' : undefined }}>
|
||||
{new Date(t.datetime!).toDateString()}
|
||||
<Text style={{ color: t.amount > 0 ? 'red' : 'green' }}> {lpad(t.amount)}</Text>
|
||||
<Image style={{ width: 15, height: 15, marginHorizontal: 10 }} source={{ uri: uuu(t) || "" }} />
|
||||
{t.name.substring(0, 50)}
|
||||
</Text>
|
||||
</Pressable>)}
|
||||
</View>
|
||||
<ScrollView>
|
||||
<Text style={{ fontFamily: 'mono' }}>{JSON.stringify(JSON.parse(filteredTransactions.at(idx)?.json || "null"), null, 4)}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
130
apps/expo/app/settings.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, Linking, Platform, Pressable, Text, View } from 'react-native';
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import { queries, type Mutators, type Schema } from '@money/shared';
|
||||
import Header from '@/components/Header';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const onLogout = () => {
|
||||
authClient.signOut();
|
||||
}
|
||||
const z = useZero<Schema, Mutators>();
|
||||
const [plaidLink] = useQuery(queries.getPlaidLink(session));
|
||||
const [items] = useQuery(queries.getItems(session));
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<Header />
|
||||
|
||||
<UI
|
||||
columns={[
|
||||
{
|
||||
name: "Banks",
|
||||
items: items,
|
||||
renderItem: (item, props) => <Row {...props}>{item.name}</Row>
|
||||
},
|
||||
{
|
||||
name: "Family",
|
||||
items: [],
|
||||
renderItem() {
|
||||
return <View></View>;
|
||||
},
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
type Col<T> = {
|
||||
name: string;
|
||||
items: T[];
|
||||
renderItem: (item: T, props: { isSelected: boolean, isActive: boolean }) => ReactNode;
|
||||
}
|
||||
|
||||
type State = {
|
||||
idx: number;
|
||||
columns: Map<number, State>;
|
||||
};
|
||||
|
||||
function UI({ columns }: { columns: Col<any>[] }) {
|
||||
const [col, setCol] = useState(0);
|
||||
const [state, setState] = useState<State>({
|
||||
idx: 0,
|
||||
columns: new Map(
|
||||
Array.from({ length: columns.length })
|
||||
.map((_, i) => ([i, { idx: 0, columns: new Map() }]))
|
||||
)
|
||||
});
|
||||
|
||||
const getColState = (res: State): State => {
|
||||
let i = col;
|
||||
while (i > 0) {
|
||||
res = res.columns.get(res.idx)!;
|
||||
i--;
|
||||
}
|
||||
return res;
|
||||
|
||||
}
|
||||
|
||||
const colState = getColState(state);
|
||||
|
||||
const curr = columns.at(col)!;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS != 'web') return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "j") {
|
||||
setState((prev) => {
|
||||
if (prev.idx + 1 == colState.columns.size) return prev;
|
||||
return {...prev, ...{ idx: prev.idx + 1 }};
|
||||
});
|
||||
} else if (event.key === "k") {
|
||||
setState((prev) => {
|
||||
if (prev.idx == 0) return prev;
|
||||
return {...prev, ...{ idx: prev.idx - 1 }};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Column>
|
||||
{columns.map((c, i) => <Pressable onPress={() => { setCol(i) }}><Row isSelected={col == i} isActive={col == 0}>{c.name}</Row></Pressable>)}
|
||||
</Column>
|
||||
<Column>
|
||||
{curr.items.map((item, i) => curr.renderItem(item, { isSelected: colState.idx == i, isActive: col == 1 }))}
|
||||
</Column>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children, isSelected, isActive }: { children: ReactNode, isSelected: boolean, isActive: boolean }) {
|
||||
const color = isSelected ? 'white': undefined;
|
||||
const backgroundColor = isSelected ? (isActive ? 'black' : 'gray'): undefined;
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ fontFamily: 'mono', color, backgroundColor }}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
BIN
apps/expo/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/expo/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/expo/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/expo/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/expo/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/expo/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/expo/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/expo/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/expo/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/expo/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
70
apps/expo/components/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queries } from "@money/shared";
|
||||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { Link, usePathname, useRouter, type LinkProps } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { View, Text, Platform } from "react-native";
|
||||
|
||||
type Page = { name: string, href: LinkProps['href'] };
|
||||
const PAGES: Page[] = [
|
||||
{
|
||||
name: "Home",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [user] = useQuery(queries.me(session));
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS != 'web') return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "1" && event.ctrlKey) {
|
||||
router.push(PAGES.at(0)!.href);
|
||||
} else if (event.key === "2" && event.ctrlKey) {
|
||||
router.push(PAGES.at(1)!.href);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", backgroundColor: "#f7e2c8" }}>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
{PAGES.map(page => <Page
|
||||
key={page.name}
|
||||
name={page.name}
|
||||
href={page.href}
|
||||
/>)}
|
||||
</View>
|
||||
|
||||
<Link href={"#" as any}>
|
||||
<Text style={{ fontFamily: 'mono' }}>{user?.name} </Text>
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ name, href }: Page) {
|
||||
const path = usePathname();
|
||||
|
||||
return (
|
||||
<Link href={href }>
|
||||
<Text style={{ fontFamily: 'mono' }}>{path == href ? `[ ${name} ]` : ` ${name} `}</Text>
|
||||
</Link>
|
||||
)
|
||||
|
||||
}
|
||||
10
apps/expo/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
]);
|
||||
17
apps/expo/lib/auth-client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { BASE_URL } from "@money/shared";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NODE_ENV == 'production' ? 'https://money-api.koon.us' : `${BASE_URL}:3000`,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: "money",
|
||||
storagePrefix: "money",
|
||||
storage: SecureStore,
|
||||
}),
|
||||
genericOAuthClient(),
|
||||
]
|
||||
});
|
||||
10
apps/expo/metro.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
const config = getDefaultConfig(__dirname)
|
||||
|
||||
// Add wasm asset support
|
||||
config.resolver.assetExts.push("wasm");
|
||||
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
|
||||
module.exports = config;
|
||||
61
apps/expo/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@money/expo",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"build": "expo export --platform web",
|
||||
"lint": "expo lint",
|
||||
"db:migrate": "dotenv -- pnpm run --dir=shared db:migrate",
|
||||
"db:gen": "dotenv -- pnpm run --dir=shared generate:zero"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "^1.3.27",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@money/shared": "workspace:*",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@rocicorp/zero": "^0.23.2025090100",
|
||||
"better-auth": "^1.3.27",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"expo": "~54.0.13",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.9",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.11",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.8",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/react": "~19.1.0",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
11
apps/expo/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||