diff --git a/src-tauri/CLAUDE.md b/src-tauri/CLAUDE.md index 19c70039..b5641769 100644 --- a/src-tauri/CLAUDE.md +++ b/src-tauri/CLAUDE.md @@ -465,3 +465,42 @@ Essential crates used in this project: 4. **Platform Integration**: Native system APIs for enhanced functionality 5. **Event-Driven**: Tauri events for real-time communication with frontend 6. **Testing**: Comprehensive unit and integration tests + +## Development Process + +### Incremental Development Pattern + +When implementing new functionality, follow this pattern: + +1. **One Function at a Time**: Implement a single function completely before moving to the next +2. **Test Each Function**: Write comprehensive tests for each function before proceeding +3. **Validate Functionality**: Run tests to ensure the function works correctly +4. **Iterate**: Only after tests pass, move to the next function + +**Example workflow:** +```rust +// Step 1: Implement single function +pub async fn get_or_create_active_tides_for_period(&self, evaluation_time: OffsetDateTime) -> Result> { + // Implementation +} + +// Step 2: Write tests for this function +#[tokio::test] +async fn test_get_or_create_active_tides_basic() -> Result<()> { + // Test implementation +} + +// Step 3: Run tests and validate +// Step 4: Only then implement next function +``` + +**Benefits:** +- **Easier Review**: Smaller, focused changes are easier to validate +- **Better Debugging**: Issues are isolated to single functions +- **Incremental Progress**: Each step provides working functionality +- **Reduced Complexity**: Avoid overwhelming changes + +**Avoid:** +- Implementing multiple complex functions simultaneously +- Large code changes without intermediate testing +- Adding many functions before validating any work diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aea24c49..c06528b1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1453,6 +1453,7 @@ dependencies = [ "chrono", "dirs 6.0.0", "ebb-db", + "ebb_tide_manager", "log", "monitor", "objc2-app-kit", @@ -1501,6 +1502,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "ebb_tide_manager" +version = "0.1.0" +dependencies = [ + "ebb-db", + "log", + "thiserror 2.0.12", + "time", + "tokio", + "uuid", +] + [[package]] name = "either" version = "1.15.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 29f52735..ab5af0be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] } chrono = "0.4" dirs = "6.0.0" ebb-db = { path = "./src/ebb_db" } +ebb_tide_manager = { path = "./src/ebb_tide_manager" } log = "0.4.25" once_cell = "1.19" os-monitor = { version = "0.4.9" } diff --git a/src-tauri/src/ebb_db/Cargo.lock b/src-tauri/src/ebb_db/Cargo.lock index 52348622..5efe6e03 100644 --- a/src-tauri/src/ebb_db/Cargo.lock +++ b/src-tauri/src/ebb_db/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -148,12 +163,57 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.1", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" + [[package]] name = "byteorder" version = "1.5.0" @@ -278,6 +338,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -332,12 +398,46 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -544,6 +644,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.0" @@ -551,7 +657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.1", - "objc2", + "objc2 0.6.1", ] [[package]] @@ -565,6 +671,29 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "dlopen2" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -612,11 +741,15 @@ name = "ebb-db" version = "0.1.0" dependencies = [ "dirs", + "futures-core", + "indexmap 2.9.0", "log", "serde", "serde_json", "sqlx", + "tauri", "tauri-plugin-sql", + "thiserror 1.0.69", "time", "tokio", "uuid", @@ -741,6 +874,33 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -911,6 +1071,47 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1483,6 +1684,29 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "jni" version = "0.21.1" @@ -1570,12 +1794,46 @@ dependencies = [ "spin", ] +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libm" version = "0.2.15" @@ -1713,10 +1971,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.1", "once_cell", "png", "serde", @@ -1724,6 +1982,36 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.1", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1772,70 +2060,231 @@ dependencies = [ name = "num-iter" version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.1", + "libc", + "objc2 0.6.1", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.1", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.1", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" dependencies = [ - "autocfg", - "num-integer", - "num-traits", + "cc", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2-foundation" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "autocfg", - "libm", + "bitflags 2.9.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", ] [[package]] -name = "objc2" -version = "0.6.1" +name = "objc2-foundation" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "objc2-encode", + "bitflags 2.9.1", + "block2 0.6.1", + "libc", + "objc2 0.6.1", + "objc2-core-foundation", ] [[package]] -name = "objc2-app-kit" +name = "objc2-io-surface" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ "bitflags 2.9.1", - "objc2", + "objc2 0.6.1", "objc2-core-foundation", - "objc2-foundation", ] [[package]] -name = "objc2-core-foundation" -version = "0.3.1" +name = "objc2-metal" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.9.1", - "dispatch2", - "objc2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "objc2-quartz-core" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] [[package]] -name = "objc2-foundation" +name = "objc2-quartz-core" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ "bitflags 2.9.1", - "objc2", - "objc2-core-foundation", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -1845,8 +2294,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ "bitflags 2.9.1", - "objc2", - "objc2-foundation", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.1", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", ] [[package]] @@ -2783,6 +3247,54 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "spin" version = "0.9.8" @@ -3114,6 +3626,56 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tao" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3142,9 +3704,9 @@ dependencies = [ "log", "mime", "muda", - "objc2", + "objc2 0.6.1", "objc2-app-kit", - "objc2-foundation", + "objc2-foundation 0.3.1", "objc2-ui-kit", "percent-encoding", "plist", @@ -3158,11 +3720,15 @@ dependencies = [ "tauri-build", "tauri-macros", "tauri-runtime", + "tauri-runtime-wry", "tauri-utils", "thiserror 2.0.12", "tokio", + "tray-icon", "url", "urlpattern", + "webkit2gtk", + "webview2-com", "window-vibrancy", "windows", ] @@ -3196,6 +3762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93f035551bf7b11b3f51ad9bc231ebbe5e085565527991c16cf326aa38cdf47" dependencies = [ "base64 0.22.1", + "brotli", "ico", "json-patch", "plist", @@ -3276,7 +3843,7 @@ dependencies = [ "gtk", "http", "jni", - "objc2", + "objc2 0.6.1", "objc2-ui-kit", "raw-window-handle", "serde", @@ -3287,6 +3854,33 @@ dependencies = [ "windows", ] +[[package]] +name = "tauri-runtime-wry" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85d056f4d4b014fe874814034f3416d57114b617a493a4fe552580851a3f3a2" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + [[package]] name = "tauri-utils" version = "2.4.0" @@ -3294,6 +3888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2900399c239a471bcff7f15c4399eb1a8c4fe511ba2853e07c996d771a5e0a4" dependencies = [ "anyhow", + "brotli", "cargo_metadata", "ctor", "dunce", @@ -3641,6 +4236,28 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tray-icon" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7eee98ec5c90daf179d55c20a49d8c0d043054ce7c26336c09a24d31f14fa0" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.12", + "windows-sys 0.59.0", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -3959,6 +4576,86 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b542b5cfbd9618c46c2784e4d41ba218c336ac70d44c55e47b251033e7d85601" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "webview2-com-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae2d11c4a686e4409659d7891791254cf9286d3cfe0eef54df1523533d22295" +dependencies = [ + "thiserror 2.0.12", + "windows", + "windows-core", +] + [[package]] name = "whoami" version = "1.6.0" @@ -4006,10 +4703,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2", + "objc2 0.6.1", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.1", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -4392,6 +5089,71 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wry" +version = "0.51.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c886a0a9d2a94fd90cfa1d929629b79cfefb1546e2c7430c63a47f0664c0e4e2" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.12", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/src-tauri/src/ebb_db/src/db.rs b/src-tauri/src/ebb_db/src/db.rs index f388fc85..ea12aa90 100644 --- a/src-tauri/src/ebb_db/src/db.rs +++ b/src-tauri/src/ebb_db/src/db.rs @@ -1,3 +1,7 @@ +pub mod activity_state_repo; pub mod device_profile_repo; pub mod device_repo; pub mod models; +pub mod tag_repo; +pub mod tide_repo; +pub mod tide_template_repo; diff --git a/src-tauri/src/ebb_db/src/db/activity_state_repo.rs b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs new file mode 100644 index 00000000..bbfa0730 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -0,0 +1,1283 @@ +use sqlx::{Pool, Sqlite}; +use time::OffsetDateTime; + +use crate::db::models::activity_state::ActivityState; + +pub type Result = std::result::Result>; + +pub struct ActivityStateRepo { + pool: Pool, +} + +impl ActivityStateRepo { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + /// Get activity states within a date range that are ACTIVE + pub async fn get_active_states_in_range( + &self, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + ) -> Result> { + let states = sqlx::query_as::<_, ActivityState>( + "SELECT * FROM activity_state + WHERE state = 'ACTIVE' + AND start_time < ?1 + AND end_time > ?2 + ORDER BY start_time ASC", + ) + .bind(end_time) + .bind(start_time) + .fetch_all(&self.pool) + .await?; + + Ok(states) + } + + /// Get activity states within a date range with specific tag + pub async fn get_active_states_with_tag_in_range( + &self, + tag_name: &str, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + ) -> Result> { + let states = sqlx::query_as::<_, ActivityState>( + "SELECT DISTINCT activity_state.* + FROM activity_state + JOIN activity_state_tag ON activity_state.id = activity_state_tag.activity_state_id + JOIN tag ON activity_state_tag.tag_id = tag.id + WHERE activity_state.state = 'ACTIVE' + AND tag.name = ?1 + AND activity_state.start_time < ?2 + AND activity_state.end_time > ?3 + ORDER BY activity_state.start_time ASC", + ) + .bind(tag_name) + .bind(end_time) + .bind(start_time) + .fetch_all(&self.pool) + .await?; + + Ok(states) + } + + /// Calculate total duration in minutes for activity states with specific tag in date range + /// Uses application-side aggregation for better performance + pub async fn calculate_tagged_duration_in_range( + &self, + tag_name: &str, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + ) -> Result { + let function_start = std::time::Instant::now(); + log::debug!( + "Calculating tagged duration for tag '{}' from {} to {}", + tag_name, + start_time, + end_time + ); + + // First, get the tag_type for the requested tag_name + let query_start = std::time::Instant::now(); + let tag_type: Option = + sqlx::query_scalar("SELECT tag_type FROM tag WHERE name = ?1") + .bind(tag_name) + .fetch_optional(&self.pool) + .await?; + let tag_type_query_duration = query_start.elapsed(); + log::debug!("Tag type query took: {:?}", tag_type_query_duration); + + let tag_type = match tag_type { + Some(t) => t, + None => return Ok(0.0), // Tag doesn't exist, return 0 + }; + + // Fetch all relevant activity state data in a single query + #[derive(Debug)] + struct ActivityStateData { + id: i64, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + tag_name: String, + tag_type: String, + } + + let main_query_start = std::time::Instant::now(); + let raw_data: Vec<(i64, OffsetDateTime, OffsetDateTime, String, String)> = sqlx::query_as( + "SELECT + activity_state.id, + activity_state.start_time, + activity_state.end_time, + tag.name as tag_name, + tag.tag_type + FROM activity_state + JOIN activity_state_tag ON activity_state.id = activity_state_tag.activity_state_id + JOIN tag ON activity_state_tag.tag_id = tag.id + WHERE activity_state.start_time < ?1 + AND activity_state.end_time > ?2 + ORDER BY activity_state.id", + ) + .bind(end_time) + .bind(start_time) + .fetch_all(&self.pool) + .await?; + let main_query_duration = main_query_start.elapsed(); + log::debug!( + "Main data query took: {:?} and returned {} records", + main_query_duration, + raw_data.len() + ); + + // Convert to structured data + let processing_start = std::time::Instant::now(); + let activity_data: Vec = raw_data + .into_iter() + .map(|(id, start, end, tag_name, tag_type)| ActivityStateData { + id, + start_time: start, + end_time: end, + tag_name, + tag_type, + }) + .collect(); + let data_conversion_duration = processing_start.elapsed(); + log::debug!("Data conversion took: {:?}", data_conversion_duration); + + // Group by activity_state_id and calculate tag counts per tag_type + use std::collections::HashMap; + + let mut activity_states: HashMap< + i64, + (OffsetDateTime, OffsetDateTime, HashMap), + > = HashMap::new(); + let mut target_tag_records: Vec = Vec::new(); // Each record for the target tag + + for data in activity_data { + let entry = activity_states + .entry(data.id) + .or_insert_with(|| (data.start_time, data.end_time, HashMap::new())); + + // Count tags by tag_type (each record counts as one tag) + *entry.2.entry(data.tag_type.clone()).or_insert(0) += 1; + + // Track each individual record that has our target tag + if data.tag_name == tag_name { + target_tag_records.push(data.id); + } + } + + // Calculate the total duration + let mut total_minutes = 0.0; + let target_record_count = target_tag_records.len(); + + // For each record of the target tag, calculate its contribution + for activity_state_id in &target_tag_records { + if let Some((activity_start, activity_end, tag_counts)) = + activity_states.get(&activity_state_id) + { + // Calculate the full duration of the activity state (not just overlap) + // This ensures activities discovered late due to recording timing still get full credit + let duration_minutes = (*activity_end - *activity_start).as_seconds_f64() / 60.0; + + // Get the count of tags for the target tag_type + let tag_count = tag_counts.get(&tag_type).unwrap_or(&0); + + if *tag_count > 0 { + // Each individual record gets: duration / total_tags_of_same_type + let split_minutes = duration_minutes / (*tag_count as f64); + total_minutes += split_minutes; + } + } + } + + let total_function_duration = function_start.elapsed(); + + log::debug!("=== APPLICATION-SIDE CALCULATION ==="); + log::debug!("Target tag: '{}' (type: '{}')", tag_name, tag_type); + log::debug!("Time range: {} to {}", start_time, end_time); + log::debug!("Processed {} activity states", activity_states.len()); + log::debug!("Found {} records with target tag", target_record_count); + log::debug!("Total minutes: {:.2}", total_minutes); + log::debug!("TOTAL FUNCTION TIME: {:?}", total_function_duration); + log::debug!("====================================="); + + Ok(total_minutes) + } + + /// Debug helper: Get raw activity states for a tag to manually verify calculation + pub async fn debug_get_tagged_activity_states( + &self, + tag_name: &str, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + ) -> Result<()> { + let states: Vec<(i64, OffsetDateTime, Option)> = sqlx::query_as( + "SELECT activity_state.id, activity_state.start_time, activity_state.end_time + FROM activity_state + JOIN activity_state_tag ON activity_state.id = activity_state_tag.activity_state_id + JOIN tag ON activity_state_tag.tag_id = tag.id + WHERE tag.name = ?1 + AND activity_state.start_time < ?2 + AND activity_state.end_time > ?3 + ORDER BY activity_state.start_time", + ) + .bind(tag_name) + .bind(end_time) + .bind(start_time) + .fetch_all(&self.pool) + .await?; + + log::debug!("=== DEBUG ACTIVITY STATES FOR TAG '{}' ===", tag_name); + log::debug!("Query range: {} to {}", start_time, end_time); + log::debug!("Found {} matching activity states:", states.len()); + + let mut total_manual = 0.0; + for (id, start, end_opt) in states { + if let Some(end) = end_opt { + // Calculate overlap manually + let actual_start = if start < start_time { + start_time + } else { + start + }; + let actual_end = if end > end_time { end_time } else { end }; + let duration = (actual_end - actual_start).whole_minutes() as f64; + + log::debug!( + " ID: {}, Original: {} to {}, Clipped: {} to {}, Duration: {:.2} min", + id, + start, + end, + actual_start, + actual_end, + duration + ); + total_manual += duration; + } + } + log::debug!("Manual calculation total: {:.2} minutes", total_manual); + log::debug!("==============================================="); + + Ok(()) + } + + /// Get a single activity state by ID + pub async fn get_activity_state(&self, id: i64) -> Result> { + let state = + sqlx::query_as::<_, ActivityState>("SELECT * FROM activity_state WHERE id = ?1") + .bind(id) + .fetch_optional(&self.pool) + .await?; + + Ok(state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db_manager; + use sqlx::{Pool, Sqlite}; + use time::macros::datetime; + /// Clean all activity state related data for testing + pub async fn cleanup_activity_state_data(pool: &Pool) -> Result<()> { + // Delete in reverse dependency order to avoid foreign key constraints + sqlx::query("DELETE FROM activity_state_tag") + .execute(pool) + .await?; + sqlx::query("DELETE FROM activity_state") + .execute(pool) + .await?; + sqlx::query("DELETE FROM tag").execute(pool).await?; + + // Reset auto-increment counters + sqlx::query("DELETE FROM sqlite_sequence WHERE name IN ('activity_state', 'tag')") + .execute(pool) + .await?; + + Ok(()) + } + + /// Seed tags for testing + pub async fn seed_test_tags(pool: &Pool) -> Result<()> { + // Insert tags that will be used in test data + let tags = vec![ + // Default type tags (for productivity tracking) + ("creating-tag-id", "creating", "default"), + ("consuming-tag-id", "consuming", "default"), + ("neutral-tag-id", "neutral", "default"), + ("idle-tag-id", "idle", "default"), + // Category type tags (for activity categorization) + ("coding-tag-id", "coding", "category"), + ("browsing-tag-id", "browsing", "category"), + ("writing-tag-id", "writing", "category"), + ("meeting-tag-id", "meeting", "category"), + ]; + + for (id, name, tag_type) in tags { + sqlx::query("INSERT INTO tag (id, name, tag_type) VALUES (?1, ?2, ?3)") + .bind(id) + .bind(name) + .bind(tag_type) + .execute(pool) + .await?; + } + + Ok(()) + } + + /// Create just the tables we need for testing + async fn create_test_tables(pool: &Pool) -> Result<()> { + // Create the minimal tables needed for our tests + sqlx::query( + "CREATE TABLE IF NOT EXISTS tag ( + id TEXT PRIMARY KEY NOT NULL, + tag_type TEXT NOT NULL, + name TEXT NOT NULL + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS activity_state ( + id INTEGER PRIMARY KEY, + state TEXT NOT NULL, + app_switches INTEGER, + activity_type INTEGER NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME, + created_at DATETIME NOT NULL + )", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS activity_state_tag ( + activity_state_id INTEGER NOT NULL, + tag_id TEXT NOT NULL, + app_tag_id TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (activity_state_id, tag_id, app_tag_id), + FOREIGN KEY (activity_state_id) REFERENCES activity_state (id), + FOREIGN KEY (tag_id) REFERENCES tag (id) + )", + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Setup test database with clean data and seeding + async fn setup_test_repo() -> Result { + let pool = db_manager::create_test_db().await; + + // Create required tables + create_test_tables(&pool).await?; + + // Clean and seed test data + cleanup_activity_state_data(&pool).await?; + seed_test_tags(&pool).await?; + + Ok(ActivityStateRepo::new(pool)) + } + + // ===== CALCULATE_TAGGED_DURATION_IN_RANGE TESTS ===== + // Test suite for time calculation with proper tag time splitting + + #[tokio::test] + async fn test_calculate_tagged_duration_single_activity_state_no_tags() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has no tags + // Should return 0 minutes for any tag query since activity has no tags + + // Create a custom activity state with no tags for this test + let pool = &repo.pool; + let test_start = datetime!(2025-01-01 10:00:00 UTC); + let test_end = datetime!(2025-01-01 11:00:00 UTC); // 1 hour duration + + // Insert activity state without any tags + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999001, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Query for any tag name and expect 0 minutes since this activity has no tags + let query_start = datetime!(2025-01-01 09:00:00 UTC); + let query_end = datetime!(2025-01-01 12:00:00 UTC); + + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let idle_minutes = repo + .calculate_tagged_duration_in_range("idle", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // All should return 0 since the activity state has no tags + assert_eq!( + creating_minutes, 0.0, + "Activity with no tags should contribute 0 minutes to 'creating'" + ); + assert_eq!( + idle_minutes, 0.0, + "Activity with no tags should contribute 0 minutes to 'idle'" + ); + assert_eq!( + neutral_minutes, 0.0, + "Activity with no tags should contribute 0 minutes to 'neutral'" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_single_activity_state_one_tag() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has exactly 1 tag + // Should return full 60 minutes for that tag, 0 for others + + // Create a custom activity state with exactly 1 tag for this test + let pool = &repo.pool; + let test_start = datetime!(2025-01-02 10:00:00 UTC); + let test_end = datetime!(2025-01-02 11:00:00 UTC); // 1 hour duration + + // Insert activity state with exactly one tag + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999002, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to exactly one tag: "creating" + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999002, 'creating-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-02 09:00:00 UTC); + let query_end = datetime!(2025-01-02 12:00:00 UTC); + + // Query for "creating" - should get full 60 minutes + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + + // Query for other tags - should get 0 minutes + let idle_minutes = repo + .calculate_tagged_duration_in_range("idle", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // Assertions + assert_eq!( + creating_minutes, 60.0, + "Activity with 1 'creating' tag should contribute full 60 minutes to 'creating'" + ); + assert_eq!( + idle_minutes, 0.0, + "Activity with only 'creating' tag should contribute 0 minutes to 'idle'" + ); + assert_eq!( + neutral_minutes, 0.0, + "Activity with only 'creating' tag should contribute 0 minutes to 'neutral'" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_single_activity_state_multiple_tags() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has multiple tags + // Should split the 60 minutes evenly between all tags + + // Create a custom activity state with exactly 3 tags for this test + let pool = &repo.pool; + let test_start = datetime!(2025-01-03 10:00:00 UTC); + let test_end = datetime!(2025-01-03 11:00:00 UTC); // 1 hour duration + + // Insert activity state with multiple tags + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999003, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to exactly 3 tags: "creating", "consuming", "neutral" + let tags = [ + ("creating-tag-id", "creating"), + ("consuming-tag-id", "consuming"), + ("neutral-tag-id", "neutral"), + ]; + + for (tag_id, _tag_name) in tags { + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999003, ?1, NULL, datetime('now'), datetime('now'))" + ) + .bind(tag_id) + .execute(pool) + .await?; + } + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-03 09:00:00 UTC); + let query_end = datetime!(2025-01-03 12:00:00 UTC); + + // Query for each of the 3 tags - should get 20 minutes each (60/3) + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let consuming_minutes = repo + .calculate_tagged_duration_in_range("consuming", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // Query for tag not on this activity - should get 0 minutes + let idle_minutes = repo + .calculate_tagged_duration_in_range("idle", query_start, query_end) + .await?; + + // Assertions - each of the 3 tags should get 20 minutes (60/3) + assert_eq!( + creating_minutes, 20.0, + "Activity with 3 tags should contribute 20 minutes (60/3) to 'creating'" + ); + assert_eq!( + consuming_minutes, 20.0, + "Activity with 3 tags should contribute 20 minutes (60/3) to 'consuming'" + ); + assert_eq!( + neutral_minutes, 20.0, + "Activity with 3 tags should contribute 20 minutes (60/3) to 'neutral'" + ); + assert_eq!( + idle_minutes, 0.0, + "Activity without 'idle' tag should contribute 0 minutes to 'idle'" + ); + + // Verify total adds up correctly + let total = creating_minutes + consuming_minutes + neutral_minutes; + assert_eq!( + total, 60.0, + "Sum of all tag times should equal original duration" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_multiple_activity_states_mixed_tagging() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with multiple activity states having different tag configurations + // Should sum up times correctly across all activity states + + let pool = &repo.pool; + let base_time = datetime!(2025-01-04 10:00:00 UTC); + + // Create 4 activity states, each 15 minutes long (total 1 hour) + let activity_states = [ + (999011, 0), // 10:00-10:15 - no tags + (999012, 15), // 10:15-10:30 - 1 tag "creating" + (999013, 30), // 10:30-10:45 - 2 tags "creating" + "neutral" + (999014, 45), // 10:45-11:00 - 3 tags "creating" + "consuming" + "neutral" + ]; + + // Insert activity states (each 15 minutes) + for (id, offset_minutes) in activity_states { + let start = base_time + time::Duration::minutes(offset_minutes); + let end = start + time::Duration::minutes(15); + + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (?1, 'ACTIVE', 1, ?2, ?3, ?4)" + ) + .bind(id) + .bind(start) + .bind(end) + .bind(start) + .execute(pool) + .await?; + } + + // Activity state 1 (999011): no tags - contributes 0 to any tag + + // Activity state 2 (999012): 1 tag "creating" - contributes full 15 minutes to "creating" + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999012, 'creating-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Activity state 3 (999013): 2 tags "creating" + "neutral" - contributes 7.5 minutes to each + let tags_state_3 = ["creating-tag-id", "neutral-tag-id"]; + for tag_id in tags_state_3 { + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999013, ?1, NULL, datetime('now'), datetime('now'))" + ) + .bind(tag_id) + .execute(pool) + .await?; + } + + // Activity state 4 (999014): 3 tags "creating" + "consuming" + "neutral" - contributes 5 minutes to each + let tags_state_4 = ["creating-tag-id", "consuming-tag-id", "neutral-tag-id"]; + for tag_id in tags_state_4 { + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999014, ?1, NULL, datetime('now'), datetime('now'))" + ) + .bind(tag_id) + .execute(pool) + .await?; + } + + // Query range that encompasses all test activities + let query_start = datetime!(2025-01-04 09:00:00 UTC); + let query_end = datetime!(2025-01-04 12:00:00 UTC); + + // Query for each tag and verify expected summation + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + let consuming_minutes = repo + .calculate_tagged_duration_in_range("consuming", query_start, query_end) + .await?; + let idle_minutes = repo + .calculate_tagged_duration_in_range("idle", query_start, query_end) + .await?; + + // Expected calculations (accounting for SQLite ROUND behavior with banker's rounding): + // "creating": + // - State 1: 0 minutes (no tags) + // - State 2: 15 minutes (1 tag, gets full 15) + // - State 3: 7 minutes (2 tags, gets ROUND(15/2) = ROUND(7.5) = 8, but banker's rounding gives 7) + // - State 4: 5 minutes (3 tags, gets ROUND(15/3) = ROUND(5.0) = 5) + // - Total: 0 + 15 + 7 + 5 = 27 minutes + assert_eq!( + creating_minutes, 27.5, + "Creating should get sum from states 2, 3, and 4: 15 + 7.5 + 5 = 27.5 (application-side precision)" + ); + + // "neutral": + // - State 1: 0 minutes (no tags) + // - State 2: 0 minutes (doesn't have neutral tag) + // - State 3: 7.5 minutes (2 tags, gets 15/2 = 7.5) + // - State 4: 5 minutes (3 tags, gets 15/3 = 5) + // - Total: 0 + 0 + 7.5 + 5 = 12.5 minutes + assert_eq!( + neutral_minutes, 12.5, + "Neutral should get sum from states 3 and 4: 7.5 + 5 = 12.5 (application-side precision)" + ); + + // "consuming": + // - State 1: 0 minutes (no tags) + // - State 2: 0 minutes (doesn't have consuming tag) + // - State 3: 0 minutes (doesn't have consuming tag) + // - State 4: 5 minutes (3 tags, gets 15/3) + // - Total: 0 + 0 + 0 + 5 = 5 minutes + assert_eq!( + consuming_minutes, 5.0, + "Consuming should get sum from state 4 only: 5" + ); + + // "idle": + // - No activity states have idle tag + // - Total: 0 minutes + assert_eq!( + idle_minutes, 0.0, + "Idle should get 0 minutes as no activity states have idle tag" + ); + + // Verify that the original 60 minutes is properly distributed + // Note: Time can be counted multiple times across different tags for the same activity + // State 2: 15 minutes goes only to "creating" + // State 3: 15 minutes split between "creating" (7.5) and "neutral" (7.5) = 15 total + // State 4: 15 minutes split between "creating" (5), "consuming" (5), "neutral" (5) = 15 total + // This confirms time is properly split, not duplicated + + println!("=== MIXED TAGGING TEST RESULTS ==="); + println!("Creating: {} minutes (expected 27.5)", creating_minutes); + println!("Neutral: {} minutes (expected 12.5)", neutral_minutes); + println!("Consuming: {} minutes (expected 5.0)", consuming_minutes); + println!("Idle: {} minutes (expected 0.0)", idle_minutes); + println!("==================================="); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_empty_result() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test scenarios that should return 0 minutes + + // Scenario 1: Query for tag that doesn't exist on any activity states + let nonexistent_tag_result = repo + .calculate_tagged_duration_in_range( + "nonexistent_tag", + datetime!(2025-01-04 9:00:00.0 +00:00:00), + datetime!(2025-01-04 12:00:00.0 +00:00:00), + ) + .await?; + assert_eq!( + nonexistent_tag_result, 0.0, + "Nonexistent tag should return 0 minutes" + ); + + // Scenario 2: Query for time range completely outside all activity states (before) + let before_range_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-04 6:00:00.0 +00:00:00), // 6:00-7:00 (before our 9:00-12:00 data) + datetime!(2025-01-04 7:00:00.0 +00:00:00), + ) + .await?; + assert_eq!( + before_range_result, 0.0, + "Time range before all activity states should return 0 minutes" + ); + + // Scenario 3: Query for time range completely outside all activity states (after) + let after_range_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-04 15:00:00.0 +00:00:00), // 15:00-16:00 (after our 9:00-12:00 data) + datetime!(2025-01-04 16:00:00.0 +00:00:00), + ) + .await?; + assert_eq!( + after_range_result, 0.0, + "Time range after all activity states should return 0 minutes" + ); + + // Scenario 4: Query for time range with no activity states (gap between states) + // Our current test data has 4 contiguous 15-minute states from 9:00-12:00 + // Let's query a gap that could exist between activities + let gap_range_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-04 12:30:00.0 +00:00:00), // 12:30-13:30 (gap after our data) + datetime!(2025-01-04 13:30:00.0 +00:00:00), + ) + .await?; + assert_eq!( + gap_range_result, 0.0, + "Time range in gap between activity states should return 0 minutes" + ); + + Ok(()) + } + + // ===== TAG_TYPE ISOLATION TESTS ===== + // Test suite for verifying that time splitting only considers tags within the same tag_type + + #[tokio::test] + async fn test_calculate_tagged_duration_single_category_tag() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has exactly 1 category tag + // Should return full 60 minutes for that category tag, 0 for default tags + + let pool = &repo.pool; + let test_start = datetime!(2025-01-03 10:00:00 UTC); + let test_end = datetime!(2025-01-03 11:00:00 UTC); // 1 hour duration + + // Insert activity state with exactly one category tag + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888001, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to exactly one category tag: "coding" + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (888001, 'coding-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-03 09:00:00 UTC); + let query_end = datetime!(2025-01-03 12:00:00 UTC); + + // Query for "coding" (category type) - should get full 60 minutes + let coding_minutes = repo + .calculate_tagged_duration_in_range("coding", query_start, query_end) + .await?; + + // Query for default type tags - should get 0 minutes since this activity only has category tags + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // Query for other category tags - should get 0 minutes + let browsing_minutes = repo + .calculate_tagged_duration_in_range("browsing", query_start, query_end) + .await?; + + // Assertions + assert_eq!( + coding_minutes, 60.0, + "Activity with 1 'coding' category tag should contribute full 60 minutes to 'coding'" + ); + assert_eq!( + creating_minutes, 0.0, + "Activity with only category tags should contribute 0 minutes to default 'creating' tag" + ); + assert_eq!( + neutral_minutes, 0.0, + "Activity with only category tags should contribute 0 minutes to default 'neutral' tag" + ); + assert_eq!( + browsing_minutes, 0.0, + "Activity with only 'coding' tag should contribute 0 minutes to 'browsing'" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_multiple_category_tags() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has multiple category tags + // Time should be split proportionally among the category tags + + let pool = &repo.pool; + let test_start = datetime!(2025-01-03 14:00:00 UTC); + let test_end = datetime!(2025-01-03 15:00:00 UTC); // 1 hour duration + + // Insert activity state with multiple category tags + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888002, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to multiple category tags: "coding" and "browsing" + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (888002, 'coding-tag-id', NULL, datetime('now'), datetime('now')), + (888002, 'browsing-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-03 13:00:00 UTC); + let query_end = datetime!(2025-01-03 16:00:00 UTC); + + // Query for each category tag - should get 60/2 = 30 minutes each + let coding_minutes = repo + .calculate_tagged_duration_in_range("coding", query_start, query_end) + .await?; + let browsing_minutes = repo + .calculate_tagged_duration_in_range("browsing", query_start, query_end) + .await?; + + // Query for default type tags - should get 0 minutes since this activity only has category tags + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + + // Query for other category tags - should get 0 minutes + let writing_minutes = repo + .calculate_tagged_duration_in_range("writing", query_start, query_end) + .await?; + + // Assertions + assert_eq!( + coding_minutes, 30.0, + "Activity with 2 category tags should contribute 30 minutes to 'coding'" + ); + assert_eq!( + browsing_minutes, 30.0, + "Activity with 2 category tags should contribute 30 minutes to 'browsing'" + ); + assert_eq!( + creating_minutes, 0.0, + "Activity with only category tags should contribute 0 minutes to default 'creating' tag" + ); + assert_eq!( + writing_minutes, 0.0, + "Activity without 'writing' tag should contribute 0 minutes to 'writing'" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_mixed_default_and_category_tags() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has both default and category tags + // Time should be split separately within each tag_type + + let pool = &repo.pool; + let test_start = datetime!(2025-01-03 18:00:00 UTC); + let test_end = datetime!(2025-01-03 19:00:00 UTC); // 1 hour duration + + // Insert activity state with mixed tag types + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888003, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to 2 default tags and 1 category tag + // Default: "creating" and "neutral" + // Category: "coding" + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (888003, 'creating-tag-id', NULL, datetime('now'), datetime('now')), + (888003, 'neutral-tag-id', NULL, datetime('now'), datetime('now')), + (888003, 'coding-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-03 17:00:00 UTC); + let query_end = datetime!(2025-01-03 20:00:00 UTC); + + // Query for default tags - should split among 2 default tags: 60/2 = 30 minutes each + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // Query for category tag - should get full 60 minutes since it's the only category tag + let coding_minutes = repo + .calculate_tagged_duration_in_range("coding", query_start, query_end) + .await?; + + // Query for other tags - should get 0 minutes + let consuming_minutes = repo + .calculate_tagged_duration_in_range("consuming", query_start, query_end) + .await?; + let browsing_minutes = repo + .calculate_tagged_duration_in_range("browsing", query_start, query_end) + .await?; + + // Assertions + assert_eq!( + creating_minutes, 30.0, + "Activity with mixed tags should contribute 30 minutes to 'creating' (split among 2 default tags)" + ); + assert_eq!( + neutral_minutes, 30.0, + "Activity with mixed tags should contribute 30 minutes to 'neutral' (split among 2 default tags)" + ); + assert_eq!( + coding_minutes, 60.0, + "Activity with mixed tags should contribute full 60 minutes to 'coding' (only category tag)" + ); + assert_eq!( + consuming_minutes, 0.0, + "Activity without 'consuming' tag should contribute 0 minutes" + ); + assert_eq!( + browsing_minutes, 0.0, + "Activity without 'browsing' tag should contribute 0 minutes" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_multiple_default_and_multiple_category_tags() + -> Result<()> { + let repo = setup_test_repo().await?; + + // Test 1-hour period with one activity state that has multiple tags of both types + // Time should be split separately within each tag_type + + let pool = &repo.pool; + let test_start = datetime!(2025-01-03 22:00:00 UTC); + let test_end = datetime!(2025-01-03 23:00:00 UTC); // 1 hour duration + + // Insert activity state with multiple tags of both types + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888004, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to 3 default tags and 2 category tags + // Default: "creating", "consuming", "neutral" + // Category: "coding", "writing" + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (888004, 'creating-tag-id', NULL, datetime('now'), datetime('now')), + (888004, 'consuming-tag-id', NULL, datetime('now'), datetime('now')), + (888004, 'neutral-tag-id', NULL, datetime('now'), datetime('now')), + (888004, 'coding-tag-id', NULL, datetime('now'), datetime('now')), + (888004, 'writing-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-03 21:00:00 UTC); + let query_end = datetime!(2025-01-04 00:00:00 UTC); + + // Query for default tags - should split among 3 default tags: 60/3 = 20 minutes each + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let consuming_minutes = repo + .calculate_tagged_duration_in_range("consuming", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // Query for category tags - should split among 2 category tags: 60/2 = 30 minutes each + let coding_minutes = repo + .calculate_tagged_duration_in_range("coding", query_start, query_end) + .await?; + let writing_minutes = repo + .calculate_tagged_duration_in_range("writing", query_start, query_end) + .await?; + + // Query for other tags - should get 0 minutes + let idle_minutes = repo + .calculate_tagged_duration_in_range("idle", query_start, query_end) + .await?; + let browsing_minutes = repo + .calculate_tagged_duration_in_range("browsing", query_start, query_end) + .await?; + + // Assertions for default tags (split among 3) + assert_eq!( + creating_minutes, 20.0, + "Activity should contribute 20 minutes to 'creating' (split among 3 default tags)" + ); + assert_eq!( + consuming_minutes, 20.0, + "Activity should contribute 20 minutes to 'consuming' (split among 3 default tags)" + ); + assert_eq!( + neutral_minutes, 20.0, + "Activity should contribute 20 minutes to 'neutral' (split among 3 default tags)" + ); + + // Assertions for category tags (split among 2) + assert_eq!( + coding_minutes, 30.0, + "Activity should contribute 30 minutes to 'coding' (split among 2 category tags)" + ); + assert_eq!( + writing_minutes, 30.0, + "Activity should contribute 30 minutes to 'writing' (split among 2 category tags)" + ); + + // Assertions for tags not present + assert_eq!( + idle_minutes, 0.0, + "Activity without 'idle' tag should contribute 0 minutes" + ); + assert_eq!( + browsing_minutes, 0.0, + "Activity without 'browsing' tag should contribute 0 minutes" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_multiple_app_tags_same_name() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test the app_tag_id scenario: 1 activity state with 5 activity_state_tag records + // 3 records with "creating" tag from different apps + // 2 records with "neutral" tag from different apps + // Should return: creating gets 3/5ths (36 minutes), neutral gets 2/5ths (24 minutes) + + let pool = &repo.pool; + let test_start = datetime!(2025-01-04 8:00:00 UTC); + let test_end = datetime!(2025-01-04 9:00:00 UTC); // 1 hour duration + + // Insert activity state + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (777001, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Link to 5 activity_state_tag records: + // 3 with "creating" tag from different apps (Ebb, Warp, Cursor) + // 2 with "neutral" tag from different apps (Slack, BeekeeperStudio) + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (777001, 'creating-tag-id', 'ebb-app', datetime('now'), datetime('now')), + (777001, 'creating-tag-id', 'warp-app', datetime('now'), datetime('now')), + (777001, 'creating-tag-id', 'cursor-app', datetime('now'), datetime('now')), + (777001, 'neutral-tag-id', 'slack-app', datetime('now'), datetime('now')), + (777001, 'neutral-tag-id', 'beekeeper-app', datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Query range that encompasses our test activity + let query_start = datetime!(2025-01-04 7:00:00 UTC); + let query_end = datetime!(2025-01-04 10:00:00 UTC); + + // Query for tags - should split proportionally among the number of records of the same tag_type + let creating_minutes = repo + .calculate_tagged_duration_in_range("creating", query_start, query_end) + .await?; + let neutral_minutes = repo + .calculate_tagged_duration_in_range("neutral", query_start, query_end) + .await?; + + // Query for tags not present - should get 0 minutes + let consuming_minutes = repo + .calculate_tagged_duration_in_range("consuming", query_start, query_end) + .await?; + let idle_minutes = repo + .calculate_tagged_duration_in_range("idle", query_start, query_end) + .await?; + + // Assertions + // Total default tag records: 3 creating + 2 neutral = 5 records + // Each record gets: 60 minutes ÷ 5 records = 12 minutes per record + // Creating total: 3 records × 12 minutes = 36 minutes + // Neutral total: 2 records × 12 minutes = 24 minutes + assert_eq!( + creating_minutes, 36.0, + "Creating should get 3/5ths of total time: 3 × 12 = 36 minutes" + ); + assert_eq!( + neutral_minutes, 24.0, + "Neutral should get 2/5ths of total time: 2 × 12 = 24 minutes" + ); + assert_eq!( + consuming_minutes, 0.0, + "Activity without 'consuming' tag should contribute 0 minutes" + ); + assert_eq!( + idle_minutes, 0.0, + "Activity without 'idle' tag should contribute 0 minutes" + ); + + // Verify total adds up correctly + let total = creating_minutes + neutral_minutes; + assert_eq!( + total, 60.0, + "Sum of all tag times should equal original duration" + ); + + Ok(()) + } + + // ===== OTHER REPOSITORY TESTS ===== + + #[tokio::test] + async fn test_activity_state_repo_creation() -> Result<()> { + let _repo = setup_test_repo().await?; + Ok(()) + } + + #[tokio::test] + async fn test_get_activity_state() -> Result<()> { + let repo = setup_test_repo().await?; + + // Insert activity state with multiple tags + let test_start = datetime!(2025-01-04 10:00:00 UTC); + let test_end = datetime!(2025-01-04 11:00:00 UTC); + let pool = &repo.pool; + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999003, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) + .await?; + + // Test retrieval of existing activity state from seeded data + let result = repo.get_activity_state(999003).await?; + assert!(result.is_some()); + + let activity_state = result.unwrap(); + assert_eq!(activity_state.id, 999003); + assert_eq!(activity_state.state, "ACTIVE"); + + Ok(()) + } + + #[tokio::test] + async fn test_get_activity_state_not_found() -> Result<()> { + let repo = setup_test_repo().await?; + + let result = repo.get_activity_state(999003).await?; + assert!(result.is_none()); + + Ok(()) + } +} diff --git a/src-tauri/src/ebb_db/src/db/models.rs b/src-tauri/src/ebb_db/src/db/models.rs index 1e707e24..9fefa090 100644 --- a/src-tauri/src/ebb_db/src/db/models.rs +++ b/src-tauri/src/ebb_db/src/db/models.rs @@ -1,2 +1,7 @@ +pub mod activity_state; +pub mod activity_state_tag; pub mod device; pub mod device_profile; +pub mod tag; +pub mod tide; +pub mod tide_template; diff --git a/src-tauri/src/ebb_db/src/db/models/activity_state.rs b/src-tauri/src/ebb_db/src/db/models/activity_state.rs new file mode 100644 index 00000000..77f84d0a --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/activity_state.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ActivityState { + pub id: i64, + pub state: String, // 'ACTIVE' or 'INACTIVE' + pub app_switches: i64, + pub start_time: OffsetDateTime, + pub end_time: OffsetDateTime, + pub created_at: OffsetDateTime, +} + +impl ActivityState { + pub fn new( + state: String, + app_switches: i64, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + ) -> Self { + Self { + id: 0, // Will be set by database + state, + app_switches, + start_time, + end_time, + created_at: OffsetDateTime::now_utc(), + } + } + + /// Calculate the duration of this activity state in minutes + pub fn duration_minutes(&self) -> f64 { + let duration = self.end_time - self.start_time; + duration.whole_minutes() as f64 + (duration.subsec_milliseconds() as f64 / 60000.0) + } + + /// Check if this activity state overlaps with a given time range + pub fn overlaps_with(&self, start: OffsetDateTime, end: OffsetDateTime) -> bool { + self.start_time < end && self.end_time > start + } + + /// Get the overlapping duration with a given time range in minutes + pub fn overlap_duration_minutes(&self, start: OffsetDateTime, end: OffsetDateTime) -> f64 { + if !self.overlaps_with(start, end) { + return 0.0; + } + + let overlap_start = self.start_time.max(start); + let overlap_end = self.end_time.min(end); + let duration = overlap_end - overlap_start; + + duration.whole_minutes() as f64 + (duration.subsec_milliseconds() as f64 / 60000.0) + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/models/activity_state_tag.rs b/src-tauri/src/ebb_db/src/db/models/activity_state_tag.rs new file mode 100644 index 00000000..c5a0a709 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/activity_state_tag.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityStateTag { + pub activity_state_id: String, + pub tag_id: String, + pub app_tag_id: Option, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, +} + +impl ActivityStateTag { + pub fn new( + activity_state_id: String, + tag_id: String, + app_tag_id: Option, + ) -> Self { + let now = OffsetDateTime::now_utc(); + Self { + activity_state_id, + tag_id, + app_tag_id, + created_at: now, + updated_at: now, + } + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/models/tag.rs b/src-tauri/src/ebb_db/src/db/models/tag.rs new file mode 100644 index 00000000..49a3e8aa --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/tag.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Tag { + pub id: String, + pub name: String, + pub parent_tag_id: Option, + pub tag_type: String, + pub is_blocked: bool, + pub is_default: bool, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, +} + +impl Tag { + pub fn new( + id: String, + name: String, + parent_tag_id: Option, + tag_type: String, + is_blocked: bool, + is_default: bool, + ) -> Self { + let now = OffsetDateTime::now_utc(); + Self { + id, + name, + parent_tag_id, + tag_type, + is_blocked, + is_default, + created_at: now, + updated_at: now, + } + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/models/tide.rs b/src-tauri/src/ebb_db/src/db/models/tide.rs new file mode 100644 index 00000000..4d96b679 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/tide.rs @@ -0,0 +1,268 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Tide { + pub id: String, + pub start: OffsetDateTime, + pub end: Option, // System-generated end time based on frequency/interval (nullable for indefinite tides) + pub completed_at: Option, // When the tide was actually completed by the user + pub metrics_type: String, // "creating", etc. + pub tide_frequency: String, // "daily", "weekly", "monthly", "indefinite" + pub goal_amount: f64, + pub actual_amount: f64, + pub tide_template_id: String, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, +} + +impl Tide { + pub fn new( + start: OffsetDateTime, + end: Option, + metrics_type: String, + tide_frequency: String, + goal_amount: f64, + tide_template_id: String, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + start, + end, + completed_at: None, + metrics_type, + tide_frequency, + goal_amount, + actual_amount: 0.0, + tide_template_id, + created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), + } + } + + pub fn from_template(template: &super::tide_template::TideTemplate, start: OffsetDateTime) -> Self { + use time::Duration; + + // Calculate end time based on tide frequency + let end = match template.tide_frequency.as_str() { + "daily" => Some(start + Duration::days(1)), + "weekly" => Some(start + Duration::days(7)), + "monthly" => Some(start + Duration::days(30)), // Approximate + "indefinite" => None, // No end time for indefinite tides + _ => Some(start + Duration::days(1)), // Default to daily + }; + + Self::new( + start, + end, + template.metrics_type.clone(), + template.tide_frequency.clone(), + template.goal_amount, + template.id.clone(), + ) + } + + /// Check if the tide is completed (has completed_at set) + pub fn is_completed(&self) -> bool { + self.completed_at.is_some() + } + + /// Check if the tide is active (not completed and within its time window) + pub fn is_active(&self) -> bool { + if self.is_completed() { + return false; + } + + let now = OffsetDateTime::now_utc(); + match self.end { + Some(end_time) => now <= end_time, + None => true, // Indefinite tides are always active until completed + } + } + + /// Mark the tide as completed + pub fn mark_completed(&mut self) { + self.completed_at = Some(OffsetDateTime::now_utc()); + self.updated_at = OffsetDateTime::now_utc(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::models::tide_template::TideTemplate as TideTemplateModel; + use time::macros::datetime; + use time::Duration; + + fn create_test_template() -> TideTemplateModel { + let first_tide = datetime!(2025-01-01 0:00 UTC); + TideTemplateModel::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ) + } + + #[test] + fn test_new_tide() { + let start = datetime!(2025-01-01 12:00 UTC); + let end = Some(datetime!(2025-01-02 12:00 UTC)); + let tide = Tide::new( + start, + end, + "creating".to_string(), + "daily".to_string(), + 100.0, + "template-id".to_string(), + ); + + assert_eq!(tide.start, start); + assert_eq!(tide.end, end); + assert_eq!(tide.completed_at, None); + assert_eq!(tide.metrics_type, "creating"); + assert_eq!(tide.tide_frequency, "daily"); + assert_eq!(tide.goal_amount, 100.0); + assert_eq!(tide.actual_amount, 0.0); + assert_eq!(tide.tide_template_id, "template-id"); + assert!(!tide.id.is_empty()); + } + + #[test] + fn test_from_template_daily() { + let template = create_test_template(); + let start = datetime!(2025-01-01 12:00 UTC); + let tide = Tide::from_template(&template, start); + + assert_eq!(tide.start, start); + assert_eq!(tide.end, Some(start + Duration::days(1))); + assert_eq!(tide.completed_at, None); + assert_eq!(tide.metrics_type, "creating"); + assert_eq!(tide.tide_frequency, "daily"); + assert_eq!(tide.goal_amount, 100.0); + assert_eq!(tide.tide_template_id, template.id); + } + + #[test] + fn test_from_template_weekly() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplateModel::new( + "learning".to_string(), + "weekly".to_string(), + 500.0, + first_tide, + None, + ); + let start = datetime!(2025-01-01 12:00 UTC); + let tide = Tide::from_template(&template, start); + + assert_eq!(tide.end, Some(start + Duration::days(7))); + assert_eq!(tide.tide_frequency, "weekly"); + } + + #[test] + fn test_from_template_indefinite() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplateModel::new( + "project".to_string(), + "indefinite".to_string(), + 1000.0, + first_tide, + None, + ); + let start = datetime!(2025-01-01 12:00 UTC); + let tide = Tide::from_template(&template, start); + + assert_eq!(tide.end, None); + assert_eq!(tide.tide_frequency, "indefinite"); + } + + #[test] + fn test_is_completed_false() { + let template = create_test_template(); + let start = datetime!(2025-01-01 12:00 UTC); + let tide = Tide::from_template(&template, start); + + assert!(!tide.is_completed()); + } + + #[test] + fn test_is_completed_true() { + let template = create_test_template(); + let start = datetime!(2025-01-01 12:00 UTC); + let mut tide = Tide::from_template(&template, start); + tide.mark_completed(); + + assert!(tide.is_completed()); + } + + #[test] + fn test_is_active_not_completed_within_window() { + let template = create_test_template(); + let start = OffsetDateTime::now_utc() - Duration::hours(12); // Started 12 hours ago + let tide = Tide::from_template(&template, start); + + // Should be active since it's not completed and within 24 hour window + assert!(tide.is_active()); + } + + #[test] + fn test_is_active_completed() { + let template = create_test_template(); + let start = OffsetDateTime::now_utc() - Duration::hours(12); + let mut tide = Tide::from_template(&template, start); + tide.mark_completed(); + + // Should not be active since it's completed + assert!(!tide.is_active()); + } + + #[test] + fn test_is_active_past_end_time() { + let template = create_test_template(); + let start = OffsetDateTime::now_utc() - Duration::days(2); // Started 2 days ago + let tide = Tide::from_template(&template, start); + + // Should not be active since it's past the end time (daily = 1 day) + assert!(!tide.is_active()); + } + + #[test] + fn test_is_active_indefinite() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplateModel::new( + "project".to_string(), + "indefinite".to_string(), + 1000.0, + first_tide, + None, + ); + let start = OffsetDateTime::now_utc() - Duration::days(30); // Started 30 days ago + let tide = Tide::from_template(&template, start); + + // Should be active since indefinite tides have no end time + assert!(tide.is_active()); + } + + #[test] + fn test_mark_completed() { + let template = create_test_template(); + let start = datetime!(2025-01-01 12:00 UTC); + let mut tide = Tide::from_template(&template, start); + let original_updated_at = tide.updated_at; + + assert_eq!(tide.completed_at, None); + + // Add a small delay to ensure timestamp difference + std::thread::sleep(std::time::Duration::from_millis(1)); + + tide.mark_completed(); + + assert!(tide.completed_at.is_some()); + assert!(tide.updated_at >= original_updated_at); + assert!(tide.is_completed()); + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/models/tide_template.rs b/src-tauri/src/ebb_db/src/db/models/tide_template.rs new file mode 100644 index 00000000..4c860356 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/tide_template.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct TideTemplate { + pub id: String, + pub metrics_type: String, // "creating", etc. + pub tide_frequency: String, // "daily", "weekly" + pub first_tide: OffsetDateTime, // How far back to create tides when generating + pub day_of_week: Option, // For daily tides: comma-separated days "0,1,2,3,4,5,6" (0=Sunday, 6=Saturday) + pub goal_amount: f64, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, +} + +impl TideTemplate { + pub fn new( + metrics_type: String, + tide_frequency: String, + goal_amount: f64, + first_tide: OffsetDateTime, + day_of_week: Option, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + metrics_type, + tide_frequency, + first_tide, + day_of_week, + goal_amount, + created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), + } + } + + /// Helper method to parse day_of_week string into a Vec + pub fn get_days_of_week(&self) -> Vec { + match &self.day_of_week { + Some(days_str) => { + days_str + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|&day| day <= 6) // Only allow 0-6 + .collect() + } + None => vec![0, 1, 2, 3, 4, 5, 6], // All days if not specified + } + } + + /// Helper method to create day_of_week string from Vec + pub fn set_days_of_week(days: Vec) -> Option { + if days.len() == 7 && days == vec![0, 1, 2, 3, 4, 5, 6] { + None // All days = None for simplicity + } else { + Some(days.iter().map(|d| d.to_string()).collect::>().join(",")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use time::macros::datetime; + + #[test] + fn test_new_tide_template() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,2,3,4,5".to_string()), // Weekdays only + ); + + assert_eq!(template.metrics_type, "creating"); + assert_eq!(template.tide_frequency, "daily"); + assert_eq!(template.goal_amount, 100.0); + assert_eq!(template.first_tide, first_tide); + assert_eq!(template.day_of_week, Some("1,2,3,4,5".to_string())); + assert!(!template.id.is_empty()); + } + + #[test] + fn test_get_days_of_week_with_specific_days() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,3,5".to_string()), // Monday, Wednesday, Friday + ); + + let days = template.get_days_of_week(); + assert_eq!(days, vec![1, 3, 5]); + } + + #[test] + fn test_get_days_of_week_all_days() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, // All days + ); + + let days = template.get_days_of_week(); + assert_eq!(days, vec![0, 1, 2, 3, 4, 5, 6]); + } + + #[test] + fn test_get_days_of_week_filters_invalid() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,3,8,invalid,5".to_string()), // 8 and "invalid" should be filtered out + ); + + let days = template.get_days_of_week(); + assert_eq!(days, vec![1, 3, 5]); + } + + #[test] + fn test_get_days_of_week_empty_string() { + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("".to_string()), // Empty string + ); + + let days = template.get_days_of_week(); + assert_eq!(days, Vec::::new()); // Should return empty vec for empty string + } + + #[test] + fn test_set_days_of_week_all_days() { + let all_days = vec![0, 1, 2, 3, 4, 5, 6]; + let result = TideTemplate::set_days_of_week(all_days); + assert_eq!(result, None); // Should return None for all days + } + + #[test] + fn test_set_days_of_week_specific_days() { + let weekdays = vec![1, 2, 3, 4, 5]; + let result = TideTemplate::set_days_of_week(weekdays); + assert_eq!(result, Some("1,2,3,4,5".to_string())); + } + + #[test] + fn test_set_days_of_week_single_day() { + let single_day = vec![0]; + let result = TideTemplate::set_days_of_week(single_day); + assert_eq!(result, Some("0".to_string())); + } + + #[test] + fn test_set_days_of_week_empty() { + let empty_days = vec![]; + let result = TideTemplate::set_days_of_week(empty_days); + assert_eq!(result, Some("".to_string())); + } + + #[test] + fn test_roundtrip_days_of_week() { + // Test that we can roundtrip days of week + let original_days = vec![1, 3, 5, 6]; + let days_string = TideTemplate::set_days_of_week(original_days.clone()); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + days_string, + ); + + let parsed_days = template.get_days_of_week(); + assert_eq!(parsed_days, original_days); + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/tag_repo.rs b/src-tauri/src/ebb_db/src/db/tag_repo.rs new file mode 100644 index 00000000..fc20fe46 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/tag_repo.rs @@ -0,0 +1,200 @@ +use sqlx::{Pool, Sqlite}; + +use crate::db::models::tag::Tag; + +pub type Result = std::result::Result>; + +pub struct TagRepo { + pool: Pool, +} + +impl TagRepo { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + /// Get a tag by its name + pub async fn get_tag_by_name(&self, name: &str) -> Result> { + let tag = sqlx::query_as::<_, Tag>( + "SELECT * FROM tag WHERE name = ?1" + ) + .bind(name) + .fetch_optional(&self.pool) + .await?; + + Ok(tag) + } + + /// Get a tag by its ID + pub async fn get_tag_by_id(&self, id: &str) -> Result> { + let tag = sqlx::query_as::<_, Tag>( + "SELECT * FROM tag WHERE id = ?1" + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + + Ok(tag) + } + + /// Get all tags + pub async fn get_all_tags(&self) -> Result> { + let tags = sqlx::query_as::<_, Tag>( + "SELECT * FROM tag ORDER BY name ASC" + ) + .fetch_all(&self.pool) + .await?; + + Ok(tags) + } + + /// Get tags by type + pub async fn get_tags_by_type(&self, tag_type: &str) -> Result> { + let tags = sqlx::query_as::<_, Tag>( + "SELECT * FROM tag WHERE tag_type = ?1 ORDER BY name ASC" + ) + .bind(tag_type) + .fetch_all(&self.pool) + .await?; + + Ok(tags) + } + + /// Check if a tag exists by name + pub async fn tag_exists(&self, name: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM tag WHERE name = ?1" + ) + .bind(name) + .fetch_one(&self.pool) + .await?; + + Ok(count > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn create_test_db() -> Pool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + + // Set WAL mode + sqlx::query("PRAGMA journal_mode=WAL;") + .execute(&pool) + .await + .unwrap(); + + // Create the tag table + sqlx::query( + "CREATE TABLE IF NOT EXISTS tag ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_tag_id TEXT, + tag_type TEXT NOT NULL, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_tag_id) REFERENCES tag(id), + UNIQUE(name, tag_type) + )" + ) + .execute(&pool) + .await + .unwrap(); + + pool + } + + #[tokio::test] + async fn test_tag_repo_creation() -> Result<()> { + let pool = create_test_db().await; + let _repo = TagRepo::new(pool); + Ok(()) + } + + #[tokio::test] + async fn test_get_tag_by_name() -> Result<()> { + let pool = create_test_db().await; + let repo = TagRepo::new(pool.clone()); + + let now = time::OffsetDateTime::now_utc(); + + // Insert test tag + sqlx::query( + "INSERT INTO tag (id, name, parent_tag_id, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)" + ) + .bind("test-tag-1") + .bind("creating") + .bind::>(None) + .bind("activity") + .bind(false) + .bind(true) + .bind(now) + .bind(now) + .execute(&pool) + .await?; + + // Test retrieval by name + let result = repo.get_tag_by_name("creating").await?; + assert!(result.is_some()); + + let tag = result.unwrap(); + assert_eq!(tag.id, "test-tag-1"); + assert_eq!(tag.name, "creating"); + assert_eq!(tag.tag_type, "activity"); + assert!(!tag.is_blocked); + assert!(tag.is_default); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tag_by_name_not_found() -> Result<()> { + let pool = create_test_db().await; + let repo = TagRepo::new(pool); + + let result = repo.get_tag_by_name("nonexistent").await?; + assert!(result.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_tag_exists() -> Result<()> { + let pool = create_test_db().await; + let repo = TagRepo::new(pool.clone()); + + let now = time::OffsetDateTime::now_utc(); + + // Insert test tag + sqlx::query( + "INSERT INTO tag (id, name, parent_tag_id, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)" + ) + .bind("test-tag-2") + .bind("learning") + .bind::>(None) + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&pool) + .await?; + + // Test exists + assert!(repo.tag_exists("learning").await?); + assert!(!repo.tag_exists("nonexistent").await?); + + Ok(()) + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/tide_repo.rs b/src-tauri/src/ebb_db/src/db/tide_repo.rs new file mode 100644 index 00000000..c7fe401f --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/tide_repo.rs @@ -0,0 +1,690 @@ +use sqlx::{Pool, Sqlite}; +use time::OffsetDateTime; + +use crate::db::models::tide::Tide; + +pub type Result = std::result::Result>; + +pub struct TideRepo { + pool: Pool, +} + +impl TideRepo { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + pub async fn create_tide(&self, tide: &Tide) -> Result<()> { + sqlx::query( + "INSERT INTO tide (id, start, end, completed_at, metrics_type, tide_frequency, goal_amount, actual_amount, tide_template_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + ) + .bind(&tide.id) + .bind(&tide.start) + .bind(&tide.end) + .bind(&tide.completed_at) + .bind(&tide.metrics_type) + .bind(&tide.tide_frequency) + .bind(tide.goal_amount) + .bind(tide.actual_amount) + .bind(&tide.tide_template_id) + .bind(&tide.created_at) + .bind(&tide.updated_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn get_tide(&self, id: &str) -> Result> { + let tide = sqlx::query_as::<_, Tide>("SELECT * FROM tide WHERE id = ?1") + .bind(id) + .fetch_optional(&self.pool) + .await?; + + Ok(tide) + } + + pub async fn get_all_tides(&self) -> Result> { + let tides = sqlx::query_as::<_, Tide>("SELECT * FROM tide ORDER BY start DESC") + .fetch_all(&self.pool) + .await?; + + Ok(tides) + } + + pub async fn get_active_tides(&self) -> Result> { + let now = OffsetDateTime::now_utc(); + self.get_active_tides_at(now).await + } + + pub async fn get_active_tides_at(&self, evaluation_time: OffsetDateTime) -> Result> { + let tides = sqlx::query_as::<_, Tide>( + "SELECT * FROM tide WHERE start <= ?1 AND (end IS NULL OR end >= ?1) ORDER BY start DESC" + ) + .bind(evaluation_time) + .fetch_all(&self.pool) + .await?; + + Ok(tides) + } + + pub async fn get_tides_by_template(&self, template_id: &str) -> Result> { + let tides = sqlx::query_as::<_, Tide>( + "SELECT * FROM tide WHERE tide_template_id = ?1 ORDER BY start DESC" + ) + .bind(template_id) + .fetch_all(&self.pool) + .await?; + + Ok(tides) + } + + pub async fn get_tides_in_date_range( + &self, + start: OffsetDateTime, + end: OffsetDateTime, + ) -> Result> { + let tides = sqlx::query_as::<_, Tide>( + "SELECT * FROM tide WHERE start >= ?1 AND start <= ?2 ORDER BY start DESC" + ) + .bind(start) + .bind(end) + .fetch_all(&self.pool) + .await?; + + Ok(tides) + } + + pub async fn update_tide(&self, tide: &Tide) -> Result<()> { + sqlx::query( + "UPDATE tide + SET start = ?2, end = ?3, completed_at = ?4, metrics_type = ?5, tide_frequency = ?6, + goal_amount = ?7, actual_amount = ?8, tide_template_id = ?9, updated_at = ?10 + WHERE id = ?1" + ) + .bind(&tide.id) + .bind(&tide.start) + .bind(&tide.end) + .bind(&tide.completed_at) + .bind(&tide.metrics_type) + .bind(&tide.tide_frequency) + .bind(tide.goal_amount) + .bind(tide.actual_amount) + .bind(&tide.tide_template_id) + .bind(&tide.updated_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn update_actual_amount(&self, id: &str, actual_amount: f64) -> Result<()> { + sqlx::query( + "UPDATE tide SET actual_amount = ?2, updated_at = ?3 WHERE id = ?1" + ) + .bind(id) + .bind(actual_amount) + .bind(OffsetDateTime::now_utc()) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn end_tide(&self, id: &str, end_time: OffsetDateTime) -> Result<()> { + sqlx::query( + "UPDATE tide SET end = ?2, updated_at = ?3 WHERE id = ?1" + ) + .bind(id) + .bind(end_time) + .bind(OffsetDateTime::now_utc()) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn complete_tide(&self, id: &str) -> Result<()> { + let now = OffsetDateTime::now_utc(); + sqlx::query( + "UPDATE tide SET completed_at = ?2, updated_at = ?3 WHERE id = ?1" + ) + .bind(id) + .bind(now) + .bind(now) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn delete_tide(&self, id: &str) -> Result<()> { + sqlx::query("DELETE FROM tide WHERE id = ?1") + .bind(id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Get the latest tide end time for a specific template + /// Returns None if no tides exist for the template or if all tides have NULL end times + pub async fn get_latest_tide_end_for_template(&self, template_id: &str) -> Result> { + let result = sqlx::query_scalar::<_, Option>( + "SELECT MAX(end) FROM tide WHERE tide_template_id = ?1 AND end IS NOT NULL" + ) + .bind(template_id) + .fetch_one(&self.pool) + .await?; + + Ok(result) + } + + /// Check if a template has any tide that starts within the specified date range + /// This is used to prevent creating duplicate tides during backfill operations + pub async fn has_tide_for_date_range( + &self, + template_id: &str, + range_start: OffsetDateTime, + range_end: OffsetDateTime, + ) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM tide WHERE tide_template_id = ?1 AND start >= ?2 AND start < ?3" + ) + .bind(template_id) + .bind(range_start) + .bind(range_end) + .fetch_one(&self.pool) + .await?; + + Ok(count > 0) + } +} + +#[cfg(test)] +mod tests { + use crate::db_manager; + use crate::db::models::tide_template::TideTemplate; + use crate::db::tide_template_repo::TideTemplateRepo; + use time::macros::datetime; + + use super::*; + + fn create_test_template() -> TideTemplate { + let first_tide = datetime!(2025-01-01 0:00 UTC); + TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ) + } + + #[tokio::test] + async fn test_create_and_get_tide() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); + tide_repo.create_tide(&tide).await?; + + let retrieved = tide_repo.get_tide(&tide.id).await?; + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, tide.id); + assert_eq!(retrieved.metrics_type, "creating"); + assert_eq!(retrieved.tide_frequency, "daily"); + assert_eq!(retrieved.goal_amount, 100.0); + assert_eq!(retrieved.actual_amount, 0.0); + + Ok(()) + } + + #[tokio::test] + async fn test_get_active_tides() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let mut tide1 = Tide::from_template(&template, OffsetDateTime::now_utc()); + let tide2 = Tide::from_template(&template, OffsetDateTime::now_utc()); + + // End one tide + tide1.end = Some(OffsetDateTime::now_utc()); + + tide_repo.create_tide(&tide1).await?; + tide_repo.create_tide(&tide2).await?; + + let active_tides = tide_repo.get_active_tides().await?; + assert_eq!(active_tides.len(), 1); + assert_eq!(active_tides[0].id, tide2.id); + + Ok(()) + } + + #[tokio::test] + async fn test_update_actual_amount() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); + tide_repo.create_tide(&tide).await?; + + tide_repo.update_actual_amount(&tide.id, 50.0).await?; + + let updated = tide_repo.get_tide(&tide.id).await?; + assert!(updated.is_some()); + assert_eq!(updated.unwrap().actual_amount, 50.0); + + Ok(()) + } + + #[tokio::test] + async fn test_end_tide() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); + tide_repo.create_tide(&tide).await?; + + let end_time = OffsetDateTime::now_utc(); + tide_repo.end_tide(&tide.id, end_time).await?; + + let ended = tide_repo.get_tide(&tide.id).await?; + assert!(ended.is_some()); + assert!(ended.unwrap().end.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tides_by_template() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0, first_tide, None); + let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0, first_tide, None); + + template_repo.create_tide_template(&template1).await?; + template_repo.create_tide_template(&template2).await?; + + let tide1 = Tide::from_template(&template1, OffsetDateTime::now_utc()); + let tide2 = Tide::from_template(&template1, OffsetDateTime::now_utc()); + let tide3 = Tide::from_template(&template2, OffsetDateTime::now_utc()); + + tide_repo.create_tide(&tide1).await?; + tide_repo.create_tide(&tide2).await?; + tide_repo.create_tide(&tide3).await?; + + let template1_tides = tide_repo.get_tides_by_template(&template1.id).await?; + assert_eq!(template1_tides.len(), 2); + + let template2_tides = tide_repo.get_tides_by_template(&template2.id).await?; + assert_eq!(template2_tides.len(), 1); + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_tide_end_for_template_no_tides() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // No tides exist for this template + let latest_end = tide_repo.get_latest_tide_end_for_template(&template.id).await?; + assert!(latest_end.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_tide_end_for_template_single_tide() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); + tide_repo.create_tide(&tide).await?; + + let latest_end = tide_repo.get_latest_tide_end_for_template(&template.id).await?; + assert!(latest_end.is_some()); + assert_eq!(latest_end.unwrap(), tide.end.unwrap()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_tide_end_for_template_multiple_tides() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let now = OffsetDateTime::now_utc(); + + // Create multiple tides with different end times + let tide1 = Tide::from_template(&template, now - time::Duration::days(3)); + let tide2 = Tide::from_template(&template, now - time::Duration::days(2)); + let tide3 = Tide::from_template(&template, now - time::Duration::days(1)); + + tide_repo.create_tide(&tide1).await?; + tide_repo.create_tide(&tide2).await?; + tide_repo.create_tide(&tide3).await?; + + let latest_end = tide_repo.get_latest_tide_end_for_template(&template.id).await?; + assert!(latest_end.is_some()); + // Should return the latest (most recent) end time + assert_eq!(latest_end.unwrap(), tide3.end.unwrap()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_tide_end_for_template_with_null_end() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let now = OffsetDateTime::now_utc(); + + // Create one tide with end time and one indefinite tide (end = None) + let tide_with_end = Tide::from_template(&template, now - time::Duration::days(2)); + let mut indefinite_tide = Tide::from_template(&template, now - time::Duration::days(1)); + indefinite_tide.end = None; // Indefinite tide + + tide_repo.create_tide(&tide_with_end).await?; + tide_repo.create_tide(&indefinite_tide).await?; + + let latest_end = tide_repo.get_latest_tide_end_for_template(&template.id).await?; + assert!(latest_end.is_some()); + // Should return the end time from tide_with_end (ignoring NULL end times) + assert_eq!(latest_end.unwrap(), tide_with_end.end.unwrap()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_tide_end_for_template_only_null_ends() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Create multiple indefinite tides (all end = None) + let mut tide1 = Tide::from_template(&template, OffsetDateTime::now_utc() - time::Duration::days(3)); + let mut tide2 = Tide::from_template(&template, OffsetDateTime::now_utc() - time::Duration::days(2)); + tide1.end = None; + tide2.end = None; + + tide_repo.create_tide(&tide1).await?; + tide_repo.create_tide(&tide2).await?; + + let latest_end = tide_repo.get_latest_tide_end_for_template(&template.id).await?; + assert!(latest_end.is_none()); // Should return None when all end times are NULL + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_tide_end_for_template_different_templates() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + // Create two different templates + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0, first_tide, None); + let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0, first_tide, None); + + template_repo.create_tide_template(&template1).await?; + template_repo.create_tide_template(&template2).await?; + + let now = OffsetDateTime::now_utc(); + + // Create tides for template1 only + let tide1 = Tide::from_template(&template1, now - time::Duration::days(2)); + let tide2 = Tide::from_template(&template1, now - time::Duration::days(1)); + + tide_repo.create_tide(&tide1).await?; + tide_repo.create_tide(&tide2).await?; + + // template1 should have latest end time + let latest_end1 = tide_repo.get_latest_tide_end_for_template(&template1.id).await?; + assert!(latest_end1.is_some()); + assert_eq!(latest_end1.unwrap(), tide2.end.unwrap()); + + // template2 should have no tides + let latest_end2 = tide_repo.get_latest_tide_end_for_template(&template2.id).await?; + assert!(latest_end2.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_no_tides() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + // No tides exist for this template + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(!has_tide); + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_tide_within_range() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Create a tide that starts within the range + let tide_start = datetime!(2025-01-01 12:00:00 UTC); + let tide = Tide::from_template(&template, tide_start); + tide_repo.create_tide(&tide).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(has_tide); + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_tide_before_range() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Create a tide that starts before the range + let tide_start = datetime!(2024-12-31 12:00:00 UTC); + let tide = Tide::from_template(&template, tide_start); + tide_repo.create_tide(&tide).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(!has_tide); // Tide starts before range, should not be found + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_tide_after_range() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Create a tide that starts after the range + let tide_start = datetime!(2025-01-02 12:00:00 UTC); + let tide = Tide::from_template(&template, tide_start); + tide_repo.create_tide(&tide).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(!has_tide); // Tide starts at range_end, should not be included (start < range_end) + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_tide_at_range_start() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Create a tide that starts exactly at range_start + let tide_start = datetime!(2025-01-01 00:00:00 UTC); + let tide = Tide::from_template(&template, tide_start); + tide_repo.create_tide(&tide).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(has_tide); // Tide starts at range_start, should be included (start >= range_start) + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_multiple_tides() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Create multiple tides - some in range, some outside + let tide1 = Tide::from_template(&template, datetime!(2024-12-31 12:00:00 UTC)); // Before range + let tide2 = Tide::from_template(&template, datetime!(2025-01-01 08:00:00 UTC)); // In range + let tide3 = Tide::from_template(&template, datetime!(2025-01-01 16:00:00 UTC)); // In range + let tide4 = Tide::from_template(&template, datetime!(2025-01-03 12:00:00 UTC)); // After range + + tide_repo.create_tide(&tide1).await?; + tide_repo.create_tide(&tide2).await?; + tide_repo.create_tide(&tide3).await?; + tide_repo.create_tide(&tide4).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(has_tide); // Should find tide2 and tide3 + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_different_templates() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + // Create two different templates + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0, first_tide, None); + let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0, first_tide, None); + + template_repo.create_tide_template(&template1).await?; + template_repo.create_tide_template(&template2).await?; + + // Create a tide for template1 within the range + let tide = Tide::from_template(&template1, datetime!(2025-01-01 12:00:00 UTC)); + tide_repo.create_tide(&tide).await?; + + let range_start = datetime!(2025-01-01 00:00:00 UTC); + let range_end = datetime!(2025-01-02 00:00:00 UTC); + + // template1 should have a tide in the range + let has_tide1 = tide_repo.has_tide_for_date_range(&template1.id, range_start, range_end).await?; + assert!(has_tide1); + + // template2 should not have any tides in the range + let has_tide2 = tide_repo.has_tide_for_date_range(&template2.id, range_start, range_end).await?; + assert!(!has_tide2); + + Ok(()) + } + + #[tokio::test] + async fn test_has_tide_for_date_range_precise_boundaries() -> Result<()> { + let pool = db_manager::create_test_db().await; + let tide_repo = TideRepo::new(pool.clone()); + let template_repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + template_repo.create_tide_template(&template).await?; + + // Test precise boundary conditions + let range_start = datetime!(2025-01-01 10:00:00 UTC); + let range_end = datetime!(2025-01-01 20:00:00 UTC); + + // Tide that starts exactly at range_end (should NOT be included) + let tide_at_end = Tide::from_template(&template, range_end); + tide_repo.create_tide(&tide_at_end).await?; + + let has_tide = tide_repo.has_tide_for_date_range(&template.id, range_start, range_end).await?; + assert!(!has_tide); // Should not include tide that starts exactly at range_end + + Ok(()) + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/tide_template_repo.rs b/src-tauri/src/ebb_db/src/db/tide_template_repo.rs new file mode 100644 index 00000000..7f550279 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/tide_template_repo.rs @@ -0,0 +1,172 @@ +use sqlx::{Pool, Sqlite}; + +use crate::db::models::tide_template::TideTemplate; + +pub type Result = std::result::Result>; + +pub struct TideTemplateRepo { + pool: Pool, +} + +impl TideTemplateRepo { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + pub async fn create_tide_template(&self, template: &TideTemplate) -> Result<()> { + sqlx::query( + "INSERT INTO tide_template (id, metrics_type, tide_frequency, first_tide, day_of_week, goal_amount, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)" + ) + .bind(&template.id) + .bind(&template.metrics_type) + .bind(&template.tide_frequency) + .bind(&template.first_tide) + .bind(&template.day_of_week) + .bind(template.goal_amount) + .bind(&template.created_at) + .bind(&template.updated_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn get_tide_template(&self, id: &str) -> Result> { + let template = sqlx::query_as::<_, TideTemplate>("SELECT * FROM tide_template WHERE id = ?1") + .bind(id) + .fetch_optional(&self.pool) + .await?; + + Ok(template) + } + + pub async fn get_all_tide_templates(&self) -> Result> { + let templates = sqlx::query_as::<_, TideTemplate>( + "SELECT * FROM tide_template ORDER BY created_at DESC" + ) + .fetch_all(&self.pool) + .await?; + + Ok(templates) + } + + pub async fn update_tide_template(&self, template: &TideTemplate) -> Result<()> { + sqlx::query( + "UPDATE tide_template + SET metrics_type = ?2, tide_frequency = ?3, first_tide = ?4, day_of_week = ?5, goal_amount = ?6, updated_at = ?7 + WHERE id = ?1" + ) + .bind(&template.id) + .bind(&template.metrics_type) + .bind(&template.tide_frequency) + .bind(&template.first_tide) + .bind(&template.day_of_week) + .bind(template.goal_amount) + .bind(&template.updated_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn delete_tide_template(&self, id: &str) -> Result<()> { + sqlx::query("DELETE FROM tide_template WHERE id = ?1") + .bind(id) + .execute(&self.pool) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db_manager; + use time::macros::datetime; + + use super::*; + + fn create_test_template() -> TideTemplate { + let first_tide = datetime!(2025-01-01 0:00 UTC); + TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ) + } + + #[tokio::test] + async fn test_create_and_get_tide_template() -> Result<()> { + let pool = db_manager::create_test_db().await; + let repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + repo.create_tide_template(&template).await?; + + let retrieved = repo.get_tide_template(&template.id).await?; + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, template.id); + assert_eq!(retrieved.metrics_type, "creating"); + assert_eq!(retrieved.tide_frequency, "daily"); + assert_eq!(retrieved.goal_amount, 100.0); + + Ok(()) + } + + #[tokio::test] + async fn test_get_all_tide_templates() -> Result<()> { + let pool = db_manager::create_test_db().await; + let repo = TideTemplateRepo::new(pool); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0, first_tide, None); + let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0, first_tide, None); + + repo.create_tide_template(&template1).await?; + repo.create_tide_template(&template2).await?; + + let all_templates = repo.get_all_tide_templates().await?; + assert_eq!(all_templates.len(), 4); + + Ok(()) + } + + #[tokio::test] + async fn test_update_tide_template() -> Result<()> { + let pool = db_manager::create_test_db().await; + let repo = TideTemplateRepo::new(pool); + + let mut template = create_test_template(); + repo.create_tide_template(&template).await?; + + template.goal_amount = 150.0; + template.updated_at = time::OffsetDateTime::now_utc(); + repo.update_tide_template(&template).await?; + + let updated = repo.get_tide_template(&template.id).await?; + assert!(updated.is_some()); + assert_eq!(updated.unwrap().goal_amount, 150.0); + + Ok(()) + } + + #[tokio::test] + async fn test_delete_tide_template() -> Result<()> { + let pool = db_manager::create_test_db().await; + let repo = TideTemplateRepo::new(pool); + + let template = create_test_template(); + repo.create_tide_template(&template).await?; + + repo.delete_tide_template(&template.id).await?; + + let deleted = repo.get_tide_template(&template.id).await?; + assert!(deleted.is_none()); + + Ok(()) + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/migrations.rs b/src-tauri/src/ebb_db/src/migrations.rs index 6d2f77b2..7ec140b2 100644 --- a/src-tauri/src/ebb_db/src/migrations.rs +++ b/src-tauri/src/ebb_db/src/migrations.rs @@ -259,6 +259,81 @@ pub fn get_migrations() -> Vec { "#, kind: MigrationKind::Up, }, + Migration { + version: 18, + description: "create_tide_template", + sql: r#" + CREATE TABLE IF NOT EXISTS tide_template ( + id TEXT PRIMARY KEY NOT NULL, + metrics_type TEXT NOT NULL, + tide_frequency TEXT NOT NULL, + first_tide DATETIME NOT NULL, + day_of_week TEXT, + goal_amount REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "#, + kind: MigrationKind::Up, + }, + Migration { + version: 19, + description: "create_tide", + sql: r#" + CREATE TABLE IF NOT EXISTS tide ( + id TEXT PRIMARY KEY NOT NULL, + start DATETIME NOT NULL, + end DATETIME, + completed_at DATETIME, + metrics_type TEXT NOT NULL, + tide_frequency TEXT NOT NULL, + goal_amount REAL NOT NULL, + actual_amount REAL NOT NULL DEFAULT 0.0, + tide_template_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tide_template_id) REFERENCES tide_template (id) + ); + "#, + kind: MigrationKind::Up, + }, + Migration { + version: 20, + description: "seed_default_tide_templates", + sql: r#" + INSERT INTO tide_template ( + id, + metrics_type, + tide_frequency, + first_tide, + day_of_week, + goal_amount, + created_at, + updated_at + ) VALUES + ( + 'default-daily-template', + 'creating', + 'daily', + datetime('now'), + '1,2,3,4,5', + 180.0, + datetime('now'), + datetime('now') + ), + ( + 'default-weekly-template', + 'creating', + 'weekly', + datetime('now'), + '0,1,2,3,4,5,6', + 600.0, + datetime('now'), + datetime('now') + ); + "#, + kind: MigrationKind::Up, + }, ] } @@ -310,6 +385,8 @@ mod tests { "workflow", "device_profile", // renamed from user_profile in migration 14 "user_notification", + "tide_template", + "tide", ]; for table_name in tables_to_check { diff --git a/src-tauri/src/ebb_tide_manager/.gitignore b/src-tauri/src/ebb_tide_manager/.gitignore new file mode 100644 index 00000000..b21bd681 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/src-tauri/src/ebb_tide_manager/Cargo.lock b/src-tauri/src/ebb_tide_manager/Cargo.lock new file mode 100644 index 00000000..55d5ef36 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/Cargo.lock @@ -0,0 +1,5478 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.9.4", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.16", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.5", +] + +[[package]] +name = "cc" +version = "1.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.1.3", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.9.4", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dlopen2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ebb-db" +version = "0.1.0" +dependencies = [ + "dirs", + "futures-core", + "indexmap 2.11.0", + "log", + "serde", + "serde_json", + "sqlx", + "tauri", + "tauri-plugin-sql", + "thiserror 1.0.69", + "time", + "tokio", + "uuid", +] + +[[package]] +name = "ebb_tide_manager" +version = "0.1.0" +dependencies = [ + "ebb-db", + "sqlx", + "thiserror 2.0.16", + "time", + "tokio", + "uuid", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "embed-resource" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.4+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.9.4", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.9.4", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.11.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.4", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.16", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.4", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate 2.0.2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.1", + "libc", + "objc2 0.6.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +dependencies = [ + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.1", + "libc", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a" +dependencies = [ + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.9.4", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-security" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" +dependencies = [ + "bitflags 2.9.4", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-foundation 0.3.1", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.16", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +dependencies = [ + "erased-serde", + "serde", + "typeid", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.0", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "bytemuck", + "cfg_aliases", + "core-graphics", + "foreign-types", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", + "raw-window-handle", + "redox_syscall", + "wasm-bindgen", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.11.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.16", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.106", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.106", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.9.4", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.16", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.9.4", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.16", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.16", + "time", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" +dependencies = [ + "bitflags 2.9.4", + "block2 0.6.1", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.3", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.16", + "tokio", + "tray-icon", + "url", + "urlpattern", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.5", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.106", + "tauri-utils", + "thiserror 2.0.16", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.5", + "walkdir", +] + +[[package]] +name = "tauri-plugin-sql" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df059378695202fef1e274b8e7916fc3dffc44716ae4baf8c0226089b2f390ae" +dependencies = [ + "futures-core", + "indexmap 2.11.0", + "log", + "serde", + "serde_json", + "sqlx", + "tauri", + "tauri-plugin", + "thiserror 2.0.16", + "time", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.16", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.16", + "toml 0.9.5", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" +dependencies = [ + "embed-resource", + "toml 0.9.5", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.11.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.1", + "once_cell", + "png", + "serde", + "thiserror 2.0.16", + "windows-sys 0.59.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.4+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +dependencies = [ + "thiserror 2.0.16", + "windows", + "windows-core", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e061eb0a22b4a1d778ad70f7575ec7845490abb35b08fa320df7895882cacb" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wry" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90" +dependencies = [ + "base64 0.22.1", + "block2 0.6.1", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2 0.6.2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.16", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/src-tauri/src/ebb_tide_manager/Cargo.toml b/src-tauri/src/ebb_tide_manager/Cargo.toml new file mode 100644 index 00000000..cd315db9 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ebb_tide_manager" +version = "0.1.0" +edition = "2024" + +[dependencies] +ebb-db = { path = "../ebb_db" } +log = "0.4" +time = { version = "0.3", features = ["serde", "local-offset"] } +tokio = { version = "1.42", features = ["full"] } +thiserror = "2.0" +uuid = { version = "1.17", features = ["v4"] } + +[dev-dependencies] +sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] } diff --git a/src-tauri/src/ebb_tide_manager/NEXT_STEPS.md b/src-tauri/src/ebb_tide_manager/NEXT_STEPS.md new file mode 100644 index 00000000..e84581a5 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/NEXT_STEPS.md @@ -0,0 +1,133 @@ +# TideProgress Next Steps + +## What We've Accomplished +- ✅ Created `ActivityStateRepo` and `TagRepo` with comprehensive tests +- ✅ Implemented basic `TideProgress` with `calculate_tide_progress(tide, evaluation_time)` +- ✅ Added incremental caching system with `get_tide_progress_cached()` +- ✅ Implemented cache management (`clear_tide_cache()`, `clear_all_cache()`) + +## Next Steps to Complete TideProgress + +### 1. Add Tide Completion Validation +**Purpose**: Determine when a tide should be marked as complete based on progress vs goal. + +**Implementation**: +```rust +/// Check if a tide should be marked as complete +pub async fn should_complete_tide(&self, tide: &Tide, evaluation_time: OffsetDateTime) -> Result { + let current_progress = self.get_tide_progress_cached(tide, evaluation_time).await?; + + // If progress meets or exceeds goal, force refresh validation to ensure accuracy + if current_progress >= tide.goal_amount { + let validated_progress = self.calculate_tide_progress(tide, evaluation_time).await?; + Ok(validated_progress >= tide.goal_amount) + } else { + Ok(false) + } +} +``` + +**Tests to add immediately**: +- Test when progress < goal (should return false) +- Test when cached progress >= goal but actual < goal (should return false) +- Test when both cached and actual >= goal (should return true) + +### 2. Add Integration Method for TideManager +**Purpose**: Update the tide's actual_amount in database via TideService. + +**Implementation**: +```rust +/// Update a tide's progress in the database and cache +pub async fn update_tide_progress(&self, tide: &mut Tide, tide_service: &TideService, evaluation_time: OffsetDateTime) -> Result { + let current_progress = self.get_tide_progress_cached(tide, evaluation_time).await?; + + // Update the database through TideService + tide_service.update_tide_progress(&tide.id, current_progress).await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Update the local tide object + tide.actual_amount = current_progress; + + Ok(current_progress) +} +``` + +**Tests to add immediately**: +- Test progress update with mock TideService +- Test database integration +- Test local tide object update + +### 3. Integration with TideManager +**Purpose**: Use TideProgress in the `perform_tide_check` function. + +**In `src/lib.rs`**: +```rust +use tide_progress::{TideProgress, TideProgressError}; + +// Add TideProgress to TideManager +pub struct TideManager { + scheduler: Arc, + service: Arc, + progress: Arc, // Add this +} + +// Update perform_tide_check +async fn perform_tide_check(service: &TideService, progress: &TideProgress) -> Result<()> { + let evaluation_time = OffsetDateTime::now_utc(); + + // Get or create active tides for current period + let mut active_tides = service.get_or_create_active_tides_for_period(evaluation_time).await?; + + for mut tide in active_tides { + // Update progress + let current_progress = progress.update_tide_progress(&mut tide, service, evaluation_time).await?; + + // Check if tide should be completed + if progress.should_complete_tide(&tide, evaluation_time).await? { + service.complete_tide(&tide.id).await?; + progress.clear_tide_cache(&tide.id).await; // Clear cache for completed tide + } + } + + Ok(()) +} +``` + +## Development Process (Following Your Pattern) + +### Step-by-Step Approach: +1. **Implement one method at a time** +2. **Add tests immediately after each method** +3. **Run tests to validate before proceeding** +4. **Only move to next method after tests pass** + +### Testing Strategy: +- **Minimal testing**: Test only the new method being implemented +- **Incremental validation**: Each method is proven working before building on it +- **Use `cargo test method_name --lib`** to run specific tests + +### Example Testing Commands: +```bash +# Test specific method +cargo test should_complete_tide --lib + +# Test all TideProgress tests +cargo test tide_progress::tests --lib + +# Test integration after adding to TideManager +cargo test perform_tide_check --lib +``` + +## Key Implementation Notes + +1. **Error Handling**: Convert `TideServiceError` to `TideProgressError::Database` +2. **Cache Management**: Clear cache when tides are completed to free memory +3. **Evaluation Time**: Always pass `evaluation_time` parameter for testability +4. **Incremental Pattern**: Build → Test → Validate → Next function + +## Files to Modify +- `src/tide_progress.rs` - Add completion validation and integration methods +- `src/lib.rs` - Update TideManager to use TideProgress +- Both files' test modules - Add comprehensive tests for each new method + +This approach ensures each piece is working correctly before building the next layer, making debugging easier and maintaining code quality. \ No newline at end of file diff --git a/src-tauri/src/ebb_tide_manager/src/lib.rs b/src-tauri/src/ebb_tide_manager/src/lib.rs new file mode 100644 index 00000000..2a9409e4 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -0,0 +1,332 @@ +pub mod tide_scheduler; +pub mod tide_service; +pub mod tide_progress; +pub mod time_helpers; + +use std::sync::Arc; +use thiserror::Error; +use time::OffsetDateTime; +use tide_scheduler::{TideScheduler, TideSchedulerError, TideSchedulerEvent}; +use tide_service::{TideService, TideServiceError}; +use tide_progress::{TideProgress, TideProgressError}; + +#[derive(Error, Debug)] +pub enum TideManagerError { + #[error("Service error: {0}")] + Service(#[from] TideServiceError), + #[error("Scheduler error: {0}")] + Scheduler(#[from] TideSchedulerError), + #[error("Progress error: {0}")] + Progress(#[from] TideProgressError), + #[error("Manager already running")] + AlreadyRunning, + #[error("Manager not running")] + NotRunning, + #[error("Invalid operation: {message}")] + InvalidOperation { message: String }, +} + +pub type Result = std::result::Result; + +/// TideManager handles lifecycle management activities for tides +/// This includes scheduling, automatic generation, and complex business workflows +pub struct TideManager { + scheduler: Arc, + service: Arc, + progress: Arc, +} + +impl TideManager { + /// Create a new TideManager with default configuration (60 second intervals) + pub async fn new() -> Result { + Self::new_with_interval(30).await + } + + /// Create a new TideManager with custom interval + pub async fn new_with_interval(interval_seconds: u64) -> Result { + println!("Creating TideManager with interval: {} seconds", interval_seconds); + let scheduler = Arc::new(TideScheduler::new(interval_seconds)?); + let service = Arc::new(TideService::new().await?); + let progress = Arc::new(TideProgress::new().await?); + + Ok(Self { scheduler, service, progress }) + } + + /// Start the TideManager - begins listening to scheduler events + pub async fn start(&self) -> Result<()> { + println!("Starting TideManager"); + // Start the scheduler (it manages its own running state) + self.scheduler.start().await?; + + // Subscribe to scheduler events and handle them + let mut receiver = self.scheduler.subscribe(); + let service = Arc::clone(&self.service); + let progress = Arc::clone(&self.progress); + let scheduler = Arc::clone(&self.scheduler); + + tokio::spawn(async move { + while scheduler.is_running() { + match receiver.recv().await { + Ok(event) => { + if let Err(e) = Self::handle_scheduler_event(event, &service, &progress).await { + eprintln!("Error handling scheduler event: {}", e); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Scheduler closed, stop listening + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // We're lagging behind, continue + eprintln!("TideManager is lagging behind scheduler events"); + continue; + } + } + } + }); + + Ok(()) + } + + /// Stop the TideManager - delegates to scheduler + pub fn stop(&self) -> Result<()> { + self.scheduler.stop()?; + Ok(()) + } + + /// Check if the TideManager is currently running - delegates to scheduler + pub fn is_running(&self) -> bool { + self.scheduler.is_running() + } + + /// Handle scheduler events (private method) + async fn handle_scheduler_event( + event: TideSchedulerEvent, + service: &TideService, + progress: &TideProgress, + ) -> Result<()> { + match event { + TideSchedulerEvent::Check { timestamp: _ } => { + // Placeholder for tide lifecycle operations + Self::perform_tide_check(service, progress).await?; + } + } + Ok(()) + } + + /// Perform tide lifecycle checks - the core tide management logic + async fn perform_tide_check(service: &TideService, progress: &TideProgress) -> Result<()> { + let evaluation_time = OffsetDateTime::now_utc(); + + // Get or create active tides for current period + let active_tides = service.get_or_create_active_tides_for_period(evaluation_time).await?; + println!("Processing {} tides at evaluation_time: {:?}", active_tides.len(), evaluation_time); + println!("Active tides: {:?}", active_tides); + + for mut tide in active_tides { + // Update progress + let current_progress = progress.update_tide_progress(&mut tide, service, evaluation_time).await?; + + println!("Tide {} progress: {}/{}", tide.id, current_progress, tide.goal_amount); + + // Check if tide should be completed + if progress.should_complete_tide(&tide, evaluation_time).await? { + println!("Completing tide {}", tide.id); + service.complete_tide(&tide.id).await?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +pub mod test_helpers { + use ebb_db::db_manager::DbManager; + use sqlx::sqlite::SqlitePoolOptions; + use std::sync::Arc; + + /// Create a test database with all necessary tables for TideManager tests + pub async fn create_test_db_manager() -> Arc { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + + // Set WAL mode + sqlx::query("PRAGMA journal_mode=WAL;") + .execute(&pool) + .await + .unwrap(); + + // Run the ebb_db migrations to get tide tables + let migrations = ebb_db::migrations::get_migrations(); + for migration in migrations { + sqlx::query(&migration.sql).execute(&pool).await.unwrap(); + } + + // Create the CodeClimbers-specific tables for activity tracking + sqlx::query( + "CREATE TABLE IF NOT EXISTS activity_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL CHECK (state IN ('ACTIVE', 'INACTIVE')) DEFAULT 'INACTIVE', + app_switches INTEGER NOT NULL DEFAULT 0, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )" + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS tag ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_tag_id TEXT, + tag_type TEXT NOT NULL, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_tag_id) REFERENCES tag(id), + UNIQUE(name, tag_type) + )" + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS activity_state_tag ( + activity_state_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + app_tag_id TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (activity_state_id) REFERENCES activity_state(id), + FOREIGN KEY (tag_id) REFERENCES tag(id), + UNIQUE(activity_state_id, tag_id) + )" + ) + .execute(&pool) + .await + .unwrap(); + + Arc::new(DbManager { pool }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::test_helpers::create_test_db_manager; + + #[tokio::test] + async fn test_perform_tide_check_workflow() -> Result<()> { + let db_manager = create_test_db_manager().await; + + // Create TideService and TideProgress + let service = TideService::new_with_manager(db_manager.clone()); + let progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create a test tide that covers "now" so it will be active + let now_time = OffsetDateTime::now_utc(); + let tide_start = now_time - time::Duration::hours(2); // Started 2 hours ago + let tide_end = now_time + time::Duration::hours(2); // Ends 2 hours from now + + let tide = ebb_db::db::models::tide::Tide::new( + tide_start, + Some(tide_end), + "creating".to_string(), + "daily".to_string(), + 60.0, // Goal is 60 minutes - easy to exceed + "default-daily-template".to_string(), + ); + + // Insert tide into database + sqlx::query( + "INSERT INTO tide (id, start, end, completed_at, metrics_type, tide_frequency, goal_amount, actual_amount, tide_template_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + ) + .bind(&tide.id) + .bind(tide.start) + .bind(tide.end) + .bind(tide.completed_at) + .bind(&tide.metrics_type) + .bind(&tide.tide_frequency) + .bind(tide.goal_amount) + .bind(tide.actual_amount) + .bind(&tide.tide_template_id) + .bind(tide.created_at) + .bind(tide.updated_at) + .execute(&db_manager.pool) + .await + .map_err(|e| TideManagerError::Service(TideServiceError::Database(Box::new(e))))?; + + // Create activity data that exceeds the goal (90 minutes > 60 goal) + let tag_id = "test-creating-tag"; + let now = now_time; + + // Insert tag + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideManagerError::Service(TideServiceError::Database(Box::new(e))))?; + + // Insert 90 minutes of activity within the tide period (exceeds 60 minute goal) + let activity_start = tide_start + time::Duration::minutes(30); + let activity_end = activity_start + time::Duration::minutes(90); + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(activity_start) + .bind(activity_end) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideManagerError::Service(TideServiceError::Database(Box::new(e))))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)" + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideManagerError::Service(TideServiceError::Database(Box::new(e))))?; + + // Verify tide is not completed initially + let tide_before = service.get_tide(&tide.id).await?.unwrap(); + assert!(tide_before.completed_at.is_none(), "Tide should not be completed initially"); + assert_eq!(tide_before.actual_amount, 0.0, "Tide should start with 0 progress"); + + // Run the tide check workflow + TideManager::perform_tide_check(&service, &progress).await?; + + // Verify the workflow worked: + // 1. Progress was updated in database + // 2. Tide was completed because progress (90) >= goal (60) + let tide_after = service.get_tide(&tide.id).await?.unwrap(); + assert!(tide_after.completed_at.is_some(), "Tide should be completed after workflow"); + assert!((tide_after.actual_amount - 90.0).abs() < 0.01, "Tide actual_amount should be ~90 minutes"); + + Ok(()) + } +} diff --git a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs new file mode 100644 index 00000000..25e888c1 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -0,0 +1,1978 @@ +use crate::tide_service::{TideService, TideServiceError}; +use ebb_db::{ + db::{activity_state_repo::ActivityStateRepo, models::tide::Tide}, + db_manager::{self, DbManager}, +}; +use log; +use std::collections::HashMap; +use std::sync::Arc; +use thiserror::Error; +use time::OffsetDateTime; +use tokio::sync::Mutex; + +#[derive(Error, Debug)] +pub enum TideProgressError { + #[error("Database error: {0}")] + Database(#[from] Box), + #[error("Service error: {0}")] + Service(#[from] TideServiceError), + #[error("Invalid operation: {message}")] + InvalidOperation { message: String }, +} + +pub type Result = std::result::Result; + +/// Cached progress data for a tide +#[derive(Debug, Clone)] +pub struct CachedProgress { + pub amount: f64, + pub last_evaluation_time: OffsetDateTime, +} + +impl CachedProgress { + pub fn new(amount: f64, evaluation_time: OffsetDateTime) -> Self { + Self { + amount, + last_evaluation_time: evaluation_time, + } + } +} + +/// TideProgress handles querying tide progress data from the CodeClimbers database +pub struct TideProgress { + activity_state_repo: ActivityStateRepo, + progress_cache: Arc>>, +} + +impl TideProgress { + /// Create a new TideProgress instance + pub async fn new() -> Result { + let codeclimbers_db = db_manager::DbManager::get_shared_codeclimbers() + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + Ok(Self { + activity_state_repo: ActivityStateRepo::new(codeclimbers_db.pool.clone()), + progress_cache: Arc::new(Mutex::new(HashMap::new())), + }) + } + + /// Create a new TideProgress instance with a specific database manager + pub fn new_with_db_manager(codeclimbers_db: Arc) -> Self { + Self { + activity_state_repo: ActivityStateRepo::new(codeclimbers_db.pool.clone()), + progress_cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Get the current progress for a tide, using cache with incremental calculation + pub async fn get_tide_progress_cached( + &self, + tide: &Tide, + evaluation_time: OffsetDateTime, + skip_cache: bool, + ) -> Result { + let tide_id = &tide.id; + + // Check cache first and clone the data if found + let cached_data = { + let cache = self.progress_cache.lock().await; + cache.get(tide_id).cloned() + }; + + if let Some(cached) = cached_data { + if !skip_cache { + // Cache hit - calculate incremental progress + let time_diff = evaluation_time - cached.last_evaluation_time; + log::debug!( + "Cache check: tide_id={}, cached_time={:?}, eval_time={:?}, diff={:?} ({} seconds)", + tide_id, + cached.last_evaluation_time, + evaluation_time, + time_diff, + time_diff.whole_seconds() + ); + log::debug!( + "Cache hit for tide: incremental progress for tide with start and end times: {:?}, {:?}", + cached.last_evaluation_time, + evaluation_time + ); + let delta_minutes = self + .activity_state_repo + .calculate_tagged_duration_in_range( + &tide.metrics_type, + cached.last_evaluation_time, + evaluation_time, + ) + .await + .map_err(|e| TideProgressError::Database(e))?; + + let new_total = cached.amount + delta_minutes; + + // Update cache with new values + { + let mut cache = self.progress_cache.lock().await; + cache.insert( + tide_id.clone(), + CachedProgress::new(new_total, evaluation_time), + ); + } + + return Ok(new_total); + } + } + + // Cache miss - calculate full range from tide start + log::debug!( + "Cache miss for tide: calculating full range for tide with start and end times: {:?}, {:?}", + tide.start, + tide.end + ); + let total_minutes = self.calculate_tide_progress(tide, evaluation_time).await?; + + // Store in cache + { + let mut cache = self.progress_cache.lock().await; + cache.insert( + tide_id.clone(), + CachedProgress::new(total_minutes, evaluation_time), + ); + } + + Ok(total_minutes) + } + + /// Calculate the current progress for a tide by querying the database + /// Progress is calculated from tide start time to the evaluation time + pub async fn calculate_tide_progress( + &self, + tide: &Tide, + evaluation_time: OffsetDateTime, + ) -> Result { + // Use the repository to calculate the tagged duration from tide start to evaluation time + let total_minutes = self + .activity_state_repo + .calculate_tagged_duration_in_range(&tide.metrics_type, tide.start, evaluation_time) + .await + .map_err(|e| TideProgressError::Database(e))?; + + Ok(total_minutes) + } + + /// Clear the progress cache for a specific tide + pub async fn clear_tide_cache(&self, tide_id: &str) { + let mut cache = self.progress_cache.lock().await; + cache.remove(tide_id); + } + + /// Clear the entire progress cache + pub async fn clear_all_cache(&self) { + let mut cache = self.progress_cache.lock().await; + cache.clear(); + } + + /// Check if a tide should be marked as complete + pub async fn should_complete_tide( + &self, + tide: &Tide, + evaluation_time: OffsetDateTime, + ) -> Result { + // if tide is completed, return true + if tide.is_completed() { + return Ok(false); + } + + let current_progress = self + .get_tide_progress_cached(tide, evaluation_time, false) + .await?; + + // If progress meets or exceeds goal, force refresh validation to ensure accuracy + if current_progress >= tide.goal_amount { + let validated_progress = self.calculate_tide_progress(tide, evaluation_time).await?; + Ok(validated_progress >= tide.goal_amount) + } else { + Ok(false) + } + } + + /// Update a tide's progress in the database and cache + pub async fn update_tide_progress( + &self, + tide: &mut Tide, + tide_service: &TideService, + evaluation_time: OffsetDateTime, + ) -> Result { + let current_progress = self + .get_tide_progress_cached(tide, evaluation_time, false) + .await?; + + // Update the database through TideService + tide_service + .update_tide_progress(&tide.id, current_progress) + .await?; + + // Update the local tide object + tide.actual_amount = current_progress; + + Ok(current_progress) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ebb_db::db::models::tide_template::TideTemplate; + use time::macros::datetime; + + use crate::test_helpers::create_test_db_manager; + + #[tokio::test] + async fn test_cached_progress_creation() -> Result<()> { + let evaluation_time = datetime!(2025-01-06 10:00 UTC); + let cached = CachedProgress::new(120.0, evaluation_time); + + assert_eq!(cached.amount, 120.0); + assert_eq!(cached.last_evaluation_time, evaluation_time); + + Ok(()) + } + + #[tokio::test] + async fn test_cached_progress_clone() -> Result<()> { + let evaluation_time = datetime!(2025-01-06 10:00 UTC); + let cached = CachedProgress::new(60.0, evaluation_time); + let cloned = cached.clone(); + + assert_eq!(cloned.amount, 60.0); + assert_eq!(cloned.last_evaluation_time, evaluation_time); + + Ok(()) + } + + #[tokio::test] + async fn test_tide_progress_creation() -> Result<()> { + let db_manager = create_test_db_manager().await; + let _tide_progress = TideProgress::new_with_db_manager(db_manager); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tide_progress() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create test activity states (2 hours total) + let start_time = datetime!(2025-01-06 09:00 UTC); + let mid_time = datetime!(2025-01-06 10:00 UTC); + let end_time = datetime!(2025-01-06 11:00 UTC); + + // First activity state (1 hour) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(start_time) + .bind(mid_time) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Second activity state (1 hour) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (2, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(mid_time) + .bind(end_time) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Link activity states to tag + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('2', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create a test tide + let tide_start = datetime!(2025-01-06 08:00 UTC); + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // 2 hours goal + tide_start, + None, + ), + tide_start, + ); + + // Evaluate progress at 12:00 UTC (4 hours after tide start, covering our 2-hour activity window) + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let progress = tide_progress + .calculate_tide_progress(&tide, evaluation_time) + .await?; + + // Use approximate equality due to floating point precision + assert!( + (progress - 120.0).abs() < 0.01, + "Expected ~120 minutes, got {}", + progress + ); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tide_progress_partial_evaluation() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag and data (same as previous test) + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create test activity states (2 hours total: 09:00-10:00 and 10:00-11:00) + let start_time = datetime!(2025-01-06 09:00 UTC); + let mid_time = datetime!(2025-01-06 10:00 UTC); + let end_time = datetime!(2025-01-06 11:00 UTC); + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(start_time) + .bind(mid_time) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (2, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(mid_time) + .bind(end_time) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('2', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide_start = datetime!(2025-01-06 08:00 UTC); + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + tide_start, + None, + ), + tide_start, + ); + + // Test evaluation at 09:30 - should capture the full 60 minutes + let partial_evaluation = datetime!(2025-01-06 09:30 UTC); + let progress = tide_progress + .calculate_tide_progress(&tide, partial_evaluation) + .await?; + assert!( + (progress - 60.0).abs() < 0.01, + "Expected ~60 minutes, got {}", + progress + ); + + // Test evaluation at 10:30 - should capture the full 120 minutes + let mid_evaluation = datetime!(2025-01-06 10:30 UTC); + let progress = tide_progress + .calculate_tide_progress(&tide, mid_evaluation) + .await?; + assert!( + (progress - 120.0).abs() < 0.01, + "Expected ~120 minutes, got {}", + progress + ); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tide_progress_cached_miss() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data (same setup as previous tests) + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 1 hour of activity: 09:00-10:00 + let start_time = datetime!(2025-01-06 09:00 UTC); + let end_time = datetime!(2025-01-06 10:00 UTC); + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(start_time) + .bind(end_time) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide_start = datetime!(2025-01-06 08:00 UTC); + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + tide_start, + None, + ), + tide_start, + ); + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let progress = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, false) + .await?; + + // Should be 60 minutes and now cached + assert!( + (progress - 60.0).abs() < 0.01, + "Expected ~60 minutes, got {}", + progress + ); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tide_progress_cached_hit_incremental() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create first hour of activity: 09:00-10:00 + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide_start = datetime!(2025-01-06 08:00 UTC); + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + tide_start, + None, + ), + tide_start, + ); + + // First call at 10:30 - should cache 60 minutes + let first_evaluation = datetime!(2025-01-06 10:30 UTC); + let progress1 = tide_progress + .get_tide_progress_cached(&tide, first_evaluation, false) + .await?; + assert!( + (progress1 - 60.0).abs() < 0.01, + "Expected ~60 minutes, got {}", + progress1 + ); + + // Add more activity for the incremental test: 11:00-12:00 (another hour) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (2, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(datetime!(2025-01-06 11:00 UTC)) + .bind(datetime!(2025-01-06 12:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('2', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Second call at 12:30 - should use cache and add incremental (60 minutes delta) + let second_evaluation = datetime!(2025-01-06 12:30 UTC); + let progress2 = tide_progress + .get_tide_progress_cached(&tide, second_evaluation, false) + .await?; + assert!( + (progress2 - 120.0).abs() < 0.01, + "Expected ~120 minutes, got {}", + progress2 + ); + + Ok(()) + } + + #[tokio::test] + async fn test_clear_tide_cache() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // First call to populate cache + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let progress1 = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, false) + .await?; + assert!((progress1 - 60.0).abs() < 0.01); + + // Verify cache has the value + { + let cache = tide_progress.progress_cache.lock().await; + assert!(cache.contains_key(&tide.id)); + } + + // Clear cache for this specific tide + tide_progress.clear_tide_cache(&tide.id).await; + + // Verify cache is cleared + { + let cache = tide_progress.progress_cache.lock().await; + assert!(!cache.contains_key(&tide.id)); + } + + // Next call should recalculate (cache miss) + let progress2 = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, false) + .await?; + assert!((progress2 - 60.0).abs() < 0.01); + + Ok(()) + } + + #[tokio::test] + async fn test_clear_tide_cache_nonexistent() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager); + + // Clearing a non-existent cache should not error + tide_progress.clear_tide_cache("nonexistent-tide-id").await; + + Ok(()) + } + + #[tokio::test] + async fn test_clear_all_cache() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create two different tides + let tide1 = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + let tide2 = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "weekly".to_string(), + 300.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + + // Populate cache for both tides + let _progress1 = tide_progress + .get_tide_progress_cached(&tide1, evaluation_time, false) + .await?; + let _progress2 = tide_progress + .get_tide_progress_cached(&tide2, evaluation_time, false) + .await?; + + // Verify both are cached + { + let cache = tide_progress.progress_cache.lock().await; + assert!(cache.contains_key(&tide1.id)); + assert!(cache.contains_key(&tide2.id)); + assert_eq!(cache.len(), 2); + } + + // Clear all cache + tide_progress.clear_all_cache().await; + + // Verify cache is completely empty + { + let cache = tide_progress.progress_cache.lock().await; + assert!(!cache.contains_key(&tide1.id)); + assert!(!cache.contains_key(&tide2.id)); + assert_eq!(cache.len(), 0); + } + + Ok(()) + } + + #[tokio::test] + async fn test_clear_all_cache_empty() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager); + + // Clearing an empty cache should not error + tide_progress.clear_all_cache().await; + + // Verify it's still empty + { + let cache = tide_progress.progress_cache.lock().await; + assert_eq!(cache.len(), 0); + } + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_progress_below_goal() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager); + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Mock scenario: cached progress is below goal (60 < 120) + // We need to manually insert cached data to simulate this + { + let mut cache = tide_progress.progress_cache.lock().await; + cache.insert( + tide.id.clone(), + CachedProgress::new(60.0, datetime!(2025-01-06 10:00 UTC)), + ); + } + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return false because cached progress (60) < goal (120) + assert!(!should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_cached_meets_goal_but_validated_fails() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag but with insufficient actual data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create only 1 hour of actual activity data (insufficient for 120-minute goal) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Mock scenario: cached progress shows >= goal (stale/incorrect cache) + { + let mut cache = tide_progress.progress_cache.lock().await; + cache.insert( + tide.id.clone(), + CachedProgress::new(125.0, datetime!(2025-01-06 10:00 UTC)), + ); + } + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return false because validated progress (60) < goal (120) + // even though cached showed 125 >= 120 + assert!(!should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_both_cached_and_validated_meet_goal() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 2.5 hours of activity data (150 minutes > 120 goal) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 11:30 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Mock scenario: cached progress shows >= goal AND actual data supports it + { + let mut cache = tide_progress.progress_cache.lock().await; + cache.insert( + tide.id.clone(), + CachedProgress::new(140.0, datetime!(2025-01-06 11:00 UTC)), + ); + } + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return true because both cached (140) >= goal (120) AND validated (150) >= goal (120) + assert!(should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_update_tide_progress_basic() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 1.5 hours of activity data + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:30 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create TideService + use crate::tide_service::TideService; + let tide_service = TideService::new_with_manager(db_manager.clone()); + + // Create a test tide using the seeded template + let mut tide = Tide::new( + datetime!(2025-01-06 08:00 UTC), + Some(datetime!(2025-01-07 08:00 UTC)), // daily tide ends next day + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes (different from seeded template's 180) + "default-daily-template".to_string(), // Use the seeded template ID + ); + + // Insert the tide into the database so TideService can update it + sqlx::query( + "INSERT INTO tide (id, start, end, completed_at, metrics_type, tide_frequency, goal_amount, actual_amount, tide_template_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + ) + .bind(&tide.id) + .bind(tide.start) + .bind(tide.end) + .bind(tide.completed_at) + .bind(&tide.metrics_type) + .bind(&tide.tide_frequency) + .bind(tide.goal_amount) + .bind(tide.actual_amount) + .bind(&tide.tide_template_id) + .bind(tide.created_at) + .bind(tide.updated_at) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Store initial actual_amount + let initial_actual = tide.actual_amount; // Should be 0.0 + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let returned_progress = tide_progress + .update_tide_progress(&mut tide, &tide_service, evaluation_time) + .await?; + + // Should return 90 minutes (1.5 hours) + assert!( + (returned_progress - 90.0).abs() < 0.01, + "Expected ~90 minutes, got {}", + returned_progress + ); + + // Local tide object should be updated + assert!( + (tide.actual_amount - 90.0).abs() < 0.01, + "Tide actual_amount should be ~90, got {}", + tide.actual_amount + ); + assert_ne!( + tide.actual_amount, initial_actual, + "Tide actual_amount should have changed from initial value" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_update_tide_progress_with_cache() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create TideService + use crate::tide_service::TideService; + let tide_service = TideService::new_with_manager(db_manager.clone()); + + // Create a test tide using the seeded template + let mut tide = Tide::new( + datetime!(2025-01-06 08:00 UTC), + Some(datetime!(2025-01-07 08:00 UTC)), + "creating".to_string(), + "daily".to_string(), + 120.0, + "default-daily-template".to_string(), + ); + + // Insert the tide into the database so TideService can update it + sqlx::query( + "INSERT INTO tide (id, start, end, completed_at, metrics_type, tide_frequency, goal_amount, actual_amount, tide_template_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + ) + .bind(&tide.id) + .bind(tide.start) + .bind(tide.end) + .bind(tide.completed_at) + .bind(&tide.metrics_type) + .bind(&tide.tide_frequency) + .bind(tide.goal_amount) + .bind(tide.actual_amount) + .bind(&tide.tide_template_id) + .bind(tide.created_at) + .bind(tide.updated_at) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Pre-populate cache with progress data + { + let mut cache = tide_progress.progress_cache.lock().await; + cache.insert( + tide.id.clone(), + CachedProgress::new(75.0, datetime!(2025-01-06 10:00 UTC)), + ); + } + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let returned_progress = tide_progress + .update_tide_progress(&mut tide, &tide_service, evaluation_time) + .await?; + + // Should return the cached value (since no additional activity data exists) + assert!( + (returned_progress - 75.0).abs() < 0.01, + "Expected ~75 minutes from cache, got {}", + returned_progress + ); + + // Local tide object should be updated with cached value + assert!( + (tide.actual_amount - 75.0).abs() < 0.01, + "Tide actual_amount should be ~75, got {}", + tide.actual_amount + ); + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_already_completed() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager); + + // Create a tide that is already completed + let mut tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Mark the tide as completed + tide.completed_at = Some(datetime!(2025-01-06 10:00 UTC)); + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return false because tide is already completed + assert!(!should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_not_completed_but_progress_exceeds_goal() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 2.5 hours of activity data (150 minutes > 120 goal) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 11:30 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create a tide that is NOT completed but has sufficient progress + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Verify tide is not completed + assert!(!tide.is_completed()); + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return true because tide is not completed AND progress (150) >= goal (120) + assert!(should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_not_completed_and_progress_below_goal() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create only 1 hour of activity data (60 minutes < 120 goal) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create a tide that is NOT completed with insufficient progress + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Verify tide is not completed + assert!(!tide.is_completed()); + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return false because progress (60) < goal (120) + assert!(!should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_should_complete_tide_completed_with_high_progress() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 3 hours of activity data (180 minutes > 120 goal) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 12:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create a tide that is ALREADY completed with high progress + let mut tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, // Goal is 120 minutes + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Mark the tide as completed + tide.completed_at = Some(datetime!(2025-01-06 11:00 UTC)); + + // Verify tide is completed + assert!(tide.is_completed()); + + let evaluation_time = datetime!(2025-01-06 13:00 UTC); + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; + + // Should return false because tide is already completed (even though progress would exceed goal) + assert!(!should_complete); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tide_progress_cached_skip_cache_false_uses_cache() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 1 hour of activity: 09:00-10:00 + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Pre-populate cache with incorrect value + { + let mut cache = tide_progress.progress_cache.lock().await; + cache.insert( + tide.id.clone(), + CachedProgress::new(100.0, datetime!(2025-01-06 09:30 UTC)), + ); + } + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + + // Call with skip_cache = false - should use cache and do incremental calculation + let progress_cached = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, false) + .await?; + + // Should use cached value (100.0) plus any incremental progress since 09:30 + // activity from 09:00 to 10:00 is 60 minutes + assert!( + (progress_cached - 160.0).abs() < 0.01, + "Expected ~160 minutes from cache, got {}", + progress_cached + ); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tide_progress_cached_skip_cache_true_ignores_cache() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 1 hour of activity: 09:00-10:00 + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + // Pre-populate cache with incorrect value + { + let mut cache = tide_progress.progress_cache.lock().await; + cache.insert( + tide.id.clone(), + CachedProgress::new(100.0, datetime!(2025-01-06 09:30 UTC)), + ); + } + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + + // Call with skip_cache = true - should ignore cache and calculate from tide start + let progress_fresh = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, true) + .await?; + + // Should ignore cached value and calculate from tide start (08:00 to 12:00) + // Only 1 hour of activity (09:00-10:00), so should be 60.0 + assert!( + (progress_fresh - 60.0).abs() < 0.01, + "Expected ~60 minutes from fresh calculation, got {}", + progress_fresh + ); + + // Verify cache was updated with the fresh calculation + { + let cache = tide_progress.progress_cache.lock().await; + let cached_value = cache.get(&tide.id).unwrap(); + assert!( + (cached_value.amount - 60.0).abs() < 0.01, + "Cache should be updated with fresh value" + ); + } + + Ok(()) + } + + #[tokio::test] + async fn test_get_tide_progress_cached_skip_cache_comparison() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create initial activity: 09:00-10:00 (1 hour) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + let first_evaluation = datetime!(2025-01-06 10:30 UTC); + + // First call with skip_cache = false to populate cache + let progress1 = tide_progress + .get_tide_progress_cached(&tide, first_evaluation, false) + .await?; + assert!((progress1 - 60.0).abs() < 0.01); // 1 hour of activity + + // Add more activity: 11:00-12:00 (another hour) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (2, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 11:00 UTC)) + .bind(datetime!(2025-01-06 12:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('2', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let second_evaluation = datetime!(2025-01-06 12:30 UTC); + + // Call with skip_cache = false - should use incremental calculation + let progress_cached = tide_progress + .get_tide_progress_cached(&tide, second_evaluation, false) + .await?; + + // Call with skip_cache = true - should recalculate from start + let progress_fresh = tide_progress + .get_tide_progress_cached(&tide, second_evaluation, true) + .await?; + + // Both should give the same result (2 hours total) + assert!( + (progress_cached - 120.0).abs() < 0.01, + "Cached calculation should be ~120 minutes, got {}", + progress_cached + ); + assert!( + (progress_fresh - 120.0).abs() < 0.01, + "Fresh calculation should be ~120 minutes, got {}", + progress_fresh + ); + + // Results should be identical + assert!( + (progress_cached - progress_fresh).abs() < 0.01, + "Cached ({}) and fresh ({}) calculations should match", + progress_cached, + progress_fresh + ); + + Ok(()) + } + + #[tokio::test] + async fn test_get_tide_progress_cached_skip_cache_no_existing_cache() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test data + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create 1 hour of activity: 09:00-10:00 + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(datetime!(2025-01-06 09:00 UTC)) + .bind(datetime!(2025-01-06 10:00 UTC)) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('1', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + let tide = Tide::from_template( + &TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 120.0, + datetime!(2025-01-06 08:00 UTC), + None, + ), + datetime!(2025-01-06 08:00 UTC), + ); + + let evaluation_time = datetime!(2025-01-06 12:00 UTC); + + // Call with skip_cache = true when no cache exists - should calculate from start + let progress_skip_true = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, true) + .await?; + + // Call with skip_cache = false when no cache exists - should also calculate from start + tide_progress.clear_tide_cache(&tide.id).await; // Clear cache after first call + let progress_skip_false = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, false) + .await?; + + // Both should give the same result when no cache exists + assert!( + (progress_skip_true - 60.0).abs() < 0.01, + "Skip cache true should be ~60 minutes, got {}", + progress_skip_true + ); + assert!( + (progress_skip_false - 60.0).abs() < 0.01, + "Skip cache false should be ~60 minutes, got {}", + progress_skip_false + ); + assert!( + (progress_skip_true - progress_skip_false).abs() < 0.01, + "Results should be identical when no cache exists" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_incremental_cache_overlap_scenario() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_progress = TideProgress::new_with_db_manager(db_manager.clone()); + + // Create test tag - "creating" with "default" type + let tag_id = "test-creating-tag"; + let now = OffsetDateTime::now_utc(); + sqlx::query( + "INSERT INTO tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(tag_id) + .bind("creating") + .bind("default") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Create activity state that matches the log scenario: + // Activity runs from 20:02:06.977351 to 20:04:06.977351 (2 minutes) + // Query range is 20:04:04.760153 to 20:06:04.759916 (overlap of ~2.2 seconds) + let activity_start = time::OffsetDateTime::parse( + "2025-09-27T20:02:06.977351+00:00", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + let activity_end = time::OffsetDateTime::parse( + "2025-09-27T20:04:06.977351+00:00", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (111267, 'ACTIVE', 0, ?1, ?2, ?3)", + ) + .bind(activity_start) + .bind(activity_end) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Link activity state to the creating tag + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('111267', ?1, ?2, ?3)", + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&db_manager.pool) + .await + .map_err(|e| TideProgressError::Database(Box::new(e)))?; + + // Set up cache scenario - previous evaluation time + let cached_time = time::OffsetDateTime::parse( + "2025-09-27T20:04:04.760153+00:00", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + let eval_time = time::OffsetDateTime::parse( + "2025-09-27T20:06:04.759916+00:00", + &time::format_description::well_known::Rfc3339, + ) + .unwrap(); + + // Test the incremental calculation that matches the logs + // This should find the overlap between the activity and the query range + let progress = tide_progress + .activity_state_repo + .calculate_tagged_duration_in_range("creating", cached_time, eval_time) + .await + .map_err(|e| TideProgressError::Database(e))?; + + // Expected calculation: + // Activity: 20:02:06.977351 to 20:04:06.977351 (2 minutes total) + // Query range: 20:04:04.760153 to 20:06:04.759916 + // Since the activity state hasn't been recorded yet in the cache, + // it should count the whole duration of the activity state = 2 minutes + // This reproduces the user's scenario where they expect 2 minutes but get 0 + + println!("Calculated progress: {} minutes", progress); + + assert!( + (progress - 2.0).abs() < 0.01, + "Expected 2 minutes (full activity duration), got {} minutes", + progress + ); + + Ok(()) + } +} diff --git a/src-tauri/src/ebb_tide_manager/src/tide_scheduler.rs b/src-tauri/src/ebb_tide_manager/src/tide_scheduler.rs new file mode 100644 index 00000000..87ac50f9 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/src/tide_scheduler.rs @@ -0,0 +1,234 @@ +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::broadcast; +use tokio::time::{interval, Instant}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TideSchedulerError { + #[error("Scheduler is already running")] + AlreadyRunning, + #[error("Scheduler is not running")] + NotRunning, + #[error("Invalid interval: {0}")] + InvalidInterval(String), +} + +pub type Result = std::result::Result; + +/// Events emitted by the TideScheduler +#[derive(Debug, Clone)] +pub enum TideSchedulerEvent { + /// Periodic check event - fired immediately on start and then at intervals + Check { timestamp: Instant }, +} + +/// TideScheduler handles periodic event emission for tide lifecycle management +pub struct TideScheduler { + interval_duration: Duration, + sender: broadcast::Sender, + is_running: Arc, +} + +impl TideScheduler { + /// Create a new TideScheduler with the specified interval in seconds + pub fn new(interval_seconds: u64) -> Result { + if interval_seconds == 0 { + return Err(TideSchedulerError::InvalidInterval( + "Interval must be greater than 0".to_string(), + )); + } + + let (sender, _) = broadcast::channel(100); // Buffer for 100 events + + Ok(Self { + interval_duration: Duration::from_secs(interval_seconds), + sender, + is_running: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }) + } + + /// Subscribe to scheduler events - returns a receiver + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + /// Start the scheduler - fires immediately and then at intervals + pub async fn start(&self) -> Result<()> { + if self.is_running.load(std::sync::atomic::Ordering::SeqCst) { + return Err(TideSchedulerError::AlreadyRunning); + } + + self.is_running.store(true, std::sync::atomic::Ordering::SeqCst); + + // Fire immediate event + let immediate_event = TideSchedulerEvent::Check { + timestamp: Instant::now(), + }; + + if let Err(_) = self.sender.send(immediate_event) { + // No subscribers yet, which is fine + } + + // Start interval timer + let mut timer = interval(self.interval_duration); + let sender = self.sender.clone(); + let is_running = self.is_running.clone(); + + tokio::spawn(async move { + while is_running.load(std::sync::atomic::Ordering::SeqCst) { + timer.tick().await; + + let event = TideSchedulerEvent::Check { + timestamp: Instant::now(), + }; + + if let Err(_) = sender.send(event) { + // All receivers dropped, continue running + } + } + }); + + Ok(()) + } + + /// Stop the scheduler + pub fn stop(&self) -> Result<()> { + if !self.is_running.load(std::sync::atomic::Ordering::SeqCst) { + return Err(TideSchedulerError::NotRunning); + } + + self.is_running.store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + /// Check if the scheduler is currently running + pub fn is_running(&self) -> bool { + self.is_running.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Get the current interval duration + pub fn interval(&self) -> Duration { + self.interval_duration + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::timeout; + + #[tokio::test] + async fn test_scheduler_creation() -> Result<()> { + let scheduler = TideScheduler::new(60)?; + assert_eq!(scheduler.interval(), Duration::from_secs(60)); + assert!(!scheduler.is_running()); + Ok(()) + } + + #[tokio::test] + async fn test_invalid_interval() { + let result = TideScheduler::new(0); + assert!(matches!(result, Err(TideSchedulerError::InvalidInterval(_)))); + } + + #[tokio::test] + async fn test_subscription() -> Result<()> { + let scheduler = TideScheduler::new(1)?; + let receiver = scheduler.subscribe(); + + // Receiver should be created successfully + assert!(receiver.len() == 0); + Ok(()) + } + + #[tokio::test] + async fn test_start_and_immediate_event() -> Result<()> { + let scheduler = TideScheduler::new(60)?; + let mut receiver = scheduler.subscribe(); + + scheduler.start().await?; + assert!(scheduler.is_running()); + + // Should receive immediate event + let event = timeout(Duration::from_millis(100), receiver.recv()).await; + assert!(event.is_ok()); + + if let Ok(Ok(TideSchedulerEvent::Check { timestamp: _ })) = event { + // Success - received the immediate check event + } else { + panic!("Expected immediate Check event"); + } + + scheduler.stop()?; + Ok(()) + } + + #[tokio::test] + async fn test_interval_events() -> Result<()> { + let scheduler = TideScheduler::new(1)?; // 1 second interval for testing + let mut receiver = scheduler.subscribe(); + + scheduler.start().await?; + + // Should receive immediate event + let immediate = timeout(Duration::from_millis(100), receiver.recv()).await; + assert!(immediate.is_ok()); + + // Should receive interval event after ~1 second + let interval_event = timeout(Duration::from_millis(1200), receiver.recv()).await; + assert!(interval_event.is_ok()); + + scheduler.stop()?; + Ok(()) + } + + #[tokio::test] + async fn test_stop_scheduler() -> Result<()> { + let scheduler = TideScheduler::new(60)?; + + scheduler.start().await?; + assert!(scheduler.is_running()); + + scheduler.stop()?; + assert!(!scheduler.is_running()); + + // Stopping again should return error + assert!(matches!(scheduler.stop(), Err(TideSchedulerError::NotRunning))); + + Ok(()) + } + + #[tokio::test] + async fn test_double_start() -> Result<()> { + let scheduler = TideScheduler::new(60)?; + + scheduler.start().await?; + + // Starting again should return error + let result = scheduler.start().await; + assert!(matches!(result, Err(TideSchedulerError::AlreadyRunning))); + + scheduler.stop()?; + Ok(()) + } + + #[tokio::test] + async fn test_multiple_subscribers() -> Result<()> { + let scheduler = TideScheduler::new(60)?; + let mut receiver1 = scheduler.subscribe(); + let mut receiver2 = scheduler.subscribe(); + + scheduler.start().await?; + + // Both receivers should get the immediate event + let event1 = timeout(Duration::from_millis(100), receiver1.recv()).await; + let event2 = timeout(Duration::from_millis(100), receiver2.recv()).await; + + assert!(event1.is_ok()); + assert!(event2.is_ok()); + + scheduler.stop()?; + Ok(()) + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_tide_manager/src/tide_service.rs b/src-tauri/src/ebb_tide_manager/src/tide_service.rs new file mode 100644 index 00000000..3fd50a16 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/src/tide_service.rs @@ -0,0 +1,875 @@ +use ebb_db::{ + db::{ + models::{tide::Tide, tide_template::TideTemplate}, + tide_repo::TideRepo, + tide_template_repo::TideTemplateRepo, + }, + db_manager::{self, DbManager}, +}; +use std::sync::Arc; +use thiserror::Error; +use time::OffsetDateTime; + +#[derive(Error, Debug)] +pub enum TideServiceError { + #[error("Database error: {0}")] + Database(#[from] Box), + #[error("Template not found: {template_id}")] + TemplateNotFound { template_id: String }, + #[error("Tide not found: {tide_id}")] + TideNotFound { tide_id: String }, + #[error("Invalid operation: {message}")] + InvalidOperation { message: String }, +} + +pub type Result = std::result::Result; + +/// TideService handles CRUD operations and basic queries for tides and templates +/// This is the data access layer for tide-related operations +pub struct TideService { + tide_repo: TideRepo, + tide_template_repo: TideTemplateRepo, + _db_manager: Arc, // Keep reference to ensure connection pool stays alive +} + +impl TideService { + pub async fn new() -> Result { + let db_manager = db_manager::DbManager::get_shared_ebb() + .await + .map_err(|e| TideServiceError::Database(Box::new(e)))?; + + Ok(Self { + tide_repo: TideRepo::new(db_manager.pool.clone()), + tide_template_repo: TideTemplateRepo::new(db_manager.pool.clone()), + _db_manager: db_manager, + }) + } + + pub fn new_with_manager(db_manager: Arc) -> Self { + Self { + tide_repo: TideRepo::new(db_manager.pool.clone()), + tide_template_repo: TideTemplateRepo::new(db_manager.pool.clone()), + _db_manager: db_manager, + } + } + + pub async fn create_tide_from_template( + &self, + template_id: &str, + start_time: Option, + ) -> Result { + let template = self + .tide_template_repo + .get_tide_template(template_id) + .await? + .ok_or_else(|| TideServiceError::TemplateNotFound { + template_id: template_id.to_string(), + })?; + + let start = start_time.unwrap_or_else(OffsetDateTime::now_utc); + let tide = Tide::from_template(&template, start); + + self.tide_repo.create_tide(&tide).await?; + + Ok(tide) + } + + pub async fn get_tide(&self, tide_id: &str) -> Result> { + let tide = self.tide_repo.get_tide(tide_id).await?; + Ok(tide) + } + + pub async fn get_all_templates(&self) -> Result> { + let templates = self.tide_template_repo.get_all_tide_templates().await?; + Ok(templates) + } + + pub async fn create_template(&self, template: &TideTemplate) -> Result<()> { + self.tide_template_repo + .create_tide_template(template) + .await?; + Ok(()) + } + + /// Get a specific template by ID + pub async fn get_template(&self, template_id: &str) -> Result> { + let template = self + .tide_template_repo + .get_tide_template(template_id) + .await?; + Ok(template) + } + + pub async fn update_template(&self, template: &TideTemplate) -> Result<()> { + self.tide_template_repo + .update_tide_template(template) + .await?; + Ok(()) + } + + pub async fn delete_template(&self, template_id: &str) -> Result<()> { + self.tide_template_repo + .delete_tide_template(template_id) + .await?; + Ok(()) + } + + pub async fn update_tide_progress(&self, tide_id: &str, actual_amount: f64) -> Result<()> { + self.tide_repo + .update_actual_amount(tide_id, actual_amount) + .await?; + Ok(()) + } + + pub async fn complete_tide(&self, tide_id: &str) -> Result<()> { + self.tide_repo.complete_tide(tide_id).await?; + Ok(()) + } + + pub async fn get_tides_by_template(&self, template_id: &str) -> Result> { + let tides = self.tide_repo.get_tides_by_template(template_id).await?; + Ok(tides) + } + + pub async fn get_tides_in_date_range( + &self, + start: OffsetDateTime, + end: OffsetDateTime, + ) -> Result> { + let tides = self.tide_repo.get_tides_in_date_range(start, end).await?; + Ok(tides) + } + + pub async fn get_all_tides(&self) -> Result> { + let tides = self.tide_repo.get_all_tides().await?; + Ok(tides) + } + + /// Get or create active tides for the current period based on templates + /// This method ensures that all templates have appropriate active tides for the evaluation time + /// Currently only creates tides for the current evaluation time (no backfill) + pub async fn get_or_create_active_tides_for_period( + &self, + evaluation_time: OffsetDateTime, + ) -> Result> { + // Get all templates and active tides (2 efficient queries) + let templates = self.get_all_templates().await?; + let mut active_tides = self.tide_repo.get_active_tides_at(evaluation_time).await?; + + println!("Active tides: {:?}", active_tides); + // Create a set of template IDs that already have active tides + let active_template_ids: std::collections::HashSet = active_tides + .iter() + .map(|tide| tide.tide_template_id.clone()) + .collect(); + + println!("Active template IDs: {:?}", active_template_ids); + + // Find templates that don't have active tides + let templates_needing_evaluation: Vec<&TideTemplate> = templates + .iter() + .filter(|template| !active_template_ids.contains(&template.id)) + .collect(); + + println!( + "Templates needing evaluation: {:?}", + templates_needing_evaluation + ); + + // For each template without an active tide, check if we should create one + for template in templates_needing_evaluation { + if self.should_create_tide_now(template, evaluation_time) { + // Calculate the appropriate start time based on tide frequency + let tide_start_time = self.calculate_tide_start_time(template, evaluation_time); + println!("Creating tide for template: {:?}", template); + println!("Tide start time: {:?}", tide_start_time); + let new_tide = self + .create_tide_from_template(&template.id, Some(tide_start_time)) + .await?; + println!("New tide: {:?}", new_tide); + active_tides.push(new_tide); + } + } + + Ok(active_tides) + } + + /// Determine if we should create a new tide for a template at the given time + fn should_create_tide_now( + &self, + template: &TideTemplate, + evaluation_time: OffsetDateTime, + ) -> bool { + match template.tide_frequency.as_str() { + "indefinite" => true, // Always create if no active tide exists + "daily" => { + // Only create if evaluation day matches the template's day_of_week pattern + let current_weekday = evaluation_time.weekday().number_days_from_sunday() as u8; + let allowed_days = template.get_days_of_week(); + allowed_days.contains(¤t_weekday) + } + "weekly" => true, // Always create if no active tide exists + "monthly" => true, // Always create if no active tide exists + _ => false, // Unknown frequency + } + } + + /// Calculate the appropriate start time for a new tide based on the template frequency + fn calculate_tide_start_time( + &self, + template: &TideTemplate, + evaluation_time: OffsetDateTime, + ) -> OffsetDateTime { + use crate::time_helpers::{get_day_start, get_month_start, get_week_start}; + + match template.tide_frequency.as_str() { + "daily" => get_day_start(evaluation_time), // Start of the current day + "weekly" => get_week_start(evaluation_time), // Start of the current week + "monthly" => get_month_start(evaluation_time), // Start of the current month + "indefinite" => get_day_start(evaluation_time), // Default to start of day for indefinite + _ => evaluation_time, // Fallback to current time for unknown frequencies + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + use time::macros::datetime; + + // NOTE: The database migrations create 2 default templates that affect test expectations: + // 1. 'default-daily-template' - daily, weekdays only (Mon-Fri), creating, 180.0 goal + // 2. 'default-weekly-template' - weekly, all days, learning, 600.0 goal + // Tests running on Monday (evaluation_time) will create tides for both default templates, + // so expected counts should account for these 2 additional tides. + + async fn create_test_db_manager() -> Arc { + use ebb_db::migrations::get_migrations; + + // Create in-memory database + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + + // Set WAL mode + sqlx::query("PRAGMA journal_mode=WAL;") + .execute(&pool) + .await + .unwrap(); + + // Run migrations manually + let migrations = get_migrations(); + for migration in migrations { + sqlx::query(&migration.sql).execute(&pool).await.unwrap(); + } + + Arc::new(DbManager { pool }) + } + + #[tokio::test] + async fn test_tide_service_creation() -> Result<()> { + let db_manager = create_test_db_manager().await; + let _tide_service = TideService::new_with_manager(db_manager); + + Ok(()) + } + + #[tokio::test] + async fn test_create_tide_from_template() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // First create a template + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ); + tide_service.create_template(&template).await?; + + // Now create a tide from the template + let tide = tide_service + .create_tide_from_template(&template.id, None) + .await?; + + // Verify the tide was created correctly + assert_eq!(tide.tide_template_id, template.id); + assert_eq!(tide.metrics_type, "creating"); + assert_eq!(tide.tide_frequency, "daily"); + assert_eq!(tide.goal_amount, 100.0); + assert_eq!(tide.actual_amount, 0.0); + assert!(tide.end.is_some()); // Should have system-generated end time + assert!(tide.completed_at.is_none()); // Should not be completed yet + + Ok(()) + } + + #[tokio::test] + async fn test_complete_tide() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // Create a template and tide + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ); + tide_service.create_template(&template).await?; + + let tide = tide_service + .create_tide_from_template(&template.id, None) + .await?; + + // Complete the tide + tide_service.complete_tide(&tide.id).await?; + + // Verify it's completed + let completed_tide = tide_service.get_tide(&tide.id).await?; + assert!(completed_tide.is_some()); + let completed_tide = completed_tide.unwrap(); + assert!(completed_tide.completed_at.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_indefinite_always_true() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let indefinite_template = TideTemplate::new( + "project".to_string(), + "indefinite".to_string(), + 1000.0, + first_tide, + None, + ); + + let test_time = datetime!(2025-01-06 10:00 UTC); // Monday + assert!(tide_service.should_create_tide_now(&indefinite_template, test_time)); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_weekly_always_true() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let weekly_template = TideTemplate::new( + "learning".to_string(), + "weekly".to_string(), + 500.0, + first_tide, + None, + ); + + let test_time = datetime!(2025-01-06 10:00 UTC); // Monday + assert!(tide_service.should_create_tide_now(&weekly_template, test_time)); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_monthly_always_true() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let monthly_template = TideTemplate::new( + "fitness".to_string(), + "monthly".to_string(), + 2000.0, + first_tide, + None, + ); + + let test_time = datetime!(2025-01-06 10:00 UTC); // Monday + assert!(tide_service.should_create_tide_now(&monthly_template, test_time)); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_daily_weekdays_only() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let weekday_template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,2,3,4,5".to_string()), // Monday through Friday + ); + + // Test Monday (1) - should be true + let monday = datetime!(2025-01-06 10:00 UTC); // This is a Monday + assert!(tide_service.should_create_tide_now(&weekday_template, monday)); + + // Test Tuesday (2) - should be true + let tuesday = datetime!(2025-01-07 10:00 UTC); // This is a Tuesday + assert!(tide_service.should_create_tide_now(&weekday_template, tuesday)); + + // Test Friday (5) - should be true + let friday = datetime!(2025-01-03 10:00 UTC); // This is a Friday + assert!(tide_service.should_create_tide_now(&weekday_template, friday)); + + // Test Sunday (0) - should be false + let sunday = datetime!(2025-01-05 10:00 UTC); // This is a Sunday + assert!(!tide_service.should_create_tide_now(&weekday_template, sunday)); + + // Test Saturday (6) - should be false + let saturday = datetime!(2025-01-04 10:00 UTC); // This is a Saturday + assert!(!tide_service.should_create_tide_now(&weekday_template, saturday)); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_daily_all_days() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let all_days_template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, // All days (default) + ); + + // Test various days - all should be true + let monday = datetime!(2025-01-06 10:00 UTC); // Monday + assert!(tide_service.should_create_tide_now(&all_days_template, monday)); + + let sunday = datetime!(2025-01-05 10:00 UTC); // Sunday + assert!(tide_service.should_create_tide_now(&all_days_template, sunday)); + + let saturday = datetime!(2025-01-04 10:00 UTC); // Saturday + assert!(tide_service.should_create_tide_now(&all_days_template, saturday)); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_daily_specific_days() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let specific_days_template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,3,5".to_string()), // Monday, Wednesday, Friday + ); + + // Test Monday (1) - should be true + let monday = datetime!(2025-01-06 10:00 UTC); + assert!(tide_service.should_create_tide_now(&specific_days_template, monday)); + + // Test Wednesday (3) - should be true + let wednesday = datetime!(2025-01-08 10:00 UTC); + assert!(tide_service.should_create_tide_now(&specific_days_template, wednesday)); + + // Test Friday (5) - should be true + let friday = datetime!(2025-01-03 10:00 UTC); + assert!(tide_service.should_create_tide_now(&specific_days_template, friday)); + + // Test Tuesday (2) - should be false + let tuesday = datetime!(2025-01-07 10:00 UTC); + assert!(!tide_service.should_create_tide_now(&specific_days_template, tuesday)); + + // Test Sunday (0) - should be false + let sunday = datetime!(2025-01-05 10:00 UTC); + assert!(!tide_service.should_create_tide_now(&specific_days_template, sunday)); + + Ok(()) + } + + #[tokio::test] + async fn test_should_create_tide_now_unknown_frequency() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let unknown_template = TideTemplate::new( + "unknown".to_string(), + "yearly".to_string(), // Unknown frequency + 1000.0, + first_tide, + None, + ); + + let test_time = datetime!(2025-01-06 10:00 UTC); + assert!(!tide_service.should_create_tide_now(&unknown_template, test_time)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_no_templates() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let evaluation_time = datetime!(2025-01-06 10:00 UTC); // Monday + + // Should have 2 default templates: daily (weekdays) and weekly (all days) + // Both should create tides on Monday + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + assert_eq!(active_tides.len(), 2); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_indefinite_template() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // Create an indefinite template + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "project".to_string(), + "indefinite".to_string(), + 1000.0, + first_tide, + None, + ); + tide_service.create_template(&template).await?; + + let evaluation_time = datetime!(2025-01-06 10:00 UTC); // Monday + + // Should create a tide since indefinite templates always get one + // Plus 2 default templates (daily weekdays + weekly all days) = 3 total + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + assert_eq!(active_tides.len(), 3); + assert_eq!(active_tides[0].tide_template_id, template.id); + assert_eq!(active_tides[0].tide_frequency, "indefinite"); + + // Call again - should not create another tide (should return existing active tide) + let active_tides_2 = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + assert_eq!(active_tides_2.len(), 3); + assert_eq!(active_tides_2[0].id, active_tides[0].id); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_daily_weekdays_monday() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // Create a daily template for weekdays only (Monday-Friday: 1,2,3,4,5) + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,2,3,4,5".to_string()), // Monday through Friday + ); + tide_service.create_template(&template).await?; + + // Test on Monday (day 1) - should create tide + // Plus 2 default templates = 3 total + let monday_time = datetime!(2025-01-06 10:00 UTC); // Monday + let active_tides = tide_service + .get_or_create_active_tides_for_period(monday_time) + .await?; + assert_eq!(active_tides.len(), 3); + assert_eq!(active_tides[0].tide_template_id, template.id); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_daily_weekdays_sunday() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // Create a daily template for weekdays only (Monday-Friday: 1,2,3,4,5) + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + Some("1,2,3,4,5".to_string()), // Monday through Friday + ); + tide_service.create_template(&template).await?; + + // Test on Sunday (day 0) - should NOT create tide for weekdays-only template + // But default weekly template should create = 1 total tide + let sunday_time = datetime!(2025-01-05 10:00 UTC); // Sunday + let active_tides = tide_service + .get_or_create_active_tides_for_period(sunday_time) + .await?; + assert_eq!(active_tides.len(), 1); // Only weekly template creates on Sunday + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_weekly_template() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // Create a weekly template + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "learning".to_string(), + "weekly".to_string(), + 500.0, + first_tide, + None, + ); + tide_service.create_template(&template).await?; + + let evaluation_time = datetime!(2025-01-06 10:00 UTC); // Monday + + // Should create a tide since weekly templates always get one if none exists + // Plus 2 default templates = 3 total + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + assert_eq!(active_tides.len(), 3); + assert_eq!(active_tides[0].tide_template_id, template.id); + assert_eq!(active_tides[0].tide_frequency, "weekly"); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_multiple_templates() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let evaluation_time = datetime!(2025-01-06 10:00 UTC); // Monday + + // Create multiple templates + let indefinite_template = TideTemplate::new( + "project".to_string(), + "indefinite".to_string(), + 1000.0, + first_tide, + None, + ); + let weekly_template = TideTemplate::new( + "learning".to_string(), + "weekly".to_string(), + 500.0, + first_tide, + None, + ); + let daily_template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, // All days + ); + + tide_service.create_template(&indefinite_template).await?; + tide_service.create_template(&weekly_template).await?; + tide_service.create_template(&daily_template).await?; + + // Should create tides for all templates (3 custom + 2 default = 5 total) + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + assert_eq!(active_tides.len(), 5); + + // Verify all template IDs are represented + let template_ids: std::collections::HashSet = active_tides + .iter() + .map(|tide| tide.tide_template_id.clone()) + .collect(); + + assert!(template_ids.contains(&indefinite_template.id)); + assert!(template_ids.contains(&weekly_template.id)); + assert!(template_ids.contains(&daily_template.id)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_existing_active_tide() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + // Create template and manually create a tide + let first_tide = datetime!(2025-01-01 0:00 UTC); + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ); + tide_service.create_template(&template).await?; + + let evaluation_time = datetime!(2025-01-06 10:00 UTC); // Monday + + // Manually create an active tide + let existing_tide = tide_service + .create_tide_from_template(&template.id, Some(evaluation_time)) + .await?; + + // Should return the existing tide, not create a new one (1 custom + 2 default = 3 total) + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + assert_eq!(active_tides.len(), 3); + assert!(active_tides.iter().any(|t| t.id == existing_tide.id)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_or_create_active_tides_for_period_mixed_scenario() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager); + + let first_tide = datetime!(2025-01-01 0:00 UTC); + let evaluation_time = datetime!(2025-01-06 10:00 UTC); // Monday + + // Create templates with different scenarios + let template_with_active = TideTemplate::new( + "existing".to_string(), + "daily".to_string(), + 100.0, + first_tide, + None, + ); + let template_should_create = TideTemplate::new( + "should_create".to_string(), + "weekly".to_string(), + 200.0, + first_tide, + None, + ); + let template_weekdays_only = TideTemplate::new( + "weekdays".to_string(), + "daily".to_string(), + 300.0, + first_tide, + Some("1,2,3,4,5".to_string()), // Weekdays only + ); + + tide_service.create_template(&template_with_active).await?; + tide_service + .create_template(&template_should_create) + .await?; + tide_service + .create_template(&template_weekdays_only) + .await?; + + // Pre-create a tide for the first template + let existing_tide = tide_service + .create_tide_from_template(&template_with_active.id, Some(evaluation_time)) + .await?; + + // Run the function + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + + // Should have 5 tides: 1 existing + 2 newly created + 2 default templates + assert_eq!(active_tides.len(), 5); + + // Verify existing tide is included + assert!(active_tides.iter().any(|t| t.id == existing_tide.id)); + + // Verify new tides were created for the other templates + let new_template_ids: std::collections::HashSet = active_tides + .iter() + .filter(|t| t.id != existing_tide.id) + .map(|t| t.tide_template_id.clone()) + .collect(); + + assert!(new_template_ids.contains(&template_should_create.id)); + assert!(new_template_ids.contains(&template_weekdays_only.id)); + + Ok(()) + } + + #[tokio::test] + async fn test_tide_starts_at_beginning_of_day() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_service = TideService::new_with_manager(db_manager.clone()); + + // Create a daily template + let template = TideTemplate::new( + "creating".to_string(), + "daily".to_string(), + 180.0, + datetime!(2025-01-06 00:00 UTC), + Some("1,2,3,4,5".to_string()), + ); + tide_service.create_template(&template).await?; + + // Test evaluation time in the middle of the day (2:30 PM) + let evaluation_time = datetime!(2025-01-06 14:30 UTC); + + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; + + // Should create tides (including from seeded templates), find our specific one + assert!(active_tides.len() == 3); + + // Find the tide created from our template + let our_tide = active_tides + .iter() + .find(|t| t.tide_template_id == template.id) + .expect("Should find our tide"); + + // The tide should be stored in UTC but represent midnight in local timezone + assert_eq!(our_tide.start.offset(), time::UtcOffset::UTC); + + // When converted to local time, should be at midnight + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let start_local = our_tide.start.to_offset(local_offset); + assert_eq!(start_local.hour(), 0); + assert_eq!(start_local.minute(), 0); + assert_eq!(start_local.second(), 0); + + // Verify the date in local timezone matches expected date + let evaluation_local = evaluation_time.to_offset(local_offset); + assert_eq!(start_local.date(), evaluation_local.date()); + + // The tide should end at the end of the day (for daily tides) + let end_time = our_tide.end.expect("Daily tide should have an end time"); + assert_eq!(end_time.offset(), time::UtcOffset::UTC); + let end_local = end_time.to_offset(local_offset); + assert_eq!(end_local.hour(), 0); + assert_eq!(end_local.minute(), 0); + assert_eq!(end_local.second(), 0); + + Ok(()) + } +} diff --git a/src-tauri/src/ebb_tide_manager/src/time_helpers.rs b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs new file mode 100644 index 00000000..8c0a3dc0 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs @@ -0,0 +1,452 @@ +use time::OffsetDateTime; + +/// Get the start of the week (Monday at 00:00:00) for a given time in the system's local timezone +/// Returns the result as UTC time (representing local midnight converted to UTC) +/// This is used for weekly tide calculations +pub fn get_week_start(time: OffsetDateTime) -> OffsetDateTime { + // Get system's local offset + let local_offset = time::UtcOffset::current_local_offset() + .unwrap_or(time::UtcOffset::UTC); + + // Convert to local timezone + let local_time = time.to_offset(local_offset); + + let weekday = local_time.weekday().number_days_from_sunday() as i64; + let days_since_monday = if weekday == 0 { 6 } else { weekday - 1 }; + + let start_of_week_local = local_time.replace_hour(0) + .unwrap() + .replace_minute(0) + .unwrap() + .replace_second(0) + .unwrap() + .replace_nanosecond(0) + .unwrap() + - time::Duration::days(days_since_monday); + + // Convert back to UTC for storage + start_of_week_local.to_offset(time::UtcOffset::UTC) +} + +/// Get the start of the month (1st day at 00:00:00) for a given time in the system's local timezone +/// Returns the result as UTC time (representing local midnight converted to UTC) +/// This is used for monthly tide calculations +pub fn get_month_start(time: OffsetDateTime) -> OffsetDateTime { + // Get system's local offset + let local_offset = time::UtcOffset::current_local_offset() + .unwrap_or(time::UtcOffset::UTC); + + // Convert to local timezone + let local_time = time.to_offset(local_offset); + + let start_of_month_local = local_time.replace_day(1) + .unwrap() + .replace_hour(0) + .unwrap() + .replace_minute(0) + .unwrap() + .replace_second(0) + .unwrap() + .replace_nanosecond(0) + .unwrap(); + + // Convert back to UTC for storage + start_of_month_local.to_offset(time::UtcOffset::UTC) +} + +/// Get the start of the day (00:00:00) for a given time in the system's local timezone +/// Returns the result as UTC time (representing local midnight converted to UTC) +/// This is used for daily tide calculations +pub fn get_day_start(time: OffsetDateTime) -> OffsetDateTime { + // Get system's local offset + let local_offset = time::UtcOffset::current_local_offset() + .unwrap_or(time::UtcOffset::UTC); // Fallback to UTC if local offset unavailable + + // Convert to local timezone + let local_time = time.to_offset(local_offset); + + // Get start of day in local timezone + let start_of_day_local = local_time + .replace_hour(0) + .unwrap() + .replace_minute(0) + .unwrap() + .replace_second(0) + .unwrap() + .replace_nanosecond(0) + .unwrap(); + + // Convert back to UTC for storage + start_of_day_local.to_offset(time::UtcOffset::UTC) +} + +#[cfg(test)] +mod tests { + use super::*; + use time::macros::datetime; + + #[test] + fn test_get_week_start_monday() { + // Monday should return the same day at 00:00:00 in local time, returned as UTC + let monday = datetime!(2025-01-06 15:30:45 UTC); // Monday afternoon + let week_start = get_week_start(monday); + + assert_eq!(week_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let week_start_local = week_start.to_offset(local_offset); + + assert_eq!(week_start_local.hour(), 0); + assert_eq!(week_start_local.minute(), 0); + assert_eq!(week_start_local.second(), 0); + assert_eq!(week_start_local.nanosecond(), 0); + assert_eq!(week_start_local.weekday(), time::Weekday::Monday); + } + + #[test] + fn test_get_week_start_tuesday() { + // Tuesday should return previous Monday at 00:00:00 in local time, returned as UTC + let tuesday = datetime!(2025-01-07 10:15:30 UTC); // Tuesday morning + let week_start = get_week_start(tuesday); + + assert_eq!(week_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let week_start_local = week_start.to_offset(local_offset); + + assert_eq!(week_start_local.hour(), 0); + assert_eq!(week_start_local.minute(), 0); + assert_eq!(week_start_local.second(), 0); + assert_eq!(week_start_local.nanosecond(), 0); + assert_eq!(week_start_local.weekday(), time::Weekday::Monday); + } + + #[test] + fn test_get_week_start_friday() { + // Friday should return Monday of the same week at 00:00:00 in local time, returned as UTC + let friday = datetime!(2025-01-03 18:45:12 UTC); // Friday evening + let week_start = get_week_start(friday); + + assert_eq!(week_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let week_start_local = week_start.to_offset(local_offset); + + assert_eq!(week_start_local.hour(), 0); + assert_eq!(week_start_local.minute(), 0); + assert_eq!(week_start_local.second(), 0); + assert_eq!(week_start_local.nanosecond(), 0); + assert_eq!(week_start_local.weekday(), time::Weekday::Monday); + } + + #[test] + fn test_get_week_start_sunday() { + // Sunday should return previous Monday at 00:00:00 in local time, returned as UTC + let sunday = datetime!(2025-01-05 12:00:00 UTC); // Sunday noon + let week_start = get_week_start(sunday); + + assert_eq!(week_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let week_start_local = week_start.to_offset(local_offset); + + assert_eq!(week_start_local.hour(), 0); + assert_eq!(week_start_local.minute(), 0); + assert_eq!(week_start_local.second(), 0); + assert_eq!(week_start_local.nanosecond(), 0); + assert_eq!(week_start_local.weekday(), time::Weekday::Monday); + } + + #[test] + fn test_get_week_start_saturday() { + // Saturday should return Monday of the same week at 00:00:00 in local time, returned as UTC + let saturday = datetime!(2025-01-04 08:20:15 UTC); // Saturday morning + let week_start = get_week_start(saturday); + + assert_eq!(week_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let week_start_local = week_start.to_offset(local_offset); + + assert_eq!(week_start_local.hour(), 0); + assert_eq!(week_start_local.minute(), 0); + assert_eq!(week_start_local.second(), 0); + assert_eq!(week_start_local.nanosecond(), 0); + assert_eq!(week_start_local.weekday(), time::Weekday::Monday); + } + + #[test] + fn test_get_week_start_already_midnight() { + // Test with a time already at midnight + let wednesday_midnight = datetime!(2025-01-08 00:00:00 UTC); // Wednesday at midnight + let week_start = get_week_start(wednesday_midnight); + + assert_eq!(week_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let week_start_local = week_start.to_offset(local_offset); + + assert_eq!(week_start_local.hour(), 0); + assert_eq!(week_start_local.minute(), 0); + assert_eq!(week_start_local.second(), 0); + assert_eq!(week_start_local.nanosecond(), 0); + assert_eq!(week_start_local.weekday(), time::Weekday::Monday); + } + + #[test] + fn test_get_month_start_first_day() { + // First day of month should return the same day at 00:00:00 in local time, returned as UTC + let first_day = datetime!(2025-01-01 15:30:45 UTC); // January 1st afternoon + let month_start = get_month_start(first_day); + + assert_eq!(month_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let month_start_local = month_start.to_offset(local_offset); + let first_day_local = first_day.to_offset(local_offset); + + assert_eq!(month_start_local.hour(), 0); + assert_eq!(month_start_local.minute(), 0); + assert_eq!(month_start_local.second(), 0); + assert_eq!(month_start_local.nanosecond(), 0); + assert_eq!(month_start_local.day(), 1); + assert_eq!(month_start_local.month(), first_day_local.month()); + assert_eq!(month_start_local.year(), first_day_local.year()); + } + + #[test] + fn test_get_month_start_middle_of_month() { + // Middle of month should return first day at 00:00:00 in local time, returned as UTC + let mid_month = datetime!(2025-01-15 10:25:30 UTC); // January 15th morning + let month_start = get_month_start(mid_month); + + assert_eq!(month_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let month_start_local = month_start.to_offset(local_offset); + let mid_month_local = mid_month.to_offset(local_offset); + + assert_eq!(month_start_local.hour(), 0); + assert_eq!(month_start_local.minute(), 0); + assert_eq!(month_start_local.second(), 0); + assert_eq!(month_start_local.nanosecond(), 0); + assert_eq!(month_start_local.day(), 1); + assert_eq!(month_start_local.month(), mid_month_local.month()); + assert_eq!(month_start_local.year(), mid_month_local.year()); + } + + #[test] + fn test_get_month_start_end_of_month() { + // End of month should return first day at 00:00:00 in local time, returned as UTC + let end_month = datetime!(2025-01-31 23:59:59 UTC); // January 31st end of day + let month_start = get_month_start(end_month); + + assert_eq!(month_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let month_start_local = month_start.to_offset(local_offset); + let end_month_local = end_month.to_offset(local_offset); + + assert_eq!(month_start_local.hour(), 0); + assert_eq!(month_start_local.minute(), 0); + assert_eq!(month_start_local.second(), 0); + assert_eq!(month_start_local.nanosecond(), 0); + assert_eq!(month_start_local.day(), 1); + assert_eq!(month_start_local.month(), end_month_local.month()); + assert_eq!(month_start_local.year(), end_month_local.year()); + } + + #[test] + fn test_get_month_start_february() { + // Test with February (shorter month) + let february = datetime!(2025-02-20 14:45:12 UTC); // February 20th + let month_start = get_month_start(february); + + assert_eq!(month_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let month_start_local = month_start.to_offset(local_offset); + let february_local = february.to_offset(local_offset); + + assert_eq!(month_start_local.hour(), 0); + assert_eq!(month_start_local.minute(), 0); + assert_eq!(month_start_local.second(), 0); + assert_eq!(month_start_local.nanosecond(), 0); + assert_eq!(month_start_local.day(), 1); + assert_eq!(month_start_local.month(), february_local.month()); + assert_eq!(month_start_local.year(), february_local.year()); + } + + #[test] + fn test_get_month_start_december() { + // Test with December (end of year) + let december = datetime!(2025-12-25 08:15:30 UTC); // December 25th + let month_start = get_month_start(december); + + assert_eq!(month_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let month_start_local = month_start.to_offset(local_offset); + let december_local = december.to_offset(local_offset); + + assert_eq!(month_start_local.hour(), 0); + assert_eq!(month_start_local.minute(), 0); + assert_eq!(month_start_local.second(), 0); + assert_eq!(month_start_local.nanosecond(), 0); + assert_eq!(month_start_local.day(), 1); + assert_eq!(month_start_local.month(), december_local.month()); + assert_eq!(month_start_local.year(), december_local.year()); + } + + #[test] + fn test_get_month_start_already_first_midnight() { + // Test with a time already at first of month at midnight + let first_midnight = datetime!(2025-06-01 00:00:00 UTC); // June 1st at midnight + let month_start = get_month_start(first_midnight); + + assert_eq!(month_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let month_start_local = month_start.to_offset(local_offset); + let first_midnight_local = first_midnight.to_offset(local_offset); + + assert_eq!(month_start_local.hour(), 0); + assert_eq!(month_start_local.minute(), 0); + assert_eq!(month_start_local.second(), 0); + assert_eq!(month_start_local.nanosecond(), 0); + assert_eq!(month_start_local.day(), 1); + assert_eq!(month_start_local.month(), first_midnight_local.month()); + assert_eq!(month_start_local.year(), first_midnight_local.year()); + } + + #[test] + fn test_get_day_start_morning() { + // Morning time should return same day at 00:00:00 in local time, returned as UTC + let morning = datetime!(2025-01-15 08:30:45 UTC); // January 15th morning + let day_start = get_day_start(morning); + + // Result should be in UTC + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + + // When converted to local time, should be at midnight + let local_offset = time::UtcOffset::current_local_offset() + .unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + // Verify it's the same day in local timezone + let morning_local = morning.to_offset(local_offset); + assert_eq!(day_start_local.date(), morning_local.date()); + } + + #[test] + fn test_get_day_start_afternoon() { + // Afternoon time should return same day at 00:00:00 in local time, returned as UTC + let afternoon = datetime!(2025-01-15 14:25:12 UTC); // January 15th afternoon + let day_start = get_day_start(afternoon); + + // Result should be in UTC + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + + // When converted to local time, should be at midnight + let local_offset = time::UtcOffset::current_local_offset() + .unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + // Verify it's the same day in local timezone + let afternoon_local = afternoon.to_offset(local_offset); + assert_eq!(day_start_local.date(), afternoon_local.date()); + } + + #[test] + fn test_get_day_start_evening() { + // Evening time should return same day at 00:00:00 in local time, returned as UTC + let evening = datetime!(2025-01-15 21:45:30 UTC); // January 15th evening + let day_start = get_day_start(evening); + + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + let evening_local = evening.to_offset(local_offset); + assert_eq!(day_start_local.date(), evening_local.date()); + } + + #[test] + fn test_get_day_start_end_of_day() { + // End of day should return same day at 00:00:00 in local time, returned as UTC + let end_of_day = datetime!(2025-01-15 23:59:59 UTC); // January 15th end of day + let day_start = get_day_start(end_of_day); + + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + let end_of_day_local = end_of_day.to_offset(local_offset); + assert_eq!(day_start_local.date(), end_of_day_local.date()); + } + + #[test] + fn test_get_day_start_already_midnight() { + // Time already at midnight should return same time in local time, returned as UTC + let midnight = datetime!(2025-01-15 00:00:00 UTC); // January 15th at midnight + let day_start = get_day_start(midnight); + + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + let midnight_local = midnight.to_offset(local_offset); + assert_eq!(day_start_local.date(), midnight_local.date()); + } + + #[test] + fn test_get_day_start_with_microseconds() { + // Time with microseconds should be normalized to 00:00:00 in local time, returned as UTC + let precise_time = datetime!(2025-01-15 12:34:56.789123 UTC); // January 15th with microseconds + let day_start = get_day_start(precise_time); + + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + let precise_time_local = precise_time.to_offset(local_offset); + assert_eq!(day_start_local.date(), precise_time_local.date()); + } + + #[test] + fn test_get_day_start_leap_year() { + // Test with leap year date (February 29th) + let leap_day = datetime!(2024-02-29 16:20:10 UTC); // February 29th (leap year) + let day_start = get_day_start(leap_day); + + assert_eq!(day_start.offset(), time::UtcOffset::UTC); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let day_start_local = day_start.to_offset(local_offset); + + assert_eq!(day_start_local.hour(), 0); + assert_eq!(day_start_local.minute(), 0); + assert_eq!(day_start_local.second(), 0); + assert_eq!(day_start_local.nanosecond(), 0); + + let leap_day_local = leap_day.to_offset(local_offset); + assert_eq!(day_start_local.date(), leap_day_local.date()); + } +} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9fdd7e67..10ebfda2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,4 +1,7 @@ use ebb_db::{db_manager, migrations, services::device_service::DeviceService, shared_sql_plugin}; +use ebb_tide_manager::TideManager; +use once_cell::sync::OnceCell; +use std::sync::Arc; use tauri::Manager; use tokio; @@ -11,6 +14,9 @@ mod window; use autostart::{change_autostart, enable_autostart}; +// Global TideManager instance +static TIDE_MANAGER: OnceCell> = OnceCell::new(); + async fn initialize_device_profile() -> Result<(), Box> { log::info!("Starting device profile initialization..."); @@ -33,6 +39,27 @@ async fn initialize_device_profile() -> Result<(), Box Result<(), Box> { + log::info!("Starting TideManager initialization..."); + + // Create TideManager with default 60-second intervals + let tide_manager = Arc::new(TideManager::new().await?); + + // Store in global static + TIDE_MANAGER.set(tide_manager.clone()).map_err(|_| { + Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to set TideManager - already initialized", + )) as Box + })?; + + // Start the tide manager + tide_manager.start().await?; + + log::info!("TideManager started successfully"); + Ok(()) +} + #[tokio::main] async fn main() -> Result<(), Box> { let _guard = sentry::init(("https://d23e3cf5027dc14dfe8128f4d35219f7@o4508951187554304.ingest.us.sentry.io/4508951212851200", sentry::ClientOptions { @@ -58,6 +85,11 @@ async fn main() -> Result<(), Box> { if let Err(e) = initialize_device_profile().await { log::error!("Failed to initialize device profile: {}", e); } + + // Initialize TideManager after device profile is set up + if let Err(e) = initialize_tide_manager().await { + log::error!("Failed to initialize TideManager: {}", e); + } } else { log::warn!("Migration notification channel closed without receiving signal"); } @@ -89,7 +121,7 @@ async fn main() -> Result<(), Box> { .plugin( tauri_plugin_log::Builder::new() .clear_targets() - .level(log::LevelFilter::Info) + .level(log::LevelFilter::Debug) .targets([ tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: Some("logs".to_string()), @@ -153,6 +185,17 @@ async fn main() -> Result<(), Box> { window.set_focus().unwrap(); } } + tauri::RunEvent::ExitRequested { .. } => { + // Stop TideManager on app shutdown + if let Some(tide_manager) = TIDE_MANAGER.get() { + log::info!("Stopping TideManager..."); + if let Err(e) = tide_manager.stop() { + log::error!("Error stopping TideManager: {}", e); + } else { + log::info!("TideManager stopped successfully"); + } + } + } _ => {} }, ); diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts new file mode 100644 index 00000000..69711209 --- /dev/null +++ b/src/api/ebbApi/tideApi.ts @@ -0,0 +1,258 @@ +import { QueryResult } from '@tauri-apps/plugin-sql' +import { + Tide, + TideTemplate, + TideRepo, + TideSchema, + TideTemplateSchema, + TideWithTemplate +} from '@/db/ebb/tideRepo' +import { GraphableTimeByHourBlock, MonitorApi } from '@/api/monitorApi/monitorApi' +import { DateTime } from 'luxon' + +export type { Tide, TideTemplate, TideWithTemplate } + +export interface TideProgress { + current: number + goal: number + isCompleted: boolean + progressPercentage: number + overflowAmount?: number +} + +export interface TideProgressData { + tide?: Tide + progress: TideProgress +} +export interface TideOverview { + daily: TideProgressData + weekly: TideProgressData +} + +// Tide Template API Functions + +const updateTideTemplate = async ( + id: string, + updates: Partial +): Promise => { + const updatedTemplate = { + ...updates, + updated_at: new Date().toISOString(), + } + return TideRepo.updateTideTemplate(id, updatedTemplate) +} + +export interface TemplateEdit { + id: string + goal_amount: number + metrics_type: string + days_of_week: number[] + tide_frequency: string +} + +const updateTideTemplates = async (editedTemplates: TemplateEdit[]): Promise => { + const updatePromises = [] + const activeTides = await getActiveTides() + + // Update all templates + for (const editedTemplate of editedTemplates) { + updatePromises.push( + updateTideTemplate(editedTemplate.id, { + goal_amount: editedTemplate.goal_amount, + day_of_week: editedTemplate.days_of_week.length > 0 + ? editedTemplate.days_of_week.join(',') + : undefined + }) + ) + + // Update any active tides using this template + const activeTidesForTemplate = activeTides.filter(tide => + tide.tide_template_id === editedTemplate.id + ) + + for (const activeTide of activeTidesForTemplate) { // update any tides that are currently active + updatePromises.push( + updateTide(activeTide.id, { + goal_amount: editedTemplate.goal_amount + }) + ) + } + } + + await Promise.all(updatePromises) +} + +const getTideTemplates = async (): Promise => { + return TideRepo.getAllTideTemplates() +} + +// Tide API Functions + +const updateTide = async ( + id: string, + updates: Partial +): Promise => { + const updatedTide = { + ...updates, + updated_at: new Date().toISOString(), + } + return TideRepo.updateTide(id, updatedTide) +} + +const getActiveTides = async (): Promise => { + return TideRepo.getActiveTides() +} + +// Business Logic Functions + +const getTideProgress = async (type = 'daily', date = new Date()): Promise => { + + const evaluationTime = date.toISOString() + console.log('getTideProgress', type, evaluationTime) + const activeTides = await TideRepo.getActiveTidesForPeriod(evaluationTime) + // Find today's daily tide for the specific metrics type + const tide = activeTides.find(tide => + tide.tide_frequency === type + ) + + if(!tide) { + let time: GraphableTimeByHourBlock[] = [] + if(type === 'daily') { + time = await MonitorApi.getTimeCreatingByDay(DateTime.fromISO(evaluationTime).startOf('day'), DateTime.fromISO(evaluationTime).endOf('day')) + } else if(type === 'weekly') { + time = await MonitorApi.getTimeCreatingByWeek(DateTime.fromISO(evaluationTime).startOf('week'), DateTime.fromISO(evaluationTime).endOf('week')) + } + const current = time.reduce((acc, curr) => acc + curr.creating, 0) + return { // no tide for the date + tide: undefined, + progress: { + current: current, + goal: 0, + isCompleted: false, + progressPercentage: 0, + overflowAmount: undefined + } + } + } + + const progress: TideProgress = { + current: tide?.actual_amount || 0, + goal: tide?.goal_amount || 0, + isCompleted: !!tide?.completed_at, + progressPercentage: Math.min((tide?.actual_amount / tide?.goal_amount) * 100, 100), + overflowAmount: tide?.actual_amount > tide?.goal_amount + ? tide?.actual_amount - tide?.goal_amount + : undefined + } + + return { + tide, + progress + } +} + +const getTideOverview = async (date = new Date()): Promise => { + const [daily, weekly] = await Promise.all([ + getTideProgress('daily', date), + getTideProgress('weekly', date) + ]) + + return { + daily, + weekly + } +} + +export interface DailyTideHistory { + date: string // ISO date + dayOfWeek: number // 0-6 + progress: TideProgress +} + +const getWeeklyDailyHistory = async (date = new Date()): Promise => { + const dateTime = DateTime.fromJSDate(date) + const startOfWeek = dateTime.startOf('week') // Sunday + + const history: DailyTideHistory[] = [] + + // Get daily tide template to know which days have goals + const templates = await TideRepo.getAllTideTemplates() + const dailyTemplate = templates.find(t => t.tide_frequency === 'daily') + + if (!dailyTemplate) { + return [] + } + + // Parse days of week from template (comma-separated: "0,1,2,3,4,5,6") + const activeDays = dailyTemplate.day_of_week + ? dailyTemplate.day_of_week.split(',').map(Number) + : [1, 2, 3, 4, 5] // Default to weekdays + + // Iterate through each day of the week + for (let i = 0; i < 7; i++) { + const currentDay = startOfWeek.plus({ days: i }) + // Luxon weekday is 1-7 (Mon-Sun), convert to 0-6 (Sun-Sat) + const dayOfWeek = currentDay.weekday === 7 ? 0 : currentDay.weekday + + // Skip if this day doesn't have a goal + if (!activeDays.includes(dayOfWeek)) { + continue + } + + const progress = await getTideProgress('daily', currentDay.toJSDate()) + + history.push({ + date: currentDay.toISODate() || '', + dayOfWeek, + progress: progress.progress + }) + } + + return history +} + +const formatTime = (minutes: number): string => { + const hours = Math.floor(minutes / 60) + const remainingMinutes = Math.round(minutes % 60) + + if (hours === 0) return `${remainingMinutes}m` + if (remainingMinutes === 0) return `${hours}h` + return `${hours}h ${remainingMinutes}m` +} + +const calculateDaysBetween = (startDate: string, endDate: string): number => { + const start = new Date(startDate) + const end = new Date(endDate) + const diffTime = Math.abs(end.getTime() - start.getTime()) + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) +} + +const isWithinTimeRange = (checkTime: string, startTime: string, endTime?: string): boolean => { + const check = new Date(checkTime) + const start = new Date(startTime) + + if (!endTime) return check >= start // No end time means indefinite + + const end = new Date(endTime) + return check >= start && check <= end +} + +export const TideApi = { + // Template operations + updateTideTemplate, + updateTideTemplates, + getTideTemplates, + + // Tide operations + updateTide, + getActiveTides, + + // Business logic + getTideOverview, + getWeeklyDailyHistory, + + // Utilities + formatTime, + calculateDaysBetween, + isWithinTimeRange, +} diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts new file mode 100644 index 00000000..28c2558b --- /dev/null +++ b/src/api/hooks/useTides.ts @@ -0,0 +1,61 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { TideApi, TemplateEdit } from '@/api/ebbApi/tideApi' + +const tideKeys = { + all: ['tides'] as const, + overview: (date?: Date) => [...tideKeys.all, 'overview', date?.toISOString()] as const, + recent: (limit?: number) => [...tideKeys.all, 'recent', limit] as const, + active: () => [...tideKeys.all, 'active'] as const, + templates: () => [...tideKeys.all, 'templates'] as const, + detail: (id: string) => [...tideKeys.all, 'detail', id] as const, + weeklyHistory: (date?: Date) => [...tideKeys.all, 'weeklyHistory', date?.toISOString()] as const, +} + +// Query Hooks + +const useGetTideOverview = (date = new Date()) => { + return useQuery({ + queryKey: tideKeys.overview(date), + queryFn: () => TideApi.getTideOverview(date), + staleTime: 30000, // 30 seconds - tides update frequently + refetchInterval: 60000, // Refetch every minute for real-time updates + }) +} + +const useGetTideTemplates = () => { + return useQuery({ + queryKey: tideKeys.templates(), + queryFn: () => TideApi.getTideTemplates(), + staleTime: 300000, // 5 minutes - templates change less frequently + }) +} + +const useGetWeeklyDailyHistory = (date = new Date()) => { + return useQuery({ + queryKey: tideKeys.weeklyHistory(date), + queryFn: () => TideApi.getWeeklyDailyHistory(date), + staleTime: 30000, // 30 seconds + refetchInterval: 60000, // Refetch every minute + }) +} + +// Mutation Hooks + +const useUpdateTideTemplates = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (editedTemplates: TemplateEdit[]) => + TideApi.updateTideTemplates(editedTemplates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: tideKeys.all }) + }, + }) +} + +export const useTides = { + useGetTideOverview, + useGetTideTemplates, + useGetWeeklyDailyHistory, + useUpdateTideTemplates, +} diff --git a/src/components/ColumnWrapper.tsx b/src/components/ColumnWrapper.tsx index e0cf1358..904ff86b 100644 --- a/src/components/ColumnWrapper.tsx +++ b/src/components/ColumnWrapper.tsx @@ -2,6 +2,7 @@ import { useDroppable } from '@dnd-kit/core' import { Card, CardContent, CardHeader, CardTitle } from './ui/card' import { DraggableAppCard } from './DraggableAppCard' import { AppsWithTime } from '../api/monitorApi/monitorApi' +import { formatTime } from '@/components/UsageSummary' type ColumnWrapperProps = { @@ -32,6 +33,9 @@ export function ColumnWrapper({ {title} + + {formatTime(categoryUsage)} + {/* min-h to prevent collapse when empty */} {apps.length === 0 && ( diff --git a/src/components/TideEditDialog.tsx b/src/components/TideEditDialog.tsx new file mode 100644 index 00000000..dd537149 --- /dev/null +++ b/src/components/TideEditDialog.tsx @@ -0,0 +1,178 @@ +import { type FC, useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { AnalyticsButton } from '@/components/ui/analytics-button' +import { useTides } from '@/api/hooks/useTides' +import { Skeleton } from '@/components/ui/skeleton' +import { TimeSelector } from '@/components/TimeSelector' +import { toast } from 'sonner' +import { TemplateEdit } from '@/api/ebbApi/tideApi' + +interface TideEditDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +interface TideTemplateItemProps { + edit: TemplateEdit + onUpdate: (templateId: string, updates: Partial) => void +} + +const TideTemplateItem: FC = ({ edit, onUpdate }) => { + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + + return ( +
+
+

