refactor: better shortcut hook

This commit is contained in:
Max Koon
2025-12-05 17:05:23 -05:00
parent 2df7f2d924
commit 76f2a43bd0
21 changed files with 481 additions and 143 deletions

View File

@@ -74,6 +74,11 @@ export function View({ children, style }: ViewProps) {
justifyContent: attr(style, "justifyContent", "string"),
flexShrink: attr(style, "flexShrink", "number"),
flexDirection: attr(style, "flexDirection", "string"),
top: attr(style, "top", "number"),
zIndex: attr(style, "zIndex", "number"),
left: attr(style, "left", "number"),
right: attr(style, "right", "number"),
bottom: attr(style, "bottom", "number"),
flexGrow:
attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
};

View File

@@ -6,6 +6,7 @@ import {
timestamp,
pgEnum,
uniqueIndex,
numeric,
} from "drizzle-orm/pg-core";
export const users = pgTable(
@@ -65,3 +66,25 @@ export const plaidAccessTokens = pgTable("plaidAccessToken", {
token: text("token").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const budget = pgTable("budget", {
id: text("id").primaryKey(),
orgId: text("org_id").notNull(),
label: text("label").notNull(),
createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const category = pgTable("category", {
id: text("id").primaryKey(),
budgetId: text("budget_id").notNull(),
amount: decimal("amount").notNull(),
every: text("every", { enum: ["year", "month", "week"] }).notNull(),
order: numeric("order").notNull(),
label: text("label").notNull(),
color: text("color").notNull(),
createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

View File

@@ -1,5 +1,5 @@
import type { Transaction } from "@rocicorp/zero";
import type { AuthData } from "./auth";
import { authDataSchema, type AuthData } from "./auth";
import { type Schema } from "./zero-schema.gen";
import { isLoggedIn } from "./zql";
@@ -39,6 +39,17 @@ export function createMutators(authData: AuthData | null) {
}
},
},
budget: {
async create(tx: Tx, { id }: { id: string }) {
isLoggedIn(authData);
await tx.mutate.budget.insert({
id,
orgId: authData.user.id,
label: "New Budget",
createdBy: authData.user.id,
});
},
},
} as const;
}

View File

@@ -60,4 +60,20 @@ export const queries = {
.orderBy("createdAt", "desc");
},
),
getBudgets: syncedQueryWithContext(
"getBudgets",
z.tuple([]),
(authData: AuthData | null) => {
isLoggedIn(authData);
return builder.budget.limit(10);
},
),
getBudgetCategories: syncedQueryWithContext(
"getBudgetCategories",
z.tuple([]),
(authData: AuthData | null) => {
isLoggedIn(authData);
return builder.category.orderBy("order", "desc");
},
),
};

View File

@@ -112,6 +112,170 @@ export const schema = {
},
primaryKey: ["id"],
},
budget: {
name: "budget",
columns: {
id: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"budget",
"id"
>,
},
orgId: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"budget",
"orgId"
>,
serverName: "org_id",
},
label: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"budget",
"label"
>,
},
createdBy: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"budget",
"createdBy"
>,
serverName: "created_by",
},
createdAt: {
type: "number",
optional: true,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"budget",
"createdAt"
>,
serverName: "created_at",
},
updatedAt: {
type: "number",
optional: true,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"budget",
"updatedAt"
>,
serverName: "updated_at",
},
},
primaryKey: ["id"],
},
category: {
name: "category",
columns: {
id: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"id"
>,
},
budgetId: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"budgetId"
>,
serverName: "budget_id",
},
amount: {
type: "number",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"amount"
>,
},
every: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"every"
>,
},
order: {
type: "number",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"order"
>,
},
label: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"label"
>,
},
color: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"color"
>,
},
createdBy: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"createdBy"
>,
serverName: "created_by",
},
createdAt: {
type: "number",
optional: true,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"createdAt"
>,
serverName: "created_at",
},
updatedAt: {
type: "number",
optional: true,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"category",
"updatedAt"
>,
serverName: "updated_at",
},
},
primaryKey: ["id"],
},
plaidAccessTokens: {
name: "plaidAccessTokens",
columns: {
@@ -433,6 +597,16 @@ export type Schema = typeof schema;
* This type is auto-generated from your Drizzle schema definition.
*/
export type Balance = Row<Schema["tables"]["balance"]>;
/**
* Represents a row from the "budget" table.
* This type is auto-generated from your Drizzle schema definition.
*/
export type Budget = Row<Schema["tables"]["budget"]>;
/**
* Represents a row from the "category" table.
* This type is auto-generated from your Drizzle schema definition.
*/
export type Category = Row<Schema["tables"]["category"]>;
/**
* Represents a row from the "plaidAccessTokens" table.
* This type is auto-generated from your Drizzle schema definition.

View File

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

View File

@@ -1,6 +1,5 @@
import { createContext, type ReactNode } from "react";
import { Modal, View, Text } from "react-native";
import { useKeyboard } from "../src/useKeyboard";
export interface DialogState {
close?: () => void;
@@ -15,12 +14,6 @@ interface ProviderProps {
close?: () => void;
}
export function Provider({ children, visible, close }: ProviderProps) {
useKeyboard((key) => {
if (key.name == "escape") {
if (close) close();
}
}, []);
return (
<Context.Provider value={{ close }}>
<Modal transparent visible={visible}>

View File

@@ -1,7 +1,14 @@
import { createContext, use, useState, type ReactNode } from "react";
import {
createContext,
use,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { View, Text } from "react-native";
import { useKeyboard } from "../src/useKeyboard";
import type { KeyEvent } from "@opentui/core";
import { useShortcut } from "../lib/shortcuts/hooks";
const HEADER_COLOR = "#7158e2";
const TABLE_COLORS = ["#ddd", "#eee"];
@@ -58,33 +65,12 @@ export function Provider<T extends ValidRecord>({
const [idx, setIdx] = useState(0);
const [selectedFrom, setSelectedFrom] = useState<number>();
useKeyboard(
(key) => {
if (key.name == "j" || key.name == "down") {
if (key.shift && selectedFrom == undefined) {
setSelectedFrom(idx);
}
useShortcut("j", () => {
setIdx((prev) => Math.min(prev + 1, data.length - 1));
} else if (key.name == "k" || key.name == "up") {
if (key.shift && selectedFrom == undefined) {
setSelectedFrom(idx);
}
});
useShortcut("k", () => {
setIdx((prev) => Math.max(prev - 1, 0));
} else if (key.name == "g" && key.shift) {
setIdx(data.length - 1);
} else if (key.name == "v") {
setSelectedFrom(idx);
} else if (key.name == "escape") {
setSelectedFrom(undefined);
} else {
const from = selectedFrom ? Math.min(idx, selectedFrom) : idx;
const to = selectedFrom ? Math.max(idx, selectedFrom) : idx;
const selected = data.slice(from, to + 1);
if (onKey) onKey(key, selected);
}
},
[data, idx, selectedFrom],
);
});
const columnMap = new Map(
columns.map((col) => {

View File

@@ -0,0 +1,30 @@
import { useSyncExternalStore } from "react";
import { View, Text } from "react-native";
import { keysStore } from "./store";
export function ShortcutDebug() {
const entries = useSyncExternalStore(
keysStore.subscribe,
keysStore.getSnapshot,
);
return (
<View
style={{
position: "absolute",
zIndex: 100,
bottom: 0,
right: 0,
backgroundColor: "black",
}}
>
<Text style={{ color: "red", fontFamily: "mono" }}>
{entries
.values()
.map(([key, _]) => key)
.toArray()
.join(",")}
</Text>
</View>
);
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import { useKeyboard } from "@opentui/react";
import { keysStore } from "./store";
export function ShortcutProvider({ children }: { children: ReactNode }) {
useKeyboard((e) => {
const fn = keysStore.getHandler(e.name);
fn?.();
});
return children;
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { keysStore } from "./store";
if (typeof window !== "undefined") {
window.addEventListener("keydown", (e) => {
const fn = keysStore.getHandler(e.key);
fn?.();
});
}
export function ShortcutProvider({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,14 @@
import { useEffect, useRef } from "react";
import { keysStore } from "./store";
export const useShortcut = (key: string, handler: () => void) => {
const ref = useRef(handler);
ref.current = handler;
useEffect(() => {
keysStore.register(key, ref);
return () => {
keysStore.deregister(key);
};
}, []);
};

View File

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

View File

@@ -0,0 +1,40 @@
import { type RefObject } from "react";
// internal map
const keys = new Map<string, RefObject<() => void>>();
// cached snapshot (stable reference)
let snapshot: [string, RefObject<() => void>][] = [];
let listeners = new Set<() => void>();
function emit() {
// refresh snapshot ONLY when keys actually change
snapshot = Array.from(keys.entries());
for (const fn of listeners) fn();
}
export const keysStore = {
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
getSnapshot() {
return snapshot; // stable unless emit() ran
},
register(key: string, ref: RefObject<() => void>) {
keys.set(key, ref);
emit();
},
deregister(key: string) {
keys.delete(key);
emit();
},
getHandler(key: string) {
return keys.get(key)?.current;
},
};

View File

@@ -0,0 +1,72 @@
import { use } from "react";
import { View, Text } from "react-native";
import { RouterContext } from ".";
import { queries, 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";
const COLUMNS: Table.Column[] = [{ name: "label", label: "Name" }];
export function Budget() {
const { auth } = use(RouterContext);
const [budgets] = useQuery(queries.getBudgets(auth));
// const [items] = useQuery(queries.getBudgetCategories(auth));
const items: any[] = [];
const z = useZero<Schema, Mutators>();
const newBudget = () => {
const id = new Date().getTime().toString();
z.mutate.budget.create({
id,
});
};
if (budgets.length == 0)
return (
<View
style={{
justifyContent: "center",
alignItems: "center",
flex: 1,
gap: 10,
}}
>
<Text style={{ fontFamily: "mono" }}>
No budgets, please create a new budget
</Text>
<Button onPress={newBudget} shortcut="n">
New budget
</Button>
</View>
);
const budget = budgets[0]!;
return (
<>
<View>
<Text style={{ fontFamily: "mono" }}>
Selected Budget: {budget.label}
</Text>
</View>
<Table.Provider
data={items}
columns={COLUMNS}
onKey={(event) => {
if (event.name == "n" && event.shift) {
newBudget();
}
}}
>
<View style={{ flex: 1 }}>
<View style={{ flexShrink: 0 }}>
<Table.Body />
</View>
</View>
</Table.Provider>
</>
);
}

View File

@@ -1,18 +1,24 @@
import { createContext, use } from "react";
import { createContext, use, useEffect, useRef } from "react";
import { Transactions } from "./transactions";
import { View, Text } from "react-native";
import { View } from "react-native";
import { Settings } from "./settings";
import { useKeyboard } from "./useKeyboard";
import type { AuthData } from "@money/shared/auth";
import { Budget } from "./budget";
import { ShortcutProvider, ShortcutDebug, keysStore } from "../lib/shortcuts";
import { useShortcut } from "../lib/shortcuts/hooks";
const PAGES = {
"/": {
screen: <Transactions />,
key: "1",
},
"/budget": {
screen: <Budget />,
key: "2",
},
"/settings": {
screen: <Settings />,
key: "2",
key: "3",
children: {
"/accounts": {},
"/family": {},
@@ -59,7 +65,10 @@ type AppProps = {
export function App({ auth, route, setRoute }: AppProps) {
return (
<RouterContext.Provider value={{ auth, route, setRoute }}>
<ShortcutProvider>
<ShortcutDebug />
<Main />
</ShortcutProvider>
</RouterContext.Provider>
);
}
@@ -67,17 +76,9 @@ export function App({ auth, route, setRoute }: AppProps) {
function Main() {
const { route, setRoute } = use(RouterContext);
useKeyboard((key) => {
const screen = Object.entries(PAGES).find(
([, screen]) => screen.key == key.name,
);
if (!screen) return;
const [route] = screen as [Route, never];
setRoute(route);
});
for (const [route, page] of Object.entries(PAGES)) {
useShortcut(page.key, () => setRoute(route as Route));
}
const match =
route in PAGES

View File

@@ -4,8 +4,6 @@ import { RouterContext, type Route } from ".";
import { General } from "./settings/general";
import { Accounts } from "./settings/accounts";
import { Family } from "./settings/family";
import { useKeyboard } from "./useKeyboard";
import { Modal } from "react-native-opentui";
type SettingsRoute = Extract<Route, `/settings${string}`>;
@@ -32,28 +30,27 @@ 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);
}
},
[route],
);
// 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);
// }
// },
// );
return (
<View style={{ flexDirection: "row" }}>

View File

@@ -3,7 +3,6 @@ import { queries, type Mutators, type Schema } from "@money/shared";
import { use, useEffect, useState } from "react";
import { RouterContext } from "..";
import { View, Text, Linking } from "react-native";
import { useKeyboard } from "../useKeyboard";
import { Button } from "../../components/Button";
import * as Table from "../../components/Table";
import * as Dialog from "../../components/Dialog";

View File

@@ -1,8 +0,0 @@
import { useKeyboard as useOpentuiKeyboard } from "@opentui/react";
export function useKeyboard(
handler: Parameters<typeof useOpentuiKeyboard>[0],
_deps: any[] = [],
) {
return useOpentuiKeyboard(handler);
}

View File

@@ -1,47 +0,0 @@
import { useEffect } from "react";
import type { KeyboardEvent } from "react";
import type { KeyEvent } from "@opentui/core";
function convertName(keyName: string): string {
const result = keyName.toLowerCase();
if (result == "arrowdown") return "down";
if (result == "arrowup") return "up";
return result;
}
export function useKeyboard(
handler: (key: KeyEvent) => void,
deps: any[] = [],
) {
useEffect(() => {
const handlerWeb = (event: KeyboardEvent) => {
// @ts-ignore
handler({
name: convertName(event.key),
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
option: event.metaKey,
sequence: "",
number: false,
raw: "",
eventType: "press",
source: "raw",
code: event.code,
super: false,
hyper: false,
capsLock: false,
numLock: false,
baseCode: event.keyCode,
preventDefault: () => event.preventDefault(),
});
};
// @ts-ignore
window.addEventListener("keydown", handlerWeb);
return () => {
// @ts-ignore
window.removeEventListener("keydown", handlerWeb);
};
}, deps);
}

View File

@@ -1,10 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
},
// Environment setup & latest features
"lib": ["ESNext"],
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",