├── .DS_Store ├── work_tuimer_example.png ├── src ├── ui │ ├── mod.rs │ └── history.rs ├── models │ ├── mod.rs │ ├── time_point.rs │ ├── work_record.rs │ └── day_data.rs ├── lib.rs ├── cli │ └── mod.rs ├── integrations │ └── mod.rs ├── main.rs └── timer │ └── mod.rs ├── shell.nix ├── .opencode.json ├── packaging ├── aur │ ├── .SRCINFO │ └── PKGBUILD ├── homebrew │ └── work-tuimer.rb └── README.md ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── data └── 2025-10-31.json ├── Cargo.toml ├── AGENTS.md ├── justfile ├── docs ├── ISSUE_TRACKER_INTEGRATION.md ├── FEATURE_IDEAS.md ├── SESSIONS.md └── THEMING.md ├── README.md ├── tests └── storage_manager_integration.rs └── Cargo.lock /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kamyil/work-tuimer/HEAD/.DS_Store -------------------------------------------------------------------------------- /work_tuimer_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kamyil/work-tuimer/HEAD/work_tuimer_example.png -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app_state; 2 | pub mod history; 3 | pub mod render; 4 | 5 | pub use app_state::{AppMode, AppState, EditField}; 6 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | pkgs.mkShell { 4 | nativeBuildInputs = with pkgs; [ 5 | rustc 6 | cargo 7 | libiconv 8 | ]; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod day_data; 2 | mod time_point; 3 | mod work_record; 4 | 5 | pub use day_data::DayData; 6 | pub use time_point::TimePoint; 7 | pub use work_record::WorkRecord; 8 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Library exports for work-tuimer 2 | // This allows integration tests to access internal modules 3 | 4 | pub mod cli; 5 | pub mod config; 6 | pub mod integrations; 7 | pub mod models; 8 | pub mod storage; 9 | pub mod timer; 10 | pub mod ui; 11 | -------------------------------------------------------------------------------- /.opencode.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": [ 3 | "GitHub CLI is READ-ONLY: Only use 'gh issue list', 'gh issue view', 'gh pr list', 'gh pr view' for reading. DO NOT comment on, close, or modify GitHub issues/PRs. After implementing fixes, inform the user so they can manage issues themselves." 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /packaging/aur/.SRCINFO: -------------------------------------------------------------------------------- 1 | pkgbase = work-tuimer 2 | pkgdesc = Simple, keyboard-driven TUI for time-tracking 3 | pkgver = 0.3.0 4 | pkgrel = 1 5 | url = https://github.com/Kamyil/work-tuimer 6 | arch = x86_64 7 | arch = aarch64 8 | license = MIT 9 | makedepends = cargo 10 | makedepends = rust 11 | depends = gcc-libs 12 | depends = glibc 13 | source = work-tuimer-0.3.0.tar.gz::https://github.com/Kamyil/work-tuimer/archive/refs/tags/v0.3.0.tar.gz 14 | b2sums = SKIP 15 | 16 | pkgname = work-tuimer 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug 4 | target 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | # Generated by cargo mutants 13 | # Contains mutation testing data 14 | **/mutants.out*/ 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | -------------------------------------------------------------------------------- /packaging/homebrew/work-tuimer.rb: -------------------------------------------------------------------------------- 1 | class WorkTuimer < Formula 2 | desc "Simple, keyboard-driven TUI for time-tracking" 3 | homepage "https://github.com/Kamyil/work-tuimer" 4 | url "https://github.com/Kamyil/work-tuimer/archive/refs/tags/v0.3.0.tar.gz" 5 | sha256 "468577cf23cab371261b2896568a539bb0bdbcdbaa0711c1653c17cb1949a6c3" 6 | license "MIT" 7 | 8 | depends_on "rust" => :build 9 | 10 | def install 11 | system "cargo", "install", *std_cargo_args 12 | end 13 | 14 | test do 15 | # Test that the binary runs and responds to --version 16 | assert_match "work-tuimer #{version}", shell_output("#{bin}/work-tuimer --version") 17 | 18 | # Test that --help works 19 | assert_match "Simple, keyboard-driven TUI", shell_output("#{bin}/work-tuimer --help") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [@Kamyil] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, master, develop] 6 | pull_request: 7 | branches: [main, master, develop] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: dtolnay/rust-toolchain@stable 17 | 18 | - uses: Swatinem/rust-cache@v2 19 | 20 | - name: Run tests 21 | run: cargo test --verbose 22 | 23 | clippy: 24 | name: Clippy 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: dtolnay/rust-toolchain@stable 30 | 31 | - uses: Swatinem/rust-cache@v2 32 | 33 | - name: Run clippy 34 | run: cargo clippy -- -D warnings 35 | 36 | fmt: 37 | name: Format Check 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - uses: dtolnay/rust-toolchain@stable 43 | 44 | - name: Check formatting 45 | run: cargo fmt -- --check 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kamil Kseń 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packaging/aur/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Kamil 2 | 3 | pkgname=work-tuimer 4 | pkgver=0.3.0 5 | pkgrel=1 6 | pkgdesc='Simple, keyboard-driven TUI for time-tracking' 7 | url='https://github.com/Kamyil/work-tuimer' 8 | license=('MIT') 9 | makedepends=('cargo' 'rust') 10 | depends=('gcc-libs' 'glibc') 11 | arch=('x86_64' 'aarch64') 12 | source=("$pkgname-$pkgver.tar.gz::https://github.com/Kamyil/$pkgname/archive/refs/tags/v$pkgver.tar.gz") 13 | b2sums=('SKIP') # Will be filled after first test build 14 | 15 | prepare() { 16 | cd "$pkgname-$pkgver" 17 | export RUSTUP_TOOLCHAIN=stable 18 | cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" 19 | } 20 | 21 | build() { 22 | cd "$pkgname-$pkgver" 23 | export RUSTUP_TOOLCHAIN=stable 24 | export CARGO_TARGET_DIR=target 25 | cargo build --frozen --release --all-features 26 | } 27 | 28 | check() { 29 | cd "$pkgname-$pkgver" 30 | export RUSTUP_TOOLCHAIN=stable 31 | cargo test --frozen --all-features 32 | } 33 | 34 | package() { 35 | cd "$pkgname-$pkgver" 36 | install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/$pkgname" 37 | install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" 38 | install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" 39 | } 40 | -------------------------------------------------------------------------------- /data/2025-10-31.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": "2025-10-31", 3 | "last_id": 6, 4 | "work_records": { 5 | "1": { 6 | "id": 1, 7 | "name": "Coding", 8 | "start": { "hour": 9, "minute": 0 }, 9 | "end": { "hour": 11, "minute": 30 }, 10 | "total_minutes": 150 11 | }, 12 | "2": { 13 | "id": 2, 14 | "name": "Meeting", 15 | "start": { "hour": 11, "minute": 30 }, 16 | "end": { "hour": 12, "minute": 30 }, 17 | "total_minutes": 60 18 | }, 19 | "3": { 20 | "id": 3, 21 | "name": "Break", 22 | "start": { "hour": 12, "minute": 30 }, 23 | "end": { "hour": 13, "minute": 0 }, 24 | "total_minutes": 30 25 | }, 26 | "4": { 27 | "id": 4, 28 | "name": "Coding", 29 | "start": { "hour": 13, "minute": 0 }, 30 | "end": { "hour": 15, "minute": 30 }, 31 | "total_minutes": 150 32 | }, 33 | "5": { 34 | "id": 5, 35 | "name": "Meeting", 36 | "start": { "hour": 15, "minute": 30 }, 37 | "end": { "hour": 16, "minute": 0 }, 38 | "total_minutes": 30 39 | }, 40 | "6": { 41 | "id": 6, 42 | "name": "Code review", 43 | "start": { "hour": 16, "minute": 0 }, 44 | "end": { "hour": 17, "minute": 0 }, 45 | "total_minutes": 60 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "work-tuimer" 3 | version = "0.3.4" 4 | edition = "2024" 5 | authors = ["Kamil Ksen "] 6 | description = "Simple, keyboard-driven TUI for time-tracking that allows you to quickly add time blocks and automatically group time if same task was done in different sessions" 7 | license = "MIT" 8 | repository = "https://github.com/Kamyil/work-tuimer" 9 | homepage = "https://github.com/Kamyil/work-tuimer" 10 | documentation = "https://github.com/Kamyil/work-tuimer#readme" 11 | readme = "README.md" 12 | keywords = ["tui", "time-tracking", "productivity", "terminal", "ratatui"] 13 | categories = ["command-line-utilities", "date-and-time"] 14 | exclude = [ 15 | ".github/", 16 | ".DS_Store", 17 | "data/", 18 | "packaging/", 19 | "*.png", 20 | "shell.nix", 21 | "justfile", 22 | "AGENTS.md", 23 | "TASKS.md", 24 | "PACKAGING_GUIDE.md" 25 | ] 26 | 27 | [lib] 28 | name = "work_tuimer" 29 | path = "src/lib.rs" 30 | 31 | [dependencies] 32 | ratatui = "0.26" 33 | crossterm = "0.27" 34 | serde = { version = "1.0", features = ["derive"] } 35 | serde_json = "1.0" 36 | anyhow = "1.0" 37 | dirs = "5.0" 38 | time = { version = "0.3", features = ["serde", "macros", "formatting", "parsing", "local-offset"] } 39 | fuzzy-matcher = "0.3" 40 | toml = "0.8" 41 | regex = "1.10" 42 | clap = { version = "4.4", features = ["derive"] } 43 | 44 | [dev-dependencies] 45 | tempfile = "3.8" 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | name: Build ${{ matrix.os }} 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | include: 18 | - os: ubuntu-latest 19 | target: x86_64-unknown-linux-gnu 20 | artifact_name: work-tuimer 21 | asset_name: work-tuimer-linux-x86_64 22 | 23 | - os: macos-latest 24 | target: x86_64-apple-darwin 25 | artifact_name: work-tuimer 26 | asset_name: work-tuimer-macos-x86_64 27 | 28 | - os: macos-latest 29 | target: aarch64-apple-darwin 30 | artifact_name: work-tuimer 31 | asset_name: work-tuimer-macos-aarch64 32 | 33 | - os: windows-latest 34 | target: x86_64-pc-windows-msvc 35 | artifact_name: work-tuimer.exe 36 | asset_name: work-tuimer-windows-x86_64.exe 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - uses: dtolnay/rust-toolchain@stable 42 | with: 43 | targets: ${{ matrix.target }} 44 | 45 | - uses: Swatinem/rust-cache@v2 46 | 47 | - name: Build 48 | run: cargo build --verbose --release --target ${{ matrix.target }} 49 | 50 | - name: Rename binary 51 | run: cp target/${{ matrix.target }}/release/${{ matrix.artifact_name }} ${{ matrix.asset_name }} 52 | 53 | - name: Upload Release Asset 54 | uses: softprops/action-gh-release@v1 55 | with: 56 | files: ${{ matrix.asset_name }} 57 | tag_name: ${{ github.ref_name }} 58 | draft: false 59 | generate_release_notes: true 60 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agent Guidelines for WorkTimer TUI 2 | 3 | ## GitHub Issues Management 4 | 5 | **IMPORTANT: GitHub CLI is READ-ONLY** 6 | - **DO NOT** comment on, close, or modify GitHub issues 7 | - **DO NOT** use commands like `gh issue comment`, `gh issue close`, `gh pr comment`, etc. 8 | - **ONLY** use `gh issue list`, `gh issue view`, `gh pr list`, `gh pr view` for reading 9 | - After implementing fixes, inform the user so they can manage the issue themselves 10 | 11 | ## Task Management 12 | 13 | ### TASKS.md Usage 14 | When working on any feature or bug fix: 15 | 1. **Always read TASKS.md first** to check current status 16 | 2. **Add new tasks** when starting work - break down complex work into clear steps 17 | 3. **Update status in real-time** as you progress: 18 | - `[ ]` Pending → `[~]` In Progress → `[x]` Completed 19 | 4. **Move completed tasks** to the "Completed Tasks" section with timestamp 20 | 5. **Add context notes** for important decisions, blockers, or next steps 21 | 6. **Keep tasks specific and actionable** - prefer multiple small tasks over one large task 22 | 23 | ### Task Workflow Example 24 | ```markdown 25 | ## Current Sprint 26 | 27 | ### Feature: Add CSV Export 28 | - [~] Create CSV export module in `src/export/csv.rs` 29 | - [ ] Add export command to CLI 30 | - [ ] Add tests for CSV formatting 31 | - [ ] Update README with export documentation 32 | ``` 33 | 34 | ## Build/Test/Lint Commands 35 | - **Build**: `cargo build` 36 | - **Run**: `cargo run` 37 | - **Test**: `cargo test` (run all tests) or `cargo test ` (single test) 38 | - **Check**: `cargo check` (fast type checking without compilation) 39 | - **Clippy**: `cargo clippy` (linting) 40 | 41 | ## Code Style 42 | - **Language**: Rust 2024 edition 43 | - **Error Handling**: Use `anyhow::Result` for functions, `anyhow::Context` for error context 44 | - **Imports**: Group by std → external crates → internal modules, separated by blank lines 45 | - **Types**: Prefer explicit types on public APIs; use `pub` fields for simple data structs 46 | - **Naming**: `snake_case` for variables/functions, `PascalCase` for types/enums, `SCREAMING_SNAKE_CASE` for constants 47 | - **String Handling**: Use `.to_string()` for owned strings, `&str` for borrowed; trim user input 48 | - **Time Format**: Use `time` crate (`TimePoint` format: `HH:MM`, validates 0-23h, 0-59m) 49 | - **Serialization**: Use `serde` with `Serialize`/`Deserialize` derives; pretty-print JSON with `serde_json::to_string_pretty` 50 | - **State Management**: Mutable methods on structs (e.g., `&mut self`); validate before mutating 51 | - **UI Pattern**: Separate state (`app_state.rs`) from rendering (`render.rs`); use ratatui widgets 52 | - **Module Structure**: Each module exports types via `pub use` in `mod.rs` 53 | - **Data Storage**: JSON files per day (`YYYY-MM-DD.json`), auto-create dirs with `fs::create_dir_all` 54 | -------------------------------------------------------------------------------- /packaging/README.md: -------------------------------------------------------------------------------- 1 | # Packaging Guide for work-tuimer 2 | 3 | This directory contains packaging files for various package managers. 4 | 5 | ## Cargo (crates.io) - Recommended First Step! 6 | 7 | ### Publishing to crates.io 8 | 9 | **Prerequisites:** 10 | - crates.io account (login via GitHub at https://crates.io) 11 | - API token from https://crates.io/me 12 | 13 | **Steps:** 14 | 15 | 1. Login to crates.io (one-time setup): 16 | ```bash 17 | cargo login YOUR_API_TOKEN 18 | ``` 19 | 20 | 2. Verify package is ready: 21 | ```bash 22 | cargo package --list 23 | # Should show ~28 files 24 | ``` 25 | 26 | 3. Test the package builds correctly: 27 | ```bash 28 | cargo package --no-verify 29 | # or if outside nix-shell: 30 | cargo package 31 | ``` 32 | 33 | 4. Publish to crates.io: 34 | ```bash 35 | cargo publish 36 | ``` 37 | 38 | 5. Done! Users can now install with: 39 | ```bash 40 | cargo install work-tuimer 41 | ``` 42 | 43 | **Notes:** 44 | - Once published, you CANNOT delete or modify a version (only yank) 45 | - Publishing is instant - no review process 46 | - Package appears at: https://crates.io/crates/work-tuimer 47 | - For future releases, just bump version in Cargo.toml and run `cargo publish` 48 | 49 | ## Homebrew (macOS/Linux) 50 | 51 | ### Testing Locally 52 | 53 | 1. Install the formula locally: 54 | ```bash 55 | brew install --build-from-source packaging/homebrew/work-tuimer.rb 56 | ``` 57 | 58 | 2. Test the installation: 59 | ```bash 60 | work-tuimer --version 61 | work-tuimer --help 62 | ``` 63 | 64 | 3. Audit the formula: 65 | ```bash 66 | brew audit --new --formula packaging/homebrew/work-tuimer.rb 67 | ``` 68 | 69 | ### Submitting to homebrew-core 70 | 71 | 1. Fork the [homebrew-core](https://github.com/Homebrew/homebrew-core) repository 72 | 73 | 2. Copy the formula to the correct location: 74 | ```bash 75 | cp packaging/homebrew/work-tuimer.rb /path/to/homebrew-core/Formula/w/work-tuimer.rb 76 | ``` 77 | 78 | 3. Create a branch and commit: 79 | ```bash 80 | cd /path/to/homebrew-core 81 | git checkout -b work-tuimer-0.3.0 82 | git add Formula/w/work-tuimer.rb 83 | git commit -m "work-tuimer 0.3.0 (new formula)" 84 | ``` 85 | 86 | 4. Run tests: 87 | ```bash 88 | brew test work-tuimer 89 | brew audit --strict --online work-tuimer 90 | ``` 91 | 92 | 5. Push and open a PR to homebrew-core 93 | 94 | ## AUR (Arch Linux) 95 | 96 | ### Testing Locally 97 | 98 | 1. Install required tools: 99 | ```bash 100 | sudo pacman -S base-devel 101 | ``` 102 | 103 | 2. Build and test: 104 | ```bash 105 | cd packaging/aur 106 | makepkg -si 107 | ``` 108 | 109 | 3. Generate checksum: 110 | ```bash 111 | makepkg -g >> PKGBUILD 112 | # Then manually edit PKGBUILD to replace SKIP with the generated b2sum 113 | ``` 114 | 115 | 4. Test the package: 116 | ```bash 117 | work-tuimer --version 118 | ``` 119 | 120 | ### Publishing to AUR 121 | 122 | 1. Create an AUR account at https://aur.archlinux.org/ 123 | 124 | 2. Set up SSH keys for AUR 125 | 126 | 3. Clone the AUR repository: 127 | ```bash 128 | git clone ssh://aur@aur.archlinux.org/work-tuimer.git aur-repo 129 | ``` 130 | 131 | 4. Copy files and commit: 132 | ```bash 133 | cp packaging/aur/{PKGBUILD,.SRCINFO} aur-repo/ 134 | cd aur-repo 135 | makepkg --printsrcinfo > .SRCINFO 136 | git add PKGBUILD .SRCINFO 137 | git commit -m "Initial release: work-tuimer 0.3.0" 138 | git push 139 | ``` 140 | 141 | ## FreeBSD 142 | 143 | FreeBSD port already exists and is maintained upstream: 144 | ```bash 145 | pkg install work-tuimer 146 | ``` 147 | 148 | ## Notes 149 | 150 | - Always test locally before submitting 151 | - Ensure all checksums are correct 152 | - Follow each platform's contribution guidelines 153 | - Update version numbers in all files when releasing new versions 154 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # WorkTimer build recipes 2 | 3 | # Display all available recipes 4 | default: 5 | @just --list 6 | 7 | # Build the project in release mode 8 | build: 9 | cargo build --release 10 | 11 | # Run tests 12 | test: 13 | cargo test 14 | 15 | # Run clippy linting 16 | lint: 17 | cargo clippy -- -D warnings 18 | 19 | # Check code formatting 20 | fmt-check: 21 | cargo fmt -- --check 22 | 23 | # Format code 24 | fmt: 25 | cargo fmt 26 | 27 | # Create a full release: bump version, commit, tag, push, and publish to cargo 28 | # Usage: just release v0.3.2 29 | release version: 30 | #!/usr/bin/env bash 31 | set -euo pipefail 32 | 33 | # Validate version format (v followed by semver) 34 | if ! [[ "{{version}}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then 35 | echo "Invalid version format: {{version}}" 36 | echo "✓ Expected format: v0.1.0 or v0.1.0-rc1" 37 | exit 1 38 | fi 39 | 40 | # Check if tag already exists 41 | if git rev-parse "{{version}}" >/dev/null 2>&1; then 42 | echo "Tag {{version}} already exists" 43 | exit 1 44 | fi 45 | 46 | # Check for uncommitted changes 47 | if ! git diff-index --quiet HEAD --; then 48 | echo "You have uncommitted changes. Please commit or stash them first." 49 | exit 1 50 | fi 51 | 52 | # Extract version without 'v' prefix 53 | VERSION="{{version}}" 54 | VERSION="${VERSION#v}" 55 | echo "📦 Preparing release {{version}} (version: $VERSION)..." 56 | 57 | # Bump version in Cargo.toml 58 | echo "📝 Bumping version in Cargo.toml to $VERSION..." 59 | sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml 60 | rm Cargo.toml.bak 61 | 62 | # Verify the change 63 | if ! grep -q "version = \"$VERSION\"" Cargo.toml; then 64 | echo "Failed to update version in Cargo.toml" 65 | exit 1 66 | fi 67 | echo "✓ Version updated in Cargo.toml" 68 | 69 | # Run tests to ensure everything works 70 | echo "Running tests..." 71 | nix-shell --run "cargo test --quiet" 72 | echo "✓ Tests passed" 73 | 74 | # Commit version bump 75 | echo "Committing version bump..." 76 | git add Cargo.toml Cargo.lock 77 | git commit -m "Bump version to $VERSION" 78 | echo "✓ Version bump committed" 79 | 80 | # Create and push tag 81 | echo " Creating git tag {{version}}..." 82 | git tag "{{version}}" 83 | echo "✓ Tag created" 84 | 85 | echo "Pushing to remote..." 86 | git push origin main 87 | git push origin "{{version}}" 88 | echo "✓ Changes and tag pushed" 89 | 90 | # Publish to crates.io 91 | echo "Publishing to crates.io..." 92 | nix-shell --run "cargo publish" 93 | echo "✓ Published to crates.io" 94 | 95 | echo "" 96 | echo "Release {{version}} complete!" 97 | echo "GitHub Actions will now build and publish pre-built binaries" 98 | echo "Watch progress at: https://github.com/$(git config --get remote.origin.url | sed 's/.*:\(.*\)\.git/\1/')/actions" 99 | echo "Crate available at: https://crates.io/crates/work-tuimer" 100 | 101 | # Dry-run release: check what would happen without making changes 102 | release-check version: 103 | #!/usr/bin/env bash 104 | set -euo pipefail 105 | 106 | # Validate version format 107 | if ! [[ "{{version}}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then 108 | echo "❌ Invalid version format: {{version}}" 109 | exit 1 110 | fi 111 | 112 | VERSION="{{version}}" 113 | VERSION="${VERSION#v}" 114 | CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') 115 | 116 | echo "🔍 Release check for {{version}}" 117 | echo "" 118 | echo "Current version: $CURRENT_VERSION" 119 | echo "New version: $VERSION" 120 | echo "" 121 | 122 | # Check git status 123 | if git diff-index --quiet HEAD --; then 124 | echo "✓ No uncommitted changes" 125 | else 126 | echo "❌ Uncommitted changes detected" 127 | fi 128 | 129 | # Check if tag exists 130 | if git rev-parse "{{version}}" >/dev/null 2>&1; then 131 | echo "❌ Tag {{version}} already exists" 132 | else 133 | echo "✓ Tag {{version}} is available" 134 | fi 135 | 136 | # Check cargo login 137 | echo "" 138 | echo "Checking cargo registry authentication..." 139 | if cargo login --help >/dev/null 2>&1; then 140 | echo "✓ Cargo is available" 141 | else 142 | echo "❌ Cargo not found" 143 | fi 144 | -------------------------------------------------------------------------------- /docs/ISSUE_TRACKER_INTEGRATION.md: -------------------------------------------------------------------------------- 1 | # Issue Tracker Integration 2 | 3 | WorkTimer supports automatic ticket detection from task names and browser integration for **any** issue tracker (JIRA, Linear, GitHub Issues, GitLab, Azure DevOps, etc.). **This feature is completely optional** - the application works perfectly without any configuration. 4 | 5 | ## Table of Contents 6 | 7 | - [Setup](#setup) 8 | - [Configuration Examples](#configuration-examples) 9 | - [Usage](#usage) 10 | - [Ticket Detection](#ticket-detection) 11 | - [Multiple Tracker Support](#multiple-tracker-support) 12 | - [Supported Platforms](#supported-platforms) 13 | 14 | ## Setup 15 | 16 | **Note**: If you don't create a config file, the integration feature will be hidden (no `T`/`L` keybindings, no ticket badges). The app works perfectly without it. 17 | 18 | To enable the integration, create a configuration file at the appropriate location for your platform: 19 | 20 | - **Linux/macOS**: `~/.config/work-tuimer/config.toml` (or `$XDG_CONFIG_HOME/work-tuimer/config.toml` if set) 21 | - **Windows**: `%APPDATA%\work-tuimer\config.toml` 22 | 23 | ## Configuration Examples 24 | 25 | ### Example: JIRA tracker 26 | 27 | ```toml 28 | [integrations] 29 | default_tracker = "my-jira" # Default tracker when pattern is ambiguous 30 | 31 | [integrations.trackers.my-jira] 32 | enabled = true 33 | base_url = "https://your-company.atlassian.net" 34 | ticket_patterns = ["^PROJ-\\d+$", "^WORK-\\d+$"] # Regex to match your tickets 35 | browse_url = "{base_url}/browse/{ticket}" 36 | worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1" 37 | ``` 38 | 39 | ### Example: GitHub Issues tracker 40 | 41 | ```toml 42 | [integrations] 43 | default_tracker = "github" 44 | 45 | [integrations.trackers.github] 46 | enabled = true 47 | base_url = "https://github.com/yourorg/yourrepo" 48 | ticket_patterns = ["^#\\d+$"] # Matches #123 49 | browse_url = "{base_url}/issues/{ticket}" 50 | worklog_url = "" # GitHub doesn't have worklogs 51 | ``` 52 | 53 | ### Example: Multiple trackers (JIRA + Linear + GitHub) 54 | 55 | ```toml 56 | [integrations] 57 | default_tracker = "work-jira" # Fallback when patterns overlap 58 | 59 | [integrations.trackers.work-jira] 60 | enabled = true 61 | base_url = "https://company.atlassian.net" 62 | ticket_patterns = ["^PROJ-\\d+$", "^WORK-\\d+$"] # Company JIRA projects 63 | browse_url = "{base_url}/browse/{ticket}" 64 | worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1" 65 | 66 | [integrations.trackers.team-linear] 67 | enabled = true 68 | base_url = "https://linear.app/your-team" 69 | ticket_patterns = ["^ENG-\\d+$", "^DESIGN-\\d+$"] # Linear team patterns 70 | browse_url = "{base_url}/issue/{ticket}" 71 | worklog_url = "" 72 | 73 | [integrations.trackers.oss-github] 74 | enabled = true 75 | base_url = "https://github.com/myorg/myrepo" 76 | ticket_patterns = ["^#\\d+$"] # GitHub issue numbers 77 | browse_url = "{base_url}/issues/{ticket}" 78 | worklog_url = "" 79 | ``` 80 | 81 | ## Usage 82 | 83 | ### 1. Include ticket IDs in task names 84 | 85 | When creating or editing tasks, include the ticket ID in the name: 86 | - JIRA: `"PROJ-123: Fix login bug"` 87 | - Linear: `"ENG-456: Add dark mode"` 88 | - GitHub: `"#789: Update documentation"` 89 | 90 | ### 2. Visual indicator 91 | 92 | Tasks with detected tickets show a badge with a ticket icon: `🎫 Task Name [PROJ-123]` 93 | 94 | ### 3. Open ticket in browser 95 | 96 | Press `T` (capital T) while a task with a detected ticket (🎫 icon visible) is selected to open the ticket in your default browser 97 | 98 | ### 4. Open worklog URL 99 | 100 | Press `L` (capital L) while a task with a detected ticket (🎫 icon visible) is selected to open the worklog URL (if configured). Useful for JIRA users to quickly jump to the worklog entry form for a ticket 101 | 102 | **Note**: The `T` and `L` keybindings only appear in the footer and only work when: 103 | - Integrations are configured in `config.toml` 104 | - The selected task has a ticket ID that matches one of your `ticket_patterns` (indicated by the 🎫 icon) 105 | 106 | ## Ticket Detection 107 | 108 | The system uses regex patterns defined in `ticket_patterns` to detect ticket IDs from task names. Common patterns: 109 | - **JIRA/Linear**: `^[A-Z]+-\\d+$` matches `PROJ-123`, `ENG-456` 110 | - **GitHub Issues**: `^#\\d+$` matches `#123`, `#456` 111 | - **Custom**: Define any regex pattern that matches your tracker's ticket format 112 | 113 | Tickets are detected automatically from task names at runtime (no data model changes required). 114 | 115 | ## Multiple Tracker Support 116 | 117 | **Multiple Tracker Support**: The app automatically detects which tracker to use based on the `ticket_patterns` regex: 118 | - Each tracker is checked in order until a pattern matches the ticket ID 119 | - If a ticket matches multiple patterns, the **first matching tracker** in the config is used 120 | - If **no pattern matches**, it falls back to the `default_tracker` (useful for catch-all scenarios or tickets that don't follow a strict pattern) 121 | - You can name your trackers anything you want (e.g., `work-jira`, `my-company-tracker`, `team-issues`) 122 | 123 | **Best Practice**: Define specific patterns for each tracker to avoid conflicts: 124 | - ✅ Good: JIRA uses `^PROJ-\\d+$`, GitHub uses `^#\\d+$`, Linear uses `^ENG-\\d+$` (distinct patterns) 125 | - ❌ Avoid: JIRA uses `^[A-Z]+-\\d+$`, Linear uses `^[A-Z]+-\\d+$` (overlapping - first one wins) 126 | 127 | ## Supported Platforms 128 | 129 | - **macOS**: Uses `open` command 130 | - **Linux**: Uses `xdg-open` command 131 | - **Windows**: Uses `cmd /C start` command 132 | -------------------------------------------------------------------------------- /docs/FEATURE_IDEAS.md: -------------------------------------------------------------------------------- 1 | # WorkTimer TUI - Feature Ideas 2 | 3 | A comprehensive list of potential features to enhance the WorkTimer TUI application. 4 | 5 | ## Analytics & Reporting 6 | 7 | ### Daily Summary 8 | - Display total hours worked, break duration, and productivity metrics 9 | - Show at the top of the UI for quick reference 10 | - Display: Total time worked, Total breaks, Effective work hours, Number of tasks 11 | 12 | ### Weekly/Monthly Views 13 | - Navigate to different days and view aggregated stats 14 | - Show trends across weeks or months 15 | - Compare productivity across different time periods 16 | 17 | ### Time Category Tracking 18 | - Group tasks by categories (e.g., "Development", "Meetings", "Admin") 19 | - Show time breakdown per category 20 | - Visualize category distribution (pie chart style) 21 | 22 | ### Export Reports 23 | - Generate CSV/PDF reports for time tracking analysis 24 | - Export data for specific date ranges 25 | - Include summaries and detailed breakdowns 26 | 27 | --- 28 | 29 | ## Time Tracking Enhancements 30 | 31 | ### Timer Mode 32 | - Start an active timer on a task without needing to close it 33 | - Display real-time countdown/elapsed time in UI 34 | - Quick stop/pause functionality 35 | 36 | ### Auto-fill End Time 37 | - When creating a new task, intelligently suggest end times 38 | - Learn from previous patterns (e.g., "last meeting was 1 hour") 39 | - Allow quick acceptance or override 40 | 41 | ### Task Templates 42 | - Save common task names for quick reuse 43 | - Access templates via keyboard shortcut 44 | - Edit/manage template library 45 | 46 | ### Undo/Redo 47 | - Recover from accidental deletions 48 | - Maintain operation history (configurable depth) 49 | - Keyboard shortcuts: `Ctrl+Z` for undo, `Ctrl+Y` for redo 50 | 51 | --- 52 | 53 | ## UI/UX Improvements 54 | 55 | ### Search/Filter 56 | - Find tasks by name or date range 57 | - Filter by task category or duration 58 | - Real-time search highlighting 59 | 60 | ### Sorting Options 61 | - Sort by duration (ascending/descending) 62 | - Sort by start time (ascending/descending) 63 | - Sort by task name (alphabetical) 64 | - Persist sorting preference 65 | 66 | ### Color Coding 67 | - Visual distinction between work tasks, breaks, and meetings 68 | - Customizable color schemes 69 | - Highlight overdue or long tasks 70 | 71 | ### Status Bar 72 | - Show help text relevant to current mode 73 | - Display "modified" indicator for unsaved changes 74 | - Show current mode and selected field 75 | - Display save status 76 | 77 | ### Themes 78 | - Dark/light mode support 79 | - Customizable color palettes 80 | - Auto-detect system theme preference 81 | 82 | --- 83 | 84 | ## Multi-day Operations 85 | 86 | ### Day Navigation 87 | - Previous/Next day shortcuts (e.g., `[` and `]`) 88 | - Jump to specific date (e.g., `:date YYYY-MM-DD`) 89 | - Quick access to today/yesterday/tomorrow 90 | 91 | ### Copy Day 92 | - Duplicate yesterday's schedule as a template 93 | - Adjust times and task names as needed 94 | - Useful for recurring work patterns 95 | 96 | ### Recurring Tasks 97 | - Set up repeating work blocks (daily, weekly, monthly) 98 | - Auto-generate tasks on specified days 99 | - Modify individual occurrences 100 | 101 | ### Calendar View 102 | - Mini calendar for quick date navigation 103 | - Show summary of work time per day 104 | - Click/navigate to specific dates 105 | 106 | --- 107 | 108 | ## Data Management 109 | 110 | ### Import 111 | - Load data from CSV or other time tracking tools 112 | - Support common formats (CSV, JSON) 113 | - Preview before import 114 | 115 | ### Backup 116 | - Automatic or manual backups of work history 117 | - Backup to configurable location 118 | - Version control for backups 119 | 120 | ### Statistics 121 | - Historical trends showing work patterns over time 122 | - Average daily hours, busiest days, etc. 123 | - Export statistics reports 124 | 125 | ### Cloud Sync (Optional) 126 | - Optional cloud backup for multi-device access 127 | - Encrypt sensitive data 128 | - Conflict resolution for overlapping edits 129 | 130 | --- 131 | 132 | ## Quality of Life 133 | 134 | ### Validation 135 | - Prevent overlapping time entries with warnings 136 | - Alert if end time is before start time 137 | - Suggest corrections before saving 138 | 139 | ### Auto-complete 140 | - Remember previous task names while typing 141 | - Suggest based on frequency and recency 142 | - Quick access to common tasks 143 | 144 | ### Help & Documentation 145 | - Quick `?` reference card showing keybindings 146 | - Context-sensitive help based on current mode 147 | - Inline hints for new users 148 | 149 | ### Configuration File 150 | - Allow customizing default times, storage location, theme, etc. 151 | - Support `~/.config/work-tuimer/config.toml` or similar 152 | - Override defaults via CLI flags 153 | 154 | --- 155 | 156 | ## Priority Recommendations 157 | 158 | ### High Priority (Quick Wins) 159 | 1. **Daily Summary** - Provides immediate value with minimal complexity 160 | 2. **Day Navigation** - Essential for multi-day usage 161 | 3. **Status Bar** - Improves UX significantly 162 | 4. **Undo/Redo** - Reduces user friction 163 | 164 | ### Medium Priority (High Value) 165 | 1. **Search/Filter** - Useful for busy users 166 | 2. **Color Coding** - Visual feedback improvement 167 | 3. **Export Reports** - Business value for time tracking 168 | 4. **Validation** - Prevents errors 169 | 170 | ### Low Priority (Nice to Have) 171 | 1. **Cloud Sync** - Niche feature, complex implementation 172 | 2. **Calendar View** - Good but not essential 173 | 3. **Recurring Tasks** - Can be done manually for now 174 | 4. **Themes** - Cosmetic enhancement 175 | 176 | -------------------------------------------------------------------------------- /src/models/time_point.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fmt; 3 | use std::str::FromStr; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] 6 | pub struct TimePoint { 7 | pub hour: u8, 8 | pub minute: u8, 9 | } 10 | 11 | impl TimePoint { 12 | pub fn new(hour: u8, minute: u8) -> Result { 13 | if hour >= 24 { 14 | return Err(format!("Hour must be 0-23, got {}", hour)); 15 | } 16 | if minute >= 60 { 17 | return Err(format!("Minute must be 0-59, got {}", minute)); 18 | } 19 | Ok(TimePoint { hour, minute }) 20 | } 21 | 22 | pub fn from_minutes_since_midnight(minutes: u32) -> Result { 23 | if minutes >= 24 * 60 { 24 | return Err(format!("Minutes must be < 1440, got {}", minutes)); 25 | } 26 | Ok(TimePoint { 27 | hour: (minutes / 60) as u8, 28 | minute: (minutes % 60) as u8, 29 | }) 30 | } 31 | 32 | pub fn to_minutes_since_midnight(self) -> u32 { 33 | (self.hour as u32) * 60 + (self.minute as u32) 34 | } 35 | 36 | pub fn parse(s: &str) -> Result { 37 | let parts: Vec<&str> = s.split(':').collect(); 38 | if parts.len() != 2 { 39 | return Err(format!("Invalid time format: {}", s)); 40 | } 41 | 42 | let hour = parts[0] 43 | .parse::() 44 | .map_err(|_| format!("Invalid hour: {}", parts[0]))?; 45 | let minute = parts[1] 46 | .parse::() 47 | .map_err(|_| format!("Invalid minute: {}", parts[1]))?; 48 | 49 | Self::new(hour, minute) 50 | } 51 | } 52 | 53 | impl fmt::Display for TimePoint { 54 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 55 | write!(f, "{:02}:{:02}", self.hour, self.minute) 56 | } 57 | } 58 | 59 | impl FromStr for TimePoint { 60 | type Err = String; 61 | 62 | fn from_str(s: &str) -> Result { 63 | TimePoint::parse(s) 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use super::*; 70 | 71 | #[test] 72 | fn test_new_valid_time() { 73 | let time = TimePoint::new(14, 30).unwrap(); 74 | assert_eq!(time.hour, 14); 75 | assert_eq!(time.minute, 30); 76 | } 77 | 78 | #[test] 79 | fn test_new_boundary_values() { 80 | assert!(TimePoint::new(0, 0).is_ok()); 81 | assert!(TimePoint::new(23, 59).is_ok()); 82 | } 83 | 84 | #[test] 85 | fn test_new_invalid_hour() { 86 | assert!(TimePoint::new(24, 0).is_err()); 87 | assert!(TimePoint::new(25, 30).is_err()); 88 | } 89 | 90 | #[test] 91 | fn test_new_invalid_minute() { 92 | assert!(TimePoint::new(12, 60).is_err()); 93 | assert!(TimePoint::new(12, 99).is_err()); 94 | } 95 | 96 | #[test] 97 | fn test_parse_valid_time() { 98 | let time = TimePoint::parse("14:30").unwrap(); 99 | assert_eq!(time.hour, 14); 100 | assert_eq!(time.minute, 30); 101 | } 102 | 103 | #[test] 104 | fn test_parse_with_leading_zeros() { 105 | let time = TimePoint::parse("09:05").unwrap(); 106 | assert_eq!(time.hour, 9); 107 | assert_eq!(time.minute, 5); 108 | } 109 | 110 | #[test] 111 | fn test_parse_without_leading_zeros() { 112 | let time = TimePoint::parse("9:5").unwrap(); 113 | assert_eq!(time.hour, 9); 114 | assert_eq!(time.minute, 5); 115 | } 116 | 117 | #[test] 118 | fn test_parse_invalid_format() { 119 | assert!(TimePoint::parse("14").is_err()); 120 | assert!(TimePoint::parse("14:30:00").is_err()); 121 | assert!(TimePoint::parse("not a time").is_err()); 122 | assert!(TimePoint::parse("").is_err()); 123 | } 124 | 125 | #[test] 126 | fn test_parse_invalid_values() { 127 | assert!(TimePoint::parse("24:00").is_err()); 128 | assert!(TimePoint::parse("12:60").is_err()); 129 | assert!(TimePoint::parse("-1:30").is_err()); 130 | } 131 | 132 | #[test] 133 | fn test_to_minutes_since_midnight() { 134 | assert_eq!(TimePoint::new(0, 0).unwrap().to_minutes_since_midnight(), 0); 135 | assert_eq!( 136 | TimePoint::new(1, 0).unwrap().to_minutes_since_midnight(), 137 | 60 138 | ); 139 | assert_eq!( 140 | TimePoint::new(14, 30).unwrap().to_minutes_since_midnight(), 141 | 870 142 | ); 143 | assert_eq!( 144 | TimePoint::new(23, 59).unwrap().to_minutes_since_midnight(), 145 | 1439 146 | ); 147 | } 148 | 149 | #[test] 150 | fn test_from_minutes_since_midnight() { 151 | let time = TimePoint::from_minutes_since_midnight(0).unwrap(); 152 | assert_eq!(time, TimePoint::new(0, 0).unwrap()); 153 | 154 | let time = TimePoint::from_minutes_since_midnight(60).unwrap(); 155 | assert_eq!(time, TimePoint::new(1, 0).unwrap()); 156 | 157 | let time = TimePoint::from_minutes_since_midnight(870).unwrap(); 158 | assert_eq!(time, TimePoint::new(14, 30).unwrap()); 159 | 160 | let time = TimePoint::from_minutes_since_midnight(1439).unwrap(); 161 | assert_eq!(time, TimePoint::new(23, 59).unwrap()); 162 | } 163 | 164 | #[test] 165 | fn test_from_minutes_invalid() { 166 | assert!(TimePoint::from_minutes_since_midnight(1440).is_err()); 167 | assert!(TimePoint::from_minutes_since_midnight(9999).is_err()); 168 | } 169 | 170 | #[test] 171 | fn test_roundtrip_conversion() { 172 | let original = TimePoint::new(14, 30).unwrap(); 173 | let minutes = original.to_minutes_since_midnight(); 174 | let converted = TimePoint::from_minutes_since_midnight(minutes).unwrap(); 175 | assert_eq!(original, converted); 176 | } 177 | 178 | #[test] 179 | fn test_display_format() { 180 | assert_eq!(TimePoint::new(9, 5).unwrap().to_string(), "09:05"); 181 | assert_eq!(TimePoint::new(14, 30).unwrap().to_string(), "14:30"); 182 | assert_eq!(TimePoint::new(0, 0).unwrap().to_string(), "00:00"); 183 | assert_eq!(TimePoint::new(23, 59).unwrap().to_string(), "23:59"); 184 | } 185 | 186 | #[test] 187 | fn test_from_str_trait() { 188 | let time: TimePoint = "14:30".parse().unwrap(); 189 | assert_eq!(time.hour, 14); 190 | assert_eq!(time.minute, 30); 191 | } 192 | 193 | #[test] 194 | fn test_ordering() { 195 | let time1 = TimePoint::new(9, 0).unwrap(); 196 | let time2 = TimePoint::new(14, 30).unwrap(); 197 | let time3 = TimePoint::new(14, 30).unwrap(); 198 | 199 | assert!(time1 < time2); 200 | assert!(time2 > time1); 201 | assert_eq!(time2, time3); 202 | } 203 | 204 | #[test] 205 | fn test_clone_and_copy() { 206 | let time1 = TimePoint::new(14, 30).unwrap(); 207 | let time2 = time1; 208 | assert_eq!(time1, time2); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/models/work_record.rs: -------------------------------------------------------------------------------- 1 | use super::TimePoint; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct WorkRecord { 6 | pub id: u32, 7 | pub name: String, 8 | pub start: TimePoint, 9 | pub end: TimePoint, 10 | pub total_minutes: u32, 11 | #[serde(default)] 12 | pub description: String, 13 | } 14 | 15 | impl WorkRecord { 16 | pub fn new(id: u32, name: String, start: TimePoint, end: TimePoint) -> Self { 17 | let total_minutes = Self::calculate_duration(&start, &end); 18 | WorkRecord { 19 | id, 20 | name, 21 | start, 22 | end, 23 | total_minutes, 24 | description: String::new(), 25 | } 26 | } 27 | 28 | pub fn calculate_duration(start: &TimePoint, end: &TimePoint) -> u32 { 29 | let start_mins = start.to_minutes_since_midnight(); 30 | let end_mins = end.to_minutes_since_midnight(); 31 | 32 | if end_mins >= start_mins { 33 | end_mins - start_mins 34 | } else { 35 | (24 * 60 - start_mins) + end_mins 36 | } 37 | } 38 | 39 | pub fn update_duration(&mut self) { 40 | self.total_minutes = Self::calculate_duration(&self.start, &self.end); 41 | } 42 | 43 | pub fn format_duration(&self) -> String { 44 | let hours = self.total_minutes / 60; 45 | let minutes = self.total_minutes % 60; 46 | format!("{}h {:02}m", hours, minutes) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | 54 | #[test] 55 | fn test_new_work_record() { 56 | let start = TimePoint::new(9, 0).unwrap(); 57 | let end = TimePoint::new(17, 0).unwrap(); 58 | let record = WorkRecord::new(1, "Coding".to_string(), start, end); 59 | 60 | assert_eq!(record.id, 1); 61 | assert_eq!(record.name, "Coding"); 62 | assert_eq!(record.start, start); 63 | assert_eq!(record.end, end); 64 | assert_eq!(record.total_minutes, 480); // 8 hours 65 | assert_eq!(record.description, ""); 66 | } 67 | 68 | #[test] 69 | fn test_calculate_duration_same_day() { 70 | let start = TimePoint::new(9, 0).unwrap(); 71 | let end = TimePoint::new(17, 30).unwrap(); 72 | let duration = WorkRecord::calculate_duration(&start, &end); 73 | assert_eq!(duration, 510); // 8h 30m = 510 minutes 74 | } 75 | 76 | #[test] 77 | fn test_calculate_duration_zero() { 78 | let time = TimePoint::new(12, 0).unwrap(); 79 | let duration = WorkRecord::calculate_duration(&time, &time); 80 | assert_eq!(duration, 0); 81 | } 82 | 83 | #[test] 84 | fn test_calculate_duration_one_minute() { 85 | let start = TimePoint::new(12, 0).unwrap(); 86 | let end = TimePoint::new(12, 1).unwrap(); 87 | let duration = WorkRecord::calculate_duration(&start, &end); 88 | assert_eq!(duration, 1); 89 | } 90 | 91 | #[test] 92 | fn test_calculate_duration_overnight() { 93 | let start = TimePoint::new(23, 0).unwrap(); 94 | let end = TimePoint::new(1, 0).unwrap(); 95 | let duration = WorkRecord::calculate_duration(&start, &end); 96 | assert_eq!(duration, 120); // 2 hours 97 | } 98 | 99 | #[test] 100 | fn test_calculate_duration_overnight_complex() { 101 | let start = TimePoint::new(22, 30).unwrap(); 102 | let end = TimePoint::new(2, 15).unwrap(); 103 | let duration = WorkRecord::calculate_duration(&start, &end); 104 | assert_eq!(duration, 225); // 3h 45m = 225 minutes 105 | } 106 | 107 | #[test] 108 | fn test_calculate_duration_full_day() { 109 | let start = TimePoint::new(0, 0).unwrap(); 110 | let end = TimePoint::new(0, 0).unwrap(); 111 | let duration = WorkRecord::calculate_duration(&start, &end); 112 | assert_eq!(duration, 0); // Same time = 0 duration 113 | } 114 | 115 | #[test] 116 | fn test_calculate_duration_almost_full_day() { 117 | let start = TimePoint::new(0, 1).unwrap(); 118 | let end = TimePoint::new(0, 0).unwrap(); 119 | let duration = WorkRecord::calculate_duration(&start, &end); 120 | assert_eq!(duration, 1439); // 23h 59m 121 | } 122 | 123 | #[test] 124 | fn test_update_duration() { 125 | let start = TimePoint::new(9, 0).unwrap(); 126 | let end = TimePoint::new(10, 0).unwrap(); 127 | let mut record = WorkRecord::new(1, "Task".to_string(), start, end); 128 | assert_eq!(record.total_minutes, 60); 129 | 130 | // Change the end time 131 | record.end = TimePoint::new(11, 30).unwrap(); 132 | record.update_duration(); 133 | assert_eq!(record.total_minutes, 150); // 2h 30m 134 | } 135 | 136 | #[test] 137 | fn test_format_duration_zero() { 138 | let start = TimePoint::new(9, 0).unwrap(); 139 | let end = TimePoint::new(9, 0).unwrap(); 140 | let record = WorkRecord::new(1, "Task".to_string(), start, end); 141 | assert_eq!(record.format_duration(), "0h 00m"); 142 | } 143 | 144 | #[test] 145 | fn test_format_duration_minutes_only() { 146 | let start = TimePoint::new(9, 0).unwrap(); 147 | let end = TimePoint::new(9, 45).unwrap(); 148 | let record = WorkRecord::new(1, "Task".to_string(), start, end); 149 | assert_eq!(record.format_duration(), "0h 45m"); 150 | } 151 | 152 | #[test] 153 | fn test_format_duration_hours_only() { 154 | let start = TimePoint::new(9, 0).unwrap(); 155 | let end = TimePoint::new(12, 0).unwrap(); 156 | let record = WorkRecord::new(1, "Task".to_string(), start, end); 157 | assert_eq!(record.format_duration(), "3h 00m"); 158 | } 159 | 160 | #[test] 161 | fn test_format_duration_hours_and_minutes() { 162 | let start = TimePoint::new(9, 15).unwrap(); 163 | let end = TimePoint::new(17, 45).unwrap(); 164 | let record = WorkRecord::new(1, "Task".to_string(), start, end); 165 | assert_eq!(record.format_duration(), "8h 30m"); 166 | } 167 | 168 | #[test] 169 | fn test_format_duration_long() { 170 | let start = TimePoint::new(0, 0).unwrap(); 171 | let end = TimePoint::new(23, 59).unwrap(); 172 | let record = WorkRecord::new(1, "Task".to_string(), start, end); 173 | assert_eq!(record.format_duration(), "23h 59m"); 174 | } 175 | 176 | #[test] 177 | fn test_description_field() { 178 | let start = TimePoint::new(9, 0).unwrap(); 179 | let end = TimePoint::new(10, 0).unwrap(); 180 | let mut record = WorkRecord::new(1, "Task".to_string(), start, end); 181 | 182 | assert_eq!(record.description, ""); 183 | record.description = "Important meeting notes".to_string(); 184 | assert_eq!(record.description, "Important meeting notes"); 185 | } 186 | 187 | #[test] 188 | fn test_clone() { 189 | let start = TimePoint::new(9, 0).unwrap(); 190 | let end = TimePoint::new(17, 0).unwrap(); 191 | let record1 = WorkRecord::new(1, "Coding".to_string(), start, end); 192 | let record2 = record1.clone(); 193 | 194 | assert_eq!(record1.id, record2.id); 195 | assert_eq!(record1.name, record2.name); 196 | assert_eq!(record1.start, record2.start); 197 | assert_eq!(record1.end, record2.end); 198 | assert_eq!(record1.total_minutes, record2.total_minutes); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::storage::Storage; 2 | use crate::timer::TimerManager; 3 | use anyhow::Result; 4 | use clap::{Parser, Subcommand}; 5 | use std::time::Duration; 6 | 7 | /// WorkTimer CLI - Automatic time tracking 8 | #[derive(Parser)] 9 | #[command(name = "work-tuimer")] 10 | #[command(about = "Automatic time tracking with CLI commands and TUI", long_about = None)] 11 | #[command(version)] 12 | pub struct Cli { 13 | #[command(subcommand)] 14 | pub command: Commands, 15 | } 16 | 17 | /// Available CLI commands 18 | #[derive(Subcommand)] 19 | pub enum Commands { 20 | /// Manage timer sessions (start/stop/pause/resume/status) 21 | Session { 22 | #[command(subcommand)] 23 | command: SessionCommands, 24 | }, 25 | } 26 | 27 | /// Session management commands 28 | #[derive(Subcommand)] 29 | pub enum SessionCommands { 30 | /// Start a new timer session 31 | Start { 32 | /// Task name 33 | task: String, 34 | 35 | /// Optional task description 36 | #[arg(short, long)] 37 | description: Option, 38 | }, 39 | 40 | /// Stop the running timer session 41 | Stop, 42 | 43 | /// Pause the running timer session 44 | Pause, 45 | 46 | /// Resume the paused timer session 47 | Resume, 48 | 49 | /// Show status of running timer session 50 | Status, 51 | } 52 | 53 | /// Handle CLI command execution 54 | pub fn handle_command(cmd: Commands, storage: Storage) -> Result<()> { 55 | match cmd { 56 | Commands::Session { command } => match command { 57 | SessionCommands::Start { task, description } => { 58 | handle_start(task, description, storage) 59 | } 60 | SessionCommands::Stop => handle_stop(storage), 61 | SessionCommands::Pause => handle_pause(storage), 62 | SessionCommands::Resume => handle_resume(storage), 63 | SessionCommands::Status => handle_status(storage), 64 | }, 65 | } 66 | } 67 | 68 | /// Start a new session 69 | fn handle_start(task: String, description: Option, storage: Storage) -> Result<()> { 70 | let timer_manager = TimerManager::new(storage); 71 | 72 | // Trim task name 73 | let task = task.trim().to_string(); 74 | if task.is_empty() { 75 | return Err(anyhow::anyhow!("Task name cannot be empty")); 76 | } 77 | 78 | let timer = timer_manager.start(task, description, None, None)?; 79 | 80 | let start_time = format_time(timer.start_time); 81 | println!("✓ Session started"); 82 | println!(" Task: {}", timer.task_name); 83 | if let Some(desc) = &timer.description { 84 | println!(" Description: {}", desc); 85 | } 86 | println!(" Started at: {}", start_time); 87 | 88 | Ok(()) 89 | } 90 | 91 | /// Stop the running session 92 | fn handle_stop(storage: Storage) -> Result<()> { 93 | let timer_manager = TimerManager::new(storage); 94 | 95 | // Load and validate timer exists 96 | let timer = timer_manager 97 | .status()? 98 | .ok_or_else(|| anyhow::anyhow!("No session is running"))?; 99 | 100 | let elapsed = timer_manager.get_elapsed_duration(&timer); 101 | let formatted_duration = format_duration(elapsed); 102 | 103 | let start_time = format_time(timer.start_time); 104 | 105 | // Stop the timer and get the work record 106 | let record = timer_manager.stop()?; 107 | 108 | // Format end time from the work record (HH:MM format) 109 | let end_time = format!("{:02}:{:02}:{:02}", record.end.hour, record.end.minute, 0); 110 | 111 | println!("✓ Session stopped"); 112 | println!(" Task: {}", timer.task_name); 113 | println!(" Duration: {}", formatted_duration); 114 | println!(" Started at: {}", start_time); 115 | println!(" Ended at: {}", end_time); 116 | 117 | Ok(()) 118 | } 119 | 120 | /// Pause the running session 121 | fn handle_pause(storage: Storage) -> Result<()> { 122 | let timer_manager = TimerManager::new(storage); 123 | 124 | let timer = timer_manager 125 | .status()? 126 | .ok_or_else(|| anyhow::anyhow!("No session is running"))?; 127 | 128 | let _paused_timer = timer_manager.pause()?; 129 | let elapsed = timer_manager.get_elapsed_duration(&timer); 130 | let formatted_duration = format_duration(elapsed); 131 | 132 | println!("⏸ Session paused"); 133 | println!(" Task: {}", timer.task_name); 134 | println!(" Elapsed: {}", formatted_duration); 135 | 136 | Ok(()) 137 | } 138 | 139 | /// Resume the paused session 140 | fn handle_resume(storage: Storage) -> Result<()> { 141 | let timer_manager = TimerManager::new(storage); 142 | 143 | let timer = timer_manager 144 | .status()? 145 | .ok_or_else(|| anyhow::anyhow!("No session is running"))?; 146 | 147 | let _resumed_timer = timer_manager.resume()?; 148 | let elapsed = timer_manager.get_elapsed_duration(&timer); 149 | let formatted_duration = format_duration(elapsed); 150 | 151 | println!("▶ Session resumed"); 152 | println!(" Task: {}", timer.task_name); 153 | println!(" Total elapsed (before pause): {}", formatted_duration); 154 | 155 | Ok(()) 156 | } 157 | 158 | /// Show status of running session 159 | fn handle_status(storage: Storage) -> Result<()> { 160 | let timer_manager = TimerManager::new(storage); 161 | 162 | match timer_manager.status()? { 163 | Some(timer) => { 164 | let elapsed = timer_manager.get_elapsed_duration(&timer); 165 | let formatted_duration = format_duration(elapsed); 166 | let start_time = format_time(timer.start_time); 167 | 168 | println!("⏱ Session Status"); 169 | println!(" Task: {}", timer.task_name); 170 | println!( 171 | " Status: {}", 172 | match timer.status { 173 | crate::timer::TimerStatus::Running => "Running", 174 | crate::timer::TimerStatus::Paused => "Paused", 175 | crate::timer::TimerStatus::Stopped => "Stopped", 176 | } 177 | ); 178 | println!(" Elapsed: {}", formatted_duration); 179 | println!(" Started at: {}", start_time); 180 | if let Some(desc) = &timer.description { 181 | println!(" Description: {}", desc); 182 | } 183 | } 184 | None => { 185 | println!("No session is currently running"); 186 | } 187 | } 188 | 189 | Ok(()) 190 | } 191 | 192 | /// Format time::OffsetDateTime for display (HH:MM:SS) 193 | fn format_time(dt: time::OffsetDateTime) -> String { 194 | format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second()) 195 | } 196 | 197 | /// Format Duration for display (h:mm:ss or mm:ss) 198 | fn format_duration(duration: Duration) -> String { 199 | let total_secs = duration.as_secs(); 200 | let hours = total_secs / 3600; 201 | let minutes = (total_secs % 3600) / 60; 202 | let seconds = total_secs % 60; 203 | 204 | if hours > 0 { 205 | format!("{}h {:02}m {:02}s", hours, minutes, seconds) 206 | } else { 207 | format!("{}m {:02}s", minutes, seconds) 208 | } 209 | } 210 | 211 | #[cfg(test)] 212 | mod tests { 213 | use super::*; 214 | 215 | #[test] 216 | fn test_format_duration_hours_minutes_seconds() { 217 | let duration = Duration::from_secs(3661); // 1h 1m 1s 218 | assert_eq!(format_duration(duration), "1h 01m 01s"); 219 | } 220 | 221 | #[test] 222 | fn test_format_duration_minutes_seconds() { 223 | let duration = Duration::from_secs(125); // 2m 5s 224 | assert_eq!(format_duration(duration), "2m 05s"); 225 | } 226 | 227 | #[test] 228 | fn test_format_duration_seconds_only() { 229 | let duration = Duration::from_secs(45); 230 | assert_eq!(format_duration(duration), "0m 45s"); 231 | } 232 | 233 | #[test] 234 | fn test_format_duration_zero() { 235 | let duration = Duration::from_secs(0); 236 | assert_eq!(format_duration(duration), "0m 00s"); 237 | } 238 | 239 | #[test] 240 | fn test_format_time() { 241 | use time::macros::datetime; 242 | let dt = datetime!(2025-01-15 14:30:45 UTC); 243 | assert_eq!(format_time(dt), "14:30:45"); 244 | } 245 | 246 | #[test] 247 | fn test_cli_has_version() { 248 | use clap::CommandFactory; 249 | let cmd = Cli::command(); 250 | let version = cmd.get_version(); 251 | assert!(version.is_some(), "CLI should have version configured"); 252 | // Version comes from Cargo.toml 253 | assert_eq!(version.unwrap(), env!("CARGO_PKG_VERSION")); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /docs/SESSIONS.md: -------------------------------------------------------------------------------- 1 | # Timer Sessions 2 | 3 | Sessions track time in real-time with automatic updates, pause/resume support, and shared state between TUI and CLI. 4 | 5 | ## Table of Contents 6 | 7 | - [What are Sessions?](#what-are-sessions) 8 | - [TUI Usage](#tui-usage) 9 | - [CLI Usage](#cli-usage) 10 | - [Session Features](#session-features) 11 | - [Common Workflows](#common-workflows) 12 | - [Data Persistence](#data-persistence) 13 | 14 | ## What are Sessions? 15 | 16 | A session is an active timer that tracks time spent on a task. Recorded data: 17 | - Task name and optional description 18 | - Start time 19 | - Elapsed time (updated in real-time) 20 | - Pause/resume history 21 | - Final duration when stopped 22 | 23 | Start a timer when you begin work, stop it when done. End time is automatically set to the current time. 24 | 25 | ## TUI Usage 26 | 27 | ### Starting a Session 28 | 29 | In the TUI, you must select an existing work record, then press `S` to start a session: 30 | - Updates that record's end time when stopped 31 | - Use to extend existing entries 32 | 33 | To create a new task with a session, use the CLI `session start` command. 34 | 35 | ### Session Controls 36 | 37 | | Key | Action | 38 | |-----|--------| 39 | | `S` | Start/Stop session on selected record | 40 | | `P` | Pause/Resume active session | 41 | 42 | ### Visual Indicators 43 | 44 | Active session displays: 45 | 46 | 1. **Timer Status Bar** (top of screen): 47 | ``` 48 | ⏱ Running: Task Name | 1h 23m 45s | Status: Running 49 | ``` 50 | Shows task name, elapsed time (H:MM:SS), and status (Running/Paused) 51 | 52 | 2. **Record Highlighting**: 53 | - Active session records show ⏱ icon 54 | 55 | ### Session States 56 | 57 | - **Running**: Time is actively being tracked 58 | - **Paused**: Timer is paused, paused duration is tracked separately 59 | - **Stopped**: Session has ended, time is saved to the work record 60 | 61 | ## CLI Usage 62 | 63 | Control sessions from the command line without opening the TUI. 64 | 65 | ### Starting a Session 66 | 67 | The CLI can create new tasks with sessions: 68 | 69 | ```bash 70 | # Start a basic session 71 | work-tuimer session start "My Task" 72 | 73 | # Start with a description 74 | work-tuimer session start "Bug Fix" -d "Fixing authentication issue" 75 | ``` 76 | 77 | This creates a new work record and starts tracking immediately. 78 | 79 | Output: 80 | ``` 81 | ✓ Session started 82 | Task: My Task 83 | Description: Optional description 84 | Started at: 14:30:45 85 | ``` 86 | 87 | ### Checking Session Status 88 | 89 | ```bash 90 | work-tuimer session status 91 | ``` 92 | 93 | Output: 94 | ``` 95 | ⏱ Session Status 96 | Task: My Task 97 | Status: Running 98 | Elapsed: 1h 23m 45s 99 | Started at: 14:30:45 100 | Description: Optional description 101 | ``` 102 | 103 | ### Pausing and Resuming 104 | 105 | ```bash 106 | # Pause the active session 107 | work-tuimer session pause 108 | ``` 109 | 110 | Output: 111 | ``` 112 | ⏸ Session paused 113 | Task: My Task 114 | Elapsed: 0m 45s 115 | ``` 116 | 117 | ```bash 118 | # Resume the paused session 119 | work-tuimer session resume 120 | ``` 121 | 122 | Output: 123 | ``` 124 | ▶ Session resumed 125 | Task: My Task 126 | Total elapsed (before pause): 0m 45s 127 | ``` 128 | 129 | ### Stopping a Session 130 | 131 | ```bash 132 | work-tuimer session stop 133 | ``` 134 | 135 | Output: 136 | ``` 137 | ✓ Session stopped 138 | Task: My Task 139 | Duration: 1h 23m 45s 140 | Started at: 14:30:45 141 | Ended at: 15:54:30 142 | ``` 143 | 144 | ### Error Handling 145 | 146 | If you try to control a session when none is running: 147 | ```bash 148 | $ work-tuimer session stop 149 | Error: No session is running 150 | ``` 151 | 152 | If you try to start a session when one is already running: 153 | ```bash 154 | $ work-tuimer session start "Another Task" 155 | Error: A timer is already running 156 | ``` 157 | 158 | ## Session Features 159 | 160 | ### Automatic Time Updates 161 | 162 | End time is automatically set to current time when stopped. 163 | 164 | ### Pause Support 165 | 166 | Pause and resume sessions: 167 | - **Elapsed time**: Only counts active time (excludes paused duration) 168 | - **Paused duration**: Tracked separately 169 | - **Multiple pauses**: Pause/resume as needed 170 | 171 | ### Persistence Across Restarts 172 | 173 | Sessions survive application restarts. State is saved to `~/.local/share/work-tuimer/active_timer.json`. 174 | 175 | ### Cross-Date Support 176 | 177 | Start a session on a record from any date: 178 | - Navigate to any day in the TUI 179 | - Start a session on that day's record 180 | - End time updates correctly when stopped 181 | 182 | ### CLI and TUI Integration 183 | 184 | Sessions share state across both interfaces: 185 | - Start in CLI, pause in TUI 186 | - Start in TUI, check status in CLI 187 | - Changes sync automatically 188 | 189 | TUI auto-reloads every 500ms to reflect external changes. 190 | 191 | ## Common Workflows 192 | 193 | ### Workflow 1: Simple Session 194 | 195 | ```bash 196 | # Start working 197 | work-tuimer session start "Write documentation" 198 | 199 | # ... work on your task ... 200 | 201 | # Stop when done 202 | work-tuimer session stop 203 | ``` 204 | 205 | ### Workflow 2: Session with Breaks 206 | 207 | ```bash 208 | # Start working 209 | work-tuimer session start "Code review" 210 | 211 | # ... work for a while ... 212 | 213 | # Take a break 214 | work-tuimer session pause 215 | 216 | # ... break time ... 217 | 218 | # Resume work 219 | work-tuimer session resume 220 | 221 | # ... finish up ... 222 | 223 | # Stop when done 224 | work-tuimer session stop 225 | ``` 226 | 227 | ### Workflow 3: TUI + CLI Hybrid 228 | 229 | ```bash 230 | # Start in CLI before opening TUI 231 | work-tuimer session start "Morning standup prep" 232 | 233 | # Open TUI to view full day 234 | work-tuimer 235 | 236 | # Continue working in TUI, see session status at top 237 | # Press P to pause, S to stop, or let it run 238 | 239 | # Later, check status from CLI 240 | work-tuimer session status 241 | 242 | # Stop from CLI when done 243 | work-tuimer session stop 244 | ``` 245 | 246 | ### Workflow 4: Updating Existing Records 247 | 248 | In the TUI: 249 | 1. Navigate to a work record to extend 250 | 2. Press `S` to start a session 251 | 3. Work continues... 252 | 4. Press `S` again to stop 253 | 254 | Record's end time and duration update automatically. 255 | 256 | ### Workflow 5: Quick Status Checks 257 | 258 | ```bash 259 | # Quick check if anything is running 260 | work-tuimer session status 261 | 262 | # If nothing running, start new task 263 | work-tuimer session start "New task" 264 | ``` 265 | 266 | ## Data Persistence 267 | 268 | ### Active Session Storage 269 | 270 | Active sessions are stored in: 271 | - **Linux/macOS**: `~/.local/share/work-tuimer/active_timer.json` 272 | - **Windows**: `%APPDATA%\work-tuimer\active_timer.json` 273 | - **Fallback**: `./data/active_timer.json` 274 | 275 | ### Session State Format 276 | 277 | ```json 278 | { 279 | "task_name": "My Task", 280 | "description": "Optional description", 281 | "start_time": "2025-11-12T14:30:45.123456789Z", 282 | "status": "Running", 283 | "paused_duration_secs": 0, 284 | "source_record_id": 1, 285 | "date": "2025-11-12" 286 | } 287 | ``` 288 | 289 | ### Work Record Integration 290 | 291 | When a session stops, it creates or updates a work record in the daily file: 292 | 293 | ```json 294 | { 295 | "date": "2025-11-12", 296 | "work_records": [ 297 | { 298 | "id": 1, 299 | "name": "My Task", 300 | "start": "14:30", 301 | "end": "15:54", 302 | "total_minutes": 84, 303 | "description": "Optional description" 304 | } 305 | ] 306 | } 307 | ``` 308 | 309 | ### File Location Priority 310 | 311 | Daily work records are saved to (checked in order): 312 | 1. `~/.local/share/work-tuimer/YYYY-MM-DD.json` 313 | 2. `./data/YYYY-MM-DD.json` (fallback) 314 | 315 | ## Tips 316 | 317 | 1. **Use descriptive task names**: Easier to identify work later 318 | 2. **Add descriptions for context**: Useful when reviewing time logs 319 | 3. **Pause during interruptions**: Accurate tracking excludes breaks 320 | 4. **Check status regularly**: `work-tuimer session status` shows active sessions 321 | 5. **Stop sessions promptly**: Remember to stop when switching tasks 322 | 6. **Use CLI for quick starts**: Start sessions without opening TUI 323 | 7. **Work in both interfaces**: Auto-reload syncs changes every 500ms 324 | 325 | ## Troubleshooting 326 | 327 | ### Session not showing in TUI 328 | 329 | - The TUI auto-reloads every 500ms, wait a moment 330 | - If still not visible, restart the TUI 331 | 332 | ### Lost session after restart 333 | 334 | - Sessions are saved to `active_timer.json` - check if the file exists 335 | - If the file was deleted, the session cannot be recovered 336 | 337 | ### Wrong end time on stopped session 338 | 339 | - End time is set to when you stopped 340 | - If stopped late, manually edit the end time in the TUI 341 | 342 | ### CLI and TUI showing different data 343 | 344 | - The TUI caches data and auto-reloads every 500ms 345 | - Wait a moment or restart the TUI to see latest changes 346 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WorkTUImer 2 | ![work-tuimer](https://github.com/user-attachments/assets/207f9b66-0b08-4e97-a471-a9f413a7369c) 3 | 4 | Live demo: https://x.com/KsenKamil/status/1985423210859368716 5 | 6 | Simple, keyboard-driven TUI for time-tracking that allows you to quickly add time blocks and automatically group time if same task was done in different sessions 7 | Built with Rust and ratatui for efficient time management. 8 | 9 | ## Features 10 | 11 | - **Fully keyboard-driven**: No mouse required - everything accessible via keybinds 12 | - **Active timer tracking**: Start/stop/pause timers that automatically update work records with actual time spent 13 | - **Time as PIN-Inputs**: Easly type time with 4 clicks, since all time inputs are PIN-input alike 14 | - **Log tasks and breaks, get totals automatically**: Add work entries with start/end times - durations are calculated and summed 15 | - **Task picker with history**: Quickly select from previously used task names or create new ones 16 | - **Calendar navigation**: Jump between days, weeks, and months 17 | - **Arrow keys or Vim motions**: Navigate with arrow keys + Enter, or use h/j/k/l + i for Vim-style workflow 18 | - **Inline editing with undo/redo**: Fix mistakes in place, up to 50 levels of history 19 | - **Auto-saves locally per day**: Data stored as JSON files, for each day, on your machine (`~/.local/share/work-tuimer/`) 20 | - **Optional ticket integration**: Detect and link to JIRA, Linear, GitHub issues from task names - open ticket URLs directly in your browser from the app 21 | 22 | ## Installation 23 | 24 | ### Package Managers 25 | 26 | #### Cargo (Rust) 27 | LINK: https://crates.io/crates/work-tuimer 28 | ```sh 29 | cargo install work-tuimer 30 | ``` 31 | 32 | #### (!!! NOT READY YET !!!) Homebrew (macOS/Linux) 33 | 34 | ```sh 35 | brew install work-tuimer 36 | ``` 37 | 38 | #### Arch Linux (AUR) 39 | LINK: https://aur.archlinux.org/packages/work-tuimer 40 | ```sh 41 | # Using yay 42 | yay -S work-tuimer 43 | 44 | # Or manually 45 | git clone https://aur.archlinux.org/work-tuimer.git 46 | cd work-tuimer 47 | makepkg -si 48 | ``` 49 | 50 | #### FreeBSD 51 | 52 | ```sh 53 | pkg install work-tuimer 54 | ``` 55 | 56 | ### Pre-built Binaries 57 | 58 | Download the latest pre-built binary for your platform from [GitHub Releases](https://github.com/Kamyil/work-tuimer/releases): 59 | 60 | - **Linux (x86_64)**: `work-tuimer-linux-x86_64` 61 | - **macOS (Intel)**: `work-tuimer-macos-x86_64` 62 | - **macOS (Apple Silicon)**: `work-tuimer-macos-aarch64` 63 | - **Windows**: `work-tuimer-windows-x86_64.exe` 64 | 65 | After downloading, make the binary executable and run it: 66 | 67 | ```bash 68 | # Linux / macOS 69 | chmod +x work-tuimer-linux-x86_64 70 | ./work-tuimer-linux-x86_64 71 | 72 | # Windows 73 | work-tuimer-windows-x86_64.exe 74 | ``` 75 | 76 | ### Build from Source 77 | 78 | If you prefer to build from source or don't see a binary for your platform: 79 | 80 | ```bash 81 | cargo build --release 82 | ./target/release/work-tuimer 83 | ``` 84 | 85 | ## Usage 86 | 87 | ### Browse Mode 88 | 89 | | Key | Action | 90 | |-----|--------| 91 | | `↑/k` | Move selection up | 92 | | `↓/j` | Move selection down | 93 | | `←/h` | Move field left (Name → Start → End) | 94 | | `→/l` | Move field right (Name → Start → End) | 95 | | `[` | Navigate to previous day (auto-saves) | 96 | | `]` | Navigate to next day (auto-saves) | 97 | | `C` | Open calendar view for date navigation | 98 | | `Enter/i` | Enter edit mode on selected field | 99 | | `c` | Change task name (opens picker to select/filter/create) | 100 | | `n` | Add new work record | 101 | | `b` | Add break (uses selected record's end time as start) | 102 | | `d` | Delete selected record | 103 | | `v` | Enter visual mode (multi-select) | 104 | | `S` | Start/Stop timer for selected record | 105 | | `P` | Pause/Resume active timer | 106 | | `t` | Set current time on selected field | 107 | | `T` | Open ticket in browser (only visible if config exists) | 108 | | `L` | Open worklog URL in browser (only visible if config exists) | 109 | | `u` | Undo last change | 110 | | `r` | Redo undone change | 111 | | `s` | Save to file | 112 | | `q` | Quit (auto-saves) | 113 | 114 | ### Edit Mode 115 | 116 | | Key | Action | 117 | |-----|--------| 118 | | `Tab` | Next field (Name → Start → End → Description → Name) | 119 | | `Enter` | Save changes and exit edit mode | 120 | | `Esc` | Cancel and exit edit mode | 121 | | `Backspace` | Delete character | 122 | | Any char | Insert character | 123 | 124 | ### Task Picker (accessed via `c` in Browse mode) 125 | 126 | Press `c` on the Name field to open the task picker: 127 | - Shows all unique task names from the current day 128 | - Type to filter the list 129 | - Press Enter to select a task or create a new one 130 | 131 | | Key | Action | 132 | |-----|--------| 133 | | Any char | Type to filter tasks or create new name (including h/j/k/l) | 134 | | `↑` | Move selection up in filtered list | 135 | | `↓` | Move selection down in filtered list | 136 | | `Enter` | Select highlighted task or create typed name | 137 | | `Backspace` | Delete character from filter | 138 | | `Esc` | Cancel and return to browse mode | 139 | 140 | ### Visual Mode 141 | 142 | | Key | Action | 143 | |-----|--------| 144 | | `↑/k` | Extend selection up | 145 | | `↓/j` | Extend selection down | 146 | | `d` | Delete selected records | 147 | | `Esc` | Exit visual mode | 148 | 149 | ### Calendar View 150 | 151 | | Key | Action | 152 | |-----|--------| 153 | | `↑/k` | Move selection up (1 week) | 154 | | `↓/j` | Move selection down (1 week) | 155 | | `←/h` | Move selection left (1 day) | 156 | | `→/l` | Move selection right (1 day) | 157 | | `[//.` | Next month | 159 | | `Enter` | Jump to selected date | 160 | | `Esc` | Close calendar view | 161 | 162 | ## Timer Sessions 163 | 164 | WorkTimer includes a built-in timer system for real-time time tracking. Sessions allow you to track time as you work, with automatic updates, pause/resume support, and seamless CLI/TUI integration. 165 | 166 | ### Quick Start 167 | 168 | **In the TUI:** 169 | 1. Select a work record and press `S` to start a session 170 | 2. See the timer status bar at the top with elapsed time 171 | 3. Press `P` to pause/resume, `S` to stop 172 | 173 | **From the CLI:** 174 | ```bash 175 | # Start a session 176 | work-tuimer session start "My Task" 177 | 178 | # Check status 179 | work-tuimer session status 180 | 181 | # Pause/resume 182 | work-tuimer session pause 183 | work-tuimer session resume 184 | 185 | # Stop and save 186 | work-tuimer session stop 187 | ``` 188 | 189 | ### Key Features 190 | 191 | - **Automatic time updates**: End time is set when you stop the session 192 | - **Pause support**: Only active time is counted, paused duration tracked separately 193 | - **Cross-session persistence**: Sessions survive app restarts 194 | - **CLI + TUI integration**: Start in CLI, stop in TUI, or vice versa 195 | - **Visual indicators**: Active sessions highlighted with ⏱ icon 196 | 197 | **For more info, check [Timer Sessions Guide](docs/SESSIONS.md)** 198 | 199 | ## Issue Tracker Integration 200 | 201 | WorkTimer supports automatic ticket detection from task names and browser integration for **any** issue tracker (JIRA, Linear, GitHub Issues, GitLab, Azure DevOps, etc.). 202 | 203 | ### Quick Start 204 | 205 | 1. **Include ticket IDs in task names**: `"PROJ-123: Fix login bug"` or `"#456: Update docs"` 206 | 2. **See the ticket badge**: Tasks with detected tickets show `🎫 Task Name [PROJ-123]` 207 | 3. **Open in browser**: Press `T` to open the ticket or `L` to open the worklog 208 | 209 | ### Configuration 210 | 211 | Create a config file at `~/.config/work-tuimer/config.toml`: 212 | 213 | ```toml 214 | [integrations] 215 | default_tracker = "my-jira" 216 | 217 | [integrations.trackers.my-jira] 218 | enabled = true 219 | base_url = "https://your-company.atlassian.net" 220 | ticket_patterns = ["^PROJ-\\d+$", "^WORK-\\d+$"] 221 | browse_url = "{base_url}/browse/{ticket}" 222 | worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1" 223 | ``` 224 | 225 | **For more info, check [Issue Tracker Integration Guide](docs/ISSUE_TRACKER_INTEGRATION.md)** 226 | 227 | ## Theme Configuration 228 | 229 | WorkTimer supports customizable color themes to personalize your UI experience. The application includes 8 pre-defined themes and supports custom theme definitions. 230 | 231 | ```toml 232 | [theme] 233 | active = "kanagawa" # Options: default, kanagawa, catppuccin, gruvbox, monokai, dracula, everforest, terminal 234 | ``` 235 | Available Themes: default, kanagawa, catppuccin, gruvbox, monokai, dracula, everforest, terminal 236 | 237 | **For more info, check [Theme Configuration Guide](docs/THEMING.md)** 238 | 239 | ## Data Format 240 | 241 | Data is stored per day in JSON format: 242 | 243 | ```json 244 | { 245 | "date": "2025-10-31", 246 | "work_records": [ 247 | { 248 | "id": 1, 249 | "name": "Task name", 250 | "start": "09:00", 251 | "end": "12:00", 252 | "total_minutes": 180, 253 | "description": "Optional description" 254 | } 255 | ] 256 | } 257 | ``` 258 | 259 | Storage locations (checked in order): 260 | 1. `~/.local/share/work-tuimer/YYYY-MM-DD.json` 261 | 2. `./data/YYYY-MM-DD.json` (fallback) 262 | 263 | ## Project Structure 264 | 265 | ``` 266 | src/ 267 | ├── models/ # Core data models 268 | │ ├── time_point.rs - Time representation (HH:MM format) 269 | │ ├── work_record.rs - Individual work entry 270 | │ └── day_data.rs - Daily collection of records 271 | ├── storage/ # File I/O 272 | │ └── storage.rs - JSON persistence 273 | ├── ui/ # Terminal interface 274 | │ ├── app_state.rs - State management & event handlers 275 | │ └── render.rs - UI rendering with ratatui 276 | └── main.rs # Entry point & event loop 277 | ``` 278 | 279 | ## Development 280 | 281 | ```bash 282 | cargo check 283 | cargo build 284 | cargo test 285 | cargo clippy 286 | ``` 287 | 288 | ### Creating a Release 289 | 290 | This project uses GitHub Actions to automatically build and publish pre-built binaries. To create a new release: 291 | 292 | ```bash 293 | just release v0.2.0 294 | ``` 295 | 296 | This will: 297 | 1. Create a git tag for the version 298 | 2. Push the tag to GitHub 299 | 3. Trigger GitHub Actions to build binaries for all platforms 300 | 4. Automatically upload the binaries to a GitHub Release 301 | 302 | You can track the build progress in the [Actions tab](https://github.com/sst/work-tuimer/actions). 303 | 304 | ## License 305 | 306 | MIT 307 | -------------------------------------------------------------------------------- /src/ui/history.rs: -------------------------------------------------------------------------------- 1 | use crate::models::DayData; 2 | 3 | const MAX_HISTORY_DEPTH: usize = 50; 4 | 5 | #[derive(Debug, Default)] 6 | pub struct History { 7 | undo_stack: Vec, 8 | redo_stack: Vec, 9 | } 10 | 11 | impl History { 12 | pub fn new() -> Self { 13 | Self::default() 14 | } 15 | 16 | pub fn push(&mut self, state: DayData) { 17 | if self.undo_stack.len() >= MAX_HISTORY_DEPTH { 18 | self.undo_stack.remove(0); 19 | } 20 | self.undo_stack.push(state); 21 | self.redo_stack.clear(); 22 | } 23 | 24 | pub fn undo(&mut self, current_state: DayData) -> Option { 25 | if let Some(previous_state) = self.undo_stack.pop() { 26 | self.redo_stack.push(current_state); 27 | Some(previous_state) 28 | } else { 29 | None 30 | } 31 | } 32 | 33 | pub fn redo(&mut self, current_state: DayData) -> Option { 34 | if let Some(next_state) = self.redo_stack.pop() { 35 | self.undo_stack.push(current_state); 36 | Some(next_state) 37 | } else { 38 | None 39 | } 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | use crate::models::{TimePoint, WorkRecord}; 47 | use time::Date; 48 | 49 | fn create_test_date() -> Date { 50 | Date::from_calendar_date(2025, time::Month::November, 6).unwrap() 51 | } 52 | 53 | fn create_day_with_record(id: u32, name: &str) -> DayData { 54 | let mut day = DayData::new(create_test_date()); 55 | let start = TimePoint::new(9, 0).unwrap(); 56 | let end = TimePoint::new(17, 0).unwrap(); 57 | let record = WorkRecord::new(id, name.to_string(), start, end); 58 | day.add_record(record); 59 | day 60 | } 61 | 62 | #[test] 63 | fn test_new_history() { 64 | let history = History::new(); 65 | assert_eq!(history.undo_stack.len(), 0); 66 | assert_eq!(history.redo_stack.len(), 0); 67 | } 68 | 69 | #[test] 70 | fn test_push_single_state() { 71 | let mut history = History::new(); 72 | let day = create_day_with_record(1, "Task1"); 73 | 74 | history.push(day); 75 | 76 | assert_eq!(history.undo_stack.len(), 1); 77 | assert_eq!(history.redo_stack.len(), 0); 78 | } 79 | 80 | #[test] 81 | fn test_push_multiple_states() { 82 | let mut history = History::new(); 83 | 84 | history.push(create_day_with_record(1, "Task1")); 85 | history.push(create_day_with_record(2, "Task2")); 86 | history.push(create_day_with_record(3, "Task3")); 87 | 88 | assert_eq!(history.undo_stack.len(), 3); 89 | assert_eq!(history.redo_stack.len(), 0); 90 | } 91 | 92 | #[test] 93 | fn test_push_clears_redo_stack() { 94 | let mut history = History::new(); 95 | let day1 = create_day_with_record(1, "Task1"); 96 | let day2 = create_day_with_record(2, "Task2"); 97 | let day3 = create_day_with_record(3, "Task3"); 98 | 99 | history.push(day1.clone()); 100 | history.push(day2.clone()); 101 | 102 | // Undo once to populate redo stack 103 | history.undo(day3.clone()); 104 | assert_eq!(history.redo_stack.len(), 1); 105 | 106 | // Push new state should clear redo stack 107 | history.push(create_day_with_record(4, "Task4")); 108 | assert_eq!(history.redo_stack.len(), 0); 109 | } 110 | 111 | #[test] 112 | fn test_push_respects_max_depth() { 113 | let mut history = History::new(); 114 | 115 | // Push more than MAX_HISTORY_DEPTH states 116 | for i in 0..55 { 117 | history.push(create_day_with_record(i, &format!("Task{}", i))); 118 | } 119 | 120 | // Should not exceed MAX_HISTORY_DEPTH 121 | assert_eq!(history.undo_stack.len(), MAX_HISTORY_DEPTH); 122 | } 123 | 124 | #[test] 125 | fn test_push_max_depth_removes_oldest() { 126 | let mut history = History::new(); 127 | 128 | // Push MAX_HISTORY_DEPTH states 129 | for i in 0..MAX_HISTORY_DEPTH { 130 | history.push(create_day_with_record(i as u32, &format!("Task{}", i))); 131 | } 132 | 133 | // Push one more 134 | history.push(create_day_with_record(999, "NewTask")); 135 | 136 | assert_eq!(history.undo_stack.len(), MAX_HISTORY_DEPTH); 137 | // The last one should be the newest 138 | assert_eq!( 139 | history 140 | .undo_stack 141 | .last() 142 | .unwrap() 143 | .work_records 144 | .get(&999) 145 | .unwrap() 146 | .name, 147 | "NewTask" 148 | ); 149 | } 150 | 151 | #[test] 152 | fn test_undo_empty_history() { 153 | let mut history = History::new(); 154 | let current = create_day_with_record(1, "Current"); 155 | 156 | let result = history.undo(current); 157 | 158 | assert!(result.is_none()); 159 | assert_eq!(history.undo_stack.len(), 0); 160 | assert_eq!(history.redo_stack.len(), 0); 161 | } 162 | 163 | #[test] 164 | fn test_undo_single_state() { 165 | let mut history = History::new(); 166 | let day1 = create_day_with_record(1, "Task1"); 167 | let day2 = create_day_with_record(2, "Task2"); 168 | 169 | history.push(day1.clone()); 170 | 171 | let result = history.undo(day2.clone()); 172 | 173 | assert!(result.is_some()); 174 | let previous = result.unwrap(); 175 | assert_eq!(previous.work_records.get(&1).unwrap().name, "Task1"); 176 | assert_eq!(history.undo_stack.len(), 0); 177 | assert_eq!(history.redo_stack.len(), 1); 178 | } 179 | 180 | #[test] 181 | fn test_undo_multiple_times() { 182 | let mut history = History::new(); 183 | let day1 = create_day_with_record(1, "Task1"); 184 | let day2 = create_day_with_record(2, "Task2"); 185 | let day3 = create_day_with_record(3, "Task3"); 186 | 187 | history.push(day1.clone()); 188 | history.push(day2.clone()); 189 | 190 | // First undo 191 | let result1 = history.undo(day3.clone()); 192 | assert!(result1.is_some()); 193 | assert_eq!(result1.unwrap().work_records.get(&2).unwrap().name, "Task2"); 194 | 195 | // Second undo 196 | let result2 = history.undo(day2.clone()); 197 | assert!(result2.is_some()); 198 | assert_eq!(result2.unwrap().work_records.get(&1).unwrap().name, "Task1"); 199 | 200 | // Third undo should return None 201 | let result3 = history.undo(day1.clone()); 202 | assert!(result3.is_none()); 203 | } 204 | 205 | #[test] 206 | fn test_undo_moves_to_redo_stack() { 207 | let mut history = History::new(); 208 | let day1 = create_day_with_record(1, "Task1"); 209 | let day2 = create_day_with_record(2, "Task2"); 210 | 211 | history.push(day1.clone()); 212 | history.undo(day2.clone()); 213 | 214 | assert_eq!(history.redo_stack.len(), 1); 215 | assert_eq!( 216 | history 217 | .redo_stack 218 | .last() 219 | .unwrap() 220 | .work_records 221 | .get(&2) 222 | .unwrap() 223 | .name, 224 | "Task2" 225 | ); 226 | } 227 | 228 | #[test] 229 | fn test_redo_empty_redo_stack() { 230 | let mut history = History::new(); 231 | let current = create_day_with_record(1, "Current"); 232 | 233 | let result = history.redo(current); 234 | 235 | assert!(result.is_none()); 236 | } 237 | 238 | #[test] 239 | fn test_redo_after_undo() { 240 | let mut history = History::new(); 241 | let day1 = create_day_with_record(1, "Task1"); 242 | let day2 = create_day_with_record(2, "Task2"); 243 | let day3 = create_day_with_record(3, "Task3"); 244 | 245 | history.push(day1.clone()); 246 | history.push(day2.clone()); 247 | 248 | // Undo 249 | history.undo(day3.clone()); 250 | 251 | // Redo 252 | let result = history.redo(day2.clone()); 253 | assert!(result.is_some()); 254 | assert_eq!(result.unwrap().work_records.get(&3).unwrap().name, "Task3"); 255 | } 256 | 257 | #[test] 258 | fn test_undo_redo_cycle() { 259 | let mut history = History::new(); 260 | let day1 = create_day_with_record(1, "Task1"); 261 | let day2 = create_day_with_record(2, "Task2"); 262 | let day3 = create_day_with_record(3, "Task3"); 263 | 264 | history.push(day1.clone()); 265 | history.push(day2.clone()); 266 | 267 | // Undo twice 268 | let undo1 = history.undo(day3.clone()).unwrap(); 269 | let undo2 = history.undo(undo1.clone()).unwrap(); 270 | 271 | assert_eq!(undo2.work_records.get(&1).unwrap().name, "Task1"); 272 | 273 | // Redo twice 274 | let redo1 = history.redo(undo2.clone()).unwrap(); 275 | let redo2 = history.redo(redo1.clone()).unwrap(); 276 | 277 | assert_eq!(redo2.work_records.get(&3).unwrap().name, "Task3"); 278 | } 279 | 280 | #[test] 281 | fn test_redo_moves_to_undo_stack() { 282 | let mut history = History::new(); 283 | let day1 = create_day_with_record(1, "Task1"); 284 | let day2 = create_day_with_record(2, "Task2"); 285 | 286 | history.push(day1.clone()); 287 | history.undo(day2.clone()); 288 | 289 | assert_eq!(history.undo_stack.len(), 0); 290 | 291 | history.redo(day1.clone()); 292 | 293 | assert_eq!(history.undo_stack.len(), 1); 294 | assert_eq!( 295 | history 296 | .undo_stack 297 | .last() 298 | .unwrap() 299 | .work_records 300 | .get(&1) 301 | .unwrap() 302 | .name, 303 | "Task1" 304 | ); 305 | } 306 | 307 | #[test] 308 | fn test_multiple_undos_and_redos() { 309 | let mut history = History::new(); 310 | 311 | for i in 1..=5 { 312 | history.push(create_day_with_record(i, &format!("Task{}", i))); 313 | } 314 | 315 | let mut current = create_day_with_record(6, "Task6"); 316 | 317 | // Undo 3 times 318 | current = history.undo(current).unwrap(); 319 | current = history.undo(current).unwrap(); 320 | current = history.undo(current).unwrap(); 321 | 322 | assert_eq!(history.undo_stack.len(), 2); 323 | assert_eq!(history.redo_stack.len(), 3); 324 | 325 | // Redo 2 times 326 | current = history.redo(current).unwrap(); 327 | let _final_state = history.redo(current).unwrap(); 328 | 329 | assert_eq!(history.undo_stack.len(), 4); 330 | assert_eq!(history.redo_stack.len(), 1); 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /docs/THEMING.md: -------------------------------------------------------------------------------- 1 | # Theme Configuration 2 | 3 | WorkTimer supports customizable color themes to personalize your UI experience. The application includes 8 pre-defined themes and supports custom theme definitions. 4 | 5 | ## Table of Contents 6 | 7 | - [Quick Start](#quick-start) 8 | - [Pre-defined Themes](#pre-defined-themes) 9 | - [Custom Themes](#custom-themes) 10 | - [Color Format Options](#color-format-options) 11 | - [Theme Color Reference](#theme-color-reference) 12 | 13 | ## Quick Start 14 | 15 | **Note**: Theming is completely optional. Without a config file, WorkTimer uses the default theme. 16 | 17 | To enable theming, create a configuration file at the appropriate location for your platform: 18 | 19 | - **Linux/macOS**: `~/.config/work-tuimer/config.toml` (or `$XDG_CONFIG_HOME/work-tuimer/config.toml` if set) 20 | - **Windows**: `%APPDATA%\work-tuimer\config.toml` 21 | 22 | Choose a pre-defined theme: 23 | 24 | ```toml 25 | [theme] 26 | active = "kanagawa" # Options: default, kanagawa, catppuccin, gruvbox, monokai, dracula, everforest, terminal 27 | ``` 28 | 29 | ## Pre-defined Themes 30 | 31 | WorkTimer includes 8 carefully crafted themes: 32 | 33 | ### 1. **default** 34 | The original WorkTimer color scheme with cyan highlights and dark backgrounds. Clean and professional. 35 | 36 | ```toml 37 | [theme] 38 | active = "default" 39 | ``` 40 | 41 | ### 2. **kanagawa** 42 | Dark navy blue aesthetic inspired by the Great Wave off Kanagawa. Deep blues with warm accents. 43 | 44 | ```toml 45 | [theme] 46 | active = "kanagawa" 47 | ``` 48 | 49 | ### 3. **catppuccin** 50 | Soothing pastel theme (Mocha variant) for comfortable viewing. Soft purples and blues. 51 | 52 | ```toml 53 | [theme] 54 | active = "catppuccin" 55 | ``` 56 | 57 | ### 4. **gruvbox** 58 | Retro groove warm color palette. Earth tones with high contrast. 59 | 60 | ```toml 61 | [theme] 62 | active = "gruvbox" 63 | ``` 64 | 65 | ### 5. **monokai** 66 | Classic editor theme with vibrant colors. Bright highlights on dark background. 67 | 68 | ```toml 69 | [theme] 70 | active = "monokai" 71 | ``` 72 | 73 | ### 6. **dracula** 74 | Dark theme with purple and pink accents. Modern and stylish. 75 | 76 | ```toml 77 | [theme] 78 | active = "dracula" 79 | ``` 80 | 81 | ### 7. **everforest** 82 | Comfortable green forest color scheme. Easy on the eyes. 83 | 84 | ```toml 85 | [theme] 86 | active = "everforest" 87 | ``` 88 | 89 | ### 8. **terminal** 90 | Uses your terminal's default colors. Inherits your terminal theme settings. 91 | 92 | ```toml 93 | [theme] 94 | active = "terminal" 95 | ``` 96 | 97 | ## Custom Themes 98 | 99 | Create your own theme with custom colors. Add a `[theme.custom.mytheme]` section to your config: 100 | 101 | ```toml 102 | [theme] 103 | active = "mytheme" # Use your custom theme name 104 | 105 | [theme.custom.mytheme] 106 | # Border colors 107 | active_border = "#00ffff" # Cyan (can use hex) 108 | inactive_border = "DarkGray" # Can use named colors 109 | searching_border = "yellow" # Lowercase also works 110 | 111 | # Background colors 112 | selected_bg = "(40, 40, 60)" # Can use RGB tuples 113 | selected_inactive_bg = "#1e1e2d" 114 | visual_bg = "#4682b4" 115 | timer_active_bg = "#228b22" 116 | row_alternate_bg = "#191923" 117 | edit_bg = "#164e63" 118 | 119 | # Text colors 120 | primary_text = "White" 121 | secondary_text = "Gray" 122 | highlight_text = "Cyan" 123 | 124 | # Status colors 125 | success = "Green" 126 | warning = "Yellow" 127 | error = "LightRed" 128 | info = "Cyan" 129 | 130 | # Specific element colors 131 | timer_text = "Yellow" 132 | badge = "LightMagenta" 133 | ``` 134 | 135 | ### Multiple Custom Themes 136 | 137 | You can define multiple custom themes and switch between them: 138 | 139 | ```toml 140 | [theme] 141 | active = "work" # Use 'work' during work hours 142 | 143 | [theme.custom.work] 144 | active_border = "#00ffff" 145 | selected_bg = "#1e3a5f" 146 | # ... other colors 147 | 148 | [theme.custom.evening] 149 | active_border = "#ff8c00" 150 | selected_bg = "#2d1e1e" 151 | # ... other colors 152 | ``` 153 | 154 | To switch themes, just change the `active` value and restart the app. 155 | 156 | ## Color Format Options 157 | 158 | Colors can be specified in three formats: 159 | 160 | ### 1. Hex Colors 161 | Standard hexadecimal RGB colors: 162 | - **6-digit format**: `"#RRGGBB"` (e.g., `"#00ff00"` for green) 163 | - **3-digit format**: `"#RGB"` (e.g., `"#0f0"` for green, expanded to `#00ff00`) 164 | 165 | ```toml 166 | active_border = "#00ffff" # Cyan 167 | selected_bg = "#1e3a5f" # Dark blue 168 | primary_text = "#fff" # White (shorthand) 169 | ``` 170 | 171 | ### 2. RGB Tuples 172 | RGB values as strings with parentheses: 173 | - **Format**: `"(R, G, B)"` where R, G, B are 0-255 174 | - Spaces are optional: `"(255,128,0)"` works too 175 | 176 | ```toml 177 | selected_bg = "(40, 40, 60)" # Dark purple-gray 178 | warning = "(255, 165, 0)" # Orange 179 | timer_text = "(255,255,0)" # Yellow (no spaces) 180 | ``` 181 | 182 | ### 3. Named Colors 183 | Standard terminal color names (case-insensitive): 184 | 185 | **Basic Colors**: 186 | - `Black`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`, `White` 187 | 188 | **Bright Colors**: 189 | - `LightRed`, `LightGreen`, `LightYellow`, `LightBlue`, `LightMagenta`, `LightCyan` 190 | 191 | **Grayscale**: 192 | - `Gray`, `DarkGray` 193 | 194 | ```toml 195 | primary_text = "White" 196 | secondary_text = "Gray" 197 | error = "LightRed" 198 | success = "green" # Case-insensitive 199 | ``` 200 | 201 | ### Fallback Behavior 202 | 203 | If a color value is invalid or cannot be parsed, it falls back to **White**. This ensures the UI remains functional even with configuration errors. 204 | 205 | ## Theme Color Reference 206 | 207 | All themes use these semantic color names. Each color serves a specific purpose in the UI: 208 | 209 | ### Border Colors 210 | 211 | | Color Name | Usage | Example Context | 212 | |------------|-------|-----------------| 213 | | `active_border` | Border color for focused elements | Selected table, active modal | 214 | | `inactive_border` | Border color for unfocused elements | Unfocused panels, inactive windows | 215 | | `searching_border` | Border color during search/filter | Task picker, search mode | 216 | 217 | ### Background Colors 218 | 219 | | Color Name | Usage | Example Context | 220 | |------------|-------|-----------------| 221 | | `selected_bg` | Background for selected items | Currently selected row in table | 222 | | `selected_inactive_bg` | Background for selected items when unfocused | Selected row when in different mode | 223 | | `visual_bg` | Background in visual/multi-select mode | Multiple selected rows | 224 | | `timer_active_bg` | Background for running timers | Row with active timer, timer bar | 225 | | `row_alternate_bg` | Alternating row background color | Every other row for readability | 226 | | `edit_bg` | Background for editable fields | Input fields in edit mode | 227 | 228 | ### Text Colors 229 | 230 | | Color Name | Usage | Example Context | 231 | |------------|-------|-----------------| 232 | | `primary_text` | Main text color | Task names, times, main content | 233 | | `secondary_text` | Dimmed/secondary text | Descriptions, labels, hints | 234 | | `highlight_text` | Emphasized text | Titles, headers, important info | 235 | 236 | ### Status Colors 237 | 238 | | Color Name | Usage | Example Context | 239 | |------------|-------|-----------------| 240 | | `success` | Success status (green actions) | Saved notification, completed actions | 241 | | `warning` | Warning status (yellow alerts) | Validation warnings, cautionary messages | 242 | | `error` | Error status (red errors) | Error messages, critical failures | 243 | | `info` | Info status (blue information) | Help text, informational messages | 244 | 245 | ### Specific Element Colors 246 | 247 | | Color Name | Usage | Example Context | 248 | |------------|-------|-----------------| 249 | | `timer_text` | Active timer text color | Timer duration display, elapsed time | 250 | | `badge` | Badge/tag text color | Ticket badges (e.g., `[PROJ-123]`) | 251 | 252 | ## Examples 253 | 254 | ### Example: Minimalist Monochrome 255 | 256 | ```toml 257 | [theme] 258 | active = "mono" 259 | 260 | [theme.custom.mono] 261 | active_border = "White" 262 | inactive_border = "DarkGray" 263 | searching_border = "Gray" 264 | selected_bg = "(50, 50, 50)" 265 | selected_inactive_bg = "(30, 30, 30)" 266 | visual_bg = "(70, 70, 70)" 267 | timer_active_bg = "(80, 80, 80)" 268 | row_alternate_bg = "(20, 20, 20)" 269 | edit_bg = "(60, 60, 60)" 270 | primary_text = "White" 271 | secondary_text = "Gray" 272 | highlight_text = "White" 273 | success = "White" 274 | warning = "White" 275 | error = "White" 276 | info = "White" 277 | timer_text = "White" 278 | badge = "Gray" 279 | ``` 280 | 281 | ### Example: High Contrast 282 | 283 | ```toml 284 | [theme] 285 | active = "contrast" 286 | 287 | [theme.custom.contrast] 288 | active_border = "#ffff00" # Bright yellow 289 | inactive_border = "#808080" # Gray 290 | searching_border = "#00ffff" # Cyan 291 | selected_bg = "#000080" # Navy blue 292 | selected_inactive_bg = "#1a1a1a" 293 | visual_bg = "#800080" # Purple 294 | timer_active_bg = "#006400" # Dark green 295 | row_alternate_bg = "#0a0a0a" 296 | edit_bg = "#003366" 297 | primary_text = "#ffffff" # White 298 | secondary_text = "#cccccc" # Light gray 299 | highlight_text = "#ffff00" # Yellow 300 | success = "#00ff00" # Bright green 301 | warning = "#ffa500" # Orange 302 | error = "#ff0000" # Bright red 303 | info = "#00ffff" # Cyan 304 | timer_text = "#ffff00" # Yellow 305 | badge = "#ff00ff" # Magenta 306 | ``` 307 | 308 | ### Example: Soft Pastels 309 | 310 | ```toml 311 | [theme] 312 | active = "pastel" 313 | 314 | [theme.custom.pastel] 315 | active_border = "#b4befe" # Lavender 316 | inactive_border = "#6c7086" # Gray 317 | searching_border = "#f5c2e7" # Pink 318 | selected_bg = "(49, 50, 68)" # Dark blue-gray 319 | selected_inactive_bg = "(30, 30, 46)" 320 | visual_bg = "(116, 199, 236)" # Light blue 321 | timer_active_bg = "(166, 227, 161)" # Light green 322 | row_alternate_bg = "(24, 24, 37)" 323 | edit_bg = "(69, 71, 90)" 324 | primary_text = "#cdd6f4" # Light blue-white 325 | secondary_text = "#9399b2" # Blue-gray 326 | highlight_text = "#f5c2e7" # Pink 327 | success = "#a6e3a1" # Light green 328 | warning = "#f9e2af" # Light yellow 329 | error = "#f38ba8" # Light red 330 | info = "#89dceb" # Light cyan 331 | timer_text = "#f9e2af" # Light yellow 332 | badge = "#cba6f7" # Light purple 333 | ``` 334 | 335 | ## Tips 336 | 337 | 1. **Test your theme**: Make changes and restart the app to see them immediately 338 | 2. **Start with a pre-defined theme**: Modify an existing theme instead of creating from scratch 339 | 3. **Use consistent color families**: Pick 2-3 main colors and use variations (lighter/darker) 340 | 4. **Consider contrast**: Ensure text is readable against backgrounds 341 | 5. **Check in different lighting**: Test your theme in both bright and dim environments 342 | 6. **Save backups**: Keep a copy of working configurations before major changes 343 | 344 | ## Troubleshooting 345 | 346 | ### Theme not loading 347 | - Check the config file path is correct for your platform 348 | - Verify TOML syntax is valid (use a TOML validator) 349 | - Ensure the `active` theme name matches your custom theme name 350 | - Check file permissions (must be readable) 351 | 352 | ### Colors look wrong 353 | - Verify color format is correct (hex, RGB tuple, or named color) 354 | - Check for typos in color names (case-insensitive but must be spelled correctly) 355 | - Remember: invalid colors fall back to White 356 | - Some terminals may not support true color (24-bit) - named colors are more compatible 357 | 358 | ### Theme not applying to all elements 359 | - This is expected - WorkTimer currently uses a fixed set of semantic colors 360 | - All UI elements are designed to use one of the 18 theme colors 361 | - If you find an element that doesn't respect themes, please report it as a bug 362 | -------------------------------------------------------------------------------- /src/models/day_data.rs: -------------------------------------------------------------------------------- 1 | use super::WorkRecord; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | use time::Date; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct DayData { 8 | pub date: Date, 9 | pub last_id: u32, 10 | pub work_records: HashMap, 11 | } 12 | 13 | impl DayData { 14 | pub fn new(date: Date) -> Self { 15 | DayData { 16 | date, 17 | last_id: 0, 18 | work_records: HashMap::new(), 19 | } 20 | } 21 | 22 | pub fn add_record(&mut self, record: WorkRecord) { 23 | if record.id > self.last_id { 24 | self.last_id = record.id; 25 | } 26 | self.work_records.insert(record.id, record); 27 | } 28 | 29 | pub fn remove_record(&mut self, id: u32) -> Option { 30 | self.work_records.remove(&id) 31 | } 32 | 33 | pub fn next_id(&mut self) -> u32 { 34 | self.last_id += 1; 35 | self.last_id 36 | } 37 | 38 | pub fn get_sorted_records(&self) -> Vec<&WorkRecord> { 39 | let mut records: Vec<&WorkRecord> = self.work_records.values().collect(); 40 | records.sort_by_key(|r| r.start); 41 | records 42 | } 43 | 44 | pub fn get_grouped_totals(&self) -> Vec<(String, u32)> { 45 | let mut totals: HashMap = HashMap::new(); 46 | 47 | for record in self.work_records.values() { 48 | *totals.entry(record.name.clone()).or_insert(0) += record.total_minutes; 49 | } 50 | 51 | let mut result: Vec<(String, u32)> = totals.into_iter().collect(); 52 | // Sort by duration (descending), then by task name (ascending) for stable ordering 53 | result.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); 54 | result 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | use crate::models::TimePoint; 62 | 63 | fn create_test_date() -> Date { 64 | Date::from_calendar_date(2025, time::Month::November, 6).unwrap() 65 | } 66 | 67 | fn create_test_record(id: u32, name: &str, start_hour: u8, end_hour: u8) -> WorkRecord { 68 | let start = TimePoint::new(start_hour, 0).unwrap(); 69 | let end = TimePoint::new(end_hour, 0).unwrap(); 70 | WorkRecord::new(id, name.to_string(), start, end) 71 | } 72 | 73 | #[test] 74 | fn test_new_day_data() { 75 | let date = create_test_date(); 76 | let day = DayData::new(date); 77 | 78 | assert_eq!(day.date, date); 79 | assert_eq!(day.last_id, 0); 80 | assert_eq!(day.work_records.len(), 0); 81 | } 82 | 83 | #[test] 84 | fn test_add_record() { 85 | let mut day = DayData::new(create_test_date()); 86 | let record = create_test_record(1, "Coding", 9, 17); 87 | 88 | day.add_record(record.clone()); 89 | 90 | assert_eq!(day.work_records.len(), 1); 91 | assert_eq!(day.last_id, 1); 92 | assert!(day.work_records.contains_key(&1)); 93 | } 94 | 95 | #[test] 96 | fn test_add_multiple_records() { 97 | let mut day = DayData::new(create_test_date()); 98 | 99 | day.add_record(create_test_record(1, "Coding", 9, 12)); 100 | day.add_record(create_test_record(2, "Meeting", 13, 14)); 101 | day.add_record(create_test_record(3, "Code Review", 14, 16)); 102 | 103 | assert_eq!(day.work_records.len(), 3); 104 | assert_eq!(day.last_id, 3); 105 | } 106 | 107 | #[test] 108 | fn test_add_record_updates_last_id() { 109 | let mut day = DayData::new(create_test_date()); 110 | 111 | day.add_record(create_test_record(5, "Task", 9, 10)); 112 | assert_eq!(day.last_id, 5); 113 | 114 | day.add_record(create_test_record(2, "Task2", 10, 11)); 115 | assert_eq!(day.last_id, 5); // Should not decrease 116 | 117 | day.add_record(create_test_record(10, "Task3", 11, 12)); 118 | assert_eq!(day.last_id, 10); 119 | } 120 | 121 | #[test] 122 | fn test_remove_record() { 123 | let mut day = DayData::new(create_test_date()); 124 | day.add_record(create_test_record(1, "Coding", 9, 17)); 125 | 126 | let removed = day.remove_record(1); 127 | assert!(removed.is_some()); 128 | assert_eq!(removed.unwrap().name, "Coding"); 129 | assert_eq!(day.work_records.len(), 0); 130 | } 131 | 132 | #[test] 133 | fn test_remove_nonexistent_record() { 134 | let mut day = DayData::new(create_test_date()); 135 | day.add_record(create_test_record(1, "Coding", 9, 17)); 136 | 137 | let removed = day.remove_record(999); 138 | assert!(removed.is_none()); 139 | assert_eq!(day.work_records.len(), 1); 140 | } 141 | 142 | #[test] 143 | fn test_next_id() { 144 | let mut day = DayData::new(create_test_date()); 145 | 146 | assert_eq!(day.next_id(), 1); 147 | assert_eq!(day.next_id(), 2); 148 | assert_eq!(day.next_id(), 3); 149 | assert_eq!(day.last_id, 3); 150 | } 151 | 152 | #[test] 153 | fn test_next_id_after_add_record() { 154 | let mut day = DayData::new(create_test_date()); 155 | day.add_record(create_test_record(5, "Task", 9, 10)); 156 | 157 | assert_eq!(day.last_id, 5); 158 | assert_eq!(day.next_id(), 6); 159 | assert_eq!(day.next_id(), 7); 160 | } 161 | 162 | #[test] 163 | fn test_get_sorted_records_empty() { 164 | let day = DayData::new(create_test_date()); 165 | let sorted = day.get_sorted_records(); 166 | assert_eq!(sorted.len(), 0); 167 | } 168 | 169 | #[test] 170 | fn test_get_sorted_records_single() { 171 | let mut day = DayData::new(create_test_date()); 172 | day.add_record(create_test_record(1, "Coding", 9, 17)); 173 | 174 | let sorted = day.get_sorted_records(); 175 | assert_eq!(sorted.len(), 1); 176 | assert_eq!(sorted[0].name, "Coding"); 177 | } 178 | 179 | #[test] 180 | fn test_get_sorted_records_already_sorted() { 181 | let mut day = DayData::new(create_test_date()); 182 | day.add_record(create_test_record(1, "Morning", 9, 12)); 183 | day.add_record(create_test_record(2, "Afternoon", 13, 17)); 184 | 185 | let sorted = day.get_sorted_records(); 186 | assert_eq!(sorted.len(), 2); 187 | assert_eq!(sorted[0].name, "Morning"); 188 | assert_eq!(sorted[1].name, "Afternoon"); 189 | } 190 | 191 | #[test] 192 | fn test_get_sorted_records_unsorted() { 193 | let mut day = DayData::new(create_test_date()); 194 | day.add_record(create_test_record(1, "Afternoon", 13, 17)); 195 | day.add_record(create_test_record(2, "Morning", 9, 12)); 196 | day.add_record(create_test_record(3, "Evening", 18, 20)); 197 | 198 | let sorted = day.get_sorted_records(); 199 | assert_eq!(sorted.len(), 3); 200 | assert_eq!(sorted[0].name, "Morning"); 201 | assert_eq!(sorted[1].name, "Afternoon"); 202 | assert_eq!(sorted[2].name, "Evening"); 203 | } 204 | 205 | #[test] 206 | fn test_get_sorted_records_same_start_time() { 207 | let mut day = DayData::new(create_test_date()); 208 | let start = TimePoint::new(9, 0).unwrap(); 209 | let end1 = TimePoint::new(10, 0).unwrap(); 210 | let end2 = TimePoint::new(11, 0).unwrap(); 211 | 212 | day.add_record(WorkRecord::new(1, "Task1".to_string(), start, end1)); 213 | day.add_record(WorkRecord::new(2, "Task2".to_string(), start, end2)); 214 | 215 | let sorted = day.get_sorted_records(); 216 | assert_eq!(sorted.len(), 2); 217 | // Both start at 9:00, order doesn't matter but both should be present 218 | assert!(sorted.iter().any(|r| r.name == "Task1")); 219 | assert!(sorted.iter().any(|r| r.name == "Task2")); 220 | } 221 | 222 | #[test] 223 | fn test_get_grouped_totals_empty() { 224 | let day = DayData::new(create_test_date()); 225 | let totals = day.get_grouped_totals(); 226 | assert_eq!(totals.len(), 0); 227 | } 228 | 229 | #[test] 230 | fn test_get_grouped_totals_single_task() { 231 | let mut day = DayData::new(create_test_date()); 232 | day.add_record(create_test_record(1, "Coding", 9, 17)); // 8 hours 233 | 234 | let totals = day.get_grouped_totals(); 235 | assert_eq!(totals.len(), 1); 236 | assert_eq!(totals[0].0, "Coding"); 237 | assert_eq!(totals[0].1, 480); // 8 * 60 minutes 238 | } 239 | 240 | #[test] 241 | fn test_get_grouped_totals_multiple_different_tasks() { 242 | let mut day = DayData::new(create_test_date()); 243 | day.add_record(create_test_record(1, "Coding", 9, 12)); // 3 hours 244 | day.add_record(create_test_record(2, "Meeting", 13, 14)); // 1 hour 245 | day.add_record(create_test_record(3, "Code Review", 14, 16)); // 2 hours 246 | 247 | let totals = day.get_grouped_totals(); 248 | assert_eq!(totals.len(), 3); 249 | 250 | // Should be sorted by duration (descending) 251 | assert_eq!(totals[0].0, "Coding"); 252 | assert_eq!(totals[0].1, 180); 253 | assert_eq!(totals[1].0, "Code Review"); 254 | assert_eq!(totals[1].1, 120); 255 | assert_eq!(totals[2].0, "Meeting"); 256 | assert_eq!(totals[2].1, 60); 257 | } 258 | 259 | #[test] 260 | fn test_get_grouped_totals_same_task_multiple_times() { 261 | let mut day = DayData::new(create_test_date()); 262 | day.add_record(create_test_record(1, "Coding", 9, 11)); // 2 hours 263 | day.add_record(create_test_record(2, "Meeting", 11, 12)); // 1 hour 264 | day.add_record(create_test_record(3, "Coding", 13, 16)); // 3 hours 265 | day.add_record(create_test_record(4, "Coding", 16, 17)); // 1 hour 266 | 267 | let totals = day.get_grouped_totals(); 268 | assert_eq!(totals.len(), 2); 269 | 270 | // Coding should be grouped: 2 + 3 + 1 = 6 hours 271 | assert_eq!(totals[0].0, "Coding"); 272 | assert_eq!(totals[0].1, 360); 273 | assert_eq!(totals[1].0, "Meeting"); 274 | assert_eq!(totals[1].1, 60); 275 | } 276 | 277 | #[test] 278 | fn test_get_grouped_totals_sorted_by_duration() { 279 | let mut day = DayData::new(create_test_date()); 280 | day.add_record(create_test_record(1, "Short", 9, 10)); // 1 hour 281 | day.add_record(create_test_record(2, "Long", 10, 15)); // 5 hours 282 | day.add_record(create_test_record(3, "Medium", 15, 17)); // 2 hours 283 | 284 | let totals = day.get_grouped_totals(); 285 | 286 | // Should be sorted by duration descending 287 | assert_eq!(totals[0].0, "Long"); 288 | assert_eq!(totals[1].0, "Medium"); 289 | assert_eq!(totals[2].0, "Short"); 290 | } 291 | 292 | #[test] 293 | fn test_get_grouped_totals_stable_sort_on_tied_durations() { 294 | let mut day = DayData::new(create_test_date()); 295 | // Create tasks with same duration to test stable sorting 296 | day.add_record(create_test_record(1, "Zebra Task", 9, 11)); // 2 hours 297 | day.add_record(create_test_record(2, "Alpha Task", 11, 13)); // 2 hours 298 | day.add_record(create_test_record(3, "Beta Task", 13, 15)); // 2 hours 299 | 300 | let totals = day.get_grouped_totals(); 301 | 302 | // All have same duration, should be sorted alphabetically by name 303 | assert_eq!(totals.len(), 3); 304 | assert_eq!(totals[0].0, "Alpha Task"); 305 | assert_eq!(totals[0].1, 120); 306 | assert_eq!(totals[1].0, "Beta Task"); 307 | assert_eq!(totals[1].1, 120); 308 | assert_eq!(totals[2].0, "Zebra Task"); 309 | assert_eq!(totals[2].1, 120); 310 | 311 | // Test multiple times to ensure sort is stable (non-blinking) 312 | for _ in 0..10 { 313 | let totals_repeat = day.get_grouped_totals(); 314 | assert_eq!(totals_repeat[0].0, "Alpha Task"); 315 | assert_eq!(totals_repeat[1].0, "Beta Task"); 316 | assert_eq!(totals_repeat[2].0, "Zebra Task"); 317 | } 318 | } 319 | 320 | #[test] 321 | fn test_clone() { 322 | let mut day1 = DayData::new(create_test_date()); 323 | day1.add_record(create_test_record(1, "Coding", 9, 17)); 324 | 325 | let day2 = day1.clone(); 326 | 327 | assert_eq!(day1.date, day2.date); 328 | assert_eq!(day1.last_id, day2.last_id); 329 | assert_eq!(day1.work_records.len(), day2.work_records.len()); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /tests/storage_manager_integration.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::thread; 3 | use std::time::Duration; 4 | use tempfile::TempDir; 5 | use time::OffsetDateTime; 6 | use work_tuimer::models::{DayData, TimePoint, WorkRecord}; 7 | use work_tuimer::storage::StorageManager; 8 | 9 | fn create_test_record(id: u32, name: &str, start_hour: u8, end_hour: u8) -> WorkRecord { 10 | let start = TimePoint::new(start_hour, 0).unwrap(); 11 | let end = TimePoint::new(end_hour, 0).unwrap(); 12 | WorkRecord::new(id, name.to_string(), start, end) 13 | } 14 | 15 | #[test] 16 | fn test_timer_lifecycle_start_stop() -> Result<()> { 17 | let temp_dir = TempDir::new()?; 18 | let manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 19 | 20 | // Start a timer 21 | let timer = manager.start_timer( 22 | "Integration Test Task".to_string(), 23 | Some("Testing timer lifecycle".to_string()), 24 | None, 25 | None, 26 | )?; 27 | 28 | assert_eq!(timer.task_name, "Integration Test Task"); 29 | assert_eq!( 30 | timer.description, 31 | Some("Testing timer lifecycle".to_string()) 32 | ); 33 | 34 | // Verify timer was saved 35 | let loaded_timer = manager.load_active_timer()?; 36 | assert!(loaded_timer.is_some()); 37 | assert_eq!(loaded_timer.unwrap().task_name, "Integration Test Task"); 38 | 39 | // Wait a bit (timing tests are challenging in integration tests) 40 | thread::sleep(Duration::from_millis(100)); 41 | 42 | // Stop the timer 43 | let record = manager.stop_timer()?; 44 | 45 | assert_eq!(record.name, "Integration Test Task"); 46 | assert_eq!(record.description, "Testing timer lifecycle"); 47 | // Note: total_minutes might be 0 if start and stop are in the same minute 48 | // We just verify the record was created successfully 49 | 50 | // Verify timer was cleared 51 | let cleared_timer = manager.load_active_timer()?; 52 | assert!(cleared_timer.is_none()); 53 | 54 | Ok(()) 55 | } 56 | 57 | #[test] 58 | fn test_timer_pause_resume() -> Result<()> { 59 | let temp_dir = TempDir::new()?; 60 | let manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 61 | 62 | // Start a timer 63 | manager.start_timer("Pausable Task".to_string(), None, None, None)?; 64 | 65 | // Wait a bit 66 | thread::sleep(Duration::from_millis(100)); 67 | 68 | // Pause 69 | let paused_timer = manager.pause_timer()?; 70 | assert_eq!(paused_timer.task_name, "Pausable Task"); 71 | 72 | let elapsed_at_pause = manager.get_timer_elapsed(&paused_timer); 73 | 74 | // Wait while paused (this time shouldn't count) 75 | thread::sleep(Duration::from_millis(100)); 76 | 77 | // Resume 78 | let resumed_timer = manager.resume_timer()?; 79 | assert_eq!(resumed_timer.task_name, "Pausable Task"); 80 | 81 | // Elapsed should be approximately the same as when paused 82 | // Note: Allow some tolerance for system timing variations (increased for CI environments) 83 | let elapsed_after_resume = manager.get_timer_elapsed(&resumed_timer); 84 | let diff = elapsed_after_resume 85 | .as_millis() 86 | .abs_diff(elapsed_at_pause.as_millis()); 87 | assert!( 88 | diff < 300, 89 | "Elapsed time increased too much during pause: {} ms", 90 | diff 91 | ); 92 | 93 | // Stop and verify 94 | let record = manager.stop_timer()?; 95 | assert_eq!(record.name, "Pausable Task"); 96 | 97 | Ok(()) 98 | } 99 | 100 | #[test] 101 | fn test_timer_with_source_record() -> Result<()> { 102 | let temp_dir = TempDir::new()?; 103 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 104 | 105 | let today = OffsetDateTime::now_utc().date(); 106 | 107 | // Create and save a work record to use as source 108 | let mut day_data = DayData::new(today); 109 | let source_record = create_test_record(1, "Original Task", 9, 10); 110 | day_data.add_record(source_record); 111 | manager.save(&day_data)?; 112 | 113 | // Start a timer linked to this record 114 | let timer = manager.start_timer( 115 | "Original Task".to_string(), 116 | Some("Continuing work".to_string()), 117 | Some(1), 118 | Some(today), 119 | )?; 120 | 121 | assert_eq!(timer.source_record_id, Some(1)); 122 | assert_eq!(timer.source_record_date, Some(today)); 123 | 124 | // Stop and verify the link is preserved 125 | thread::sleep(Duration::from_millis(50)); 126 | let record = manager.stop_timer()?; 127 | 128 | assert_eq!(record.name, "Original Task"); 129 | assert_eq!(record.description, "Continuing work"); 130 | 131 | Ok(()) 132 | } 133 | 134 | #[test] 135 | fn test_end_to_end_workflow_timer_to_saved_record() -> Result<()> { 136 | let temp_dir = TempDir::new()?; 137 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 138 | 139 | let today = OffsetDateTime::now_utc().date(); 140 | 141 | // 1. Start timer 142 | manager.start_timer("Full Workflow Task".to_string(), None, None, None)?; 143 | 144 | // 2. Simulate work 145 | thread::sleep(Duration::from_millis(100)); 146 | 147 | // 3. Stop timer - this automatically saves the record to day data 148 | let record = manager.stop_timer()?; 149 | assert_eq!(record.name, "Full Workflow Task"); 150 | 151 | // 4. Load day data with tracking to verify the record was saved 152 | let day_data = manager.load_with_tracking(today)?; 153 | assert_eq!(day_data.work_records.len(), 1); 154 | 155 | let saved_record = day_data.work_records.get(&1).unwrap(); 156 | assert_eq!(saved_record.name, "Full Workflow Task"); 157 | // Note: total_minutes might be 0 if timer started and stopped in same minute 158 | 159 | // 5. Verify tracking is updated 160 | assert!(manager.get_last_modified(&today).is_some()); 161 | 162 | Ok(()) 163 | } 164 | 165 | #[test] 166 | fn test_concurrent_external_modification_detection() -> Result<()> { 167 | let temp_dir = TempDir::new()?; 168 | let mut manager1 = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 169 | let mut manager2 = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 170 | 171 | let today = OffsetDateTime::now_utc().date(); 172 | 173 | // Manager 1: Load initial data 174 | let day_data1 = manager1.load_with_tracking(today)?; 175 | assert_eq!(day_data1.work_records.len(), 0); 176 | 177 | // Manager 2: Add a record (external modification) 178 | manager2.add_record(today, create_test_record(1, "External Change", 9, 10))?; 179 | 180 | // Manager 1: Check for external changes 181 | let reloaded = manager1.check_and_reload(today)?; 182 | assert!(reloaded.is_some(), "Should detect external modification"); 183 | 184 | let reloaded_data = reloaded.unwrap(); 185 | assert_eq!(reloaded_data.work_records.len(), 1); 186 | assert_eq!( 187 | reloaded_data.work_records.get(&1).unwrap().name, 188 | "External Change" 189 | ); 190 | 191 | Ok(()) 192 | } 193 | 194 | #[test] 195 | fn test_transactional_operations_rollback_on_error() -> Result<()> { 196 | let temp_dir = TempDir::new()?; 197 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 198 | 199 | let today = OffsetDateTime::now_utc().date(); 200 | 201 | // Add initial record 202 | manager.add_record(today, create_test_record(1, "Initial Task", 9, 10))?; 203 | 204 | // Try to remove non-existent record (should fail) 205 | let result = manager.remove_record(today, 999); 206 | assert!(result.is_err()); 207 | 208 | // Verify original data is intact 209 | let day_data = manager.load_with_tracking(today)?; 210 | assert_eq!(day_data.work_records.len(), 1); 211 | assert_eq!(day_data.work_records.get(&1).unwrap().name, "Initial Task"); 212 | 213 | Ok(()) 214 | } 215 | 216 | #[test] 217 | fn test_multiple_saves_update_tracking() -> Result<()> { 218 | let temp_dir = TempDir::new()?; 219 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 220 | 221 | let today = OffsetDateTime::now_utc().date(); 222 | 223 | // First save 224 | let mut day_data = DayData::new(today); 225 | day_data.add_record(create_test_record(1, "Task 1", 9, 10)); 226 | manager.save(&day_data)?; 227 | 228 | let first_modified = manager.get_last_modified(&today); 229 | assert!(first_modified.is_some()); 230 | 231 | // Wait to ensure different timestamp 232 | thread::sleep(Duration::from_millis(10)); 233 | 234 | // Second save 235 | day_data.add_record(create_test_record(2, "Task 2", 10, 11)); 236 | manager.save(&day_data)?; 237 | 238 | let second_modified = manager.get_last_modified(&today); 239 | assert!(second_modified.is_some()); 240 | 241 | // Modification times should be different 242 | assert_ne!(first_modified, second_modified); 243 | 244 | Ok(()) 245 | } 246 | 247 | #[test] 248 | fn test_add_update_remove_workflow() -> Result<()> { 249 | let temp_dir = TempDir::new()?; 250 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 251 | 252 | let today = OffsetDateTime::now_utc().date(); 253 | 254 | // Add a record 255 | manager.add_record(today, create_test_record(1, "Original Name", 9, 10))?; 256 | 257 | let data = manager.load_with_tracking(today)?; 258 | assert_eq!(data.work_records.len(), 1); 259 | assert_eq!(data.work_records.get(&1).unwrap().name, "Original Name"); 260 | 261 | // Update the record 262 | manager.update_record(today, create_test_record(1, "Updated Name", 9, 11))?; 263 | 264 | let data = manager.load_with_tracking(today)?; 265 | assert_eq!(data.work_records.len(), 1); 266 | assert_eq!(data.work_records.get(&1).unwrap().name, "Updated Name"); 267 | assert_eq!(data.work_records.get(&1).unwrap().total_minutes, 120); 268 | 269 | // Remove the record 270 | let removed = manager.remove_record(today, 1)?; 271 | assert_eq!(removed.name, "Updated Name"); 272 | 273 | let data = manager.load_with_tracking(today)?; 274 | assert_eq!(data.work_records.len(), 0); 275 | 276 | Ok(()) 277 | } 278 | 279 | #[test] 280 | fn test_timer_cleared_after_stop() -> Result<()> { 281 | let temp_dir = TempDir::new()?; 282 | let manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 283 | 284 | // Start and immediately stop 285 | manager.start_timer("Quick Task".to_string(), None, None, None)?; 286 | assert!(manager.load_active_timer()?.is_some()); 287 | 288 | thread::sleep(Duration::from_millis(10)); 289 | manager.stop_timer()?; 290 | 291 | // Timer file should be deleted 292 | assert!(manager.load_active_timer()?.is_none()); 293 | 294 | Ok(()) 295 | } 296 | 297 | #[test] 298 | fn test_multiple_days_isolation() -> Result<()> { 299 | let temp_dir = TempDir::new()?; 300 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 301 | 302 | let today = OffsetDateTime::now_utc().date(); 303 | let yesterday = today.previous_day().unwrap(); 304 | 305 | // Add records to different days 306 | manager.add_record(today, create_test_record(1, "Today Task", 9, 10))?; 307 | manager.add_record(yesterday, create_test_record(1, "Yesterday Task", 9, 10))?; 308 | 309 | // Load and verify isolation 310 | let today_data = manager.load_with_tracking(today)?; 311 | let yesterday_data = manager.load_with_tracking(yesterday)?; 312 | 313 | assert_eq!(today_data.work_records.len(), 1); 314 | assert_eq!(yesterday_data.work_records.len(), 1); 315 | 316 | assert_eq!(today_data.work_records.get(&1).unwrap().name, "Today Task"); 317 | assert_eq!( 318 | yesterday_data.work_records.get(&1).unwrap().name, 319 | "Yesterday Task" 320 | ); 321 | 322 | // Verify separate tracking 323 | assert!(manager.get_last_modified(&today).is_some()); 324 | assert!(manager.get_last_modified(&yesterday).is_some()); 325 | 326 | Ok(()) 327 | } 328 | 329 | #[test] 330 | fn test_load_with_tracking_updates_internal_state() -> Result<()> { 331 | let temp_dir = TempDir::new()?; 332 | let mut manager = StorageManager::new_with_dir(temp_dir.path().to_path_buf())?; 333 | 334 | let today = OffsetDateTime::now_utc().date(); 335 | 336 | // Initially no tracking 337 | assert!(manager.get_last_modified(&today).is_none()); 338 | 339 | // Load with tracking 340 | manager.load_with_tracking(today)?; 341 | 342 | // Now tracking should exist (even for non-existent file, it tracks None) 343 | // For non-existent file, tracking is None but the entry exists 344 | assert!(manager.get_last_modified(&today).is_none()); 345 | 346 | // Save a file 347 | let mut day_data = DayData::new(today); 348 | day_data.add_record(create_test_record(1, "Task", 9, 10)); 349 | manager.save(&day_data)?; 350 | 351 | // Now tracking should have a value 352 | assert!(manager.get_last_modified(&today).is_some()); 353 | 354 | Ok(()) 355 | } 356 | -------------------------------------------------------------------------------- /src/integrations/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use anyhow::Result; 3 | use regex::Regex; 4 | 5 | /// Extract ticket ID from task name using regex pattern: "PROJ-123 - Task name" -> "PROJ-123" 6 | pub fn extract_ticket_from_name(name: &str) -> Option { 7 | // Match common ticket patterns: WORD-NUMBER (e.g., PROJ-123, WL-1, LIN-456) 8 | let re = Regex::new(r"\b([A-Z]{2,10}-\d+)\b").ok()?; 9 | 10 | re.captures(name) 11 | .and_then(|caps| caps.get(1)) 12 | .map(|m| m.as_str().to_string()) 13 | } 14 | 15 | /// Detect which tracker a ticket belongs to based on config patterns 16 | /// Returns the tracker name if a match is found 17 | pub fn detect_tracker(ticket: &str, config: &Config) -> Option { 18 | // Try each enabled tracker's patterns 19 | for (name, tracker_config) in &config.integrations.trackers { 20 | if tracker_config.enabled && matches_patterns(ticket, &tracker_config.ticket_patterns) { 21 | return Some(name.clone()); 22 | } 23 | } 24 | 25 | // Fallback to default tracker if configured 26 | config.integrations.default_tracker.clone() 27 | } 28 | 29 | /// Check if ticket matches any of the provided patterns 30 | fn matches_patterns(ticket: &str, patterns: &[String]) -> bool { 31 | patterns.iter().any(|pattern| { 32 | Regex::new(pattern) 33 | .ok() 34 | .map(|re| re.is_match(ticket)) 35 | .unwrap_or(false) 36 | }) 37 | } 38 | 39 | /// Build a URL for the given ticket and tracker name 40 | pub fn build_url( 41 | ticket: &str, 42 | tracker_name: &str, 43 | config: &Config, 44 | for_worklog: bool, 45 | ) -> Result { 46 | let tracker_config = config 47 | .integrations 48 | .trackers 49 | .get(tracker_name) 50 | .ok_or_else(|| anyhow::anyhow!("Tracker '{}' not found in config", tracker_name))?; 51 | 52 | if !tracker_config.enabled { 53 | anyhow::bail!("Tracker '{}' is not enabled in config", tracker_name); 54 | } 55 | 56 | let template = if for_worklog && !tracker_config.worklog_url.is_empty() { 57 | &tracker_config.worklog_url 58 | } else { 59 | &tracker_config.browse_url 60 | }; 61 | 62 | let url = template 63 | .replace("{base_url}", &tracker_config.base_url) 64 | .replace("{ticket}", ticket); 65 | 66 | Ok(url) 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | 73 | #[test] 74 | fn test_extract_ticket_simple() { 75 | let name = "PROJ-123 Fix login bug"; 76 | let ticket = extract_ticket_from_name(name); 77 | assert_eq!(ticket, Some("PROJ-123".to_string())); 78 | } 79 | 80 | #[test] 81 | fn test_extract_ticket_wl_format() { 82 | let name = "WL-1 Morning standup"; 83 | let ticket = extract_ticket_from_name(name); 84 | assert_eq!(ticket, Some("WL-1".to_string())); 85 | } 86 | 87 | #[test] 88 | fn test_extract_ticket_lin_format() { 89 | let name = "LIN-456 Code review"; 90 | let ticket = extract_ticket_from_name(name); 91 | assert_eq!(ticket, Some("LIN-456".to_string())); 92 | } 93 | 94 | #[test] 95 | fn test_extract_ticket_bracketed() { 96 | let name = "[ABC-789] Task name"; 97 | let ticket = extract_ticket_from_name(name); 98 | assert_eq!(ticket, Some("ABC-789".to_string())); 99 | } 100 | 101 | #[test] 102 | fn test_extract_ticket_in_middle() { 103 | let name = "Work on PROJ-456 - code cleanup"; 104 | let ticket = extract_ticket_from_name(name); 105 | assert_eq!(ticket, Some("PROJ-456".to_string())); 106 | } 107 | 108 | #[test] 109 | fn test_extract_ticket_no_ticket() { 110 | let name = "Just a regular task"; 111 | let ticket = extract_ticket_from_name(name); 112 | assert_eq!(ticket, None); 113 | } 114 | 115 | #[test] 116 | fn test_extract_ticket_invalid_format() { 117 | let name = "task-123 invalid"; 118 | let ticket = extract_ticket_from_name(name); 119 | assert_eq!(ticket, None); // lowercase doesn't match 120 | } 121 | 122 | #[test] 123 | fn test_detect_tracker_by_pattern() { 124 | let toml_str = r#" 125 | [integrations] 126 | default_tracker = "my-jira" 127 | 128 | [integrations.trackers.my-jira] 129 | enabled = true 130 | base_url = "https://test.atlassian.net" 131 | ticket_patterns = ["^PROJ-\\d+$", "^WL-\\d+$"] 132 | browse_url = "{base_url}/browse/{ticket}" 133 | "#; 134 | let config: Config = toml::from_str(toml_str).unwrap(); 135 | 136 | let tracker = detect_tracker("PROJ-123", &config); 137 | assert_eq!(tracker, Some("my-jira".to_string())); 138 | 139 | let tracker = detect_tracker("WL-1", &config); 140 | assert_eq!(tracker, Some("my-jira".to_string())); 141 | } 142 | 143 | #[test] 144 | fn test_detect_tracker_default_fallback() { 145 | let toml_str = r#" 146 | [integrations] 147 | default_tracker = "my-jira" 148 | 149 | [integrations.trackers.my-jira] 150 | enabled = true 151 | base_url = "https://test.atlassian.net" 152 | ticket_patterns = ["^PROJ-\\d+$"] 153 | browse_url = "{base_url}/browse/{ticket}" 154 | "#; 155 | let config: Config = toml::from_str(toml_str).unwrap(); 156 | 157 | // UNKNOWN-999 doesn't match pattern, falls back to default 158 | let tracker = detect_tracker("UNKNOWN-999", &config); 159 | assert_eq!(tracker, Some("my-jira".to_string())); 160 | } 161 | 162 | #[test] 163 | fn test_detect_tracker_multiple_trackers() { 164 | let toml_str = r#" 165 | [integrations] 166 | default_tracker = "my-jira" 167 | 168 | [integrations.trackers.my-jira] 169 | enabled = true 170 | base_url = "https://test.atlassian.net" 171 | ticket_patterns = ["^PROJ-\\d+$"] 172 | browse_url = "{base_url}/browse/{ticket}" 173 | 174 | [integrations.trackers.github] 175 | enabled = true 176 | base_url = "https://github.com/user/repo" 177 | ticket_patterns = ["^#\\d+$"] 178 | browse_url = "{base_url}/issues/{ticket}" 179 | "#; 180 | let config: Config = toml::from_str(toml_str).unwrap(); 181 | 182 | let tracker = detect_tracker("PROJ-123", &config); 183 | assert_eq!(tracker, Some("my-jira".to_string())); 184 | 185 | let tracker = detect_tracker("#456", &config); 186 | assert_eq!(tracker, Some("github".to_string())); 187 | } 188 | 189 | #[test] 190 | fn test_detect_tracker_overlapping_patterns_first_wins() { 191 | // Test that when multiple trackers match, the first one in iteration order wins 192 | let toml_str = r#" 193 | [integrations] 194 | default_tracker = "fallback" 195 | 196 | [integrations.trackers.jira] 197 | enabled = true 198 | base_url = "https://jira.example.com" 199 | ticket_patterns = ["^[A-Z]+-\\d+$"] 200 | browse_url = "{base_url}/browse/{ticket}" 201 | 202 | [integrations.trackers.linear] 203 | enabled = true 204 | base_url = "https://linear.app/team" 205 | ticket_patterns = ["^[A-Z]+-\\d+$"] 206 | browse_url = "{base_url}/issue/{ticket}" 207 | "#; 208 | let config: Config = toml::from_str(toml_str).unwrap(); 209 | 210 | // PROJ-123 matches both patterns - should use whichever tracker appears first 211 | let tracker = detect_tracker("PROJ-123", &config); 212 | assert!(tracker.is_some()); 213 | 214 | // The result should be deterministic (either "jira" or "linear") 215 | // Note: HashMap iteration order is not guaranteed in Rust, but it should be consistent 216 | let tracker_name = tracker.unwrap(); 217 | assert!(tracker_name == "jira" || tracker_name == "linear"); 218 | 219 | // Verify the same ticket always resolves to the same tracker 220 | let tracker2 = detect_tracker("PROJ-123", &config); 221 | assert_eq!(tracker2, Some(tracker_name)); 222 | } 223 | 224 | #[test] 225 | fn test_detect_tracker_no_match_no_default() { 226 | // Test that when no patterns match and no default is set, returns None 227 | let toml_str = r#" 228 | [integrations] 229 | 230 | [integrations.trackers.jira] 231 | enabled = true 232 | base_url = "https://jira.example.com" 233 | ticket_patterns = ["^PROJ-\\d+$"] 234 | browse_url = "{base_url}/browse/{ticket}" 235 | "#; 236 | let config: Config = toml::from_str(toml_str).unwrap(); 237 | 238 | // UNKNOWN-999 doesn't match and there's no default_tracker 239 | let tracker = detect_tracker("UNKNOWN-999", &config); 240 | assert_eq!(tracker, None); 241 | } 242 | 243 | #[test] 244 | fn test_build_url_browse() { 245 | let toml_str = r#" 246 | [integrations] 247 | default_tracker = "my-jira" 248 | 249 | [integrations.trackers.my-jira] 250 | enabled = true 251 | base_url = "https://test.atlassian.net" 252 | ticket_patterns = ["^[A-Z]+-\\d+$"] 253 | browse_url = "{base_url}/browse/{ticket}" 254 | worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1" 255 | "#; 256 | let config: Config = toml::from_str(toml_str).unwrap(); 257 | let url = build_url("WL-1", "my-jira", &config, false); 258 | assert!(url.is_ok()); 259 | assert_eq!(url.unwrap(), "https://test.atlassian.net/browse/WL-1"); 260 | } 261 | 262 | #[test] 263 | fn test_build_url_worklog() { 264 | let toml_str = r#" 265 | [integrations] 266 | default_tracker = "my-jira" 267 | 268 | [integrations.trackers.my-jira] 269 | enabled = true 270 | base_url = "https://test.atlassian.net" 271 | ticket_patterns = ["^[A-Z]+-\\d+$"] 272 | browse_url = "{base_url}/browse/{ticket}" 273 | worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1" 274 | "#; 275 | let config: Config = toml::from_str(toml_str).unwrap(); 276 | let url = build_url("WL-1", "my-jira", &config, true); 277 | assert!(url.is_ok()); 278 | assert_eq!( 279 | url.unwrap(), 280 | "https://test.atlassian.net/browse/WL-1?focusedWorklogId=-1" 281 | ); 282 | } 283 | 284 | #[test] 285 | fn test_build_url_github() { 286 | let toml_str = r#" 287 | [integrations] 288 | 289 | [integrations.trackers.github] 290 | enabled = true 291 | base_url = "https://github.com/user/repo" 292 | ticket_patterns = ["^#\\d+$"] 293 | browse_url = "{base_url}/issues/{ticket}" 294 | worklog_url = "" 295 | "#; 296 | 297 | let config: Config = toml::from_str(toml_str).unwrap(); 298 | let url = build_url("#456", "github", &config, false); 299 | assert!(url.is_ok()); 300 | assert_eq!(url.unwrap(), "https://github.com/user/repo/issues/#456"); 301 | } 302 | 303 | #[test] 304 | fn test_matches_patterns() { 305 | let patterns = vec!["^[A-Z]+-\\d+$".to_string()]; 306 | assert!(matches_patterns("PROJ-123", &patterns)); 307 | assert!(matches_patterns("WL-1", &patterns)); 308 | assert!(!matches_patterns("invalid", &patterns)); 309 | } 310 | 311 | #[test] 312 | fn test_extract_first_ticket_only() { 313 | // If there are multiple tickets, extract the first one 314 | let name = "PROJ-123 and WL-456 task"; 315 | let ticket = extract_ticket_from_name(name); 316 | assert_eq!(ticket, Some("PROJ-123".to_string())); 317 | } 318 | 319 | #[test] 320 | fn test_build_url_with_query_params_in_browse_url() { 321 | // Issue #42: URLs with query parameters in browse_url template 322 | // e.g., Zentao-style URLs: {base_url}?m=my&f=work&mode=bug 323 | let toml_str = r#" 324 | [integrations] 325 | default_tracker = "zentao" 326 | 327 | [integrations.trackers.zentao] 328 | enabled = true 329 | base_url = "http://domain/index.php" 330 | ticket_patterns = ["^BUG-\\d+$"] 331 | browse_url = "{base_url}?m=my&f=work&mode=bug&type=assignedTo" 332 | worklog_url = "{base_url}?m=bug&f=view&bugID={ticket}" 333 | "#; 334 | let config: Config = toml::from_str(toml_str).unwrap(); 335 | 336 | // Test browse URL with query params (no ticket placeholder in browse_url) 337 | let url = build_url("BUG-123", "zentao", &config, false); 338 | assert!(url.is_ok()); 339 | let url_str = url.unwrap(); 340 | assert_eq!( 341 | url_str, 342 | "http://domain/index.php?m=my&f=work&mode=bug&type=assignedTo" 343 | ); 344 | // Verify URL contains all query parameters 345 | assert!(url_str.contains("m=my")); 346 | assert!(url_str.contains("f=work")); 347 | assert!(url_str.contains("mode=bug")); 348 | assert!(url_str.contains("type=assignedTo")); 349 | } 350 | 351 | #[test] 352 | fn test_build_url_with_ticket_in_query_params() { 353 | // Issue #42: worklog URLs that put ticket ID in query parameter 354 | let toml_str = r#" 355 | [integrations] 356 | default_tracker = "zentao" 357 | 358 | [integrations.trackers.zentao] 359 | enabled = true 360 | base_url = "http://domain/index.php" 361 | ticket_patterns = ["^BUG-\\d+$"] 362 | browse_url = "{base_url}?m=my&f=work&mode=bug" 363 | worklog_url = "{base_url}?m=bug&f=view&bugID={ticket}" 364 | "#; 365 | let config: Config = toml::from_str(toml_str).unwrap(); 366 | 367 | // Test worklog URL with ticket in query string 368 | let url = build_url("BUG-456", "zentao", &config, true); 369 | assert!(url.is_ok()); 370 | let url_str = url.unwrap(); 371 | assert_eq!( 372 | url_str, 373 | "http://domain/index.php?m=bug&f=view&bugID=BUG-456" 374 | ); 375 | // Verify ticket was substituted correctly 376 | assert!(url_str.contains("bugID=BUG-456")); 377 | assert!(url_str.contains("m=bug")); 378 | assert!(url_str.contains("f=view")); 379 | } 380 | 381 | #[test] 382 | fn test_build_url_complex_query_string() { 383 | // Test URL with many & characters that could break Windows cmd 384 | let toml_str = r#" 385 | [integrations] 386 | default_tracker = "tracker" 387 | 388 | [integrations.trackers.tracker] 389 | enabled = true 390 | base_url = "https://tracker.example.com" 391 | ticket_patterns = ["^ISSUE-\\d+$"] 392 | browse_url = "{base_url}/view?id={ticket}&action=show&tab=details&expand=true" 393 | worklog_url = "" 394 | "#; 395 | let config: Config = toml::from_str(toml_str).unwrap(); 396 | 397 | let url = build_url("ISSUE-789", "tracker", &config, false); 398 | assert!(url.is_ok()); 399 | let url_str = url.unwrap(); 400 | assert_eq!( 401 | url_str, 402 | "https://tracker.example.com/view?id=ISSUE-789&action=show&tab=details&expand=true" 403 | ); 404 | // Verify all parts are present 405 | assert!(url_str.contains("id=ISSUE-789")); 406 | assert!(url_str.contains("action=show")); 407 | assert!(url_str.contains("tab=details")); 408 | assert!(url_str.contains("expand=true")); 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod config; 3 | mod integrations; 4 | mod models; 5 | mod storage; 6 | mod timer; 7 | mod ui; 8 | 9 | use anyhow::{Context, Result}; 10 | use clap::Parser; 11 | use crossterm::{ 12 | event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, 13 | execute, 14 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 15 | }; 16 | use ratatui::{Terminal, backend::CrosstermBackend}; 17 | use std::io; 18 | use time::OffsetDateTime; 19 | use ui::AppState; 20 | 21 | fn main() -> Result<()> { 22 | // Try to parse CLI arguments 23 | let args: Vec = std::env::args().collect(); 24 | 25 | // If there are CLI arguments (beyond program name), run in CLI mode 26 | if args.len() > 1 { 27 | return run_cli(); 28 | } 29 | 30 | // Otherwise, run TUI 31 | run_tui() 32 | } 33 | 34 | /// Run in CLI mode 35 | fn run_cli() -> Result<()> { 36 | let cli = cli::Cli::parse(); 37 | let storage = storage::Storage::new()?; 38 | cli::handle_command(cli.command, storage) 39 | } 40 | 41 | /// Run in TUI mode 42 | fn run_tui() -> Result<()> { 43 | let today = OffsetDateTime::now_local() 44 | .context("Failed to get local time")? 45 | .date(); 46 | let mut storage = storage::StorageManager::new()?; 47 | let day_data = storage.load_with_tracking(today)?; 48 | 49 | enable_raw_mode()?; 50 | let mut stdout = io::stdout(); 51 | execute!(stdout, EnterAlternateScreen)?; 52 | let backend = CrosstermBackend::new(stdout); 53 | let mut terminal = Terminal::new(backend)?; 54 | 55 | let mut app = AppState::new(day_data); 56 | 57 | // Load active timer if one exists 58 | if let Ok(Some(timer)) = storage.load_active_timer() { 59 | app.active_timer = Some(timer); 60 | } 61 | 62 | // Initialize last_file_modified with tracked time 63 | app.last_file_modified = storage.get_last_modified(&today); 64 | 65 | let result = run_app(&mut terminal, &mut app, &mut storage); 66 | 67 | disable_raw_mode()?; 68 | execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 69 | terminal.show_cursor()?; 70 | 71 | if let Err(err) = result { 72 | eprintln!("Error: {}", err); 73 | } 74 | 75 | Ok(()) 76 | } 77 | 78 | fn run_app( 79 | terminal: &mut Terminal, 80 | app: &mut AppState, 81 | storage: &mut storage::StorageManager, 82 | ) -> Result<()> { 83 | loop { 84 | terminal.draw(|f| ui::render::render(f, app))?; 85 | 86 | if app.should_quit { 87 | storage.save(&app.day_data)?; 88 | app.last_file_modified = storage.get_last_modified(&app.current_date); 89 | break; 90 | } 91 | 92 | if app.date_changed { 93 | storage.save(&app.day_data)?; 94 | let new_day_data = storage.load_with_tracking(app.current_date)?; 95 | app.load_new_day_data(new_day_data); 96 | app.last_file_modified = storage.get_last_modified(&app.current_date); 97 | continue; // Force redraw with new data before waiting for next event 98 | } 99 | 100 | // Poll for events with timeout to update timer display 101 | if event::poll(std::time::Duration::from_millis(500))? 102 | && let Event::Key(key) = event::read()? 103 | && key.kind == KeyEventKind::Press 104 | { 105 | handle_key_event(app, key, storage); 106 | } 107 | // If no event (timeout), check for external file changes and redraw with updated timer 108 | else { 109 | // Check if the file has been modified externally (e.g., by CLI) 110 | app.check_and_reload_if_modified(storage); 111 | } 112 | } 113 | 114 | Ok(()) 115 | } 116 | 117 | fn handle_key_event(app: &mut AppState, key: KeyEvent, storage: &mut storage::StorageManager) { 118 | // Clear any previous error messages on new key press 119 | app.clear_error(); 120 | 121 | match app.mode { 122 | ui::AppMode::Browse => match key.code { 123 | KeyCode::Char('q') => app.should_quit = true, 124 | KeyCode::Char('?') => app.open_command_palette(), 125 | KeyCode::Char('C') => app.open_calendar(), 126 | KeyCode::Char('T') if app.config.has_integrations() => app.open_ticket_in_browser(), 127 | KeyCode::Char('L') if app.config.has_integrations() => app.open_worklog_in_browser(), 128 | // Timer keybindings 129 | KeyCode::Char('S') => { 130 | // Start/Stop toggle - Start if no timer active, Stop if timer is running 131 | if let Some(timer) = app.get_timer_status() { 132 | use crate::timer::TimerStatus; 133 | if matches!(timer.status, TimerStatus::Running | TimerStatus::Paused) { 134 | if let Err(e) = app.stop_active_timer(storage) { 135 | app.last_error_message = Some(e); 136 | } 137 | } else if let Err(e) = app.start_timer_for_selected(storage) { 138 | app.last_error_message = Some(e); 139 | } 140 | } else if let Err(e) = app.start_timer_for_selected(storage) { 141 | app.last_error_message = Some(e); 142 | } 143 | } 144 | KeyCode::Char('P') => { 145 | // Pause/Resume toggle 146 | if let Some(timer) = app.get_timer_status() { 147 | use crate::timer::TimerStatus; 148 | match timer.status { 149 | TimerStatus::Running => { 150 | if let Err(e) = app.pause_active_timer(storage) { 151 | app.last_error_message = Some(e); 152 | } 153 | } 154 | TimerStatus::Paused => { 155 | if let Err(e) = app.resume_active_timer(storage) { 156 | app.last_error_message = Some(e); 157 | } 158 | } 159 | _ => {} 160 | } 161 | } 162 | } 163 | KeyCode::Up | KeyCode::Char('k') => app.move_selection_up(), 164 | KeyCode::Down | KeyCode::Char('j') => app.move_selection_down(), 165 | KeyCode::Left | KeyCode::Char('h') => app.move_field_left(), 166 | KeyCode::Right | KeyCode::Char('l') => app.move_field_right(), 167 | KeyCode::Enter | KeyCode::Char('i') => app.enter_edit_mode(), 168 | KeyCode::Char('c') => app.change_task_name(), 169 | KeyCode::Char('n') => { 170 | app.add_new_record(); 171 | let _ = storage.save(&app.day_data); 172 | app.last_file_modified = storage.get_last_modified(&app.current_date); 173 | } 174 | KeyCode::Char('b') => { 175 | app.add_break(); 176 | let _ = storage.save(&app.day_data); 177 | app.last_file_modified = storage.get_last_modified(&app.current_date); 178 | } 179 | KeyCode::Char('d') => { 180 | app.delete_selected_record(); 181 | let _ = storage.save(&app.day_data); 182 | app.last_file_modified = storage.get_last_modified(&app.current_date); 183 | } 184 | KeyCode::Char('v') => app.enter_visual_mode(), 185 | KeyCode::Char('t') => { 186 | app.set_current_time_on_field(); 187 | let _ = storage.save(&app.day_data); 188 | app.last_file_modified = storage.get_last_modified(&app.current_date); 189 | } 190 | KeyCode::Char('u') => { 191 | app.undo(); 192 | let _ = storage.save(&app.day_data); 193 | app.last_file_modified = storage.get_last_modified(&app.current_date); 194 | } 195 | KeyCode::Char('r') => { 196 | app.redo(); 197 | let _ = storage.save(&app.day_data); 198 | app.last_file_modified = storage.get_last_modified(&app.current_date); 199 | } 200 | KeyCode::Char('s') => { 201 | let _ = storage.save(&app.day_data); 202 | app.last_file_modified = storage.get_last_modified(&app.current_date); 203 | } 204 | KeyCode::Char('[') => app.navigate_to_previous_day(), 205 | KeyCode::Char(']') => app.navigate_to_next_day(), 206 | _ => {} 207 | }, 208 | ui::AppMode::Edit => match key.code { 209 | KeyCode::Esc => app.exit_edit_mode(), 210 | KeyCode::Tab => app.next_field(), 211 | KeyCode::Enter => { 212 | let _ = app.save_edit(); 213 | let _ = storage.save(&app.day_data); 214 | app.last_file_modified = storage.get_last_modified(&app.current_date); 215 | } 216 | KeyCode::Backspace => app.handle_backspace(), 217 | KeyCode::Char(c) => app.handle_char_input(c), 218 | _ => {} 219 | }, 220 | ui::AppMode::Visual => match key.code { 221 | KeyCode::Esc => app.exit_visual_mode(), 222 | KeyCode::Up | KeyCode::Char('k') => app.move_selection_up(), 223 | KeyCode::Down | KeyCode::Char('j') => app.move_selection_down(), 224 | KeyCode::Char('d') => { 225 | app.delete_visual_selection(); 226 | let _ = storage.save(&app.day_data); 227 | app.last_file_modified = storage.get_last_modified(&app.current_date); 228 | } 229 | _ => {} 230 | }, 231 | ui::AppMode::CommandPalette => match key.code { 232 | KeyCode::Esc => app.close_command_palette(), 233 | KeyCode::Up => app.move_command_palette_up(), 234 | KeyCode::Down => { 235 | let filtered_count = app.get_filtered_commands().len(); 236 | app.move_command_palette_down(filtered_count); 237 | } 238 | KeyCode::Enter => { 239 | if let Some(action) = app.execute_selected_command() { 240 | execute_command_action(app, action, storage); 241 | } 242 | } 243 | KeyCode::Backspace => app.handle_command_palette_backspace(), 244 | KeyCode::Char(c) => app.handle_command_palette_char(c), 245 | _ => {} 246 | }, 247 | ui::AppMode::Calendar => match key.code { 248 | KeyCode::Esc => app.close_calendar(), 249 | KeyCode::Enter => app.calendar_select_date(), 250 | KeyCode::Left | KeyCode::Char('h') => app.calendar_navigate_left(), 251 | KeyCode::Right | KeyCode::Char('l') => app.calendar_navigate_right(), 252 | KeyCode::Up | KeyCode::Char('k') => app.calendar_navigate_up(), 253 | KeyCode::Down | KeyCode::Char('j') => app.calendar_navigate_down(), 254 | KeyCode::Char('<') | KeyCode::Char(',') | KeyCode::Char('[') => { 255 | app.calendar_previous_month() 256 | } 257 | KeyCode::Char('>') | KeyCode::Char('.') | KeyCode::Char(']') => { 258 | app.calendar_next_month() 259 | } 260 | _ => {} 261 | }, 262 | ui::AppMode::TaskPicker => match key.code { 263 | KeyCode::Esc => app.close_task_picker(), 264 | KeyCode::Up => app.move_task_picker_up(), 265 | KeyCode::Down => { 266 | let filtered_tasks = app.get_filtered_task_names(); 267 | app.move_task_picker_down(filtered_tasks.len()); 268 | } 269 | KeyCode::Enter => { 270 | app.select_task_from_picker(); 271 | let _ = storage.save(&app.day_data); 272 | app.last_file_modified = storage.get_last_modified(&app.current_date); 273 | } 274 | KeyCode::Backspace => app.handle_task_picker_backspace(), 275 | KeyCode::Char(c) => app.handle_task_picker_char(c), 276 | _ => {} 277 | }, 278 | } 279 | } 280 | 281 | fn execute_command_action( 282 | app: &mut AppState, 283 | action: ui::app_state::CommandAction, 284 | storage: &mut storage::StorageManager, 285 | ) { 286 | use ui::app_state::CommandAction; 287 | 288 | match action { 289 | CommandAction::MoveUp => app.move_selection_up(), 290 | CommandAction::MoveDown => app.move_selection_down(), 291 | CommandAction::MoveLeft => app.move_field_left(), 292 | CommandAction::MoveRight => app.move_field_right(), 293 | CommandAction::Edit => app.enter_edit_mode(), 294 | CommandAction::Change => app.change_task_name(), 295 | CommandAction::New => { 296 | app.add_new_record(); 297 | let _ = storage.save(&app.day_data); 298 | app.last_file_modified = storage.get_last_modified(&app.current_date); 299 | } 300 | CommandAction::Break => { 301 | app.add_break(); 302 | let _ = storage.save(&app.day_data); 303 | app.last_file_modified = storage.get_last_modified(&app.current_date); 304 | } 305 | CommandAction::Delete => { 306 | app.delete_selected_record(); 307 | let _ = storage.save(&app.day_data); 308 | app.last_file_modified = storage.get_last_modified(&app.current_date); 309 | } 310 | CommandAction::Visual => app.enter_visual_mode(), 311 | CommandAction::SetNow => { 312 | app.set_current_time_on_field(); 313 | let _ = storage.save(&app.day_data); 314 | app.last_file_modified = storage.get_last_modified(&app.current_date); 315 | } 316 | CommandAction::Undo => { 317 | app.undo(); 318 | let _ = storage.save(&app.day_data); 319 | app.last_file_modified = storage.get_last_modified(&app.current_date); 320 | } 321 | CommandAction::Redo => { 322 | app.redo(); 323 | let _ = storage.save(&app.day_data); 324 | app.last_file_modified = storage.get_last_modified(&app.current_date); 325 | } 326 | CommandAction::Save => { 327 | let _ = storage.save(&app.day_data); 328 | app.last_file_modified = storage.get_last_modified(&app.current_date); 329 | } 330 | CommandAction::StartTimer => { 331 | if let Err(e) = app.start_timer_for_selected(storage) { 332 | app.last_error_message = Some(format!("Failed to start timer: {}", e)); 333 | } 334 | } 335 | CommandAction::PauseTimer => { 336 | #[allow(clippy::collapsible_if)] 337 | if app 338 | .active_timer 339 | .as_ref() 340 | .is_some_and(|t| matches!(t.status, crate::timer::TimerStatus::Running)) 341 | { 342 | if let Err(e) = app.pause_active_timer(storage) { 343 | app.last_error_message = Some(format!("Failed to pause timer: {}", e)); 344 | } 345 | } else if app 346 | .active_timer 347 | .as_ref() 348 | .is_some_and(|t| matches!(t.status, crate::timer::TimerStatus::Paused)) 349 | { 350 | if let Err(e) = app.resume_active_timer(storage) { 351 | app.last_error_message = Some(format!("Failed to resume timer: {}", e)); 352 | } 353 | } 354 | } 355 | CommandAction::Quit => app.should_quit = true, 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/timer/mod.rs: -------------------------------------------------------------------------------- 1 | //! Timer module for automatic time tracking 2 | //! 3 | //! This module provides automatic timer functionality for tracking work sessions. 4 | //! Timers can be started, paused, resumed, and stopped, with automatic conversion 5 | //! to WorkRecord upon completion. 6 | 7 | use crate::models::{TimePoint, WorkRecord}; 8 | use crate::storage::Storage; 9 | use anyhow::{Context, Result, anyhow}; 10 | use serde::{Deserialize, Serialize}; 11 | use std::time::Duration as StdDuration; 12 | use time::{Date, OffsetDateTime}; 13 | 14 | /// Timer status enumeration 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 16 | #[serde(rename_all = "lowercase")] 17 | pub enum TimerStatus { 18 | Running, 19 | Paused, 20 | Stopped, 21 | } 22 | 23 | /// Active timer state with SQLite-ready fields 24 | /// 25 | /// This struct represents an active timer session. All fields are designed 26 | /// to be compatible with SQLite storage for future migration (Issue #22). 27 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 28 | pub struct TimerState { 29 | /// Optional ID for future SQLite primary key (currently unused) 30 | pub id: Option, 31 | 32 | /// Task name being tracked 33 | pub task_name: String, 34 | 35 | /// Optional description for the task 36 | pub description: Option, 37 | 38 | /// When the timer was started (UTC) 39 | pub start_time: OffsetDateTime, 40 | 41 | /// When the timer was stopped (UTC), None if still active 42 | pub end_time: Option, 43 | 44 | /// Date when timer was started 45 | pub date: Date, 46 | 47 | /// Current status of the timer 48 | pub status: TimerStatus, 49 | 50 | /// Total duration in seconds when paused (cumulative) 51 | pub paused_duration_secs: i64, 52 | 53 | /// When timer was last paused (to track current pause duration) 54 | pub paused_at: Option, 55 | 56 | /// When this timer record was created (audit field) 57 | pub created_at: OffsetDateTime, 58 | 59 | /// When this timer record was last updated (audit field) 60 | pub updated_at: OffsetDateTime, 61 | 62 | /// ID of the source work record that this timer was started from 63 | /// If present, stopping the timer will update the existing record instead of creating a new one 64 | #[serde(default)] 65 | pub source_record_id: Option, 66 | 67 | /// Date of the source work record (needed when timer is started from a past/future date view) 68 | /// If present, we'll update the record in this date's file instead of the timer start date 69 | #[serde(default)] 70 | pub source_record_date: Option, 71 | } 72 | 73 | /// Timer manager for controlling timer operations 74 | /// 75 | /// Provides methods to start, stop, pause, and resume timers, as well as 76 | /// query their current status. Manages persistence through the StorageManager layer. 77 | pub struct TimerManager { 78 | storage: Storage, 79 | } 80 | 81 | impl TimerManager { 82 | /// Create a new timer manager with low-level Storage 83 | /// For internal use - external callers should use storage::StorageManager instead 84 | pub fn new(storage: Storage) -> Self { 85 | TimerManager { storage } 86 | } 87 | 88 | /// Start a new timer 89 | /// 90 | /// # Errors 91 | /// Returns an error if a timer is already running 92 | pub fn start( 93 | &self, 94 | task_name: String, 95 | description: Option, 96 | source_record_id: Option, 97 | source_record_date: Option, 98 | ) -> Result { 99 | // Check if timer already running 100 | if (self.storage.load_active_timer()?).is_some() { 101 | return Err(anyhow!("A timer is already running")); 102 | } 103 | 104 | let now = OffsetDateTime::now_local() 105 | .context("Failed to get local time. System clock may not be configured correctly.")?; 106 | let timer = TimerState { 107 | id: None, 108 | task_name, 109 | description, 110 | start_time: now, 111 | end_time: None, 112 | date: now.date(), 113 | status: TimerStatus::Running, 114 | paused_duration_secs: 0, 115 | paused_at: None, 116 | created_at: now, 117 | updated_at: now, 118 | source_record_id, 119 | source_record_date, 120 | }; 121 | 122 | self.storage.save_active_timer(&timer)?; 123 | Ok(timer) 124 | } 125 | 126 | /// Stop the active timer and convert it to a WorkRecord 127 | /// 128 | /// # Errors 129 | /// Returns an error if no timer is running 130 | pub fn stop(&self) -> Result { 131 | let mut timer = self 132 | .storage 133 | .load_active_timer()? 134 | .ok_or_else(|| anyhow!("No timer is currently running"))?; 135 | 136 | let now = OffsetDateTime::now_local() 137 | .context("Failed to get local time. System clock may not be configured correctly.")?; 138 | 139 | // Determine which date's data file to load: 140 | // - If timer has source_record_date, use that (record is from a specific day's view) 141 | // - Otherwise use timer.start_time.date() (creating new record on timer's start date) 142 | let target_date = timer 143 | .source_record_date 144 | .unwrap_or_else(|| timer.start_time.date()); 145 | 146 | timer.end_time = Some(now); 147 | timer.status = TimerStatus::Stopped; 148 | timer.updated_at = now; 149 | 150 | // Load the day's data file 151 | let mut day_data = self.storage.load(&target_date)?; 152 | 153 | // If timer was started from an existing record, update that record's end time 154 | // Otherwise, create a new work record 155 | if let Some(source_id) = timer.source_record_id { 156 | // Find and update the existing record 157 | if let Some(record) = day_data.work_records.get_mut(&source_id) { 158 | // Update the end time to now 159 | let end_timepoint = TimePoint::new(now.hour(), now.minute()) 160 | .map_err(|e| anyhow!(e)) 161 | .context("Failed to create TimePoint for timer end time")?; 162 | record.end = end_timepoint; 163 | record.update_duration(); 164 | } else { 165 | // Source record not found, create new one instead 166 | let mut work_record = self.to_work_record(timer.clone())?; 167 | // Assign proper ID from day_data instead of using placeholder 168 | work_record.id = day_data.next_id(); 169 | day_data.add_record(work_record); 170 | } 171 | } else { 172 | // No source record, create a new work record 173 | let mut work_record = self.to_work_record(timer.clone())?; 174 | // Assign proper ID from day_data instead of using placeholder 175 | work_record.id = day_data.next_id(); 176 | day_data.add_record(work_record); 177 | } 178 | 179 | self.storage.save(&day_data)?; 180 | self.storage.clear_active_timer()?; 181 | 182 | // Return a work record for the stopped timer (for display purposes) 183 | let work_record = self.to_work_record(timer)?; 184 | Ok(work_record) 185 | } 186 | 187 | /// Pause the active timer 188 | /// 189 | /// # Errors 190 | /// Returns an error if timer is not running 191 | pub fn pause(&self) -> Result { 192 | let mut timer = self 193 | .storage 194 | .load_active_timer()? 195 | .ok_or_else(|| anyhow!("No timer is currently running"))?; 196 | 197 | if timer.status == TimerStatus::Paused { 198 | return Err(anyhow!("Timer is already paused")); 199 | } 200 | 201 | if timer.status != TimerStatus::Running { 202 | return Err(anyhow!("Can only pause a running timer")); 203 | } 204 | 205 | let now = OffsetDateTime::now_local() 206 | .context("Failed to get local time. System clock may not be configured correctly.")?; 207 | timer.paused_at = Some(now); 208 | timer.status = TimerStatus::Paused; 209 | timer.updated_at = now; 210 | 211 | self.storage.save_active_timer(&timer)?; 212 | Ok(timer) 213 | } 214 | 215 | /// Resume a paused timer 216 | /// 217 | /// # Errors 218 | /// Returns an error if timer is not paused 219 | pub fn resume(&self) -> Result { 220 | let mut timer = self 221 | .storage 222 | .load_active_timer()? 223 | .ok_or_else(|| anyhow!("No timer is currently running"))?; 224 | 225 | if timer.status != TimerStatus::Paused { 226 | return Err(anyhow!("Can only resume a paused timer")); 227 | } 228 | 229 | let now = OffsetDateTime::now_local() 230 | .context("Failed to get local time. System clock may not be configured correctly.")?; 231 | 232 | // Add current pause duration to cumulative paused time 233 | if let Some(paused_at) = timer.paused_at { 234 | let pause_duration = (now - paused_at).whole_seconds(); 235 | timer.paused_duration_secs += pause_duration; 236 | } 237 | 238 | timer.paused_at = None; 239 | timer.status = TimerStatus::Running; 240 | timer.updated_at = now; 241 | 242 | self.storage.save_active_timer(&timer)?; 243 | Ok(timer) 244 | } 245 | 246 | /// Get the current timer status 247 | /// 248 | /// Returns None if no timer is running 249 | pub fn status(&self) -> Result> { 250 | self.storage.load_active_timer() 251 | } 252 | 253 | /// Calculate elapsed duration of a timer 254 | /// 255 | /// Returns the time since start_time, minus any paused durations. 256 | pub fn get_elapsed_duration(&self, timer: &TimerState) -> StdDuration { 257 | let end_point = if timer.status == TimerStatus::Paused { 258 | // If paused, use when it was paused 259 | timer.paused_at.unwrap_or_else(|| { 260 | OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()) 261 | }) 262 | } else { 263 | // If running, use now 264 | OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()) 265 | }; 266 | 267 | let elapsed = end_point - timer.start_time; 268 | let paused_duration_std = StdDuration::from_secs(timer.paused_duration_secs as u64); 269 | 270 | // Convert time::Duration to std::Duration for arithmetic 271 | let elapsed_std = StdDuration::from_secs(elapsed.whole_seconds() as u64) 272 | + StdDuration::from_nanos(elapsed.subsec_nanoseconds() as u64); 273 | 274 | elapsed_std 275 | .checked_sub(paused_duration_std) 276 | .unwrap_or(StdDuration::ZERO) 277 | } 278 | 279 | /// Convert a stopped timer to a WorkRecord 280 | fn to_work_record(&self, timer: TimerState) -> Result { 281 | if timer.status != TimerStatus::Stopped { 282 | return Err(anyhow!("Can only convert stopped timers to WorkRecord")); 283 | } 284 | 285 | let start_time = timer.start_time; 286 | let end_time = timer 287 | .end_time 288 | .ok_or_else(|| anyhow!("Stopped timer must have end_time"))?; 289 | 290 | // Extract just the time portion from the OffsetDateTime values 291 | let start_timepoint = TimePoint::new(start_time.hour(), start_time.minute()) 292 | .map_err(|e| anyhow!(e)) 293 | .context("Failed to create TimePoint for timer start time")?; 294 | 295 | let end_timepoint = TimePoint::new(end_time.hour(), end_time.minute()) 296 | .map_err(|e| anyhow!(e)) 297 | .context("Failed to create TimePoint for timer end time")?; 298 | 299 | let mut record = WorkRecord::new( 300 | 1, // Placeholder ID, will be set by DayData 301 | timer.task_name, 302 | start_timepoint, 303 | end_timepoint, 304 | ); 305 | 306 | if let Some(description) = timer.description { 307 | record.description = description; 308 | } 309 | 310 | Ok(record) 311 | } 312 | } 313 | 314 | #[cfg(test)] 315 | mod tests { 316 | use super::*; 317 | use tempfile::TempDir; 318 | 319 | fn create_test_storage() -> (Storage, TempDir) { 320 | let temp_dir = TempDir::new().unwrap(); 321 | let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap(); 322 | (storage, temp_dir) 323 | } 324 | 325 | #[test] 326 | fn test_timer_state_creation() { 327 | let now = OffsetDateTime::now_utc(); 328 | let timer = TimerState { 329 | id: None, 330 | task_name: "Test Task".to_string(), 331 | description: None, 332 | start_time: now, 333 | end_time: None, 334 | date: now.date(), 335 | status: TimerStatus::Running, 336 | paused_duration_secs: 0, 337 | paused_at: None, 338 | created_at: now, 339 | updated_at: now, 340 | source_record_id: None, 341 | source_record_date: None, 342 | }; 343 | 344 | assert_eq!(timer.task_name, "Test Task"); 345 | assert_eq!(timer.status, TimerStatus::Running); 346 | assert_eq!(timer.paused_duration_secs, 0); 347 | } 348 | 349 | #[test] 350 | fn test_timer_serialization() { 351 | let now = OffsetDateTime::now_utc(); 352 | let timer = TimerState { 353 | id: None, 354 | task_name: "Test Task".to_string(), 355 | description: Some("Test description".to_string()), 356 | start_time: now, 357 | end_time: None, 358 | date: now.date(), 359 | status: TimerStatus::Running, 360 | paused_duration_secs: 0, 361 | paused_at: None, 362 | created_at: now, 363 | updated_at: now, 364 | source_record_id: None, 365 | source_record_date: None, 366 | }; 367 | 368 | let json = serde_json::to_string(&timer).unwrap(); 369 | let deserialized: TimerState = serde_json::from_str(&json).unwrap(); 370 | 371 | assert_eq!(deserialized.task_name, timer.task_name); 372 | assert_eq!(deserialized.status, timer.status); 373 | } 374 | 375 | #[test] 376 | fn test_start_timer() { 377 | let (storage, _temp) = create_test_storage(); 378 | let manager = TimerManager::new(storage); 379 | 380 | let result = manager.start("Work".to_string(), None, None, None); 381 | assert!(result.is_ok()); 382 | 383 | let timer = result.unwrap(); 384 | assert_eq!(timer.task_name, "Work"); 385 | assert_eq!(timer.status, TimerStatus::Running); 386 | assert_eq!(timer.paused_duration_secs, 0); 387 | } 388 | 389 | #[test] 390 | fn test_cannot_start_when_already_running() { 391 | let (storage, _temp) = create_test_storage(); 392 | let manager = TimerManager::new(storage); 393 | 394 | let _ = manager.start("Task 1".to_string(), None, None, None); 395 | let result = manager.start("Task 2".to_string(), None, None, None); 396 | 397 | assert!(result.is_err()); 398 | assert_eq!( 399 | result.unwrap_err().to_string(), 400 | "A timer is already running" 401 | ); 402 | } 403 | 404 | #[test] 405 | fn test_pause_running_timer() { 406 | let (storage, _temp) = create_test_storage(); 407 | let manager = TimerManager::new(storage); 408 | 409 | let _ = manager.start("Work".to_string(), None, None, None); 410 | let result = manager.pause(); 411 | 412 | assert!(result.is_ok()); 413 | let timer = result.unwrap(); 414 | assert_eq!(timer.status, TimerStatus::Paused); 415 | assert!(timer.paused_at.is_some()); 416 | } 417 | 418 | #[test] 419 | fn test_cannot_pause_paused_timer() { 420 | let (storage, _temp) = create_test_storage(); 421 | let manager = TimerManager::new(storage); 422 | 423 | let _ = manager.start("Work".to_string(), None, None, None); 424 | let _ = manager.pause(); 425 | let result = manager.pause(); 426 | 427 | assert!(result.is_err()); 428 | } 429 | 430 | #[test] 431 | fn test_pause_without_running_timer() { 432 | let (storage, _temp) = create_test_storage(); 433 | let manager = TimerManager::new(storage); 434 | 435 | let result = manager.pause(); 436 | assert!(result.is_err()); 437 | } 438 | 439 | #[test] 440 | fn test_resume_paused_timer() { 441 | let (storage, _temp) = create_test_storage(); 442 | let manager = TimerManager::new(storage); 443 | 444 | let _ = manager.start("Work".to_string(), None, None, None); 445 | let _ = manager.pause(); 446 | let result = manager.resume(); 447 | 448 | assert!(result.is_ok()); 449 | let timer = result.unwrap(); 450 | assert_eq!(timer.status, TimerStatus::Running); 451 | assert!(timer.paused_at.is_none()); 452 | } 453 | 454 | #[test] 455 | fn test_resume_updates_paused_duration() { 456 | let (storage, _temp) = create_test_storage(); 457 | let manager = TimerManager::new(storage); 458 | 459 | let _ = manager.start("Work".to_string(), None, None, None); 460 | let paused1 = manager.pause().unwrap(); 461 | assert_eq!(paused1.paused_duration_secs, 0); 462 | 463 | // Simulate time passing by manually updating 464 | let _ = manager.resume(); 465 | let paused2 = manager.pause().unwrap(); 466 | 467 | // paused_duration_secs should have increased 468 | assert!(paused2.paused_duration_secs >= 0); 469 | } 470 | 471 | #[test] 472 | fn test_cannot_resume_running_timer() { 473 | let (storage, _temp) = create_test_storage(); 474 | let manager = TimerManager::new(storage); 475 | 476 | let _ = manager.start("Work".to_string(), None, None, None); 477 | let result = manager.resume(); 478 | 479 | assert!(result.is_err()); 480 | } 481 | 482 | #[test] 483 | fn test_status_returns_none_when_no_timer() { 484 | let (storage, _temp) = create_test_storage(); 485 | let manager = TimerManager::new(storage); 486 | 487 | let result = manager.status().unwrap(); 488 | assert!(result.is_none()); 489 | } 490 | 491 | #[test] 492 | fn test_status_returns_running_timer() { 493 | let (storage, _temp) = create_test_storage(); 494 | let manager = TimerManager::new(storage); 495 | 496 | let _ = manager.start("Work".to_string(), None, None, None); 497 | let result = manager.status().unwrap(); 498 | 499 | assert!(result.is_some()); 500 | let timer = result.unwrap(); 501 | assert_eq!(timer.task_name, "Work"); 502 | assert_eq!(timer.status, TimerStatus::Running); 503 | } 504 | 505 | #[test] 506 | fn test_stop_running_timer() { 507 | let (storage, _temp) = create_test_storage(); 508 | let manager = TimerManager::new(storage); 509 | 510 | let _ = manager.start("Work".to_string(), None, None, None); 511 | let result = manager.stop(); 512 | 513 | assert!(result.is_ok()); 514 | let work_record = result.unwrap(); 515 | assert_eq!(work_record.name, "Work"); 516 | 517 | // Timer should be cleared 518 | let timer_status = manager.status().unwrap(); 519 | assert!(timer_status.is_none()); 520 | } 521 | 522 | #[test] 523 | fn test_cannot_stop_without_running_timer() { 524 | let (storage, _temp) = create_test_storage(); 525 | let manager = TimerManager::new(storage); 526 | 527 | let result = manager.stop(); 528 | assert!(result.is_err()); 529 | } 530 | 531 | #[test] 532 | fn test_stop_returns_work_record_with_description() { 533 | let (storage, _temp) = create_test_storage(); 534 | let manager = TimerManager::new(storage); 535 | 536 | let _ = manager.start( 537 | "Work".to_string(), 538 | Some("Important task".to_string()), 539 | None, 540 | None, 541 | ); 542 | let work_record = manager.stop().unwrap(); 543 | 544 | assert_eq!(work_record.name, "Work"); 545 | assert_eq!(work_record.description, "Important task"); 546 | } 547 | 548 | #[test] 549 | fn test_full_timer_lifecycle() { 550 | let (storage, _temp) = create_test_storage(); 551 | let manager = TimerManager::new(storage); 552 | 553 | // Start 554 | let started = manager.start("Task".to_string(), None, None, None).unwrap(); 555 | assert_eq!(started.status, TimerStatus::Running); 556 | 557 | // Pause 558 | let paused = manager.pause().unwrap(); 559 | assert_eq!(paused.status, TimerStatus::Paused); 560 | 561 | // Resume 562 | let resumed = manager.resume().unwrap(); 563 | assert_eq!(resumed.status, TimerStatus::Running); 564 | 565 | // Pause again 566 | let paused_again = manager.pause().unwrap(); 567 | assert_eq!(paused_again.status, TimerStatus::Paused); 568 | 569 | // Resume again 570 | let resumed_again = manager.resume().unwrap(); 571 | assert_eq!(resumed_again.status, TimerStatus::Running); 572 | 573 | // Stop 574 | let work_record = manager.stop().unwrap(); 575 | assert_eq!(work_record.name, "Task"); 576 | 577 | // Verify timer is cleared 578 | let status = manager.status().unwrap(); 579 | assert!(status.is_none()); 580 | } 581 | 582 | #[test] 583 | fn test_get_elapsed_duration_running() { 584 | let (storage, _temp) = create_test_storage(); 585 | let manager = TimerManager::new(storage); 586 | 587 | let timer = manager.start("Task".to_string(), None, None, None).unwrap(); 588 | let elapsed = manager.get_elapsed_duration(&timer); 589 | 590 | // Should be close to 0 since just started 591 | assert!(elapsed.as_secs() < 2); 592 | } 593 | 594 | #[test] 595 | fn test_get_elapsed_duration_with_pause() { 596 | let (storage, _temp) = create_test_storage(); 597 | let manager = TimerManager::new(storage); 598 | 599 | let _ = manager.start("Task".to_string(), None, None, None); 600 | let _ = manager.pause(); 601 | 602 | let timer = manager.status().unwrap().unwrap(); 603 | let elapsed = manager.get_elapsed_duration(&timer); 604 | 605 | // Should be very small since just paused 606 | assert!(elapsed.as_secs() < 2); 607 | } 608 | 609 | #[test] 610 | fn test_stop_updates_existing_record() { 611 | use crate::models::DayData; 612 | use crate::models::TimePoint; 613 | use crate::models::WorkRecord; 614 | use tempfile::TempDir; 615 | use time::OffsetDateTime; 616 | 617 | // Create a temp dir and storage that we can reuse 618 | let temp_dir = TempDir::new().unwrap(); 619 | let storage_path = temp_dir.path().to_path_buf(); 620 | 621 | // Create initial day data with one record 622 | let now = OffsetDateTime::now_utc(); 623 | let today = now.date(); 624 | let mut day_data = DayData::new(today); 625 | 626 | let record = WorkRecord::new( 627 | 1, 628 | "Existing Task".to_string(), 629 | TimePoint::new(9, 0).unwrap(), 630 | TimePoint::new(10, 0).unwrap(), 631 | ); 632 | day_data.add_record(record); 633 | 634 | // Save using first storage instance 635 | let storage1 = Storage::new_with_dir(storage_path.clone()).unwrap(); 636 | storage1.save(&day_data).unwrap(); 637 | 638 | // Start timer with source_record_id = 1, source_record_date = today 639 | let manager = TimerManager::new(storage1); 640 | manager 641 | .start("Existing Task".to_string(), None, Some(1), Some(today)) 642 | .unwrap(); 643 | 644 | // Stop timer - should update the existing record's end time 645 | manager.stop().unwrap(); 646 | 647 | // Create a new storage instance pointing to the same temp dir to verify the update 648 | let storage2 = Storage::new_with_dir(storage_path).unwrap(); 649 | let updated_day_data = storage2.load(&today).unwrap(); 650 | 651 | // Should still have only 1 record (not 2!) 652 | assert_eq!(updated_day_data.work_records.len(), 1); 653 | 654 | // The record should have updated end time (not still 10:00) 655 | let updated_record = updated_day_data.work_records.get(&1).unwrap(); 656 | assert_eq!(updated_record.name, "Existing Task"); 657 | // End time should be close to now (within a few minutes) 658 | assert!( 659 | updated_record.end.hour >= now.hour() 660 | || (updated_record.end.hour == 0 && now.hour() == 23) 661 | ); // Handle day boundary 662 | } 663 | } 664 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "allocator-api2" 16 | version = "0.2.21" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 | 20 | [[package]] 21 | name = "anstream" 22 | version = "0.6.21" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "is_terminal_polyfill", 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle" 37 | version = "1.0.13" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 40 | 41 | [[package]] 42 | name = "anstyle-parse" 43 | version = "0.2.7" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 46 | dependencies = [ 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-query" 52 | version = "1.1.4" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 55 | dependencies = [ 56 | "windows-sys 0.60.2", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-wincon" 61 | version = "3.0.10" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 64 | dependencies = [ 65 | "anstyle", 66 | "once_cell_polyfill", 67 | "windows-sys 0.60.2", 68 | ] 69 | 70 | [[package]] 71 | name = "anyhow" 72 | version = "1.0.100" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 75 | 76 | [[package]] 77 | name = "bitflags" 78 | version = "2.10.0" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 81 | 82 | [[package]] 83 | name = "cassowary" 84 | version = "0.3.0" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 87 | 88 | [[package]] 89 | name = "castaway" 90 | version = "0.2.4" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 93 | dependencies = [ 94 | "rustversion", 95 | ] 96 | 97 | [[package]] 98 | name = "cfg-if" 99 | version = "1.0.4" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 102 | 103 | [[package]] 104 | name = "clap" 105 | version = "4.5.51" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" 108 | dependencies = [ 109 | "clap_builder", 110 | "clap_derive", 111 | ] 112 | 113 | [[package]] 114 | name = "clap_builder" 115 | version = "4.5.51" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" 118 | dependencies = [ 119 | "anstream", 120 | "anstyle", 121 | "clap_lex", 122 | "strsim", 123 | ] 124 | 125 | [[package]] 126 | name = "clap_derive" 127 | version = "4.5.49" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 130 | dependencies = [ 131 | "heck", 132 | "proc-macro2", 133 | "quote", 134 | "syn", 135 | ] 136 | 137 | [[package]] 138 | name = "clap_lex" 139 | version = "0.7.6" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 142 | 143 | [[package]] 144 | name = "colorchoice" 145 | version = "1.0.4" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 148 | 149 | [[package]] 150 | name = "compact_str" 151 | version = "0.7.1" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" 154 | dependencies = [ 155 | "castaway", 156 | "cfg-if", 157 | "itoa", 158 | "ryu", 159 | "static_assertions", 160 | ] 161 | 162 | [[package]] 163 | name = "crossterm" 164 | version = "0.27.0" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" 167 | dependencies = [ 168 | "bitflags", 169 | "crossterm_winapi", 170 | "libc", 171 | "mio", 172 | "parking_lot", 173 | "signal-hook", 174 | "signal-hook-mio", 175 | "winapi", 176 | ] 177 | 178 | [[package]] 179 | name = "crossterm_winapi" 180 | version = "0.9.1" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 183 | dependencies = [ 184 | "winapi", 185 | ] 186 | 187 | [[package]] 188 | name = "deranged" 189 | version = "0.5.5" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" 192 | dependencies = [ 193 | "powerfmt", 194 | "serde_core", 195 | ] 196 | 197 | [[package]] 198 | name = "dirs" 199 | version = "5.0.1" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 202 | dependencies = [ 203 | "dirs-sys", 204 | ] 205 | 206 | [[package]] 207 | name = "dirs-sys" 208 | version = "0.4.1" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 211 | dependencies = [ 212 | "libc", 213 | "option-ext", 214 | "redox_users", 215 | "windows-sys 0.48.0", 216 | ] 217 | 218 | [[package]] 219 | name = "either" 220 | version = "1.15.0" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 223 | 224 | [[package]] 225 | name = "equivalent" 226 | version = "1.0.2" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 229 | 230 | [[package]] 231 | name = "errno" 232 | version = "0.3.14" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 235 | dependencies = [ 236 | "libc", 237 | "windows-sys 0.61.2", 238 | ] 239 | 240 | [[package]] 241 | name = "fastrand" 242 | version = "2.3.0" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 245 | 246 | [[package]] 247 | name = "foldhash" 248 | version = "0.1.5" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 251 | 252 | [[package]] 253 | name = "fuzzy-matcher" 254 | version = "0.3.7" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" 257 | dependencies = [ 258 | "thread_local", 259 | ] 260 | 261 | [[package]] 262 | name = "getrandom" 263 | version = "0.2.16" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 266 | dependencies = [ 267 | "cfg-if", 268 | "libc", 269 | "wasi", 270 | ] 271 | 272 | [[package]] 273 | name = "getrandom" 274 | version = "0.3.4" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 277 | dependencies = [ 278 | "cfg-if", 279 | "libc", 280 | "r-efi", 281 | "wasip2", 282 | ] 283 | 284 | [[package]] 285 | name = "hashbrown" 286 | version = "0.15.5" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 289 | dependencies = [ 290 | "allocator-api2", 291 | "equivalent", 292 | "foldhash", 293 | ] 294 | 295 | [[package]] 296 | name = "hashbrown" 297 | version = "0.16.0" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 300 | 301 | [[package]] 302 | name = "heck" 303 | version = "0.5.0" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 306 | 307 | [[package]] 308 | name = "indexmap" 309 | version = "2.12.0" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" 312 | dependencies = [ 313 | "equivalent", 314 | "hashbrown 0.16.0", 315 | ] 316 | 317 | [[package]] 318 | name = "is_terminal_polyfill" 319 | version = "1.70.2" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 322 | 323 | [[package]] 324 | name = "itertools" 325 | version = "0.12.1" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" 328 | dependencies = [ 329 | "either", 330 | ] 331 | 332 | [[package]] 333 | name = "itertools" 334 | version = "0.13.0" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 337 | dependencies = [ 338 | "either", 339 | ] 340 | 341 | [[package]] 342 | name = "itoa" 343 | version = "1.0.15" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 346 | 347 | [[package]] 348 | name = "libc" 349 | version = "0.2.177" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 352 | 353 | [[package]] 354 | name = "libredox" 355 | version = "0.1.10" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 358 | dependencies = [ 359 | "bitflags", 360 | "libc", 361 | ] 362 | 363 | [[package]] 364 | name = "linux-raw-sys" 365 | version = "0.11.0" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 368 | 369 | [[package]] 370 | name = "lock_api" 371 | version = "0.4.14" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 374 | dependencies = [ 375 | "scopeguard", 376 | ] 377 | 378 | [[package]] 379 | name = "log" 380 | version = "0.4.28" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 383 | 384 | [[package]] 385 | name = "lru" 386 | version = "0.12.5" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 389 | dependencies = [ 390 | "hashbrown 0.15.5", 391 | ] 392 | 393 | [[package]] 394 | name = "memchr" 395 | version = "2.7.6" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 398 | 399 | [[package]] 400 | name = "mio" 401 | version = "0.8.11" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 404 | dependencies = [ 405 | "libc", 406 | "log", 407 | "wasi", 408 | "windows-sys 0.48.0", 409 | ] 410 | 411 | [[package]] 412 | name = "num-conv" 413 | version = "0.1.0" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 416 | 417 | [[package]] 418 | name = "num_threads" 419 | version = "0.1.7" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 422 | dependencies = [ 423 | "libc", 424 | ] 425 | 426 | [[package]] 427 | name = "once_cell" 428 | version = "1.21.3" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 431 | 432 | [[package]] 433 | name = "once_cell_polyfill" 434 | version = "1.70.2" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 437 | 438 | [[package]] 439 | name = "option-ext" 440 | version = "0.2.0" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 443 | 444 | [[package]] 445 | name = "parking_lot" 446 | version = "0.12.5" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 449 | dependencies = [ 450 | "lock_api", 451 | "parking_lot_core", 452 | ] 453 | 454 | [[package]] 455 | name = "parking_lot_core" 456 | version = "0.9.12" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 459 | dependencies = [ 460 | "cfg-if", 461 | "libc", 462 | "redox_syscall", 463 | "smallvec", 464 | "windows-link", 465 | ] 466 | 467 | [[package]] 468 | name = "paste" 469 | version = "1.0.15" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 472 | 473 | [[package]] 474 | name = "powerfmt" 475 | version = "0.2.0" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 478 | 479 | [[package]] 480 | name = "proc-macro2" 481 | version = "1.0.103" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 484 | dependencies = [ 485 | "unicode-ident", 486 | ] 487 | 488 | [[package]] 489 | name = "quote" 490 | version = "1.0.41" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 493 | dependencies = [ 494 | "proc-macro2", 495 | ] 496 | 497 | [[package]] 498 | name = "r-efi" 499 | version = "5.3.0" 500 | source = "registry+https://github.com/rust-lang/crates.io-index" 501 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 502 | 503 | [[package]] 504 | name = "ratatui" 505 | version = "0.26.3" 506 | source = "registry+https://github.com/rust-lang/crates.io-index" 507 | checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" 508 | dependencies = [ 509 | "bitflags", 510 | "cassowary", 511 | "compact_str", 512 | "crossterm", 513 | "itertools 0.12.1", 514 | "lru", 515 | "paste", 516 | "stability", 517 | "strum", 518 | "unicode-segmentation", 519 | "unicode-truncate", 520 | "unicode-width", 521 | ] 522 | 523 | [[package]] 524 | name = "redox_syscall" 525 | version = "0.5.18" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 528 | dependencies = [ 529 | "bitflags", 530 | ] 531 | 532 | [[package]] 533 | name = "redox_users" 534 | version = "0.4.6" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 537 | dependencies = [ 538 | "getrandom 0.2.16", 539 | "libredox", 540 | "thiserror", 541 | ] 542 | 543 | [[package]] 544 | name = "regex" 545 | version = "1.12.2" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 548 | dependencies = [ 549 | "aho-corasick", 550 | "memchr", 551 | "regex-automata", 552 | "regex-syntax", 553 | ] 554 | 555 | [[package]] 556 | name = "regex-automata" 557 | version = "0.4.13" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 560 | dependencies = [ 561 | "aho-corasick", 562 | "memchr", 563 | "regex-syntax", 564 | ] 565 | 566 | [[package]] 567 | name = "regex-syntax" 568 | version = "0.8.8" 569 | source = "registry+https://github.com/rust-lang/crates.io-index" 570 | checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 571 | 572 | [[package]] 573 | name = "rustix" 574 | version = "1.1.2" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 577 | dependencies = [ 578 | "bitflags", 579 | "errno", 580 | "libc", 581 | "linux-raw-sys", 582 | "windows-sys 0.61.2", 583 | ] 584 | 585 | [[package]] 586 | name = "rustversion" 587 | version = "1.0.22" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 590 | 591 | [[package]] 592 | name = "ryu" 593 | version = "1.0.20" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 596 | 597 | [[package]] 598 | name = "scopeguard" 599 | version = "1.2.0" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 602 | 603 | [[package]] 604 | name = "serde" 605 | version = "1.0.228" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 608 | dependencies = [ 609 | "serde_core", 610 | "serde_derive", 611 | ] 612 | 613 | [[package]] 614 | name = "serde_core" 615 | version = "1.0.228" 616 | source = "registry+https://github.com/rust-lang/crates.io-index" 617 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 618 | dependencies = [ 619 | "serde_derive", 620 | ] 621 | 622 | [[package]] 623 | name = "serde_derive" 624 | version = "1.0.228" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 627 | dependencies = [ 628 | "proc-macro2", 629 | "quote", 630 | "syn", 631 | ] 632 | 633 | [[package]] 634 | name = "serde_json" 635 | version = "1.0.145" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 638 | dependencies = [ 639 | "itoa", 640 | "memchr", 641 | "ryu", 642 | "serde", 643 | "serde_core", 644 | ] 645 | 646 | [[package]] 647 | name = "serde_spanned" 648 | version = "0.6.9" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 651 | dependencies = [ 652 | "serde", 653 | ] 654 | 655 | [[package]] 656 | name = "signal-hook" 657 | version = "0.3.18" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 660 | dependencies = [ 661 | "libc", 662 | "signal-hook-registry", 663 | ] 664 | 665 | [[package]] 666 | name = "signal-hook-mio" 667 | version = "0.2.5" 668 | source = "registry+https://github.com/rust-lang/crates.io-index" 669 | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 670 | dependencies = [ 671 | "libc", 672 | "mio", 673 | "signal-hook", 674 | ] 675 | 676 | [[package]] 677 | name = "signal-hook-registry" 678 | version = "1.4.6" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 681 | dependencies = [ 682 | "libc", 683 | ] 684 | 685 | [[package]] 686 | name = "smallvec" 687 | version = "1.15.1" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 690 | 691 | [[package]] 692 | name = "stability" 693 | version = "0.2.1" 694 | source = "registry+https://github.com/rust-lang/crates.io-index" 695 | checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" 696 | dependencies = [ 697 | "quote", 698 | "syn", 699 | ] 700 | 701 | [[package]] 702 | name = "static_assertions" 703 | version = "1.1.0" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 706 | 707 | [[package]] 708 | name = "strsim" 709 | version = "0.11.1" 710 | source = "registry+https://github.com/rust-lang/crates.io-index" 711 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 712 | 713 | [[package]] 714 | name = "strum" 715 | version = "0.26.3" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 718 | dependencies = [ 719 | "strum_macros", 720 | ] 721 | 722 | [[package]] 723 | name = "strum_macros" 724 | version = "0.26.4" 725 | source = "registry+https://github.com/rust-lang/crates.io-index" 726 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 727 | dependencies = [ 728 | "heck", 729 | "proc-macro2", 730 | "quote", 731 | "rustversion", 732 | "syn", 733 | ] 734 | 735 | [[package]] 736 | name = "syn" 737 | version = "2.0.108" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" 740 | dependencies = [ 741 | "proc-macro2", 742 | "quote", 743 | "unicode-ident", 744 | ] 745 | 746 | [[package]] 747 | name = "tempfile" 748 | version = "3.23.0" 749 | source = "registry+https://github.com/rust-lang/crates.io-index" 750 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 751 | dependencies = [ 752 | "fastrand", 753 | "getrandom 0.3.4", 754 | "once_cell", 755 | "rustix", 756 | "windows-sys 0.61.2", 757 | ] 758 | 759 | [[package]] 760 | name = "thiserror" 761 | version = "1.0.69" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 764 | dependencies = [ 765 | "thiserror-impl", 766 | ] 767 | 768 | [[package]] 769 | name = "thiserror-impl" 770 | version = "1.0.69" 771 | source = "registry+https://github.com/rust-lang/crates.io-index" 772 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 773 | dependencies = [ 774 | "proc-macro2", 775 | "quote", 776 | "syn", 777 | ] 778 | 779 | [[package]] 780 | name = "thread_local" 781 | version = "1.1.9" 782 | source = "registry+https://github.com/rust-lang/crates.io-index" 783 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 784 | dependencies = [ 785 | "cfg-if", 786 | ] 787 | 788 | [[package]] 789 | name = "time" 790 | version = "0.3.44" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" 793 | dependencies = [ 794 | "deranged", 795 | "itoa", 796 | "libc", 797 | "num-conv", 798 | "num_threads", 799 | "powerfmt", 800 | "serde", 801 | "time-core", 802 | "time-macros", 803 | ] 804 | 805 | [[package]] 806 | name = "time-core" 807 | version = "0.1.6" 808 | source = "registry+https://github.com/rust-lang/crates.io-index" 809 | checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 810 | 811 | [[package]] 812 | name = "time-macros" 813 | version = "0.2.24" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" 816 | dependencies = [ 817 | "num-conv", 818 | "time-core", 819 | ] 820 | 821 | [[package]] 822 | name = "toml" 823 | version = "0.8.23" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 826 | dependencies = [ 827 | "serde", 828 | "serde_spanned", 829 | "toml_datetime", 830 | "toml_edit", 831 | ] 832 | 833 | [[package]] 834 | name = "toml_datetime" 835 | version = "0.6.11" 836 | source = "registry+https://github.com/rust-lang/crates.io-index" 837 | checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 838 | dependencies = [ 839 | "serde", 840 | ] 841 | 842 | [[package]] 843 | name = "toml_edit" 844 | version = "0.22.27" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 847 | dependencies = [ 848 | "indexmap", 849 | "serde", 850 | "serde_spanned", 851 | "toml_datetime", 852 | "toml_write", 853 | "winnow", 854 | ] 855 | 856 | [[package]] 857 | name = "toml_write" 858 | version = "0.1.2" 859 | source = "registry+https://github.com/rust-lang/crates.io-index" 860 | checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 861 | 862 | [[package]] 863 | name = "unicode-ident" 864 | version = "1.0.22" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 867 | 868 | [[package]] 869 | name = "unicode-segmentation" 870 | version = "1.12.0" 871 | source = "registry+https://github.com/rust-lang/crates.io-index" 872 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 873 | 874 | [[package]] 875 | name = "unicode-truncate" 876 | version = "1.1.0" 877 | source = "registry+https://github.com/rust-lang/crates.io-index" 878 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 879 | dependencies = [ 880 | "itertools 0.13.0", 881 | "unicode-segmentation", 882 | "unicode-width", 883 | ] 884 | 885 | [[package]] 886 | name = "unicode-width" 887 | version = "0.1.14" 888 | source = "registry+https://github.com/rust-lang/crates.io-index" 889 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 890 | 891 | [[package]] 892 | name = "utf8parse" 893 | version = "0.2.2" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 896 | 897 | [[package]] 898 | name = "wasi" 899 | version = "0.11.1+wasi-snapshot-preview1" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 902 | 903 | [[package]] 904 | name = "wasip2" 905 | version = "1.0.1+wasi-0.2.4" 906 | source = "registry+https://github.com/rust-lang/crates.io-index" 907 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 908 | dependencies = [ 909 | "wit-bindgen", 910 | ] 911 | 912 | [[package]] 913 | name = "winapi" 914 | version = "0.3.9" 915 | source = "registry+https://github.com/rust-lang/crates.io-index" 916 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 917 | dependencies = [ 918 | "winapi-i686-pc-windows-gnu", 919 | "winapi-x86_64-pc-windows-gnu", 920 | ] 921 | 922 | [[package]] 923 | name = "winapi-i686-pc-windows-gnu" 924 | version = "0.4.0" 925 | source = "registry+https://github.com/rust-lang/crates.io-index" 926 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 927 | 928 | [[package]] 929 | name = "winapi-x86_64-pc-windows-gnu" 930 | version = "0.4.0" 931 | source = "registry+https://github.com/rust-lang/crates.io-index" 932 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 933 | 934 | [[package]] 935 | name = "windows-link" 936 | version = "0.2.1" 937 | source = "registry+https://github.com/rust-lang/crates.io-index" 938 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 939 | 940 | [[package]] 941 | name = "windows-sys" 942 | version = "0.48.0" 943 | source = "registry+https://github.com/rust-lang/crates.io-index" 944 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 945 | dependencies = [ 946 | "windows-targets 0.48.5", 947 | ] 948 | 949 | [[package]] 950 | name = "windows-sys" 951 | version = "0.60.2" 952 | source = "registry+https://github.com/rust-lang/crates.io-index" 953 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 954 | dependencies = [ 955 | "windows-targets 0.53.5", 956 | ] 957 | 958 | [[package]] 959 | name = "windows-sys" 960 | version = "0.61.2" 961 | source = "registry+https://github.com/rust-lang/crates.io-index" 962 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 963 | dependencies = [ 964 | "windows-link", 965 | ] 966 | 967 | [[package]] 968 | name = "windows-targets" 969 | version = "0.48.5" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 972 | dependencies = [ 973 | "windows_aarch64_gnullvm 0.48.5", 974 | "windows_aarch64_msvc 0.48.5", 975 | "windows_i686_gnu 0.48.5", 976 | "windows_i686_msvc 0.48.5", 977 | "windows_x86_64_gnu 0.48.5", 978 | "windows_x86_64_gnullvm 0.48.5", 979 | "windows_x86_64_msvc 0.48.5", 980 | ] 981 | 982 | [[package]] 983 | name = "windows-targets" 984 | version = "0.53.5" 985 | source = "registry+https://github.com/rust-lang/crates.io-index" 986 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 987 | dependencies = [ 988 | "windows-link", 989 | "windows_aarch64_gnullvm 0.53.1", 990 | "windows_aarch64_msvc 0.53.1", 991 | "windows_i686_gnu 0.53.1", 992 | "windows_i686_gnullvm", 993 | "windows_i686_msvc 0.53.1", 994 | "windows_x86_64_gnu 0.53.1", 995 | "windows_x86_64_gnullvm 0.53.1", 996 | "windows_x86_64_msvc 0.53.1", 997 | ] 998 | 999 | [[package]] 1000 | name = "windows_aarch64_gnullvm" 1001 | version = "0.48.5" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1004 | 1005 | [[package]] 1006 | name = "windows_aarch64_gnullvm" 1007 | version = "0.53.1" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 1010 | 1011 | [[package]] 1012 | name = "windows_aarch64_msvc" 1013 | version = "0.48.5" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1016 | 1017 | [[package]] 1018 | name = "windows_aarch64_msvc" 1019 | version = "0.53.1" 1020 | source = "registry+https://github.com/rust-lang/crates.io-index" 1021 | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 1022 | 1023 | [[package]] 1024 | name = "windows_i686_gnu" 1025 | version = "0.48.5" 1026 | source = "registry+https://github.com/rust-lang/crates.io-index" 1027 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1028 | 1029 | [[package]] 1030 | name = "windows_i686_gnu" 1031 | version = "0.53.1" 1032 | source = "registry+https://github.com/rust-lang/crates.io-index" 1033 | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 1034 | 1035 | [[package]] 1036 | name = "windows_i686_gnullvm" 1037 | version = "0.53.1" 1038 | source = "registry+https://github.com/rust-lang/crates.io-index" 1039 | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 1040 | 1041 | [[package]] 1042 | name = "windows_i686_msvc" 1043 | version = "0.48.5" 1044 | source = "registry+https://github.com/rust-lang/crates.io-index" 1045 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1046 | 1047 | [[package]] 1048 | name = "windows_i686_msvc" 1049 | version = "0.53.1" 1050 | source = "registry+https://github.com/rust-lang/crates.io-index" 1051 | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 1052 | 1053 | [[package]] 1054 | name = "windows_x86_64_gnu" 1055 | version = "0.48.5" 1056 | source = "registry+https://github.com/rust-lang/crates.io-index" 1057 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1058 | 1059 | [[package]] 1060 | name = "windows_x86_64_gnu" 1061 | version = "0.53.1" 1062 | source = "registry+https://github.com/rust-lang/crates.io-index" 1063 | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 1064 | 1065 | [[package]] 1066 | name = "windows_x86_64_gnullvm" 1067 | version = "0.48.5" 1068 | source = "registry+https://github.com/rust-lang/crates.io-index" 1069 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1070 | 1071 | [[package]] 1072 | name = "windows_x86_64_gnullvm" 1073 | version = "0.53.1" 1074 | source = "registry+https://github.com/rust-lang/crates.io-index" 1075 | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 1076 | 1077 | [[package]] 1078 | name = "windows_x86_64_msvc" 1079 | version = "0.48.5" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1082 | 1083 | [[package]] 1084 | name = "windows_x86_64_msvc" 1085 | version = "0.53.1" 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" 1087 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 1088 | 1089 | [[package]] 1090 | name = "winnow" 1091 | version = "0.7.13" 1092 | source = "registry+https://github.com/rust-lang/crates.io-index" 1093 | checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 1094 | dependencies = [ 1095 | "memchr", 1096 | ] 1097 | 1098 | [[package]] 1099 | name = "wit-bindgen" 1100 | version = "0.46.0" 1101 | source = "registry+https://github.com/rust-lang/crates.io-index" 1102 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 1103 | 1104 | [[package]] 1105 | name = "work-tuimer" 1106 | version = "0.3.4" 1107 | dependencies = [ 1108 | "anyhow", 1109 | "clap", 1110 | "crossterm", 1111 | "dirs", 1112 | "fuzzy-matcher", 1113 | "ratatui", 1114 | "regex", 1115 | "serde", 1116 | "serde_json", 1117 | "tempfile", 1118 | "time", 1119 | "toml", 1120 | ] 1121 | --------------------------------------------------------------------------------