From 0d0aa067afe5d6dc81ac7738d35c42d4416d7ca1 Mon Sep 17 00:00:00 2001 From: ponbac Date: Thu, 26 Feb 2026 17:58:35 +0100 Subject: [PATCH] tui code clean up --- .github/workflows/backend.yml | 2 + Cargo.lock | 189 +- Cargo.toml | 18 + az-devops/Cargo.toml | 14 +- justfile | 11 +- milltime/Cargo.toml | 18 +- toki-api/Cargo.toml | 20 +- .../src/adapters/outbound/azure_devops/mod.rs | 2 +- toki-tui/Cargo.toml | 22 +- toki-tui/README.md | 20 +- toki-tui/src/api/client.rs | 499 ++++++ toki-tui/src/api/dev_backend.rs | 212 +++ toki-tui/src/api/dto.rs | 77 + toki-tui/src/api/mod.rs | 5 + toki-tui/src/api_client.rs | 712 -------- toki-tui/src/app/history.rs | 6 +- toki-tui/src/app/mod.rs | 61 +- toki-tui/src/auth/mod.rs | 12 - toki-tui/src/bootstrap.rs | 51 + toki-tui/src/cli.rs | 23 + toki-tui/src/config.rs | 174 +- toki-tui/src/login.rs | 17 +- toki-tui/src/main.rs | 1533 +---------------- toki-tui/src/runtime/action_queue.rs | 35 + toki-tui/src/runtime/actions.rs | 741 ++++++++ toki-tui/src/runtime/event_loop.rs | 64 + toki-tui/src/runtime/mod.rs | 7 + toki-tui/src/runtime/views.rs | 53 + toki-tui/src/runtime/views/confirm_delete.rs | 22 + .../src/runtime/views/edit_description.rs | 206 +++ toki-tui/src/runtime/views/history.rs | 124 ++ toki-tui/src/runtime/views/save_action.rs | 31 + toki-tui/src/runtime/views/selection.rs | 182 ++ toki-tui/src/runtime/views/statistics.rs | 12 + toki-tui/src/runtime/views/timer.rs | 241 +++ toki-tui/src/session_store.rs | 109 ++ toki-tui/src/terminal.rs | 35 + toki-tui/src/time_utils.rs | 9 + toki-tui/src/ui/statistics_view.rs | 3 +- toki-tui/src/ui/timer_view.rs | 3 +- toki-tui/src/ui/utils.rs | 7 +- 41 files changed, 3090 insertions(+), 2492 deletions(-) create mode 100644 toki-tui/src/api/client.rs create mode 100644 toki-tui/src/api/dev_backend.rs create mode 100644 toki-tui/src/api/dto.rs create mode 100644 toki-tui/src/api/mod.rs delete mode 100644 toki-tui/src/api_client.rs delete mode 100644 toki-tui/src/auth/mod.rs create mode 100644 toki-tui/src/bootstrap.rs create mode 100644 toki-tui/src/cli.rs create mode 100644 toki-tui/src/runtime/action_queue.rs create mode 100644 toki-tui/src/runtime/actions.rs create mode 100644 toki-tui/src/runtime/event_loop.rs create mode 100644 toki-tui/src/runtime/mod.rs create mode 100644 toki-tui/src/runtime/views.rs create mode 100644 toki-tui/src/runtime/views/confirm_delete.rs create mode 100644 toki-tui/src/runtime/views/edit_description.rs create mode 100644 toki-tui/src/runtime/views/history.rs create mode 100644 toki-tui/src/runtime/views/save_action.rs create mode 100644 toki-tui/src/runtime/views/selection.rs create mode 100644 toki-tui/src/runtime/views/statistics.rs create mode 100644 toki-tui/src/runtime/views/timer.rs create mode 100644 toki-tui/src/session_store.rs create mode 100644 toki-tui/src/terminal.rs create mode 100644 toki-tui/src/time_utils.rs diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 2ef46d68..85001c7c 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -7,6 +7,7 @@ on: - "toki-api/**" - "az-devops/**" - "milltime/**" + - "toki-tui/**" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/backend.yml" @@ -15,6 +16,7 @@ on: - "toki-api/**" - "az-devops/**" - "milltime/**" + - "toki-tui/**" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/backend.yml" diff --git a/Cargo.lock b/Cargo.lock index bddc8921..406b79c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -341,7 +391,7 @@ dependencies = [ "axum", "axum-core", "bytes", - "cookie 0.18.1", + "cookie", "fastrand 2.3.0", "futures-util", "headers", @@ -651,6 +701,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + [[package]] name = "coarsetime" version = "0.1.37" @@ -668,6 +758,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "compact_str" version = "0.9.0" @@ -777,17 +873,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - [[package]] name = "cookie" version = "0.18.1" @@ -799,32 +884,15 @@ dependencies = [ "version_check", ] -[[package]] -name = "cookie_store" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" -dependencies = [ - "cookie 0.17.0", - "idna 0.3.0", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "cookie_store" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" dependencies = [ - "cookie 0.18.1", + "cookie", "document-features", - "idna 1.1.0", + "idna", "log", "publicsuffix", "serde", @@ -2268,19 +2336,6 @@ dependencies = [ "webpki-roots 1.0.6", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -2439,16 +2494,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "1.1.0" @@ -2600,6 +2645,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "isahc" version = "1.7.2" @@ -3022,7 +3073,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dotenvy", - "reqwest 0.11.27", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 1.0.69", @@ -3311,6 +3362,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3863,7 +3920,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" dependencies = [ - "idna 1.1.0", + "idna", "psl-types", ] @@ -4261,8 +4318,6 @@ checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ "base64 0.21.7", "bytes", - "cookie 0.17.0", - "cookie_store 0.20.0", "encoding_rs", "futures-core", "futures-util", @@ -4271,13 +4326,10 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "hyper-rustls 0.24.2", - "hyper-tls 0.5.0", "ipnet", "js-sys", "log", "mime", - "mime_guess", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -4289,7 +4341,6 @@ dependencies = [ "sync_wrapper 0.1.2", "system-configuration 0.5.1", "tokio", - "tokio-native-tls", "tokio-rustls 0.24.1", "tower-service", "url", @@ -4308,8 +4359,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "cookie 0.18.1", - "cookie_store 0.22.1", + "cookie", + "cookie_store", "encoding_rs", "futures-core", "futures-util", @@ -4319,11 +4370,12 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-rustls 0.27.7", - "hyper-tls 0.6.0", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -5663,6 +5715,8 @@ name = "toki-tui" version = "0.1.0" dependencies = [ "anyhow", + "clap", + "config", "crossterm", "dirs", "fuzzy-matcher", @@ -5671,7 +5725,6 @@ dependencies = [ "rpassword", "serde", "serde_json", - "thiserror 1.0.69", "throbber-widgets-tui", "time", "tokio", @@ -5839,7 +5892,7 @@ checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" dependencies = [ "async-trait", "axum-core", - "cookie 0.18.1", + "cookie", "futures-util", "http 1.4.0", "parking_lot", @@ -6267,7 +6320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 1.1.0", + "idna", "percent-encoding", "serde", "serde_derive", diff --git a/Cargo.toml b/Cargo.toml index 5f8bb524..88343a2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,21 @@ [workspace] resolver = "2" members = ["toki-api", "az-devops", "milltime", "toki-tui"] + +[workspace.dependencies] +anyhow = "1.0" +base64 = "0.22.1" +chrono = { version = "0.4.38", features = ["serde"] } +config = "0.14.0" +dotenvy = "0.15.7" +reqwest = { version = "0.12.28", default-features = true } +serde = { version = "1.0.193", features = ["derive"] } +serde_json = "1.0.108" +time = { version = "0.3.47", features = ["serde", "parsing"] } +tokio = { version = "1.49.0", features = ["full"] } +tracing = { version = "0.1.40", features = ["attributes"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/az-devops/Cargo.toml b/az-devops/Cargo.toml index ea168a7c..040b1d75 100644 --- a/az-devops/Cargo.toml +++ b/az-devops/Cargo.toml @@ -15,14 +15,14 @@ azure_devops_rust_api = { version = "0.28.0", features = [ "work", "graph", ] } -serde = { version = "1.0.193", features = ["derive"] } -serde_json = "1.0.108" -reqwest = { version = "0.12.15", default-features = false, features = ["json", "rustls-tls"] } +serde.workspace = true +serde_json.workspace = true +reqwest = { workspace = true, features = ["json", "rustls-tls"] } thiserror = "2.0.12" -time = { version = "0.3.47", features = ["serde", "parsing"] } -tokio = { version = "1.35.1", features = ["full"] } -tracing = { version = "0.1.40", features = ["attributes"] } +time.workspace = true +tokio.workspace = true +tracing.workspace = true typespec = "0.4.0" [dev-dependencies] -dotenvy = "0.15.7" +dotenvy.workspace = true diff --git a/justfile b/justfile index 1896202a..a310a4f6 100644 --- a/justfile +++ b/justfile @@ -78,21 +78,20 @@ fmt: # Run the TUI (requires login — run `just tui-login` first if needed) tui: - cd toki-tui && cargo run + cd toki-tui && cargo run -- run # Run the TUI in dev mode (no login required, mock data) tui-dev: - cd toki-tui && cargo run -- --dev + cd toki-tui && cargo run -- dev # Authenticate the TUI via browser OAuth tui-login: - cd toki-tui && cargo run -- --login + cd toki-tui && cargo run -- login # Log out (clear saved session) tui-logout: - cd toki-tui && cargo run -- --logout + cd toki-tui && cargo run -- logout # Print TUI config path and create default config if missing tui-config: - cd toki-tui && cargo run -- --config-path - + cd toki-tui && cargo run -- config-path diff --git a/milltime/Cargo.toml b/milltime/Cargo.toml index c740889f..dd48c2b3 100644 --- a/milltime/Cargo.toml +++ b/milltime/Cargo.toml @@ -6,17 +6,17 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -serde = { version = "1.0.193", features = ["derive"] } -serde_json = "1.0.108" -tokio = { version = "1.35.1", features = ["full"] } -dotenvy = "0.15.7" -time = { version = "0.3.47", features = ["serde", "parsing"] } -tracing = { version = "0.1.40", features = ["attributes"] } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +dotenvy.workspace = true +time.workspace = true +tracing.workspace = true thiserror = "1.0.56" -reqwest = { version = "0.11.23", features = ["json", "cookies", "multipart"] } +reqwest = { workspace = true, features = ["json", "cookies", "multipart"] } axum-extra = { version = "0.9.1", features = ["typed-header", "cookie"] } -chrono = { version = "0.4.38", features = ["serde"] } -base64 = "0.22.1" +chrono.workspace = true +base64.workspace = true [lints.clippy] too_many_arguments = "allow" diff --git a/toki-api/Cargo.toml b/toki-api/Cargo.toml index 2fb238f9..a4a9c4c4 100644 --- a/toki-api/Cargo.toml +++ b/toki-api/Cargo.toml @@ -8,18 +8,18 @@ edition = "2021" [dependencies] az-devops = { path = "../az-devops" } milltime = { path = "../milltime" } -dotenvy = "0.15.7" +dotenvy.workspace = true itertools = "0.13.0" -serde = { version = "1.0.193", features = ["derive"] } -serde_json = "1.0.108" +serde.workspace = true +serde_json.workspace = true serde_with = "3.4.0" -tokio = { version = "1.35.1", features = ["full"] } -time = { version = "0.3.47", features = ["serde", "parsing"] } -chrono = { version = "0.4.38", features = ["serde"] } +tokio.workspace = true +time.workspace = true +chrono.workspace = true axum = { version = "0.7.3", features = ["ws", "macros", "multipart"] } axum-extra = { version = "0.9.1", features = ["typed-header", "cookie"] } -config = "0.14.0" -tracing = { version = "0.1.40", features = ["attributes"] } +config.workspace = true +tracing.workspace = true tracing-subscriber = { version = "0.3.18", features = [ "env-filter", "time", @@ -45,13 +45,13 @@ sqlx = { version = "0.8.0", features = [ axum-login = "0.16.0" thiserror = "1.0" oauth2 = "4.4.2" -reqwest = { version = "0.12.5", features = ["json"] } +reqwest = { workspace = true, features = ["json"] } async-trait = "0.1.77" crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] } web-push = "0.10.1" url = "2.5.0" aes-gcm = "0.10.3" -base64 = "0.22.1" +base64.workspace = true strum_macros = "0.26.4" tower-sessions-moka-store = "0.14" tower-sessions-sqlx-store = { version = "0.14.2", features = ["postgres"] } diff --git a/toki-api/src/adapters/outbound/azure_devops/mod.rs b/toki-api/src/adapters/outbound/azure_devops/mod.rs index 2c83b01a..9f9352ff 100644 --- a/toki-api/src/adapters/outbound/azure_devops/mod.rs +++ b/toki-api/src/adapters/outbound/azure_devops/mod.rs @@ -818,7 +818,7 @@ impl AzureDevOpsWorkItemAdapter { .map_err(to_provider_error)?; let selected_team = select_default_project_team(project, self.client.repo_name(), &project_teams) - .ok_or_else(|| { + .ok_or_else(|| { WorkItemError::ProviderError(format!( "No teams were found for project '{project}'" )) diff --git a/toki-tui/Cargo.toml b/toki-tui/Cargo.toml index d1e1153e..86942de9 100644 --- a/toki-tui/Cargo.toml +++ b/toki-tui/Cargo.toml @@ -11,13 +11,13 @@ tui-piechart = "0.3.0" throbber-widgets-tui = "0.11" # Async runtime -tokio = { version = "1", features = ["full"] } +tokio.workspace = true # HTTP client -reqwest = { version = "0.12", features = ["json", "cookies"] } +reqwest = { workspace = true, features = ["json", "cookies"] } # Time handling (same as toki-api) -time = { version = "0.3", features = [ +time = { workspace = true, features = [ "serde", "formatting", "parsing", @@ -26,16 +26,17 @@ time = { version = "0.3", features = [ ] } # Error handling -anyhow = "1.0" -thiserror = "1.0" +anyhow.workspace = true # Configuration -toml = "0.8" +config.workspace = true +clap = { version = "4.5.40", features = ["derive"] } dirs = "5" +toml = "0.8" # Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde.workspace = true +serde_json.workspace = true # Fuzzy matching fuzzy-matcher = "0.3" @@ -45,8 +46,3 @@ urlencoding = "2" # Password input (no echo) rpassword = "7" - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 diff --git a/toki-tui/README.md b/toki-tui/README.md index 6f6db16d..8a3ab317 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -25,10 +25,28 @@ just tui-config Config file: `~/.config/toki-tui/config.toml` -Run `just tui-config` (or `cargo run -- --config-path`) to print the path and create the file with defaults if it does not exist. +Run `just tui-config` (or `cargo run -- config-path`) to print the path and create the file with defaults if it does not exist. All keys are optional. If the file is missing, built-in defaults are used. +### Environment variables + +You can override config values with environment variables. + +- Prefix: `TOKI_TUI_` +- Key format: uppercase snake case +- Nested keys (if added later): use `__` as separator + +Current variables: + +```bash +TOKI_TUI_API_URL="http://localhost:8080" +TOKI_TUI_GIT_DEFAULT_PREFIX="Development" +TOKI_TUI_TASK_FILTER="+work project:Toki" +``` + +Environment variables override values from `config.toml`. + ```toml # URL of the toki-api server. Defaults to the production instance. api_url = "https://toki-api.spinit.se" diff --git a/toki-tui/src/api/client.rs b/toki-tui/src/api/client.rs new file mode 100644 index 00000000..625b6783 --- /dev/null +++ b/toki-tui/src/api/client.rs @@ -0,0 +1,499 @@ +use anyhow::{Context, Result}; +use reqwest::{ + cookie::{CookieStore, Jar}, + Client, RequestBuilder, Response, StatusCode, Url, +}; +use serde::de::DeserializeOwned; +use std::sync::Arc; + +use crate::api::dev_backend::DevBackend; +use crate::api::dto::{ + ActivityDto, AuthenticateRequest, DeleteEntryRequest, EditEntryRequest, ProjectDto, + SaveTimerRequest, StartTimerRequest, UpdateActiveTimerRequest, +}; +use crate::session_store; +use crate::types::{ + ActiveTimerState, Activity, GetTimerResponse, Me, Project, TimeEntry, TimeInfo, +}; + +const SESSION_COOKIE: &str = "id"; +const UNAUTH_INVALID_SESSION: &str = + "Session expired or invalid. Run `toki-tui login` to authenticate."; +const UNAUTH_RELOGIN: &str = "Session expired. Run `toki-tui login` to re-authenticate."; +const UNAUTH_INVALID_MILLTIME_CREDENTIALS: &str = "Invalid Milltime credentials."; + +#[derive(Debug, Clone)] +pub struct ApiClient { + client: Client, + base_url: Url, + jar: Arc, + mt_cookies: Vec<(String, String)>, + dev_backend: Option, +} + +impl ApiClient { + pub fn new( + base_url: &str, + session_id: &str, + mt_cookies: Vec<(String, String)>, + ) -> Result { + let base_url = Url::parse(base_url.trim_end_matches('/')) + .with_context(|| format!("Invalid API URL: {}", base_url))?; + let jar = Arc::new(Jar::default()); + + jar.add_cookie_str( + &format!("{}={}; Path=/", SESSION_COOKIE, session_id), + &base_url, + ); + for (name, value) in &mt_cookies { + jar.add_cookie_str(&format!("{}={}; Path=/", name, value), &base_url); + } + + let client = Client::builder() + .cookie_provider(jar.clone()) + .build() + .context("Failed to build HTTP client")?; + + Ok(Self { + client, + base_url, + jar, + mt_cookies, + dev_backend: None, + }) + } + + pub fn dev() -> Result { + let base_url = Url::parse("http://localhost")?; + let jar = Arc::new(Jar::default()); + let client = Client::builder() + .cookie_provider(jar.clone()) + .build() + .context("Failed to build HTTP client")?; + + Ok(Self { + client, + base_url, + jar, + mt_cookies: vec![], + dev_backend: Some(DevBackend::new()), + }) + } + + fn endpoint(&self, path: &str) -> Result { + self.base_url + .join(path) + .with_context(|| format!("Failed to build URL for path {}", path)) + } + + fn sync_mt_cookies_from_jar(&mut self) -> Result<()> { + let Some(header_value) = self.jar.cookies(&self.base_url) else { + return Ok(()); + }; + + let header = header_value + .to_str() + .context("Invalid cookie header from cookie jar")?; + + let mut cookies = header + .split(';') + .filter_map(|segment| { + let pair = segment.trim(); + if pair.is_empty() { + return None; + } + + let mut parts = pair.splitn(2, '='); + let name = parts.next()?.trim(); + let value = parts.next()?.trim(); + if name.is_empty() || name == SESSION_COOKIE { + return None; + } + + Some((name.to_string(), value.to_string())) + }) + .collect::>(); + + cookies.sort_by(|a, b| a.0.cmp(&b.0)); + + if cookies != self.mt_cookies { + self.mt_cookies = cookies; + session_store::save_mt_cookies(&self.mt_cookies)?; + } + + Ok(()) + } + + async fn send( + &mut self, + request: RequestBuilder, + call_name: &str, + unauthorized_message: &str, + ) -> Result { + let response = request + .send() + .await + .with_context(|| format!("Failed to call {}", call_name))?; + + if matches!( + response.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + anyhow::bail!("{unauthorized_message}"); + } + + response + .error_for_status_ref() + .with_context(|| format!("{} returned error", call_name))?; + + self.sync_mt_cookies_from_jar()?; + Ok(response) + } + + async fn get_json( + &mut self, + request: RequestBuilder, + call_name: &str, + unauthorized_message: &str, + ) -> Result { + let response = self.send(request, call_name, unauthorized_message).await?; + response + .json::() + .await + .with_context(|| format!("Failed to parse {} response", call_name)) + } + + async fn send_without_body( + &mut self, + request: RequestBuilder, + call_name: &str, + unauthorized_message: &str, + ) -> Result<()> { + let response = self.send(request, call_name, unauthorized_message).await?; + let _ = response.bytes().await; + Ok(()) + } + + pub async fn me(&mut self) -> Result { + if self.dev_backend.is_some() { + return Ok(Me { + id: 1, + email: "dev@localhost".to_string(), + full_name: "Dev User".to_string(), + }); + } + + self.get_json( + self.client.get(self.endpoint("/me")?), + "GET /me", + UNAUTH_INVALID_SESSION, + ) + .await + } + + pub async fn get_active_timer(&mut self) -> Result> { + if self.dev_backend.is_some() { + return Ok(None); + } + + let response: GetTimerResponse = self + .get_json( + self.client.get(self.endpoint("/time-tracking/timer")?), + "GET /time-tracking/timer", + UNAUTH_RELOGIN, + ) + .await?; + + Ok(response.timer) + } + + pub async fn get_time_info(&mut self, from: time::Date, to: time::Date) -> Result { + if let Some(dev) = &self.dev_backend { + return Ok(dev.time_info()); + } + + let format = time::format_description::parse("[year]-[month]-[day]")?; + let from_str = from.format(&format).context("Failed to format from date")?; + let to_str = to.format(&format).context("Failed to format to date")?; + + self.get_json( + self.client + .get(self.endpoint("/time-tracking/time-info")?) + .query(&[("from", &from_str), ("to", &to_str)]), + "GET /time-tracking/time-info", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn get_time_entries( + &mut self, + from: time::Date, + to: time::Date, + ) -> Result> { + if let Some(dev) = &self.dev_backend { + let from_str = format!( + "{:04}-{:02}-{:02}", + from.year(), + from.month() as u8, + from.day() + ); + let to_str = format!("{:04}-{:02}-{:02}", to.year(), to.month() as u8, to.day()); + return Ok(dev + .time_entries() + .into_iter() + .filter(|entry| entry.date >= from_str && entry.date <= to_str) + .collect()); + } + + let format = time::format_description::parse("[year]-[month]-[day]")?; + let from_str = from.format(&format).context("Failed to format from date")?; + let to_str = to.format(&format).context("Failed to format to date")?; + + self.get_json( + self.client + .get(self.endpoint("/time-tracking/time-entries")?) + .query(&[("from", &from_str), ("to", &to_str)]), + "GET /time-tracking/time-entries", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn start_timer( + &mut self, + project_id: Option, + project_name: Option, + activity_id: Option, + activity_name: Option, + note: Option, + ) -> Result<()> { + if self.dev_backend.is_some() { + return Ok(()); + } + + self.send_without_body( + self.client + .post(self.endpoint("/time-tracking/timer")?) + .json(&StartTimerRequest { + project_id, + project_name, + activity_id, + activity_name, + user_note: note, + }), + "POST /time-tracking/timer", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn save_timer(&mut self, note: Option) -> Result<()> { + if self.dev_backend.is_some() { + return Ok(()); + } + + self.send_without_body( + self.client + .put(self.endpoint("/time-tracking/timer")?) + .json(&SaveTimerRequest { user_note: note }), + "PUT /time-tracking/timer", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn stop_timer(&mut self) -> Result<()> { + if self.dev_backend.is_some() { + return Ok(()); + } + + self.send_without_body( + self.client.delete(self.endpoint("/time-tracking/timer")?), + "DELETE /time-tracking/timer", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn update_active_timer( + &mut self, + project_id: Option, + project_name: Option, + activity_id: Option, + activity_name: Option, + note: Option, + start_time: Option, + ) -> Result<()> { + if self.dev_backend.is_some() { + return Ok(()); + } + + self.send_without_body( + self.client + .put(self.endpoint("/time-tracking/update-timer")?) + .json(&UpdateActiveTimerRequest { + project_id, + project_name, + activity_id, + activity_name, + user_note: note, + start_time, + }), + "PUT /time-tracking/update-timer", + UNAUTH_RELOGIN, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn edit_time_entry( + &mut self, + project_registration_id: &str, + project_id: &str, + project_name: &str, + activity_id: &str, + activity_name: &str, + start_time: time::OffsetDateTime, + end_time: time::OffsetDateTime, + reg_day: &str, + week_number: i32, + user_note: &str, + original_project_id: Option<&str>, + original_activity_id: Option<&str>, + ) -> Result<()> { + if let Some(dev) = &self.dev_backend { + dev.edit_entry( + project_registration_id, + project_id, + project_name, + activity_id, + activity_name, + start_time, + end_time, + user_note, + ); + return Ok(()); + } + + let format = time::format_description::well_known::Rfc3339; + let body = EditEntryRequest { + project_registration_id, + project_id, + project_name, + activity_id, + activity_name, + start_time: start_time + .format(&format) + .context("Failed to format start_time")?, + end_time: end_time + .format(&format) + .context("Failed to format end_time")?, + reg_day, + week_number, + user_note, + original_reg_day: None, + original_project_id, + original_activity_id, + }; + + self.send_without_body( + self.client + .put(self.endpoint("/time-tracking/time-entries")?) + .json(&body), + "PUT /time-tracking/time-entries", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn delete_time_entry(&mut self, registration_id: &str) -> Result<()> { + if let Some(dev) = &self.dev_backend { + dev.delete_entry(registration_id); + return Ok(()); + } + + self.send_without_body( + self.client + .delete(self.endpoint("/time-tracking/time-entries")?) + .json(&DeleteEntryRequest { + project_registration_id: registration_id, + }), + "DELETE /time-tracking/time-entries", + UNAUTH_RELOGIN, + ) + .await + } + + pub async fn authenticate(&mut self, username: &str, password: &str) -> Result<()> { + if self.dev_backend.is_some() { + return Ok(()); + } + + self.send_without_body( + self.client + .post(self.endpoint("/time-tracking/authenticate")?) + .json(&AuthenticateRequest { username, password }), + "POST /time-tracking/authenticate", + UNAUTH_INVALID_MILLTIME_CREDENTIALS, + ) + .await + } + + pub fn mt_cookies(&self) -> &[(String, String)] { + &self.mt_cookies + } + + pub async fn get_projects(&mut self) -> Result> { + if let Some(dev) = &self.dev_backend { + return Ok(dev.projects()); + } + + let dtos: Vec = self + .get_json( + self.client.get(self.endpoint("/time-tracking/projects")?), + "GET /time-tracking/projects", + UNAUTH_RELOGIN, + ) + .await?; + + let mut projects: Vec = dtos + .into_iter() + .map(|dto| Project { + id: dto.project_id, + name: dto.project_name, + }) + .collect(); + projects.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(projects) + } + + pub async fn get_activities(&mut self, project_id: &str) -> Result> { + if let Some(dev) = &self.dev_backend { + return Ok(dev.activities(project_id)); + } + + let dtos: Vec = self + .get_json( + self.client.get(self.endpoint(&format!( + "/time-tracking/projects/{}/activities", + project_id + ))?), + "GET /time-tracking/projects/:id/activities", + UNAUTH_RELOGIN, + ) + .await?; + + let mut activities: Vec = dtos + .into_iter() + .map(|dto| Activity { + id: dto.activity, + name: dto.activity_name, + project_id: project_id.to_string(), + }) + .collect(); + + activities.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(activities) + } +} diff --git a/toki-tui/src/api/dev_backend.rs b/toki-tui/src/api/dev_backend.rs new file mode 100644 index 00000000..1a586aa3 --- /dev/null +++ b/toki-tui/src/api/dev_backend.rs @@ -0,0 +1,212 @@ +use crate::types::{Activity, Project, TimeEntry}; +use std::sync::{Arc, Mutex}; +use time::macros::offset; +use time::OffsetDateTime; + +#[derive(Debug, Clone)] +pub struct DevBackend { + store: Arc>>, +} + +#[derive(Debug, Clone)] +struct DevEntry { + registration_id: String, + start_time: OffsetDateTime, + end_time: Option, + project_id: Option, + project_name: Option, + activity_id: Option, + activity_name: Option, + note: Option, +} + +impl DevBackend { + pub fn new() -> Self { + Self { + store: Arc::new(Mutex::new(seed_dev_history())), + } + } + + pub fn time_entries(&self) -> Vec { + let store = self.store.lock().expect("dev store lock poisoned").clone(); + store + .iter() + .map(|e| TimeEntry { + registration_id: e.registration_id.clone(), + project_id: e.project_id.clone().unwrap_or_default(), + project_name: e.project_name.clone().unwrap_or_default(), + activity_id: e.activity_id.clone().unwrap_or_default(), + activity_name: e.activity_name.clone().unwrap_or_default(), + date: { + let d = e.start_time.date(); + format!("{:04}-{:02}-{:02}", d.year(), d.month() as u8, d.day()) + }, + hours: e + .end_time + .map(|end| (end - e.start_time).whole_seconds() as f64 / 3600.0) + .unwrap_or(0.0), + note: e.note.clone(), + start_time: Some(e.start_time), + end_time: e.end_time, + week_number: e.start_time.iso_week(), + }) + .collect() + } + + pub fn delete_entry(&self, registration_id: &str) { + self.store + .lock() + .expect("dev store lock poisoned") + .retain(|entry| entry.registration_id != registration_id); + } + + #[allow(clippy::too_many_arguments)] + pub fn edit_entry( + &self, + registration_id: &str, + project_id: &str, + project_name: &str, + activity_id: &str, + activity_name: &str, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + user_note: &str, + ) { + if let Some(entry) = self + .store + .lock() + .expect("dev store lock poisoned") + .iter_mut() + .find(|entry| entry.registration_id == registration_id) + { + entry.project_id = Some(project_id.to_string()); + entry.project_name = Some(project_name.to_string()); + entry.activity_id = Some(activity_id.to_string()); + entry.activity_name = Some(activity_name.to_string()); + entry.start_time = start_time; + entry.end_time = Some(end_time); + entry.note = Some(user_note.to_string()); + } + } + + pub fn projects(&self) -> Vec { + vec![ + Project { + id: "proj_1".to_string(), + name: "Nordic Crisis Manager".to_string(), + }, + Project { + id: "proj_2".to_string(), + name: "Azure DevOps Integration".to_string(), + }, + Project { + id: "proj_3".to_string(), + name: "TUI Development".to_string(), + }, + ] + } + + pub fn activities(&self, project_id: &str) -> Vec { + vec![ + Activity { + id: "act_1_1".to_string(), + name: "Backend Development".to_string(), + project_id: project_id.to_string(), + }, + Activity { + id: "act_1_4".to_string(), + name: "Code Review".to_string(), + project_id: project_id.to_string(), + }, + ] + } + + pub fn time_info(&self) -> crate::types::TimeInfo { + crate::types::TimeInfo { + period_time_left: 6.0, + worked_period_time: 26.0, + scheduled_period_time: 32.0, + worked_period_with_absence_time: 26.0, + flex_time_current: 1.5, + } + } +} + +fn seed_dev_history() -> Vec { + let now = OffsetDateTime::now_utc().to_offset(offset!(+1)); + let today = now.date(); + + let entry = |idx: u32, + h_start: u8, + h_end: u8, + pid: &str, + pname: &str, + aid: &str, + aname: &str, + note: &str| { + let start = OffsetDateTime::new_in_offset( + today, + time::Time::from_hms(h_start, 0, 0).expect("valid hour"), + offset!(+1), + ); + let end = OffsetDateTime::new_in_offset( + today, + time::Time::from_hms(h_end, 0, 0).expect("valid hour"), + offset!(+1), + ); + + DevEntry { + registration_id: format!("dev-reg-{}", idx), + start_time: start, + end_time: Some(end), + project_id: Some(pid.to_string()), + project_name: Some(pname.to_string()), + activity_id: Some(aid.to_string()), + activity_name: Some(aname.to_string()), + note: Some(note.to_string()), + } + }; + + vec![ + entry( + 1, + 8, + 10, + "proj_1", + "Nordic Crisis Manager", + "act_1_1", + "Backend Development", + "API refactor", + ), + entry( + 2, + 10, + 12, + "proj_1", + "Nordic Crisis Manager", + "act_1_4", + "Code Review", + "PR review", + ), + entry( + 3, + 13, + 15, + "proj_2", + "Azure DevOps Integration", + "act_2_1", + "API Integration", + "Webhook setup", + ), + entry( + 4, + 15, + 17, + "proj_3", + "TUI Development", + "act_3_2", + "Feature Implementation", + "Scrollable lists", + ), + ] +} diff --git a/toki-tui/src/api/dto.rs b/toki-tui/src/api/dto.rs new file mode 100644 index 00000000..a69778f3 --- /dev/null +++ b/toki-tui/src/api/dto.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectDto { + pub project_id: String, + pub project_name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityDto { + pub activity: String, + pub activity_name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartTimerRequest { + pub project_id: Option, + pub project_name: Option, + pub activity_id: Option, + pub activity_name: Option, + pub user_note: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveTimerRequest { + pub user_note: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateActiveTimerRequest { + pub project_id: Option, + pub project_name: Option, + pub activity_id: Option, + pub activity_name: Option, + pub user_note: Option, + #[serde( + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub start_time: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditEntryRequest<'a> { + pub project_registration_id: &'a str, + pub project_id: &'a str, + pub project_name: &'a str, + pub activity_id: &'a str, + pub activity_name: &'a str, + pub start_time: String, + pub end_time: String, + pub reg_day: &'a str, + pub week_number: i32, + pub user_note: &'a str, + pub original_reg_day: Option, + pub original_project_id: Option<&'a str>, + pub original_activity_id: Option<&'a str>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteEntryRequest<'a> { + pub project_registration_id: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticateRequest<'a> { + pub username: &'a str, + pub password: &'a str, +} diff --git a/toki-tui/src/api/mod.rs b/toki-tui/src/api/mod.rs new file mode 100644 index 00000000..9f3aac43 --- /dev/null +++ b/toki-tui/src/api/mod.rs @@ -0,0 +1,5 @@ +mod client; +mod dev_backend; +mod dto; + +pub use client::ApiClient; diff --git a/toki-tui/src/api_client.rs b/toki-tui/src/api_client.rs deleted file mode 100644 index a6421a97..00000000 --- a/toki-tui/src/api_client.rs +++ /dev/null @@ -1,712 +0,0 @@ -use anyhow::{Context, Result}; -use reqwest::{Client, StatusCode}; -use serde::Serialize; -use std::sync::{Arc, Mutex}; -use time::OffsetDateTime; - -use crate::types::Me; - -/// Cookie name used by tower-sessions (default). -const SESSION_COOKIE: &str = "id"; - -#[derive(Debug, Clone)] -pub struct ApiClient { - client: Client, - base_url: String, - session_id: String, - mt_cookies: Vec<(String, String)>, - /// When true, all API calls mutate/read this in-memory store instead of hitting the server. - dev_history: Option>>>, -} - -impl ApiClient { - /// Create a new client with the given session cookie. - pub fn new(base_url: &str, session_id: &str, mt_cookies: Vec<(String, String)>) -> Result { - let client = Client::builder() - .cookie_store(true) - .build() - .context("Failed to build HTTP client")?; - Ok(Self { - client, - base_url: base_url.trim_end_matches('/').to_string(), - session_id: session_id.to_string(), - mt_cookies, - dev_history: None, - }) - } - - /// Create a dev-mode client that returns fake data without hitting any server. - pub fn dev() -> Result { - let client = Client::builder().build().context("Failed to build HTTP client")?; - Ok(Self { - client, - base_url: String::new(), - session_id: String::new(), - mt_cookies: vec![], - dev_history: Some(Arc::new(Mutex::new(dev_history()))), - }) - } - - fn url(&self, path: &str) -> String { - format!("{}{}", self.base_url, path) - } - - /// Add the session cookie to a request. - fn with_session(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - let mut cookie_header = format!("{}={}", SESSION_COOKIE, self.session_id); - for (name, value) in &self.mt_cookies { - cookie_header.push_str(&format!("; {}={}", name, value)); - } - req.header("Cookie", cookie_header) - } - - /// GET /me — verify session is valid, return current user info. - pub async fn me(&self) -> Result { - if self.dev_history.is_some() { - return Ok(Me { - id: 1, - email: "dev@localhost".to_string(), - full_name: "Dev User".to_string(), - }); - } - - let resp = self - .with_session(self.client.get(self.url("/me"))) - .send() - .await - .context("Failed to call /me")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired or invalid. Run `toki-tui --login` to authenticate."); - } - resp.error_for_status_ref() - .context("GET /me returned error")?; - resp.json::().await.context("Failed to parse /me response") - } - - /// GET /time-tracking/timer — fetch the currently running timer (if any). - pub async fn get_active_timer(&mut self) -> Result> { - if self.dev_history.is_some() { - return Ok(None); // dev mode: no active timer on startup - } - - let resp = self - .with_session(self.client.get(self.url("/time-tracking/timer"))) - .send() - .await - .context("Failed to call GET /time-tracking/timer")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("GET /time-tracking/timer returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let result = resp - .json::() - .await - .context("Failed to parse GET /time-tracking/timer response")?; - - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - - Ok(result.timer) - } - - /// GET /time-tracking/time-info — fetch scheduled/worked/flex hours for a date range. - pub async fn get_time_info(&mut self, from: time::Date, to: time::Date) -> Result { - if self.dev_history.is_some() { - // Dev mode: return a sensible default matching 32h/week - return Ok(crate::types::TimeInfo { - period_time_left: 6.0, - worked_period_time: 26.0, - scheduled_period_time: 32.0, - worked_period_with_absence_time: 26.0, - flex_time_current: 1.5, - }); - } - - let format = time::format_description::parse("[year]-[month]-[day]").unwrap(); - let from_str = from.format(&format).context("Failed to format from date")?; - let to_str = to.format(&format).context("Failed to format to date")?; - - let resp = self - .with_session(self.client.get(self.url("/time-tracking/time-info"))) - .query(&[("from", &from_str), ("to", &to_str)]) - .send() - .await - .context("Failed to call GET /time-tracking/time-info")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("GET /time-tracking/time-info returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let result = resp - .json::() - .await - .context("Failed to parse time-info response")?; - - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - - Ok(result) - } - - /// GET /time-tracking/time-entries — fetch Milltime-authoritative entries for a date range. - pub async fn get_time_entries( - &mut self, - from: time::Date, - to: time::Date, - ) -> Result> { - if let Some(dev) = &self.dev_history { - // In dev mode, synthesize TimeEntry records from the local stub data - let store = dev.lock().unwrap().clone(); - return Ok(store - .iter() - .filter_map(|e| { - let reg_id = e.registration_id.clone()?; - Some(crate::types::TimeEntry { - registration_id: reg_id, - project_id: e.project_id.clone().unwrap_or_default(), - project_name: e.project_name.clone().unwrap_or_default(), - activity_id: e.activity_id.clone().unwrap_or_default(), - activity_name: e.activity_name.clone().unwrap_or_default(), - date: { - let d = e.start_time.date(); - format!("{:04}-{:02}-{:02}", d.year(), d.month() as u8, d.day()) - }, - hours: e.end_time.map(|end| { - (end - e.start_time).whole_seconds() as f64 / 3600.0 - }).unwrap_or(0.0), - note: e.note.clone(), - start_time: Some(e.start_time), - end_time: e.end_time, - week_number: e.start_time.iso_week(), - }) - }) - .collect()); - } - - let fmt = time::format_description::parse("[year]-[month]-[day]") - .context("Failed to build date format")?; - let from_str = from.format(&fmt).context("Failed to format from date")?; - let to_str = to.format(&fmt).context("Failed to format to date")?; - - let resp = self - .with_session( - self.client - .get(self.url("/time-tracking/time-entries")) - .query(&[("from", &from_str), ("to", &to_str)]), - ) - .send() - .await - .context("Failed to call GET /time-tracking/time-entries")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("GET /time-tracking/time-entries returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let entries: Vec = resp - .json() - .await - .context("Failed to parse GET /time-tracking/time-entries response")?; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(entries) - } - - - pub async fn start_timer( - &mut self, - project_id: Option, - project_name: Option, - activity_id: Option, - activity_name: Option, - note: Option, - ) -> Result<()> { - if self.dev_history.is_some() { - return Ok(()); // dev mode: timer is local-only - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body { - project_id: Option, - project_name: Option, - activity_id: Option, - activity_name: Option, - user_note: Option, - } - let resp = self - .with_session(self.client.post(self.url("/time-tracking/timer"))) - .json(&Body { project_id, project_name, activity_id, activity_name, user_note: note }) - .send() - .await - .context("Failed to call POST /time-tracking/timer")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("POST /time-tracking/timer returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let _ = resp.bytes().await; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(()) - } - - /// PUT /time-tracking/timer — save the active timer to Milltime and stop it. - /// The server uses the project/activity already stored on the active timer. - pub async fn save_timer(&mut self, note: Option) -> Result<()> { - if self.dev_history.is_some() { - return Ok(()); // dev mode: no-op - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body { user_note: Option } - let resp = self - .with_session(self.client.put(self.url("/time-tracking/timer"))) - .json(&Body { user_note: note }) - .send() - .await - .context("Failed to call PUT /time-tracking/timer")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("PUT /time-tracking/timer returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let _ = resp.bytes().await; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(()) - } - - /// DELETE /time-tracking/timer — discard the active timer without registering to Milltime. - pub async fn stop_timer(&mut self) -> Result<()> { - if self.dev_history.is_some() { - return Ok(()); - } - - let resp = self - .with_session(self.client.delete(self.url("/time-tracking/timer"))) - .send() - .await - .context("Failed to call DELETE /time-tracking/timer")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("DELETE /time-tracking/timer returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let _ = resp.bytes().await; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(()) - } - - /// PUT /time-tracking/update-timer — update fields on the currently running timer. - /// Any None field is left unchanged on the server (server merges with current state). - pub async fn update_active_timer( - &mut self, - project_id: Option, - project_name: Option, - activity_id: Option, - activity_name: Option, - note: Option, - start_time: Option, - ) -> Result<()> { - if self.dev_history.is_some() { - return Ok(()); - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body { - project_id: Option, - project_name: Option, - activity_id: Option, - activity_name: Option, - user_note: Option, - #[serde( - skip_serializing_if = "Option::is_none", - with = "time::serde::rfc3339::option" - )] - start_time: Option, - } - let resp = self - .with_session(self.client.put(self.url("/time-tracking/update-timer"))) - .json(&Body { project_id, project_name, activity_id, activity_name, user_note: note, start_time }) - .send() - .await - .context("Failed to call PUT /time-tracking/update-timer")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("PUT /time-tracking/update-timer returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let _ = resp.bytes().await; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(()) - } - - /// PUT /time-tracking/time-entries — edit an existing saved time entry. - #[allow(clippy::too_many_arguments)] - pub async fn edit_time_entry( - &mut self, - project_registration_id: &str, - project_id: &str, - project_name: &str, - activity_id: &str, - activity_name: &str, - start_time: time::OffsetDateTime, - end_time: time::OffsetDateTime, - reg_day: &str, - week_number: i32, - user_note: &str, - original_project_id: Option<&str>, - original_activity_id: Option<&str>, - ) -> Result<()> { - if self.dev_history.is_some() { - return Ok(()); - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - project_registration_id: &'a str, - project_id: &'a str, - project_name: &'a str, - activity_id: &'a str, - activity_name: &'a str, - start_time: String, - end_time: String, - reg_day: &'a str, - week_number: i32, - user_note: &'a str, - original_reg_day: Option, - original_project_id: Option<&'a str>, - original_activity_id: Option<&'a str>, - } - - let fmt = time::format_description::well_known::Rfc3339; - let body = Body { - project_registration_id, - project_id, - project_name, - activity_id, - activity_name, - start_time: start_time.format(&fmt).context("Failed to format start_time")?, - end_time: end_time.format(&fmt).context("Failed to format end_time")?, - reg_day, - week_number, - user_note, - original_reg_day: None, - original_project_id, - original_activity_id, - }; - - let resp = self - .with_session(self.client.put(self.url("/time-tracking/time-entries"))) - .json(&body) - .send() - .await - .context("Failed to call PUT /time-tracking/time-entries")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("PUT /time-tracking/time-entries returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let _ = resp.bytes().await; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(()) - } - - /// DELETE /time-tracking/time-entries — permanently delete a saved time entry. - pub async fn delete_time_entry(&mut self, registration_id: &str) -> Result<()> { - if self.dev_history.is_some() { - // Dev mode: remove from in-memory store - if let Some(dev) = &self.dev_history { - dev.lock().unwrap().retain(|e| { - e.registration_id.as_deref() != Some(registration_id) - }); - } - return Ok(()); - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - project_registration_id: &'a str, - } - - let resp = self - .with_session(self.client.delete(self.url("/time-tracking/time-entries"))) - .json(&Body { project_registration_id: registration_id }) - .send() - .await - .context("Failed to call DELETE /time-tracking/time-entries")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("DELETE /time-tracking/time-entries returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let _ = resp.bytes().await; - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - Ok(()) - } - - /// POST /time-tracking/authenticate — exchange username/password for Milltime cookies. - /// Returns the Set-Cookie values as (name, value) pairs. - pub async fn authenticate( - &self, - username: &str, - password: &str, - ) -> Result> { - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - username: &'a str, - password: &'a str, - } - let resp = self - .with_session( - self.client - .post(self.url("/time-tracking/authenticate")) - ) - .json(&Body { username, password }) - .send() - .await - .context("Failed to call POST /time-tracking/authenticate")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Invalid Milltime credentials."); - } - resp.error_for_status_ref() - .context("POST /time-tracking/authenticate returned error")?; - - Ok(extract_set_cookies(&resp)) - } - - /// Merge new cookies into self.mt_cookies (update existing names, add new). - /// Returns true if any cookie changed (caller should persist). - pub fn update_mt_cookies(&mut self, new_cookies: Vec<(String, String)>) -> bool { - if new_cookies.is_empty() { - return false; - } - for (name, value) in new_cookies { - if let Some(existing) = self.mt_cookies.iter_mut().find(|(n, _)| n == &name) { - existing.1 = value; - } else { - self.mt_cookies.push((name, value)); - } - } - true - } - - /// Return the current Milltime cookies (for persisting). - pub fn mt_cookies(&self) -> &[(String, String)] { - &self.mt_cookies - } - - /// GET /time-tracking/projects — returns all projects for the authenticated user. - pub async fn get_projects(&mut self) -> Result> { - if self.dev_history.is_some() { - return Ok(vec![ - crate::types::Project { id: "proj_1".to_string(), name: "Nordic Crisis Manager".to_string() }, - crate::types::Project { id: "proj_2".to_string(), name: "Azure DevOps Integration".to_string() }, - crate::types::Project { id: "proj_3".to_string(), name: "TUI Development".to_string() }, - ]); - } - - let resp = self - .with_session(self.client.get(self.url("/time-tracking/projects"))) - .send() - .await - .context("Failed to call GET /time-tracking/projects")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("GET /time-tracking/projects returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let dtos = resp - .json::>() - .await - .context("Failed to parse projects response")?; - - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - - let mut projects: Vec = dtos - .into_iter() - .map(|d| crate::types::Project { id: d.project_id, name: d.project_name }) - .collect(); - projects.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(projects) - } - - /// GET /time-tracking/projects/:id/activities — returns activities for a project. - pub async fn get_activities(&mut self, project_id: &str) -> Result> { - if self.dev_history.is_some() { - let activities = vec![ - crate::types::Activity { id: "act_1_1".to_string(), name: "Backend Development".to_string(), project_id: project_id.to_string() }, - crate::types::Activity { id: "act_1_4".to_string(), name: "Code Review".to_string(), project_id: project_id.to_string() }, - ]; - return Ok(activities); - } - - let resp = self - .with_session(self.client.get(self.url(&format!("/time-tracking/projects/{}/activities", project_id)))) - .send() - .await - .context("Failed to call GET /time-tracking/projects/:id/activities")?; - - if resp.status() == StatusCode::UNAUTHORIZED { - anyhow::bail!("Session expired. Run `toki-tui --login` to re-authenticate."); - } - resp.error_for_status_ref() - .context("GET /time-tracking/projects/:id/activities returned error")?; - - let new_cookies = extract_set_cookies(&resp); - let dtos = resp - .json::>() - .await - .context("Failed to parse activities response")?; - - if self.update_mt_cookies(new_cookies) { - crate::config::TokiConfig::save_mt_cookies(&self.mt_cookies)?; - } - - let mut activities: Vec = dtos - .into_iter() - .map(|d| crate::types::Activity { - id: d.activity, - name: d.activity_name, - project_id: project_id.to_string(), - }) - .collect(); - activities.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(activities) - } -} - -/// Parse Set-Cookie headers from a response into (name, value) pairs. -/// Strips all cookie attributes (path, domain, expires, httponly, secure, samesite). -fn extract_set_cookies(resp: &reqwest::Response) -> Vec<(String, String)> { - resp.headers() - .get_all(reqwest::header::SET_COOKIE) - .iter() - .filter_map(|v| { - let s = v.to_str().ok()?; - // Take only the first segment (before any ';') which is "name=value" - let pair = s.split(';').next()?; - let mut parts = pair.splitn(2, '='); - let name = parts.next()?.trim().to_string(); - let value = parts.next()?.trim().to_string(); - if name.is_empty() { None } else { Some((name, value)) } - }) - .collect() -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProjectDto { - project_id: String, - project_name: String, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct ActivityDto { - activity: String, - activity_name: String, -} - -/// Minimal timer-history record used only in dev mode to simulate a local data store. -#[derive(Debug, Clone)] -struct DevEntry { - registration_id: Option, - start_time: OffsetDateTime, - end_time: Option, - project_id: Option, - project_name: Option, - activity_id: Option, - activity_name: Option, - note: Option, -} - -/// Generate a handful of fake timer history entries for dev mode. -fn dev_history() -> Vec { - use time::macros::offset; - let now = OffsetDateTime::now_utc().to_offset(offset!(+1)); - let today = now.date(); - - let entry = |h_start: u8, h_end: u8, pid: &str, pname: &str, aid: &str, aname: &str, note: &str| { - let start = OffsetDateTime::new_in_offset( - today, - time::Time::from_hms(h_start, 0, 0).unwrap(), - offset!(+1), - ); - let end = OffsetDateTime::new_in_offset( - today, - time::Time::from_hms(h_end, 0, 0).unwrap(), - offset!(+1), - ); - DevEntry { - start_time: start, - end_time: Some(end), - project_id: Some(pid.to_string()), - project_name: Some(pname.to_string()), - activity_id: Some(aid.to_string()), - activity_name: Some(aname.to_string()), - note: Some(note.to_string()), - registration_id: None, - } - }; - - vec![ - entry(8, 10, "proj_1", "Nordic Crisis Manager", "act_1_1", "Backend Development", "API refactor"), - entry(10, 12, "proj_1", "Nordic Crisis Manager", "act_1_4", "Code Review", "PR review"), - entry(13, 15, "proj_2", "Azure DevOps Integration", "act_2_1", "API Integration", "Webhook setup"), - entry(15, 17, "proj_3", "TUI Development", "act_3_2", "Feature Implementation", "Scrollable lists"), - ] -} diff --git a/toki-tui/src/app/history.rs b/toki-tui/src/app/history.rs index a540d099..7c73b8f1 100644 --- a/toki-tui/src/app/history.rs +++ b/toki-tui/src/app/history.rs @@ -1,4 +1,5 @@ use super::*; +use std::collections::HashMap; impl App { /// Build the history list entries (indices into time_entries) @@ -27,7 +28,6 @@ impl App { pub(super) fn compute_overlaps(&mut self) { self.overlapping_entry_ids.clear(); - use std::collections::HashMap; let mut entries_by_date: HashMap<&str, Vec<&TimeEntry>> = HashMap::new(); for entry in &self.time_entries { @@ -132,8 +132,6 @@ impl App { /// Per-project/activity breakdown for this week (≥ 1% of total, sorted desc) pub fn weekly_project_stats(&self) -> Vec { - use std::collections::HashMap; - let entries = self.this_week_history(); let mut map: HashMap = HashMap::new(); @@ -177,8 +175,6 @@ impl App { /// Per-day breakdown for this week, Mon–Sun, each day split by project/activity. /// Projects are colored by their global rank (same order as weekly_project_stats). pub fn weekly_daily_stats(&self) -> Vec { - use std::collections::HashMap; - // Build the global project ordering (for consistent palette indices) let global_stats = self.weekly_project_stats(); let color_index: HashMap = global_stats diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 95227cff..01222362 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -1,8 +1,11 @@ use crate::config::TokiConfig; +use crate::time_utils::to_local_time; use crate::types::{Activity, Project, TimeEntry}; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; -use time::{OffsetDateTime, UtcOffset}; +use time::OffsetDateTime; mod edit; mod history; @@ -15,14 +18,6 @@ pub use state::{ TaskEntry, TaskwarriorOverlay, TextInput, TimerSize, TimerState, View, }; -fn to_local_time(dt: OffsetDateTime) -> OffsetDateTime { - if let Ok(local_offset) = UtcOffset::current_local_offset() { - dt.to_offset(local_offset) - } else { - dt - } -} - pub struct App { pub running: bool, pub timer_state: TimerState, @@ -69,6 +64,7 @@ pub struct App { pub editing_description: bool, pub description_is_default: bool, pub saved_timer_note: Option, // Saved when editing entry note to restore later + pub pending_edit_selection_restore: Option<(Option, Option)>, // Today box navigation (This Week view) pub focused_this_week_index: Option, @@ -151,6 +147,7 @@ impl App { editing_description: false, description_is_default: true, saved_timer_note: None, + pending_edit_selection_restore: None, focused_this_week_index: None, this_week_edit_state: None, this_week_scroll: 0, @@ -482,6 +479,7 @@ impl App { /// Cancel current selection and return to timer view pub fn cancel_selection(&mut self) { + self.pending_edit_selection_restore = None; self.navigate_to(View::Timer); } @@ -570,9 +568,6 @@ impl App { /// Filter projects based on search input using fuzzy matching pub fn filter_projects(&mut self) { - use fuzzy_matcher::skim::SkimMatcherV2; - use fuzzy_matcher::FuzzyMatcher; - if self.project_search_input.value.is_empty() { self.filtered_projects = self.projects.clone(); self.filtered_project_index = 0; @@ -612,11 +607,23 @@ impl App { /// Filter activities based on search input using fuzzy matching pub fn filter_activities(&mut self) { - use fuzzy_matcher::skim::SkimMatcherV2; - use fuzzy_matcher::FuzzyMatcher; + let selected_project_id = self + .selected_project + .as_ref() + .map(|project| project.id.as_str()); + let project_activities = self + .activities + .iter() + .filter(|activity| { + selected_project_id + .map(|project_id| activity.project_id == project_id) + .unwrap_or(true) + }) + .cloned() + .collect::>(); if self.activity_search_input.value.is_empty() { - self.filtered_activities = self.activities.clone(); + self.filtered_activities = project_activities; self.filtered_activity_index = 0; return; } @@ -625,6 +632,11 @@ impl App { let mut scored_activities: Vec<(Activity, i64)> = self .activities .iter() + .filter(|activity| { + selected_project_id + .map(|project_id| activity.project_id == project_id) + .unwrap_or(true) + }) .filter_map(|activity| { matcher .fuzzy_match(&activity.name, &self.activity_search_input.value) @@ -1021,16 +1033,21 @@ fn longest_common_prefix(strings: &[String]) -> String { if strings.is_empty() { return String::new(); } - let first = &strings[0]; - let mut len = first.len(); + + let mut prefix: Vec = strings[0].chars().collect(); for s in &strings[1..] { - len = len.min(s.len()); - for (i, (a, b)) in first.chars().zip(s.chars()).enumerate() { - if a != b { - len = len.min(i); + let mut matched_chars = 0usize; + for (a, b) in prefix.iter().zip(s.chars()) { + if *a != b { break; } + matched_chars += 1; + } + prefix.truncate(matched_chars); + if prefix.is_empty() { + break; } } - first[..len].to_string() + + prefix.into_iter().collect() } diff --git a/toki-tui/src/auth/mod.rs b/toki-tui/src/auth/mod.rs deleted file mode 100644 index d62352a3..00000000 --- a/toki-tui/src/auth/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -// Auth module placeholder -// Future: Add session cookie reuse, device code flow, or credential storage - -pub struct Credentials { - pub user_id: i32, -} - -impl Credentials { - pub fn demo() -> Self { - Self { user_id: 1 } - } -} diff --git a/toki-tui/src/bootstrap.rs b/toki-tui/src/bootstrap.rs new file mode 100644 index 00000000..e924be24 --- /dev/null +++ b/toki-tui/src/bootstrap.rs @@ -0,0 +1,51 @@ +use crate::api::ApiClient; +use crate::app::App; +use crate::runtime::restore_active_timer; + +pub async fn initialize_app_state(app: &mut App, client: &mut ApiClient) { + app.is_loading = true; + + let today = time::OffsetDateTime::now_utc().date(); + let month_ago = today - time::Duration::days(30); + + match client.get_time_entries(month_ago, today).await { + Ok(entries) => { + app.update_history(entries); + app.rebuild_history_list(); + } + Err(e) => eprintln!("Warning: Could not load history: {}", e), + } + + match client.get_projects().await { + Ok(projects) => { + app.set_projects_activities(projects, vec![]); + } + Err(e) => eprintln!("Warning: Could not load projects: {}", e), + } + + match client.get_active_timer().await { + Ok(Some(timer)) => { + restore_active_timer(app, timer); + println!("Restored running timer from server."); + } + Ok(None) => {} + Err(e) => eprintln!("Warning: Could not check active timer: {}", e), + } + + let local_today = time::OffsetDateTime::now_utc() + .to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC)) + .date(); + let days_from_monday = local_today.weekday().number_days_from_monday() as i64; + let week_start = local_today - time::Duration::days(days_from_monday); + let week_end = week_start + time::Duration::days(6); + + match client.get_time_info(week_start, week_end).await { + Ok(time_info) => { + app.scheduled_hours_per_week = time_info.scheduled_period_time; + app.flex_time_current = time_info.flex_time_current; + } + Err(e) => eprintln!("Warning: Could not load time info: {}", e), + } + + app.is_loading = false; +} diff --git a/toki-tui/src/cli.rs b/toki-tui/src/cli.rs new file mode 100644 index 00000000..328d3e36 --- /dev/null +++ b/toki-tui/src/cli.rs @@ -0,0 +1,23 @@ +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command(name = "toki-tui")] +#[command(about = "Terminal UI for Toki time tracking")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Run against a real toki-api server + Run, + /// Run in dev mode with local in-memory data + Dev, + /// Authenticate via browser OAuth login + Login, + /// Remove local session and Milltime cookies + Logout, + /// Print config path and create default file if missing + ConfigPath, +} diff --git a/toki-tui/src/config.rs b/toki-tui/src/config.rs index c5563e57..c65c555f 100644 --- a/toki-tui/src/config.rs +++ b/toki-tui/src/config.rs @@ -4,18 +4,15 @@ use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokiConfig { - /// Base URL of the toki-api server, e.g. "http://localhost:8080" + /// URL of the toki-api server. Defaults to the production instance. #[serde(default = "default_api_url")] pub api_url: String, - - /// Taskwarrior filter tokens passed before `status:pending export`. - /// e.g. "+work project:Toki" to restrict which tasks appear in the picker. - /// Defaults to no filter (all pending tasks). + /// Taskwarrior filter tokens prepended before `status:pending export`. + /// Leave empty to show all pending tasks. #[serde(default)] pub task_filter: String, - - /// Default prefix used in git branch → note conversion when no recognised - /// prefix or ticket number is found. e.g. "Development" or "Utveckling". + /// Prefix used when converting a git branch name to a time entry note + /// when no conventional commit prefix or ticket number is found. #[serde(default = "default_git_prefix")] pub git_default_prefix: String, } @@ -46,147 +43,42 @@ impl TokiConfig { .join("config.toml")) } - pub fn session_path() -> Result { - Ok(dirs::config_dir() - .context("Cannot determine config directory")? - .join("toki-tui") - .join("session")) - } - - /// Load config from disk. Returns default config if file doesn't exist. - pub fn load() -> Result { + pub fn ensure_exists() -> Result { let path = Self::config_path()?; - if !path.exists() { - return Ok(Self::default()); + if path.exists() { + return Ok(path); } - let raw = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read config at {}", path.display()))?; - let config: Self = toml::from_str(&raw) - .with_context(|| format!("Failed to parse config at {}", path.display()))?; - Ok(config) - } - /// Save config to disk, creating parent directories as needed. - pub fn save(&self) -> Result<()> { - let path = Self::config_path()?; if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Load the saved session ID from disk. Returns None if not logged in. - pub fn load_session() -> Result> { - let path = Self::session_path()?; - if !path.exists() { - return Ok(None); - } - let session = std::fs::read_to_string(&path).context("Failed to read session file")?; - let session = session.trim().to_string(); - if session.is_empty() { - return Ok(None); + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; } - Ok(Some(session)) - } - /// Save the session ID to disk. - pub fn save_session(session_id: &str) -> Result<()> { - let path = Self::session_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)? - .write_all(session_id.as_bytes())?; - } - #[cfg(not(unix))] - std::fs::write(&path, session_id)?; - Ok(()) + let raw = toml::to_string_pretty(&Self::default()) + .context("Failed to serialize default config")?; + std::fs::write(&path, raw) + .with_context(|| format!("Failed to write default config {}", path.display()))?; + Ok(path) } - /// Delete the saved session (logout). - pub fn clear_session() -> Result<()> { - let path = Self::session_path()?; - if path.exists() { - std::fs::remove_file(&path)?; - } - Ok(()) - } - - pub fn mt_cookies_path() -> Result { - Ok(dirs::config_dir() - .context("Cannot determine config directory")? - .join("toki-tui") - .join("mt_cookies")) - } - - /// Load saved Milltime cookies from disk. Returns empty vec if file doesn't exist. - pub fn load_mt_cookies() -> Result> { - let path = Self::mt_cookies_path()?; - if !path.exists() { - return Ok(vec![]); - } - let raw = std::fs::read_to_string(&path).context("Failed to read mt_cookies")?; - let cookies = raw - .lines() - .filter_map(|line| { - let mut parts = line.splitn(2, '='); - let name = parts.next()?.trim().to_string(); - let value = parts.next()?.trim().to_string(); - if name.is_empty() { - None - } else { - Some((name, value)) - } - }) - .collect(); - Ok(cookies) - } - - /// Save Milltime cookies to disk. - pub fn save_mt_cookies(cookies: &[(String, String)]) -> Result<()> { - let path = Self::mt_cookies_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let content = cookies - .iter() - .map(|(name, value)| format!("{}={}", name, value)) - .collect::>() - .join("\n"); - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)? - .write_all(content.as_bytes())?; - } - #[cfg(not(unix))] - std::fs::write(&path, content)?; - Ok(()) - } + pub fn load() -> Result { + let path = Self::config_path()?; - /// Delete saved Milltime cookies. - pub fn clear_mt_cookies() -> Result<()> { - let path = Self::mt_cookies_path()?; - if path.exists() { - std::fs::remove_file(&path)?; - } - Ok(()) + let settings = config::Config::builder() + .set_default("api_url", default_api_url())? + .set_default("task_filter", "")? + .set_default("git_default_prefix", default_git_prefix())? + .add_source(config::File::from(path.clone()).required(false)) + .add_source( + config::Environment::with_prefix("TOKI_TUI") + .prefix_separator("_") + .separator("__"), + ) + .build() + .context("Failed to build TUI config")?; + + settings + .try_deserialize::() + .with_context(|| format!("Failed to parse config from {}", path.display())) } } diff --git a/toki-tui/src/login.rs b/toki-tui/src/login.rs index 69581bf8..1d3429c2 100644 --- a/toki-tui/src/login.rs +++ b/toki-tui/src/login.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; const TUI_CALLBACK_PORT: u16 = 9876; const TUI_LOGIN_PORT: u16 = 9875; @@ -53,7 +54,7 @@ pub async fn run_login(api_url: &str) -> Result { // Wait for the OAuth callback with the session_id let session_id = wait_for_callback().await?; - crate::config::TokiConfig::save_session(&session_id)?; + crate::session_store::save_session(&session_id)?; println!("Login successful. Session saved."); Ok(session_id) @@ -63,8 +64,6 @@ pub async fn run_login(api_url: &str) -> Result { /// Accepts up to 10 connections to handle browser pre-connections/favicon/etc, /// but exits as soon as a GET / request has been served. async fn serve_one_page(port: u16, html: String) { - use tokio::net::TcpListener; - let listener = match TcpListener::bind(format!("127.0.0.1:{}", port)).await { Ok(l) => l, Err(e) => { @@ -110,15 +109,19 @@ fn open_browser(url: &str) { /// Start a minimal HTTP server, wait for one request to /callback?session_id=, /// return the session_id. async fn wait_for_callback() -> Result { - use tokio::net::TcpListener; - let listener = TcpListener::bind(format!("127.0.0.1:{}", TUI_CALLBACK_PORT)) .await .with_context(|| format!("Failed to bind to port {}", TUI_CALLBACK_PORT))?; - println!("Waiting for browser callback on port {}...", TUI_CALLBACK_PORT); + println!( + "Waiting for browser callback on port {}...", + TUI_CALLBACK_PORT + ); - let (mut stream, _) = listener.accept().await.context("Failed to accept connection")?; + let (mut stream, _) = listener + .accept() + .await + .context("Failed to accept connection")?; let mut buf = vec![0u8; 4096]; let n = stream diff --git a/toki-tui/src/main.rs b/toki-tui/src/main.rs index 573f5334..24cf981d 100644 --- a/toki-tui/src/main.rs +++ b/toki-tui/src/main.rs @@ -1,1519 +1,116 @@ -mod api_client; +mod api; mod app; +mod bootstrap; +mod cli; mod config; mod git; mod login; +mod runtime; +mod session_store; +mod terminal; +mod time_utils; mod types; mod ui; -use anyhow::{Context, Result}; -use api_client::ApiClient; -use app::{App, TextInput}; -use crossterm::{ - event::{self, Event, KeyCode, KeyModifiers}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{backend::CrosstermBackend, Terminal}; -use std::io; -use std::time::Duration; +use anyhow::Result; +use api::ApiClient; +use app::App; +use clap::Parser; +use cli::{Cli, Commands}; +use std::io::Write; #[tokio::main] async fn main() -> Result<()> { - let args: Vec = std::env::args().collect(); - let flag = args.get(1).map(|s| s.as_str()); + let cli = Cli::parse(); - if matches!(flag, Some("--config-path")) { - let path = config::TokiConfig::config_path()?; - if !path.exists() { - config::TokiConfig::default().save()?; - println!("Created default config at {}", path.display()); - } else { + match cli.command { + Commands::ConfigPath => { + let path = config::TokiConfig::ensure_exists()?; println!("{}", path.display()); } - return Ok(()); - } - - let cfg = config::TokiConfig::load()?; - - match flag { - Some("--login") => { + Commands::Login => { + let cfg = config::TokiConfig::load()?; login::run_login(&cfg.api_url).await?; - return Ok(()); } - Some("--logout") => { - config::TokiConfig::clear_session()?; - config::TokiConfig::clear_mt_cookies()?; + Commands::Logout => { + session_store::clear_session()?; + session_store::clear_mt_cookies()?; println!("Logged out. Session and Milltime cookies cleared."); - return Ok(()); - } - Some("--dev") => { - let mut client = ApiClient::dev()?; - let me = client.me().await?; - println!("Dev mode: logged in as {} ({})\n", me.full_name, me.email); - let mut app = App::new(me.id, &cfg); - { - let today = time::OffsetDateTime::now_utc().date(); - let month_ago = today - time::Duration::days(30); - if let Ok(entries) = client.get_time_entries(month_ago, today).await { - app.update_history(entries); - app.rebuild_history_list(); - } - } - if let Ok(projects) = client.get_projects().await { - app.set_projects_activities(projects, vec![]); - } - if let Ok(Some(timer)) = client.get_active_timer().await { - restore_active_timer(&mut app, timer); - } - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - let res = run_app(&mut terminal, &mut app, &mut client).await; - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - if let Err(err) = res { - eprintln!("Error: {:?}", err); - } - println!("\nGoodbye!"); - return Ok(()); - } - _ => {} - } - - // Load session — require login if missing - let session_id = match config::TokiConfig::load_session()? { - Some(s) => s, - None => { - eprintln!("Not logged in. Run `toki-tui --login` to authenticate."); - std::process::exit(1); - } - }; - - let mt_cookies = config::TokiConfig::load_mt_cookies()?; - let mut client = ApiClient::new(&cfg.api_url, &session_id, mt_cookies)?; - - // Verify session is valid - let me = match client.me().await { - Ok(me) => me, - Err(e) => { - eprintln!("{}", e); - std::process::exit(1); - } - }; - - println!("Logged in as {} ({})\n", me.full_name, me.email); - - // Authenticate against Milltime if we don't have cookies yet - if client.mt_cookies().is_empty() { - println!("Milltime credentials required."); - print!("Username: "); - std::io::Write::flush(&mut std::io::stdout())?; - let mut username = String::new(); - std::io::BufRead::read_line(&mut std::io::BufReader::new(std::io::stdin()), &mut username)?; - let username = username.trim().to_string(); - - let password = rpassword::prompt_password("Password: ")?; - - print!("Authenticating..."); - std::io::Write::flush(&mut std::io::stdout())?; - match client.authenticate(&username, &password).await { - Ok(cookies) => { - client.update_mt_cookies(cookies); - config::TokiConfig::save_mt_cookies(client.mt_cookies())?; - println!(" OK"); - } - Err(e) => { - eprintln!("\nMilltime authentication failed: {}", e); - std::process::exit(1); - } } - } - - let mut app = App::new(me.id, &cfg); - - // Load timer history (last 30 days from Milltime) - app.is_loading = true; - { - let today = time::OffsetDateTime::now_utc().date(); - let month_ago = today - time::Duration::days(30); - match client.get_time_entries(month_ago, today).await { - Ok(entries) => { - app.update_history(entries); - app.rebuild_history_list(); - } - Err(e) => eprintln!("Warning: Could not load history: {}", e), + Commands::Dev => { + run_dev_mode().await?; } - } - - // Fetch projects from Milltime API - match client.get_projects().await { - Ok(projects) => { - app.set_projects_activities(projects, vec![]); - } - Err(e) => eprintln!("Warning: Could not load projects: {}", e), - } - - // Restore running timer from server (if one was left running) - match client.get_active_timer().await { - Ok(Some(timer)) => { - restore_active_timer(&mut app, timer); - println!("Restored running timer from server."); - } - Ok(None) => {} - Err(e) => eprintln!("Warning: Could not check active timer: {}", e), - } - - // Compute Mon–Sun of the current ISO week for time-info query - let today = time::OffsetDateTime::now_utc() - .to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC)) - .date(); - let days_from_monday = today.weekday().number_days_from_monday() as i64; - let week_start = today - time::Duration::days(days_from_monday); - let week_end = week_start + time::Duration::days(6); - - // Fetch scheduled hours per week from Milltime - match client.get_time_info(week_start, week_end).await { - Ok(time_info) => { - app.scheduled_hours_per_week = time_info.scheduled_period_time; - app.flex_time_current = time_info.flex_time_current; + Commands::Run => { + run_real_mode().await?; } - Err(e) => eprintln!("Warning: Could not load time info: {}", e), } - app.is_loading = false; - - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Run the app - let res = run_app(&mut terminal, &mut app, &mut client).await; - - // Restore terminal - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - if let Err(err) = res { - eprintln!("Error: {:?}", err); - } - - println!("\nGoodbye!"); - Ok(()) } -async fn run_app( - terminal: &mut Terminal>, - app: &mut App, - client: &mut ApiClient, -) -> Result<()> { - // Show throbber for at least 3 seconds on startup - app.is_loading = true; - let loading_until = std::time::Instant::now() + std::time::Duration::from_secs(3); - - // Background polling: refresh time entries every 60 seconds - let mut last_history_refresh = std::time::Instant::now(); - const HISTORY_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); - - loop { - terminal.draw(|f| ui::render(f, app))?; - - // Advance throbber every frame when loading - if app.is_loading { - app.throbber_state.calc_next(); - // Stop the startup animation after 3 seconds (real loads set is_loading themselves) - if std::time::Instant::now() >= loading_until { - app.is_loading = false; - } - } - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - // Milltime re-auth overlay intercepts all keys while it is open - if app.milltime_reauth.is_some() { - match key.code { - KeyCode::Tab | KeyCode::BackTab => { - app.milltime_reauth_next_field(); - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.milltime_reauth_input_char(c); - } - KeyCode::Backspace => { - app.milltime_reauth_backspace(); - } - KeyCode::Enter => { - handle_milltime_reauth_submit(app, client).await; - } - KeyCode::Esc => { - app.close_milltime_reauth(); - app.set_status("Milltime re-authentication cancelled".to_string()); - } - _ => {} - } - continue; - } - - match &app.current_view { - app::View::SelectProject => { - // Save edit state and running timer state before any selection - let had_edit_state = app.is_in_edit_mode(); - // Save running timer's project/activity - let saved_selected_project = app.selected_project.clone(); - let saved_selected_activity = app.selected_activity.clone(); - - match key.code { - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.search_input_clear(); - } - KeyCode::Tab => { - app.selection_list_focused = true; - } - KeyCode::BackTab => { - app.selection_list_focused = false; - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) && c != 'q' && c != 'Q' => { - if app.selection_list_focused && c == 'j' { - if app.filtered_project_index + 1 >= app.filtered_projects.len() { - app.selection_list_focused = false; - } else { - app.select_next(); - } - } else if app.selection_list_focused && c == 'k' { - if app.filtered_project_index == 0 { - app.selection_list_focused = false; - } else { - app.select_previous(); - } - } else if !app.selection_list_focused { - app.search_input_char(c); - } - } - KeyCode::Backspace => { - app.search_input_backspace(); - } - KeyCode::Up => { - if app.selection_list_focused && app.filtered_project_index == 0 { - app.selection_list_focused = false; - } else { - app.select_previous(); - } - } - KeyCode::Down => { - if app.selection_list_focused && app.filtered_project_index + 1 >= app.filtered_projects.len() { - app.selection_list_focused = false; - } else { - app.select_next(); - } - } - KeyCode::Left => { if !app.selection_list_focused { app.search_move_cursor(true); } } - KeyCode::Right => { if !app.selection_list_focused { app.search_move_cursor(false); } } - KeyCode::Home => { if !app.selection_list_focused { app.search_cursor_home_end(true); } } - KeyCode::End => { if !app.selection_list_focused { app.search_cursor_home_end(false); } } - KeyCode::Enter => { - app.confirm_selection(); - // Fetch activities for the selected project (lazy, cached) - if let Some(project) = app.selected_project.clone() { - if !app.activity_cache.contains_key(&project.id) { - app.is_loading = true; - match client.get_activities(&project.id).await { - Ok(activities) => { - app.activity_cache.insert(project.id.clone(), activities); - } - Err(e) => { - app.set_status(format!("Failed to load activities: {}", e)); - } - } - app.is_loading = false; - } - // Populate app.activities from cache for this project - if let Some(cached) = app.activity_cache.get(&project.id) { - app.activities = cached.clone(); - app.filtered_activities = cached.clone(); - app.filtered_activity_index = 0; - } - } - // If we were in edit mode, restore with selected project AND restore running timer state - if had_edit_state { - if let Some(project) = app.selected_project.clone() { - app.update_edit_state_project(project.id.clone(), project.name.clone()); - } - // Restore running timer's project/activity - app.selected_project = saved_selected_project; - app.selected_activity = saved_selected_activity; - } - // Auto-show activity selection - app.navigate_to(app::View::SelectActivity); - } - KeyCode::Esc => app.cancel_selection(), - KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), - _ => {} - } - } - app::View::SelectActivity => { - // Save edit state and running timer state before any selection - let was_in_edit_mode = app.is_in_edit_mode(); - let saved_selected_project = app.selected_project.clone(); - let saved_selected_activity = app.selected_activity.clone(); - - match key.code { - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.activity_search_input_clear(); - } - KeyCode::Tab => { - app.selection_list_focused = true; - } - KeyCode::BackTab => { - app.selection_list_focused = false; - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) && c != 'q' && c != 'Q' => { - if app.selection_list_focused && c == 'j' { - if app.filtered_activity_index + 1 >= app.filtered_activities.len() { - app.selection_list_focused = false; - } else { - app.select_next(); - } - } else if app.selection_list_focused && c == 'k' { - if app.filtered_activity_index == 0 { - app.selection_list_focused = false; - } else { - app.select_previous(); - } - } else if !app.selection_list_focused { - app.activity_search_input_char(c); - } - } - KeyCode::Backspace => { - app.activity_search_input_backspace(); - } - KeyCode::Up => { - if app.selection_list_focused && app.filtered_activity_index == 0 { - app.selection_list_focused = false; - } else { - app.select_previous(); - } - } - KeyCode::Down => { - if app.selection_list_focused && app.filtered_activity_index + 1 >= app.filtered_activities.len() { - app.selection_list_focused = false; - } else { - app.select_next(); - } - } - KeyCode::Left => { if !app.selection_list_focused { app.activity_search_move_cursor(true); } } - KeyCode::Right => { if !app.selection_list_focused { app.activity_search_move_cursor(false); } } - KeyCode::Home => { if !app.selection_list_focused { app.activity_search_cursor_home_end(true); } } - KeyCode::End => { if !app.selection_list_focused { app.activity_search_cursor_home_end(false); } } - KeyCode::Enter => { - app.confirm_selection(); - - // If we were in edit mode, restore edit state with selected activity AND restore running timer state - if was_in_edit_mode { - if let Some(activity) = app.selected_activity.clone() { - app.update_edit_state_activity(activity.id.clone(), activity.name.clone()); - } - // Restore running timer's project/activity - app.selected_project = saved_selected_project; - app.selected_activity = saved_selected_activity; - // Navigate back to the appropriate view - let return_view = app.get_return_view_from_edit(); - app.navigate_to(return_view); - if return_view == app::View::Timer { - app.focused_box = app::FocusedBox::Today; - app.entry_edit_set_focused_field(app::EntryEditField::Activity); - } else { - app.focused_box = app::FocusedBox::Today; // Not used in History view but keep consistent - app.entry_edit_set_focused_field(app::EntryEditField::Activity); - } - } else if app.timer_state == app::TimerState::Running { - // Sync new project/activity to server - let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); - let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); - let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); - let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - if let Err(e) = client.update_active_timer( - project_id, project_name, activity_id, activity_name, - None, None, - ).await { - app.set_status(format!("Warning: Could not sync project to server: {}", e)); - } - } - } - KeyCode::Esc => app.cancel_selection(), - KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), - _ => {} - } - } - app::View::EditDescription => { - let was_in_edit_mode = app.is_in_edit_mode(); - - // CWD change mode takes priority - if app.cwd_input.is_some() { - match key.code { - KeyCode::Esc => app.cancel_cwd_change(), - KeyCode::Enter => { - if let Err(e) = app.confirm_cwd_change() { - app.status_message = Some(e); - } - } - KeyCode::Tab => app.cwd_tab_complete(), - KeyCode::Backspace => app.cwd_input_backspace(), - KeyCode::Left => app.cwd_move_cursor(true), - KeyCode::Right => app.cwd_move_cursor(false), - KeyCode::Home => app.cwd_cursor_home_end(true), - KeyCode::End => app.cwd_cursor_home_end(false), - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.cwd_input_char(c); - } - _ => {} - } - } else if app.taskwarrior_overlay.is_some() { - match key.code { - KeyCode::Esc => app.close_taskwarrior_overlay(), - KeyCode::Char('t') | KeyCode::Char('T') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.close_taskwarrior_overlay(); - } - KeyCode::Down | KeyCode::Char('j') => { - app.taskwarrior_move(true); - } - KeyCode::Up | KeyCode::Char('k') => { - app.taskwarrior_move(false); - } - KeyCode::Enter => app.taskwarrior_confirm(), - _ => {} - } - } else if app.git_mode { - // Second key of Ctrl+G sequence - match key.code { - KeyCode::Char('b') | KeyCode::Char('B') => app.paste_git_branch_raw(), - KeyCode::Char('p') | KeyCode::Char('P') => app.paste_git_branch_parsed(), - KeyCode::Char('c') | KeyCode::Char('C') => app.paste_git_last_commit(), - _ => app.exit_git_mode(), // any other key cancels git mode - } - } else { - match key.code { - KeyCode::Char('x') | KeyCode::Char('X') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.description_input.clear(); - } - KeyCode::Char('g') | KeyCode::Char('G') - if key.modifiers.contains(KeyModifiers::CONTROL) - && app.git_context.branch.is_some() => - { - app.enter_git_mode(); - } - KeyCode::Char('d') | KeyCode::Char('D') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.begin_cwd_change(); - } - KeyCode::Char('t') | KeyCode::Char('T') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.open_taskwarrior_overlay(); - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - app.input_char(c); - } - KeyCode::Backspace => app.input_backspace(), - KeyCode::Left => app.input_move_cursor(true), - KeyCode::Right => app.input_move_cursor(false), - KeyCode::Home => app.input_cursor_home_end(true), - KeyCode::End => app.input_cursor_home_end(false), - KeyCode::Enter => { - if was_in_edit_mode { - app.update_edit_state_note(app.description_input.value.clone()); - if let Some(saved_note) = app.saved_timer_note.take() { - app.description_input = TextInput::from_str(&saved_note); - } - let return_view = app.get_return_view_from_edit(); - app.navigate_to(return_view); - if return_view == app::View::Timer { - app.focused_box = app::FocusedBox::Today; - } - } else { - app.confirm_description(); - } - } - KeyCode::Esc => { - if was_in_edit_mode { - if let Some(saved_note) = app.saved_timer_note.take() { - app.description_input = TextInput::from_str(&saved_note); - } - let return_view = app.get_return_view_from_edit(); - app.navigate_to(return_view); - if return_view == app::View::Timer { - app.focused_box = app::FocusedBox::Today; - } - } else { - app.cancel_selection(); - } - } - KeyCode::Char('q') | KeyCode::Char('Q') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.quit(); - } - _ => {} - } - } - } - app::View::SaveAction => { - match key.code { - KeyCode::Char('1') => { - app.select_save_action_by_number(1); - handle_save_timer_with_action(app, client).await?; - } - KeyCode::Char('2') => { - app.select_save_action_by_number(2); - handle_save_timer_with_action(app, client).await?; - } - KeyCode::Char('3') => { - app.select_save_action_by_number(3); - handle_save_timer_with_action(app, client).await?; - } - KeyCode::Char('4') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { - // Cancel - return to timer view - app.navigate_to(app::View::Timer); - } - KeyCode::Up | KeyCode::Char('k') => app.select_previous_save_action(), - KeyCode::Down | KeyCode::Char('j') => app.select_next_save_action(), - KeyCode::Enter => { - handle_save_timer_with_action(app, client).await?; - } - _ => {} - } - } - app::View::History => { - // Check if we're in edit mode - if app.history_edit_state.is_some() { - match key.code { - // Tab: next field - KeyCode::Tab => { - app.entry_edit_next_field(); - } - KeyCode::BackTab => { - app.entry_edit_prev_field(); - } - // Arrow keys: navigate fields (or cursor movement in Note) - KeyCode::Down | KeyCode::Char('j') => { - app.entry_edit_next_field(); - } - KeyCode::Up | KeyCode::Char('k') => { - app.entry_edit_prev_field(); - } - KeyCode::Right => { - if app.history_edit_state.as_ref().is_some_and(|s| s.focused_field == app::EntryEditField::Note) { - app.entry_edit_move_cursor(false); - } else { - app.entry_edit_next_field(); - } - } - KeyCode::Char('l') | KeyCode::Char('L') => { - app.entry_edit_next_field(); - } - KeyCode::Left => { - if app.history_edit_state.as_ref().is_some_and(|s| s.focused_field == app::EntryEditField::Note) { - app.entry_edit_move_cursor(true); - } else { - app.entry_edit_prev_field(); - } - } - KeyCode::Char('h') | KeyCode::Char('H') => { - app.entry_edit_prev_field(); - } - KeyCode::Home => app.entry_edit_cursor_home_end(true), - KeyCode::End => app.entry_edit_cursor_home_end(false), - // Number keys for time input - KeyCode::Char(c) if c.is_ascii_digit() => { - app.entry_edit_input_char(c); - } - KeyCode::Backspace => { - app.entry_edit_backspace(); - } - // Enter: edit field or move to next field for times - KeyCode::Enter => { - if let Some(state) = &app.history_edit_state { - match state.focused_field { - app::EntryEditField::StartTime | app::EntryEditField::EndTime => { - // Move to next field - app.entry_edit_next_field(); - } - _ => { - handle_entry_edit_enter(app); - } - } - } - } - // Ctrl+X: Clear time field (when focused on time input) - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(state) = &app.history_edit_state { - match state.focused_field { - app::EntryEditField::StartTime | app::EntryEditField::EndTime => { - app.entry_edit_clear_time(); - } - _ => {} - } - } - } - // Escape: save and exit edit mode - KeyCode::Esc => { - if let Some(error) = app.entry_edit_validate() { - app.entry_edit_revert_invalid_times(); - app.set_status(format!("Edit cancelled: {}", error)); - app.exit_history_edit_mode(); - } else { - handle_history_edit_save(app, client).await?; - } - } - // P: select project - KeyCode::Char('p') | KeyCode::Char('P') => { - app.navigate_to(app::View::SelectProject); - } - // Q: quit - KeyCode::Char('q') | KeyCode::Char('Q') => { - app.quit(); - } - _ => {} - } - } else { - // Not in edit mode - match key.code { - KeyCode::Up | KeyCode::Char('k') => app.select_previous(), - KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Enter => { - // Enter edit mode - app.enter_history_edit_mode(); - } - KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Esc => { - app.navigate_to(app::View::Timer); - } - KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), - KeyCode::Delete | KeyCode::Backspace - if app.focused_history_index.is_some() => - { - app.enter_delete_confirm(app::DeleteOrigin::History); - } - KeyCode::Char('x') - if key.modifiers.contains(KeyModifiers::CONTROL) - && app.focused_history_index.is_some() => - { - app.enter_delete_confirm(app::DeleteOrigin::History); - } - _ => {} - } - } - } - app::View::Statistics => { - match key.code { - KeyCode::Char('s') | KeyCode::Char('S') - | KeyCode::Esc => { - app.navigate_to(app::View::Timer); - } - KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), - _ => {} - } - } - app::View::ConfirmDelete => { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - if let Some(ctx) = app.delete_context.take() { - let origin = ctx.origin; - match client.delete_time_entry(&ctx.registration_id).await { - Ok(()) => { - // Remove from local state immediately - app.time_entries.retain(|e| e.registration_id != ctx.registration_id); - app.rebuild_history_list(); - app.set_status("Entry deleted".to_string()); - } - Err(e) => { - app.set_status(format!("Delete failed: {}", e)); - } - } - match origin { - app::DeleteOrigin::Timer => app.navigate_to(app::View::Timer), - app::DeleteOrigin::History => app.navigate_to(app::View::History), - } - } - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - let origin = app.delete_context.as_ref().map(|c| c.origin); - app.delete_context = None; - match origin { - Some(app::DeleteOrigin::Timer) | None => app.navigate_to(app::View::Timer), - Some(app::DeleteOrigin::History) => app.navigate_to(app::View::History), - } - } - _ => {} - } - } - app::View::Timer => { - match key.code { - // Quit - KeyCode::Char('q') | KeyCode::Char('Q') => { - app.quit(); - } - // Ctrl+C also quits - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.quit(); - } - // Ctrl+S: Save & continue - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Validate first - if app.timer_state == app::TimerState::Stopped { - app.set_status("No active timer to save".to_string()); - } else if !app.has_project_activity() { - app.set_status("Cannot save: Please select Project / Activity first (press P)".to_string()); - } else { - // Show save action dialog - app.navigate_to(app::View::SaveAction); - } - } - // Tab: Navigate forward between boxes (or next field in edit mode) - KeyCode::Tab => { - if app.this_week_edit_state.is_some() { - app.entry_edit_next_field(); - } else { - app.focus_next(); - } - } - // Shift+Tab (BackTab): Navigate backward between boxes (or prev field in edit mode) - KeyCode::BackTab => { - if app.this_week_edit_state.is_some() { - app.entry_edit_prev_field(); - } else { - app.focus_previous(); - } - } - // Arrow down / j: Move down (next row in This Week, or next field in edit mode) - KeyCode::Down | KeyCode::Char('j') => { - if app.this_week_edit_state.is_some() { - app.entry_edit_next_field(); - } else if app.focused_box == app::FocusedBox::Today { - app.this_week_focus_down(); - } else { - app.focus_next(); - } - } - // Arrow up / k: Move up (prev row in This Week, or prev field in edit mode) - KeyCode::Up | KeyCode::Char('k') => { - if app.this_week_edit_state.is_some() { - app.entry_edit_prev_field(); - } else if app.focused_box == app::FocusedBox::Today { - app.this_week_focus_up(); - } else { - app.focus_previous(); - } - } - // Arrow right / l: Next field (edit mode only; Note is not inline-editable) - KeyCode::Right => { - if app.this_week_edit_state.is_some() { - app.entry_edit_next_field(); - } - } - KeyCode::Char('l') | KeyCode::Char('L') => { - if app.this_week_edit_state.is_some() { - app.entry_edit_next_field(); - } - } - // Arrow left: Prev field in edit mode (Note is not inline-editable) - KeyCode::Left => { - if app.this_week_edit_state.is_some() { - app.entry_edit_prev_field(); - } - } - KeyCode::Char('h') | KeyCode::Char('H') => { - if app.this_week_edit_state.is_some() { - app.entry_edit_prev_field(); - } else if app.this_week_edit_state.is_none() { - // Open History view when not in edit mode - let today = time::OffsetDateTime::now_utc().date(); - let month_ago = today - time::Duration::days(30); - match client.get_time_entries(month_ago, today).await { - Ok(entries) => { - app.update_history(entries); - app.rebuild_history_list(); - app.navigate_to(app::View::History); - } - Err(e) => { - app.set_status(format!("Error loading history: {}", e)); - } - } - } - } - KeyCode::Home => { - if app.this_week_edit_state.is_some() { - app.entry_edit_cursor_home_end(true); - } - } - KeyCode::End => { - if app.this_week_edit_state.is_some() { - app.entry_edit_cursor_home_end(false); - } - } - // Enter: activate focused box or move to next field in edit mode - KeyCode::Enter => { - if app.this_week_edit_state.is_some() { - // Check if focused on time field - move to next field - if let Some(state) = &app.this_week_edit_state { - match state.focused_field { - app::EntryEditField::StartTime | app::EntryEditField::EndTime => { - // Move to next field - app.entry_edit_next_field(); - } - _ => { - // In edit mode, Enter on Project/Activity/Note opens modal - handle_entry_edit_enter(app); - } - } - } - } else { - match app.focused_box { - app::FocusedBox::Timer => { - // Start timer when Timer box is focused - handle_start_timer(app, client).await?; - } - app::FocusedBox::Today => { - // If no entry selected, default to first entry - if app.focused_this_week_index.is_none() && !app.this_week_history().is_empty() { - app.focused_this_week_index = Some(0); - } - app.enter_this_week_edit_mode(); - } - _ => { - app.activate_focused_box(); - } - } - } - } - // Number keys for time input in edit mode - KeyCode::Char(c) if app.this_week_edit_state.is_some() && c.is_ascii_digit() => { - app.entry_edit_input_char(c); - } - KeyCode::Backspace => { - if app.this_week_edit_state.is_some() { - // Note field is not inline-editable; Enter opens the Notes view - let on_note = app.this_week_edit_state.as_ref() - .is_some_and(|s| s.focused_field == app::EntryEditField::Note); - if !on_note { - app.entry_edit_backspace(); - } - } else if app.focused_box == app::FocusedBox::Today - && app.focused_this_week_index.is_some_and(|idx| { - !(app.timer_state == app::TimerState::Running && idx == 0) - }) - { - app.enter_delete_confirm(app::DeleteOrigin::Timer); - } - } - // Escape to exit zen mode first, then exit edit mode - KeyCode::Esc => { - if app.zen_mode { - app.exit_zen_mode(); - } else if app.this_week_edit_state.is_some() { - // Check validation - if let Some(error) = app.entry_edit_validate() { - // Revert invalid times and show error - app.entry_edit_revert_invalid_times(); - app.set_status(format!("Edit cancelled: {}", error)); - app.exit_this_week_edit_mode(); - app.focused_box = app::FocusedBox::Today; - } else { - // Save changes via API - handle_this_week_edit_save(app, client).await?; - } - } else { - app.focused_box = app::FocusedBox::Timer; - app.focused_this_week_index = None; - } - } - // Space: Start timer or Save & Stop - KeyCode::Char(' ') => { - match app.timer_state { - app::TimerState::Stopped => { - handle_start_timer(app, client).await?; - } - app::TimerState::Running => { - if !app.has_project_activity() { - app.set_status("Cannot save: Please select Project / Activity first (press P)".to_string()); - } else { - // Save & stop directly without showing dialog - app.selected_save_action = app::SaveAction::SaveAndStop; - handle_save_timer_with_action(app, client).await?; - } - } - } - } - // P: Select project - KeyCode::Char('p') | KeyCode::Char('P') => { - app.navigate_to(app::View::SelectProject); - } - // N: Edit note - KeyCode::Char('n') | KeyCode::Char('N') => { - app.navigate_to(app::View::EditDescription); - } - // T: Toggle timer size - KeyCode::Char('t') | KeyCode::Char('T') => { - app.toggle_timer_size(); - } - // S: Open Statistics view (unmodified only — Ctrl+S is save) - KeyCode::Char('s') | KeyCode::Char('S') - if !key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.navigate_to(app::View::Statistics); - } - // Ctrl+X: Clear time field (when in edit mode on time input) or clear timer - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if app.this_week_edit_state.is_some() { - // In edit mode - clear time field if focused on time input - if let Some(state) = &app.this_week_edit_state { - match state.focused_field { - app::EntryEditField::StartTime | app::EntryEditField::EndTime => { - app.entry_edit_clear_time(); - } - _ => {} - } - } - } else { - // If a DB entry row is selected, treat Ctrl+X as delete - let selected_is_db_row = app.focused_box == app::FocusedBox::Today - && app.focused_this_week_index.is_some_and(|idx| { - !(app.timer_state == app::TimerState::Running && idx == 0) - }); - if selected_is_db_row { - app.enter_delete_confirm(app::DeleteOrigin::Timer); - } else { - // Original behaviour: discard running timer - if app.timer_state == app::TimerState::Running { - if let Err(e) = client.stop_timer().await { - app.set_status(format!("Warning: Could not stop server timer: {}", e)); - } - } - app.clear_timer(); - } - } - } - KeyCode::Delete - if app.this_week_edit_state.is_none() - && app.focused_box == app::FocusedBox::Today - && app.focused_this_week_index.is_some_and(|idx| { - !(app.timer_state == app::TimerState::Running && idx == 0) - }) => - { - app.enter_delete_confirm(app::DeleteOrigin::Timer); - } - // Z: Toggle zen mode - KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(), - _ => {} - } - } - } - } - } - - // Background polling: silently refresh time entries every 60 seconds - // Skip if user is in edit mode to avoid disrupting their input - if last_history_refresh.elapsed() >= HISTORY_REFRESH_INTERVAL && !app.is_in_edit_mode() { - let today = time::OffsetDateTime::now_utc().date(); - let month_ago = today - time::Duration::days(30); - match client.get_time_entries(month_ago, today).await { - Ok(entries) => { - app.update_history(entries); - app.rebuild_history_list(); - } - Err(e) if is_milltime_auth_error(&e) => { - app.open_milltime_reauth(); - } - Err(_) => {} // transient errors are silently ignored on background refresh - } - last_history_refresh = std::time::Instant::now(); - } - - if !app.running { - break; - } - } +async fn run_dev_mode() -> Result<()> { + let cfg = config::TokiConfig::load()?; + let mut client = ApiClient::dev()?; + let me = client.me().await?; - Ok(()) + println!("Dev mode: logged in as {} ({})\n", me.full_name, me.email); + run_ui(App::new(me.id, &cfg), client).await } -/// Apply an active timer fetched from the server into App state. -fn restore_active_timer(app: &mut App, timer: crate::types::ActiveTimerState) { - use std::time::{Duration, Instant}; - let elapsed_secs = (timer.hours * 3600 + timer.minutes * 60 + timer.seconds) as u64; - app.absolute_start = Some(timer.start_time); - app.local_start = Some(Instant::now() - Duration::from_secs(elapsed_secs)); - app.timer_state = app::TimerState::Running; - if let (Some(id), Some(name)) = (timer.project_id, timer.project_name) { - app.selected_project = Some(crate::types::Project { id, name }); - } - if let (Some(id), Some(name)) = (timer.activity_id, timer.activity_name) { - app.selected_activity = Some(crate::types::Activity { - id, - name, - project_id: app.selected_project.as_ref().map(|p| p.id.clone()).unwrap_or_default(), - }); - } - if !timer.note.is_empty() { - app.description_input = app::TextInput::from_str(&timer.note); - app.description_is_default = false; - } -} +async fn run_real_mode() -> Result<()> { + let cfg = config::TokiConfig::load()?; -async fn handle_start_timer(app: &mut App, client: &mut ApiClient) -> Result<()> { - match app.timer_state { - app::TimerState::Stopped => { - let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); - let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); - let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); - let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) - }; - if let Err(e) = client.start_timer(project_id, project_name, activity_id, activity_name, note).await { - if is_milltime_auth_error(&e) { - app.open_milltime_reauth(); - } else { - app.set_status(format!("Error starting timer: {}", e)); - } - return Ok(()); - } - app.start_timer(); - app.clear_status(); - } - app::TimerState::Running => { - app.set_status("Timer already running (Ctrl+S to save)".to_string()); + let session_id = match session_store::load_session()? { + Some(session_id) => session_id, + None => { + anyhow::bail!("Not logged in. Run `toki-tui login` to authenticate."); } - } - Ok(()) -} - -async fn handle_save_timer_with_action(app: &mut App, client: &mut ApiClient) -> Result<()> { - // Handle Cancel first - if app.selected_save_action == app::SaveAction::Cancel { - app.navigate_to(app::View::Timer); - return Ok(()); - } - - let duration = app.elapsed_duration(); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) }; - let project_display = app.current_project_name(); - let activity_display = app.current_activity_name(); - - // Save the active timer to Milltime - match client.save_timer(note.clone()).await { - Ok(()) => { - let hours = duration.as_secs() / 3600; - let minutes = (duration.as_secs() % 3600) / 60; - let seconds = duration.as_secs() % 60; - let duration_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds); - - // Refresh history - { - let today = time::OffsetDateTime::now_utc().date(); - let month_ago = today - time::Duration::days(30); - if let Ok(entries) = client.get_time_entries(month_ago, today).await { - app.update_history(entries); - app.rebuild_history_list(); - } - } - - match app.selected_save_action { - app::SaveAction::ContinueSameProject => { - app.description_input.clear(); - app.description_is_default = true; - // Start a new server-side timer with same project/activity - let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); - let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); - let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); - let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - if let Err(e) = client.start_timer(project_id, project_name, activity_id, activity_name, None).await { - app.set_status(format!("Saved but could not restart timer: {}", e)); - } else { - app.start_timer(); - app.set_status(format!("Saved {} to {} / {}", duration_str, project_display, activity_display)); - } - } - app::SaveAction::ContinueNewProject => { - app.selected_project = None; - app.selected_activity = None; - app.description_input.clear(); - app.description_is_default = true; - // Start new timer with no project yet - if let Err(e) = client.start_timer(None, None, None, None, None).await { - app.set_status(format!("Saved but could not restart timer: {}", e)); - } else { - app.start_timer(); - app.set_status(format!("Saved {}. Timer started. Press P to select project.", duration_str)); - } - } - app::SaveAction::SaveAndStop => { - app.timer_state = app::TimerState::Stopped; - app.absolute_start = None; - app.local_start = None; - if let Some(idx) = app.focused_this_week_index { - app.focused_this_week_index = if idx == 0 { None } else { Some(idx.saturating_sub(1)) }; - } - app.set_status(format!("Saved {} to {} / {}", duration_str, project_display, activity_display)); - } - app::SaveAction::Cancel => unreachable!(), - } - - app.navigate_to(app::View::Timer); - } - Err(e) => { - if is_milltime_auth_error(&e) { - app.open_milltime_reauth(); - } else { - app.set_status(format!("Error saving timer: {}", e)); - } - app.navigate_to(app::View::Timer); - } - } - - Ok(()) -} - -// Helper functions for edit mode - -/// Handle Enter key in edit mode - open modal for Project/Activity/Note or move to next field -fn handle_entry_edit_enter(app: &mut App) { - // Extract the data we need first to avoid borrow conflicts - let action = { - if let Some(state) = app.current_edit_state() { - match state.focused_field { - app::EntryEditField::Project => Some(('P', None)), - app::EntryEditField::Activity => { - if state.project_id.is_some() { - Some(('A', None)) - } else { - app.set_status("Please select a project first".to_string()); - None - } - } - app::EntryEditField::Note => { - let note = state.note.value.clone(); - Some(('N', Some(note))) - } - app::EntryEditField::StartTime | app::EntryEditField::EndTime => { - // Move to next field (like Tab) - app.entry_edit_next_field(); - None - } - } - } else { - None - } - }; + let mt_cookies = session_store::load_mt_cookies()?; + let mut client = ApiClient::new(&cfg.api_url, &session_id, mt_cookies)?; - // Now perform actions that don't require the borrow - if let Some((action, note)) = action { - match action { - 'P' => { - app.navigate_to(app::View::SelectProject); - } - 'A' => { - app.navigate_to(app::View::SelectActivity); - } - 'N' => { - // Save running timer's note before overwriting with entry's note - app.saved_timer_note = Some(app.description_input.value.clone()); - // Set description_input from the edit state before navigating - if let Some(n) = note { - app.description_input = TextInput::from_str(&n); - } - // Open description editor - app.navigate_to(app::View::EditDescription); - } - _ => {} - } - } -} + let me = client.me().await?; + println!("Logged in as {} ({})\n", me.full_name, me.email); -/// Save changes from This Week edit mode to database -async fn handle_this_week_edit_save(app: &mut App, client: &mut ApiClient) -> Result<()> { - // Running timer edits don't touch the DB - if app - .this_week_edit_state - .as_ref() - .map(|s| s.registration_id.is_empty()) - == Some(true) - { - handle_running_timer_edit_save(app, client).await; - return Ok(()); + if client.mt_cookies().is_empty() { + prompt_milltime_authentication(&mut client).await?; } - let Some(state) = app.this_week_edit_state.take() else { - return Ok(()); - }; - app.exit_this_week_edit_mode(); - if let Err(e) = handle_saved_entry_edit_save(state, app, client).await { - if is_milltime_auth_error(&e) { - app.open_milltime_reauth(); - } else { - app.set_status(format!("Error saving entry: {}", e)); - } - } - Ok(()) + run_ui(App::new(me.id, &cfg), client).await } -/// Apply edits from This Week edit mode back to the live running timer (no DB write). -/// Called when registration_id is empty (sentinel for the running timer). -async fn handle_running_timer_edit_save(app: &mut App, client: &mut ApiClient) { - let Some(state) = app.this_week_edit_state.take() else { - return; - }; - - // Parse start time input - let start_parts: Vec<&str> = state.start_time_input.split(':').collect(); - if start_parts.len() != 2 { - app.set_status("Error: Invalid time format".to_string()); - return; - } - let Ok(start_hours) = start_parts[0].parse::() else { - app.set_status("Error: Invalid start hour".to_string()); - return; - }; - let Ok(start_mins) = start_parts[1].parse::() else { - app.set_status("Error: Invalid start minute".to_string()); - return; - }; +async fn run_ui(mut app: App, mut client: ApiClient) -> Result<()> { + bootstrap::initialize_app_state(&mut app, &mut client).await; - // Build new absolute_start: today's local date + typed HH:MM, converted to UTC - let local_offset = time::UtcOffset::current_local_offset() - .unwrap_or(time::UtcOffset::UTC); - let today = time::OffsetDateTime::now_utc().to_offset(local_offset).date(); - let Ok(new_time) = time::Time::from_hms(start_hours, start_mins, 0) else { - app.set_status("Error: Invalid start time".to_string()); - return; + let result = { + let mut terminal = terminal::TerminalGuard::new()?; + runtime::run_app(terminal.terminal_mut(), &mut app, &mut client).await }; - let new_start = time::OffsetDateTime::new_in_offset(today, new_time, local_offset); - - // Reject if new start is in the future - if new_start > time::OffsetDateTime::now_utc() { - app.set_status("Error: Start time cannot be in the future".to_string()); - // Restore edit state so the user can correct it - app.this_week_edit_state = Some(state); - return; - } - // Write back to App fields - app.absolute_start = Some(new_start.to_offset(time::UtcOffset::UTC)); - - // Recalculate local_start so elapsed_duration() reflects the new start time - let now_utc = time::OffsetDateTime::now_utc(); - let elapsed_secs = (now_utc - new_start.to_offset(time::UtcOffset::UTC)) - .whole_seconds() - .max(0) as u64; - app.local_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(elapsed_secs)); - - app.selected_project = state.project_id.zip(state.project_name).map(|(id, name)| { - types::Project { id, name } - }); - app.selected_activity = state.activity_id.zip(state.activity_name).map(|(id, name)| { - types::Activity { id, name, project_id: String::new() } - }); - app.description_input = TextInput::from_str(&state.note.value); - - app.set_status("Running timer updated".to_string()); - - // Sync updated start time / project / activity / note to server - let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); - let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); - let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); - let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { None } else { Some(app.description_input.value.clone()) }; - if let Err(e) = client.update_active_timer( - project_id, project_name, activity_id, activity_name, - note, app.absolute_start, - ).await { - app.set_status(format!("Warning: Could not sync timer to server: {}", e)); + if let Err(err) = result { + eprintln!("Error: {:?}", err); } -} -/// Save changes from History edit mode to database -async fn handle_history_edit_save(app: &mut App, client: &mut ApiClient) -> Result<()> { - let Some(state) = app.history_edit_state.take() else { - return Ok(()); - }; - app.exit_history_edit_mode(); - if let Err(e) = handle_saved_entry_edit_save(state, app, client).await { - if is_milltime_auth_error(&e) { - app.open_milltime_reauth(); - } else { - app.set_status(format!("Error saving entry: {}", e)); - } - } + println!("\nGoodbye!"); Ok(()) } -/// Shared helper: save edits to a completed (non-running) timer history entry via the API. -async fn handle_saved_entry_edit_save( - state: app::EntryEditState, - app: &mut App, - client: &mut ApiClient, -) -> Result<()> { - // Look up the original entry from history - let entry = match app - .time_entries - .iter() - .find(|e| e.registration_id == state.registration_id) - { - Some(e) => e.clone(), - None => { - app.set_status("Error: Entry not found in history".to_string()); - return Ok(()); - } - }; - - // registration_id is always present on TimeEntry - let registration_id = entry.registration_id.clone(); - - // Parse start / end times (HH:MM) on the entry's original local date - let local_offset = time::UtcOffset::current_local_offset() - .unwrap_or(time::UtcOffset::UTC); - - // Parse entry.date ("YYYY-MM-DD") to get the calendar date - let entry_date = app::parse_date_str(&entry.date) - .ok_or_else(|| anyhow::anyhow!("Unexpected date format: {}", entry.date))?; - - let parse_hhmm = |s: &str| -> Result { - let parts: Vec<&str> = s.split(':').collect(); - anyhow::ensure!(parts.len() == 2, "Expected HH:MM format, got {:?}", s); - let h: u8 = parts[0].parse().context("Invalid hour")?; - let m: u8 = parts[1].parse().context("Invalid minute")?; - time::Time::from_hms(h, m, 0).map_err(|e| anyhow::anyhow!("Invalid time: {}", e)) - }; - - let start_local = time::OffsetDateTime::new_in_offset( - entry_date, - parse_hhmm(&state.start_time_input)?, - local_offset, - ); - let end_local = time::OffsetDateTime::new_in_offset( - entry_date, - parse_hhmm(&state.end_time_input)?, - local_offset, - ); - - anyhow::ensure!(end_local > start_local, "End time must be after start time"); +async fn prompt_milltime_authentication(client: &mut ApiClient) -> Result<()> { + println!("Milltime credentials required."); + print!("Username: "); + std::io::stdout().flush()?; - // Compute reg_day and week_number from the entry date - let reg_day = format!( - "{:04}-{:02}-{:02}", - entry_date.year(), - entry_date.month() as u8, - entry_date.day() - ); - let week_number = entry_date.iso_week() as i32; - - // Determine delta fields (only set if project/activity changed) - let original_project_id = if state.project_id.as_deref() != Some(entry.project_id.as_str()) { - Some(entry.project_id.as_str()) - } else { - None - }; - let original_activity_id = - if state.activity_id.as_deref() != Some(entry.activity_id.as_str()) { - Some(entry.activity_id.as_str()) - } else { - None - }; + let mut username = String::new(); + std::io::stdin().read_line(&mut username)?; + let username = username.trim().to_string(); - let project_id = state.project_id.as_deref().unwrap_or(""); - let project_name = state.project_name.as_deref().unwrap_or(""); - let activity_id = state.activity_id.as_deref().unwrap_or(""); - let activity_name = state.activity_name.as_deref().unwrap_or(""); - let user_note = &state.note.value; + let password = rpassword::prompt_password("Password: ")?; - client - .edit_time_entry( - ®istration_id, - project_id, - project_name, - activity_id, - activity_name, - start_local.to_offset(time::UtcOffset::UTC), - end_local.to_offset(time::UtcOffset::UTC), - ®_day, - week_number, - user_note, - original_project_id, - original_activity_id, - ) - .await?; + print!("Authenticating..."); + std::io::stdout().flush()?; + client.authenticate(&username, &password).await?; + println!(" OK"); - // Reload history to reflect the changes - { - let today = time::OffsetDateTime::now_utc().date(); - let month_ago = today - time::Duration::days(30); - match client.get_time_entries(month_ago, today).await { - Ok(entries) => { - app.update_history(entries); - app.rebuild_history_list(); - } - Err(e) => { - app.set_status(format!( - "Entry updated (warning: could not reload history: {})", - e - )); - return Ok(()); - } - } - } - - app.set_status("Entry updated".to_string()); Ok(()) } - -/// Attempt Milltime re-authentication with the credentials from the overlay. -/// On success: updates cookies and closes the overlay. -/// On failure: sets the error message on the overlay so the user can retry. -async fn handle_milltime_reauth_submit(app: &mut App, client: &mut ApiClient) { - let (username, password) = match app.milltime_reauth_credentials() { - Some(creds) => creds, - None => return, - }; - if username.is_empty() { - app.milltime_reauth_set_error("Username is required".to_string()); - return; - } - match client.authenticate(&username, &password).await { - Ok(cookies) => { - client.update_mt_cookies(cookies); - if let Err(e) = config::TokiConfig::save_mt_cookies(client.mt_cookies()) { - app.milltime_reauth_set_error(format!("Authenticated but could not save cookies: {}", e)); - return; - } - app.close_milltime_reauth(); - app.set_status("Milltime re-authenticated successfully".to_string()); - } - Err(e) => { - app.milltime_reauth_set_error(format!("Authentication failed: {}", e)); - } - } -} - -/// Returns true when an error looks like a Milltime authentication failure. -/// Used to decide whether to open the re-auth overlay. -fn is_milltime_auth_error(e: &anyhow::Error) -> bool { - let msg = e.to_string().to_lowercase(); - msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") -} diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs new file mode 100644 index 00000000..c52fc62a --- /dev/null +++ b/toki-tui/src/runtime/action_queue.rs @@ -0,0 +1,35 @@ +use crate::types::{Activity, Project}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +#[derive(Debug, Clone)] +pub(super) enum Action { + SubmitMilltimeReauth, + ApplyProjectSelection { + had_edit_state: bool, + saved_selected_project: Option, + saved_selected_activity: Option, + }, + ApplyActivitySelection { + was_in_edit_mode: bool, + saved_selected_project: Option, + saved_selected_activity: Option, + }, + StartTimer, + SaveTimer, + SyncRunningTimerNote { + note: String, + }, + SaveHistoryEdit, + SaveThisWeekEdit, + LoadHistoryAndOpen, + ConfirmDelete, + StopServerTimerAndClear, + RefreshHistoryBackground, +} + +pub(super) type ActionTx = UnboundedSender; +pub(super) type ActionRx = UnboundedReceiver; + +pub(super) fn channel() -> (ActionTx, ActionRx) { + mpsc::unbounded_channel() +} diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs new file mode 100644 index 00000000..daf54be0 --- /dev/null +++ b/toki-tui/src/runtime/actions.rs @@ -0,0 +1,741 @@ +use crate::api::ApiClient; +use crate::app::{self, App, TextInput}; +use crate::types; +use anyhow::{Context, Result}; +use std::time::{Duration, Instant}; + +use super::action_queue::Action; + +/// Apply an active timer fetched from the server into App state. +pub(crate) fn restore_active_timer(app: &mut App, timer: crate::types::ActiveTimerState) { + let elapsed_secs = (timer.hours * 3600 + timer.minutes * 60 + timer.seconds) as u64; + app.absolute_start = Some(timer.start_time); + app.local_start = Some(Instant::now() - Duration::from_secs(elapsed_secs)); + app.timer_state = app::TimerState::Running; + if let (Some(id), Some(name)) = (timer.project_id, timer.project_name) { + app.selected_project = Some(crate::types::Project { id, name }); + } + if let (Some(id), Some(name)) = (timer.activity_id, timer.activity_name) { + app.selected_activity = Some(crate::types::Activity { + id, + name, + project_id: app + .selected_project + .as_ref() + .map(|p| p.id.clone()) + .unwrap_or_default(), + }); + } + if !timer.note.is_empty() { + app.description_input = app::TextInput::from_str(&timer.note); + app.description_is_default = false; + } +} + +pub(super) async fn run_action( + action: Action, + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + match action { + Action::SubmitMilltimeReauth => { + handle_milltime_reauth_submit(app, client).await; + } + Action::ApplyProjectSelection { + had_edit_state, + saved_selected_project, + saved_selected_activity, + } => { + handle_project_selection_enter( + app, + client, + had_edit_state, + saved_selected_project, + saved_selected_activity, + ) + .await; + } + Action::ApplyActivitySelection { + was_in_edit_mode, + saved_selected_project, + saved_selected_activity, + } => { + handle_activity_selection_enter( + app, + client, + was_in_edit_mode, + saved_selected_project, + saved_selected_activity, + ) + .await; + } + Action::StartTimer => { + handle_start_timer(app, client).await?; + } + Action::SaveTimer => { + handle_save_timer_with_action(app, client).await?; + } + Action::SyncRunningTimerNote { note } => { + sync_running_timer_note(note, app, client).await; + } + Action::SaveHistoryEdit => { + handle_history_edit_save(app, client).await?; + } + Action::SaveThisWeekEdit => { + handle_this_week_edit_save(app, client).await?; + } + Action::LoadHistoryAndOpen => { + load_history_and_open(app, client).await; + } + Action::ConfirmDelete => { + handle_confirm_delete(app, client).await; + } + Action::StopServerTimerAndClear => { + stop_server_timer_and_clear(app, client).await; + } + Action::RefreshHistoryBackground => { + refresh_history_background(app, client).await; + } + } + Ok(()) +} + +pub(super) async fn handle_start_timer(app: &mut App, client: &mut ApiClient) -> Result<()> { + match app.timer_state { + app::TimerState::Stopped => { + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); + let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); + let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); + let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); + let note = if app.description_input.value.is_empty() { + None + } else { + Some(app.description_input.value.clone()) + }; + if let Err(e) = client + .start_timer(project_id, project_name, activity_id, activity_name, note) + .await + { + if is_milltime_auth_error(&e) { + app.open_milltime_reauth(); + } else { + app.set_status(format!("Error starting timer: {}", e)); + } + return Ok(()); + } + app.start_timer(); + app.clear_status(); + } + app::TimerState::Running => { + app.set_status("Timer already running (Ctrl+S to save)".to_string()); + } + } + Ok(()) +} + +async fn handle_project_selection_enter( + app: &mut App, + client: &mut ApiClient, + had_edit_state: bool, + saved_selected_project: Option, + saved_selected_activity: Option, +) { + // Fetch activities for the selected project (lazy, cached). + if let Some(project) = app.selected_project.clone() { + if !app.activity_cache.contains_key(&project.id) { + app.is_loading = true; + match client.get_activities(&project.id).await { + Ok(activities) => { + app.activity_cache.insert(project.id.clone(), activities); + } + Err(e) => { + app.set_status(format!("Failed to load activities: {}", e)); + } + } + app.is_loading = false; + } + + if let Some(cached) = app.activity_cache.get(&project.id) { + app.activities = cached.clone(); + app.filtered_activities = cached.clone(); + app.filtered_activity_index = 0; + } + } + + if had_edit_state { + if let Some(project) = app.selected_project.clone() { + app.update_edit_state_project(project.id.clone(), project.name.clone()); + } + app.pending_edit_selection_restore = + Some((saved_selected_project, saved_selected_activity)); + } + + app.navigate_to(app::View::SelectActivity); +} + +async fn handle_activity_selection_enter( + app: &mut App, + client: &mut ApiClient, + was_in_edit_mode: bool, + saved_selected_project: Option, + saved_selected_activity: Option, +) { + if was_in_edit_mode { + if let Some(activity) = app.selected_activity.clone() { + app.update_edit_state_activity(activity.id.clone(), activity.name.clone()); + } + let (restore_project, restore_activity) = app + .pending_edit_selection_restore + .take() + .unwrap_or((saved_selected_project, saved_selected_activity)); + app.selected_project = restore_project; + app.selected_activity = restore_activity; + let return_view = app.get_return_view_from_edit(); + app.navigate_to(return_view); + app.focused_box = app::FocusedBox::Today; + app.entry_edit_set_focused_field(app::EntryEditField::Activity); + return; + } + + app.pending_edit_selection_restore = None; + + if app.timer_state == app::TimerState::Running { + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); + let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); + let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); + let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); + if let Err(e) = client + .update_active_timer( + project_id, + project_name, + activity_id, + activity_name, + None, + None, + ) + .await + { + app.set_status(format!("Warning: Could not sync project to server: {}", e)); + } + } +} + +fn apply_recent_history(app: &mut App, entries: Vec) { + app.update_history(entries); + app.rebuild_history_list(); +} + +async fn fetch_recent_history(client: &mut ApiClient) -> Result> { + let today = time::OffsetDateTime::now_utc().date(); + let month_ago = today - time::Duration::days(30); + client.get_time_entries(month_ago, today).await +} + +async fn sync_running_timer_note(note: String, app: &mut App, client: &mut ApiClient) { + if app.timer_state != app::TimerState::Running { + return; + } + + if let Err(e) = client + .update_active_timer(None, None, None, None, Some(note), None) + .await + { + app.set_status(format!("Warning: Could not sync note to server: {}", e)); + } +} + +async fn load_history_and_open(app: &mut App, client: &mut ApiClient) { + match fetch_recent_history(client).await { + Ok(entries) => { + apply_recent_history(app, entries); + app.navigate_to(app::View::History); + } + Err(e) => { + app.set_status(format!("Error loading history: {}", e)); + } + } +} + +async fn handle_confirm_delete(app: &mut App, client: &mut ApiClient) { + if let Some(ctx) = app.delete_context.take() { + let origin = ctx.origin; + match client.delete_time_entry(&ctx.registration_id).await { + Ok(()) => { + app.time_entries + .retain(|e| e.registration_id != ctx.registration_id); + app.rebuild_history_list(); + app.set_status("Entry deleted".to_string()); + } + Err(e) => { + app.set_status(format!("Delete failed: {}", e)); + } + } + match origin { + app::DeleteOrigin::Timer => app.navigate_to(app::View::Timer), + app::DeleteOrigin::History => app.navigate_to(app::View::History), + } + } +} + +async fn stop_server_timer_and_clear(app: &mut App, client: &mut ApiClient) { + if app.timer_state == app::TimerState::Running { + if let Err(e) = client.stop_timer().await { + app.set_status(format!("Warning: Could not stop server timer: {}", e)); + } + } + app.clear_timer(); +} + +async fn refresh_history_background(app: &mut App, client: &mut ApiClient) { + match fetch_recent_history(client).await { + Ok(entries) => { + apply_recent_history(app, entries); + } + Err(e) if is_milltime_auth_error(&e) => { + app.open_milltime_reauth(); + } + Err(_) => {} + } +} + +pub(super) async fn handle_save_timer_with_action( + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + // Handle Cancel first + if app.selected_save_action == app::SaveAction::Cancel { + app.navigate_to(app::View::Timer); + return Ok(()); + } + + let duration = app.elapsed_duration(); + let note = if app.description_input.value.is_empty() { + None + } else { + Some(app.description_input.value.clone()) + }; + + let project_display = app.current_project_name(); + let activity_display = app.current_activity_name(); + + // Save the active timer to Milltime + match client.save_timer(note.clone()).await { + Ok(()) => { + let hours = duration.as_secs() / 3600; + let minutes = (duration.as_secs() % 3600) / 60; + let seconds = duration.as_secs() % 60; + let duration_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds); + + // Refresh history + if let Ok(entries) = fetch_recent_history(client).await { + apply_recent_history(app, entries); + } + + match app.selected_save_action { + app::SaveAction::ContinueSameProject => { + app.description_input.clear(); + app.description_is_default = true; + // Start a new server-side timer with same project/activity + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); + let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); + let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); + let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); + if let Err(e) = client + .start_timer(project_id, project_name, activity_id, activity_name, None) + .await + { + app.set_status(format!("Saved but could not restart timer: {}", e)); + } else { + app.start_timer(); + app.set_status(format!( + "Saved {} to {} / {}", + duration_str, project_display, activity_display + )); + } + } + app::SaveAction::ContinueNewProject => { + app.selected_project = None; + app.selected_activity = None; + app.description_input.clear(); + app.description_is_default = true; + // Start new timer with no project yet + if let Err(e) = client.start_timer(None, None, None, None, None).await { + app.set_status(format!("Saved but could not restart timer: {}", e)); + } else { + app.start_timer(); + app.set_status(format!( + "Saved {}. Timer started. Press P to select project.", + duration_str + )); + } + } + app::SaveAction::SaveAndStop => { + app.timer_state = app::TimerState::Stopped; + app.absolute_start = None; + app.local_start = None; + if let Some(idx) = app.focused_this_week_index { + app.focused_this_week_index = if idx == 0 { + None + } else { + Some(idx.saturating_sub(1)) + }; + } + app.set_status(format!( + "Saved {} to {} / {}", + duration_str, project_display, activity_display + )); + } + app::SaveAction::Cancel => unreachable!(), + } + + app.navigate_to(app::View::Timer); + } + Err(e) => { + if is_milltime_auth_error(&e) { + app.open_milltime_reauth(); + } else { + app.set_status(format!("Error saving timer: {}", e)); + } + app.navigate_to(app::View::Timer); + } + } + + Ok(()) +} + +// Helper functions for edit mode + +/// Handle Enter key in edit mode - open modal for Project/Activity/Note or move to next field +pub(super) fn handle_entry_edit_enter(app: &mut App) { + // Extract the data we need first to avoid borrow conflicts + let action = { + if let Some(state) = app.current_edit_state() { + match state.focused_field { + app::EntryEditField::Project => Some(('P', None)), + app::EntryEditField::Activity => { + if state.project_id.is_some() { + Some(('A', None)) + } else { + app.set_status("Please select a project first".to_string()); + None + } + } + app::EntryEditField::Note => { + let note = state.note.value.clone(); + Some(('N', Some(note))) + } + app::EntryEditField::StartTime | app::EntryEditField::EndTime => { + // Move to next field (like Tab) + app.entry_edit_next_field(); + None + } + } + } else { + None + } + }; + + // Now perform actions that don't require the borrow + if let Some((action, note)) = action { + match action { + 'P' => { + app.navigate_to(app::View::SelectProject); + } + 'A' => { + app.navigate_to(app::View::SelectActivity); + } + 'N' => { + // Save running timer's note before overwriting with entry's note + app.saved_timer_note = Some(app.description_input.value.clone()); + // Set description_input from the edit state before navigating + if let Some(n) = note { + app.description_input = TextInput::from_str(&n); + } + // Open description editor + app.navigate_to(app::View::EditDescription); + } + _ => {} + } + } +} + +/// Save changes from This Week edit mode to database +pub(super) async fn handle_this_week_edit_save( + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + // Running timer edits don't touch the DB + if app + .this_week_edit_state + .as_ref() + .map(|s| s.registration_id.is_empty()) + == Some(true) + { + handle_running_timer_edit_save(app, client).await; + return Ok(()); + } + + let Some(state) = app.this_week_edit_state.take() else { + return Ok(()); + }; + app.exit_this_week_edit_mode(); + if let Err(e) = handle_saved_entry_edit_save(state, app, client).await { + if is_milltime_auth_error(&e) { + app.open_milltime_reauth(); + } else { + app.set_status(format!("Error saving entry: {}", e)); + } + } + Ok(()) +} + +/// Apply edits from This Week edit mode back to the live running timer (no DB write). +/// Called when registration_id is empty (sentinel for the running timer). +async fn handle_running_timer_edit_save(app: &mut App, client: &mut ApiClient) { + let Some(state) = app.this_week_edit_state.take() else { + return; + }; + + // Parse start time input + let start_parts: Vec<&str> = state.start_time_input.split(':').collect(); + if start_parts.len() != 2 { + app.set_status("Error: Invalid time format".to_string()); + return; + } + let Ok(start_hours) = start_parts[0].parse::() else { + app.set_status("Error: Invalid start hour".to_string()); + return; + }; + let Ok(start_mins) = start_parts[1].parse::() else { + app.set_status("Error: Invalid start minute".to_string()); + return; + }; + + // Build new absolute_start: today's local date + typed HH:MM, converted to UTC + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let today = time::OffsetDateTime::now_utc() + .to_offset(local_offset) + .date(); + let Ok(new_time) = time::Time::from_hms(start_hours, start_mins, 0) else { + app.set_status("Error: Invalid start time".to_string()); + return; + }; + let new_start = time::OffsetDateTime::new_in_offset(today, new_time, local_offset); + + // Reject if new start is in the future + if new_start > time::OffsetDateTime::now_utc() { + app.set_status("Error: Start time cannot be in the future".to_string()); + // Restore edit state so the user can correct it + app.this_week_edit_state = Some(state); + return; + } + + // Write back to App fields + app.absolute_start = Some(new_start.to_offset(time::UtcOffset::UTC)); + + // Recalculate local_start so elapsed_duration() reflects the new start time + let now_utc = time::OffsetDateTime::now_utc(); + let elapsed_secs = (now_utc - new_start.to_offset(time::UtcOffset::UTC)) + .whole_seconds() + .max(0) as u64; + app.local_start = + Some(std::time::Instant::now() - std::time::Duration::from_secs(elapsed_secs)); + + app.selected_project = state + .project_id + .zip(state.project_name) + .map(|(id, name)| types::Project { id, name }); + app.selected_activity = state + .activity_id + .zip(state.activity_name) + .map(|(id, name)| types::Activity { + id, + name, + project_id: String::new(), + }); + app.description_input = TextInput::from_str(&state.note.value); + + app.set_status("Running timer updated".to_string()); + + // Sync updated start time / project / activity / note to server + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); + let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); + let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); + let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); + let note = if app.description_input.value.is_empty() { + None + } else { + Some(app.description_input.value.clone()) + }; + if let Err(e) = client + .update_active_timer( + project_id, + project_name, + activity_id, + activity_name, + note, + app.absolute_start, + ) + .await + { + app.set_status(format!("Warning: Could not sync timer to server: {}", e)); + } +} + +/// Save changes from History edit mode to database +pub(super) async fn handle_history_edit_save(app: &mut App, client: &mut ApiClient) -> Result<()> { + let Some(state) = app.history_edit_state.take() else { + return Ok(()); + }; + app.exit_history_edit_mode(); + if let Err(e) = handle_saved_entry_edit_save(state, app, client).await { + if is_milltime_auth_error(&e) { + app.open_milltime_reauth(); + } else { + app.set_status(format!("Error saving entry: {}", e)); + } + } + Ok(()) +} + +/// Shared helper: save edits to a completed (non-running) timer history entry via the API. +async fn handle_saved_entry_edit_save( + state: app::EntryEditState, + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + // Look up the original entry from history + let entry = match app + .time_entries + .iter() + .find(|e| e.registration_id == state.registration_id) + { + Some(e) => e.clone(), + None => { + app.set_status("Error: Entry not found in history".to_string()); + return Ok(()); + } + }; + + // registration_id is always present on TimeEntry + let registration_id = entry.registration_id.clone(); + + // Parse start / end times (HH:MM) on the entry's original local date + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + + // Parse entry.date ("YYYY-MM-DD") to get the calendar date + let entry_date = app::parse_date_str(&entry.date) + .ok_or_else(|| anyhow::anyhow!("Unexpected date format: {}", entry.date))?; + + let parse_hhmm = |s: &str| -> Result { + let parts: Vec<&str> = s.split(':').collect(); + anyhow::ensure!(parts.len() == 2, "Expected HH:MM format, got {:?}", s); + let h: u8 = parts[0].parse().context("Invalid hour")?; + let m: u8 = parts[1].parse().context("Invalid minute")?; + time::Time::from_hms(h, m, 0).map_err(|e| anyhow::anyhow!("Invalid time: {}", e)) + }; + + let start_local = time::OffsetDateTime::new_in_offset( + entry_date, + parse_hhmm(&state.start_time_input)?, + local_offset, + ); + let end_local = time::OffsetDateTime::new_in_offset( + entry_date, + parse_hhmm(&state.end_time_input)?, + local_offset, + ); + + anyhow::ensure!(end_local > start_local, "End time must be after start time"); + + // Compute reg_day and week_number from the entry date + let reg_day = format!( + "{:04}-{:02}-{:02}", + entry_date.year(), + entry_date.month() as u8, + entry_date.day() + ); + let week_number = entry_date.iso_week() as i32; + + // Determine delta fields (only set if project/activity changed) + let original_project_id = if state.project_id.as_deref() != Some(entry.project_id.as_str()) { + Some(entry.project_id.as_str()) + } else { + None + }; + let original_activity_id = if state.activity_id.as_deref() != Some(entry.activity_id.as_str()) { + Some(entry.activity_id.as_str()) + } else { + None + }; + + let project_id = state.project_id.as_deref().unwrap_or(""); + let project_name = state.project_name.as_deref().unwrap_or(""); + let activity_id = state.activity_id.as_deref().unwrap_or(""); + let activity_name = state.activity_name.as_deref().unwrap_or(""); + let user_note = &state.note.value; + + client + .edit_time_entry( + ®istration_id, + project_id, + project_name, + activity_id, + activity_name, + start_local.to_offset(time::UtcOffset::UTC), + end_local.to_offset(time::UtcOffset::UTC), + ®_day, + week_number, + user_note, + original_project_id, + original_activity_id, + ) + .await?; + + // Reload history to reflect the changes + match fetch_recent_history(client).await { + Ok(entries) => { + apply_recent_history(app, entries); + } + Err(e) => { + app.set_status(format!( + "Entry updated (warning: could not reload history: {})", + e + )); + return Ok(()); + } + } + + app.set_status("Entry updated".to_string()); + Ok(()) +} + +/// Attempt Milltime re-authentication with the credentials from the overlay. +/// On success: updates cookies and closes the overlay. +/// On failure: sets the error message on the overlay so the user can retry. +pub(super) async fn handle_milltime_reauth_submit(app: &mut App, client: &mut ApiClient) { + let (username, password) = match app.milltime_reauth_credentials() { + Some(creds) => creds, + None => return, + }; + if username.is_empty() { + app.milltime_reauth_set_error("Username is required".to_string()); + return; + } + match client.authenticate(&username, &password).await { + Ok(()) => { + app.close_milltime_reauth(); + app.set_status("Milltime re-authenticated successfully".to_string()); + } + Err(e) => { + app.milltime_reauth_set_error(format!("Authentication failed: {}", e)); + } + } +} + +/// Returns true when an error looks like a Milltime authentication failure. +/// Used to decide whether to open the re-auth overlay. +pub(super) fn is_milltime_auth_error(e: &anyhow::Error) -> bool { + let msg = e.to_string().to_lowercase(); + msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") +} diff --git a/toki-tui/src/runtime/event_loop.rs b/toki-tui/src/runtime/event_loop.rs new file mode 100644 index 00000000..4e86b853 --- /dev/null +++ b/toki-tui/src/runtime/event_loop.rs @@ -0,0 +1,64 @@ +use crate::api::ApiClient; +use crate::app::App; +use crate::ui; +use anyhow::Result; +use crossterm::event::{self, Event}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; +use std::time::{Duration, Instant}; + +use super::action_queue::{channel, Action}; +use super::actions::run_action; +use super::views::{handle_milltime_reauth_key, handle_view_key}; + +pub async fn run_app( + terminal: &mut Terminal>, + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + // Show throbber for at least 3 seconds on startup. + app.is_loading = true; + let loading_until = Instant::now() + Duration::from_secs(3); + + // Background polling: refresh time entries every 60 seconds. + let mut last_history_refresh = Instant::now(); + const HISTORY_REFRESH_INTERVAL: Duration = Duration::from_secs(60); + + let (action_tx, mut action_rx) = channel(); + + loop { + terminal.draw(|f| ui::render(f, app))?; + + if app.is_loading { + app.throbber_state.calc_next(); + if Instant::now() >= loading_until { + app.is_loading = false; + } + } + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if app.milltime_reauth.is_some() { + handle_milltime_reauth_key(key, app, &action_tx); + } else { + handle_view_key(key, app, &action_tx); + } + } + } + + if last_history_refresh.elapsed() >= HISTORY_REFRESH_INTERVAL && !app.is_in_edit_mode() { + let _ = action_tx.send(Action::RefreshHistoryBackground); + last_history_refresh = Instant::now(); + } + + while let Ok(action) = action_rx.try_recv() { + run_action(action, app, client).await?; + } + + if !app.running { + break; + } + } + + Ok(()) +} diff --git a/toki-tui/src/runtime/mod.rs b/toki-tui/src/runtime/mod.rs new file mode 100644 index 00000000..f0687ad9 --- /dev/null +++ b/toki-tui/src/runtime/mod.rs @@ -0,0 +1,7 @@ +mod action_queue; +mod actions; +mod event_loop; +mod views; + +pub(crate) use actions::restore_active_timer; +pub use event_loop::run_app; diff --git a/toki-tui/src/runtime/views.rs b/toki-tui/src/runtime/views.rs new file mode 100644 index 00000000..72ece672 --- /dev/null +++ b/toki-tui/src/runtime/views.rs @@ -0,0 +1,53 @@ +use crate::app::{self, App}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::action_queue::{Action, ActionTx}; + +mod confirm_delete; +mod edit_description; +mod history; +mod save_action; +mod selection; +mod statistics; +mod timer; + +fn enqueue_action(action_tx: &ActionTx, action: Action) { + let _ = action_tx.send(action); +} + +pub(super) fn handle_milltime_reauth_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + match key.code { + KeyCode::Tab | KeyCode::BackTab => { + app.milltime_reauth_next_field(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.milltime_reauth_input_char(c); + } + KeyCode::Backspace => { + app.milltime_reauth_backspace(); + } + KeyCode::Enter => { + enqueue_action(action_tx, Action::SubmitMilltimeReauth); + } + KeyCode::Esc => { + app.close_milltime_reauth(); + app.set_status("Milltime re-authentication cancelled".to_string()); + } + _ => {} + } +} + +pub(super) fn handle_view_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + match &app.current_view { + app::View::SelectProject => selection::handle_select_project_key(key, app, action_tx), + app::View::SelectActivity => selection::handle_select_activity_key(key, app, action_tx), + app::View::EditDescription => { + edit_description::handle_edit_description_key(key, app, action_tx) + } + app::View::SaveAction => save_action::handle_save_action_key(key, app, action_tx), + app::View::History => history::handle_history_key(key, app, action_tx), + app::View::Statistics => statistics::handle_statistics_key(key, app), + app::View::ConfirmDelete => confirm_delete::handle_confirm_delete_key(key, app, action_tx), + app::View::Timer => timer::handle_timer_key(key, app, action_tx), + } +} diff --git a/toki-tui/src/runtime/views/confirm_delete.rs b/toki-tui/src/runtime/views/confirm_delete.rs new file mode 100644 index 00000000..041efcda --- /dev/null +++ b/toki-tui/src/runtime/views/confirm_delete.rs @@ -0,0 +1,22 @@ +use crate::app::{self, App}; +use crossterm::event::{KeyCode, KeyEvent}; + +use super::super::action_queue::{Action, ActionTx}; +use super::enqueue_action; + +pub(super) fn handle_confirm_delete_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + enqueue_action(action_tx, Action::ConfirmDelete); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + let origin = app.delete_context.as_ref().map(|c| c.origin); + app.delete_context = None; + match origin { + Some(app::DeleteOrigin::Timer) | None => app.navigate_to(app::View::Timer), + Some(app::DeleteOrigin::History) => app.navigate_to(app::View::History), + } + } + _ => {} + } +} diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs new file mode 100644 index 00000000..f2872ce3 --- /dev/null +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -0,0 +1,206 @@ +use crate::app::{self, App, TextInput}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::action_queue::{Action, ActionTx}; +use super::enqueue_action; + +pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + let was_in_edit_mode = app.is_in_edit_mode(); + + // CWD change mode takes priority. + if app.cwd_input.is_some() { + match key.code { + KeyCode::Esc => app.cancel_cwd_change(), + KeyCode::Enter => { + if let Err(e) = app.confirm_cwd_change() { + app.status_message = Some(e); + } + } + KeyCode::Tab => app.cwd_tab_complete(), + KeyCode::Backspace => app.cwd_input_backspace(), + KeyCode::Left => app.cwd_move_cursor(true), + KeyCode::Right => app.cwd_move_cursor(false), + KeyCode::Home => app.cwd_cursor_home_end(true), + KeyCode::End => app.cwd_cursor_home_end(false), + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.cwd_input_char(c); + } + _ => {} + } + } else if app.taskwarrior_overlay.is_some() { + match key.code { + KeyCode::Esc => app.close_taskwarrior_overlay(), + KeyCode::Char('t') | KeyCode::Char('T') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.close_taskwarrior_overlay(); + } + KeyCode::Down | KeyCode::Char('j') => { + app.taskwarrior_move(true); + } + KeyCode::Up | KeyCode::Char('k') => { + app.taskwarrior_move(false); + } + KeyCode::Enter => app.taskwarrior_confirm(), + _ => {} + } + } else if app.git_mode { + // Second key of Ctrl+G sequence. + match key.code { + KeyCode::Char('b') | KeyCode::Char('B') => app.paste_git_branch_raw(), + KeyCode::Char('p') | KeyCode::Char('P') => app.paste_git_branch_parsed(), + KeyCode::Char('c') | KeyCode::Char('C') => app.paste_git_last_commit(), + _ => app.exit_git_mode(), // any other key cancels git mode + } + } else { + match key.code { + KeyCode::Char('x') | KeyCode::Char('X') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.description_input.clear(); + } + KeyCode::Char('g') | KeyCode::Char('G') + if key.modifiers.contains(KeyModifiers::CONTROL) + && app.git_context.branch.is_some() => + { + app.enter_git_mode(); + } + KeyCode::Char('d') | KeyCode::Char('D') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.begin_cwd_change(); + } + KeyCode::Char('t') | KeyCode::Char('T') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.open_taskwarrior_overlay(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.input_char(c); + } + KeyCode::Backspace => app.input_backspace(), + KeyCode::Left => app.input_move_cursor(true), + KeyCode::Right => app.input_move_cursor(false), + KeyCode::Home => app.input_cursor_home_end(true), + KeyCode::End => app.input_cursor_home_end(false), + KeyCode::Enter => { + if was_in_edit_mode { + app.update_edit_state_note(app.description_input.value.clone()); + if let Some(saved_note) = app.saved_timer_note.take() { + app.description_input = TextInput::from_str(&saved_note); + } + let return_view = app.get_return_view_from_edit(); + app.navigate_to(return_view); + if return_view == app::View::Timer { + app.focused_box = app::FocusedBox::Today; + } + } else { + let should_sync_running_note = app.timer_state == app::TimerState::Running; + let note = app.description_input.value.clone(); + app.confirm_description(); + if should_sync_running_note { + enqueue_action(action_tx, Action::SyncRunningTimerNote { note }); + } + } + } + KeyCode::Esc => { + if was_in_edit_mode { + if let Some(saved_note) = app.saved_timer_note.take() { + app.description_input = TextInput::from_str(&saved_note); + } + let return_view = app.get_return_view_from_edit(); + app.navigate_to(return_view); + if return_view == app::View::Timer { + app.focused_box = app::FocusedBox::Today; + } + } else { + app.cancel_selection(); + } + } + KeyCode::Char('q') | KeyCode::Char('Q') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.quit(); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{EntryEditField, EntryEditState, TimerState, View}; + use crate::config::TokiConfig; + use crossterm::event::{KeyEvent, KeyModifiers}; + + use super::super::super::action_queue::channel; + + fn enter_key() -> KeyEvent { + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) + } + + fn test_app() -> App { + let mut app = App::new(1, &TokiConfig::default()); + app.current_view = View::EditDescription; + app.editing_description = true; + app.description_is_default = false; + app + } + + fn trigger_enter(app: &mut App) -> Option { + let (tx, mut rx) = channel(); + handle_edit_description_key(enter_key(), app, &tx); + rx.try_recv().ok() + } + + fn assert_sync_action(action: Option, expected_note: &str) { + match action { + Some(Action::SyncRunningTimerNote { note }) => assert_eq!(note, expected_note), + Some(other) => panic!("unexpected action: {other:?}"), + None => panic!("expected queued action"), + } + } + + #[test] + fn enter_syncs_running_timer_note_clear() { + let mut app = test_app(); + app.timer_state = TimerState::Running; + app.description_input = TextInput::from_str(""); + + assert_sync_action(trigger_enter(&mut app), ""); + } + + #[test] + fn enter_in_edit_mode_updates_edit_state_without_sync() { + let mut app = test_app(); + app.timer_state = TimerState::Running; + app.saved_timer_note = Some("original running note".to_string()); + app.description_input = TextInput::from_str("entry note"); + app.this_week_edit_state = Some(EntryEditState { + registration_id: "reg-1".to_string(), + start_time_input: "09:00".to_string(), + end_time_input: "10:00".to_string(), + original_start_time: "09:00".to_string(), + original_end_time: "10:00".to_string(), + project_id: None, + project_name: None, + activity_id: None, + activity_name: None, + note: TextInput::from_str("before"), + focused_field: EntryEditField::Note, + validation_error: None, + }); + let action = trigger_enter(&mut app); + + let note = &app + .this_week_edit_state + .as_ref() + .expect("edit state should exist") + .note + .value; + assert_eq!(note, "entry note"); + assert_eq!(app.description_input.value, "original running note"); + assert!(action.is_none(), "expected no queued action"); + } +} diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs new file mode 100644 index 00000000..ffa0b4a6 --- /dev/null +++ b/toki-tui/src/runtime/views/history.rs @@ -0,0 +1,124 @@ +use crate::app::{self, App}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::action_queue::{Action, ActionTx}; +use super::super::actions::handle_entry_edit_enter; +use super::enqueue_action; + +pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + // Check if we're in edit mode. + if app.history_edit_state.is_some() { + match key.code { + KeyCode::Tab => { + app.entry_edit_next_field(); + } + KeyCode::BackTab => { + app.entry_edit_prev_field(); + } + KeyCode::Down | KeyCode::Char('j') => { + app.entry_edit_next_field(); + } + KeyCode::Up | KeyCode::Char('k') => { + app.entry_edit_prev_field(); + } + KeyCode::Right => { + if app + .history_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) + { + app.entry_edit_move_cursor(false); + } else { + app.entry_edit_next_field(); + } + } + KeyCode::Char('l') | KeyCode::Char('L') => { + app.entry_edit_next_field(); + } + KeyCode::Left => { + if app + .history_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) + { + app.entry_edit_move_cursor(true); + } else { + app.entry_edit_prev_field(); + } + } + KeyCode::Char('h') | KeyCode::Char('H') => { + app.entry_edit_prev_field(); + } + KeyCode::Home => app.entry_edit_cursor_home_end(true), + KeyCode::End => app.entry_edit_cursor_home_end(false), + KeyCode::Char(c) if c.is_ascii_digit() => { + app.entry_edit_input_char(c); + } + KeyCode::Backspace => { + app.entry_edit_backspace(); + } + KeyCode::Enter => { + if let Some(state) = &app.history_edit_state { + match state.focused_field { + app::EntryEditField::StartTime | app::EntryEditField::EndTime => { + app.entry_edit_next_field(); + } + _ => { + handle_entry_edit_enter(app); + } + } + } + } + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(state) = &app.history_edit_state { + match state.focused_field { + app::EntryEditField::StartTime | app::EntryEditField::EndTime => { + app.entry_edit_clear_time(); + } + _ => {} + } + } + } + // Escape: save and exit edit mode. + KeyCode::Esc => { + if let Some(error) = app.entry_edit_validate() { + app.entry_edit_revert_invalid_times(); + app.set_status(format!("Edit cancelled: {}", error)); + app.exit_history_edit_mode(); + } else { + enqueue_action(action_tx, Action::SaveHistoryEdit); + } + } + KeyCode::Char('p') | KeyCode::Char('P') => { + app.navigate_to(app::View::SelectProject); + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + app.quit(); + } + _ => {} + } + } else { + // Not in edit mode. + match key.code { + KeyCode::Up | KeyCode::Char('k') => app.select_previous(), + KeyCode::Down | KeyCode::Char('j') => app.select_next(), + KeyCode::Enter => { + app.enter_history_edit_mode(); + } + KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Esc => { + app.navigate_to(app::View::Timer); + } + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + KeyCode::Delete | KeyCode::Backspace if app.focused_history_index.is_some() => { + app.enter_delete_confirm(app::DeleteOrigin::History); + } + KeyCode::Char('x') + if key.modifiers.contains(KeyModifiers::CONTROL) + && app.focused_history_index.is_some() => + { + app.enter_delete_confirm(app::DeleteOrigin::History); + } + _ => {} + } + } +} diff --git a/toki-tui/src/runtime/views/save_action.rs b/toki-tui/src/runtime/views/save_action.rs new file mode 100644 index 00000000..17e567fa --- /dev/null +++ b/toki-tui/src/runtime/views/save_action.rs @@ -0,0 +1,31 @@ +use crate::app::{self, App}; +use crossterm::event::{KeyCode, KeyEvent}; + +use super::super::action_queue::{Action, ActionTx}; +use super::enqueue_action; + +pub(super) fn handle_save_action_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + match key.code { + KeyCode::Char('1') => { + app.select_save_action_by_number(1); + enqueue_action(action_tx, Action::SaveTimer); + } + KeyCode::Char('2') => { + app.select_save_action_by_number(2); + enqueue_action(action_tx, Action::SaveTimer); + } + KeyCode::Char('3') => { + app.select_save_action_by_number(3); + enqueue_action(action_tx, Action::SaveTimer); + } + KeyCode::Char('4') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + app.navigate_to(app::View::Timer); + } + KeyCode::Up | KeyCode::Char('k') => app.select_previous_save_action(), + KeyCode::Down | KeyCode::Char('j') => app.select_next_save_action(), + KeyCode::Enter => { + enqueue_action(action_tx, Action::SaveTimer); + } + _ => {} + } +} diff --git a/toki-tui/src/runtime/views/selection.rs b/toki-tui/src/runtime/views/selection.rs new file mode 100644 index 00000000..706ec152 --- /dev/null +++ b/toki-tui/src/runtime/views/selection.rs @@ -0,0 +1,182 @@ +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::action_queue::{Action, ActionTx}; +use super::enqueue_action; + +pub(super) fn handle_select_project_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + // Save edit state and running timer state before any selection. + let had_edit_state = app.is_in_edit_mode(); + let saved_selected_project = app.selected_project.clone(); + let saved_selected_activity = app.selected_activity.clone(); + + if handle_selection_input_key( + key, + app, + app.filtered_project_index, + app.filtered_projects.len(), + SelectionInputOps { + clear_input: App::search_input_clear, + input_char: App::search_input_char, + input_backspace: App::search_input_backspace, + move_cursor: App::search_move_cursor, + cursor_home_end: App::search_cursor_home_end, + }, + ) { + return; + } + + match key.code { + KeyCode::Enter => { + app.confirm_selection(); + enqueue_action( + action_tx, + Action::ApplyProjectSelection { + had_edit_state, + saved_selected_project, + saved_selected_activity, + }, + ); + } + KeyCode::Esc => app.cancel_selection(), + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + _ => {} + } +} + +pub(super) fn handle_select_activity_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + // Save edit state and running timer state before any selection. + let was_in_edit_mode = app.is_in_edit_mode(); + let saved_selected_project = app.selected_project.clone(); + let saved_selected_activity = app.selected_activity.clone(); + + if handle_selection_input_key( + key, + app, + app.filtered_activity_index, + app.filtered_activities.len(), + SelectionInputOps { + clear_input: App::activity_search_input_clear, + input_char: App::activity_search_input_char, + input_backspace: App::activity_search_input_backspace, + move_cursor: App::activity_search_move_cursor, + cursor_home_end: App::activity_search_cursor_home_end, + }, + ) { + return; + } + + match key.code { + KeyCode::Enter => { + app.confirm_selection(); + enqueue_action( + action_tx, + Action::ApplyActivitySelection { + was_in_edit_mode, + saved_selected_project, + saved_selected_activity, + }, + ); + } + KeyCode::Esc => app.cancel_selection(), + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + _ => {} + } +} + +fn handle_selection_input_key( + key: KeyEvent, + app: &mut App, + list_index: usize, + list_len: usize, + ops: SelectionInputOps, +) -> bool { + match key.code { + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + (ops.clear_input)(app); + true + } + KeyCode::Tab => { + app.selection_list_focused = true; + true + } + KeyCode::BackTab => { + app.selection_list_focused = false; + true + } + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) && c != 'q' && c != 'Q' => + { + if app.selection_list_focused && c == 'j' { + if list_index + 1 >= list_len { + app.selection_list_focused = false; + } else { + app.select_next(); + } + } else if app.selection_list_focused && c == 'k' { + if list_index == 0 { + app.selection_list_focused = false; + } else { + app.select_previous(); + } + } else if !app.selection_list_focused { + (ops.input_char)(app, c); + } + true + } + KeyCode::Backspace => { + (ops.input_backspace)(app); + true + } + KeyCode::Up => { + if app.selection_list_focused && list_index == 0 { + app.selection_list_focused = false; + } else { + app.select_previous(); + } + true + } + KeyCode::Down => { + if app.selection_list_focused && list_index + 1 >= list_len { + app.selection_list_focused = false; + } else { + app.select_next(); + } + true + } + KeyCode::Left => { + if !app.selection_list_focused { + (ops.move_cursor)(app, true); + } + true + } + KeyCode::Right => { + if !app.selection_list_focused { + (ops.move_cursor)(app, false); + } + true + } + KeyCode::Home => { + if !app.selection_list_focused { + (ops.cursor_home_end)(app, true); + } + true + } + KeyCode::End => { + if !app.selection_list_focused { + (ops.cursor_home_end)(app, false); + } + true + } + _ => false, + } +} + +#[derive(Clone, Copy)] +struct SelectionInputOps { + clear_input: fn(&mut App), + input_char: fn(&mut App, char), + input_backspace: fn(&mut App), + move_cursor: fn(&mut App, bool), + cursor_home_end: fn(&mut App, bool), +} diff --git a/toki-tui/src/runtime/views/statistics.rs b/toki-tui/src/runtime/views/statistics.rs new file mode 100644 index 00000000..e04bdd41 --- /dev/null +++ b/toki-tui/src/runtime/views/statistics.rs @@ -0,0 +1,12 @@ +use crate::app::{self, App}; +use crossterm::event::{KeyCode, KeyEvent}; + +pub(super) fn handle_statistics_key(key: KeyEvent, app: &mut App) { + match key.code { + KeyCode::Char('s') | KeyCode::Char('S') | KeyCode::Esc => { + app.navigate_to(app::View::Timer); + } + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + _ => {} + } +} diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs new file mode 100644 index 00000000..6b0186ae --- /dev/null +++ b/toki-tui/src/runtime/views/timer.rs @@ -0,0 +1,241 @@ +use crate::app::{self, App}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::action_queue::{Action, ActionTx}; +use super::super::actions::handle_entry_edit_enter; +use super::enqueue_action; + +pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + match key.code { + // Quit + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + // Ctrl+C also quits + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => app.quit(), + // Ctrl+S: Save & continue + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app.timer_state == app::TimerState::Stopped { + app.set_status("No active timer to save".to_string()); + } else if !app.has_project_activity() { + app.set_status( + "Cannot save: Please select Project / Activity first (press P)".to_string(), + ); + } else { + app.navigate_to(app::View::SaveAction); + } + } + KeyCode::Tab => { + if is_editing_this_week(app) { + app.entry_edit_next_field(); + } else { + app.focus_next(); + } + } + KeyCode::BackTab => { + if is_editing_this_week(app) { + app.entry_edit_prev_field(); + } else { + app.focus_previous(); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if is_editing_this_week(app) { + app.entry_edit_next_field(); + } else if app.focused_box == app::FocusedBox::Today { + app.this_week_focus_down(); + } else { + app.focus_next(); + } + } + KeyCode::Up | KeyCode::Char('k') => { + if is_editing_this_week(app) { + app.entry_edit_prev_field(); + } else if app.focused_box == app::FocusedBox::Today { + app.this_week_focus_up(); + } else { + app.focus_previous(); + } + } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => { + if is_editing_this_week(app) { + app.entry_edit_next_field(); + } + } + KeyCode::Left => { + if is_editing_this_week(app) { + app.entry_edit_prev_field(); + } + } + KeyCode::Char('h') | KeyCode::Char('H') => { + if is_editing_this_week(app) { + app.entry_edit_prev_field(); + } else { + enqueue_action(action_tx, Action::LoadHistoryAndOpen); + } + } + KeyCode::Home => { + if is_editing_this_week(app) { + app.entry_edit_cursor_home_end(true); + } + } + KeyCode::End => { + if is_editing_this_week(app) { + app.entry_edit_cursor_home_end(false); + } + } + KeyCode::Enter => { + handle_enter_key(app, action_tx); + } + // Number keys for time input in edit mode + KeyCode::Char(c) if is_editing_this_week(app) && c.is_ascii_digit() => { + app.entry_edit_input_char(c); + } + KeyCode::Backspace => { + if is_editing_this_week(app) { + if !is_note_focused_in_this_week_edit(app) { + app.entry_edit_backspace(); + } + } else if is_persisted_today_row_selected(app) { + app.enter_delete_confirm(app::DeleteOrigin::Timer); + } + } + KeyCode::Esc => { + handle_escape_key(app, action_tx); + } + KeyCode::Char(' ') => { + handle_space_key(app, action_tx); + } + KeyCode::Char('p') | KeyCode::Char('P') => { + app.navigate_to(app::View::SelectProject); + } + KeyCode::Char('n') | KeyCode::Char('N') => { + app.navigate_to(app::View::EditDescription); + } + KeyCode::Char('t') | KeyCode::Char('T') => { + app.toggle_timer_size(); + } + // S: Open Statistics view (unmodified only - Ctrl+S is save) + KeyCode::Char('s') | KeyCode::Char('S') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.navigate_to(app::View::Statistics); + } + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + handle_ctrl_x_key(app, action_tx); + } + KeyCode::Delete if !is_editing_this_week(app) && is_persisted_today_row_selected(app) => { + app.enter_delete_confirm(app::DeleteOrigin::Timer); + } + KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(), + _ => {} + } +} + +fn is_editing_this_week(app: &App) -> bool { + app.this_week_edit_state.is_some() +} + +fn is_note_focused_in_this_week_edit(app: &App) -> bool { + app.this_week_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) +} + +fn is_persisted_today_row_selected(app: &App) -> bool { + app.focused_box == app::FocusedBox::Today + && app + .focused_this_week_index + .is_some_and(|idx| !(app.timer_state == app::TimerState::Running && idx == 0)) +} + +fn handle_enter_key(app: &mut App, action_tx: &ActionTx) { + if is_editing_this_week(app) { + // In edit mode, Enter on Start/End advances field; other fields open modal. + if let Some(state) = &app.this_week_edit_state { + match state.focused_field { + app::EntryEditField::StartTime | app::EntryEditField::EndTime => { + app.entry_edit_next_field(); + } + _ => { + handle_entry_edit_enter(app); + } + } + } + return; + } + + match app.focused_box { + app::FocusedBox::Timer => { + enqueue_action(action_tx, Action::StartTimer); + } + app::FocusedBox::Today => { + if app.focused_this_week_index.is_none() && !app.this_week_history().is_empty() { + app.focused_this_week_index = Some(0); + } + app.enter_this_week_edit_mode(); + } + _ => { + app.activate_focused_box(); + } + } +} + +fn handle_escape_key(app: &mut App, action_tx: &ActionTx) { + if app.zen_mode { + app.exit_zen_mode(); + return; + } + + if is_editing_this_week(app) { + if let Some(error) = app.entry_edit_validate() { + app.entry_edit_revert_invalid_times(); + app.set_status(format!("Edit cancelled: {}", error)); + app.exit_this_week_edit_mode(); + app.focused_box = app::FocusedBox::Today; + } else { + enqueue_action(action_tx, Action::SaveThisWeekEdit); + } + return; + } + + app.focused_box = app::FocusedBox::Timer; + app.focused_this_week_index = None; +} + +fn handle_space_key(app: &mut App, action_tx: &ActionTx) { + match app.timer_state { + app::TimerState::Stopped => { + enqueue_action(action_tx, Action::StartTimer); + } + app::TimerState::Running => { + if !app.has_project_activity() { + app.set_status( + "Cannot save: Please select Project / Activity first (press P)".to_string(), + ); + } else { + app.selected_save_action = app::SaveAction::SaveAndStop; + enqueue_action(action_tx, Action::SaveTimer); + } + } + } +} + +fn handle_ctrl_x_key(app: &mut App, action_tx: &ActionTx) { + if is_editing_this_week(app) { + if let Some(state) = &app.this_week_edit_state { + match state.focused_field { + app::EntryEditField::StartTime | app::EntryEditField::EndTime => { + app.entry_edit_clear_time(); + } + _ => {} + } + } + return; + } + + if is_persisted_today_row_selected(app) { + app.enter_delete_confirm(app::DeleteOrigin::Timer); + return; + } + + enqueue_action(action_tx, Action::StopServerTimerAndClear); +} diff --git a/toki-tui/src/session_store.rs b/toki-tui/src/session_store.rs new file mode 100644 index 00000000..c3f1acb9 --- /dev/null +++ b/toki-tui/src/session_store.rs @@ -0,0 +1,109 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::{io::Write, os::unix::fs::OpenOptionsExt}; + +fn root_path() -> Result { + Ok(dirs::config_dir() + .context("Cannot determine config directory")? + .join("toki-tui")) +} + +fn secure_write(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + #[cfg(unix)] + { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)? + .write_all(content.as_bytes())?; + } + + #[cfg(not(unix))] + { + std::fs::write(path, content)?; + } + + Ok(()) +} + +pub fn session_path() -> Result { + Ok(root_path()?.join("session")) +} + +pub fn mt_cookies_path() -> Result { + Ok(root_path()?.join("mt_cookies")) +} + +pub fn load_session() -> Result> { + let path = session_path()?; + if !path.exists() { + return Ok(None); + } + + let session = std::fs::read_to_string(&path).context("Failed to read session file")?; + let session = session.trim().to_string(); + if session.is_empty() { + return Ok(None); + } + Ok(Some(session)) +} + +pub fn save_session(session_id: &str) -> Result<()> { + let path = session_path()?; + secure_write(path.as_path(), session_id) +} + +pub fn clear_session() -> Result<()> { + let path = session_path()?; + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} + +pub fn load_mt_cookies() -> Result> { + let path = mt_cookies_path()?; + if !path.exists() { + return Ok(vec![]); + } + + let raw = std::fs::read_to_string(path).context("Failed to read mt_cookies")?; + Ok(raw + .lines() + .filter_map(|line| { + let mut parts = line.splitn(2, '='); + let name = parts.next()?.trim().to_string(); + let value = parts.next()?.trim().to_string(); + if name.is_empty() { + None + } else { + Some((name, value)) + } + }) + .collect()) +} + +pub fn save_mt_cookies(cookies: &[(String, String)]) -> Result<()> { + let path = mt_cookies_path()?; + let content = cookies + .iter() + .map(|(name, value)| format!("{}={}", name, value)) + .collect::>() + .join("\n"); + secure_write(path.as_path(), &content) +} + +pub fn clear_mt_cookies() -> Result<()> { + let path = mt_cookies_path()?; + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} diff --git a/toki-tui/src/terminal.rs b/toki-tui/src/terminal.rs new file mode 100644 index 00000000..3bfbab4a --- /dev/null +++ b/toki-tui/src/terminal.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; + +pub struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + pub fn new() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + Ok(Self { terminal }) + } + + pub fn terminal_mut(&mut self) -> &mut Terminal> { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let _ = self.terminal.show_cursor(); + } +} diff --git a/toki-tui/src/time_utils.rs b/toki-tui/src/time_utils.rs new file mode 100644 index 00000000..d32d0286 --- /dev/null +++ b/toki-tui/src/time_utils.rs @@ -0,0 +1,9 @@ +use time::UtcOffset; + +pub fn to_local_time(dt: time::OffsetDateTime) -> time::OffsetDateTime { + if let Ok(local_offset) = UtcOffset::current_local_offset() { + dt.to_offset(local_offset) + } else { + dt + } +} diff --git a/toki-tui/src/ui/statistics_view.rs b/toki-tui/src/ui/statistics_view.rs index 0f7cec4a..8e3ee594 100644 --- a/toki-tui/src/ui/statistics_view.rs +++ b/toki-tui/src/ui/statistics_view.rs @@ -1,4 +1,5 @@ use super::*; +use tui_piechart::{PieChart, PieSlice}; /// Shared color palette — same order for pie slices and daily bars pub const PALETTE: [Color; 12] = [ @@ -75,8 +76,6 @@ pub fn render_statistics_view(frame: &mut Frame, app: &App, body: Rect) { } fn render_pie_panel(frame: &mut Frame, app: &App, area: Rect) { - use tui_piechart::{PieChart, PieSlice}; - let stats = &app.weekly_stats_cache; if stats.is_empty() { diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 7924fc29..77356478 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::TimerSize; pub fn render_timer_view(frame: &mut Frame, app: &mut App, body: Rect) { // Timer box height depends on timer size @@ -29,8 +30,6 @@ pub fn render_timer_view(frame: &mut Frame, app: &mut App, body: Rect) { } fn render_timer(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { - use crate::app::TimerSize; - let is_running = matches!(app.timer_state, crate::app::TimerState::Running); let is_focused = app.focused_box == crate::app::FocusedBox::Timer; diff --git a/toki-tui/src/ui/utils.rs b/toki-tui/src/ui/utils.rs index f70775a2..f095cacd 100644 --- a/toki-tui/src/ui/utils.rs +++ b/toki-tui/src/ui/utils.rs @@ -1,12 +1,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use time::UtcOffset; pub fn to_local_time(dt: time::OffsetDateTime) -> time::OffsetDateTime { - if let Ok(local_offset) = UtcOffset::current_local_offset() { - dt.to_offset(local_offset) - } else { - dt - } + crate::time_utils::to_local_time(dt) } /// Helper function to create a centered rectangle