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(() => {
const handler = () => {
const newRoute = window.location.pathname.slice(1);
const newRoute = window.location.pathname.slice(1) + "/";
setRoute(newRoute);
};

View File

@@ -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<Route>("/");
const renderer = useRenderer();
useKeyboard((key) => {
if (key.name == "c" && key.ctrl) process.exit(0);
if (key.name == "i" && key.meta) renderer.console.toggle();
});
return (
<ZeroProvider
{...{
userID: auth.user.id,
auth: auth.session.token,
server: config.zeroUrl,
schema,
kvStore,
}}
>
<App auth={auth} route={route} setRoute={setRoute} />
</ZeroProvider>
);
return <App auth={auth} route={route} setRoute={setRoute} />;
}
const auth = await Effect.runPromise(
@@ -40,4 +30,17 @@ const auth = await Effect.runPromise(
),
);
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,
"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"]
}
}

View File

@@ -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 (
<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 = {
OS: "tui",
};

View File

@@ -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],
}),
}));

View File

@@ -8,10 +8,10 @@ type Tx = Transaction<Schema>;
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;

View File

@@ -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(

View File

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

View File

@@ -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, K extends keyof T> = 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 (
<Pressable onPress={onPress} style={{ backgroundColor }}>

View File

@@ -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<DialogState>({
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 (
<View
style={{ backgroundColor: "white", padding: 12, alignItems: "center" }}

View File

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

View File

@@ -16,9 +16,11 @@ export function ShortcutDebug() {
bottom: 0,
right: 0,
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
.values()
.map(([key, _]) => key)

View File

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

View File

@@ -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);
};
}, []);
};

View File

@@ -1,3 +1,4 @@
export * from "./Debug";
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 { 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<Category>();
const z = useZero<Schema, Mutators>();
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 (
<>
<View>
<Text style={{ fontFamily: "mono" }}>
<Dialog.Provider
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}
</Text>
</View>
<Table.Provider
data={items}
data={data}
columns={COLUMNS}
onKey={(event) => {
if (event.name == "n" && event.shift) {
newBudget();
}
}}
shortcuts={[
{ key: "i", handler: newCategory },
{ key: "d", handler: deleteCategory },
{ key: "r", handler: renameCategory },
]}
>
<View style={{ flex: 1 }}>
<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 { 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<string, unknown> }
>;
type Join<A extends string, B extends string> = `${A}${B}` extends `${infer X}`
? X
@@ -32,14 +39,14 @@ type Join<A extends string, B extends string> = `${A}${B}` extends `${infer X}`
type ChildRoutes<Parent extends string, Children> = {
[K in keyof Children & string]: K extends `/${string}`
? Join<Parent, K>
: never;
? Join<Parent, K>
: never;
}[keyof Children & string];
type Routes<T> = {
[K in keyof T & string]:
| K
| (T[K] extends { children: infer C } ? ChildRoutes<K, C> : never);
| K
| (T[K] extends { children: infer C } ? ChildRoutes<K, C> : never);
}[keyof T & string];
export type Route = Routes<typeof PAGES>;
@@ -53,7 +60,7 @@ interface RouterContextType {
export const RouterContext = createContext<RouterContextType>({
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 (
<View style={{ backgroundColor: "white", flex: 1 }}>

View File

@@ -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<Route, `/settings${string}`>;
@@ -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 (
<View style={{ flexDirection: "row" }}>