+ {edit.tide_frequency} Tide +

+
+ + {/* Goal Amount */} +
+ onUpdate(edit.id, { goal_amount: minutes || 0 })} + presets={ + edit.tide_frequency === 'daily' + ? [ + { value: '60', label: '1 hour' }, + { value: '120', label: '2 hours' }, + { value: '180', label: '3 hours' }, + { value: '240', label: '4 hours' } + ] + : [ + { value: '600', label: '10 hours' }, + { value: '900', label: '15 hours' }, + { value: '1200', label: '20 hours' }, + { value: '1500', label: '25 hours' } + ] + } + /> +
+ + {/* Days of Week (only for daily templates) */} + {edit.tide_frequency === 'daily' && ( +
+ Days of Week +
+ {dayNames.map((day, index) => ( + + ))} +
+
+ )} +
+ ) +} + +export const TideEditDialog: FC = ({ + open, + onOpenChange +}) => { + const { data: templates, isLoading } = useTides.useGetTideTemplates() + const updateTemplatesMutation = useTides.useUpdateTideTemplates() + const [editedTemplates, setEditedTemplates] = useState([]) + + // Initialize edited templates when templates load + useEffect(() => { + if (templates && templates.length > 0) { + const initialEdits: TemplateEdit[] = templates.map(template => ({ + id: template.id, + goal_amount: template.goal_amount, + metrics_type: template.metrics_type, + days_of_week: template.day_of_week ? template.day_of_week.split(',').map(Number) : [1, 2, 3, 4, 5], // Default weekdays + tide_frequency: template.tide_frequency + })) + setEditedTemplates(initialEdits) + } + }, [templates]) + + const handleSave = async () => { + try { + await updateTemplatesMutation.mutateAsync(editedTemplates) + toast.success('Tides going forward successfully updated to reflect changes.') + onOpenChange(false) + } catch (error) { + console.error('Failed to save tide:', error) + toast.error('Failed to save tide. Please try again.') + } + } + + const updateTemplate = (templateId: string, updates: Partial) => { + setEditedTemplates(prev => + prev.map(t => t.id === templateId ? { ...t, ...updates } : t) + ) + } + + if (isLoading) { + return ( + + + + Edit Tide Templates + +
+ + +
+
+
+ ) + } + + return ( + + + + Edit Tides + + +
+ {editedTemplates.map(edit => ( + + ))} +
+ +
+ onOpenChange(false)} + analyticsEvent="get_pro_clicked" + > + Cancel + + + {updateTemplatesMutation.isPending ? 'Saving...' : 'Save Templates'} + +
+
+
+ ) +} diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx new file mode 100644 index 00000000..958b40df --- /dev/null +++ b/src/components/TideGoalsCard.tsx @@ -0,0 +1,415 @@ +import { type FC, useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { PieChart, Pie, Cell } from 'recharts' +import { Skeleton } from '@/components/ui/skeleton' +import { TideEditDialog } from '@/components/TideEditDialog' +import { TideCompletedBadge } from '@/components/icons/TideCompletedBadge' +import { useTides } from '../api/hooks/useTides' +import { DateTime } from 'luxon' +interface TideGoalsCardProps { + date?: Date + onDateChange?: (date: Date) => void +} + +export const TideGoalsCard: FC = ({ + date = new Date(), + onDateChange +}) => { + const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') + const [editDialogOpen, setEditDialogOpen] = useState(false) + + const { data: tideData, isLoading: isTidesLoading, error: tideError } = useTides.useGetTideOverview(date) + const { data: weeklyHistory, isLoading: isHistoryLoading } = useTides.useGetWeeklyDailyHistory(date) + + const isLoading = isTidesLoading || isHistoryLoading + const hasError = tideError + + const handleEditClick = () => { + setEditDialogOpen(true) + } + + const renderWeeklyProgress = () => { + if (!weeklyHistory || weeklyHistory.length === 0) return null + + const dayNames = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'] + const selectedDay = DateTime.fromJSDate(date).startOf('day') + const currentDay = DateTime.now().startOf('day') + + return ( +
+ {weeklyHistory.map((day) => { + const dayDate = DateTime.fromISO(day.date).startOf('day') + const isSElected = dayDate.hasSame(selectedDay, 'day') + const isFuture = dayDate > currentDay + + const fillPercentage = day.progress.goal > 0 + ? Math.min((day.progress.current / day.progress.goal) * 100, 100) + : 0 + const isCompleted = day.progress.goal > 0 && day.progress.current >= day.progress.goal + + const handleDayClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (isFuture) return + if (onDateChange) { + const clickedDate = DateTime.fromISO(day.date).toJSDate() + onDateChange(clickedDate) + } + } + + return ( +
+
+ {isCompleted ? ( + + ) : ( + // Regular progress circle for incomplete days + + + {fillPercentage > 0 && ( + + )} + + )} +
+ + {dayNames[day.dayOfWeek]} + +
+ ) + })} +
+ ) + } + + // Format time helper + const formatTime = (minutes: number, options: { overrideShowHours: boolean } = { overrideShowHours: false }) => { + const hours = Math.floor(minutes / 60) + const remainingMinutes = Math.round(minutes % 60) + if (hours === 0) return `${remainingMinutes}m` + if (remainingMinutes === 0 && !options.overrideShowHours) return `${hours}h` + return `${hours}h ${remainingMinutes}m` + } + + const renderNoGoalProgress = (current: number) => { + return ( +
+
+ {/* Simple circle background for no-goal state */} +
+ {/* Center content */} +
+
+ {formatTime(current, { overrideShowHours: true })} +
+
+ Today's creating time +
+
+
+
+ + {/* Goal information - fixed height container */} +
+
+ Enjoy your day off! +
+
+
+ ) + } + + const renderGoalProgress = (current: number, goal: number) => { + // Handle the case when there's no goal set (goal = 0) + if (goal === 0) { + return renderNoGoalProgress(current) + } + + const remainingToGoal = Math.max(goal - current, 0) + + // Chart dimensions + const size = 160 // Increased from 140 to 160 (20px larger) + const strokeWidth = 12 + + // Hour-based segments with stretch goal: + // 1. Circle divided into hour-long segments + // 2. Full circle = goal + stretch (133% of goal total) + // 3. Fill starting from top, counterclockwise + // 4. Remaining time partially fills next available segment + // 5. If goal isn't even hours, final segment won't be full sized + // 6. All segments together = 360 degrees + // 7. Stretch portion is additional 33% beyond goal + + const goalHours = goal / 60 // Goal in exact hours (can be decimal) + const stretchGoal = goal * 1.33 // 33% more than goal + const stretchHours = stretchGoal / 60 // Total hours including stretch + const currentHours = current / 60 // Current progress in exact hours + const isGoalComplete = current >= goal // Check if goal is completed + + // Total segments include both goal and stretch portions + const goalSegments = Math.ceil(goalHours) // Number of goal hour segments + const totalSegments = Math.ceil(stretchHours) // Total segments including stretch + + + const chartData = [] + + for (let segmentIndex = 0; segmentIndex < totalSegments; segmentIndex++) { + const segmentStartHour = segmentIndex + const segmentEndHour = Math.min(segmentIndex + 1, stretchHours) + const segmentHours = segmentEndHour - segmentStartHour // Usually 1, but final segment might be partial + + // Determine if this is a stretch segment (beyond the goal) + const isStretchSegment = segmentIndex >= goalSegments + + // How much of this segment should be filled? + const segmentProgress = Math.max(0, Math.min(1, (currentHours - segmentStartHour) / segmentHours)) + + // Each segment gets proportional value based on its size + const segmentValue = segmentHours * 100 // Scale up for better precision + + if (segmentProgress > 0) { + // Filled portion of this segment + chartData.push({ + name: `segment${segmentIndex}Filled`, + value: segmentValue * segmentProgress, + color: 'hsl(var(--primary))', + isFilled: true, + isStretch: isStretchSegment + }) + } + + if (segmentProgress < 1) { + // Empty portion of this segment + chartData.push({ + name: `segment${segmentIndex}Empty`, + value: segmentValue * (1 - segmentProgress), + color: 'hsl(var(--muted))', + isFilled: false, + isStretch: isStretchSegment + }) + } + } + + return ( +
+
+ + + {/* Diagonal stripe pattern for stretch segments */} + + + + + + {/* Dotted pattern for empty stretch segments */} + + + + + + + {/* Background track showing goal vs stretch portions */} + + + + + + {/* Progress overlay */} + + {chartData.map((entry, index) => { + let fillValue = entry.color + let opacity = 1 + + if (entry.isStretch) { + if (entry.isFilled) { + // Filled stretch segments get diagonal stripes + fillValue = 'url(#stretchPattern)' + opacity = 0.8 + } else { + // Empty stretch segments get dotted pattern + fillValue = 'url(#stretchEmptyPattern)' + opacity = 1 + } + } else { + // Regular goal segments + opacity = entry.isFilled ? 1.0 : 0.9 // Increased from 0.4 to 0.7 for better contrast + } + + return ( + + ) + })} + + + + {/* Center content */} +
+
+ {formatTime(current, { overrideShowHours: true })} +
+ {remainingToGoal > 0 && ( +
+ {formatTime(remainingToGoal)} until target +
+ )} + {current >= goal && remainingToGoal === 0 && ( +
+
Tide reached!
+
+{formatTime(current - goal)}
+
+ + )} +
+
+ + {/* Goal information - fixed height container */} +
+
+ Creating Time Target: {formatTime(goal)} +
+ {/* Weekly progress for daily goals */} + {renderWeeklyProgress()} +
+
+ ) + } + + return ( + + +
+ + Tides + + {/* Compact Chip Style in Header */} +
+ + +
+
+
+ + + {/* Content */} + {isLoading ? ( +
+ + +
+ ) : hasError ? ( +
+
+ Unable to load tide data +
+
+ ) : ( + <> + {activeTab === 'daily' && tideData && ( +
+ {renderGoalProgress(tideData.daily.progress.current, tideData.daily.progress.goal)} +
+ )} + {activeTab === 'weekly' && tideData && ( +
+ {renderGoalProgress(tideData.weekly.progress.current, tideData.weekly.progress.goal)} +
+ )} + + )} +
+ + +
+ ) +} diff --git a/src/components/TimeSelector.tsx b/src/components/TimeSelector.tsx index 66f41957..41df0c05 100644 --- a/src/components/TimeSelector.tsx +++ b/src/components/TimeSelector.tsx @@ -15,7 +15,7 @@ import { PopoverTrigger, } from '@/components/ui/popover' -const presetTimes = [ +const defaultPresetTimes = [ { value: 'no-limit', label: 'No limit' }, { value: '15', label: '15 minutes' }, { value: '30', label: '30 minutes' }, @@ -96,9 +96,10 @@ const formatMinutes = (minutes: number): string => { interface TimeSelectorProps { value: number | null onChange: (value: number | null) => void + presets?: Array<{ value: string; label: string }> } -export function TimeSelector({ value: externalValue, onChange }: TimeSelectorProps) { +export function TimeSelector({ value: externalValue, onChange, presets = defaultPresetTimes }: TimeSelectorProps) { const [open, setOpen] = React.useState(false) const [value, setValue] = React.useState(externalValue?.toString() || 'no-limit') const [inputValue, setInputValue] = React.useState('') @@ -113,7 +114,7 @@ export function TimeSelector({ value: externalValue, onChange }: TimeSelectorPro const stringValue = externalValue.toString() setValue(stringValue) // If the value doesn't match any preset, create a custom option - if (!presetTimes.some(time => time.value === stringValue)) { + if (!presets.some(time => time.value === stringValue)) { setCustomOption({ value: stringValue, // Keep the numeric value for onChange label: formatMinutes(externalValue) @@ -122,10 +123,10 @@ export function TimeSelector({ value: externalValue, onChange }: TimeSelectorPro setCustomOption(null) // Ensure custom option is cleared if matching preset } } - }, [externalValue]) + }, [externalValue, presets]) const displayOptions = React.useMemo(() => { - const options = [...presetTimes] + const options = [...presets] const parsedInput = parseTimeInput(inputValue) const filtered = options.filter(option => { @@ -147,7 +148,7 @@ export function TimeSelector({ value: externalValue, onChange }: TimeSelectorPro return [customOption, ...filtered] } return filtered - }, [customOption, inputValue]) + }, [customOption, inputValue, presets]) const handleInputChange = (input: string) => { setInputValue(input) @@ -155,7 +156,7 @@ export function TimeSelector({ value: externalValue, onChange }: TimeSelectorPro // Try to parse for custom duration const parsedMinutes = parseTimeInput(input) if (parsedMinutes !== null) { - const matchingPreset = presetTimes.find(preset => + const matchingPreset = presets.find(preset => parseTimeInput(preset.value) === parsedMinutes ) @@ -203,7 +204,7 @@ export function TimeSelector({ value: externalValue, onChange }: TimeSelectorPro className='w-full justify-between' > {value - ? presetTimes.find((time) => time.value === value)?.label || + ? presets.find((time) => time.value === value)?.label || (customOption?.value === value ? customOption.label : 'Select duration') : 'Select duration'} diff --git a/src/components/UsageSummaryWithTides.tsx b/src/components/UsageSummaryWithTides.tsx new file mode 100644 index 00000000..9c362cb6 --- /dev/null +++ b/src/components/UsageSummaryWithTides.tsx @@ -0,0 +1,309 @@ +import { Card, CardContent } from '@/components/ui/card' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, +} from '@/components/ui/chart' +import { Skeleton } from './ui/skeleton' +import { GraphableTimeByHourBlock, AppsWithTime } from '@/api/monitorApi/monitorApi' +import { useRef, useEffect, useState } from 'react' +import { Switch } from '@/components/ui/switch' +import { useCreateNotification, useGetNotificationBySentId } from '@/api/hooks/useNotifications' +import { useAuth } from '@/hooks/useAuth' +import { AppKanbanBoard } from './AppKanbanBoard' +import { TideGoalsCard } from './TideGoalsCard' + +type ChartLabel = { + label: string + color: string +} + +type ChartConfig = { + creating: ChartLabel + neutral: ChartLabel + consuming: ChartLabel + idle?: ChartLabel +} + +const defaultChartConfig: ChartConfig = { + creating: { + label: 'Creating', + color: 'hsl(var(--primary))', // Uses primary theme color + }, + neutral: { + label: 'Neutral', + color: 'hsl(var(--muted-foreground) / 0.5)', // Using the muted foreground color with 50% opacity + }, + consuming: { + label: 'Consuming', + color: 'rgb(248,113,113)', // Red + }, +} + +const idleChartConfig: ChartConfig = { + ...defaultChartConfig, + idle: { + label: 'Idle', + color: 'rgb(156,163,175)', // Light gray - lighter than neutral to show less activity + }, +} + +export const formatTime = (minutes: number) => { + const hours = Math.floor(minutes / 60) + const remainingMinutes = Math.round(minutes % 60) + if (remainingMinutes === 60) { + return `${hours + 1}h 0m` + } + return `${hours}h ${remainingMinutes}m` +} + +// Tooltip Helper function to format time in hours/minutes for tooltip +export const formatTimeToDecimalHours = (minutes: number) => { + if (minutes >= 60) { + const hours = Math.round((minutes / 60) * 10)/ 10 + return `${hours}h` + } + return `${minutes}m` +} + +export interface UsageSummaryWithTidesProps { + chartData: GraphableTimeByHourBlock[]; + appUsage: AppsWithTime[]; + showIdleTime?: boolean; + setShowIdleTime?: (showIdleTime: boolean) => void; + isLoading?: boolean; + yAxisMax?: number; + rangeMode: 'day' | 'week' | 'month'; + date: Date; + setDate?: (date: Date) => void; + lastUpdated?: Date | null; + totalTime: { value: number; trend: { percent: number; direction: 'up' | 'down' | 'none' } }; +} + + +export const UsageSummaryWithTides = ({ + chartData, + appUsage, + isLoading = false, + yAxisMax, + showIdleTime, + rangeMode, + date, + setDate, + lastUpdated, + totalTime, + setShowIdleTime, +}: UsageSummaryWithTidesProps) => { + const { user } = useAuth() + const { mutate: createNotification } = useCreateNotification() + const { data: notificationBySentId } = useGetNotificationBySentId('firefox_not_supported') + const [chartDataState, setChartDataState] = useState(chartData) + const [chartConfigState, setChartConfigState] = useState(defaultChartConfig) + const appUsageRef = useRef(null) + + useEffect(() => { + if(!appUsage) return + const hasFirefox = appUsage.some(app => app.app_external_id === 'org.mozilla.firefox') + if (user?.id && hasFirefox) { + if (notificationBySentId) return + createNotification({ + user_id: user.id, + content: 'Site blocking is not currently supported for Firefox', + notification_type: 'app', + notification_sub_type: 'warning', + notification_sent_id: 'firefox_not_supported', + read: 0, + dismissed: 0, + }) + } + }, [user?.id, appUsage, createNotification]) + + useEffect(() => { + if(showIdleTime) { + setChartDataState(chartData) + setChartConfigState(idleChartConfig) + } else { + setChartDataState(chartData.map(d => ({...d, idle: 0}))) + setChartConfigState(defaultChartConfig) + } + }, [showIdleTime, chartData]) + + const formatLastUpdated = (date: Date | null) => { + if (!date) return null + const now = new Date() + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (diffInSeconds < 60) { + return 'Just now' + } else if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60) + return `${minutes} minute${minutes > 1 ? 's' : ''} ago` + } else if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600) + return `${hours} hour${hours > 1 ? 's' : ''} ago` + } else { + return date.toLocaleDateString() + ' at ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } + } + + return ( + <> + {/* Two-column layout: Goals card on left, Chart on right */} +
+ {/* Tide Goals Card */} + + + {/* Chart Card - spans 2 columns */} + + + {isLoading ? ( +
+ +
+ ) : ( +
+ + +
+ + +
+
+ +

When Ebb is online and no keyboard, mouse, or window events occur

+
+
+ + + + + + + + + + + + + + + + { + // Show hours/minutes for week view, minutes for today + if (yAxisMax && yAxisMax > 60) { + const hours = Math.round(value / 60 * 10) / 10 + return hours > 0 ? `${hours}h` : `${value}m` + } + return '' + }} + /> + { + if (!active || !payload?.length) return null + + const data = payload[0].payload + return ( +
+
{data.timeRange}
+
+
Creating: {formatTimeToDecimalHours(data.creating)}
+
Neutral: {formatTimeToDecimalHours(data.neutral)}
+
Consuming: {formatTimeToDecimalHours(data.consuming)}
+
Idle: {formatTimeToDecimalHours(data.idle)}
+
Offline: {formatTimeToDecimalHours(data.offline)}
+
+
+ ) + }} + /> + } /> + + + + + +
+
+
+ )} +
+
+
+ Total Time Active: {formatTime(totalTime.value)} +
+ {lastUpdated && ( +
+ Last updated: {formatLastUpdated(lastUpdated)} +
+ )} +
+
+
+ +
+ +
+ + ) +} diff --git a/src/components/icons/TideCompletedBadge.tsx b/src/components/icons/TideCompletedBadge.tsx new file mode 100644 index 00000000..185b63b6 --- /dev/null +++ b/src/components/icons/TideCompletedBadge.tsx @@ -0,0 +1,56 @@ +interface TideCompletedBadgeProps { + id: string +} + +export const TideCompletedBadge = ({ id }: TideCompletedBadgeProps) => ( + + + + + + + + + + + + + + + + + + + + {/* Outer badge ring */} + + + {/* Inner badge body with wave */} + + + + + + + + + + {/* Checkmark coin */} + + + +) diff --git a/src/components/ui/circular-progress.tsx b/src/components/ui/circular-progress.tsx new file mode 100644 index 00000000..1657b8ae --- /dev/null +++ b/src/components/ui/circular-progress.tsx @@ -0,0 +1,125 @@ +import { type FC } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +interface CircularProgressProps { + title: string + currentValue: number // in minutes + goalValue: number // in minutes + className?: string +} + +export const CircularProgress: FC = ({ + title, + currentValue, + goalValue, + className = '' +}) => { + // Calculate progress percentages + const stretchGoal = goalValue * 1.33 // 133% of base goal for stretch + const baseProgress = Math.min(currentValue / goalValue, 1) * 100 // 0-100% for base goal + const totalProgress = Math.min(currentValue / stretchGoal, 1) * 100 // 0-100% for total circle + + // Calculate remaining time to goal + const remainingToGoal = Math.max(goalValue - currentValue, 0) + + // Format time helper + const formatTime = (minutes: number) => { + const hours = Math.floor(minutes / 60) + const remainingMinutes = Math.round(minutes % 60) + if (hours === 0) return `${remainingMinutes}m` + if (remainingMinutes === 0) return `${hours}h` + return `${hours}h ${remainingMinutes}m` + } + + // SVG circle properties + const size = 120 + const strokeWidth = 8 + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + + return ( + + + + {title} + + + +
+
+ {/* Background circle */} + + + {/* Base goal progress (75% of circle when complete) */} + + {/* Stretch goal progress (remaining 25% of circle) */} + {currentValue > goalValue && ( + + )} + + + {/* Center content */} +
+
+ {formatTime(currentValue)} +
+ {remainingToGoal > 0 && ( +
+ {formatTime(remainingToGoal)} until target +
+ )} + {currentValue >= goalValue && remainingToGoal === 0 && ( +
+ Goal reached! +
+ )} +
+
+
+ + {/* Goal information */} +
+
+ Daily Target: {formatTime(goalValue)} + {currentValue > goalValue && ( + + Stretch Goal: {formatTime(stretchGoal)} + + )} +
+
+
+
+ ) +} diff --git a/src/db/ebb/tideRepo.ts b/src/db/ebb/tideRepo.ts new file mode 100644 index 00000000..52a86def --- /dev/null +++ b/src/db/ebb/tideRepo.ts @@ -0,0 +1,101 @@ +import { QueryResult } from '@tauri-apps/plugin-sql' +import { update } from '@/lib/utils/sql.util' +import { getEbbDb } from './ebbDb' + +export interface TideSchema { + id: string + start: string // ISO string + end?: string // ISO string, nullable for indefinite tides + completed_at?: string // ISO string, when the tide was actually completed + metrics_type: string // "creating", etc. + tide_frequency: string // "daily", "weekly", "monthly", "indefinite" + goal_amount: number // Goal in minutes + actual_amount: number // Current progress in minutes + tide_template_id: string + created_at: string // ISO string + updated_at: string // ISO string +} + +export interface TideTemplateSchema { + id: string + metrics_type: string // "creating", etc. + tide_frequency: string // "daily", "weekly", "monthly", "indefinite" + first_tide: string // ISO string - How far back to create tides when generating + day_of_week?: string // For daily tides: comma-separated days "0,1,2,3,4,5,6" + goal_amount: number // Goal in minutes + created_at: string // ISO string + updated_at: string // ISO string +} + +export type Tide = TideSchema +export type TideTemplate = TideTemplateSchema + +export type TideWithTemplate = Tide & { + template?: TideTemplate +} + +// Tide Repository Functions + +const updateTide = async ( + id: string, + tide: Partial, +): Promise => { + const ebbDb = await getEbbDb() + return update(ebbDb, 'tide', tide, id) +} + +const getActiveTides = async (): Promise => { + const ebbDb = await getEbbDb() + const now = new Date().toISOString() + + // Get tides that are not completed and are within their time window + const query = ` + SELECT * FROM tide + WHERE completed_at IS NULL + AND (end IS NULL OR end > ?) + ORDER BY start DESC + ` + + return await ebbDb.select(query, [now]) +} + +const getActiveTidesForPeriod = async (evaluationTime: string): Promise => { + const ebbDb = await getEbbDb() + + const query = ` + SELECT * FROM tide + WHERE datetime(start) <= datetime(?) + AND (end IS NULL OR datetime(end) > datetime(?)) + ORDER BY start DESC + ` + const tides = await ebbDb.select(query, [evaluationTime, evaluationTime]) + return tides +} + +// Tide Template Repository Functions + +const updateTideTemplate = async ( + id: string, + template: Partial, +): Promise => { + const ebbDb = await getEbbDb() + return update(ebbDb, 'tide_template', template, id) +} + +const getAllTideTemplates = async (): Promise => { + const ebbDb = await getEbbDb() + return await ebbDb.select( + 'SELECT * FROM tide_template ORDER BY created_at DESC' + ) +} + +export const TideRepo = { + // Tide operations + updateTide, + getActiveTides, + getActiveTidesForPeriod, + + // Tide template operations + updateTideTemplate, + getAllTideTemplates, +} diff --git a/src/lib/utils/environment.util.ts b/src/lib/utils/environment.util.ts index 4632ff5c..026e296a 100644 --- a/src/lib/utils/environment.util.ts +++ b/src/lib/utils/environment.util.ts @@ -15,3 +15,7 @@ export const isCanaryUser = (email?: string) => { export const isFocusScheduleFeatureEnabled = (email?: string) => { return isCanaryUser(email) } + +export const isTideGoalsFeatureEnabled = (email?: string) => { + return isCanaryUser(email) +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 58e32f3e..26ab6ed4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -10,9 +10,11 @@ import { import { Calendar } from '@/components/ui/calendar' import { useAuth } from '@/hooks/useAuth' import { UsageSummary } from '@/components/UsageSummary' +import { UsageSummaryWithTides } from '@/components/UsageSummaryWithTides' import { PermissionAlert } from '@/components/PermissionAlert' import { useUsageSummary } from './useUsageSummary' import { RangeModeSelector } from '@/components/RangeModeSelector' +import { isTideGoalsFeatureEnabled } from '@/lib/utils/environment.util' export const HomePage = () => { const { user } = useAuth() @@ -27,18 +29,20 @@ export const HomePage = () => { rangeMode, appUsage, setRangeMode, - totalCreating, - totalTime, chartData, isLoading, yAxisMax, showIdleTime, setShowIdleTime, + lastUpdated, + totalCreating, + totalTime, totalTimeTooltip, totalTimeLabel, - lastUpdated, } = useUsageSummary() + const isTideGoalsEnabled = isTideGoalsFeatureEnabled(user?.email) + return ( @@ -88,22 +92,38 @@ export const HomePage = () => { - + {isTideGoalsEnabled ? ( + + ) : ( + + )}