feat: budget crud actions

This commit is contained in:
Max Koon
2025-12-06 23:02:28 -05:00
parent 76f2a43bd0
commit 27f6e627d4
20 changed files with 445 additions and 113 deletions

View File

@@ -13,7 +13,7 @@ export default function Page() {
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
const newRoute = window.location.pathname.slice(1); const newRoute = window.location.pathname.slice(1) + "/";
setRoute(newRoute); setRoute(newRoute);
}; };

View File

@@ -1,8 +1,8 @@
import { createCliRenderer } from "@opentui/core"; 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 { App, type Route } from "@money/ui";
import { ZeroProvider } from "@rocicorp/zero/react"; import { ZeroProvider } from "@rocicorp/zero/react";
import { schema } from "@money/shared"; import { schema, createMutators } from "@money/shared";
import { useState } from "react"; import { useState } from "react";
import { AuthClientLayer, getAuth } from "./auth"; import { AuthClientLayer, getAuth } from "./auth";
import { Effect } from "effect"; import { Effect } from "effect";
@@ -13,24 +13,14 @@ import { config } from "./config";
function Main({ auth }: { auth: AuthData }) { function Main({ auth }: { auth: AuthData }) {
const [route, setRoute] = useState<Route>("/"); const [route, setRoute] = useState<Route>("/");
const renderer = useRenderer();
useKeyboard((key) => { useKeyboard((key) => {
if (key.name == "c" && key.ctrl) process.exit(0); if (key.name == "c" && key.ctrl) process.exit(0);
if (key.name == "i" && key.meta) renderer.console.toggle();
}); });
return ( return <App auth={auth} route={route} setRoute={setRoute} />;
<ZeroProvider
{...{
userID: auth.user.id,
auth: auth.session.token,
server: config.zeroUrl,
schema,
kvStore,
}}
>
<App auth={auth} route={route} setRoute={setRoute} />
</ZeroProvider>
);
} }
const auth = await Effect.runPromise( const auth = await Effect.runPromise(
@@ -40,4 +30,17 @@ const auth = await Effect.runPromise(
), ),
); );
const renderer = await createCliRenderer({ exitOnCtrlC: false }); const renderer = await createCliRenderer({ exitOnCtrlC: false });
createRoot(renderer).render(<Main auth={auth} />); createRoot(renderer).render(
<ZeroProvider
{...{
userID: auth.user.id,
auth: auth.session.token,
server: config.zeroUrl,
schema,
mutators: createMutators(auth),
kvStore,
}}
>
<Main auth={auth} />
</ZeroProvider>,
);

View File

@@ -3,16 +3,10 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "process-compose up -p 0", "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": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": ["@rocicorp/zero-sqlite3"],
"@rocicorp/zero-sqlite3" "ignoredBuiltDependencies": ["esbuild", "protobufjs", "unrs-resolver"]
],
"ignoredBuiltDependencies": [
"esbuild",
"protobufjs",
"unrs-resolver"
]
} }
} }

View File

@@ -8,6 +8,7 @@ import type {
StyleProp, StyleProp,
ViewStyle, ViewStyle,
LinkingImpl, LinkingImpl,
TextInputProps,
} from "react-native"; } from "react-native";
import { useTerminalDimensions } from "@opentui/react"; import { useTerminalDimensions } from "@opentui/react";
import { RGBA } from "@opentui/core"; import { RGBA } from "@opentui/core";
@@ -226,6 +227,32 @@ export function Modal({ children, visible }: ModalProps) {
); );
} }
export function TextInput({
defaultValue,
onChangeText,
onKeyPress,
}: TextInputProps) {
return (
<input
width={20}
backgroundColor="white"
textColor="black"
focused={true}
cursorColor={"black"}
onInput={onChangeText}
onKeyDown={(key) =>
// @ts-ignore
onKeyPress({
nativeEvent: {
key: key.name == "return" ? "Enter" : key.name,
},
})
}
placeholder={defaultValue}
/>
);
}
export const Platform = { export const Platform = {
OS: "tui", OS: "tui",
}; };

View File

@@ -1,3 +1,4 @@
import { relations } from "drizzle-orm";
import { import {
boolean, boolean,
decimal, decimal,
@@ -87,4 +88,17 @@ export const category = pgTable("category", {
createdBy: text("created_by").notNull(), createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_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],
}),
}));

