feat: budget crud actions
This commit is contained in:
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ type Tx = Transaction<Schema>;
|
|||||||
export function createMutators(authData: AuthData | null) {
|
export function createMutators(authData: AuthData | null) {
|
||||||
return {
|
return {
|
||||||
link: {
|
link: {
|
||||||
async create() {},
|
async create() { },
|
||||||
async get(tx: Tx, { link_token }: { link_token: string }) {},
|
async get(tx: Tx, { link_token }: { link_token: string }) { },
|
||||||
async updateTransactions() {},
|
async updateTransactions() { },
|
||||||
async updateBalences() {},
|
async updateBalences() { },
|
||||||
async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) {
|
async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) {
|
||||||
isLoggedIn(authData);
|
isLoggedIn(authData);
|
||||||
for (const id of accountIds) {
|
for (const id of accountIds) {
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 { Modal, View, Text } from "react-native";
|
||||||
|
import { useShortcut } from "../lib/shortcuts";
|
||||||
|
|
||||||
export interface DialogState {
|
export interface DialogState {
|
||||||
close?: () => void;
|
close?: () => void;
|
||||||
}
|
}
|
||||||
export const Context = createContext<DialogState>({
|
export const Context = createContext<DialogState>({
|
||||||
close: () => {},
|
close: () => { },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ProviderProps {
|
interface ProviderProps {
|
||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
52
packages/ui/lib/shortcuts/types.ts
Normal file
52
packages/ui/lib/shortcuts/types.ts
Normal 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;
|
||||||
|
}
|
||||||
9
packages/ui/lib/shortcuts/util.ts
Normal file
9
packages/ui/lib/shortcuts/util.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Key, KeyOptions } from "./types";
|
||||||
|
|
||||||
|
export function enforceKeyOptions(key: Key): KeyOptions {
|
||||||
|
return typeof key == "string"
|
||||||
|
? {
|
||||||
|
name: key,
|
||||||
|
}
|
||||||
|
: key;
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -53,7 +60,7 @@ interface RouterContextType {
|
|||||||
export const RouterContext = createContext<RouterContextType>({
|
export const RouterContext = createContext<RouterContextType>({
|
||||||
auth: null,
|
auth: null,
|
||||||
route: "/",
|
route: "/",
|
||||||
setRoute: () => {},
|
setRoute: () => { },
|
||||||
});
|
});
|
||||||
|
|
||||||
type AppProps = {
|
type AppProps = {
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user