diff options
| author | mo khan <mo@mokhan.ca> | 2025-06-26 16:40:45 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-06-26 16:40:45 -0600 |
| commit | 1efabb36bd626649e6b1569d64e77baf06a1677e (patch) | |
| tree | fa5a6fd7cf5c422f30e14ecd7d80c0149199a441 | |
| parent | 341e1ab4db97447ad9263941e81c66ce02434e3a (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.lock | 535 | ||||
| -rw-r--r-- | Cargo.toml | 6 | ||||
| -rw-r--r-- | README.md | 70 | ||||
| -rw-r--r-- | src/cli.rs | 2 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/tui/app.rs | 195 | ||||
| -rw-r--r-- | src/tui/dashboard.rs | 252 | ||||
| -rw-r--r-- | src/tui/event.rs | 56 | ||||
| -rw-r--r-- | src/tui/mod.rs | 49 | ||||
| -rw-r--r-- | src/tui/transactions.rs | 197 | ||||
| -rw-r--r-- | src/tui/ui.rs | 244 |
11 files changed, 1589 insertions, 22 deletions
@@ -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" @@ -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" @@ -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 @@ -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 |
