diff --git a/apps/expo/app/[...route].tsx b/apps/expo/app/[...route].tsx index 1ebd9b5..a95ec52 100644 --- a/apps/expo/app/[...route].tsx +++ b/apps/expo/app/[...route].tsx @@ -13,7 +13,7 @@ export default function Page() { useEffect(() => { const handler = () => { - const newRoute = window.location.pathname.slice(1); + const newRoute = window.location.pathname.slice(1) + "/"; setRoute(newRoute); }; diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 99cb0fb..3f037df 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1,8 +1,8 @@ import { createCliRenderer } from "@opentui/core"; -import { createRoot, useKeyboard } from "@opentui/react"; +import { createRoot, useKeyboard, useRenderer } from "@opentui/react"; import { App, type Route } from "@money/ui"; import { ZeroProvider } from "@rocicorp/zero/react"; -import { schema } from "@money/shared"; +import { schema, createMutators } from "@money/shared"; import { useState } from "react"; import { AuthClientLayer, getAuth } from "./auth"; import { Effect } from "effect"; @@ -13,24 +13,14 @@ import { config } from "./config"; function Main({ auth }: { auth: AuthData }) { const [route, setRoute] = useState("/"); + const renderer = useRenderer(); useKeyboard((key) => { if (key.name == "c" && key.ctrl) process.exit(0); + if (key.name == "i" && key.meta) renderer.console.toggle(); }); - return ( - - - - ); + return ; } const auth = await Effect.runPromise( @@ -40,4 +30,17 @@ const auth = await Effect.runPromise( ), ); const renderer = await createCliRenderer({ exitOnCtrlC: false }); -createRoot(renderer).render(
); +createRoot(renderer).render( + +
+ , +); diff --git a/package.json b/package.json index 544c1b3..6414165 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,10 @@ "private": true, "scripts": { "dev": "process-compose up -p 0", - "tui": "bun run --hot apps/tui/src/index.tsx" + "tui": "pnpm --filter=@money/tui run build && pnpm --filter=@money/tui run start" }, "pnpm": { - "onlyBuiltDependencies": [ - "@rocicorp/zero-sqlite3" - ], - "ignoredBuiltDependencies": [ - "esbuild", - "protobufjs", - "unrs-resolver" - ] + "onlyBuiltDependencies": ["@rocicorp/zero-sqlite3"], + "ignoredBuiltDependencies": ["esbuild", "protobufjs", "unrs-resolver"] } } diff --git a/packages/react-native-opentui/index.tsx b/packages/react-native-opentui/index.tsx index 9ba7d33..b7eb769 100644 --- a/packages/react-native-opentui/index.tsx +++ b/packages/react-native-opentui/index.tsx @@ -8,6 +8,7 @@ import type { StyleProp, ViewStyle, LinkingImpl, + TextInputProps, } from "react-native"; import { useTerminalDimensions } from "@opentui/react"; import { RGBA } from "@opentui/core"; @@ -56,10 +57,10 @@ export function View({ children, style }: ViewProps) { ? typeof style.backgroundColor == "string" ? style.backgroundColor.startsWith("rgba(") ? (() => { - const parts = style.backgroundColor.split("(")[1].split(")")[0]; - const [r, g, b, a] = parts.split(",").map(parseFloat); - return RGBA.fromInts(r, g, b, a * 255); - })() + const parts = style.backgroundColor.split("(")[1].split(")")[0]; + const [r, g, b, a] = parts.split(",").map(parseFloat); + return RGBA.fromInts(r, g, b, a * 255); + })() : style.backgroundColor : undefined : undefined; @@ -107,10 +108,10 @@ export function Pressable({ ? typeof style.backgroundColor == "string" ? style.backgroundColor.startsWith("rgba(") ? (() => { - const parts = style.backgroundColor.split("(")[1].split(")")[0]; - const [r, g, b, a] = parts.split(",").map(parseFloat); - return RGBA.fromInts(r, g, b, a * 255); - })() + const parts = style.backgroundColor.split("(")[1].split(")")[0]; + const [r, g, b, a] = parts.split(",").map(parseFloat); + return RGBA.fromInts(r, g, b, a * 255); + })() : style.backgroundColor : undefined : undefined; @@ -174,9 +175,9 @@ export function Pressable({ onMouseDown={ onPress ? (_event) => { - // @ts-ignore - onPress(); - } + // @ts-ignore + onPress(); + } : undefined } backgroundColor={bg} @@ -226,6 +227,32 @@ export function Modal({ children, visible }: ModalProps) { ); } +export function TextInput({ + defaultValue, + onChangeText, + onKeyPress, +}: TextInputProps) { + return ( + + // @ts-ignore + onKeyPress({ + nativeEvent: { + key: key.name == "return" ? "Enter" : key.name, + }, + }) + } + placeholder={defaultValue} + /> + ); +} + export const Platform = { OS: "tui", }; diff --git a/packages/shared/src/db/schema/public.ts b/packages/shared/src/db/schema/public.ts index 87ae188..49e7c21 100644 --- a/packages/shared/src/db/schema/public.ts +++ b/packages/shared/src/db/schema/public.ts @@ -1,3 +1,4 @@ +import { relations } from "drizzle-orm"; import { boolean, decimal, @@ -87,4 +88,17 @@ export const category = pgTable("category", { createdBy: text("created_by").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), + removedBy: text("removed_by"), + removedAt: timestamp("removed_at"), }); + +export const budgetRelations = relations(budget, ({ many }) => ({ + categories: many(category), +})); + +export const categoryRelations = relations(category, ({ one }) => ({ + budget: one(budget, { + fields: [category.budgetId], + references: [budget.id], + }), +})); diff --git a/packages/shared/src/mutators.ts b/packages/shared/src/mutators.ts index 8f3cff6..4159ca4 100644 --- a/packages/shared/src/mutators.ts +++ b/packages/shared/src/mutators.ts @@ -8,10 +8,10 @@ type Tx = Transaction; export function createMutators(authData: AuthData | null) { return { link: { - async create() {}, - async get(tx: Tx, { link_token }: { link_token: string }) {}, - async updateTransactions() {}, - async updateBalences() {}, + async create() { }, + async get(tx: Tx, { link_token }: { link_token: string }) { }, + async updateTransactions() { }, + async updateBalences() { }, async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) { isLoggedIn(authData); for (const id of accountIds) { @@ -40,7 +40,10 @@ export function createMutators(authData: AuthData | null) { }, }, budget: { - async create(tx: Tx, { id }: { id: string }) { + async create( + tx: Tx, + { id, categoryId }: { id: string; categoryId: string }, + ) { isLoggedIn(authData); await tx.mutate.budget.insert({ id, @@ -48,6 +51,60 @@ export function createMutators(authData: AuthData | null) { label: "New Budget", createdBy: authData.user.id, }); + await tx.mutate.category.insert({ + id: categoryId, + budgetId: id, + amount: 0, + every: "week", + order: 1000, + label: "My category", + color: "#f06", + createdBy: authData.user.id, + }); + }, + async delete(tx: Tx, { id }: { id: string }) { + isLoggedIn(authData); + await tx.mutate.budget.delete({ + id, + }); + }, + async createCategory( + tx: Tx, + { + id, + budgetId, + order, + }: { id: string; budgetId: string; order?: number }, + ) { + isLoggedIn(authData); + tx.mutate.category.insert({ + id, + budgetId, + amount: 0, + every: "week", + order: order || 0, + label: "My category", + color: "#f06", + createdBy: authData.user.id, + }); + }, + async deleteCategory(tx: Tx, { id }: { id: string }) { + isLoggedIn(authData); + tx.mutate.category.update({ + id, + removedAt: new Date().getTime(), + removedBy: authData.user.id, + }); + }, + async updateCategory( + tx: Tx, + { id, label }: { id: string; label: string }, + ) { + isLoggedIn(authData); + tx.mutate.category.update({ + id, + label, + }); }, }, } as const; diff --git a/packages/shared/src/queries.ts b/packages/shared/src/queries.ts index 99e2b2c..f6d4b41 100644 --- a/packages/shared/src/queries.ts +++ b/packages/shared/src/queries.ts @@ -65,7 +65,11 @@ export const queries = { z.tuple([]), (authData: AuthData | null) => { isLoggedIn(authData); - return builder.budget.limit(10); + return builder.budget + .related("categories", (q) => + q.where("removedAt", "IS", null).orderBy("order", "desc"), + ) + .limit(10); }, ), getBudgetCategories: syncedQueryWithContext( diff --git a/packages/shared/src/zero-schema.gen.ts b/packages/shared/src/zero-schema.gen.ts index d98eaad..3382efb 100644 --- a/packages/shared/src/zero-schema.gen.ts +++ b/packages/shared/src/zero-schema.gen.ts @@ -273,6 +273,26 @@ export const schema = { >, serverName: "updated_at", }, + removedBy: { + type: "string", + optional: true, + customType: null as unknown as ZeroCustomType< + ZeroSchema, + "category", + "removedBy" + >, + serverName: "removed_by", + }, + removedAt: { + type: "number", + optional: true, + customType: null as unknown as ZeroCustomType< + ZeroSchema, + "category", + "removedAt" + >, + serverName: "removed_at", + }, }, primaryKey: ["id"], }, @@ -582,7 +602,28 @@ export const schema = { serverName: "user", }, }, - relationships: {}, + relationships: { + budget: { + categories: [ + { + sourceField: ["id"], + destField: ["budgetId"], + destSchema: "category", + cardinality: "many", + }, + ], + }, + category: { + budget: [ + { + sourceField: ["budgetId"], + destField: ["id"], + destSchema: "budget", + cardinality: "one", + }, + ], + }, + }, enableLegacyQueries: false, enableLegacyMutators: false, } as const; diff --git a/packages/ui/components/Button.tsx b/packages/ui/components/Button.tsx index 8196ff5..fb6f2a8 100644 --- a/packages/ui/components/Button.tsx +++ b/packages/ui/components/Button.tsx @@ -1,5 +1,6 @@ import { useEffect, type ReactNode } from "react"; import { Text, Pressable } from "react-native"; +import { useShortcut, type Key } from "../lib/shortcuts"; type WithRequired = T & { [P in K]-?: T[P] }; @@ -7,7 +8,7 @@ export interface ButtonProps { children: ReactNode; onPress?: () => void; variant?: "default" | "secondary" | "destructive"; - shortcut?: string; + shortcut?: Key; } const STYLES: Record< @@ -22,16 +23,9 @@ const STYLES: Record< export function Button({ children, variant, onPress, shortcut }: ButtonProps) { const { backgroundColor, color } = STYLES[variant || "default"]; - // if (shortcut) { - // useKeys((key) => { - // if ( - // typeof shortcut == "object" - // ? key.name == shortcut.name - // : key.name == shortcut - // ) - // return onPress; - // }); - // } + if (shortcut && onPress) { + useShortcut(shortcut, onPress); + } return ( diff --git a/packages/ui/components/Dialog.tsx b/packages/ui/components/Dialog.tsx index 746097a..b1395c3 100644 --- a/packages/ui/components/Dialog.tsx +++ b/packages/ui/components/Dialog.tsx @@ -1,11 +1,12 @@ -import { createContext, type ReactNode } from "react"; +import { createContext, use, type ReactNode } from "react"; import { Modal, View, Text } from "react-native"; +import { useShortcut } from "../lib/shortcuts"; export interface DialogState { close?: () => void; } export const Context = createContext({ - close: () => {}, + close: () => { }, }); interface ProviderProps { @@ -37,6 +38,9 @@ interface ContentProps { children: ReactNode; } export function Content({ children }: ContentProps) { + const { close } = use(Context); + useShortcut("escape", () => close?.()); + return ( { + key: Key; + handler: (params: { selected: T[]; index: number }) => void; +} + export interface ProviderProps { data: T[]; columns: Column[]; children: ReactNode; - onKey?: (event: KeyEvent, selected: T[]) => void; + shortcuts?: TableShortcut[]; } export function Provider({ data, columns, children, - onKey, + shortcuts, }: ProviderProps) { const [idx, setIdx] = useState(0); const [selectedFrom, setSelectedFrom] = useState(); @@ -68,9 +74,30 @@ export function Provider({ useShortcut("j", () => { setIdx((prev) => Math.min(prev + 1, data.length - 1)); }); + useShortcut("down", () => { + setIdx((prev) => Math.min(prev + 1, data.length - 1)); + }); useShortcut("k", () => { setIdx((prev) => Math.max(prev - 1, 0)); }); + useShortcut("up", () => { + setIdx((prev) => Math.max(prev - 1, 0)); + }); + + useEffect(() => { + setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0)); + }, [data]); + + if (shortcuts) { + for (const shortcut of shortcuts) { + useShortcut(shortcut.key, () => { + const from = selectedFrom ? Math.min(idx, selectedFrom) : idx; + const to = selectedFrom ? Math.max(idx, selectedFrom) : idx; + const selected = data.slice(from, to + 1); + shortcut.handler({ selected, index: idx }); + }); + } + } const columnMap = new Map( columns.map((col) => { diff --git a/packages/ui/lib/shortcuts/Debug.tsx b/packages/ui/lib/shortcuts/Debug.tsx index 717ef77..e984a1c 100644 --- a/packages/ui/lib/shortcuts/Debug.tsx +++ b/packages/ui/lib/shortcuts/Debug.tsx @@ -16,9 +16,11 @@ export function ShortcutDebug() { bottom: 0, right: 0, backgroundColor: "black", + padding: 10, }} > - + Registered: + {entries .values() .map(([key, _]) => key) diff --git a/packages/ui/lib/shortcuts/Provider.web.tsx b/packages/ui/lib/shortcuts/Provider.web.tsx index a384a9b..387344d 100644 --- a/packages/ui/lib/shortcuts/Provider.web.tsx +++ b/packages/ui/lib/shortcuts/Provider.web.tsx @@ -1,10 +1,23 @@ import type { ReactNode } from "react"; import { keysStore } from "./store"; +import type { KeyName } from "./types"; + +const KEY_MAP: { [k: string]: KeyName } = { + Escape: "escape", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", +}; if (typeof window !== "undefined") { window.addEventListener("keydown", (e) => { - const fn = keysStore.getHandler(e.key); - fn?.(); + const key = Object.hasOwn(KEY_MAP, e.key) ? KEY_MAP[e.key]! : e.key; + const fn = keysStore.getHandler(key); + // console.log(e.key); + if (!fn) return; + e.preventDefault(); + fn(); }); } diff --git a/packages/ui/lib/shortcuts/hooks.ts b/packages/ui/lib/shortcuts/hooks.ts index 38454c0..2dd1aa8 100644 --- a/packages/ui/lib/shortcuts/hooks.ts +++ b/packages/ui/lib/shortcuts/hooks.ts @@ -1,14 +1,18 @@ import { useEffect, useRef } from "react"; import { keysStore } from "./store"; +import type { Key } from "./types"; +import { enforceKeyOptions } from "./util"; -export const useShortcut = (key: string, handler: () => void) => { +export const useShortcut = (key: Key, handler: () => void) => { + const keyOptions = enforceKeyOptions(key); + const keyName = keyOptions.name; const ref = useRef(handler); ref.current = handler; useEffect(() => { - keysStore.register(key, ref); + keysStore.register(keyName, ref); return () => { - keysStore.deregister(key); + keysStore.deregister(keyName); }; }, []); }; diff --git a/packages/ui/lib/shortcuts/index.ts b/packages/ui/lib/shortcuts/index.ts index eb0b8b4..fbc6355 100644 --- a/packages/ui/lib/shortcuts/index.ts +++ b/packages/ui/lib/shortcuts/index.ts @@ -1,3 +1,4 @@ export * from "./Debug"; export * from "./Provider"; -export * from "./store"; +export * from "./hooks"; +export * from "./types"; diff --git a/packages/ui/lib/shortcuts/types.ts b/packages/ui/lib/shortcuts/types.ts new file mode 100644 index 0000000..86ee6ef --- /dev/null +++ b/packages/ui/lib/shortcuts/types.ts @@ -0,0 +1,52 @@ +export type KeyName = + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" + | ":" + | "up" + | "down" + | "left" + | "right" + | "return" + | "escape"; + +export type Key = KeyName | KeyOptions; + +export interface KeyOptions { + name: KeyName; + ctrl?: boolean; + shift?: boolean; +} diff --git a/packages/ui/lib/shortcuts/util.ts b/packages/ui/lib/shortcuts/util.ts new file mode 100644 index 0000000..294e066 --- /dev/null +++ b/packages/ui/lib/shortcuts/util.ts @@ -0,0 +1,9 @@ +import type { Key, KeyOptions } from "./types"; + +export function enforceKeyOptions(key: Key): KeyOptions { + return typeof key == "string" + ? { + name: key, + } + : key; +} diff --git a/packages/ui/src/budget.tsx b/packages/ui/src/budget.tsx index 67ae010..09a99ca 100644 --- a/packages/ui/src/budget.tsx +++ b/packages/ui/src/budget.tsx @@ -1,26 +1,39 @@ -import { use } from "react"; -import { View, Text } from "react-native"; +import { use, useRef, useState } from "react"; +import { View, Text, TextInput } from "react-native"; import { RouterContext } from "."; -import { queries, type Mutators, type Schema } from "@money/shared"; +import { + queries, + type Category, + type Mutators, + type Schema, +} from "@money/shared"; import { useQuery, useZero } from "@rocicorp/zero/react"; import * as Table from "../components/Table"; import { Button } from "../components/Button"; +import * as Dialog from "../components/Dialog"; -const COLUMNS: Table.Column[] = [{ name: "label", label: "Name" }]; +const COLUMNS: Table.Column[] = [ + { name: "label", label: "Name" }, + { name: "week", label: "Week" }, + { name: "month", label: "Month" }, + { name: "year", label: "Year" }, + { name: "order", label: "Order" }, +]; export function Budget() { const { auth } = use(RouterContext); const [budgets] = useQuery(queries.getBudgets(auth)); - // const [items] = useQuery(queries.getBudgetCategories(auth)); - - const items: any[] = []; + const [renaming, setRenaming] = useState(); const z = useZero(); + const refText = useRef(""); const newBudget = () => { const id = new Date().getTime().toString(); + const categoryId = new Date().getTime().toString(); z.mutate.budget.create({ id, + categoryId, }); }; @@ -45,21 +58,89 @@ export function Budget() { const budget = budgets[0]!; + const data = budget.categories.slice().map((category) => { + const { amount } = category; + const week = amount; + const month = amount * 4; + const year = amount * 12; + + return { + ...category, + ...{ + week, + month, + year, + }, + }; + }); + + const newCategory = ({ index }: { index: number }) => { + const id = new Date().getTime().toString(); + z.mutate.budget.createCategory({ + id, + budgetId: budget.id, + order: index - 1, + }); + }; + + const deleteCategory = ({ selected }: { selected: { id: string }[] }) => { + for (const { id } of selected) { + z.mutate.budget.deleteCategory({ id }); + } + }; + + const renameCategory = ({ selected }: { selected: Category[] }) => { + for (const category of selected) { + setRenaming(category); + } + }; + return ( <> - - + setRenaming(undefined)} + > + + Edit Category + + { + refText.current = t; + }} + onKeyPress={(e) => { + if (!renaming) return; + if (e.nativeEvent.key == "Enter") { + z.mutate.budget.updateCategory({ + id: renaming.id, + label: refText.current, + }); + setRenaming(undefined); + } else if (e.nativeEvent.key == "Escape") { + setRenaming(undefined); + } + }} + /> + + + + + Selected Budget: {budget.label} { - if (event.name == "n" && event.shift) { - newBudget(); - } - }} + shortcuts={[ + { key: "i", handler: newCategory }, + { key: "d", handler: deleteCategory }, + { key: "r", handler: renameCategory }, + ]} > diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index e7ae4c5..e750e1a 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -1,11 +1,15 @@ -import { createContext, use, useEffect, useRef } from "react"; +import { createContext, use, type ReactNode } from "react"; import { Transactions } from "./transactions"; import { View } from "react-native"; import { Settings } from "./settings"; import type { AuthData } from "@money/shared/auth"; import { Budget } from "./budget"; -import { ShortcutProvider, ShortcutDebug, keysStore } from "../lib/shortcuts"; -import { useShortcut } from "../lib/shortcuts/hooks"; +import { + ShortcutProvider, + ShortcutDebug, + useShortcut, + type KeyName, +} from "../lib/shortcuts"; const PAGES = { "/": { @@ -24,7 +28,10 @@ const PAGES = { "/family": {}, }, }, -}; +} satisfies Record< + string, + { screen: ReactNode; key: KeyName; children?: Record } +>; type Join = `${A}${B}` extends `${infer X}` ? X @@ -32,14 +39,14 @@ type Join = `${A}${B}` extends `${infer X}` type ChildRoutes = { [K in keyof Children & string]: K extends `/${string}` - ? Join - : never; + ? Join + : never; }[keyof Children & string]; type Routes = { [K in keyof T & string]: - | K - | (T[K] extends { children: infer C } ? ChildRoutes : never); + | K + | (T[K] extends { children: infer C } ? ChildRoutes : never); }[keyof T & string]; export type Route = Routes; @@ -53,7 +60,7 @@ interface RouterContextType { export const RouterContext = createContext({ auth: null, route: "/", - setRoute: () => {}, + setRoute: () => { }, }); type AppProps = { @@ -84,8 +91,8 @@ function Main() { route in PAGES ? (route as keyof typeof PAGES) : (Object.keys(PAGES) - .sort((a, b) => b.length - a.length) - .find((p) => route.startsWith(p)) as keyof typeof PAGES); + .sort((a, b) => b.length - a.length) + .find((p) => route.startsWith(p)) as keyof typeof PAGES); return ( diff --git a/packages/ui/src/settings.tsx b/packages/ui/src/settings.tsx index de3842c..d278629 100644 --- a/packages/ui/src/settings.tsx +++ b/packages/ui/src/settings.tsx @@ -4,6 +4,7 @@ import { RouterContext, type Route } from "."; import { General } from "./settings/general"; import { Accounts } from "./settings/accounts"; import { Family } from "./settings/family"; +import { useShortcut } from "../lib/shortcuts"; type SettingsRoute = Extract; @@ -30,27 +31,24 @@ type Tab = keyof typeof TABS; export function Settings() { const { route, setRoute } = use(RouterContext); - // useKeyboard( - // (key) => { - // if (key.name == "h") { - // const currentIdx = Object.entries(TABS).findIndex( - // ([tabRoute, _]) => tabRoute == route, - // ); - // const routes = Object.keys(TABS) as SettingsRoute[]; - // const last = routes[currentIdx - 1]; - // if (!last) return; - // setRoute(last); - // } else if (key.name == "l") { - // const currentIdx = Object.entries(TABS).findIndex( - // ([tabRoute, _]) => tabRoute == route, - // ); - // const routes = Object.keys(TABS) as SettingsRoute[]; - // const next = routes[currentIdx + 1]; - // if (!next) return; - // setRoute(next); - // } - // }, - // ); + useShortcut("h", () => { + const currentIdx = Object.entries(TABS).findIndex( + ([tabRoute, _]) => tabRoute == route, + ); + const routes = Object.keys(TABS) as SettingsRoute[]; + const last = routes[currentIdx - 1]; + if (!last) return; + setRoute(last); + }); + useShortcut("l", () => { + const currentIdx = Object.entries(TABS).findIndex( + ([tabRoute, _]) => tabRoute == route, + ); + const routes = Object.keys(TABS) as SettingsRoute[]; + const next = routes[currentIdx + 1]; + if (!next) return; + setRoute(next); + }); return (