From 641dc25bee0c7d3ba2f354c139e7f8ae66a55eb5 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:49:17 -0500 Subject: [PATCH] feat: pages --- apps/expo/app/[...route].tsx | 30 ++++++ apps/expo/app/_layout.tsx | 3 +- apps/expo/app/{index.tsx => index.native.tsx} | 0 apps/expo/app/index.web.tsx | 98 ------------------- apps/expo/app/settings.tsx | 14 --- apps/tui/src/index.tsx | 4 +- packages/react-native-opentui/index.tsx | 8 +- packages/ui/src/index.tsx | 44 +++++---- packages/ui/src/settings.tsx | 5 + packages/ui/src/table.tsx | 71 +++++++++----- packages/ui/src/transactions.tsx | 63 ++++++++++++ 11 files changed, 177 insertions(+), 163 deletions(-) create mode 100644 apps/expo/app/[...route].tsx rename apps/expo/app/{index.tsx => index.native.tsx} (100%) delete mode 100644 apps/expo/app/index.web.tsx delete mode 100644 apps/expo/app/settings.tsx create mode 100644 packages/ui/src/settings.tsx create mode 100644 packages/ui/src/transactions.tsx diff --git a/apps/expo/app/[...route].tsx b/apps/expo/app/[...route].tsx new file mode 100644 index 0000000..c27e6be --- /dev/null +++ b/apps/expo/app/[...route].tsx @@ -0,0 +1,30 @@ +import { useLocalSearchParams } from "expo-router"; +import { Text } from "react-native"; +import { App } from "@money/ui"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { route: initalRoute } = useLocalSearchParams<{ route: string[] }>(); + const [route, setRoute] = useState(initalRoute[0]!); + + // detect back/forward + useEffect(() => { + const handler = () => { + const newRoute = window.location.pathname.slice(1); + // call your app’s page change logic + setRoute(newRoute); + }; + + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + + return ( + { + window.history.pushState({}, "", "/" + page); + }} + /> + ); +} diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index 877dbf2..e3ceeee 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -44,8 +44,7 @@ export default function RootLayout() { - - + diff --git a/apps/expo/app/index.tsx b/apps/expo/app/index.native.tsx similarity index 100% rename from apps/expo/app/index.tsx rename to apps/expo/app/index.native.tsx diff --git a/apps/expo/app/index.web.tsx b/apps/expo/app/index.web.tsx deleted file mode 100644 index e74cf47..0000000 --- a/apps/expo/app/index.web.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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(); - 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 ( - -
- - - {balances.map((bal, i) => - {bal.name}: {bal.current} ({bal.avaliable}) - )} - - - - {filteredTransactions.map((t, i) => { - setIdx(i); - }} style={{ backgroundColor: i == idx ? 'black' : undefined, cursor: 'default' as 'auto' }} key={t.id}> - - {new Date(t.datetime!).toDateString()} - 0 ? 'red' : 'green' }}> {lpad(t.amount)} - - {t.name.substring(0, 50)} - - )} - - - {JSON.stringify(JSON.parse(filteredTransactions.at(idx)?.json || "null"), null, 4)} - - - - ); -} diff --git a/apps/expo/app/settings.tsx b/apps/expo/app/settings.tsx deleted file mode 100644 index 5600435..0000000 --- a/apps/expo/app/settings.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SafeAreaView } from 'react-native-safe-area-context'; -import Header from '@/components/Header'; - -import { Settings } from "@money/ui"; - -export default function HomeScreen() { - return ( - -
- - - ); -} - diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 9a20e9f..d5f9027 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1,6 +1,6 @@ import { RGBA, TextAttributes, createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; -import { Settings } from "@money/ui"; +import { App } from "@money/ui"; import { ZeroProvider } from "@rocicorp/zero/react"; import { schema } from '@money/shared'; @@ -11,7 +11,7 @@ const auth = undefined; function Main() { return ( - + ); } diff --git a/packages/react-native-opentui/index.tsx b/packages/react-native-opentui/index.tsx index 0b1376f..ba790e8 100644 --- a/packages/react-native-opentui/index.tsx +++ b/packages/react-native-opentui/index.tsx @@ -14,8 +14,14 @@ export function View({ children, style }: ViewProps) { ? style.flexDirection : undefined : undefined; + const flex = style && + 'flex' in style + ? typeof style.flex == 'number' + ? style.flex + : undefined + : undefined; - return {children} + return {children} } diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index a9bd2ed..4b023f9 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -1,29 +1,31 @@ -import { Table, type Column } from "./table"; -import { useQuery } from "@rocicorp/zero/react"; -import { queries } from '@money/shared'; +import { useState } from "react"; +import { Transactions } from "./transactions"; +import { Text } from "react-native"; +import { Settings } from "./settings"; +import { useKeyboard } from "./useKeyboard"; - -export type Account = { - name: string; - createdAt: number; +type Page = "transactions" | "settings"; +type AppProps = { + page?: Page; + onPageChange?: (page: Page) => void; } -const COLUMNS: Column[] = [ - { name: 'createdAt', label: 'Created At', render: (n) => new Date(n).toDateString() }, - { name: 'amount', label: 'Amount' }, - { name: 'name', label: 'Name' }, -]; +export function App({ page, onPageChange }: AppProps) { + const [curr, setPage] = useState(page || "transactions"); + useKeyboard((key) => { + if (key.name == "1") { + setPage("transactions"); + if (onPageChange) + onPageChange("transactions"); + } else if (key.name == "2") { + setPage("settings"); + if (onPageChange) + onPageChange("settings"); + } + }); -export function Settings() { - const [items] = useQuery(queries.allTransactions(null)); - - return ( - - ) + return curr == "transactions" ? : ; } diff --git a/packages/ui/src/settings.tsx b/packages/ui/src/settings.tsx new file mode 100644 index 0000000..87c8927 --- /dev/null +++ b/packages/ui/src/settings.tsx @@ -0,0 +1,5 @@ +import { Text } from "react-native"; + +export function Settings() { + return Settings; +} diff --git a/packages/ui/src/table.tsx b/packages/ui/src/table.tsx index 1859ac8..9c4f943 100644 --- a/packages/ui/src/table.tsx +++ b/packages/ui/src/table.tsx @@ -4,8 +4,8 @@ import { useKeyboard } from "./useKeyboard"; const HEADER_COLOR = '#7158e2'; const TABLE_COLORS = [ - '#3c3c3c', - '#4b4b4b' + '#ddd', + '#eee' ]; const SELECTED_COLOR = '#f7b730'; @@ -14,16 +14,28 @@ const EXTRA = 5; export type ValidRecord = Record; -const TableContext = createContext<{ data: unknown[], columns: Column[], columnMap: Map }>({ +interface TableState { + data: unknown[]; + columns: Column[]; + columnMap: Map; + idx: number; + selectedFrom: number | undefined; +}; + + +const INITAL_STATE = { data: [], columns: [], columnMap: new Map(), -}); + idx: 0, + selectedFrom: undefined, +} satisfies TableState; + +export const Context = createContext(INITAL_STATE); export type Column = { name: string, label: string, render?: (i: number | string) => string }; - function renderCell(row: ValidRecord, column: Column): string { const cell = row[column.name]; if (cell == undefined) return 'n/a'; @@ -33,11 +45,12 @@ function renderCell(row: ValidRecord, column: Column): string { } -export interface TableProps { +export interface ProviderProps { data: T[]; columns: Column[]; + children: ReactNode; }; -export function Table({ data, columns }: TableProps) { +export function Provider({ data, columns, children }: ProviderProps) { const [idx, setIdx] = useState(0); const [selectedFrom, setSelectedFrom] = useState(); @@ -68,38 +81,46 @@ export function Table({ data, columns }: TableProps) { return ( - - - - {columns.map(column => {rpad(column.label, columnMap.get(column.name)! - column.label.length + EXTRA)})} - - {data.map((row, index) => { - const isSelected = index == idx || (selectedFrom != undefined && ((selectedFrom <= index && index <= idx) || (idx <= index && index <= selectedFrom))) - - return ( - - - - ); - })} - - + + {children} + ); } +export function Body() { + const { columns, data, columnMap, idx, selectedFrom } = use(Context); + return ( + + + {columns.map(column => {rpad(column.label, columnMap.get(column.name)! - column.label.length + EXTRA)})} + + {data.map((row, index) => { + const isSelected = index == idx || (selectedFrom != undefined && ((selectedFrom <= index && index <= idx) || (idx <= index && index <= selectedFrom))) + + return ( + + + + ); + })} + + ) + +} + interface RowProps { row: T; index: number; isSelected: boolean; } function TableRow({ row, isSelected }: RowProps) { - const { data, columns, columnMap } = use(TableContext); + const { data, columns, columnMap } = use(Context); return {columns.map(column => { const rendered = renderCell(row, column); - return {rpad(rendered, columnMap.get(column.name)! - rendered.length + EXTRA)}; + return {rpad(rendered, columnMap.get(column.name)! - rendered.length + EXTRA)}; })} } diff --git a/packages/ui/src/transactions.tsx b/packages/ui/src/transactions.tsx new file mode 100644 index 0000000..b741b8c --- /dev/null +++ b/packages/ui/src/transactions.tsx @@ -0,0 +1,63 @@ +import * as Table from "./table"; +import { useQuery } from "@rocicorp/zero/react"; +import { queries, type Transaction } from '@money/shared'; +import { use } from "react"; +import { View, Text } from "react-native"; + + +const FORMAT = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +export type Account = { + name: string; + createdAt: number; +} + +const COLUMNS: Table.Column[] = [ + { name: 'createdAt', label: 'Date', render: (n) => new Date(n).toDateString() }, + { name: 'amount', label: 'Amount' }, + { name: 'name', label: 'Name' }, +]; + + +export function Transactions() { + const [items] = useQuery(queries.allTransactions(null)); + + return ( + + + {/* Spacer */} + + + + ) +} + +function Selected() { + const { data, idx, selectedFrom } = use(Table.Context); + + if (selectedFrom == undefined) + return ( + + No items selected + + ); + + const from = Math.min(idx, selectedFrom); + const to = Math.max(idx, selectedFrom); + const selected = data.slice(from, to + 1) as Transaction[]; + const count = selected.length; + const sum = selected.reduce((prev, curr) => prev + curr.amount, 0); + + return ( + + {count} transaction{count == 1 ? "" : "s"} selected | ${FORMAT.format(sum)} + + ); +} + +