feat: add scoped shortcuts

This commit is contained in:
Max Koon
2025-12-12 18:44:18 -05:00
parent 27f6e627d4
commit c6dd174376
13 changed files with 451 additions and 138 deletions

View File

@@ -11,7 +11,7 @@ import type {
TextInputProps, TextInputProps,
} from "react-native"; } from "react-native";
import { useTerminalDimensions } from "@opentui/react"; import { useTerminalDimensions } from "@opentui/react";
import { RGBA } from "@opentui/core"; import { BorderSides, RGBA } from "@opentui/core";
import { platform } from "node:os"; import { platform } from "node:os";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
@@ -57,15 +57,36 @@ export function View({ children, style }: ViewProps) {
? typeof style.backgroundColor == "string" ? typeof style.backgroundColor == "string"
? style.backgroundColor.startsWith("rgba(") ? style.backgroundColor.startsWith("rgba(")
? (() => { ? (() => {
const parts = style.backgroundColor.split("(")[1].split(")")[0]; const parts = style.backgroundColor.split("(")[1].split(")")[0];
const [r, g, b, a] = parts.split(",").map(parseFloat); const [r, g, b, a] = parts.split(",").map(parseFloat);
return RGBA.fromInts(r, g, b, a * 255); return RGBA.fromInts(r, g, b, a * 255);
})() })()
: style.backgroundColor : style.backgroundColor
: undefined : undefined
: undefined; : undefined;
const padding = attr(style, "padding", "number"); const padding = attr(style, "padding", "number");
const paddingTop = attr(style, "paddingTop", "number");
const paddingLeft = attr(style, "paddingLeft", "number");
const paddingBottom = attr(style, "paddingBottom", "number");
const paddingRight = attr(style, "paddingRight", "number");
const gap = attr(style, "gap", "number");
const borderBottomWidth = attr(style, "borderBottomWidth", "number");
const borderTopWidth = attr(style, "borderTopWidth", "number");
const borderLeftWidth = attr(style, "borderLeftWidth", "number");
const borderRightWidth = attr(style, "borderRightWidth", "number");
const borderBottomColor = attr(style, "borderBottomColor", "string");
const borderTopColor = attr(style, "borderTopColor", "string");
const borderLeftColor = attr(style, "borderLeftColor", "string");
const borderRightColor = attr(style, "borderRightColor", "string");
const borderColor = attr(style, "borderColor", "string");
const top = attr(style, "top", "number");
const width = attr(style, "width", "number");
const props = { const props = {
overflow: attr(style, "overflow", "string"), overflow: attr(style, "overflow", "string"),
@@ -75,7 +96,6 @@ export function View({ children, style }: ViewProps) {
justifyContent: attr(style, "justifyContent", "string"), justifyContent: attr(style, "justifyContent", "string"),
flexShrink: attr(style, "flexShrink", "number"), flexShrink: attr(style, "flexShrink", "number"),
flexDirection: attr(style, "flexDirection", "string"), flexDirection: attr(style, "flexDirection", "string"),
top: attr(style, "top", "number"),
zIndex: attr(style, "zIndex", "number"), zIndex: attr(style, "zIndex", "number"),
left: attr(style, "left", "number"), left: attr(style, "left", "number"),
right: attr(style, "right", "number"), right: attr(style, "right", "number"),
@@ -84,13 +104,40 @@ export function View({ children, style }: ViewProps) {
attr(style, "flex", "number") || attr(style, "flexGrow", "number"), attr(style, "flex", "number") || attr(style, "flexGrow", "number"),
}; };
const border = (() => {
const sides: BorderSides[] = [];
if (borderBottomWidth) sides.push("bottom");
if (borderTopWidth) sides.push("top");
if (borderLeftWidth) sides.push("left");
if (borderRightWidth) sides.push("right");
if (!sides.length) return undefined;
return sides;
})();
return ( return (
<box <box
backgroundColor={bg} backgroundColor={bg}
paddingTop={padding && Math.round(padding / RATIO_HEIGHT)} paddingTop={
paddingBottom={padding && Math.round(padding / RATIO_HEIGHT)} (paddingTop && Math.round(paddingTop / RATIO_HEIGHT)) ||
paddingLeft={padding && Math.round(padding / RATIO_WIDTH)} (padding && Math.round(padding / RATIO_HEIGHT))
paddingRight={padding && Math.round(padding / RATIO_WIDTH)} }
paddingBottom={
(paddingBottom && Math.round(paddingBottom / RATIO_HEIGHT)) ||
(padding && Math.round(padding / RATIO_HEIGHT))
}
paddingLeft={
(paddingLeft && Math.round(paddingLeft / RATIO_WIDTH)) ||
(padding && Math.round(padding / RATIO_WIDTH))
}
paddingRight={
(paddingRight && Math.round(paddingRight / RATIO_WIDTH)) ||
(padding && Math.round(padding / RATIO_WIDTH))
}
gap={gap && Math.round(gap / RATIO_HEIGHT)}
border={border}
borderColor={borderColor}
width={width && Math.round(width / RATIO_WIDTH)}
top={top && Math.round(top / RATIO_HEIGHT)}
{...props} {...props}
> >
{children} {children}
@@ -108,10 +155,10 @@ export function Pressable({
? typeof style.backgroundColor == "string" ? typeof style.backgroundColor == "string"
? style.backgroundColor.startsWith("rgba(") ? style.backgroundColor.startsWith("rgba(")
? (() => { ? (() => {
const parts = style.backgroundColor.split("(")[1].split(")")[0]; const parts = style.backgroundColor.split("(")[1].split(")")[0];
const [r, g, b, a] = parts.split(",").map(parseFloat); const [r, g, b, a] = parts.split(",").map(parseFloat);
return RGBA.fromInts(r, g, b, a * 255); return RGBA.fromInts(r, g, b, a * 255);
})() })()
: style.backgroundColor : style.backgroundColor
: undefined : undefined
: undefined; : undefined;
@@ -175,9 +222,9 @@ export function Pressable({
onMouseDown={ onMouseDown={
onPress onPress
? (_event) => { ? (_event) => {
// @ts-ignore // @ts-ignore
onPress(); onPress();
} }
: undefined : undefined
} }
backgroundColor={bg} backgroundColor={bg}
@@ -234,7 +281,8 @@ export function TextInput({
}: TextInputProps) { }: TextInputProps) {
return ( return (
<input <input
width={20} minWidth={20}
minHeight={1}
backgroundColor="white" backgroundColor="white"
textColor="black" textColor="black"
focused={true} focused={true}

View File

@@ -1,6 +1,6 @@
import type { Transaction } from "@rocicorp/zero"; import type { Transaction } from "@rocicorp/zero";
import { authDataSchema, type AuthData } from "./auth"; import { authDataSchema, type AuthData } from "./auth";
import { type Schema } from "./zero-schema.gen"; import { type Category, type Schema } from "./zero-schema.gen";
import { isLoggedIn } from "./zql"; import { isLoggedIn } from "./zql";
type Tx = Transaction<Schema>; type Tx = Transaction<Schema>;
@@ -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) {
@@ -74,15 +74,29 @@ export function createMutators(authData: AuthData | null) {
id, id,
budgetId, budgetId,
order, order,
}: { id: string; budgetId: string; order?: number }, }: { id: string; budgetId: string; order: number },
) { ) {
isLoggedIn(authData); isLoggedIn(authData);
if (order != undefined) {
const after = await tx.query.category
.where("budgetId", "=", budgetId)
.where("order", ">", order);
after.forEach((item) => {
tx.mutate.category.update({
id: item.id,
order: item.order + 1,
});
});
}
tx.mutate.category.insert({ tx.mutate.category.insert({
id, id,
budgetId, budgetId,
amount: 0, amount: 0,
every: "week", every: "week",
order: order || 0, order: order + 1,
label: "My category", label: "My category",
color: "#f06", color: "#f06",
createdBy: authData.user.id, createdBy: authData.user.id,
@@ -90,20 +104,46 @@ export function createMutators(authData: AuthData | null) {
}, },
async deleteCategory(tx: Tx, { id }: { id: string }) { async deleteCategory(tx: Tx, { id }: { id: string }) {
isLoggedIn(authData); isLoggedIn(authData);
const item = await tx.query.category.where("id", "=", id).one();
if (!item) throw Error("Item does not exist");
tx.mutate.category.update({ tx.mutate.category.update({
id, id,
removedAt: new Date().getTime(), removedAt: new Date().getTime(),
removedBy: authData.user.id, removedBy: authData.user.id,
}); });
const after = await tx.query.category
.where("budgetId", "=", item.budgetId)
.where("order", ">", item.order)
.run();
for (const item of after) {
tx.mutate.category.update({ id: item.id, order: item.order - 1 });
}
// after.forEach((item) => {
// });
}, },
async updateCategory( async updateCategory(
tx: Tx, tx: Tx,
{ id, label }: { id: string; label: string }, {
id,
label,
order,
amount,
every,
}: {
id: string;
label?: string;
order?: number;
amount?: number;
every?: Category["every"];
},
) { ) {
isLoggedIn(authData); isLoggedIn(authData);
tx.mutate.category.update({ tx.mutate.category.update({
id, id,
label, label,
order,
amount,
every,
}); });
}, },
}, },

View File

@@ -67,7 +67,7 @@ export const queries = {
isLoggedIn(authData); isLoggedIn(authData);
return builder.budget return builder.budget
.related("categories", (q) => .related("categories", (q) =>
q.where("removedAt", "IS", null).orderBy("order", "desc"), q.where("removedAt", "IS", null).orderBy("order", "asc"),
) )
.limit(10); .limit(10);
}, },

View File

@@ -6,7 +6,7 @@ export interface DialogState {
close?: () => void; close?: () => void;
} }
export const Context = createContext<DialogState>({ export const Context = createContext<DialogState>({
close: () => { }, close: () => {},
}); });
interface ProviderProps { interface ProviderProps {
@@ -21,7 +21,7 @@ export function Provider({ children, visible, close }: ProviderProps) {
{/* <Pressable onPress={() => close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */} {/* <Pressable onPress={() => close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */}
<View <View
style={{ style={{
justifyContent: "center", // justifyContent: "center",
alignItems: "center", alignItems: "center",
flex: 1, flex: 1,
backgroundColor: "rgba(0,0,0,0.2)", backgroundColor: "rgba(0,0,0,0.2)",
@@ -39,12 +39,10 @@ interface ContentProps {
} }
export function Content({ children }: ContentProps) { export function Content({ children }: ContentProps) {
const { close } = use(Context); const { close } = use(Context);
useShortcut("escape", () => close?.()); useShortcut("escape", () => close?.(), "dialog");
return ( return (
<View <View style={{ backgroundColor: "white", alignItems: "center", top: 120 }}>
style={{ backgroundColor: "white", padding: 12, alignItems: "center" }}
>
{children} {children}
</View> </View>
); );

View File

@@ -1,19 +1,15 @@
import { import { createContext, use, useEffect, useState, type ReactNode } from "react";
createContext,
use,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import type { KeyEvent } from "@opentui/core";
import { useShortcut } from "../lib/shortcuts/hooks"; import { useShortcut } from "../lib/shortcuts/hooks";
import type { Key } from "../lib/shortcuts"; import type { Key } from "../lib/shortcuts";
const HEADER_COLOR = "#7158e2"; const HEADER_COLOR = "#7158e2";
const TABLE_COLORS = ["#ddd", "#eee"];
const SELECTED_COLOR = "#f7b730"; const COLORS = {
focused: "#ddd",
selected: "#eaebf6",
focused_selected: "#d5d7ef",
};
const EXTRA = 5; const EXTRA = 5;
@@ -24,7 +20,7 @@ interface TableState {
columns: Column[]; columns: Column[];
columnMap: Map<string, number>; columnMap: Map<string, number>;
idx: number; idx: number;
selectedFrom: number | undefined; selectedIdx: Set<number>;
} }
const INITAL_STATE = { const INITAL_STATE = {
@@ -32,7 +28,7 @@ const INITAL_STATE = {
columns: [], columns: [],
columnMap: new Map(), columnMap: new Map(),
idx: 0, idx: 0,
selectedFrom: undefined, selectedIdx: new Set(),
} satisfies TableState; } satisfies TableState;
export const Context = createContext<TableState>(INITAL_STATE); export const Context = createContext<TableState>(INITAL_STATE);
@@ -69,7 +65,7 @@ export function Provider<T extends ValidRecord>({
shortcuts, shortcuts,
}: ProviderProps<T>) { }: ProviderProps<T>) {
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const [selectedFrom, setSelectedFrom] = useState<number>(); const [selectedIdx, setSelectedIdx] = useState(new Set<number>());
useShortcut("j", () => { useShortcut("j", () => {
setIdx((prev) => Math.min(prev + 1, data.length - 1)); setIdx((prev) => Math.min(prev + 1, data.length - 1));
@@ -84,6 +80,17 @@ export function Provider<T extends ValidRecord>({
setIdx((prev) => Math.max(prev - 1, 0)); setIdx((prev) => Math.max(prev - 1, 0));
}); });
useShortcut("escape", () => {
setSelectedIdx(new Set());
});
useShortcut("x", () => {
setSelectedIdx((last) => {
const newSelected = new Set(last);
newSelected.add(idx);
return newSelected;
});
});
useEffect(() => { useEffect(() => {
setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0)); setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0));
}, [data]); }, [data]);
@@ -91,9 +98,9 @@ export function Provider<T extends ValidRecord>({
if (shortcuts) { if (shortcuts) {
for (const shortcut of shortcuts) { for (const shortcut of shortcuts) {
useShortcut(shortcut.key, () => { useShortcut(shortcut.key, () => {
const from = selectedFrom ? Math.min(idx, selectedFrom) : idx; const selected = data.filter(
const to = selectedFrom ? Math.max(idx, selectedFrom) : idx; (_, index) => idx == index || selectedIdx.has(index),
const selected = data.slice(from, to + 1); );
shortcut.handler({ selected, index: idx }); shortcut.handler({ selected, index: idx });
}); });
} }
@@ -112,14 +119,14 @@ export function Provider<T extends ValidRecord>({
); );
return ( return (
<Context.Provider value={{ data, columns, columnMap, idx, selectedFrom }}> <Context.Provider value={{ data, columns, columnMap, idx, selectedIdx }}>
{children} {children}
</Context.Provider> </Context.Provider>
); );
} }
export function Body() { export function Body() {
const { columns, data, columnMap, idx, selectedFrom } = use(Context); const { columns, data, columnMap, idx, selectedIdx } = use(Context);
return ( return (
<View> <View>
<View style={{ backgroundColor: HEADER_COLOR, flexDirection: "row" }}> <View style={{ backgroundColor: HEADER_COLOR, flexDirection: "row" }}>
@@ -136,19 +143,21 @@ export function Body() {
))} ))}
</View> </View>
{data.map((row, index) => { {data.map((row, index) => {
const isSelected = const isSelected = selectedIdx.has(index);
index == idx || const isFocused = index == idx;
(selectedFrom != undefined &&
((selectedFrom <= index && index <= idx) ||
(idx <= index && index <= selectedFrom)));
return ( return (
<View <View
key={index} key={index}
style={{ style={{
backgroundColor: isSelected backgroundColor:
? SELECTED_COLOR isSelected && isFocused
: TABLE_COLORS[index % 2], ? COLORS.focused_selected
: isFocused
? COLORS.focused
: isSelected
? COLORS.selected
: undefined,
}} }}
> >
<TableRow <TableRow

View File

@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { keysStore } from "./store"; import { keysStore, type ScopeKeys } from "./store";
export function ShortcutDebug() { export function ShortcutDebug() {
const entries = useSyncExternalStore( const entries = useSyncExternalStore(
@@ -19,14 +19,23 @@ export function ShortcutDebug() {
padding: 10, padding: 10,
}} }}
> >
<Text style={{ color: "red", fontFamily: "mono" }}>Registered:</Text> <Text style={{ color: "red", fontFamily: "mono" }}>Scopes:</Text>
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}> {entries.map(([scope, keys]) => (
{entries <ScopeView key={scope} scope={scope} keys={keys} />
.values() ))}
.map(([key, _]) => key)
.toArray()
.join(",")}
</Text>
</View> </View>
); );
} }
function ScopeView({ scope, keys }: { scope: string; keys: ScopeKeys }) {
return (
<Text style={{ color: "red", fontFamily: "mono", textAlign: "right" }}>
{scope}:
{keys
.entries()
.map(([key, _]) => key)
.toArray()
.join(",")}
</Text>
);
}

View File

@@ -3,16 +3,20 @@ import { keysStore } from "./store";
import type { Key } from "./types"; import type { Key } from "./types";
import { enforceKeyOptions } from "./util"; import { enforceKeyOptions } from "./util";
export const useShortcut = (key: Key, handler: () => void) => { export const useShortcut = (
key: Key,
handler: () => void,
scope: string = "global",
) => {
const keyOptions = enforceKeyOptions(key); const keyOptions = enforceKeyOptions(key);
const keyName = keyOptions.name; const keyName = keyOptions.name;
const ref = useRef(handler); const ref = useRef(handler);
ref.current = handler; ref.current = handler;
useEffect(() => { useEffect(() => {
keysStore.register(keyName, ref); keysStore.register(keyName, ref, scope);
return () => { return () => {
keysStore.deregister(keyName); keysStore.deregister(keyName, scope);
}; };
}, []); }, []);
}; };

View File

@@ -1,16 +1,18 @@
import { type RefObject } from "react"; import { type RefObject } from "react";
// internal map export type ScopeKeys = Map<string, RefObject<() => void>>;
const keys = new Map<string, RefObject<() => void>>();
// cached snapshot (stable reference) // outer reactive container
let snapshot: [string, RefObject<() => void>][] = []; const scopes = new Map<string, ScopeKeys>();
let listeners = new Set<() => void>(); // stable snapshot for subscribers
let snapshot: [string, ScopeKeys][] = [];
const listeners = new Set<() => void>();
function emit() { function emit() {
// refresh snapshot ONLY when keys actually change // replace identity so subscribers re-render
snapshot = Array.from(keys.entries()); snapshot = Array.from(scopes.entries());
for (const fn of listeners) fn(); for (const fn of listeners) fn();
} }
@@ -21,20 +23,36 @@ export const keysStore = {
}, },
getSnapshot() { getSnapshot() {
return snapshot; // stable unless emit() ran return snapshot;
}, },
register(key: string, ref: RefObject<() => void>) { register(key: string, ref: RefObject<() => void>, scope: string) {
keys.set(key, ref); const prev = scopes.get(scope);
const next = new Map(prev); // <-- important: new identity
next.set(key, ref);
scopes.set(scope, next); // <-- outer identity also changes
emit(); emit();
}, },
deregister(key: string) { deregister(key: string, scope: string) {
keys.delete(key); const prev = scopes.get(scope);
if (!prev) return;
const next = new Map(prev);
next.delete(key);
if (next.size === 0) {
scopes.delete(scope);
} else {
scopes.set(scope, next);
}
emit(); emit();
}, },
getHandler(key: string) { getHandler(key: string) {
return keys.get(key)?.current; // last scope wins — clarify this logic as needed
const last = Array.from(scopes.values()).at(-1);
return last?.get(key)?.current;
}, },
}; };

View File

@@ -10,7 +10,12 @@ import {
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"; import { RenameCategoryDialog } from "./budget/RenameCategoryDialog";
import {
UpdateCategoryAmountDialog,
type CategoryWithComputed,
type Updating,
} from "./budget/UpdateCategoryAmountDialog";
const COLUMNS: Table.Column[] = [ const COLUMNS: Table.Column[] = [
{ name: "label", label: "Name" }, { name: "label", label: "Name" },
@@ -24,9 +29,9 @@ export function Budget() {
const { auth } = use(RouterContext); const { auth } = use(RouterContext);
const [budgets] = useQuery(queries.getBudgets(auth)); const [budgets] = useQuery(queries.getBudgets(auth));
const [renaming, setRenaming] = useState<Category>(); const [renaming, setRenaming] = useState<Category>();
const [editCategoryAmount, setEditCategoryAmount] = useState<Updating>();
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();
@@ -60,8 +65,8 @@ export function Budget() {
const data = budget.categories.slice().map((category) => { const data = budget.categories.slice().map((category) => {
const { amount } = category; const { amount } = category;
const week = amount; const week = amount / 4;
const month = amount * 4; const month = amount;
const year = amount * 12; const year = amount * 12;
return { return {
@@ -79,7 +84,7 @@ export function Budget() {
z.mutate.budget.createCategory({ z.mutate.budget.createCategory({
id, id,
budgetId: budget.id, budgetId: budget.id,
order: index - 1, order: index,
}); });
}; };
@@ -95,38 +100,37 @@ export function Budget() {
} }
}; };
const onEditCategoryYearly = ({
selected,
}: { selected: CategoryWithComputed[] }) => {
for (const category of selected) {
setEditCategoryAmount({ category, every: "year" });
}
};
const onEditCategoryMonthly = ({
selected,
}: { selected: CategoryWithComputed[] }) => {
for (const category of selected) {
setEditCategoryAmount({ category, every: "month" });
}
};
const onEditCategoryWeekly = ({
selected,
}: { selected: CategoryWithComputed[] }) => {
for (const category of selected) {
setEditCategoryAmount({ category, every: "week" });
}
};
return ( return (
<> <>
<Dialog.Provider <RenameCategoryDialog renaming={renaming} setRenaming={setRenaming} />
visible={renaming != undefined} <UpdateCategoryAmountDialog
close={() => setRenaming(undefined)} updating={editCategoryAmount}
> setUpdating={setEditCategoryAmount}
<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" }}> <View style={{ alignItems: "flex-start" }}>
<Text style={{ fontFamily: "mono", textAlign: "left" }}> <Text style={{ fontFamily: "mono", textAlign: "left" }}>
@@ -140,6 +144,9 @@ export function Budget() {
{ key: "i", handler: newCategory }, { key: "i", handler: newCategory },
{ key: "d", handler: deleteCategory }, { key: "d", handler: deleteCategory },
{ key: "r", handler: renameCategory }, { key: "r", handler: renameCategory },
{ key: "y", handler: onEditCategoryYearly },
{ key: "m", handler: onEditCategoryMonthly },
{ key: "w", handler: onEditCategoryWeekly },
]} ]}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>

View File

@@ -0,0 +1,77 @@
import { useRef, useState } from "react";
import * as Dialog from "../../components/Dialog";
import { View, Text, TextInput } from "react-native";
import { type Category, type Mutators, type Schema } from "@money/shared";
import { useZero } from "@rocicorp/zero/react";
interface RenameCategoryDialogProps {
renaming: Category | undefined;
setRenaming: (v: Category | undefined) => void;
}
export function RenameCategoryDialog({
renaming,
setRenaming,
}: RenameCategoryDialogProps) {
const refText = useRef("");
const [renamingText, setRenamingText] = useState("");
const z = useZero<Schema, Mutators>();
return (
<Dialog.Provider
visible={renaming != undefined}
close={() => setRenaming(undefined)}
>
<Dialog.Content>
<View style={{ width: 400 }}>
<View
style={{
borderBottomWidth: 1,
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
>
<TextInput
style={{
fontFamily: "mono",
// @ts-ignore
outline: "none",
}}
autoFocus
selectTextOnFocus
defaultValue={renaming?.label}
onChangeText={(t) => {
refText.current = t;
setRenamingText(t);
}}
onKeyPress={(e) => {
if (!renaming) return;
if (e.nativeEvent.key == "Enter") {
if (refText.current.trim() == "")
return setRenaming(undefined);
z.mutate.budget.updateCategory({
id: renaming.id,
label: refText.current,
});
setRenaming(undefined);
} else if (e.nativeEvent.key == "Escape") {
setRenaming(undefined);
}
}}
/>
</View>
<View
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
>
<Text style={{ fontFamily: "mono" }}>
Rename category to: {renamingText || renaming?.label}
</Text>
<Text style={{ fontFamily: "mono" }}> Cancel</Text>
</View>
</View>
</Dialog.Content>
</Dialog.Provider>
);
}

View File

@@ -0,0 +1,107 @@
import { useRef, useState } from "react";
import * as Dialog from "../../components/Dialog";
import { View, Text, TextInput } from "react-native";
import { type Category, type Mutators, type Schema } from "@money/shared";
import { useZero } from "@rocicorp/zero/react";
export type Updating = {
category: CategoryWithComputed;
every: Category["every"];
};
export type CategoryWithComputed = Category & {
month: number;
year: number;
};
interface UpdateCategoryAmountDialogProps {
updating: Updating | undefined;
setUpdating: (v: Updating | undefined) => void;
}
export function UpdateCategoryAmountDialog({
updating,
setUpdating,
}: UpdateCategoryAmountDialogProps) {
const category = updating?.category;
const every = updating?.every;
const refText = useRef("");
const [amountText, setAmountText] = useState("");
const z = useZero<Schema, Mutators>();
return (
<Dialog.Provider
visible={category != undefined}
close={() => setUpdating(undefined)}
>
<Dialog.Content>
<View style={{ width: 400 }}>
<View
style={{
borderBottomWidth: 1,
paddingTop: 12,
paddingLeft: 12,
paddingRight: 12,
}}
>
<TextInput
style={{
fontFamily: "mono",
// @ts-ignore
outline: "none",
}}
autoFocus
selectTextOnFocus
defaultValue={category?.month.toString()}
onChangeText={(t) => {
refText.current = t;
setAmountText(t);
}}
onKeyPress={(e) => {
if (!category) return;
if (e.nativeEvent.key == "Enter") {
if (refText.current.trim() == "")
return setUpdating(undefined);
try {
const parsed = parseFloat(refText.current);
const amount = (function () {
switch (every) {
case "year":
return parsed / 12;
case "month":
return parsed;
case "week":
return parsed * 4;
}
})();
z.mutate.budget.updateCategory({
id: category.id,
amount,
every,
});
setUpdating(undefined);
} catch (e) {}
} else if (e.nativeEvent.key == "Escape") {
setUpdating(undefined);
}
}}
/>
</View>
<View
style={{ paddingLeft: 12, paddingRight: 12, paddingBottom: 12 }}
>
<Text style={{ fontFamily: "mono" }}>
Update monthly amount to: {amountText || category?.month}
</Text>
<Text style={{ fontFamily: "mono" }}> Cancel</Text>
</View>
</View>
</Dialog.Content>
</Dialog.Provider>
);
}

View File

@@ -39,14 +39,14 @@ type Join<A extends string, B extends string> = `${A}${B}` extends `${infer X}`
type ChildRoutes<Parent extends string, Children> = { type ChildRoutes<Parent extends string, Children> = {
[K in keyof Children & string]: K extends `/${string}` [K in keyof Children & string]: K extends `/${string}`
? Join<Parent, K> ? Join<Parent, K>
: never; : never;
}[keyof Children & string]; }[keyof Children & string];
type Routes<T> = { type Routes<T> = {
[K in keyof T & string]: [K in keyof T & string]:
| K | K
| (T[K] extends { children: infer C } ? ChildRoutes<K, C> : never); | (T[K] extends { children: infer C } ? ChildRoutes<K, C> : never);
}[keyof T & string]; }[keyof T & string];
export type Route = Routes<typeof PAGES>; export type Route = Routes<typeof PAGES>;
@@ -60,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 = {
@@ -91,8 +91,8 @@ function Main() {
route in PAGES route in PAGES
? (route as keyof typeof PAGES) ? (route as keyof typeof PAGES)
: (Object.keys(PAGES) : (Object.keys(PAGES)
.sort((a, b) => b.length - a.length) .sort((a, b) => b.length - a.length)
.find((p) => route.startsWith(p)) as keyof typeof PAGES); .find((p) => route.startsWith(p)) as keyof typeof PAGES);
return ( return (
<View style={{ backgroundColor: "white", flex: 1 }}> <View style={{ backgroundColor: "white", flex: 1 }}>

View File

@@ -40,13 +40,11 @@ export function Transactions() {
<Table.Provider <Table.Provider
data={items} data={items}
columns={COLUMNS} columns={COLUMNS}
onKey={(key) => { shortcuts={[
if (key.name == "r" && key.shift) { { key: "r", handler: () => z.mutate.link.updateTransactions() },
z.mutate.link.updateTransactions(); ]}
}
}}
> >
<View style={{ flex: 1 }}> <View style={{ padding: 10, flex: 1 }}>
<View style={{ flexShrink: 0 }}> <View style={{ flexShrink: 0 }}>
<Table.Body /> <Table.Body />
</View> </View>
@@ -59,18 +57,16 @@ export function Transactions() {
} }
function Selected() { function Selected() {
const { data, idx, selectedFrom } = use(Table.Context); const { data, selectedIdx } = use(Table.Context);
if (selectedFrom == undefined) if (selectedIdx.size == 0)
return ( return (
<View style={{ backgroundColor: "#ddd" }}> <View style={{ backgroundColor: "#ddd" }}>
<Text style={{ fontFamily: "mono" }}>No items selected</Text> <Text style={{ fontFamily: "mono" }}>No items selected</Text>
</View> </View>
); );
const from = Math.min(idx, selectedFrom); const selected = data.filter((_, i) => selectedIdx.has(i)) as Transaction[];
const to = Math.max(idx, selectedFrom);
const selected = data.slice(from, to + 1) as Transaction[];
const count = selected.length; const count = selected.length;
const sum = selected.reduce((prev, curr) => prev + curr.amount, 0); const sum = selected.reduce((prev, curr) => prev + curr.amount, 0);