diff --git a/packages/react-native-opentui/index.tsx b/packages/react-native-opentui/index.tsx index b7eb769..5172030 100644 --- a/packages/react-native-opentui/index.tsx +++ b/packages/react-native-opentui/index.tsx @@ -11,7 +11,7 @@ import type { TextInputProps, } from "react-native"; import { useTerminalDimensions } from "@opentui/react"; -import { RGBA } from "@opentui/core"; +import { BorderSides, RGBA } from "@opentui/core"; import { platform } from "node:os"; import { exec } from "node:child_process"; @@ -57,15 +57,36 @@ 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; 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 = { overflow: attr(style, "overflow", "string"), @@ -75,7 +96,6 @@ 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"), @@ -84,13 +104,40 @@ export function View({ children, style }: ViewProps) { 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 ( {children} @@ -108,10 +155,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; @@ -175,9 +222,9 @@ export function Pressable({ onMouseDown={ onPress ? (_event) => { - // @ts-ignore - onPress(); - } + // @ts-ignore + onPress(); + } : undefined } backgroundColor={bg} @@ -234,7 +281,8 @@ export function TextInput({ }: TextInputProps) { return ( ; @@ -8,10 +8,10 @@ type Tx = Transaction; 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) { @@ -74,15 +74,29 @@ export function createMutators(authData: AuthData | null) { id, budgetId, order, - }: { id: string; budgetId: string; order?: number }, + }: { id: string; budgetId: string; order: number }, ) { 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({ id, budgetId, amount: 0, every: "week", - order: order || 0, + order: order + 1, label: "My category", color: "#f06", createdBy: authData.user.id, @@ -90,20 +104,46 @@ export function createMutators(authData: AuthData | null) { }, async deleteCategory(tx: Tx, { id }: { id: string }) { isLoggedIn(authData); + const item = await tx.query.category.where("id", "=", id).one(); + if (!item) throw Error("Item does not exist"); tx.mutate.category.update({ id, removedAt: new Date().getTime(), 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( 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); tx.mutate.category.update({ id, label, + order, + amount, + every, }); }, }, diff --git a/packages/shared/src/queries.ts b/packages/shared/src/queries.ts index f6d4b41..b76c930 100644 --- a/packages/shared/src/queries.ts +++ b/packages/shared/src/queries.ts @@ -67,7 +67,7 @@ export const queries = { isLoggedIn(authData); return builder.budget .related("categories", (q) => - q.where("removedAt", "IS", null).orderBy("order", "desc"), + q.where("removedAt", "IS", null).orderBy("order", "asc"), ) .limit(10); }, diff --git a/packages/ui/components/Dialog.tsx b/packages/ui/components/Dialog.tsx index b1395c3..7b1a3cf 100644 --- a/packages/ui/components/Dialog.tsx +++ b/packages/ui/components/Dialog.tsx @@ -6,7 +6,7 @@ export interface DialogState { close?: () => void; } export const Context = createContext({ - close: () => { }, + close: () => {}, }); interface ProviderProps { @@ -21,7 +21,7 @@ export function Provider({ children, visible, close }: ProviderProps) { {/* close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */} close?.()); + useShortcut("escape", () => close?.(), "dialog"); return ( - + {children} ); diff --git a/packages/ui/components/Table.tsx b/packages/ui/components/Table.tsx index a45c0da..4d432e9 100644 --- a/packages/ui/components/Table.tsx +++ b/packages/ui/components/Table.tsx @@ -1,19 +1,15 @@ -import { - createContext, - use, - useEffect, - useRef, - useState, - type ReactNode, -} from "react"; +import { createContext, use, useEffect, useState, type ReactNode } from "react"; 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"]; -const SELECTED_COLOR = "#f7b730"; + +const COLORS = { + focused: "#ddd", + selected: "#eaebf6", + focused_selected: "#d5d7ef", +}; const EXTRA = 5; @@ -24,7 +20,7 @@ interface TableState { columns: Column[]; columnMap: Map; idx: number; - selectedFrom: number | undefined; + selectedIdx: Set; } const INITAL_STATE = { @@ -32,7 +28,7 @@ const INITAL_STATE = { columns: [], columnMap: new Map(), idx: 0, - selectedFrom: undefined, + selectedIdx: new Set(), } satisfies TableState; export const Context = createContext(INITAL_STATE); @@ -69,7 +65,7 @@ export function Provider({ shortcuts, }: ProviderProps) { const [idx, setIdx] = useState(0); - const [selectedFrom, setSelectedFrom] = useState(); + const [selectedIdx, setSelectedIdx] = useState(new Set()); useShortcut("j", () => { setIdx((prev) => Math.min(prev + 1, data.length - 1)); @@ -84,6 +80,17 @@ export function Provider({ 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(() => { setIdx((prev) => Math.max(Math.min(prev, data.length - 1), 0)); }, [data]); @@ -91,9 +98,9 @@ export function Provider({ 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); + const selected = data.filter( + (_, index) => idx == index || selectedIdx.has(index), + ); shortcut.handler({ selected, index: idx }); }); } @@ -112,14 +119,14 @@ export function Provider({ ); return ( - + {children} ); } export function Body() { - const { columns, data, columnMap, idx, selectedFrom } = use(Context); + const { columns, data, columnMap, idx, selectedIdx } = use(Context); return ( @@ -136,19 +143,21 @@ export function Body() { ))} {data.map((row, index) => { - const isSelected = - index == idx || - (selectedFrom != undefined && - ((selectedFrom <= index && index <= idx) || - (idx <= index && index <= selectedFrom))); + const isSelected = selectedIdx.has(index); + const isFocused = index == idx; return ( - Registered: - - {entries - .values() - .map(([key, _]) => key) - .toArray() - .join(",")} - + Scopes: + {entries.map(([scope, keys]) => ( + + ))} ); } + +function ScopeView({ scope, keys }: { scope: string; keys: ScopeKeys }) { + return ( + + {scope}: + {keys + .entries() + .map(([key, _]) => key) + .toArray() + .join(",")} + + ); +} diff --git a/packages/ui/lib/shortcuts/hooks.ts b/packages/ui/lib/shortcuts/hooks.ts index 2dd1aa8..981cfb5 100644 --- a/packages/ui/lib/shortcuts/hooks.ts +++ b/packages/ui/lib/shortcuts/hooks.ts @@ -3,16 +3,20 @@ import { keysStore } from "./store"; import type { Key } from "./types"; 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 keyName = keyOptions.name; const ref = useRef(handler); ref.current = handler; useEffect(() => { - keysStore.register(keyName, ref); + keysStore.register(keyName, ref, scope); return () => { - keysStore.deregister(keyName); + keysStore.deregister(keyName, scope); }; }, []); }; diff --git a/packages/ui/lib/shortcuts/store.ts b/packages/ui/lib/shortcuts/store.ts index 126c27c..6c26138 100644 --- a/packages/ui/lib/shortcuts/store.ts +++ b/packages/ui/lib/shortcuts/store.ts @@ -1,16 +1,18 @@ import { type RefObject } from "react"; -// internal map -const keys = new Map void>>(); +export type ScopeKeys = Map void>>; -// cached snapshot (stable reference) -let snapshot: [string, RefObject<() => void>][] = []; +// outer reactive container +const scopes = new Map(); -let listeners = new Set<() => void>(); +// stable snapshot for subscribers +let snapshot: [string, ScopeKeys][] = []; + +const listeners = new Set<() => void>(); function emit() { - // refresh snapshot ONLY when keys actually change - snapshot = Array.from(keys.entries()); + // replace identity so subscribers re-render + snapshot = Array.from(scopes.entries()); for (const fn of listeners) fn(); } @@ -21,20 +23,36 @@ export const keysStore = { }, getSnapshot() { - return snapshot; // stable unless emit() ran + return snapshot; }, - register(key: string, ref: RefObject<() => void>) { - keys.set(key, ref); + register(key: string, ref: RefObject<() => void>, scope: string) { + 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(); }, - deregister(key: string) { - keys.delete(key); + deregister(key: string, scope: string) { + 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(); }, 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; }, }; diff --git a/packages/ui/src/budget.tsx b/packages/ui/src/budget.tsx index 09a99ca..6b3ba3a 100644 --- a/packages/ui/src/budget.tsx +++ b/packages/ui/src/budget.tsx @@ -10,7 +10,12 @@ import { import { useQuery, useZero } from "@rocicorp/zero/react"; import * as Table from "../components/Table"; 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[] = [ { name: "label", label: "Name" }, @@ -24,9 +29,9 @@ export function Budget() { const { auth } = use(RouterContext); const [budgets] = useQuery(queries.getBudgets(auth)); const [renaming, setRenaming] = useState(); + const [editCategoryAmount, setEditCategoryAmount] = useState(); const z = useZero(); - const refText = useRef(""); const newBudget = () => { const id = new Date().getTime().toString(); @@ -60,8 +65,8 @@ export function Budget() { const data = budget.categories.slice().map((category) => { const { amount } = category; - const week = amount; - const month = amount * 4; + const week = amount / 4; + const month = amount; const year = amount * 12; return { @@ -79,7 +84,7 @@ export function Budget() { z.mutate.budget.createCategory({ 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 ( <> - setRenaming(undefined)} - > - - Edit Category - - { - 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); - } - }} - /> - - + + @@ -140,6 +144,9 @@ export function Budget() { { key: "i", handler: newCategory }, { key: "d", handler: deleteCategory }, { key: "r", handler: renameCategory }, + { key: "y", handler: onEditCategoryYearly }, + { key: "m", handler: onEditCategoryMonthly }, + { key: "w", handler: onEditCategoryWeekly }, ]} > diff --git a/packages/ui/src/budget/RenameCategoryDialog.tsx b/packages/ui/src/budget/RenameCategoryDialog.tsx new file mode 100644 index 0000000..4396465 --- /dev/null +++ b/packages/ui/src/budget/RenameCategoryDialog.tsx @@ -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(); + + return ( + setRenaming(undefined)} + > + + + + { + 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); + } + }} + /> + + + + + → Rename category to: {renamingText || renaming?.label} + + → Cancel + + + + + ); +} diff --git a/packages/ui/src/budget/UpdateCategoryAmountDialog.tsx b/packages/ui/src/budget/UpdateCategoryAmountDialog.tsx new file mode 100644 index 0000000..83a8546 --- /dev/null +++ b/packages/ui/src/budget/UpdateCategoryAmountDialog.tsx @@ -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(); + + return ( + setUpdating(undefined)} + > + + + + { + 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); + } + }} + /> + + + + + → Update monthly amount to: {amountText || category?.month} + + → Cancel + + + + + ); +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index e750e1a..c4ec52b 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -39,14 +39,14 @@ type Join = `${A}${B}` extends `${infer X}` type ChildRoutes = { [K in keyof Children & string]: K extends `/${string}` - ? Join - : never; + ? Join + : never; }[keyof Children & string]; type Routes = { [K in keyof T & string]: - | K - | (T[K] extends { children: infer C } ? ChildRoutes : never); + | K + | (T[K] extends { children: infer C } ? ChildRoutes : never); }[keyof T & string]; export type Route = Routes; @@ -60,7 +60,7 @@ interface RouterContextType { export const RouterContext = createContext({ auth: null, route: "/", - setRoute: () => { }, + setRoute: () => {}, }); type AppProps = { @@ -91,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 ( diff --git a/packages/ui/src/transactions.tsx b/packages/ui/src/transactions.tsx index a958e49..31f8d2d 100644 --- a/packages/ui/src/transactions.tsx +++ b/packages/ui/src/transactions.tsx @@ -40,13 +40,11 @@ export function Transactions() { { - if (key.name == "r" && key.shift) { - z.mutate.link.updateTransactions(); - } - }} + shortcuts={[ + { key: "r", handler: () => z.mutate.link.updateTransactions() }, + ]} > - + @@ -59,18 +57,16 @@ export function Transactions() { } function Selected() { - const { data, idx, selectedFrom } = use(Table.Context); + const { data, selectedIdx } = use(Table.Context); - if (selectedFrom == undefined) + if (selectedIdx.size == 0) return ( No items selected ); - const from = Math.min(idx, selectedFrom); - const to = Math.max(idx, selectedFrom); - const selected = data.slice(from, to + 1) as Transaction[]; + const selected = data.filter((_, i) => selectedIdx.has(i)) as Transaction[]; const count = selected.length; const sum = selected.reduce((prev, curr) => prev + curr.amount, 0);