View File

@@ -40,7 +40,10 @@ export function createMutators(authData: AuthData | null) {
}, },
}, },
budget: { budget: {
async create(tx: Tx, { id }: { id: string }) { async create(
tx: Tx,
{ id, categoryId }: { id: string; categoryId: string },
) {
isLoggedIn(authData); isLoggedIn(authData);
await tx.mutate.budget.insert({ await tx.mutate.budget.insert({
id, id,
@@ -48,6 +51,60 @@ export function createMutators(authData: AuthData | null) {
label: "New Budget", label: "New Budget",
createdBy: authData.user.id, 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; } as const;

View File

@@ -65,7 +65,11 @@ export const queries = {
z.tuple([]), z.tuple([]),
(authData: AuthData | null) => { (authData: AuthData | null) => {
isLoggedIn(authData); 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( getBudgetCategories: syncedQueryWithContext(

View File

@@ -273,6 +273,26 @@ export const schema = {
>, >,
serverName: "updated_at", 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"], primaryKey: ["id"],
}, },
@@ -582,7 +602,28 @@ export const schema = {
serverName: "user", 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, enableLegacyQueries: false,
enableLegacyMutators: false, enableLegacyMutators: false,
} as const; } as const;

View File

@@ -1,5 +1,6 @@
import { useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { Text, Pressable } from "react-native"; import { Text, Pressable } from "react-native";
import { useShortcut, type Key } from "../lib/shortcuts";
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] }; type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
@@ -7,7 +8,7 @@ export interface ButtonProps {
children: ReactNode; children: ReactNode;
onPress?: () => void; onPress?: () => void;
variant?: "default" | "secondary" | "destructive"; variant?: "default" | "secondary" | "destructive";
shortcut?: string; shortcut?: Key;
} }
const STYLES: Record< const STYLES: Record<
@@ -22,16 +23,9 @@ const STYLES: Record<
export function Button({ children, variant, onPress, shortcut }: ButtonProps) { export function Button({ children, variant, onPress, shortcut }: ButtonProps) {
const { backgroundColor, color } = STYLES[variant || "default"]; const { backgroundColor, color } = STYLES[variant || "default"];
// if (shortcut) { if (shortcut && onPress) {
// useKeys((key) => { useShortcut(shortcut, onPress);
// if ( }
// typeof shortcut == "object"
// ? key.name == shortcut.name
// : key.name == shortcut
// )
// return onPress;
// });
// }
return ( return (
<Pressable onPress={onPress} style={{ backgroundColor }}> <Pressable onPress={onPress} style={{ backgroundColor }}>

View File

@@ -1,5 +1,6 @@
import { createContext, type ReactNode } from "react"; import { createContext, use, type ReactNode } from "react";
import { Modal, View, Text } from "react-native"; import { Modal, View, Text } from "react-native";
import { useShortcut } from "../lib/shortcuts";
export interface DialogState { export interface DialogState {
close?: () => void; close?: () => void;
@@ -37,6 +38,9 @@ interface ContentProps {
children: ReactNode; children: ReactNode;
} }
export function Content({ children }: ContentProps) { export function Content({ children }: ContentProps) {
const { close } = use(Context);
useShortcut("escape", () => close?.());
return ( return (
<View <View
style={{ backgroundColor: "white", padding: 12, alignItems: "center" }} style={{ backgroundColor: "white", padding: 12, alignItems: "center" }}

View File

@@ -9,6 +9,7 @@ import {
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import type { KeyEvent } from "@opentui/core"; import type { KeyEvent } from "@opentui/core";
import { useShortcut } from "../lib/shortcuts/hooks"; import { useShortcut } from "../lib/shortcuts/hooks";
import type { Key } from "../lib/shortcuts";
const HEADER_COLOR = "#7158e2"; const HEADER_COLOR = "#7158e2";
const TABLE_COLORS = ["#ddd", "#eee"]; const TABLE_COLORS = ["#ddd", "#eee"];
@@ -50,17 +51,22 @@ function renderCell(row: ValidRecord, column: Column): string {
return cell.toString(); return cell.toString();
} }
interface TableShortcut<T> {
key: Key;
handler: (params: { selected: T[]; index: number }) => void;
}
export interface ProviderProps<T> { export interface ProviderProps<T> {
data: T[]; data: T[];
columns: Column[]; columns: Column[];
children: ReactNode; children: ReactNode;
onKey?: (event: KeyEvent, selected: T[]) => void; shortcuts?: TableShortcut<T>[];
} }
export function Provider<T extends ValidRecord>({ export function Provider<T extends ValidRecord>({
data, data,
columns, columns,
children, children,
onKey, shortcuts,
}: ProviderProps<T>) { }: ProviderProps<T>) {
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const [selectedFrom, setSelectedFrom] = useState<number>(); const [selectedFrom, setSelectedFrom] = useState<number>();
@@ -68,9 +74,30 @@ export function Provider<T extends ValidRecord>({
useShortcut("j", () => { useShortcut("j", () => {
setIdx((prev) => Math.min(prev + 1, data.length - 1)); setIdx((prev) => Math.min(prev + 1, data.length - 1));
}); });
useShortcut("down", () => {
setIdx((prev) => Math.min(prev + 1, data.length - 1));
});
useShortcut("k", () => { useShortcut("k", () => {
setIdx((prev) => Math.max(prev - 1, 0)); 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( const columnMap = new Map(
columns.map((col) => { columns.map((col) => {

View File

@@ -16,9 +16,11 @@ export function ShortcutDebug() {
bottom: 0, bottom: 0,
right: 0, right: 0,
backgroundColor: "black", backgroundColor: "black",
padding: 10,
}} }}
> >
<Text style={{ color: "red", fontFamily: "mono" }}> <Text style={{ color: "red", fontFamily: "mono" }}>Registered:</Text>
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}>
{entries {entries
.values() .values()
.map(([key, _]) => key) .map(([key, _]) => key)

View File

@@ -1,10 +1,23 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { keysStore } from "./store"; 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") { if (typeof window !== "undefined") {
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
const fn = keysStore.getHandler(e.key); const key = Object.hasOwn(KEY_MAP, e.key) ? KEY_MAP[e.key]! : e.key;
fn?.(); const fn = keysStore.getHandler(key);
// console.log(e.key);
if (!fn) return;
e.preventDefault();
fn();
}); });
} }

View File

@@ -1,14 +1,18 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { keysStore } from "./store"; 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); const ref = useRef(handler);
ref.current = handler; ref.current = handler;
useEffect(() => { useEffect(() => {
keysStore.register(key, ref); keysStore.register(keyName, ref);
return () => { return () => {
keysStore.deregister(key); keysStore.deregister(keyName);
}; };
}, []); }, []);
}; };

View File

@@ -1,3 +1,4 @@
export * from "./Debug"; export * from "./Debug";
export * from "./Provider"; export * from "./Provider";
export * from "./store"; export * from "./hooks";
export * from "./types";

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
import type { Key, KeyOptions } from "./types";
export function enforceKeyOptions(key: Key): KeyOptions {
return typeof key == "string"
? {
name: key,
}
: key;
}

View File

@@ -1,26 +1,39 @@
import { use } from "react"; import { use, useRef, useState } from "react";
import { View, Text } from "react-native"; import { View, Text, TextInput } from "react-native";
import { RouterContext } from "."; 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 { useQuery, useZero } from "@rocicorp/zero/react";
import * as Table from "../components/Table"; import * as Table from "../components/Table";
import { Button } from "../components/Button"; 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() { export function Budget() {
const { auth } = use(RouterContext); const { auth } = use(RouterContext);
const [budgets] = useQuery(queries.getBudgets(auth)); const [budgets] = useQuery(queries.getBudgets(auth));
// const [items] = useQuery(queries.getBudgetCategories(auth)); const [renaming, setRenaming] = useState<Category>();
const items: any[] = [];
const z = useZero<Schema, Mutators>(); const z = useZero<Schema, Mutators>();
const refText = useRef("");
const newBudget = () => { const newBudget = () => {
const id = new Date().getTime().toString(); const id = new Date().getTime().toString();
const categoryId = new Date().getTime().toString();
z.mutate.budget.create({ z.mutate.budget.create({
id, id,
categoryId,
}); });
}; };
@@ -45,21 +58,89 @@ export function Budget() {
const budget = budgets[0]!; 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 ( return (
<> <>
<View> <Dialog.Provider
<Text style={{ fontFamily: "mono" }}> visible={renaming != undefined}
close={() => setRenaming(undefined)}
>
<Dialog.Content>
<Text style={{ fontFamily: "mono" }}>Edit Category</Text>
<TextInput
style={{ fontFamily: "mono" }}
autoFocus
selectTextOnFocus
defaultValue={renaming?.label}
onChangeText={(t) => {
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);
}
}}
/>
</Dialog.Content>
</Dialog.Provider>
<View style={{ alignItems: "flex-start" }}>
<Text style={{ fontFamily: "mono", textAlign: "left" }}>
Selected Budget: {budget.label} Selected Budget: {budget.label}
</Text> </Text>
</View> </View>
<Table.Provider <Table.Provider
data={items} data={data}
columns={COLUMNS} columns={COLUMNS}
onKey={(event) => { shortcuts={[
if (event.name == "n" && event.shift) { { key: "i", handler: newCategory },
newBudget(); { key: "d", handler: deleteCategory },
} { key: "r", handler: renameCategory },
}} ]}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={{ flexShrink: 0 }}> <View style={{ flexShrink: 0 }}>

View File

@@ -1,11 +1,15 @@
import { createContext, use, useEffect, useRef } from "react"; import { createContext, use, type ReactNode } from "react";
import { Transactions } from "./transactions"; import { Transactions } from "./transactions";
import { View } from "react-native"; import { View } from "react-native";
import { Settings } from "./settings"; import { Settings } from "./settings";
import type { AuthData } from "@money/shared/auth"; import type { AuthData } from "@money/shared/auth";
import { Budget } from "./budget"; import { Budget } from "./budget";
import { ShortcutProvider, ShortcutDebug, keysStore } from "../lib/shortcuts"; import {
import { useShortcut } from "../lib/shortcuts/hooks"; ShortcutProvider,
ShortcutDebug,
useShortcut,
type KeyName,
} from "../lib/shortcuts";
const PAGES = { const PAGES = {
"/": { "/": {
@@ -24,7 +28,10 @@ const PAGES = {
"/family": {}, "/family": {},
}, },
}, },
}; } satisfies Record<
string,
{ screen: ReactNode; key: KeyName; children?: Record<string, unknown> }
>;
type Join<A extends string, B extends string> = `${A}${B}` extends `${infer X}` type Join<A extends string, B extends string> = `${A}${B}` extends `${infer X}`
? X ? X

View File

@@ -4,6 +4,7 @@ import { RouterContext, type Route } from ".";
import { General } from "./settings/general"; import { General } from "./settings/general";
import { Accounts } from "./settings/accounts"; import { Accounts } from "./settings/accounts";
import { Family } from "./settings/family"; import { Family } from "./settings/family";
import { useShortcut } from "../lib/shortcuts";
type SettingsRoute = Extract<Route, `/settings${string}`>; type SettingsRoute = Extract<Route, `/settings${string}`>;
@@ -30,27 +31,24 @@ type Tab = keyof typeof TABS;
export function Settings() { export function Settings() {
const { route, setRoute } = use(RouterContext); const { route, setRoute } = use(RouterContext);
// useKeyboard( useShortcut("h", () => {
// (key) => { const currentIdx = Object.entries(TABS).findIndex(
// if (key.name == "h") { ([tabRoute, _]) => tabRoute == route,
// const currentIdx = Object.entries(TABS).findIndex( );
// ([tabRoute, _]) => tabRoute == route, const routes = Object.keys(TABS) as SettingsRoute[];
// ); const last = routes[currentIdx - 1];
// const routes = Object.keys(TABS) as SettingsRoute[]; if (!last) return;
// const last = routes[currentIdx - 1]; setRoute(last);
// if (!last) return; });
// setRoute(last); useShortcut("l", () => {
// } else if (key.name == "l") { const currentIdx = Object.entries(TABS).findIndex(
// const currentIdx = Object.entries(TABS).findIndex( ([tabRoute, _]) => tabRoute == route,
// ([tabRoute, _]) => tabRoute == route, );
// ); const routes = Object.keys(TABS) as SettingsRoute[];
// const routes = Object.keys(TABS) as SettingsRoute[]; const next = routes[currentIdx + 1];
// const next = routes[currentIdx + 1]; if (!next) return;
// if (!next) return; setRoute(next);
// setRoute(next); });
// }
// },
// );
return ( return (
<View style={{ flexDirection: "row" }}> <View style={{ flexDirection: "row" }}>