summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-06-26 16:40:45 -0600
committermo khan <mo@mokhan.ca>2025-06-26 16:40:45 -0600
commit1efabb36bd626649e6b1569d64e77baf06a1677e (patch)
treefa5a6fd7cf5c422f30e14ecd7d80c0149199a441
parent341e1ab4db97447ad9263941e81c66ce02434e3a (diff)
feat: add Mint.com-inspired Terminal User Interface (TUI)
- Add interactive TUI with dashboard, transactions, budgets, and investments views - Implement Mint-inspired dashboard with net worth, cash flow, and spending categories - Add transaction browser with search and filtering capabilities - Include visual progress bars and gauges for financial metrics - Support keyboard navigation with Tab, arrow keys, and shortcuts - Load ALL transactions (not just recent) for complete visibility - Add comprehensive README documentation for TUI usage The TUI provides a modern, visual way to interact with financial data directly in the terminal, making it easy to browse transactions and view financial summaries at a glance. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--Cargo.lock535
-rw-r--r--Cargo.toml6
-rw-r--r--README.md70
-rw-r--r--src/cli.rs2
-rw-r--r--src/main.rs5
-rw-r--r--src/tui/app.rs195
-rw-r--r--src/tui/dashboard.rs252
-rw-r--r--src/tui/event.rs56
-rw-r--r--src/tui/mod.rs49
-rw-r--r--src/tui/transactions.rs197
-rw-r--r--src/tui/ui.rs244
11 files changed, 1589 insertions, 22 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 8a895b7..0ea89d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -139,7 +139,7 @@ dependencies = [
"miniz_oxide",
"object",
"rustc-demangle",
- "windows-targets",
+ "windows-targets 0.52.6",
]
[[package]]
@@ -167,6 +167,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "castaway"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
name = "cc"
version = "1.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -243,6 +258,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
+name = "compact_str"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -293,6 +335,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
+name = "crossterm"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "libc",
+ "mio 0.8.11",
+ "parking_lot",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "mio 1.0.4",
+ "parking_lot",
+ "rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "csv"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -314,6 +397,41 @@ dependencies = [
]
[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -399,6 +517,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -524,6 +648,11 @@ name = "hashbrown"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
[[package]]
name = "hashlink"
@@ -769,6 +898,12 @@ dependencies = [
]
[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
name = "idna"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -800,6 +935,25 @@ dependencies = [
]
[[package]]
+name = "indoc"
+version = "2.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
+
+[[package]]
+name = "instability"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -822,6 +976,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -861,6 +1033,12 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
@@ -872,6 +1050,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
+name = "lock_api"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -897,6 +1085,15 @@ dependencies = [
]
[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.4",
+]
+
+[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -931,11 +1128,24 @@ dependencies = [
[[package]]
name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mio"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
+ "log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -1048,6 +1258,35 @@ dependencies = [
]
[[package]]
+name = "parking_lot"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1111,6 +1350,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
+name = "ratatui"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "compact_str 0.7.1",
+ "crossterm 0.27.0",
+ "itertools 0.12.1",
+ "lru",
+ "paste",
+ "stability",
+ "strum",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "compact_str 0.8.1",
+ "crossterm 0.28.1",
+ "indoc",
+ "instability",
+ "itertools 0.13.0",
+ "lru",
+ "paste",
+ "strum",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
+[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1131,6 +1411,15 @@ dependencies = [
]
[[package]]
+name = "redox_syscall"
+version = "0.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1235,6 +1524,19 @@ checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
@@ -1242,7 +1544,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
]
@@ -1301,6 +1603,12 @@ dependencies = [
]
[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1374,6 +1682,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
+dependencies = [
+ "libc",
+ "mio 0.8.11",
+ "mio 1.0.4",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "slab"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1402,8 +1741,10 @@ dependencies = [
"anyhow",
"chrono",
"clap",
+ "crossterm 0.27.0",
"csv",
"lopdf",
+ "ratatui 0.26.3",
"regex",
"reqwest",
"rusqlite",
@@ -1411,6 +1752,18 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
+ "tui-textarea",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "stability"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac"
+dependencies = [
+ "quote",
+ "syn",
]
[[package]]
@@ -1420,12 +1773,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1492,7 +1873,7 @@ dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
- "rustix",
+ "rustix 1.0.7",
"windows-sys 0.59.0",
]
@@ -1546,7 +1927,7 @@ dependencies = [
"backtrace",
"bytes",
"libc",
- "mio",
+ "mio 1.0.4",
"pin-project-lite",
"socket2",
"tokio-macros",
@@ -1668,12 +2049,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
+name = "tui-textarea"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e"
+dependencies = [
+ "crossterm 0.27.0",
+ "ratatui 0.29.0",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
+[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1826,6 +2247,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1897,11 +2340,20 @@ dependencies = [
[[package]]
name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets",
+ "windows-targets 0.52.6",
]
[[package]]
@@ -1910,7 +2362,22 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
]
[[package]]
@@ -1919,30 +2386,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
@@ -1955,24 +2440,48 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
diff --git a/Cargo.toml b/Cargo.toml
index 75fe0c3..105b322 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,20 +1,24 @@
[package]
name = "spendr"
version = "0.1.0"
-edition = "2024"
+edition = "2021"
[dependencies]
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.4", features = ["derive"] }
+crossterm = "0.27"
csv = "1.3"
lopdf = "0.32"
+ratatui = "0.26"
regex = "1.10"
reqwest = { version = "0.12", features = ["json"] }
rusqlite = { version = "0.29" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.45", features = ["macros", "rt-multi-thread", "time"] }
+tui-textarea = "0.4"
+unicode-width = "0.1"
[dev-dependencies]
tempfile = "3.8"
diff --git a/README.md b/README.md
index 65bbc67..1e62f3b 100644
--- a/README.md
+++ b/README.md
@@ -9,16 +9,60 @@ transactions from Canadian banks.
- CIBC Mastercard
- BMO Mastercard
- TD Bank
+ - Simplii Financial
+ - Wise
+- Auto-detect bank formats
- Normalize transaction data
- Store transactions in SQLite
-- (Planned) Generate spending reports and summaries
+- Generate spending reports and analytics
+- Investment portfolio tracking
+- Terminal User Interface (TUI) with Mint.com-inspired design
## 🚀 Getting Started
+### Import Transactions
```bash
-cargo build --release
-./target/release/spendr import --bank cibc --file ./data/cibc.csv
-````
+# Auto-detect and import all CSV files in a directory
+cargo run -- import ./data
+
+# Import a specific file
+cargo run -- import ./data/cibc.csv
+```
+
+### View Your Data with TUI
+```bash
+# Launch the Terminal User Interface
+cargo run -- tui
+```
+
+### Command Line Analysis
+```bash
+# View summary
+cargo run -- summary
+
+# Recent transactions
+cargo run -- recent -l 20
+
+# Analytics
+cargo run -- analytics --trends --net-worth
+```
+
+## 🖥️ Terminal User Interface (TUI)
+
+The TUI provides a Mint.com-inspired interface for viewing your financial data:
+
+### Features
+- **Dashboard**: Net worth, cash flow, spending categories, and recent transactions
+- **Transactions**: Browse, search, and categorize all transactions
+- **Budgets**: View budget status and spending limits
+- **Investments**: Portfolio overview and performance
+
+### Navigation
+- `Tab`/`Shift+Tab`: Switch between views
+- `↑`/`↓`: Navigate lists
+- `/`: Search transactions
+- `?`: Show help
+- `q`: Quit
## 🧱 Project Structure
@@ -28,6 +72,12 @@ src/
├── cli.rs # Argument parsing via clap
├── db.rs # SQLite integration
├── model.rs # Transaction struct
+├── tui/ # Terminal UI modules
+│ ├── mod.rs # TUI setup
+│ ├── app.rs # Application state
+│ ├── dashboard.rs # Dashboard view
+│ ├── transactions.rs # Transactions view
+│ └── ui.rs # UI rendering
└── parser/
├── mod.rs
├── cibc.rs
@@ -43,7 +93,11 @@ src/
## 📅 Roadmap
-* [ ] Add category/tag support
-* [ ] Summarize by merchant/month/category
-* [ ] Export to JSON/CSV
-* [ ] Web/TUI dashboard
+* [x] Add category/tag support
+* [x] Summarize by merchant/month/category
+* [x] Export to JSON/CSV
+* [x] Terminal UI dashboard
+* [ ] Transaction editing in TUI
+* [ ] Budget management in TUI
+* [ ] Web dashboard
+* [ ] Mobile app
diff --git a/src/cli.rs b/src/cli.rs
index 51e04cc..93c6fd0 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -110,4 +110,6 @@ pub enum Commands {
#[arg(long, default_value = "5.0", help = "Rebalancing threshold percentage")]
threshold: f64,
},
+ #[command(about = "Launch the Terminal User Interface (TUI) for interactive viewing")]
+ Tui,
}
diff --git a/src/main.rs b/src/main.rs
index 3eb8803..568faa4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,6 +9,7 @@ mod parser;
mod smart_import;
mod performance;
mod rebalance;
+mod tui;
use analytics::{get_spending_trends, get_net_worth_over_time, get_category_insights, generate_spending_recommendations, create_simple_chart};
use clap::Parser;
@@ -992,6 +993,10 @@ async fn main() -> anyhow::Result<()> {
println!("💡 Coming Soon: Live broker API integration!");
}
}
+ Commands::Tui => {
+ println!("🌿 Launching Spendr TUI...");
+ tui::run_tui().await?;
+ }
}
Ok(())
diff --git a/src/tui/app.rs b/src/tui/app.rs
new file mode 100644
index 0000000..2c1ec0d
--- /dev/null
+++ b/src/tui/app.rs
@@ -0,0 +1,195 @@
+use std::collections::HashMap;
+use chrono::{Datelike, Local, NaiveDate};
+use crate::model::Transaction;
+use crate::db::{get_recent_transactions_filtered, get_spending_by_category_filtered,
+ get_income_vs_expenses_filtered, get_budget_status, load_portfolios};
+use crate::investment::Portfolio;
+use super::event::{Event, EventHandler};
+use super::ui;
+use crossterm::event::KeyCode;
+use super::CrosstermTerminal;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum View {
+ Dashboard,
+ Transactions,
+ Budgets,
+ Investments,
+}
+
+#[derive(Debug, Clone)]
+pub struct TimeRange {
+ pub start: Option<String>,
+ pub end: Option<String>,
+ pub label: String,
+}
+
+impl TimeRange {
+ pub fn current_month() -> Self {
+ let now = Local::now();
+ let start = NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
+ .unwrap()
+ .format("%Y-%m-%d")
+ .to_string();
+ Self {
+ start: Some(start),
+ end: None,
+ label: "This Month".to_string(),
+ }
+ }
+
+ pub fn last_30_days() -> Self {
+ let end = Local::now().naive_local().date();
+ let start = end - chrono::Duration::days(30);
+ Self {
+ start: Some(start.format("%Y-%m-%d").to_string()),
+ end: Some(end.format("%Y-%m-%d").to_string()),
+ label: "Last 30 Days".to_string(),
+ }
+ }
+}
+
+pub struct App {
+ pub current_view: View,
+ pub time_range: TimeRange,
+ pub transactions: Vec<Transaction>,
+ pub spending_by_category: HashMap<String, f64>,
+ pub income: f64,
+ pub expenses: f64,
+ pub budgets: Vec<(String, f64, f64, f64)>, // category, budget, spent, remaining
+ pub portfolios: Vec<Portfolio>,
+ pub selected_transaction_index: usize,
+ pub search_query: String,
+ pub show_help: bool,
+ pub net_worth: f64,
+ pub cash_balance: f64,
+ pub investment_balance: f64,
+}
+
+impl App {
+ pub async fn new() -> anyhow::Result<Self> {
+ let time_range = TimeRange::current_month();
+ let mut app = Self {
+ current_view: View::Dashboard,
+ time_range: time_range.clone(),
+ transactions: Vec::new(),
+ spending_by_category: HashMap::new(),
+ income: 0.0,
+ expenses: 0.0,
+ budgets: Vec::new(),
+ portfolios: Vec::new(),
+ selected_transaction_index: 0,
+ search_query: String::new(),
+ show_help: false,
+ net_worth: 0.0,
+ cash_balance: 0.0,
+ investment_balance: 0.0,
+ };
+
+ app.refresh_data().await?;
+ Ok(app)
+ }
+
+ pub async fn refresh_data(&mut self) -> anyhow::Result<()> {
+ // Load ALL transactions (no date filter for transaction view)
+ self.transactions = get_recent_transactions_filtered(
+ 100000, // Load up to 100k transactions
+ None, // No start date filter
+ None // No end date filter
+ )?;
+
+ // Load spending by category
+ self.spending_by_category = get_spending_by_category_filtered(
+ self.time_range.start.as_deref(),
+ self.time_range.end.as_deref()
+ )?;
+
+ // Load income vs expenses
+ let (income, expenses) = get_income_vs_expenses_filtered(
+ self.time_range.start.as_deref(),
+ self.time_range.end.as_deref()
+ )?;
+ self.income = income;
+ self.expenses = expenses;
+
+ // Load budgets
+ self.budgets = get_budget_status()?;
+
+ // Load investment data
+ self.portfolios = load_portfolios()?;
+
+ // Calculate net worth
+ self.cash_balance = income - expenses; // Simplified - would need actual account balances
+ self.investment_balance = self.portfolios.iter()
+ .map(|p| p.total_market_value)
+ .sum();
+ self.net_worth = self.cash_balance + self.investment_balance;
+
+ Ok(())
+ }
+
+ pub async fn run(&mut self, terminal: &mut CrosstermTerminal) -> anyhow::Result<()> {
+ let event_handler = EventHandler::new(250);
+
+ loop {
+ terminal.draw(|f| ui::draw(f, self))?;
+
+ match event_handler.next()? {
+ Event::Key(key_event) => {
+ match key_event.code {
+ KeyCode::Char('q') => break,
+ KeyCode::Tab => self.next_view(),
+ KeyCode::BackTab => self.previous_view(),
+ KeyCode::Char('t') => self.current_view = View::Transactions,
+ KeyCode::Char('d') => self.current_view = View::Dashboard,
+ KeyCode::Char('b') => self.current_view = View::Budgets,
+ KeyCode::Char('i') => self.current_view = View::Investments,
+ KeyCode::Char('r') => self.refresh_data().await?,
+ KeyCode::Char('?') => self.show_help = !self.show_help,
+ KeyCode::Up => self.move_selection_up(),
+ KeyCode::Down => self.move_selection_down(),
+ KeyCode::Char('/') => {
+ // Start search mode
+ self.search_query.clear();
+ }
+ _ => {}
+ }
+ }
+ Event::Tick => {}
+ }
+ }
+
+ Ok(())
+ }
+
+ fn next_view(&mut self) {
+ self.current_view = match self.current_view {
+ View::Dashboard => View::Transactions,
+ View::Transactions => View::Budgets,
+ View::Budgets => View::Investments,
+ View::Investments => View::Dashboard,
+ };
+ }
+
+ fn previous_view(&mut self) {
+ self.current_view = match self.current_view {
+ View::Dashboard => View::Investments,
+ View::Transactions => View::Dashboard,
+ View::Budgets => View::Transactions,
+ View::Investments => View::Budgets,
+ };
+ }
+
+ fn move_selection_up(&mut self) {
+ if self.current_view == View::Transactions && self.selected_transaction_index > 0 {
+ self.selected_transaction_index -= 1;
+ }
+ }
+
+ fn move_selection_down(&mut self) {
+ if self.current_view == View::Transactions
+ && self.selected_transaction_index < self.transactions.len().saturating_sub(1) {
+ self.selected_transaction_index += 1;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/tui/dashboard.rs b/src/tui/dashboard.rs
new file mode 100644
index 0000000..5c266c2
--- /dev/null
+++ b/src/tui/dashboard.rs
@@ -0,0 +1,252 @@
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Gauge, List, ListItem, Paragraph},
+ Frame,
+};
+use crate::tui::app::App;
+
+pub fn draw_dashboard(f: &mut Frame, app: &App, area: Rect) {
+ // Create dashboard layout
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(10), // Net worth and cash flow
+ Constraint::Length(12), // Categories and budgets
+ Constraint::Min(5), // Recent transactions
+ ])
+ .split(area);
+
+ // Top row: Net worth and cash flow
+ let top_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
+ .split(chunks[0]);
+
+ draw_net_worth(f, app, top_chunks[0]);
+ draw_cash_flow(f, app, top_chunks[1]);
+
+ // Middle row: Categories and budgets
+ let middle_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .split(chunks[1]);
+
+ draw_top_categories(f, app, middle_chunks[0]);
+ draw_budget_status(f, app, middle_chunks[1]);
+
+ // Bottom: Recent transactions
+ draw_recent_transactions(f, app, chunks[2]);
+}
+
+fn draw_net_worth(f: &mut Frame, app: &App, area: Rect) {
+ let net_worth_change = app.investment_balance * 0.5; // Placeholder calculation
+ let change_pct = if app.net_worth > 0.0 {
+ (net_worth_change / (app.net_worth - net_worth_change)) * 100.0
+ } else {
+ 0.0
+ };
+
+ let net_worth_text = vec![
+ Line::from(vec![
+ Span::raw("$"),
+ Span::styled(
+ format!("{:.0}", app.net_worth),
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ Line::from(vec![
+ Span::styled(
+ format!("↑ ${:.0} ({:+.1}%)", net_worth_change, change_pct),
+ Style::default().fg(Color::Green),
+ ),
+ ]),
+ Line::from(""),
+ Line::from(format!("💵 Cash: ${:.0}", app.cash_balance)),
+ Line::from(format!("📈 Invest: ${:.0}", app.investment_balance)),
+ ];
+
+ let net_worth = Paragraph::new(net_worth_text)
+ .block(
+ Block::default()
+ .title(" 💰 NET WORTH ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan)),
+ )
+ .alignment(Alignment::Center);
+
+ f.render_widget(net_worth, area);
+}
+
+fn draw_cash_flow(f: &mut Frame, app: &App, area: Rect) {
+ let net = app.income - app.expenses;
+ let max_value = app.income.max(app.expenses);
+
+ // Create inner layout for bars
+ let inner = Block::default()
+ .title(format!(" 📊 CASH FLOW ({}) ", app.time_range.label))
+ .borders(Borders::ALL)
+ .inner(area);
+
+ let bar_chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(2),
+ Constraint::Length(2),
+ Constraint::Length(1),
+ Constraint::Length(2),
+ ])
+ .margin(1)
+ .split(inner);
+
+ // Income bar
+ let income_ratio = if max_value > 0.0 { app.income / max_value } else { 0.0 };
+ let income_gauge = Gauge::default()
+ .block(Block::default())
+ .gauge_style(Style::default().fg(Color::Green))
+ .ratio(income_ratio)
+ .label(format!("Income: ${:.0}", app.income));
+
+ // Expenses bar
+ let expense_ratio = if max_value > 0.0 { app.expenses / max_value } else { 0.0 };
+ let expense_gauge = Gauge::default()
+ .block(Block::default())
+ .gauge_style(Style::default().fg(Color::Red))
+ .ratio(expense_ratio)
+ .label(format!("Expenses: ${:.0}", app.expenses));
+
+ // Net bar
+ let net_ratio = if max_value > 0.0 { net.abs() / max_value } else { 0.0 };
+ let net_gauge = Gauge::default()
+ .block(Block::default())
+ .gauge_style(Style::default().fg(if net >= 0.0 { Color::Cyan } else { Color::Magenta }))
+ .ratio(net_ratio)
+ .label(format!("Net: {:+.0}", net));
+
+ // Render frame and gauges
+ let frame = Block::default()
+ .title(format!(" 📊 CASH FLOW ({}) ", app.time_range.label))
+ .borders(Borders::ALL);
+ f.render_widget(frame, area);
+ f.render_widget(income_gauge, bar_chunks[0]);
+ f.render_widget(expense_gauge, bar_chunks[1]);
+ f.render_widget(net_gauge, bar_chunks[3]);
+}
+
+fn draw_top_categories(f: &mut Frame, app: &App, area: Rect) {
+ let mut categories: Vec<(&String, &f64)> = app.spending_by_category.iter().collect();
+ categories.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
+
+ let max_spending = categories.first().map(|(_, v)| **v).unwrap_or(0.0);
+
+ let items: Vec<ListItem> = categories
+ .iter()
+ .take(5)
+ .map(|(category, amount)| {
+ let bar_width = 10;
+ let ratio = if max_spending > 0.0 { *amount / max_spending } else { 0.0 };
+ let filled = (ratio * bar_width as f64) as usize;
+ let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled));
+
+ ListItem::new(Line::from(vec![
+ Span::raw(format!("{:<12} ${:>6.0} ", category, amount)),
+ Span::styled(bar, Style::default().fg(Color::Yellow)),
+ ]))
+ })
+ .collect();
+
+ let categories_list = List::new(items)
+ .block(
+ Block::default()
+ .title(" 📂 TOP SPENDING CATEGORIES ")
+ .borders(Borders::ALL),
+ );
+
+ f.render_widget(categories_list, area);
+}
+
+fn draw_budget_status(f: &mut Frame, app: &App, area: Rect) {
+ let items: Vec<ListItem> = app.budgets
+ .iter()
+ .take(4)
+ .map(|(category, budget, spent, _)| {
+ let percentage = if *budget > 0.0 { spent / budget } else { 0.0 };
+ let bar_width = 10;
+ let filled = (percentage * bar_width as f64) as usize;
+ let bar = format!("{}{}", "█".repeat(filled.min(bar_width)), "░".repeat(bar_width.saturating_sub(filled)));
+
+ let (status, color) = if percentage > 1.0 {
+ ("⚠️ OVER", Color::Red)
+ } else if percentage > 0.8 {
+ ("⚡ HIGH", Color::Yellow)
+ } else {
+ ("✅ OK", Color::Green)
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::raw(format!("{:<10} ${:>3.0}/${:<3.0} ", category, spent, budget)),
+ Span::styled(bar, Style::default().fg(color)),
+ Span::raw(" "),
+ Span::styled(status, Style::default().fg(color)),
+ ]))
+ })
+ .collect();
+
+ let budget_list = List::new(items)
+ .block(
+ Block::default()
+ .title(" 🎯 BUDGET STATUS ")
+ .borders(Borders::ALL),
+ );
+
+ f.render_widget(budget_list, area);
+}
+
+fn draw_recent_transactions(f: &mut Frame, app: &App, area: Rect) {
+ let transactions: Vec<ListItem> = app.transactions
+ .iter()
+ .take(20) // Show more recent transactions
+ .map(|txn| {
+ let amount_color = if txn.amount > 0.0 { Color::Green } else { Color::Red };
+ let emoji = match txn.category.as_deref() {
+ Some("Coffee") => "☕",
+ Some("Groceries") => "🛒",
+ Some("Dining") => "🍽️",
+ Some("Transport") | Some("Transportation") => "🚗",
+ Some("Shopping") => "📦",
+ Some("Entertainment") => "🎬",
+ Some("Income") => "💰",
+ _ => "💳",
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::raw(format!("{} ", txn.date.format("%b %d"))),
+ Span::raw(format!("{:<25} ",
+ if txn.description.len() > 25 {
+ format!("{}...", &txn.description[..22])
+ } else {
+ txn.description.clone()
+ }
+ )),
+ Span::raw(format!("{:<12} ", txn.category.as_deref().unwrap_or("Uncategorized"))),
+ Span::styled(
+ format!("{:>+10.2} ", txn.amount),
+ Style::default().fg(amount_color),
+ ),
+ Span::raw(emoji),
+ ]))
+ })
+ .collect();
+
+ let transaction_list = List::new(transactions)
+ .block(
+ Block::default()
+ .title(" 💳 RECENT TRANSACTIONS ")
+ .borders(Borders::ALL),
+ );
+
+ f.render_widget(transaction_list, area);
+} \ No newline at end of file
diff --git a/src/tui/event.rs b/src/tui/event.rs
new file mode 100644
index 0000000..05a4c8e
--- /dev/null
+++ b/src/tui/event.rs
@@ -0,0 +1,56 @@
+use std::{
+ sync::mpsc,
+ thread,
+ time::{Duration, Instant},
+};
+use anyhow::Result;
+use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
+
+pub enum Event {
+ Key(KeyEvent),
+ Tick,
+}
+
+pub struct EventHandler {
+ rx: mpsc::Receiver<Event>,
+ _tx: mpsc::Sender<Event>,
+}
+
+impl EventHandler {
+ pub fn new(tick_rate: u64) -> Self {
+ let (tx, rx) = mpsc::channel();
+ let _tx = tx.clone();
+
+ thread::spawn(move || {
+ let mut last_tick = Instant::now();
+ let tick_rate = Duration::from_millis(tick_rate);
+
+ loop {
+ let timeout = tick_rate
+ .checked_sub(last_tick.elapsed())
+ .unwrap_or_else(|| Duration::from_secs(0));
+
+ if event::poll(timeout).expect("Failed to poll events") {
+ if let Ok(CrosstermEvent::Key(key)) = event::read() {
+ if tx.send(Event::Key(key)).is_err() {
+ break;
+ }
+ }
+ }
+
+ if last_tick.elapsed() >= tick_rate {
+ if tx.send(Event::Tick).is_err() {
+ break;
+ }
+ last_tick = Instant::now();
+ }
+ }
+ });
+
+ Self { rx, _tx }
+ }
+
+ pub fn next(&self) -> Result<Event> {
+ Ok(self.rx.recv()?)
+ }
+} \ No newline at end of file
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
new file mode 100644
index 0000000..c1e9700
--- /dev/null
+++ b/src/tui/mod.rs
@@ -0,0 +1,49 @@
+pub mod app;
+pub mod ui;
+pub mod dashboard;
+pub mod transactions;
+pub mod event;
+
+use std::io;
+use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::{backend::CrosstermBackend, Terminal};
+
+pub type CrosstermTerminal = Terminal<CrosstermBackend<io::Stdout>>;
+
+pub fn setup_terminal() -> Result<CrosstermTerminal, io::Error> {
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ Terminal::new(backend)
+}
+
+pub fn restore_terminal(terminal: &mut CrosstermTerminal) -> Result<(), io::Error> {
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+ Ok(())
+}
+
+pub async fn run_tui() -> anyhow::Result<()> {
+ let mut terminal = setup_terminal()?;
+ let mut app = app::App::new().await?;
+
+ let res = app.run(&mut terminal).await;
+
+ restore_terminal(&mut terminal)?;
+
+ if let Err(err) = res {
+ eprintln!("Error: {}", err);
+ }
+
+ Ok(())
+} \ No newline at end of file
diff --git a/src/tui/transactions.rs b/src/tui/transactions.rs
new file mode 100644
index 0000000..67a0711
--- /dev/null
+++ b/src/tui/transactions.rs
@@ -0,0 +1,197 @@
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, List, ListItem, Paragraph},
+ Frame,
+};
+use crate::tui::app::App;
+
+pub fn draw_transactions(f: &mut Frame, app: &App, area: Rect) {
+ // Create main layout
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Search bar
+ Constraint::Min(10), // Transaction list
+ Constraint::Length(8), // Transaction details
+ ])
+ .split(area);
+
+ draw_search_bar(f, app, chunks[0]);
+ draw_transaction_list(f, app, chunks[1]);
+ draw_transaction_details(f, app, chunks[2]);
+}
+
+fn draw_search_bar(f: &mut Frame, app: &App, area: Rect) {
+ let search_text = if app.search_query.is_empty() {
+ "Press '/' to search transactions...".to_string()
+ } else {
+ format!("Search: {}", app.search_query)
+ };
+
+ let search = Paragraph::new(search_text)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(
+ if app.search_query.is_empty() {
+ Color::DarkGray
+ } else {
+ Color::Yellow
+ }
+ )),
+ )
+ .style(Style::default().fg(Color::White));
+
+ f.render_widget(search, area);
+}
+
+fn draw_transaction_list(f: &mut Frame, app: &App, area: Rect) {
+ // Filter transactions based on search query
+ let filtered_transactions: Vec<&_> = if app.search_query.is_empty() {
+ app.transactions.iter().collect()
+ } else {
+ app.transactions
+ .iter()
+ .filter(|t| {
+ t.description.to_lowercase().contains(&app.search_query.to_lowercase())
+ || t.category
+ .as_ref()
+ .map(|c| c.to_lowercase().contains(&app.search_query.to_lowercase()))
+ .unwrap_or(false)
+ })
+ .collect()
+ };
+
+ // Create list items
+ let items: Vec<ListItem> = filtered_transactions
+ .iter()
+ .enumerate()
+ .map(|(idx, txn)| {
+ let is_selected = idx == app.selected_transaction_index;
+ let amount_color = if txn.amount > 0.0 { Color::Green } else { Color::Red };
+ let amount_sign = if txn.amount > 0.0 { "+" } else { "" };
+
+ let category_emoji = match txn.category.as_deref() {
+ Some("Coffee") => "☕",
+ Some("Groceries") => "🛒",
+ Some("Dining") => "🍽️",
+ Some("Transport") | Some("Transportation") => "🚗",
+ Some("Shopping") => "📦",
+ Some("Entertainment") => "🎬",
+ Some("Income") => "💰",
+ Some("Bills") | Some("Utilities") => "📄",
+ Some("Healthcare") => "🏥",
+ Some("Education") => "📚",
+ Some("Travel") => "✈️",
+ _ => "💳",
+ };
+
+ let style = if is_selected {
+ Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ };
+
+ ListItem::new(Line::from(vec![
+ Span::styled(
+ format!("{} ", if is_selected { "▶" } else { " " }),
+ Style::default().fg(Color::Yellow),
+ ),
+ Span::raw(format!("{} ", txn.date.format("%m/%d"))),
+ Span::raw(format!("{} ", category_emoji)),
+ Span::raw(format!("{:<30} ",
+ if txn.description.len() > 30 {
+ format!("{}...", &txn.description[..27])
+ } else {
+ txn.description.clone()
+ }
+ )),
+ Span::raw(format!("{:<15} ", txn.category.as_deref().unwrap_or("Uncategorized"))),
+ Span::styled(
+ format!("{}{:>10.2}", amount_sign, txn.amount),
+ Style::default().fg(amount_color),
+ ),
+ ]))
+ .style(style)
+ })
+ .collect();
+
+ let total_shown = items.len();
+ let total_transactions = app.transactions.len();
+
+ let title = if app.search_query.is_empty() {
+ format!(" 💳 ALL TRANSACTIONS ({} total) ", total_transactions)
+ } else {
+ format!(" 💳 TRANSACTIONS ({}/{} shown) ", total_shown, total_transactions)
+ };
+
+ let transactions = List::new(items)
+ .block(
+ Block::default()
+ .title(title)
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Cyan)),
+ )
+ .highlight_style(Style::default().bg(Color::DarkGray))
+ .highlight_symbol("▶ ");
+
+ f.render_widget(transactions, area);
+}
+
+fn draw_transaction_details(f: &mut Frame, app: &App, area: Rect) {
+ let selected_txn = app.transactions.get(app.selected_transaction_index);
+
+ let details = if let Some(txn) = selected_txn {
+ let amount_color = if txn.amount > 0.0 { Color::Green } else { Color::Red };
+
+ vec![
+ Line::from(vec![
+ Span::raw("Date: "),
+ Span::styled(
+ txn.date.format("%B %d, %Y").to_string(),
+ Style::default().fg(Color::White),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw("Description: "),
+ Span::styled(&txn.description, Style::default().fg(Color::White)),
+ ]),
+ Line::from(vec![
+ Span::raw("Category: "),
+ Span::styled(
+ txn.category.as_deref().unwrap_or("Uncategorized"),
+ Style::default().fg(Color::Yellow),
+ ),
+ ]),
+ Line::from(vec![
+ Span::raw("Amount: "),
+ Span::styled(
+ format!("${:.2}", txn.amount.abs()),
+ Style::default().fg(amount_color).add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(
+ "Press 'c' to categorize, 'e' to edit",
+ Style::default().fg(Color::DarkGray),
+ ),
+ ]),
+ ]
+ } else {
+ vec![Line::from("No transaction selected")]
+ };
+
+ let details_widget = Paragraph::new(details)
+ .block(
+ Block::default()
+ .title(" 📋 TRANSACTION DETAILS ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Gray)),
+ )
+ .alignment(Alignment::Left);
+
+ f.render_widget(details_widget, area);
+} \ No newline at end of file
diff --git a/src/tui/ui.rs b/src/tui/ui.rs
new file mode 100644
index 0000000..abf9891
--- /dev/null
+++ b/src/tui/ui.rs
@@ -0,0 +1,244 @@
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph, Tabs},
+ Frame,
+};
+use crate::tui::app::{App, View};
+use crate::tui::{dashboard, transactions};
+
+pub fn draw(f: &mut Frame, app: &App) {
+ let size = f.size();
+
+ // Create main layout
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Header
+ Constraint::Min(0), // Content
+ Constraint::Length(3), // Footer
+ ])
+ .split(size);
+
+ draw_header(f, app, chunks[0]);
+
+ // Draw content based on current view
+ match app.current_view {
+ View::Dashboard => dashboard::draw_dashboard(f, app, chunks[1]),
+ View::Transactions => transactions::draw_transactions(f, app, chunks[1]),
+ View::Budgets => draw_budgets(f, app, chunks[1]),
+ View::Investments => draw_investments(f, app, chunks[1]),
+ }
+
+ draw_footer(f, app, chunks[2]);
+
+ // Draw help overlay if needed
+ if app.show_help {
+ draw_help(f, size);
+ }
+}
+
+fn draw_header(f: &mut Frame, app: &App, area: Rect) {
+ let titles = vec!["Dashboard", "Transactions", "Budgets", "Investments"];
+ let selected = match app.current_view {
+ View::Dashboard => 0,
+ View::Transactions => 1,
+ View::Budgets => 2,
+ View::Investments => 3,
+ };
+
+ let header = Tabs::new(titles)
+ .block(Block::default()
+ .title(" 🌿 Spendr - Personal Finance ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Green)))
+ .select(selected)
+ .style(Style::default().fg(Color::Gray))
+ .highlight_style(Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD));
+
+ f.render_widget(header, area);
+}
+
+fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
+ let shortcuts = match app.current_view {
+ View::Dashboard => vec![
+ ("Tab", "Next View"),
+ ("r", "Refresh"),
+ ("?", "Help"),
+ ("q", "Quit"),
+ ],
+ View::Transactions => vec![
+ ("↑↓", "Navigate"),
+ ("/", "Search"),
+ ("c", "Categorize"),
+ ("Tab", "Next View"),
+ ("q", "Quit"),
+ ],
+ View::Budgets => vec![
+ ("↑↓", "Navigate"),
+ ("e", "Edit Budget"),
+ ("Tab", "Next View"),
+ ("q", "Quit"),
+ ],
+ View::Investments => vec![
+ ("p", "Performance"),
+ ("r", "Rebalance"),
+ ("Tab", "Next View"),
+ ("q", "Quit"),
+ ],
+ };
+
+ let shortcut_text: Vec<Span> = shortcuts
+ .iter()
+ .flat_map(|(key, desc)| {
+ vec![
+ Span::styled(
+ format!(" [{}] ", key),
+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(format!("{} ", desc)),
+ ]
+ })
+ .collect();
+
+ let footer = Paragraph::new(Line::from(shortcut_text))
+ .block(Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray)))
+ .alignment(Alignment::Center);
+
+ f.render_widget(footer, area);
+}
+
+fn draw_budgets(f: &mut Frame, app: &App, area: Rect) {
+ let budgets_text: Vec<Line> = app.budgets.iter()
+ .map(|(category, budget, spent, _remaining)| {
+ let percentage = if *budget > 0.0 { spent / budget } else { 0.0 };
+ let bar_width = 20;
+ let filled = (percentage * bar_width as f64) as usize;
+ let bar = format!("{}{}", "█".repeat(filled.min(bar_width)), "░".repeat(bar_width.saturating_sub(filled)));
+
+ let status_color = if percentage > 1.0 {
+ Color::Red
+ } else if percentage > 0.8 {
+ Color::Yellow
+ } else {
+ Color::Green
+ };
+
+ Line::from(vec![
+ Span::raw(format!("{:<15} ", category)),
+ Span::styled(bar, Style::default().fg(status_color)),
+ Span::raw(format!(" ${:.0}/${:.0}", spent, budget)),
+ ])
+ })
+ .collect();
+
+ let budgets = Paragraph::new(budgets_text)
+ .block(Block::default()
+ .title(" 🎯 Budget Status ")
+ .borders(Borders::ALL));
+
+ f.render_widget(budgets, area);
+}
+
+fn draw_investments(f: &mut Frame, app: &App, area: Rect) {
+ let total_value: f64 = app.portfolios.iter().map(|p| p.total_market_value).sum();
+ let total_book: f64 = app.portfolios.iter().map(|p| p.total_book_value).sum();
+ let total_gain = total_value - total_book;
+ let gain_pct = if total_book > 0.0 { (total_gain / total_book) * 100.0 } else { 0.0 };
+
+ let investment_text = vec![
+ Line::from(format!("Total Market Value: ${:.2}", total_value)),
+ Line::from(format!("Total Book Value: ${:.2}", total_book)),
+ Line::from(vec![
+ Span::raw("Unrealized Gain: "),
+ Span::styled(
+ format!("${:.2} ({:+.1}%)", total_gain, gain_pct),
+ Style::default().fg(if total_gain >= 0.0 { Color::Green } else { Color::Red }),
+ ),
+ ]),
+ Line::from(""),
+ Line::from("Portfolios:"),
+ ];
+
+ let mut portfolio_lines = investment_text;
+ for portfolio in &app.portfolios {
+ portfolio_lines.push(Line::from(format!(
+ " {} ({:?}): ${:.2}",
+ portfolio.account_id,
+ portfolio.account_type,
+ portfolio.total_market_value
+ )));
+ }
+
+ let investments = Paragraph::new(portfolio_lines)
+ .block(Block::default()
+ .title(" 📈 Investment Overview ")
+ .borders(Borders::ALL));
+
+ f.render_widget(investments, area);
+}
+
+fn draw_help(f: &mut Frame, area: Rect) {
+ let help_text = vec![
+ Line::from(""),
+ Line::from(vec![Span::styled(" Spendr TUI Help ", Style::default().add_modifier(Modifier::BOLD))]),
+ Line::from(""),
+ Line::from(" Navigation:"),
+ Line::from(" Tab/Shift+Tab - Switch between views"),
+ Line::from(" ↑/↓ - Navigate lists"),
+ Line::from(" Enter - Select/Edit"),
+ Line::from(""),
+ Line::from(" View Shortcuts:"),
+ Line::from(" d - Dashboard"),
+ Line::from(" t - Transactions"),
+ Line::from(" b - Budgets"),
+ Line::from(" i - Investments"),
+ Line::from(""),
+ Line::from(" General:"),
+ Line::from(" / - Search"),
+ Line::from(" r - Refresh data"),
+ Line::from(" ? - Toggle this help"),
+ Line::from(" q - Quit"),
+ Line::from(""),
+ Line::from(" Press any key to close this help"),
+ ];
+
+ let help_width = 50;
+ let help_height = 22;
+ let help_area = centered_rect(help_width, help_height, area);
+
+ let help = Paragraph::new(help_text)
+ .block(Block::default()
+ .title(" Help ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Yellow)))
+ .style(Style::default().bg(Color::Black))
+ .alignment(Alignment::Left);
+
+ f.render_widget(help, help_area);
+}
+
+fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
+ let popup_layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length((area.height.saturating_sub(height)) / 2),
+ Constraint::Length(height),
+ Constraint::Length((area.height.saturating_sub(height)) / 2),
+ ])
+ .split(area);
+
+ Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Length((area.width.saturating_sub(width)) / 2),
+ Constraint::Length(width),
+ Constraint::Length((area.width.saturating_sub(width)) / 2),
+ ])
+ .split(popup_layout[1])[1]
+} \ No newline at end of file