From 7528e5db6fd7a922bd0a98403672e5876a1480cb Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 6 Sep 2025 12:15:51 -0600 Subject: [PATCH 01/40] Add tide repository access --- src-tauri/src/ebb_db/Cargo.lock | 840 +++++++++++++++++- src-tauri/src/ebb_db/src/db.rs | 2 + src-tauri/src/ebb_db/src/db/models.rs | 2 + src-tauri/src/ebb_db/src/db/models/tide.rs | 51 ++ .../src/ebb_db/src/db/models/tide_template.rs | 27 + src-tauri/src/ebb_db/src/db/tide_repo.rs | 276 ++++++ .../src/ebb_db/src/db/tide_template_repo.rs | 155 ++++ src-tauri/src/ebb_db/src/migrations.rs | 37 + 8 files changed, 1351 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/ebb_db/src/db/models/tide.rs create mode 100644 src-tauri/src/ebb_db/src/db/models/tide_template.rs create mode 100644 src-tauri/src/ebb_db/src/db/tide_repo.rs create mode 100644 src-tauri/src/ebb_db/src/db/tide_template_repo.rs 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..c504af10 100644 --- a/src-tauri/src/ebb_db/src/db.rs +++ b/src-tauri/src/ebb_db/src/db.rs @@ -1,3 +1,5 @@ pub mod device_profile_repo; pub mod device_repo; pub mod models; +pub mod tide_repo; +pub mod tide_template_repo; diff --git a/src-tauri/src/ebb_db/src/db/models.rs b/src-tauri/src/ebb_db/src/db/models.rs index 1e707e24..42cd2132 100644 --- a/src-tauri/src/ebb_db/src/db/models.rs +++ b/src-tauri/src/ebb_db/src/db/models.rs @@ -1,2 +1,4 @@ pub mod device; pub mod device_profile; +pub mod tide; +pub mod tide_template; 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..663ed5f0 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/tide.rs @@ -0,0 +1,51 @@ +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, + 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, + metrics_type: String, + tide_frequency: String, + goal_amount: f64, + tide_template_id: String, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + start, + end: 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 { + Self::new( + start, + template.metrics_type.clone(), + template.tide_frequency.clone(), + template.goal_amount, + template.id.clone(), + ) + } +} \ 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..7d8dea8f --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/models/tide_template.rs @@ -0,0 +1,27 @@ +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 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) -> Self { + Self { + id: Uuid::new_v4().to_string(), + metrics_type, + tide_frequency, + goal_amount, + created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), + } + } +} \ 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..8bdce28b --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/tide_repo.rs @@ -0,0 +1,276 @@ +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, 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)" + ) + .bind(&tide.id) + .bind(&tide.start) + .bind(&tide.end) + .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 tides = sqlx::query_as::<_, Tide>("SELECT * FROM tide WHERE end IS NULL ORDER BY start DESC") + .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, metrics_type = ?4, tide_frequency = ?5, + goal_amount = ?6, actual_amount = ?7, tide_template_id = ?8, updated_at = ?9 + WHERE id = ?1" + ) + .bind(&tide.id) + .bind(&tide.start) + .bind(&tide.end) + .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 delete_tide(&self, id: &str) -> Result<()> { + sqlx::query("DELETE FROM tide WHERE id = ?1") + .bind(id) + .execute(&self.pool) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::db_manager; + use crate::db::models::tide_template::TideTemplate; + use crate::db::tide_template_repo::TideTemplateRepo; + + use super::*; + + #[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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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 template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0); + + 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(()) + } +} \ 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..6cf642c9 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/tide_template_repo.rs @@ -0,0 +1,155 @@ +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, goal_amount, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + ) + .bind(&template.id) + .bind(&template.metrics_type) + .bind(&template.tide_frequency) + .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, goal_amount = ?4, updated_at = ?5 + WHERE id = ?1" + ) + .bind(&template.id) + .bind(&template.metrics_type) + .bind(&template.tide_frequency) + .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 super::*; + + #[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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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 template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0); + + 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(), 2); + + 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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + 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..328278a6 100644 --- a/src-tauri/src/ebb_db/src/migrations.rs +++ b/src-tauri/src/ebb_db/src/migrations.rs @@ -259,6 +259,41 @@ 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, + 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, + 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, + }, ] } @@ -310,6 +345,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 { From 9700dd9303628606c0ea07d6e066e29cf34a6c0d Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 6 Sep 2025 13:59:25 -0600 Subject: [PATCH 02/40] added new completion paradigm --- src-tauri/src/ebb_db/src/db/models/tide.rs | 221 +++++++++++++++++- .../src/ebb_db/src/db/models/tide_template.rs | 167 ++++++++++++- src-tauri/src/ebb_db/src/db/tide_repo.rs | 59 +++-- .../src/ebb_db/src/db/tide_template_repo.rs | 33 ++- src-tauri/src/ebb_db/src/migrations.rs | 3 + 5 files changed, 459 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/ebb_db/src/db/models/tide.rs b/src-tauri/src/ebb_db/src/db/models/tide.rs index 663ed5f0..4d96b679 100644 --- a/src-tauri/src/ebb_db/src/db/models/tide.rs +++ b/src-tauri/src/ebb_db/src/db/models/tide.rs @@ -7,7 +7,8 @@ use uuid::Uuid; pub struct Tide { pub id: String, pub start: OffsetDateTime, - pub end: Option, + 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, @@ -20,6 +21,7 @@ pub struct Tide { impl Tide { pub fn new( start: OffsetDateTime, + end: Option, metrics_type: String, tide_frequency: String, goal_amount: f64, @@ -28,7 +30,8 @@ impl Tide { Self { id: Uuid::new_v4().to_string(), start, - end: None, + end, + completed_at: None, metrics_type, tide_frequency, goal_amount, @@ -40,12 +43,226 @@ impl Tide { } 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 index 7d8dea8f..b0a7d45c 100644 --- a/src-tauri/src/ebb_db/src/db/models/tide_template.rs +++ b/src-tauri/src/ebb_db/src/db/models/tide_template.rs @@ -8,20 +8,185 @@ 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) -> Self { + 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/tide_repo.rs b/src-tauri/src/ebb_db/src/db/tide_repo.rs index 8bdce28b..c7fe372d 100644 --- a/src-tauri/src/ebb_db/src/db/tide_repo.rs +++ b/src-tauri/src/ebb_db/src/db/tide_repo.rs @@ -16,12 +16,13 @@ impl TideRepo { pub async fn create_tide(&self, tide: &Tide) -> Result<()> { sqlx::query( - "INSERT INTO tide (id, start, end, 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)" + "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) @@ -53,9 +54,13 @@ impl TideRepo { } pub async fn get_active_tides(&self) -> Result> { - let tides = sqlx::query_as::<_, Tide>("SELECT * FROM tide WHERE end IS NULL ORDER BY start DESC") - .fetch_all(&self.pool) - .await?; + let now = OffsetDateTime::now_utc(); + let tides = sqlx::query_as::<_, Tide>( + "SELECT * FROM tide WHERE start <= ?1 AND (end IS NULL OR end >= ?1) ORDER BY start DESC" + ) + .bind(now) + .fetch_all(&self.pool) + .await?; Ok(tides) } @@ -90,13 +95,14 @@ impl TideRepo { pub async fn update_tide(&self, tide: &Tide) -> Result<()> { sqlx::query( "UPDATE tide - SET start = ?2, end = ?3, metrics_type = ?4, tide_frequency = ?5, - goal_amount = ?6, actual_amount = ?7, tide_template_id = ?8, updated_at = ?9 + 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) @@ -135,6 +141,20 @@ impl TideRepo { 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) @@ -150,16 +170,28 @@ 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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template = create_test_template(); template_repo.create_tide_template(&template).await?; let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); @@ -183,7 +215,7 @@ mod tests { let tide_repo = TideRepo::new(pool.clone()); let template_repo = TideTemplateRepo::new(pool); - let template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template = create_test_template(); template_repo.create_tide_template(&template).await?; let mut tide1 = Tide::from_template(&template, OffsetDateTime::now_utc()); @@ -208,7 +240,7 @@ mod tests { let tide_repo = TideRepo::new(pool.clone()); let template_repo = TideTemplateRepo::new(pool); - let template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template = create_test_template(); template_repo.create_tide_template(&template).await?; let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); @@ -229,7 +261,7 @@ mod tests { let tide_repo = TideRepo::new(pool.clone()); let template_repo = TideTemplateRepo::new(pool); - let template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template = create_test_template(); template_repo.create_tide_template(&template).await?; let tide = Tide::from_template(&template, OffsetDateTime::now_utc()); @@ -251,8 +283,9 @@ mod tests { let tide_repo = TideRepo::new(pool.clone()); let template_repo = TideTemplateRepo::new(pool); - let template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); - let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0); + 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?; 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 index 6cf642c9..1dc440ce 100644 --- a/src-tauri/src/ebb_db/src/db/tide_template_repo.rs +++ b/src-tauri/src/ebb_db/src/db/tide_template_repo.rs @@ -15,12 +15,14 @@ impl TideTemplateRepo { pub async fn create_tide_template(&self, template: &TideTemplate) -> Result<()> { sqlx::query( - "INSERT INTO tide_template (id, metrics_type, tide_frequency, goal_amount, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + "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) @@ -52,12 +54,14 @@ impl TideTemplateRepo { pub async fn update_tide_template(&self, template: &TideTemplate) -> Result<()> { sqlx::query( "UPDATE tide_template - SET metrics_type = ?2, tide_frequency = ?3, goal_amount = ?4, updated_at = ?5 + 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) @@ -79,15 +83,27 @@ impl TideTemplateRepo { #[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 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template = create_test_template(); repo.create_tide_template(&template).await?; let retrieved = repo.get_tide_template(&template.id).await?; @@ -106,8 +122,9 @@ mod tests { let pool = db_manager::create_test_db().await; let repo = TideTemplateRepo::new(pool); - let template1 = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); - let template2 = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0); + 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?; @@ -123,7 +140,7 @@ mod tests { let pool = db_manager::create_test_db().await; let repo = TideTemplateRepo::new(pool); - let mut template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let mut template = create_test_template(); repo.create_tide_template(&template).await?; template.goal_amount = 150.0; @@ -142,7 +159,7 @@ mod tests { let pool = db_manager::create_test_db().await; let repo = TideTemplateRepo::new(pool); - let template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + let template = create_test_template(); repo.create_tide_template(&template).await?; repo.delete_tide_template(&template.id).await?; diff --git a/src-tauri/src/ebb_db/src/migrations.rs b/src-tauri/src/ebb_db/src/migrations.rs index 328278a6..b37c3ad1 100644 --- a/src-tauri/src/ebb_db/src/migrations.rs +++ b/src-tauri/src/ebb_db/src/migrations.rs @@ -267,6 +267,8 @@ pub fn get_migrations() -> Vec { 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 @@ -282,6 +284,7 @@ pub fn get_migrations() -> Vec { 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, From d4cc74457d750d24e52067e94e4cc6f77452d668 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 7 Sep 2025 10:06:20 -0600 Subject: [PATCH 03/40] tide manager --- src-tauri/ebb_tide_manager/.gitignore | 7 + src-tauri/ebb_tide_manager/Cargo.lock | 5467 +++++++++++++++++++++++++ src-tauri/ebb_tide_manager/Cargo.toml | 14 + src-tauri/ebb_tide_manager/src/lib.rs | 249 ++ 4 files changed, 5737 insertions(+) create mode 100644 src-tauri/ebb_tide_manager/.gitignore create mode 100644 src-tauri/ebb_tide_manager/Cargo.lock create mode 100644 src-tauri/ebb_tide_manager/Cargo.toml create mode 100644 src-tauri/ebb_tide_manager/src/lib.rs diff --git a/src-tauri/ebb_tide_manager/.gitignore b/src-tauri/ebb_tide_manager/.gitignore new file mode 100644 index 00000000..b21bd681 --- /dev/null +++ b/src-tauri/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/ebb_tide_manager/Cargo.lock b/src-tauri/ebb_tide_manager/Cargo.lock new file mode 100644 index 00000000..655df410 --- /dev/null +++ b/src-tauri/ebb_tide_manager/Cargo.lock @@ -0,0 +1,5467 @@ +# 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 = "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", + "num-conv", + "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/ebb_tide_manager/Cargo.toml b/src-tauri/ebb_tide_manager/Cargo.toml new file mode 100644 index 00000000..2d1648b9 --- /dev/null +++ b/src-tauri/ebb_tide_manager/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ebb_tide_manager" +version = "0.1.0" +edition = "2024" + +[dependencies] +ebb-db = { path = "../src/ebb_db" } +time = { version = "0.3", features = ["serde"] } +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/ebb_tide_manager/src/lib.rs b/src-tauri/ebb_tide_manager/src/lib.rs new file mode 100644 index 00000000..230e5ae2 --- /dev/null +++ b/src-tauri/ebb_tide_manager/src/lib.rs @@ -0,0 +1,249 @@ +use ebb_db::{ + db_manager::{self, DbManager}, + db::{ + tide_repo::TideRepo, + tide_template_repo::TideTemplateRepo, + models::{tide::Tide, tide_template::TideTemplate}, + }, +}; +use std::sync::Arc; +use time::OffsetDateTime; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TideManagerError { + #[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; + +pub struct TideManager { + tide_repo: TideRepo, + tide_template_repo: TideTemplateRepo, + _db_manager: Arc, // Keep reference to ensure connection pool stays alive +} + +impl TideManager { + /// Create a new TideManager using the shared connection pool + /// This ensures we reuse the same database connections as the rest of the application + pub async fn new() -> Result { + let db_manager = db_manager::DbManager::get_shared_ebb().await + .map_err(|e| TideManagerError::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, + }) + } + + /// Create a new TideManager with a specific database manager (useful for testing) + 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, + } + } + + /// Create a new tide from an existing template + /// This is our first core business logic method + pub async fn create_tide_from_template( + &self, + template_id: &str, + start_time: Option, + ) -> Result { + // Validate template exists + let template = self + .tide_template_repo + .get_tide_template(template_id) + .await? + .ok_or_else(|| TideManagerError::TemplateNotFound { + template_id: template_id.to_string(), + })?; + + // Create tide with current time or specified start time + let start = start_time.unwrap_or_else(OffsetDateTime::now_utc); + let tide = Tide::from_template(&template, start); + + // Save to database + self.tide_repo.create_tide(&tide).await?; + + Ok(tide) + } + + /// Get all active tides (tides without an end time) + pub async fn get_active_tides(&self) -> Result> { + let tides = self.tide_repo.get_active_tides().await?; + Ok(tides) + } + + /// Get a specific tide by ID + pub async fn get_tide(&self, tide_id: &str) -> Result> { + let tide = self.tide_repo.get_tide(tide_id).await?; + Ok(tide) + } + + /// Get all available tide templates + pub async fn get_all_templates(&self) -> Result> { + let templates = self.tide_template_repo.get_all_tide_templates().await?; + Ok(templates) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + 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_manager_creation() -> Result<()> { + let db_manager = create_test_db_manager().await; + let _tide_manager = TideManager::new_with_manager(db_manager); + + // If we get here without panicking, connection sharing works + Ok(()) + } + + #[tokio::test] + async fn test_create_tide_from_template() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_manager = TideManager::new_with_manager(db_manager); + + // First create a template + let template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); + tide_manager.tide_template_repo.create_tide_template(&template).await?; + + // Now create a tide from the template + let tide = tide_manager + .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_none()); + + // Verify it's in the database + let retrieved_tide = tide_manager.get_tide(&tide.id).await?; + assert!(retrieved_tide.is_some()); + assert_eq!(retrieved_tide.unwrap().id, tide.id); + + Ok(()) + } + + #[tokio::test] + async fn test_create_tide_from_nonexistent_template() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_manager = TideManager::new_with_manager(db_manager); + + // Try to create tide from non-existent template + let result = tide_manager + .create_tide_from_template("nonexistent-id", None) + .await; + + // Should return TemplateNotFound error + assert!(matches!(result, Err(TideManagerError::TemplateNotFound { .. }))); + + Ok(()) + } + + #[tokio::test] + async fn test_get_active_tides() -> Result<()> { + let db_manager = create_test_db_manager().await; + let tide_manager = TideManager::new_with_manager(db_manager); + + // Create a template + let template = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0); + tide_manager.tide_template_repo.create_tide_template(&template).await?; + + // Create two tides + let tide1 = tide_manager.create_tide_from_template(&template.id, None).await?; + let tide2 = tide_manager.create_tide_from_template(&template.id, None).await?; + + // End one tide + tide_manager.tide_repo.end_tide(&tide1.id, OffsetDateTime::now_utc()).await?; + + // Get active tides - should only return tide2 + let active_tides = tide_manager.get_active_tides().await?; + assert_eq!(active_tides.len(), 1); + assert_eq!(active_tides[0].id, tide2.id); + + Ok(()) + } + + #[tokio::test] + async fn test_cross_crate_connection_sharing_concept() -> Result<()> { + // This test demonstrates that our TideManager can work with + // the same connection pattern as the main application + + let db_manager = create_test_db_manager().await; + + // Create multiple managers using the same connection pool + let tide_manager1 = TideManager::new_with_manager(db_manager.clone()); + let tide_manager2 = TideManager::new_with_manager(db_manager.clone()); + + // Create a template with manager1 + let template = TideTemplate::new("focus".to_string(), "daily".to_string(), 60.0); + tide_manager1.tide_template_repo.create_tide_template(&template).await?; + + // Create a tide with manager2 (different manager, same pool) + let tide = tide_manager2.create_tide_from_template(&template.id, None).await?; + + // Verify both can access the same data + let templates1 = tide_manager1.get_all_templates().await?; + let templates2 = tide_manager2.get_all_templates().await?; + + assert_eq!(templates1.len(), 1); + assert_eq!(templates2.len(), 1); + assert_eq!(templates1[0].id, templates2[0].id); + + // Verify tide is accessible from both managers + let tide1 = tide_manager1.get_tide(&tide.id).await?; + let tide2 = tide_manager2.get_tide(&tide.id).await?; + + assert!(tide1.is_some()); + assert!(tide2.is_some()); + assert_eq!(tide1.unwrap().id, tide2.unwrap().id); + + Ok(()) + } +} \ No newline at end of file From adf8712760e0f109efe9a34894ea9b9672e0666a Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 7 Sep 2025 10:33:16 -0600 Subject: [PATCH 04/40] add scheduler for interval checking challenges/tides --- src-tauri/ebb_tide_manager/src/lib.rs | 240 +--------------- .../ebb_tide_manager/src/tide_scheduler.rs | 234 +++++++++++++++ .../ebb_tide_manager/src/tide_service.rs | 266 ++++++++++++++++++ 3 files changed, 510 insertions(+), 230 deletions(-) create mode 100644 src-tauri/ebb_tide_manager/src/tide_scheduler.rs create mode 100644 src-tauri/ebb_tide_manager/src/tide_service.rs diff --git a/src-tauri/ebb_tide_manager/src/lib.rs b/src-tauri/ebb_tide_manager/src/lib.rs index 230e5ae2..4fbc183d 100644 --- a/src-tauri/ebb_tide_manager/src/lib.rs +++ b/src-tauri/ebb_tide_manager/src/lib.rs @@ -1,249 +1,29 @@ -use ebb_db::{ - db_manager::{self, DbManager}, - db::{ - tide_repo::TideRepo, - tide_template_repo::TideTemplateRepo, - models::{tide::Tide, tide_template::TideTemplate}, - }, -}; -use std::sync::Arc; -use time::OffsetDateTime; +pub mod tide_service; +pub mod tide_scheduler; + use thiserror::Error; #[derive(Error, Debug)] pub enum TideManagerError { - #[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("Service error: {0}")] + Service(#[from] tide_service::TideServiceError), #[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 { - tide_repo: TideRepo, - tide_template_repo: TideTemplateRepo, - _db_manager: Arc, // Keep reference to ensure connection pool stays alive + // Will be populated with lifecycle management functionality later } impl TideManager { - /// Create a new TideManager using the shared connection pool - /// This ensures we reuse the same database connections as the rest of the application + /// Create a new TideManager pub async fn new() -> Result { - let db_manager = db_manager::DbManager::get_shared_ebb().await - .map_err(|e| TideManagerError::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, + // Placeholder for now }) } - - /// Create a new TideManager with a specific database manager (useful for testing) - 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, - } - } - - /// Create a new tide from an existing template - /// This is our first core business logic method - pub async fn create_tide_from_template( - &self, - template_id: &str, - start_time: Option, - ) -> Result { - // Validate template exists - let template = self - .tide_template_repo - .get_tide_template(template_id) - .await? - .ok_or_else(|| TideManagerError::TemplateNotFound { - template_id: template_id.to_string(), - })?; - - // Create tide with current time or specified start time - let start = start_time.unwrap_or_else(OffsetDateTime::now_utc); - let tide = Tide::from_template(&template, start); - - // Save to database - self.tide_repo.create_tide(&tide).await?; - - Ok(tide) - } - - /// Get all active tides (tides without an end time) - pub async fn get_active_tides(&self) -> Result> { - let tides = self.tide_repo.get_active_tides().await?; - Ok(tides) - } - - /// Get a specific tide by ID - pub async fn get_tide(&self, tide_id: &str) -> Result> { - let tide = self.tide_repo.get_tide(tide_id).await?; - Ok(tide) - } - - /// Get all available tide templates - pub async fn get_all_templates(&self) -> Result> { - let templates = self.tide_template_repo.get_all_tide_templates().await?; - Ok(templates) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::sqlite::SqlitePoolOptions; - - 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_manager_creation() -> Result<()> { - let db_manager = create_test_db_manager().await; - let _tide_manager = TideManager::new_with_manager(db_manager); - - // If we get here without panicking, connection sharing works - Ok(()) - } - - #[tokio::test] - async fn test_create_tide_from_template() -> Result<()> { - let db_manager = create_test_db_manager().await; - let tide_manager = TideManager::new_with_manager(db_manager); - - // First create a template - let template = TideTemplate::new("creating".to_string(), "daily".to_string(), 100.0); - tide_manager.tide_template_repo.create_tide_template(&template).await?; - - // Now create a tide from the template - let tide = tide_manager - .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_none()); - - // Verify it's in the database - let retrieved_tide = tide_manager.get_tide(&tide.id).await?; - assert!(retrieved_tide.is_some()); - assert_eq!(retrieved_tide.unwrap().id, tide.id); - - Ok(()) - } - - #[tokio::test] - async fn test_create_tide_from_nonexistent_template() -> Result<()> { - let db_manager = create_test_db_manager().await; - let tide_manager = TideManager::new_with_manager(db_manager); - - // Try to create tide from non-existent template - let result = tide_manager - .create_tide_from_template("nonexistent-id", None) - .await; - - // Should return TemplateNotFound error - assert!(matches!(result, Err(TideManagerError::TemplateNotFound { .. }))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_active_tides() -> Result<()> { - let db_manager = create_test_db_manager().await; - let tide_manager = TideManager::new_with_manager(db_manager); - - // Create a template - let template = TideTemplate::new("learning".to_string(), "weekly".to_string(), 500.0); - tide_manager.tide_template_repo.create_tide_template(&template).await?; - - // Create two tides - let tide1 = tide_manager.create_tide_from_template(&template.id, None).await?; - let tide2 = tide_manager.create_tide_from_template(&template.id, None).await?; - - // End one tide - tide_manager.tide_repo.end_tide(&tide1.id, OffsetDateTime::now_utc()).await?; - - // Get active tides - should only return tide2 - let active_tides = tide_manager.get_active_tides().await?; - assert_eq!(active_tides.len(), 1); - assert_eq!(active_tides[0].id, tide2.id); - - Ok(()) - } - - #[tokio::test] - async fn test_cross_crate_connection_sharing_concept() -> Result<()> { - // This test demonstrates that our TideManager can work with - // the same connection pattern as the main application - - let db_manager = create_test_db_manager().await; - - // Create multiple managers using the same connection pool - let tide_manager1 = TideManager::new_with_manager(db_manager.clone()); - let tide_manager2 = TideManager::new_with_manager(db_manager.clone()); - - // Create a template with manager1 - let template = TideTemplate::new("focus".to_string(), "daily".to_string(), 60.0); - tide_manager1.tide_template_repo.create_tide_template(&template).await?; - - // Create a tide with manager2 (different manager, same pool) - let tide = tide_manager2.create_tide_from_template(&template.id, None).await?; - - // Verify both can access the same data - let templates1 = tide_manager1.get_all_templates().await?; - let templates2 = tide_manager2.get_all_templates().await?; - - assert_eq!(templates1.len(), 1); - assert_eq!(templates2.len(), 1); - assert_eq!(templates1[0].id, templates2[0].id); - - // Verify tide is accessible from both managers - let tide1 = tide_manager1.get_tide(&tide.id).await?; - let tide2 = tide_manager2.get_tide(&tide.id).await?; - - assert!(tide1.is_some()); - assert!(tide2.is_some()); - assert_eq!(tide1.unwrap().id, tide2.unwrap().id); - - Ok(()) - } } \ No newline at end of file diff --git a/src-tauri/ebb_tide_manager/src/tide_scheduler.rs b/src-tauri/ebb_tide_manager/src/tide_scheduler.rs new file mode 100644 index 00000000..87ac50f9 --- /dev/null +++ b/src-tauri/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/ebb_tide_manager/src/tide_service.rs b/src-tauri/ebb_tide_manager/src/tide_service.rs new file mode 100644 index 00000000..f7276956 --- /dev/null +++ b/src-tauri/ebb_tide_manager/src/tide_service.rs @@ -0,0 +1,266 @@ +use ebb_db::{ + db_manager::{self, DbManager}, + db::{ + tide_repo::TideRepo, + tide_template_repo::TideTemplateRepo, + models::{tide::Tide, tide_template::TideTemplate}, + }, +}; +use std::sync::Arc; +use time::OffsetDateTime; +use thiserror::Error; + +#[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 { + /// Create a new TideService using the shared connection pool + 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, + }) + } + + /// Create a new TideService with a specific database manager (useful for testing) + 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, + } + } + + /// Create a new tide from an existing template + pub async fn create_tide_from_template( + &self, + template_id: &str, + start_time: Option, + ) -> Result { + // Validate template exists + let template = self + .tide_template_repo + .get_tide_template(template_id) + .await? + .ok_or_else(|| TideServiceError::TemplateNotFound { + template_id: template_id.to_string(), + })?; + + // Create tide with current time or specified start time + let start = start_time.unwrap_or_else(OffsetDateTime::now_utc); + let tide = Tide::from_template(&template, start); + + // Save to database + self.tide_repo.create_tide(&tide).await?; + + Ok(tide) + } + + /// Get all active tides (tides within their time window) + pub async fn get_active_tides(&self) -> Result> { + let tides = self.tide_repo.get_active_tides().await?; + Ok(tides) + } + + /// Get a specific tide by ID + pub async fn get_tide(&self, tide_id: &str) -> Result> { + let tide = self.tide_repo.get_tide(tide_id).await?; + Ok(tide) + } + + /// Get all available tide templates + pub async fn get_all_templates(&self) -> Result> { + let templates = self.tide_template_repo.get_all_tide_templates().await?; + Ok(templates) + } + + /// Create a new tide template + 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) + } + + /// Update an existing template + pub async fn update_template(&self, template: &TideTemplate) -> Result<()> { + self.tide_template_repo.update_tide_template(template).await?; + Ok(()) + } + + /// Delete a template + pub async fn delete_template(&self, template_id: &str) -> Result<()> { + self.tide_template_repo.delete_tide_template(template_id).await?; + Ok(()) + } + + /// Update a tide's progress (actual_amount) + 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(()) + } + + /// Complete a tide (sets completed_at) + pub async fn complete_tide(&self, tide_id: &str) -> Result<()> { + self.tide_repo.complete_tide(tide_id).await?; + Ok(()) + } + + /// Get tides by template ID + 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) + } + + /// Get tides in a date range + 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) + } + + /// Get all tides + pub async fn get_all_tides(&self) -> Result> { + let tides = self.tide_repo.get_all_tides().await?; + Ok(tides) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + use time::macros::datetime; + + 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); + + // If we get here without panicking, service creation works + 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(()) + } +} \ No newline at end of file From 35d47bdfd558c950cf719568817b8ebca2ad20c9 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 7 Sep 2025 10:37:04 -0600 Subject: [PATCH 05/40] remove uneeded comments --- src-tauri/ebb_tide_manager/src/lib.rs | 2 -- src-tauri/ebb_tide_manager/src/tide_service.rs | 18 ------------------ 2 files changed, 20 deletions(-) diff --git a/src-tauri/ebb_tide_manager/src/lib.rs b/src-tauri/ebb_tide_manager/src/lib.rs index 4fbc183d..f3da7529 100644 --- a/src-tauri/ebb_tide_manager/src/lib.rs +++ b/src-tauri/ebb_tide_manager/src/lib.rs @@ -13,8 +13,6 @@ pub enum TideManagerError { 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 { // Will be populated with lifecycle management functionality later } diff --git a/src-tauri/ebb_tide_manager/src/tide_service.rs b/src-tauri/ebb_tide_manager/src/tide_service.rs index f7276956..8585c3aa 100644 --- a/src-tauri/ebb_tide_manager/src/tide_service.rs +++ b/src-tauri/ebb_tide_manager/src/tide_service.rs @@ -33,7 +33,6 @@ pub struct TideService { } impl TideService { - /// Create a new TideService using the shared connection pool pub async fn new() -> Result { let db_manager = db_manager::DbManager::get_shared_ebb().await .map_err(|e| TideServiceError::Database(Box::new(e)))?; @@ -45,7 +44,6 @@ impl TideService { }) } - /// Create a new TideService with a specific database manager (useful for testing) pub fn new_with_manager(db_manager: Arc) -> Self { Self { tide_repo: TideRepo::new(db_manager.pool.clone()), @@ -54,13 +52,11 @@ impl TideService { } } - /// Create a new tide from an existing template pub async fn create_tide_from_template( &self, template_id: &str, start_time: Option, ) -> Result { - // Validate template exists let template = self .tide_template_repo .get_tide_template(template_id) @@ -69,35 +65,29 @@ impl TideService { template_id: template_id.to_string(), })?; - // Create tide with current time or specified start time let start = start_time.unwrap_or_else(OffsetDateTime::now_utc); let tide = Tide::from_template(&template, start); - // Save to database self.tide_repo.create_tide(&tide).await?; Ok(tide) } - /// Get all active tides (tides within their time window) pub async fn get_active_tides(&self) -> Result> { let tides = self.tide_repo.get_active_tides().await?; Ok(tides) } - /// Get a specific tide by ID pub async fn get_tide(&self, tide_id: &str) -> Result> { let tide = self.tide_repo.get_tide(tide_id).await?; Ok(tide) } - /// Get all available tide templates pub async fn get_all_templates(&self) -> Result> { let templates = self.tide_template_repo.get_all_tide_templates().await?; Ok(templates) } - /// Create a new tide template pub async fn create_template(&self, template: &TideTemplate) -> Result<()> { self.tide_template_repo.create_tide_template(template).await?; Ok(()) @@ -109,37 +99,31 @@ impl TideService { Ok(template) } - /// Update an existing template pub async fn update_template(&self, template: &TideTemplate) -> Result<()> { self.tide_template_repo.update_tide_template(template).await?; Ok(()) } - /// Delete a template pub async fn delete_template(&self, template_id: &str) -> Result<()> { self.tide_template_repo.delete_tide_template(template_id).await?; Ok(()) } - /// Update a tide's progress (actual_amount) 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(()) } - /// Complete a tide (sets completed_at) pub async fn complete_tide(&self, tide_id: &str) -> Result<()> { self.tide_repo.complete_tide(tide_id).await?; Ok(()) } - /// Get tides by template ID 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) } - /// Get tides in a date range pub async fn get_tides_in_date_range( &self, start: OffsetDateTime, @@ -149,7 +133,6 @@ impl TideService { Ok(tides) } - /// Get all tides pub async fn get_all_tides(&self) -> Result> { let tides = self.tide_repo.get_all_tides().await?; Ok(tides) @@ -195,7 +178,6 @@ mod tests { let db_manager = create_test_db_manager().await; let _tide_service = TideService::new_with_manager(db_manager); - // If we get here without panicking, service creation works Ok(()) } From 323b9b1fedec12946f61248ca66213461f342372 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 7 Sep 2025 10:49:27 -0600 Subject: [PATCH 06/40] scaffold TideManager --- src-tauri/ebb_tide_manager/src/lib.rs | 101 +++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/src-tauri/ebb_tide_manager/src/lib.rs b/src-tauri/ebb_tide_manager/src/lib.rs index f3da7529..aa7f4bd8 100644 --- a/src-tauri/ebb_tide_manager/src/lib.rs +++ b/src-tauri/ebb_tide_manager/src/lib.rs @@ -1,27 +1,120 @@ pub mod tide_service; pub mod tide_scheduler; +use std::sync::Arc; use thiserror::Error; +use tide_scheduler::{TideScheduler, TideSchedulerEvent, TideSchedulerError}; +use tide_service::{TideService, TideServiceError}; #[derive(Error, Debug)] pub enum TideManagerError { #[error("Service error: {0}")] - Service(#[from] tide_service::TideServiceError), + Service(#[from] TideServiceError), + #[error("Scheduler error: {0}")] + Scheduler(#[from] TideSchedulerError), + #[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 { - // Will be populated with lifecycle management functionality later + scheduler: Arc, + service: Arc, } impl TideManager { - /// Create a new TideManager + /// Create a new TideManager with default configuration (60 second intervals) pub async fn new() -> Result { + Self::new_with_interval(60).await + } + + /// Create a new TideManager with custom interval + pub async fn new_with_interval(interval_seconds: u64) -> Result { + let scheduler = Arc::new(TideScheduler::new(interval_seconds)?); + let service = Arc::new(TideService::new().await?); + Ok(Self { - // Placeholder for now + scheduler, + service, }) } + + /// Start the TideManager - begins listening to scheduler events + pub async fn start(&self) -> Result<()> { + // 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 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).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, + ) -> Result<()> { + match event { + TideSchedulerEvent::Check { timestamp: _ } => { + // Placeholder for tide lifecycle operations + Self::perform_tide_check(service).await?; + } + } + Ok(()) + } + + /// Perform tide lifecycle checks (placeholder implementation) + async fn perform_tide_check(_service: &TideService) -> Result<()> { + // TODO: Implement tide lifecycle operations: + // - Check for expired tides + // - Generate new tides from templates + // - Calculate progress + // - Send notifications + // - etc. + + println!("Performing tide check..."); + Ok(()) + } } \ No newline at end of file From 66694806285075822c71af4af00659e6be28de55 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 7 Sep 2025 11:13:58 -0600 Subject: [PATCH 07/40] add tide_template migrations --- src-tauri/ebb_tide_manager/src/lib.rs | 7 ----- src-tauri/src/ebb_db/src/migrations.rs | 37 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src-tauri/ebb_tide_manager/src/lib.rs b/src-tauri/ebb_tide_manager/src/lib.rs index aa7f4bd8..33f3931e 100644 --- a/src-tauri/ebb_tide_manager/src/lib.rs +++ b/src-tauri/ebb_tide_manager/src/lib.rs @@ -107,13 +107,6 @@ impl TideManager { /// Perform tide lifecycle checks (placeholder implementation) async fn perform_tide_check(_service: &TideService) -> Result<()> { - // TODO: Implement tide lifecycle operations: - // - Check for expired tides - // - Generate new tides from templates - // - Calculate progress - // - Send notifications - // - etc. - println!("Performing tide check..."); Ok(()) } diff --git a/src-tauri/src/ebb_db/src/migrations.rs b/src-tauri/src/ebb_db/src/migrations.rs index b37c3ad1..bd5cc81e 100644 --- a/src-tauri/src/ebb_db/src/migrations.rs +++ b/src-tauri/src/ebb_db/src/migrations.rs @@ -297,6 +297,43 @@ pub fn get_migrations() -> Vec { "#, 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', + 'learning', + 'weekly', + datetime('now'), + '0,1,2,3,4,5,6', + 600.0, + datetime('now'), + datetime('now') + ); + "#, + kind: MigrationKind::Up, + }, ] } From d5e12021fe9ff8374f768bcd985f05882ac6cb75 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Mon, 8 Sep 2025 21:12:05 -0600 Subject: [PATCH 08/40] add functionality to get or create tides from templates --- src-tauri/CLAUDE.md | 39 ++ src-tauri/ebb_tide_manager/src/lib.rs | 31 +- .../ebb_tide_manager/src/tide_service.rs | 511 +++++++++++++++++- .../ebb_tide_manager/src/time_helpers.rs | 223 ++++++++ .../src/ebb_db/src/db/models/tide_template.rs | 2 +- src-tauri/src/ebb_db/src/db/tide_repo.rs | 383 ++++++++++++- 6 files changed, 1171 insertions(+), 18 deletions(-) create mode 100644 src-tauri/ebb_tide_manager/src/time_helpers.rs 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/ebb_tide_manager/src/lib.rs b/src-tauri/ebb_tide_manager/src/lib.rs index 33f3931e..2560d568 100644 --- a/src-tauri/ebb_tide_manager/src/lib.rs +++ b/src-tauri/ebb_tide_manager/src/lib.rs @@ -1,9 +1,10 @@ -pub mod tide_service; pub mod tide_scheduler; +pub mod tide_service; +pub mod time_helpers; use std::sync::Arc; use thiserror::Error; -use tide_scheduler::{TideScheduler, TideSchedulerEvent, TideSchedulerError}; +use tide_scheduler::{TideScheduler, TideSchedulerError, TideSchedulerEvent}; use tide_service::{TideService, TideServiceError}; #[derive(Error, Debug)] @@ -39,23 +40,20 @@ impl TideManager { pub async fn new_with_interval(interval_seconds: u64) -> Result { let scheduler = Arc::new(TideScheduler::new(interval_seconds)?); let service = Arc::new(TideService::new().await?); - - Ok(Self { - scheduler, - service, - }) + + Ok(Self { scheduler, service }) } /// Start the TideManager - begins listening to scheduler events pub async fn start(&self) -> Result<()> { // 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 scheduler = Arc::clone(&self.scheduler); - + tokio::spawn(async move { while scheduler.is_running() { match receiver.recv().await { @@ -76,7 +74,7 @@ impl TideManager { } } }); - + Ok(()) } @@ -108,6 +106,17 @@ impl TideManager { /// Perform tide lifecycle checks (placeholder implementation) async fn perform_tide_check(_service: &TideService) -> Result<()> { println!("Performing tide check..."); + // get all active tides and tide templates + // create tides if needed (based on tide template) // These two should be a TideService method + // creating time to see what the actual time is // need to create a codeclimbers_db manager crate + // update tide progress // should be its own impl/struct called something like TideProgress. + // TideProgress is in charge of querying the db for progress and caching results so we only have to take snapshots at intervals rather than requerying the whole period + // it also handles the logic for determining if the tide is complete based on the goal amount and actual amount + // if actual > goal, validate the tide is complete by running full query + // if valid, complete the tide + // if not valid, set the progress cache to newly updated amount + + // Ok(()) } -} \ No newline at end of file +} diff --git a/src-tauri/ebb_tide_manager/src/tide_service.rs b/src-tauri/ebb_tide_manager/src/tide_service.rs index 8585c3aa..2ce331d5 100644 --- a/src-tauri/ebb_tide_manager/src/tide_service.rs +++ b/src-tauri/ebb_tide_manager/src/tide_service.rs @@ -73,11 +73,6 @@ impl TideService { Ok(tide) } - pub async fn get_active_tides(&self) -> Result> { - let tides = self.tide_repo.get_active_tides().await?; - Ok(tides) - } - pub async fn get_tide(&self, tide_id: &str) -> Result> { let tide = self.tide_repo.get_tide(tide_id).await?; Ok(tide) @@ -137,6 +132,53 @@ impl TideService { 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?; + + // 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(); + + // 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(); + + // 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) { + let new_tide = self.create_tide_from_template(&template.id, Some(evaluation_time)).await?; + 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 + } + } } #[cfg(test)] @@ -145,6 +187,12 @@ mod tests { 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; @@ -245,4 +293,457 @@ mod tests { 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(()) + } } \ No newline at end of file diff --git a/src-tauri/ebb_tide_manager/src/time_helpers.rs b/src-tauri/ebb_tide_manager/src/time_helpers.rs new file mode 100644 index 00000000..7cfa757f --- /dev/null +++ b/src-tauri/ebb_tide_manager/src/time_helpers.rs @@ -0,0 +1,223 @@ +use time::OffsetDateTime; + +/// Get the start of the week (Monday at 00:00:00) for a given time +/// This is used for weekly tide calculations +pub fn get_week_start(time: OffsetDateTime) -> OffsetDateTime { + let weekday = time.weekday().number_days_from_sunday() as i64; + let days_since_monday = if weekday == 0 { 6 } else { weekday - 1 }; + + time.replace_hour(0) + .unwrap() + .replace_minute(0) + .unwrap() + .replace_second(0) + .unwrap() + .replace_nanosecond(0) + .unwrap() + - time::Duration::days(days_since_monday) +} + +/// Get the start of the month (1st day at 00:00:00) for a given time +/// This is used for monthly tide calculations +pub fn get_month_start(time: OffsetDateTime) -> OffsetDateTime { + time.replace_day(1) + .unwrap() + .replace_hour(0) + .unwrap() + .replace_minute(0) + .unwrap() + .replace_second(0) + .unwrap() + .replace_nanosecond(0) + .unwrap() +} + +/// Get the start of the day (00:00:00) for a given time +/// This is used for daily tide calculations +pub fn get_day_start(time: OffsetDateTime) -> OffsetDateTime { + time.replace_hour(0) + .unwrap() + .replace_minute(0) + .unwrap() + .replace_second(0) + .unwrap() + .replace_nanosecond(0) + .unwrap() +} + +#[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 + let monday = datetime!(2025-01-06 15:30:45 UTC); // Monday afternoon + let week_start = get_week_start(monday); + let expected = datetime!(2025-01-06 00:00:00 UTC); // Monday 00:00:00 + assert_eq!(week_start, expected); + } + + #[test] + fn test_get_week_start_tuesday() { + // Tuesday should return previous Monday at 00:00:00 + let tuesday = datetime!(2025-01-07 10:15:30 UTC); // Tuesday morning + let week_start = get_week_start(tuesday); + let expected = datetime!(2025-01-06 00:00:00 UTC); // Previous Monday 00:00:00 + assert_eq!(week_start, expected); + } + + #[test] + fn test_get_week_start_friday() { + // Friday should return Monday of the same week at 00:00:00 + let friday = datetime!(2025-01-03 18:45:12 UTC); // Friday evening + let week_start = get_week_start(friday); + let expected = datetime!(2024-12-30 00:00:00 UTC); // Monday of that week 00:00:00 + assert_eq!(week_start, expected); + } + + #[test] + fn test_get_week_start_sunday() { + // Sunday should return previous Monday at 00:00:00 + let sunday = datetime!(2025-01-05 12:00:00 UTC); // Sunday noon + let week_start = get_week_start(sunday); + let expected = datetime!(2024-12-30 00:00:00 UTC); // Previous Monday 00:00:00 + assert_eq!(week_start, expected); + } + + #[test] + fn test_get_week_start_saturday() { + // Saturday should return Monday of the same week at 00:00:00 + let saturday = datetime!(2025-01-04 08:20:15 UTC); // Saturday morning + let week_start = get_week_start(saturday); + let expected = datetime!(2024-12-30 00:00:00 UTC); // Monday of that week 00:00:00 + assert_eq!(week_start, expected); + } + + #[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); + let expected = datetime!(2025-01-06 00:00:00 UTC); // Monday 00:00:00 + assert_eq!(week_start, expected); + } + + #[test] + fn test_get_month_start_first_day() { + // First day of month should return the same day at 00:00:00 + let first_day = datetime!(2025-01-01 15:30:45 UTC); // January 1st afternoon + let month_start = get_month_start(first_day); + let expected = datetime!(2025-01-01 00:00:00 UTC); // January 1st 00:00:00 + assert_eq!(month_start, expected); + } + + #[test] + fn test_get_month_start_middle_of_month() { + // Middle of month should return first day at 00:00:00 + let mid_month = datetime!(2025-01-15 10:25:30 UTC); // January 15th morning + let month_start = get_month_start(mid_month); + let expected = datetime!(2025-01-01 00:00:00 UTC); // January 1st 00:00:00 + assert_eq!(month_start, expected); + } + + #[test] + fn test_get_month_start_end_of_month() { + // End of month should return first day at 00:00:00 + let end_month = datetime!(2025-01-31 23:59:59 UTC); // January 31st end of day + let month_start = get_month_start(end_month); + let expected = datetime!(2025-01-01 00:00:00 UTC); // January 1st 00:00:00 + assert_eq!(month_start, expected); + } + + #[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); + let expected = datetime!(2025-02-01 00:00:00 UTC); // February 1st 00:00:00 + assert_eq!(month_start, expected); + } + + #[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); + let expected = datetime!(2025-12-01 00:00:00 UTC); // December 1st 00:00:00 + assert_eq!(month_start, expected); + } + + #[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); + let expected = datetime!(2025-06-01 00:00:00 UTC); // June 1st 00:00:00 + assert_eq!(month_start, expected); + } + + #[test] + fn test_get_day_start_morning() { + // Morning time should return same day at 00:00:00 + let morning = datetime!(2025-01-15 08:30:45 UTC); // January 15th morning + let day_start = get_day_start(morning); + let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 + assert_eq!(day_start, expected); + } + + #[test] + fn test_get_day_start_afternoon() { + // Afternoon time should return same day at 00:00:00 + let afternoon = datetime!(2025-01-15 14:25:12 UTC); // January 15th afternoon + let day_start = get_day_start(afternoon); + let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 + assert_eq!(day_start, expected); + } + + #[test] + fn test_get_day_start_evening() { + // Evening time should return same day at 00:00:00 + let evening = datetime!(2025-01-15 21:45:30 UTC); // January 15th evening + let day_start = get_day_start(evening); + let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 + assert_eq!(day_start, expected); + } + + #[test] + fn test_get_day_start_end_of_day() { + // End of day should return same day at 00:00:00 + 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); + let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 + assert_eq!(day_start, expected); + } + + #[test] + fn test_get_day_start_already_midnight() { + // Time already at midnight should return same time + let midnight = datetime!(2025-01-15 00:00:00 UTC); // January 15th at midnight + let day_start = get_day_start(midnight); + let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 + assert_eq!(day_start, expected); + } + + #[test] + fn test_get_day_start_with_microseconds() { + // Time with microseconds should be normalized to 00:00:00 + let precise_time = datetime!(2025-01-15 12:34:56.789123 UTC); // January 15th with microseconds + let day_start = get_day_start(precise_time); + let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 + assert_eq!(day_start, expected); + } + + #[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); + let expected = datetime!(2024-02-29 00:00:00 UTC); // February 29th 00:00:00 + assert_eq!(day_start, expected); + } +} \ 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 index b0a7d45c..4c860356 100644 --- a/src-tauri/src/ebb_db/src/db/models/tide_template.rs +++ b/src-tauri/src/ebb_db/src/db/models/tide_template.rs @@ -189,4 +189,4 @@ mod tests { let parsed_days = template.get_days_of_week(); assert_eq!(parsed_days, original_days); } -} \ No newline at end of file +} \ 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 index c7fe372d..c7fe401f 100644 --- a/src-tauri/src/ebb_db/src/db/tide_repo.rs +++ b/src-tauri/src/ebb_db/src/db/tide_repo.rs @@ -55,10 +55,14 @@ impl TideRepo { 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(now) + .bind(evaluation_time) .fetch_all(&self.pool) .await?; @@ -163,6 +167,39 @@ impl TideRepo { 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)] @@ -306,4 +343,348 @@ mod tests { 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 From a9289a1863b2ec0d04c2582315249fe38994bcc2 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Mon, 8 Sep 2025 21:12:34 -0600 Subject: [PATCH 09/40] move tide anager into src directory --- src-tauri/{ => src}/ebb_tide_manager/.gitignore | 0 src-tauri/{ => src}/ebb_tide_manager/Cargo.lock | 0 src-tauri/{ => src}/ebb_tide_manager/Cargo.toml | 0 src-tauri/{ => src}/ebb_tide_manager/src/lib.rs | 0 src-tauri/{ => src}/ebb_tide_manager/src/tide_scheduler.rs | 0 src-tauri/{ => src}/ebb_tide_manager/src/tide_service.rs | 0 src-tauri/{ => src}/ebb_tide_manager/src/time_helpers.rs | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename src-tauri/{ => src}/ebb_tide_manager/.gitignore (100%) rename src-tauri/{ => src}/ebb_tide_manager/Cargo.lock (100%) rename src-tauri/{ => src}/ebb_tide_manager/Cargo.toml (100%) rename src-tauri/{ => src}/ebb_tide_manager/src/lib.rs (100%) rename src-tauri/{ => src}/ebb_tide_manager/src/tide_scheduler.rs (100%) rename src-tauri/{ => src}/ebb_tide_manager/src/tide_service.rs (100%) rename src-tauri/{ => src}/ebb_tide_manager/src/time_helpers.rs (100%) diff --git a/src-tauri/ebb_tide_manager/.gitignore b/src-tauri/src/ebb_tide_manager/.gitignore similarity index 100% rename from src-tauri/ebb_tide_manager/.gitignore rename to src-tauri/src/ebb_tide_manager/.gitignore diff --git a/src-tauri/ebb_tide_manager/Cargo.lock b/src-tauri/src/ebb_tide_manager/Cargo.lock similarity index 100% rename from src-tauri/ebb_tide_manager/Cargo.lock rename to src-tauri/src/ebb_tide_manager/Cargo.lock diff --git a/src-tauri/ebb_tide_manager/Cargo.toml b/src-tauri/src/ebb_tide_manager/Cargo.toml similarity index 100% rename from src-tauri/ebb_tide_manager/Cargo.toml rename to src-tauri/src/ebb_tide_manager/Cargo.toml diff --git a/src-tauri/ebb_tide_manager/src/lib.rs b/src-tauri/src/ebb_tide_manager/src/lib.rs similarity index 100% rename from src-tauri/ebb_tide_manager/src/lib.rs rename to src-tauri/src/ebb_tide_manager/src/lib.rs diff --git a/src-tauri/ebb_tide_manager/src/tide_scheduler.rs b/src-tauri/src/ebb_tide_manager/src/tide_scheduler.rs similarity index 100% rename from src-tauri/ebb_tide_manager/src/tide_scheduler.rs rename to src-tauri/src/ebb_tide_manager/src/tide_scheduler.rs diff --git a/src-tauri/ebb_tide_manager/src/tide_service.rs b/src-tauri/src/ebb_tide_manager/src/tide_service.rs similarity index 100% rename from src-tauri/ebb_tide_manager/src/tide_service.rs rename to src-tauri/src/ebb_tide_manager/src/tide_service.rs diff --git a/src-tauri/ebb_tide_manager/src/time_helpers.rs b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs similarity index 100% rename from src-tauri/ebb_tide_manager/src/time_helpers.rs rename to src-tauri/src/ebb_tide_manager/src/time_helpers.rs From 0cad32a7a6f1b50bdee0c70aa93c51c009220fd1 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Wed, 10 Sep 2025 09:14:50 -0600 Subject: [PATCH 10/40] adding tides repo functionality --- src-tauri/src/ebb_db/src/db.rs | 2 + .../src/ebb_db/src/db/activity_state_repo.rs | 408 +++++++++ src-tauri/src/ebb_db/src/db/models.rs | 3 + .../ebb_db/src/db/models/activity_state.rs | 55 ++ .../src/db/models/activity_state_tag.rs | 28 + src-tauri/src/ebb_db/src/db/models/tag.rs | 38 + src-tauri/src/ebb_db/src/db/tag_repo.rs | 200 +++++ src-tauri/src/ebb_tide_manager/Cargo.toml | 2 +- src-tauri/src/ebb_tide_manager/NEXT_STEPS.md | 133 +++ src-tauri/src/ebb_tide_manager/src/lib.rs | 1 + .../src/ebb_tide_manager/src/tide_progress.rs | 805 ++++++++++++++++++ 11 files changed, 1674 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/ebb_db/src/db/activity_state_repo.rs create mode 100644 src-tauri/src/ebb_db/src/db/models/activity_state.rs create mode 100644 src-tauri/src/ebb_db/src/db/models/activity_state_tag.rs create mode 100644 src-tauri/src/ebb_db/src/db/models/tag.rs create mode 100644 src-tauri/src/ebb_db/src/db/tag_repo.rs create mode 100644 src-tauri/src/ebb_tide_manager/NEXT_STEPS.md create mode 100644 src-tauri/src/ebb_tide_manager/src/tide_progress.rs diff --git a/src-tauri/src/ebb_db/src/db.rs b/src-tauri/src/ebb_db/src/db.rs index c504af10..ea12aa90 100644 --- a/src-tauri/src/ebb_db/src/db.rs +++ b/src-tauri/src/ebb_db/src/db.rs @@ -1,5 +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..f08a1d06 --- /dev/null +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -0,0 +1,408 @@ +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 + pub async fn calculate_tagged_duration_in_range( + &self, + tag_name: &str, + start_time: OffsetDateTime, + end_time: OffsetDateTime, + ) -> Result { + let total_minutes: Option = sqlx::query_scalar( + "SELECT + COALESCE(SUM( + (julianday( + CASE + WHEN activity_state.end_time > ?2 THEN ?2 + ELSE activity_state.end_time + END + ) - julianday( + CASE + WHEN activity_state.start_time < ?3 THEN ?3 + ELSE activity_state.start_time + END + )) * 24 * 60 + ), 0.0) as total_minutes + 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.state = 'ACTIVE' + AND activity_state.start_time < ?2 + AND activity_state.end_time > ?3" + ) + .bind(tag_name) + .bind(end_time) + .bind(start_time) + .fetch_one(&self.pool) + .await?; + + Ok(total_minutes.unwrap_or(0.0)) + } + + /// 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 sqlx::sqlite::SqlitePoolOptions; + use time::macros::datetime; + + 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 tables we need for testing + 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(); + + pool + } + + #[tokio::test] + async fn test_activity_state_repo_creation() -> Result<()> { + let pool = create_test_db().await; + let _repo = ActivityStateRepo::new(pool); + Ok(()) + } + + #[tokio::test] + async fn test_get_activity_state() -> Result<()> { + let pool = create_test_db().await; + let repo = ActivityStateRepo::new(pool.clone()); + + // Insert test data + let start_time = datetime!(2025-01-06 09:00 UTC); + let end_time = datetime!(2025-01-06 10:00 UTC); + let created_at = OffsetDateTime::now_utc(); + + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (1, 'ACTIVE', 5, ?1, ?2, ?3)" + ) + .bind(start_time) + .bind(end_time) + .bind(created_at) + .execute(&pool) + .await?; + + // Test retrieval + let result = repo.get_activity_state(1).await?; + assert!(result.is_some()); + + let activity_state = result.unwrap(); + assert_eq!(activity_state.id, 1); + assert_eq!(activity_state.state, "ACTIVE"); + assert_eq!(activity_state.app_switches, 5); + assert_eq!(activity_state.start_time, start_time); + assert_eq!(activity_state.end_time, end_time); + + Ok(()) + } + + #[tokio::test] + async fn test_get_activity_state_not_found() -> Result<()> { + let pool = create_test_db().await; + let repo = ActivityStateRepo::new(pool); + + let result = repo.get_activity_state(999).await?; + assert!(result.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_in_range() -> Result<()> { + let pool = create_test_db().await; + let repo = ActivityStateRepo::new(pool.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(&pool) + .await?; + + // Create 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(&pool) + .await?; + + // 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(&pool) + .await?; + + // Create a different tag that shouldn't match + let other_tag_id = "test-learning-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(other_tag_id) + .bind("learning") + .bind("activity") + .bind(false) + .bind(false) + .bind(now) + .bind(now) + .execute(&pool) + .await?; + + // Create activity state outside the time range (shouldn't be counted) + let outside_start = datetime!(2025-01-06 06:00 UTC); + let outside_end = datetime!(2025-01-06 07:00 UTC); + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (3, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(outside_start) + .bind(outside_end) + .bind(now) + .execute(&pool) + .await?; + + // Create INACTIVE activity state (shouldn't be counted even if in range and tagged) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (4, 'INACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(start_time) + .bind(mid_time) + .bind(now) + .execute(&pool) + .await?; + + // Link first two activity states to the "creating" tag (should be counted) + 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(&pool) + .await?; + + 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(&pool) + .await?; + + // Link activity state #3 (outside range) to creating tag (shouldn't be counted due to range) + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('3', ?1, ?2, ?3)" + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&pool) + .await?; + + // Link activity state #4 (INACTIVE) to creating tag (shouldn't be counted due to state) + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('4', ?1, ?2, ?3)" + ) + .bind(tag_id) + .bind(now) + .bind(now) + .execute(&pool) + .await?; + + // Create an activity state with the wrong tag (shouldn't be counted) + sqlx::query( + "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) + VALUES (5, 'ACTIVE', 0, ?1, ?2, ?3)" + ) + .bind(start_time) + .bind(mid_time) + .bind(now) + .execute(&pool) + .await?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES ('5', ?1, ?2, ?3)" + ) + .bind(other_tag_id) // Link to "learning" tag instead of "creating" + .bind(now) + .bind(now) + .execute(&pool) + .await?; + + // Test the calculation - should return 120 minutes (2 hours) + let range_start = datetime!(2025-01-06 08:00 UTC); + let range_end = datetime!(2025-01-06 12:00 UTC); + + let total_duration = repo.calculate_tagged_duration_in_range( + "creating", + range_start, + range_end, + ).await?; + + // Use approximate equality due to floating point precision in SQLite julianday calculations + assert!((total_duration - 120.0).abs() < 0.01, "Expected ~120 minutes, got {}", total_duration); + + Ok(()) + } +} \ No newline at end of file diff --git a/src-tauri/src/ebb_db/src/db/models.rs b/src-tauri/src/ebb_db/src/db/models.rs index 42cd2132..9fefa090 100644 --- a/src-tauri/src/ebb_db/src/db/models.rs +++ b/src-tauri/src/ebb_db/src/db/models.rs @@ -1,4 +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/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_tide_manager/Cargo.toml b/src-tauri/src/ebb_tide_manager/Cargo.toml index 2d1648b9..86b3c939 100644 --- a/src-tauri/src/ebb_tide_manager/Cargo.toml +++ b/src-tauri/src/ebb_tide_manager/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -ebb-db = { path = "../src/ebb_db" } +ebb-db = { path = "../ebb_db" } time = { version = "0.3", features = ["serde"] } tokio = { version = "1.42", features = ["full"] } thiserror = "2.0" 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 index 2560d568..75f24337 100644 --- a/src-tauri/src/ebb_tide_manager/src/lib.rs +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -1,5 +1,6 @@ pub mod tide_scheduler; pub mod tide_service; +pub mod tide_progress; pub mod time_helpers; use std::sync::Arc; 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..1aaf5f80 --- /dev/null +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -0,0 +1,805 @@ +use ebb_db::{ + db_manager::{self, DbManager}, + db::{ + activity_state_repo::ActivityStateRepo, + models::tide::Tide, + }, +}; +use std::collections::HashMap; +use std::sync::Arc; +use time::OffsetDateTime; +use thiserror::Error; +use tokio::sync::Mutex; + +#[derive(Error, Debug)] +pub enum TideProgressError { + #[error("Database error: {0}")] + Database(#[from] Box), + #[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) -> 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 { + // Cache hit - calculate incremental progress + 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 + 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(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ebb_db::db::models::tide_template::TideTemplate; + use sqlx::sqlite::SqlitePoolOptions; + use time::macros::datetime; + + 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(); + + // Create the tables we need for testing + 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 }) + } + + #[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 only capture half of first activity (30 minutes) + let partial_evaluation = datetime!(2025-01-06 09:30 UTC); + let progress = tide_progress.calculate_tide_progress(&tide, partial_evaluation).await?; + assert!((progress - 30.0).abs() < 0.01, "Expected ~30 minutes, got {}", progress); + + // Test evaluation at 10:30 - should capture first activity + half of second (90 minutes) + let mid_evaluation = datetime!(2025-01-06 10:30 UTC); + let progress = tide_progress.calculate_tide_progress(&tide, mid_evaluation).await?; + assert!((progress - 90.0).abs() < 0.01, "Expected ~90 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).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).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).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).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).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).await?; + let _progress2 = tide_progress.get_tide_progress_cached(&tide2, evaluation_time).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(()) + } +} \ No newline at end of file From 1a679dd3f16bbd804fcaa1267c943b8f509013c4 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 13 Sep 2025 14:43:27 -0600 Subject: [PATCH 11/40] implement perform_tide_check orchestration --- src-tauri/src/ebb_tide_manager/src/lib.rs | 240 +++++++++- .../src/ebb_tide_manager/src/tide_progress.rs | 437 +++++++++++++++--- 2 files changed, 595 insertions(+), 82 deletions(-) diff --git a/src-tauri/src/ebb_tide_manager/src/lib.rs b/src-tauri/src/ebb_tide_manager/src/lib.rs index 75f24337..6fdb6280 100644 --- a/src-tauri/src/ebb_tide_manager/src/lib.rs +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -5,8 +5,10 @@ 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 { @@ -14,6 +16,8 @@ pub enum TideManagerError { 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")] @@ -29,6 +33,7 @@ pub type Result = std::result::Result; pub struct TideManager { scheduler: Arc, service: Arc, + progress: Arc, } impl TideManager { @@ -41,8 +46,9 @@ impl TideManager { pub async fn new_with_interval(interval_seconds: u64) -> Result { 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 }) + Ok(Self { scheduler, service, progress }) } /// Start the TideManager - begins listening to scheduler events @@ -53,13 +59,14 @@ impl TideManager { // 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).await { + if let Err(e) = Self::handle_scheduler_event(event, &service, &progress).await { eprintln!("Error handling scheduler event: {}", e); } } @@ -94,30 +101,229 @@ impl TideManager { 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).await?; + Self::perform_tide_check(service, progress).await?; } } Ok(()) } - /// Perform tide lifecycle checks (placeholder implementation) - async fn perform_tide_check(_service: &TideService) -> Result<()> { - println!("Performing tide check..."); - // get all active tides and tide templates - // create tides if needed (based on tide template) // These two should be a TideService method - // creating time to see what the actual time is // need to create a codeclimbers_db manager crate - // update tide progress // should be its own impl/struct called something like TideProgress. - // TideProgress is in charge of querying the db for progress and caching results so we only have to take snapshots at intervals rather than requerying the whole period - // it also handles the logic for determining if the tide is complete based on the goal amount and actual amount - // if actual > goal, validate the tide is complete by running full query - // if valid, complete the tide - // if not valid, set the progress cache to newly updated amount - - // + /// 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?; + + 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?; + progress.clear_tide_cache(&tide.id).await; // Clear cache for completed tide + } + } + + 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 index 1aaf5f80..0e06ee3c 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -5,6 +5,7 @@ use ebb_db::{ models::tide::Tide, }, }; +use crate::tide_service::{TideService, TideServiceError}; use std::collections::HashMap; use std::sync::Arc; use time::OffsetDateTime; @@ -15,6 +16,8 @@ use tokio::sync::Mutex; pub enum TideProgressError { #[error("Database error: {0}")] Database(#[from] Box), + #[error("Service error: {0}")] + Service(#[from] TideServiceError), #[error("Invalid operation: {message}")] InvalidOperation { message: String }, } @@ -132,79 +135,41 @@ impl TideProgress { 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 { + 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) + } + } + + /// 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?; + + // 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 sqlx::sqlite::SqlitePoolOptions; use time::macros::datetime; - 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(); - - // Create the tables we need for testing - 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 }) - } + use crate::test_helpers::create_test_db_manager; #[tokio::test] async fn test_cached_progress_creation() -> Result<()> { @@ -802,4 +767,346 @@ mod tests { 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(()) + } } \ No newline at end of file From 5077d27621b00aff985249d51f8b0b22f389e3da Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Wed, 17 Sep 2025 17:05:38 -0600 Subject: [PATCH 12/40] add initial linking of tides to the ebb app --- src-tauri/Cargo.lock | 12 + src-tauri/Cargo.toml | 1 + .../src/ebb_db/src/db/activity_state_repo.rs | 523 ++++++++++++++++-- src-tauri/src/ebb_db/src/migrations.rs | 2 +- src-tauri/src/ebb_tide_manager/Cargo.lock | 11 + src-tauri/src/ebb_tide_manager/Cargo.toml | 2 +- src-tauri/src/ebb_tide_manager/src/lib.rs | 5 +- .../src/ebb_tide_manager/src/tide_service.rs | 63 ++- .../src/ebb_tide_manager/src/time_helpers.rs | 321 +++++++++-- src-tauri/src/main.rs | 43 ++ 10 files changed, 885 insertions(+), 98 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aea24c49..4abf3ef4 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,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "ebb_tide_manager" +version = "0.1.0" +dependencies = [ + "ebb-db", + "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/src/db/activity_state_repo.rs b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs index f08a1d06..c6039c9c 100644 --- a/src-tauri/src/ebb_db/src/db/activity_state_repo.rs +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -69,36 +69,122 @@ impl ActivityStateRepo { start_time: OffsetDateTime, end_time: OffsetDateTime, ) -> Result { + log::debug!("Calculating tagged duration for tag '{}' from {} to {}", tag_name, start_time, end_time); + let total_minutes: Option = sqlx::query_scalar( - "SELECT - COALESCE(SUM( - (julianday( - CASE + "SELECT + COALESCE(SUM(creating_minutes), 0.0) as total_minutes + FROM ( + SELECT + activity_state.id, + ROUND((julianday( + CASE WHEN activity_state.end_time > ?2 THEN ?2 ELSE activity_state.end_time END ) - julianday( - CASE + CASE WHEN activity_state.start_time < ?3 THEN ?3 ELSE activity_state.start_time END - )) * 24 * 60 - ), 0.0) as total_minutes + )) * 24 * 60) as creating_minutes + 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 + GROUP BY activity_state.id + ) as distinct_activities" + ) + .bind(tag_name) + .bind(end_time) + .bind(start_time) + .fetch_one(&self.pool) + .await?; + + // Debug: Print the actual SQL query with substituted parameters + let debug_sql = format!( + "SELECT + COALESCE(SUM(creating_minutes), 0.0) as total_minutes + FROM ( + SELECT + activity_state.id, + (julianday( + CASE + WHEN activity_state.end_time > '{}' THEN '{}' + ELSE activity_state.end_time + END + ) - julianday( + CASE + WHEN activity_state.start_time < '{}' THEN '{}' + ELSE activity_state.start_time + END + )) * 24 * 60 as creating_minutes + 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 = '{}' + AND activity_state.start_time < '{}' + AND activity_state.end_time > '{}' + GROUP BY activity_state.id + ) as distinct_activities;", + end_time, end_time, start_time, start_time, tag_name, end_time, start_time + ); + + println!("=== DEBUG SQL QUERY ==="); + println!("{}", debug_sql); + println!("Query parameters: tag_name='{}', end_time={}, start_time={}", tag_name, end_time, start_time); + println!("Total minutes result: {:?}", total_minutes); + println!("======================="); + + Ok(total_minutes.unwrap_or(0.0)) + } + + /// 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.state = 'ACTIVE' AND activity_state.start_time < ?2 - AND activity_state.end_time > ?3" + AND activity_state.end_time > ?3 + ORDER BY activity_state.start_time" ) .bind(tag_name) .bind(end_time) .bind(start_time) - .fetch_one(&self.pool) + .fetch_all(&self.pool) .await?; - Ok(total_minutes.unwrap_or(0.0)) + println!("=== DEBUG ACTIVITY STATES FOR TAG '{}' ===", tag_name); + println!("Query range: {} to {}", start_time, end_time); + println!("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; + + println!(" ID: {}, Original: {} to {}, Clipped: {} to {}, Duration: {:.2} min", + id, start, end, actual_start, actual_end, duration); + total_manual += duration; + } + } + println!("Manual calculation total: {:.2} minutes", total_manual); + println!("==============================================="); + + Ok(()) } /// Get a single activity state by ID @@ -116,9 +202,398 @@ impl ActivityStateRepo { #[cfg(test)] mod tests { + use super::*; + use sqlx::{Pool, Sqlite}; + + /// 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![ + ("04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e", "creating"), + ("8a6cc870-4339-4641-9ded-50e9d34b255a", "other_tag"), + ("e1f75007-59cf-4f4a-a33c-1570742daf2b", "programming"), + ("cff963a1-655a-4a1a-8d69-10d1367dc742", "communication"), + ("1150c2f1-5d32-47b9-9175-a703a93c497f", "planning"), + ("3aeb7eb9-073b-4e62-9442-b5c7db65b654", "research"), + ("63672549-bb63-492a-87b1-20ff87397bf8", "review"), + ("882ee9eb-ce03-4221-a265-682913984eb1", "debugging"), + ("896106c9-c1e3-4379-b498-85e266860866", "testing"), + ("1f00d5cb-8646-4af6-a363-1ca67e590d14", "meeting"), + ]; + + for (id, name) in tags { + sqlx::query("INSERT INTO tag (id, name) VALUES (?1, ?2)") + .bind(id) + .bind(name) + .execute(pool) + .await?; + } + + Ok(()) + } + + /// Seed activity states for testing based on your example data + pub async fn seed_test_activity_states(pool: &Pool) -> Result<()> { + // Activity states from your dataset - each is 2 minutes long + let activity_states = vec![ + (109020, "ACTIVE", 3, "2025-09-14T17:24:25.006192Z", "2025-09-14T17:26:25.006192Z"), + (109019, "ACTIVE", 3, "2025-09-14T17:17:27.71745Z", "2025-09-14T17:19:27.71745Z"), + (109018, "ACTIVE", 1, "2025-09-14T17:04:47.485032Z", "2025-09-14T17:06:47.485032Z"), + (109017, "ACTIVE", 5, "2025-09-14T17:02:47.485032Z", "2025-09-14T17:04:47.485032Z"), + (109016, "ACTIVE", 2, "2025-09-14T16:40:40.685228Z", "2025-09-14T16:42:40.685228Z"), + (109015, "ACTIVE", 7, "2025-09-14T16:35:33.339528Z", "2025-09-14T16:37:33.339528Z"), + (109014, "ACTIVE", 2, "2025-09-14T16:31:58.586187Z", "2025-09-14T16:33:58.586187Z"), + (109013, "ACTIVE", 6, "2025-09-14T16:29:58.586187Z", "2025-09-14T16:31:58.586187Z"), + (109012, "ACTIVE", 1, "2025-09-14T16:27:58.586187Z", "2025-09-14T16:29:58.586187Z"), + (109011, "ACTIVE", 0, "2025-09-14T16:25:58.586187Z", "2025-09-14T16:27:58.586187Z"), + (109010, "ACTIVE", 6, "2025-09-14T16:23:58.586187Z", "2025-09-14T16:25:58.586187Z"), + (109009, "ACTIVE", 3, "2025-09-14T16:21:58.586187Z", "2025-09-14T16:23:58.586187Z"), + (109008, "ACTIVE", 3, "2025-09-14T16:19:58.586187Z", "2025-09-14T16:21:58.586187Z"), + (109007, "ACTIVE", 4, "2025-09-14T16:17:58.586187Z", "2025-09-14T16:19:58.586187Z"), + (109006, "ACTIVE", 5, "2025-09-14T16:15:58.586187Z", "2025-09-14T16:17:58.586187Z"), + (109005, "ACTIVE", 4, "2025-09-14T16:13:58.586187Z", "2025-09-14T16:15:58.586187Z"), + (109004, "ACTIVE", 2, "2025-09-14T16:11:58.586187Z", "2025-09-14T16:13:58.586187Z"), + // Additional activity states needed for the extended tag data + (108836, "ACTIVE", 1, "2025-09-14T05:49:16Z", "2025-09-14T05:51:16Z"), + (108833, "ACTIVE", 2, "2025-09-14T03:12:13Z", "2025-09-14T03:14:13Z"), + (108832, "ACTIVE", 1, "2025-09-14T02:53:49Z", "2025-09-14T02:55:49Z"), + (108831, "ACTIVE", 3, "2025-09-14T02:51:49Z", "2025-09-14T02:53:49Z"), + (108830, "ACTIVE", 1, "2025-09-14T02:49:49Z", "2025-09-14T02:51:49Z"), + (108829, "ACTIVE", 1, "2025-09-14T02:47:49Z", "2025-09-14T02:49:49Z"), + (108828, "ACTIVE", 1, "2025-09-14T02:45:49Z", "2025-09-14T02:47:49Z"), + (108827, "ACTIVE", 3, "2025-09-14T02:43:49Z", "2025-09-14T02:45:49Z"), + (108826, "ACTIVE", 1, "2025-09-14T02:41:49Z", "2025-09-14T02:43:49Z"), + (108825, "ACTIVE", 1, "2025-09-14T02:39:49Z", "2025-09-14T02:41:49Z"), + (108824, "ACTIVE", 1, "2025-09-14T02:37:49Z", "2025-09-14T02:39:49Z"), + (108823, "ACTIVE", 3, "2025-09-14T02:35:49Z", "2025-09-14T02:37:49Z"), + (108822, "ACTIVE", 3, "2025-09-14T02:33:49Z", "2025-09-14T02:35:49Z"), + (108821, "ACTIVE", 3, "2025-09-14T02:31:49Z", "2025-09-14T02:33:49Z"), + (108820, "ACTIVE", 3, "2025-09-14T02:29:49Z", "2025-09-14T02:31:49Z"), + (108819, "ACTIVE", 1, "2025-09-14T02:27:49Z", "2025-09-14T02:29:49Z"), + (108818, "ACTIVE", 3, "2025-09-14T02:25:49Z", "2025-09-14T02:27:49Z"), + (108817, "ACTIVE", 2, "2025-09-14T02:23:49Z", "2025-09-14T02:25:49Z"), + (108816, "ACTIVE", 6, "2025-09-14T02:21:49Z", "2025-09-14T02:23:49Z"), + (108815, "ACTIVE", 8, "2025-09-14T02:19:49Z", "2025-09-14T02:21:49Z"), + (108814, "ACTIVE", 8, "2025-09-14T02:17:49Z", "2025-09-14T02:19:49Z"), + (108813, "ACTIVE", 8, "2025-09-14T02:15:49Z", "2025-09-14T02:17:49Z"), + (108812, "ACTIVE", 2, "2025-09-14T02:13:49Z", "2025-09-14T02:15:49Z"), + (108811, "ACTIVE", 2, "2025-09-14T02:11:49Z", "2025-09-14T02:13:49Z"), + (108810, "ACTIVE", 8, "2025-09-14T02:09:49Z", "2025-09-14T02:11:49Z"), + (108809, "ACTIVE", 8, "2025-09-14T02:07:49Z", "2025-09-14T02:09:49Z"), + (108808, "ACTIVE", 8, "2025-09-14T02:05:49Z", "2025-09-14T02:07:49Z"), + (108807, "ACTIVE", 2, "2025-09-14T02:03:49Z", "2025-09-14T02:05:49Z"), + (108806, "ACTIVE", 8, "2025-09-14T02:01:49Z", "2025-09-14T02:03:49Z"), + (108805, "ACTIVE", 8, "2025-09-14T01:59:49Z", "2025-09-14T02:01:49Z"), + (108804, "ACTIVE", 2, "2025-09-14T01:57:49Z", "2025-09-14T01:59:49Z"), + (108803, "ACTIVE", 2, "2025-09-14T01:55:49Z", "2025-09-14T01:57:49Z"), + (108802, "ACTIVE", 8, "2025-09-14T01:53:49Z", "2025-09-14T01:55:49Z"), + (108801, "ACTIVE", 8, "2025-09-14T01:51:49Z", "2025-09-14T01:53:49Z"), + (108800, "ACTIVE", 8, "2025-09-14T01:49:49Z", "2025-09-14T01:51:49Z"), + (108799, "ACTIVE", 8, "2025-09-14T01:47:49Z", "2025-09-14T01:49:49Z"), + (108798, "ACTIVE", 2, "2025-09-14T01:45:49Z", "2025-09-14T01:47:49Z"), + (108797, "ACTIVE", 2, "2025-09-14T01:43:49Z", "2025-09-14T01:45:49Z"), + (108796, "ACTIVE", 2, "2025-09-14T01:41:49Z", "2025-09-14T01:43:49Z"), + (108795, "ACTIVE", 2, "2025-09-14T01:39:49Z", "2025-09-14T01:41:49Z"), + (108794, "ACTIVE", 2, "2025-09-14T01:37:49Z", "2025-09-14T01:39:49Z"), + (108793, "ACTIVE", 2, "2025-09-14T01:35:49Z", "2025-09-14T01:37:49Z"), + (108792, "ACTIVE", 9, "2025-09-14T01:33:49Z", "2025-09-14T01:35:49Z"), + (108791, "ACTIVE", 2, "2025-09-14T01:31:49Z", "2025-09-14T01:33:49Z"), + (108790, "ACTIVE", 2, "2025-09-14T01:29:49Z", "2025-09-14T01:31:49Z"), + (108789, "ACTIVE", 2, "2025-09-14T01:27:49Z", "2025-09-14T01:29:49Z"), + (108787, "ACTIVE", 2, "2025-09-14T01:23:49Z", "2025-09-14T01:25:49Z"), + (108786, "ACTIVE", 2, "2025-09-14T01:21:49Z", "2025-09-14T01:23:49Z"), + (108785, "ACTIVE", 2, "2025-09-14T01:19:49Z", "2025-09-14T01:21:49Z"), + (108784, "ACTIVE", 2, "2025-09-14T01:17:49Z", "2025-09-14T01:19:49Z"), + (108783, "ACTIVE", 2, "2025-09-14T01:15:49Z", "2025-09-14T01:17:49Z"), + (108782, "ACTIVE", 2, "2025-09-14T01:13:49Z", "2025-09-14T01:15:49Z"), + (108781, "ACTIVE", 2, "2025-09-14T01:11:49Z", "2025-09-14T01:13:49Z"), + (108780, "ACTIVE", 9, "2025-09-14T01:09:49Z", "2025-09-14T01:11:49Z"), + (108779, "ACTIVE", 2, "2025-09-14T01:07:49Z", "2025-09-14T01:09:49Z"), + (108778, "ACTIVE", 2, "2025-09-14T01:05:49Z", "2025-09-14T01:07:49Z"), + (108777, "ACTIVE", 2, "2025-09-14T01:03:49Z", "2025-09-14T01:05:49Z"), + (108776, "ACTIVE", 2, "2025-09-14T01:01:49Z", "2025-09-14T01:03:49Z"), + (108775, "ACTIVE", 2, "2025-09-14T00:59:49Z", "2025-09-14T01:01:49Z"), + (108774, "ACTIVE", 2, "2025-09-14T00:57:49Z", "2025-09-14T00:59:49Z"), + (108773, "ACTIVE", 2, "2025-09-14T00:55:49Z", "2025-09-14T00:57:49Z"), + (108772, "ACTIVE", 2, "2025-09-14T00:53:49Z", "2025-09-14T00:55:49Z"), + (108771, "ACTIVE", 2, "2025-09-14T00:51:49Z", "2025-09-14T00:53:49Z"), + (108770, "ACTIVE", 2, "2025-09-14T00:49:49Z", "2025-09-14T00:51:49Z"), + (108769, "ACTIVE", 2, "2025-09-14T00:47:49Z", "2025-09-14T00:49:49Z"), + (108764, "ACTIVE", 10, "2025-09-14T00:37:49Z", "2025-09-14T00:39:49Z"), + (108763, "ACTIVE", 10, "2025-09-14T00:35:49Z", "2025-09-14T00:37:49Z"), + (108762, "ACTIVE", 4, "2025-09-14T00:33:49Z", "2025-09-14T00:35:49Z"), + ]; + + for (id, state, activity_type, start_time, end_time) in activity_states { + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)" + ) + .bind(id) + .bind(state) + .bind(activity_type) + .bind(start_time) + .bind(end_time) + .execute(pool) + .await?; + } + + Ok(()) + } + + /// Seed activity state tags for testing - complete dataset from your example + pub async fn seed_test_activity_state_tags(pool: &Pool) -> Result<()> { + // Complete activity state tags from your dataset + let tags = vec![ + (109020, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating + (109019, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating + (109018, "8a6cc870-4339-4641-9ded-50e9d34b255a"), // other_tag + (109017, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating + (109016, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating + (109015, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating + (109014, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (109013, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (109012, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (109011, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (109010, "cff963a1-655a-4a1a-8d69-10d1367dc742"), // communication + (109009, "cff963a1-655a-4a1a-8d69-10d1367dc742"), // communication + (109008, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (109004, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108836, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108833, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108832, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108831, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108830, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108829, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108828, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108827, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108826, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108825, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108824, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108823, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108822, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108821, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108820, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108819, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning + (108818, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research + (108817, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108816, "63672549-bb63-492a-87b1-20ff87397bf8"), // review + (108815, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108814, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108813, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108812, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108811, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108810, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108809, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108808, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108807, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108806, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108805, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108804, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108803, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108802, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108801, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108800, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108799, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging + (108798, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108797, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108796, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108795, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108794, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108793, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108792, "896106c9-c1e3-4379-b498-85e266860866"), // testing + (108791, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108790, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108789, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108787, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108786, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108785, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108784, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108783, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108782, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108781, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108780, "896106c9-c1e3-4379-b498-85e266860866"), // testing + (108779, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108778, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108777, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108776, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108775, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108774, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108773, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108772, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108771, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108770, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108769, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming + (108764, "1f00d5cb-8646-4af6-a363-1ca67e590d14"), // meeting + (108763, "1f00d5cb-8646-4af6-a363-1ca67e590d14"), // meeting + (108762, "cff963a1-655a-4a1a-8d69-10d1367dc742"), // communication + ]; + + for (activity_state_id, tag_id) in tags { + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) + VALUES (?1, ?2, datetime('now'), datetime('now'))" + ) + .bind(activity_state_id) + .bind(tag_id) + .execute(pool) + .await?; + } + + Ok(()) + } use super::*; use sqlx::sqlite::SqlitePoolOptions; use time::macros::datetime; + use crate::db_manager; + + /// 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, + name TEXT NOT NULL + )" + ).execute(pool).await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS activity_state ( + id INTEGER PRIMARY KEY, + state TEXT NOT NULL, + 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, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (activity_state_id, 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?; + seed_test_activity_states(&pool).await?; + seed_test_activity_state_tags(&pool).await?; + + Ok(ActivityStateRepo::new(pool)) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_in_range_creating_tag() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test "creating" tag for a specific time range that should capture multiple activities + // From your dataset, "creating" activities: + // 109020 - 17:24:25 to 17:26:25 (2 min) + // 109019 - 17:17:27 to 17:19:27 (2 min) + // 109017 - 17:02:47 to 17:04:47 (2 min) + // 109016 - 16:40:40 to 16:42:40 (2 min) + // 109015 - 16:35:33 to 16:37:33 (2 min) + // Total expected: 10 minutes + + let start_time = datetime!(2025-09-14 16:30:00 UTC); + let end_time = datetime!(2025-09-14 17:30:00 UTC); + + // Also call debug function to see details + repo.debug_get_tagged_activity_states("creating", start_time, end_time).await?; + + let total_minutes = repo.calculate_tagged_duration_in_range("creating", start_time, end_time).await?; + + println!("=== TEST RESULTS ==="); + println!("Expected: 10.0 minutes for 'creating' tag in range {} to {}", start_time, end_time); + println!("Actual: {} minutes", total_minutes); + println!("===================="); + + // Should be 10 minutes (5 activities × 2 minutes each) - allow for floating point precision + assert!((total_minutes - 10.0).abs() < 0.01, "Should have approximately 10 minutes of 'creating' activity, got {}", total_minutes); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_in_range_full_day() -> Result<()> { + let repo = setup_test_repo().await?; + + // Test the full day for "creating" tag + // All "creating" activities in the dataset: + // 109020, 109019, 109017, 109016, 109015 = 5 activities × 2 minutes = 10 minutes + + let start_time = datetime!(2025-09-14 00:00:00 UTC); + let end_time = datetime!(2025-09-15 00:00:00 UTC); + + repo.debug_get_tagged_activity_states("creating", start_time, end_time).await?; + + let total_minutes = repo.calculate_tagged_duration_in_range("creating", start_time, end_time).await?; + + println!("=== FULL DAY TEST ==="); + println!("Expected: 10.0 minutes for 'creating' tag for full day"); + println!("Actual: {} minutes", total_minutes); + println!("====================="); + + assert_eq!(total_minutes, 10.0, "Should have exactly 10 minutes of 'creating' activity for the full day"); + + Ok(()) + } + + #[tokio::test] + async fn test_calculate_tagged_duration_programming_vs_creating() -> Result<()> { + let repo = setup_test_repo().await?; + + let start_time = datetime!(2025-09-14 00:00:00 UTC); + let end_time = datetime!(2025-09-15 00:00:00 UTC); + + // Calculate for different tags to verify the distinction + let creating_minutes = repo.calculate_tagged_duration_in_range("creating", start_time, end_time).await?; + let programming_minutes = repo.calculate_tagged_duration_in_range("programming", start_time, end_time).await?; + + repo.debug_get_tagged_activity_states("creating", start_time, end_time).await?; + repo.debug_get_tagged_activity_states("programming", start_time, end_time).await?; + + println!("=== TAG COMPARISON ==="); + println!("Creating minutes: {}", creating_minutes); + println!("Programming minutes: {}", programming_minutes); + println!("======================"); + + // Programming should have way more entries than creating in your dataset + assert!(programming_minutes > creating_minutes, "Programming should have more minutes than creating"); + assert_eq!(creating_minutes, 10.0, "Creating should be exactly 10 minutes"); + + Ok(()) + } async fn create_test_db() -> Pool { let pool = SqlitePoolOptions::new() @@ -315,17 +790,6 @@ mod tests { .execute(&pool) .await?; - // Create INACTIVE activity state (shouldn't be counted even if in range and tagged) - sqlx::query( - "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) - VALUES (4, 'INACTIVE', 0, ?1, ?2, ?3)" - ) - .bind(start_time) - .bind(mid_time) - .bind(now) - .execute(&pool) - .await?; - // Link first two activity states to the "creating" tag (should be counted) sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) @@ -358,21 +822,10 @@ mod tests { .execute(&pool) .await?; - // Link activity state #4 (INACTIVE) to creating tag (shouldn't be counted due to state) - sqlx::query( - "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('4', ?1, ?2, ?3)" - ) - .bind(tag_id) - .bind(now) - .bind(now) - .execute(&pool) - .await?; - // Create an activity state with the wrong tag (shouldn't be counted) sqlx::query( "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) - VALUES (5, 'ACTIVE', 0, ?1, ?2, ?3)" + VALUES (4, 'ACTIVE', 0, ?1, ?2, ?3)" ) .bind(start_time) .bind(mid_time) @@ -382,7 +835,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('5', ?1, ?2, ?3)" + VALUES ('4', ?1, ?2, ?3)" ) .bind(other_tag_id) // Link to "learning" tag instead of "creating" .bind(now) diff --git a/src-tauri/src/ebb_db/src/migrations.rs b/src-tauri/src/ebb_db/src/migrations.rs index bd5cc81e..7ec140b2 100644 --- a/src-tauri/src/ebb_db/src/migrations.rs +++ b/src-tauri/src/ebb_db/src/migrations.rs @@ -323,7 +323,7 @@ pub fn get_migrations() -> Vec { ), ( 'default-weekly-template', - 'learning', + 'creating', 'weekly', datetime('now'), '0,1,2,3,4,5,6', diff --git a/src-tauri/src/ebb_tide_manager/Cargo.lock b/src-tauri/src/ebb_tide_manager/Cargo.lock index 655df410..55d5ef36 100644 --- a/src-tauri/src/ebb_tide_manager/Cargo.lock +++ b/src-tauri/src/ebb_tide_manager/Cargo.lock @@ -2140,6 +2140,15 @@ dependencies = [ "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" @@ -4104,7 +4113,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", diff --git a/src-tauri/src/ebb_tide_manager/Cargo.toml b/src-tauri/src/ebb_tide_manager/Cargo.toml index 86b3c939..19432ad3 100644 --- a/src-tauri/src/ebb_tide_manager/Cargo.toml +++ b/src-tauri/src/ebb_tide_manager/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] ebb-db = { path = "../ebb_db" } -time = { version = "0.3", features = ["serde"] } +time = { version = "0.3", features = ["serde", "local-offset"] } tokio = { version = "1.42", features = ["full"] } thiserror = "2.0" uuid = { version = "1.17", features = ["v4"] } diff --git a/src-tauri/src/ebb_tide_manager/src/lib.rs b/src-tauri/src/ebb_tide_manager/src/lib.rs index 6fdb6280..5fa1887e 100644 --- a/src-tauri/src/ebb_tide_manager/src/lib.rs +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -39,11 +39,12 @@ pub struct TideManager { impl TideManager { /// Create a new TideManager with default configuration (60 second intervals) pub async fn new() -> Result { - Self::new_with_interval(60).await + Self::new_with_interval(15).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?); @@ -53,6 +54,7 @@ impl TideManager { /// 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?; @@ -118,6 +120,7 @@ impl TideManager { // Get or create active tides for current period let active_tides = service.get_or_create_active_tides_for_period(evaluation_time).await?; + println!("Active tides: {:?}", active_tides); for mut tide in active_tides { // Update progress diff --git a/src-tauri/src/ebb_tide_manager/src/tide_service.rs b/src-tauri/src/ebb_tide_manager/src/tide_service.rs index 2ce331d5..76d6054f 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_service.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_service.rs @@ -156,7 +156,9 @@ impl TideService { // 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) { - let new_tide = self.create_tide_from_template(&template.id, Some(evaluation_time)).await?; + // Calculate the appropriate start time based on tide frequency + let tide_start_time = self.calculate_tide_start_time(template, evaluation_time); + let new_tide = self.create_tide_from_template(&template.id, Some(tide_start_time)).await?; active_tides.push(new_tide); } } @@ -179,6 +181,19 @@ impl TideService { _ => 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_week_start, get_month_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)] @@ -746,4 +761,50 @@ mod tests { 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 start at the beginning of the day (midnight) in local timezone + assert_eq!(our_tide.start.hour(), 0); + assert_eq!(our_tide.start.minute(), 0); + assert_eq!(our_tide.start.second(), 0); + + // Verify the date in local timezone matches expected date + let evaluation_local = evaluation_time.to_offset(our_tide.start.offset()); + assert_eq!(our_tide.start.date(), evaluation_local.date()); + + // The tide should end at the end of the day (for daily tides) in local timezone + let end_time = our_tide.end.expect("Daily tide should have an end time"); + assert_eq!(end_time.hour(), 0); + assert_eq!(end_time.minute(), 0); + assert_eq!(end_time.second(), 0); + + Ok(()) + } } \ No newline at end of file diff --git a/src-tauri/src/ebb_tide_manager/src/time_helpers.rs b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs index 7cfa757f..fe7b49ba 100644 --- a/src-tauri/src/ebb_tide_manager/src/time_helpers.rs +++ b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs @@ -1,12 +1,19 @@ use time::OffsetDateTime; -/// Get the start of the week (Monday at 00:00:00) for a given time +/// Get the start of the week (Monday at 00:00:00) for a given time in the system's local timezone /// This is used for weekly tide calculations pub fn get_week_start(time: OffsetDateTime) -> OffsetDateTime { - let weekday = time.weekday().number_days_from_sunday() as i64; + // 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 }; - - time.replace_hour(0) + + local_time.replace_hour(0) .unwrap() .replace_minute(0) .unwrap() @@ -17,10 +24,17 @@ pub fn get_week_start(time: OffsetDateTime) -> OffsetDateTime { - time::Duration::days(days_since_monday) } -/// Get the start of the month (1st day at 00:00:00) for a given time +/// Get the start of the month (1st day at 00:00:00) for a given time in the system's local timezone /// This is used for monthly tide calculations pub fn get_month_start(time: OffsetDateTime) -> OffsetDateTime { - time.replace_day(1) + // 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); + + local_time.replace_day(1) .unwrap() .replace_hour(0) .unwrap() @@ -32,17 +46,28 @@ pub fn get_month_start(time: OffsetDateTime) -> OffsetDateTime { .unwrap() } -/// Get the start of the day (00:00:00) for a given time +/// Get the start of the day (00:00:00) for a given time in the system's local timezone /// This is used for daily tide calculations pub fn get_day_start(time: OffsetDateTime) -> OffsetDateTime { - time.replace_hour(0) + // 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() + .unwrap(); + + start_of_day_local } #[cfg(test)] @@ -55,44 +80,86 @@ mod tests { // Monday should return the same day at 00:00:00 let monday = datetime!(2025-01-06 15:30:45 UTC); // Monday afternoon let week_start = get_week_start(monday); - let expected = datetime!(2025-01-06 00:00:00 UTC); // Monday 00:00:00 - assert_eq!(week_start, expected); + + // Verify it's the same date and at start of day + assert_eq!(week_start.hour(), 0); + assert_eq!(week_start.minute(), 0); + assert_eq!(week_start.second(), 0); + assert_eq!(week_start.nanosecond(), 0); + + // Convert to UTC for date comparison to avoid timezone issues + let week_start_utc = week_start.to_offset(time::UtcOffset::UTC); + // Should be Monday (or Sunday if timezone shifted it back a day) + let weekday = week_start_utc.weekday(); + assert!(weekday == time::Weekday::Monday || weekday == time::Weekday::Sunday); } #[test] fn test_get_week_start_tuesday() { - // Tuesday should return previous Monday at 00:00:00 + // Tuesday should return previous Monday at 00:00:00 in local time let tuesday = datetime!(2025-01-07 10:15:30 UTC); // Tuesday morning let week_start = get_week_start(tuesday); - let expected = datetime!(2025-01-06 00:00:00 UTC); // Previous Monday 00:00:00 - assert_eq!(week_start, expected); + + // Verify it's at start of day + assert_eq!(week_start.hour(), 0); + assert_eq!(week_start.minute(), 0); + assert_eq!(week_start.second(), 0); + assert_eq!(week_start.nanosecond(), 0); + + // Verify it's Monday when converted to same timezone + let weekday = week_start.weekday(); + assert_eq!(weekday, time::Weekday::Monday); } #[test] fn test_get_week_start_friday() { - // Friday should return Monday of the same week at 00:00:00 + // Friday should return Monday of the same week at 00:00:00 in local time let friday = datetime!(2025-01-03 18:45:12 UTC); // Friday evening let week_start = get_week_start(friday); - let expected = datetime!(2024-12-30 00:00:00 UTC); // Monday of that week 00:00:00 - assert_eq!(week_start, expected); + + // Verify it's at start of day + assert_eq!(week_start.hour(), 0); + assert_eq!(week_start.minute(), 0); + assert_eq!(week_start.second(), 0); + assert_eq!(week_start.nanosecond(), 0); + + // Verify it's Monday + let weekday = week_start.weekday(); + assert_eq!(weekday, time::Weekday::Monday); } #[test] fn test_get_week_start_sunday() { - // Sunday should return previous Monday at 00:00:00 + // Sunday should return previous Monday at 00:00:00 in local time let sunday = datetime!(2025-01-05 12:00:00 UTC); // Sunday noon let week_start = get_week_start(sunday); - let expected = datetime!(2024-12-30 00:00:00 UTC); // Previous Monday 00:00:00 - assert_eq!(week_start, expected); + + // Verify it's at start of day + assert_eq!(week_start.hour(), 0); + assert_eq!(week_start.minute(), 0); + assert_eq!(week_start.second(), 0); + assert_eq!(week_start.nanosecond(), 0); + + // Verify it's Monday + let weekday = week_start.weekday(); + assert_eq!(weekday, time::Weekday::Monday); } #[test] fn test_get_week_start_saturday() { - // Saturday should return Monday of the same week at 00:00:00 + // Saturday should return Monday of the same week at 00:00:00 in local time let saturday = datetime!(2025-01-04 08:20:15 UTC); // Saturday morning let week_start = get_week_start(saturday); - let expected = datetime!(2024-12-30 00:00:00 UTC); // Monday of that week 00:00:00 - assert_eq!(week_start, expected); + + // Verify it's at start of day + assert_eq!(week_start.hour(), 0); + assert_eq!(week_start.minute(), 0); + assert_eq!(week_start.second(), 0); + assert_eq!(week_start.nanosecond(), 0); + + // Verify it's Monday + let weekday = week_start.weekday(); + assert_eq!(weekday, time::Weekday::Monday); } #[test] @@ -100,35 +167,79 @@ mod tests { // 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); - let expected = datetime!(2025-01-06 00:00:00 UTC); // Monday 00:00:00 - assert_eq!(week_start, expected); + + // Verify it's at start of day + assert_eq!(week_start.hour(), 0); + assert_eq!(week_start.minute(), 0); + assert_eq!(week_start.second(), 0); + assert_eq!(week_start.nanosecond(), 0); + + // Verify it's Monday + let weekday = week_start.weekday(); + assert_eq!(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 + // First day of month should return the same day at 00:00:00 in local time let first_day = datetime!(2025-01-01 15:30:45 UTC); // January 1st afternoon let month_start = get_month_start(first_day); - let expected = datetime!(2025-01-01 00:00:00 UTC); // January 1st 00:00:00 - assert_eq!(month_start, expected); + + // Verify it's at start of day + assert_eq!(month_start.hour(), 0); + assert_eq!(month_start.minute(), 0); + assert_eq!(month_start.second(), 0); + assert_eq!(month_start.nanosecond(), 0); + + // Verify it's the first day of the month + assert_eq!(month_start.day(), 1); + + // Verify it's the same month when converted to same timezone + let first_day_local = first_day.to_offset(month_start.offset()); + assert_eq!(month_start.month(), first_day_local.month()); + assert_eq!(month_start.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 + // Middle of month should return first day at 00:00:00 in local time let mid_month = datetime!(2025-01-15 10:25:30 UTC); // January 15th morning let month_start = get_month_start(mid_month); - let expected = datetime!(2025-01-01 00:00:00 UTC); // January 1st 00:00:00 - assert_eq!(month_start, expected); + + // Verify it's at start of day + assert_eq!(month_start.hour(), 0); + assert_eq!(month_start.minute(), 0); + assert_eq!(month_start.second(), 0); + assert_eq!(month_start.nanosecond(), 0); + + // Verify it's the first day of the month + assert_eq!(month_start.day(), 1); + + // Verify it's the same month when converted to same timezone + let mid_month_local = mid_month.to_offset(month_start.offset()); + assert_eq!(month_start.month(), mid_month_local.month()); + assert_eq!(month_start.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 + // End of month should return first day at 00:00:00 in local time let end_month = datetime!(2025-01-31 23:59:59 UTC); // January 31st end of day let month_start = get_month_start(end_month); - let expected = datetime!(2025-01-01 00:00:00 UTC); // January 1st 00:00:00 - assert_eq!(month_start, expected); + + // Verify it's at start of day + assert_eq!(month_start.hour(), 0); + assert_eq!(month_start.minute(), 0); + assert_eq!(month_start.second(), 0); + assert_eq!(month_start.nanosecond(), 0); + + // Verify it's the first day of the month + assert_eq!(month_start.day(), 1); + + // Verify it's the same month when converted to same timezone + let end_month_local = end_month.to_offset(month_start.offset()); + assert_eq!(month_start.month(), end_month_local.month()); + assert_eq!(month_start.year(), end_month_local.year()); } #[test] @@ -136,8 +247,20 @@ mod tests { // Test with February (shorter month) let february = datetime!(2025-02-20 14:45:12 UTC); // February 20th let month_start = get_month_start(february); - let expected = datetime!(2025-02-01 00:00:00 UTC); // February 1st 00:00:00 - assert_eq!(month_start, expected); + + // Verify it's at start of day + assert_eq!(month_start.hour(), 0); + assert_eq!(month_start.minute(), 0); + assert_eq!(month_start.second(), 0); + assert_eq!(month_start.nanosecond(), 0); + + // Verify it's the first day of the month + assert_eq!(month_start.day(), 1); + + // Verify it's February when converted to same timezone + let february_local = february.to_offset(month_start.offset()); + assert_eq!(month_start.month(), february_local.month()); + assert_eq!(month_start.year(), february_local.year()); } #[test] @@ -145,8 +268,20 @@ mod tests { // 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); - let expected = datetime!(2025-12-01 00:00:00 UTC); // December 1st 00:00:00 - assert_eq!(month_start, expected); + + // Verify it's at start of day + assert_eq!(month_start.hour(), 0); + assert_eq!(month_start.minute(), 0); + assert_eq!(month_start.second(), 0); + assert_eq!(month_start.nanosecond(), 0); + + // Verify it's the first day of the month + assert_eq!(month_start.day(), 1); + + // Verify it's December when converted to same timezone + let december_local = december.to_offset(month_start.offset()); + assert_eq!(month_start.month(), december_local.month()); + assert_eq!(month_start.year(), december_local.year()); } #[test] @@ -154,8 +289,20 @@ mod tests { // 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); - let expected = datetime!(2025-06-01 00:00:00 UTC); // June 1st 00:00:00 - assert_eq!(month_start, expected); + + // Verify it's at start of day + assert_eq!(month_start.hour(), 0); + assert_eq!(month_start.minute(), 0); + assert_eq!(month_start.second(), 0); + assert_eq!(month_start.nanosecond(), 0); + + // Verify it's the first day of the month + assert_eq!(month_start.day(), 1); + + // Verify it's June when converted to same timezone + let first_midnight_local = first_midnight.to_offset(month_start.offset()); + assert_eq!(month_start.month(), first_midnight_local.month()); + assert_eq!(month_start.year(), first_midnight_local.year()); } #[test] @@ -163,53 +310,101 @@ mod tests { // Morning time should return same day at 00:00:00 let morning = datetime!(2025-01-15 08:30:45 UTC); // January 15th morning let day_start = get_day_start(morning); - let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day (when converted to same timezone) + let morning_local = morning.to_offset(day_start.offset()); + assert_eq!(day_start.date(), morning_local.date()); } #[test] fn test_get_day_start_afternoon() { - // Afternoon time should return same day at 00:00:00 + // Afternoon time should return same day at 00:00:00 in local time let afternoon = datetime!(2025-01-15 14:25:12 UTC); // January 15th afternoon let day_start = get_day_start(afternoon); - let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day when converted to same timezone + let afternoon_local = afternoon.to_offset(day_start.offset()); + assert_eq!(day_start.date(), afternoon_local.date()); } #[test] fn test_get_day_start_evening() { - // Evening time should return same day at 00:00:00 + // Evening time should return same day at 00:00:00 in local time let evening = datetime!(2025-01-15 21:45:30 UTC); // January 15th evening let day_start = get_day_start(evening); - let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day when converted to same timezone + let evening_local = evening.to_offset(day_start.offset()); + assert_eq!(day_start.date(), evening_local.date()); } #[test] fn test_get_day_start_end_of_day() { - // End of day should return same day at 00:00:00 + // End of day should return same day at 00:00:00 in local time 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); - let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day when converted to same timezone + let end_of_day_local = end_of_day.to_offset(day_start.offset()); + assert_eq!(day_start.date(), end_of_day_local.date()); } #[test] fn test_get_day_start_already_midnight() { - // Time already at midnight should return same time + // Time already at midnight should return same time in local time let midnight = datetime!(2025-01-15 00:00:00 UTC); // January 15th at midnight let day_start = get_day_start(midnight); - let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day when converted to same timezone + let midnight_local = midnight.to_offset(day_start.offset()); + assert_eq!(day_start.date(), midnight_local.date()); } #[test] fn test_get_day_start_with_microseconds() { - // Time with microseconds should be normalized to 00:00:00 + // Time with microseconds should be normalized to 00:00:00 in local time let precise_time = datetime!(2025-01-15 12:34:56.789123 UTC); // January 15th with microseconds let day_start = get_day_start(precise_time); - let expected = datetime!(2025-01-15 00:00:00 UTC); // January 15th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day when converted to same timezone + let precise_time_local = precise_time.to_offset(day_start.offset()); + assert_eq!(day_start.date(), precise_time_local.date()); } #[test] @@ -217,7 +412,15 @@ mod tests { // 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); - let expected = datetime!(2024-02-29 00:00:00 UTC); // February 29th 00:00:00 - assert_eq!(day_start, expected); + + // Verify it's at start of day + assert_eq!(day_start.hour(), 0); + assert_eq!(day_start.minute(), 0); + assert_eq!(day_start.second(), 0); + assert_eq!(day_start.nanosecond(), 0); + + // Verify it's the same day when converted to same timezone + let leap_day_local = leap_day.to_offset(day_start.offset()); + assert_eq!(day_start.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..4bdbaae3 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"); } @@ -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"); + } + } + } _ => {} }, ); From cb1229701903679dee2465652d898ffef62c9939 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 18 Sep 2025 20:43:43 -0600 Subject: [PATCH 13/40] add handling for multiple records per activity state --- .../src/ebb_db/src/db/activity_state_repo.rs | 1204 ++++++++++------- .../src/ebb_db/src/db/tide_template_repo.rs | 2 +- 2 files changed, 728 insertions(+), 478 deletions(-) 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 index c6039c9c..10c80b0a 100644 --- a/src-tauri/src/ebb_db/src/db/activity_state_repo.rs +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -71,12 +71,24 @@ impl ActivityStateRepo { ) -> Result { 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 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 = match tag_type { + Some(t) => t, + None => return Ok(0.0), // Tag doesn't exist, return 0 + }; + let total_minutes: Option = sqlx::query_scalar( "SELECT - COALESCE(SUM(creating_minutes), 0.0) as total_minutes + COALESCE(SUM(split_minutes), 0.0) as total_minutes FROM ( SELECT - activity_state.id, ROUND((julianday( CASE WHEN activity_state.end_time > ?2 THEN ?2 @@ -87,30 +99,37 @@ impl ActivityStateRepo { WHEN activity_state.start_time < ?3 THEN ?3 ELSE activity_state.start_time END - )) * 24 * 60) as creating_minutes + )) * 24 * 60 / ( + SELECT COUNT(*) + FROM activity_state_tag ast2 + LEFT JOIN tag t + ON t.id = ast2.tag_id + WHERE ast2.activity_state_id = activity_state.id + AND t.tag_type = ?4 + )) as split_minutes 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 - GROUP BY activity_state.id - ) as distinct_activities" + ) as time_split_activities" ) .bind(tag_name) .bind(end_time) .bind(start_time) + .bind(&tag_type) .fetch_one(&self.pool) .await?; // Debug: Print the actual SQL query with substituted parameters let debug_sql = format!( "SELECT - COALESCE(SUM(creating_minutes), 0.0) as total_minutes + COALESCE(SUM(split_minutes), 0.0) as total_minutes FROM ( SELECT activity_state.id, - (julianday( + ROUND((julianday( CASE WHEN activity_state.end_time > '{}' THEN '{}' ELSE activity_state.end_time @@ -120,7 +139,14 @@ impl ActivityStateRepo { WHEN activity_state.start_time < '{}' THEN '{}' ELSE activity_state.start_time END - )) * 24 * 60 as creating_minutes + )) * 24 * 60 / ( + SELECT COUNT(*) + FROM activity_state_tag ast2 + LEFT JOIN tag t + ON t.id = ast2.tag_id + WHERE ast2.activity_state_id = activity_state.id + AND t.tag_type = '{}' + )) as split_minutes 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 @@ -128,13 +154,13 @@ impl ActivityStateRepo { AND activity_state.start_time < '{}' AND activity_state.end_time > '{}' GROUP BY activity_state.id - ) as distinct_activities;", - end_time, end_time, start_time, start_time, tag_name, end_time, start_time + ) as time_split_activities;", + end_time, end_time, start_time, start_time, tag_type, tag_name, end_time, start_time ); println!("=== DEBUG SQL QUERY ==="); println!("{}", debug_sql); - println!("Query parameters: tag_name='{}', end_time={}, start_time={}", tag_name, end_time, start_time); + println!("Query parameters: tag_name='{}', end_time={}, start_time={}, tag_type='{}'", tag_name, end_time, start_time, tag_type); println!("Total minutes result: {:?}", total_minutes); println!("======================="); @@ -204,7 +230,8 @@ impl ActivityStateRepo { mod tests { use super::*; use sqlx::{Pool, Sqlite}; - + use time::macros::datetime; + use crate::db_manager; /// 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 @@ -222,22 +249,23 @@ mod tests { pub async fn seed_test_tags(pool: &Pool) -> Result<()> { // Insert tags that will be used in test data let tags = vec![ - ("04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e", "creating"), - ("8a6cc870-4339-4641-9ded-50e9d34b255a", "other_tag"), - ("e1f75007-59cf-4f4a-a33c-1570742daf2b", "programming"), - ("cff963a1-655a-4a1a-8d69-10d1367dc742", "communication"), - ("1150c2f1-5d32-47b9-9175-a703a93c497f", "planning"), - ("3aeb7eb9-073b-4e62-9442-b5c7db65b654", "research"), - ("63672549-bb63-492a-87b1-20ff87397bf8", "review"), - ("882ee9eb-ce03-4221-a265-682913984eb1", "debugging"), - ("896106c9-c1e3-4379-b498-85e266860866", "testing"), - ("1f00d5cb-8646-4af6-a363-1ca67e590d14", "meeting"), + // 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) in tags { - sqlx::query("INSERT INTO tag (id, name) VALUES (?1, ?2)") + 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?; } @@ -245,220 +273,6 @@ mod tests { Ok(()) } - /// Seed activity states for testing based on your example data - pub async fn seed_test_activity_states(pool: &Pool) -> Result<()> { - // Activity states from your dataset - each is 2 minutes long - let activity_states = vec![ - (109020, "ACTIVE", 3, "2025-09-14T17:24:25.006192Z", "2025-09-14T17:26:25.006192Z"), - (109019, "ACTIVE", 3, "2025-09-14T17:17:27.71745Z", "2025-09-14T17:19:27.71745Z"), - (109018, "ACTIVE", 1, "2025-09-14T17:04:47.485032Z", "2025-09-14T17:06:47.485032Z"), - (109017, "ACTIVE", 5, "2025-09-14T17:02:47.485032Z", "2025-09-14T17:04:47.485032Z"), - (109016, "ACTIVE", 2, "2025-09-14T16:40:40.685228Z", "2025-09-14T16:42:40.685228Z"), - (109015, "ACTIVE", 7, "2025-09-14T16:35:33.339528Z", "2025-09-14T16:37:33.339528Z"), - (109014, "ACTIVE", 2, "2025-09-14T16:31:58.586187Z", "2025-09-14T16:33:58.586187Z"), - (109013, "ACTIVE", 6, "2025-09-14T16:29:58.586187Z", "2025-09-14T16:31:58.586187Z"), - (109012, "ACTIVE", 1, "2025-09-14T16:27:58.586187Z", "2025-09-14T16:29:58.586187Z"), - (109011, "ACTIVE", 0, "2025-09-14T16:25:58.586187Z", "2025-09-14T16:27:58.586187Z"), - (109010, "ACTIVE", 6, "2025-09-14T16:23:58.586187Z", "2025-09-14T16:25:58.586187Z"), - (109009, "ACTIVE", 3, "2025-09-14T16:21:58.586187Z", "2025-09-14T16:23:58.586187Z"), - (109008, "ACTIVE", 3, "2025-09-14T16:19:58.586187Z", "2025-09-14T16:21:58.586187Z"), - (109007, "ACTIVE", 4, "2025-09-14T16:17:58.586187Z", "2025-09-14T16:19:58.586187Z"), - (109006, "ACTIVE", 5, "2025-09-14T16:15:58.586187Z", "2025-09-14T16:17:58.586187Z"), - (109005, "ACTIVE", 4, "2025-09-14T16:13:58.586187Z", "2025-09-14T16:15:58.586187Z"), - (109004, "ACTIVE", 2, "2025-09-14T16:11:58.586187Z", "2025-09-14T16:13:58.586187Z"), - // Additional activity states needed for the extended tag data - (108836, "ACTIVE", 1, "2025-09-14T05:49:16Z", "2025-09-14T05:51:16Z"), - (108833, "ACTIVE", 2, "2025-09-14T03:12:13Z", "2025-09-14T03:14:13Z"), - (108832, "ACTIVE", 1, "2025-09-14T02:53:49Z", "2025-09-14T02:55:49Z"), - (108831, "ACTIVE", 3, "2025-09-14T02:51:49Z", "2025-09-14T02:53:49Z"), - (108830, "ACTIVE", 1, "2025-09-14T02:49:49Z", "2025-09-14T02:51:49Z"), - (108829, "ACTIVE", 1, "2025-09-14T02:47:49Z", "2025-09-14T02:49:49Z"), - (108828, "ACTIVE", 1, "2025-09-14T02:45:49Z", "2025-09-14T02:47:49Z"), - (108827, "ACTIVE", 3, "2025-09-14T02:43:49Z", "2025-09-14T02:45:49Z"), - (108826, "ACTIVE", 1, "2025-09-14T02:41:49Z", "2025-09-14T02:43:49Z"), - (108825, "ACTIVE", 1, "2025-09-14T02:39:49Z", "2025-09-14T02:41:49Z"), - (108824, "ACTIVE", 1, "2025-09-14T02:37:49Z", "2025-09-14T02:39:49Z"), - (108823, "ACTIVE", 3, "2025-09-14T02:35:49Z", "2025-09-14T02:37:49Z"), - (108822, "ACTIVE", 3, "2025-09-14T02:33:49Z", "2025-09-14T02:35:49Z"), - (108821, "ACTIVE", 3, "2025-09-14T02:31:49Z", "2025-09-14T02:33:49Z"), - (108820, "ACTIVE", 3, "2025-09-14T02:29:49Z", "2025-09-14T02:31:49Z"), - (108819, "ACTIVE", 1, "2025-09-14T02:27:49Z", "2025-09-14T02:29:49Z"), - (108818, "ACTIVE", 3, "2025-09-14T02:25:49Z", "2025-09-14T02:27:49Z"), - (108817, "ACTIVE", 2, "2025-09-14T02:23:49Z", "2025-09-14T02:25:49Z"), - (108816, "ACTIVE", 6, "2025-09-14T02:21:49Z", "2025-09-14T02:23:49Z"), - (108815, "ACTIVE", 8, "2025-09-14T02:19:49Z", "2025-09-14T02:21:49Z"), - (108814, "ACTIVE", 8, "2025-09-14T02:17:49Z", "2025-09-14T02:19:49Z"), - (108813, "ACTIVE", 8, "2025-09-14T02:15:49Z", "2025-09-14T02:17:49Z"), - (108812, "ACTIVE", 2, "2025-09-14T02:13:49Z", "2025-09-14T02:15:49Z"), - (108811, "ACTIVE", 2, "2025-09-14T02:11:49Z", "2025-09-14T02:13:49Z"), - (108810, "ACTIVE", 8, "2025-09-14T02:09:49Z", "2025-09-14T02:11:49Z"), - (108809, "ACTIVE", 8, "2025-09-14T02:07:49Z", "2025-09-14T02:09:49Z"), - (108808, "ACTIVE", 8, "2025-09-14T02:05:49Z", "2025-09-14T02:07:49Z"), - (108807, "ACTIVE", 2, "2025-09-14T02:03:49Z", "2025-09-14T02:05:49Z"), - (108806, "ACTIVE", 8, "2025-09-14T02:01:49Z", "2025-09-14T02:03:49Z"), - (108805, "ACTIVE", 8, "2025-09-14T01:59:49Z", "2025-09-14T02:01:49Z"), - (108804, "ACTIVE", 2, "2025-09-14T01:57:49Z", "2025-09-14T01:59:49Z"), - (108803, "ACTIVE", 2, "2025-09-14T01:55:49Z", "2025-09-14T01:57:49Z"), - (108802, "ACTIVE", 8, "2025-09-14T01:53:49Z", "2025-09-14T01:55:49Z"), - (108801, "ACTIVE", 8, "2025-09-14T01:51:49Z", "2025-09-14T01:53:49Z"), - (108800, "ACTIVE", 8, "2025-09-14T01:49:49Z", "2025-09-14T01:51:49Z"), - (108799, "ACTIVE", 8, "2025-09-14T01:47:49Z", "2025-09-14T01:49:49Z"), - (108798, "ACTIVE", 2, "2025-09-14T01:45:49Z", "2025-09-14T01:47:49Z"), - (108797, "ACTIVE", 2, "2025-09-14T01:43:49Z", "2025-09-14T01:45:49Z"), - (108796, "ACTIVE", 2, "2025-09-14T01:41:49Z", "2025-09-14T01:43:49Z"), - (108795, "ACTIVE", 2, "2025-09-14T01:39:49Z", "2025-09-14T01:41:49Z"), - (108794, "ACTIVE", 2, "2025-09-14T01:37:49Z", "2025-09-14T01:39:49Z"), - (108793, "ACTIVE", 2, "2025-09-14T01:35:49Z", "2025-09-14T01:37:49Z"), - (108792, "ACTIVE", 9, "2025-09-14T01:33:49Z", "2025-09-14T01:35:49Z"), - (108791, "ACTIVE", 2, "2025-09-14T01:31:49Z", "2025-09-14T01:33:49Z"), - (108790, "ACTIVE", 2, "2025-09-14T01:29:49Z", "2025-09-14T01:31:49Z"), - (108789, "ACTIVE", 2, "2025-09-14T01:27:49Z", "2025-09-14T01:29:49Z"), - (108787, "ACTIVE", 2, "2025-09-14T01:23:49Z", "2025-09-14T01:25:49Z"), - (108786, "ACTIVE", 2, "2025-09-14T01:21:49Z", "2025-09-14T01:23:49Z"), - (108785, "ACTIVE", 2, "2025-09-14T01:19:49Z", "2025-09-14T01:21:49Z"), - (108784, "ACTIVE", 2, "2025-09-14T01:17:49Z", "2025-09-14T01:19:49Z"), - (108783, "ACTIVE", 2, "2025-09-14T01:15:49Z", "2025-09-14T01:17:49Z"), - (108782, "ACTIVE", 2, "2025-09-14T01:13:49Z", "2025-09-14T01:15:49Z"), - (108781, "ACTIVE", 2, "2025-09-14T01:11:49Z", "2025-09-14T01:13:49Z"), - (108780, "ACTIVE", 9, "2025-09-14T01:09:49Z", "2025-09-14T01:11:49Z"), - (108779, "ACTIVE", 2, "2025-09-14T01:07:49Z", "2025-09-14T01:09:49Z"), - (108778, "ACTIVE", 2, "2025-09-14T01:05:49Z", "2025-09-14T01:07:49Z"), - (108777, "ACTIVE", 2, "2025-09-14T01:03:49Z", "2025-09-14T01:05:49Z"), - (108776, "ACTIVE", 2, "2025-09-14T01:01:49Z", "2025-09-14T01:03:49Z"), - (108775, "ACTIVE", 2, "2025-09-14T00:59:49Z", "2025-09-14T01:01:49Z"), - (108774, "ACTIVE", 2, "2025-09-14T00:57:49Z", "2025-09-14T00:59:49Z"), - (108773, "ACTIVE", 2, "2025-09-14T00:55:49Z", "2025-09-14T00:57:49Z"), - (108772, "ACTIVE", 2, "2025-09-14T00:53:49Z", "2025-09-14T00:55:49Z"), - (108771, "ACTIVE", 2, "2025-09-14T00:51:49Z", "2025-09-14T00:53:49Z"), - (108770, "ACTIVE", 2, "2025-09-14T00:49:49Z", "2025-09-14T00:51:49Z"), - (108769, "ACTIVE", 2, "2025-09-14T00:47:49Z", "2025-09-14T00:49:49Z"), - (108764, "ACTIVE", 10, "2025-09-14T00:37:49Z", "2025-09-14T00:39:49Z"), - (108763, "ACTIVE", 10, "2025-09-14T00:35:49Z", "2025-09-14T00:37:49Z"), - (108762, "ACTIVE", 4, "2025-09-14T00:33:49Z", "2025-09-14T00:35:49Z"), - ]; - - for (id, state, activity_type, start_time, end_time) in activity_states { - sqlx::query( - "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?5)" - ) - .bind(id) - .bind(state) - .bind(activity_type) - .bind(start_time) - .bind(end_time) - .execute(pool) - .await?; - } - - Ok(()) - } - - /// Seed activity state tags for testing - complete dataset from your example - pub async fn seed_test_activity_state_tags(pool: &Pool) -> Result<()> { - // Complete activity state tags from your dataset - let tags = vec![ - (109020, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating - (109019, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating - (109018, "8a6cc870-4339-4641-9ded-50e9d34b255a"), // other_tag - (109017, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating - (109016, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating - (109015, "04898e80-6643-4ae9-bfcb-8ce6b8ebaf2e"), // creating - (109014, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (109013, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (109012, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (109011, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (109010, "cff963a1-655a-4a1a-8d69-10d1367dc742"), // communication - (109009, "cff963a1-655a-4a1a-8d69-10d1367dc742"), // communication - (109008, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (109004, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108836, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108833, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108832, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108831, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108830, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108829, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108828, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108827, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108826, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108825, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108824, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108823, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108822, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108821, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108820, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108819, "1150c2f1-5d32-47b9-9175-a703a93c497f"), // planning - (108818, "3aeb7eb9-073b-4e62-9442-b5c7db65b654"), // research - (108817, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108816, "63672549-bb63-492a-87b1-20ff87397bf8"), // review - (108815, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108814, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108813, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108812, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108811, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108810, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108809, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108808, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108807, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108806, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108805, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108804, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108803, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108802, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108801, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108800, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108799, "882ee9eb-ce03-4221-a265-682913984eb1"), // debugging - (108798, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108797, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108796, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108795, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108794, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108793, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108792, "896106c9-c1e3-4379-b498-85e266860866"), // testing - (108791, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108790, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108789, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108787, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108786, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108785, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108784, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108783, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108782, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108781, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108780, "896106c9-c1e3-4379-b498-85e266860866"), // testing - (108779, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108778, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108777, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108776, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108775, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108774, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108773, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108772, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108771, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108770, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108769, "e1f75007-59cf-4f4a-a33c-1570742daf2b"), // programming - (108764, "1f00d5cb-8646-4af6-a363-1ca67e590d14"), // meeting - (108763, "1f00d5cb-8646-4af6-a363-1ca67e590d14"), // meeting - (108762, "cff963a1-655a-4a1a-8d69-10d1367dc742"), // communication - ]; - - for (activity_state_id, tag_id) in tags { - sqlx::query( - "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES (?1, ?2, datetime('now'), datetime('now'))" - ) - .bind(activity_state_id) - .bind(tag_id) - .execute(pool) - .await?; - } - - Ok(()) - } - use super::*; - use sqlx::sqlite::SqlitePoolOptions; - use time::macros::datetime; - use crate::db_manager; /// Create just the tables we need for testing async fn create_test_tables(pool: &Pool) -> Result<()> { @@ -466,6 +280,7 @@ mod 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?; @@ -474,6 +289,7 @@ mod tests { "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, @@ -485,9 +301,10 @@ mod tests { "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), + 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) )" @@ -506,355 +323,788 @@ mod tests { // Clean and seed test data cleanup_activity_state_data(&pool).await?; seed_test_tags(&pool).await?; - seed_test_activity_states(&pool).await?; - seed_test_activity_state_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_in_range_creating_tag() -> Result<()> { + async fn test_calculate_tagged_duration_single_activity_state_no_tags() -> Result<()> { let repo = setup_test_repo().await?; - // Test "creating" tag for a specific time range that should capture multiple activities - // From your dataset, "creating" activities: - // 109020 - 17:24:25 to 17:26:25 (2 min) - // 109019 - 17:17:27 to 17:19:27 (2 min) - // 109017 - 17:02:47 to 17:04:47 (2 min) - // 109016 - 16:40:40 to 16:42:40 (2 min) - // 109015 - 16:35:33 to 16:37:33 (2 min) - // Total expected: 10 minutes + // 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 - let start_time = datetime!(2025-09-14 16:30:00 UTC); - let end_time = datetime!(2025-09-14 17:30:00 UTC); + // 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 - // Also call debug function to see details - repo.debug_get_tagged_activity_states("creating", start_time, end_time).await?; + // 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?; - let total_minutes = repo.calculate_tagged_duration_in_range("creating", start_time, end_time).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); - println!("=== TEST RESULTS ==="); - println!("Expected: 10.0 minutes for 'creating' tag in range {} to {}", start_time, end_time); - println!("Actual: {} minutes", total_minutes); - println!("===================="); + 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?; - // Should be 10 minutes (5 activities × 2 minutes each) - allow for floating point precision - assert!((total_minutes - 10.0).abs() < 0.01, "Should have approximately 10 minutes of 'creating' activity, got {}", total_minutes); + // 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_in_range_full_day() -> Result<()> { + async fn test_calculate_tagged_duration_single_activity_state_one_tag() -> Result<()> { let repo = setup_test_repo().await?; - // Test the full day for "creating" tag - // All "creating" activities in the dataset: - // 109020, 109019, 109017, 109016, 109015 = 5 activities × 2 minutes = 10 minutes + // 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?; - let start_time = datetime!(2025-09-14 00:00:00 UTC); - let end_time = datetime!(2025-09-15 00:00:00 UTC); + // 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?; - repo.debug_get_tagged_activity_states("creating", start_time, end_time).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); - let total_minutes = repo.calculate_tagged_duration_in_range("creating", start_time, end_time).await?; + // Query for "creating" - should get full 60 minutes + let creating_minutes = repo.calculate_tagged_duration_in_range("creating", query_start, query_end).await?; - println!("=== FULL DAY TEST ==="); - println!("Expected: 10.0 minutes for 'creating' tag for full day"); - println!("Actual: {} minutes", total_minutes); - println!("====================="); + // 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?; - assert_eq!(total_minutes, 10.0, "Should have exactly 10 minutes of 'creating' activity for the full day"); + // 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_programming_vs_creating() -> Result<()> { + async fn test_calculate_tagged_duration_single_activity_state_multiple_tags() -> Result<()> { let repo = setup_test_repo().await?; - let start_time = datetime!(2025-09-14 00:00:00 UTC); - let end_time = datetime!(2025-09-15 00:00:00 UTC); + // Test 1-hour period with one activity state that has multiple tags + // Should split the 60 minutes evenly between all tags - // Calculate for different tags to verify the distinction - let creating_minutes = repo.calculate_tagged_duration_in_range("creating", start_time, end_time).await?; - let programming_minutes = repo.calculate_tagged_duration_in_range("programming", start_time, end_time).await?; + // 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 - repo.debug_get_tagged_activity_states("creating", start_time, end_time).await?; - repo.debug_get_tagged_activity_states("programming", start_time, end_time).await?; + // 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?; - println!("=== TAG COMPARISON ==="); - println!("Creating minutes: {}", creating_minutes); - println!("Programming minutes: {}", programming_minutes); - println!("======================"); + // Link to exactly 3 tags: "creating", "consuming", "neutral" + let tags = [ + ("creating-tag-id", "creating"), + ("consuming-tag-id", "consuming"), + ("neutral-tag-id", "neutral") + ]; - // Programming should have way more entries than creating in your dataset - assert!(programming_minutes > creating_minutes, "Programming should have more minutes than creating"); - assert_eq!(creating_minutes, 10.0, "Creating should be exactly 10 minutes"); + 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(()) } - async fn create_test_db() -> Pool { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .unwrap(); + #[tokio::test] + async fn test_calculate_tagged_duration_multiple_activity_states_mixed_tagging() -> Result<()> { + let repo = setup_test_repo().await?; - // Set WAL mode - sqlx::query("PRAGMA journal_mode=WAL;") - .execute(&pool) - .await - .unwrap(); + // Test 1-hour period with multiple activity states having different tag configurations + // Should sum up times correctly across all activity states - // Create the tables we need for testing - 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(); + let pool = &repo.pool; + let base_time = datetime!(2025-01-04 10:00:00 UTC); - 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(); + // 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( - "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) - )" + "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 - .unwrap(); + .execute(pool) + .await?; - pool - } + // 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.0, "Creating should get sum from states 2, 3, and 4: 15 + 7 + 5 = 27 (SQLite banker's rounding)"); + + // "neutral": + // - State 1: 0 minutes (no tags) + // - State 2: 0 minutes (doesn't have neutral tag) + // - State 3: 7 minutes (2 tags, gets ROUND(15/2) = ROUND(7.5) = 7 with banker's rounding) + // - State 4: 5 minutes (3 tags, gets ROUND(15/3) = ROUND(5.0) = 5) + // - Total: 0 + 0 + 7 + 5 = 12 minutes + assert_eq!(neutral_minutes, 12.0, "Neutral should get sum from states 3 and 4: 7 + 5 = 12 (SQLite banker's rounding)"); + + // "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!("==================================="); - #[tokio::test] - async fn test_activity_state_repo_creation() -> Result<()> { - let pool = create_test_db().await; - let _repo = ActivityStateRepo::new(pool); Ok(()) } #[tokio::test] - async fn test_get_activity_state() -> Result<()> { - let pool = create_test_db().await; - let repo = ActivityStateRepo::new(pool.clone()); + async fn test_calculate_tagged_duration_time_range_clipping() -> Result<()> { + let repo = setup_test_repo().await?; - // Insert test data - let start_time = datetime!(2025-01-06 09:00 UTC); - let end_time = datetime!(2025-01-06 10:00 UTC); - let created_at = OffsetDateTime::now_utc(); + // Test that activity states are properly clipped to the queried time range + // Only the overlapping portion should contribute to the total + // Create custom test data for clipping scenarios + let pool = &repo.pool; + + // Create test activity states with specific times for clipping tests + // State A: 10:00-11:00 (1 hour, "creating" tag) + // State B: 10:30-11:30 (1 hour, "creating" + "neutral" tags) + let state_a_start = datetime!(2025-01-05 10:00:00 UTC); + let state_a_end = datetime!(2025-01-05 11:00:00 UTC); + let state_b_start = datetime!(2025-01-05 10:30:00 UTC); + let state_b_end = datetime!(2025-01-05 11:30:00 UTC); + + // Insert State A (1 tag: creating) sqlx::query( - "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) - VALUES (1, 'ACTIVE', 5, ?1, ?2, ?3)" + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999100, 'ACTIVE', 1, ?1, ?2, ?3)" ) - .bind(start_time) - .bind(end_time) - .bind(created_at) - .execute(&pool) + .bind(state_a_start) + .bind(state_a_end) + .bind(state_a_start) + .execute(pool) .await?; - // Test retrieval - let result = repo.get_activity_state(1).await?; - assert!(result.is_some()); - - let activity_state = result.unwrap(); - assert_eq!(activity_state.id, 1); - assert_eq!(activity_state.state, "ACTIVE"); - assert_eq!(activity_state.app_switches, 5); - assert_eq!(activity_state.start_time, start_time); - assert_eq!(activity_state.end_time, end_time); + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999100, 'creating-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Insert State B (2 tags: creating + neutral) + sqlx::query( + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999101, 'ACTIVE', 1, ?1, ?2, ?3)" + ) + .bind(state_b_start) + .bind(state_b_end) + .bind(state_b_start) + .execute(pool) + .await?; + + sqlx::query( + "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) + VALUES (999101, 'creating-tag-id', NULL, datetime('now'), datetime('now')), + (999101, 'neutral-tag-id', NULL, datetime('now'), datetime('now'))" + ) + .execute(pool) + .await?; + + // Scenario 1: Query starts before State A and ends during State A + // Query 9:30-10:30 should overlap with State A for 10:00-10:30 = 30 minutes + let clip_start_result = repo.calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 9:30:00 UTC), + datetime!(2025-01-05 10:30:00 UTC), + ).await?; + assert_eq!(clip_start_result, 30.0, "Query 9:30-10:30 should clip State A to get 30 minutes for creating"); + + // Scenario 2: Query starts during State A and ends after State A + // Query 10:45-11:15 should overlap with: + // - State A: 10:45-11:00 = 15 minutes (1 tag, gets full 15 minutes) + // - State B: 10:45-11:15 = 30 minutes (2 tags, gets 30/2 = 15 minutes) + // Expected: 15 + 15 = 30 minutes + let clip_end_result = repo.calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 10:45:00 UTC), + datetime!(2025-01-05 11:15:00 UTC), + ).await?; + assert_eq!(clip_end_result, 30.0, "Query 10:45-11:15 should clip states to get 30 minutes for creating"); + + // Scenario 3: Query fully contains State A + // Query 9:30-11:30 should overlap with: + // - State A: 10:00-11:00 = 60 minutes (1 tag, gets full 60 minutes) + // - State B: 10:30-11:30 = 60 minutes (2 tags, gets 60/2 = 30 minutes) + // Expected: 60 + 30 = 90 minutes + let fully_contains_result = repo.calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 9:30:00 UTC), + datetime!(2025-01-05 11:30:00 UTC), + ).await?; + assert_eq!(fully_contains_result, 90.0, "Query 9:30-11:30 should fully contain states and get 90 minutes for creating"); + + // Scenario 4: Query range overlaps with both states + // Query 10:15-10:45 should overlap with: + // - State A: 10:15-10:45 = 30 minutes (1 tag, gets full 30 minutes) + // - State B: 10:30-10:45 = 15 minutes (2 tags, gets 15/2 = 7.5 minutes, rounded to 8) + // Expected: 30 + 8 = 38 minutes + let overlapping_states_result = repo.calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 10:15:00 UTC), + datetime!(2025-01-05 10:45:00 UTC), + ).await?; + assert_eq!(overlapping_states_result, 37.0, "Query 10:15-10:45 overlapping both states should get 37 minutes for creating"); Ok(()) } #[tokio::test] - async fn test_get_activity_state_not_found() -> Result<()> { - let pool = create_test_db().await; - let repo = ActivityStateRepo::new(pool); + async fn test_calculate_tagged_duration_empty_result() -> Result<()> { + let repo = setup_test_repo().await?; - let result = repo.get_activity_state(999).await?; - assert!(result.is_none()); + // 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_in_range() -> Result<()> { - let pool = create_test_db().await; - let repo = ActivityStateRepo::new(pool.clone()); + async fn test_calculate_tagged_duration_single_category_tag() -> Result<()> { + let repo = setup_test_repo().await?; - // Create test tag - let tag_id = "test-creating-tag"; - let now = OffsetDateTime::now_utc(); + // 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 tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + "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'))" ) - .bind(tag_id) - .bind("creating") - .bind("activity") - .bind(false) - .bind(false) - .bind(now) - .bind(now) - .execute(&pool) + .execute(pool) .await?; - // Create 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); + // 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?; - // First activity state (1 hour) + // 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, app_switches, start_time, end_time, created_at) - VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)" + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888002, 'ACTIVE', 1, ?1, ?2, ?3)" ) - .bind(start_time) - .bind(mid_time) - .bind(now) - .execute(&pool) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) .await?; - // Second activity state (1 hour) + // Link to multiple category tags: "coding" and "browsing" sqlx::query( - "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) - VALUES (2, 'ACTIVE', 0, ?1, ?2, ?3)" + "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'))" ) - .bind(mid_time) - .bind(end_time) - .bind(now) - .execute(&pool) + .execute(pool) .await?; - // Create a different tag that shouldn't match - let other_tag_id = "test-learning-tag"; + // 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 tag (id, name, tag_type, is_blocked, is_default, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888003, 'ACTIVE', 1, ?1, ?2, ?3)" ) - .bind(other_tag_id) - .bind("learning") - .bind("activity") - .bind(false) - .bind(false) - .bind(now) - .bind(now) - .execute(&pool) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) .await?; - // Create activity state outside the time range (shouldn't be counted) - let outside_start = datetime!(2025-01-06 06:00 UTC); - let outside_end = datetime!(2025-01-06 07:00 UTC); + // Link to 2 default tags and 1 category tag + // Default: "creating" and "neutral" + // Category: "coding" sqlx::query( - "INSERT INTO activity_state (id, state, app_switches, start_time, end_time, created_at) - VALUES (3, 'ACTIVE', 0, ?1, ?2, ?3)" + "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'))" ) - .bind(outside_start) - .bind(outside_end) - .bind(now) - .execute(&pool) + .execute(pool) .await?; - // Link first two activity states to the "creating" tag (should be counted) + // 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_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (888004, 'ACTIVE', 1, ?1, ?2, ?3)" ) - .bind(tag_id) - .bind(now) - .bind(now) - .execute(&pool) + .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, created_at, updated_at) - VALUES ('2', ?1, ?2, ?3)" + "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'))" ) - .bind(tag_id) - .bind(now) - .bind(now) - .execute(&pool) + .execute(pool) .await?; - // Link activity state #3 (outside range) to creating tag (shouldn't be counted due to range) + // 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_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('3', ?1, ?2, ?3)" + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (777001, 'ACTIVE', 1, ?1, ?2, ?3)" ) - .bind(tag_id) - .bind(now) - .bind(now) - .execute(&pool) + .bind(test_start) + .bind(test_end) + .bind(test_start) + .execute(pool) .await?; - // Create an activity state with the wrong tag (shouldn't be counted) + // 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 (id, state, app_switches, start_time, end_time, created_at) - VALUES (4, 'ACTIVE', 0, ?1, ?2, ?3)" + "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'))" ) - .bind(start_time) - .bind(mid_time) - .bind(now) - .execute(&pool) + .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_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('4', ?1, ?2, ?3)" + "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) + VALUES (999003, 'ACTIVE', 1, ?1, ?2, ?3)" ) - .bind(other_tag_id) // Link to "learning" tag instead of "creating" - .bind(now) - .bind(now) - .execute(&pool) + .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()); - // Test the calculation - should return 120 minutes (2 hours) - let range_start = datetime!(2025-01-06 08:00 UTC); - let range_end = datetime!(2025-01-06 12:00 UTC); - - let total_duration = repo.calculate_tagged_duration_in_range( - "creating", - range_start, - range_end, - ).await?; + let activity_state = result.unwrap(); + assert_eq!(activity_state.id, 999003); + assert_eq!(activity_state.state, "ACTIVE"); - // Use approximate equality due to floating point precision in SQLite julianday calculations - assert!((total_duration - 120.0).abs() < 0.01, "Expected ~120 minutes, got {}", total_duration); + 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/tide_template_repo.rs b/src-tauri/src/ebb_db/src/db/tide_template_repo.rs index 1dc440ce..7f550279 100644 --- a/src-tauri/src/ebb_db/src/db/tide_template_repo.rs +++ b/src-tauri/src/ebb_db/src/db/tide_template_repo.rs @@ -130,7 +130,7 @@ mod tests { repo.create_tide_template(&template2).await?; let all_templates = repo.get_all_tide_templates().await?; - assert_eq!(all_templates.len(), 2); + assert_eq!(all_templates.len(), 4); Ok(()) } From 4413d389429938be344a8a5d0a898f58fc1bcd2e Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 21 Sep 2025 12:29:53 -0600 Subject: [PATCH 14/40] tmp --- .../src/ebb_db/src/db/activity_state_repo.rs | 205 +++++++++++------- src-tauri/src/ebb_tide_manager/src/lib.rs | 3 +- .../src/ebb_tide_manager/src/tide_progress.rs | 2 + 3 files changed, 127 insertions(+), 83 deletions(-) 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 index 10c80b0a..40959d78 100644 --- a/src-tauri/src/ebb_db/src/db/activity_state_repo.rs +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -63,108 +63,151 @@ impl ActivityStateRepo { } /// 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(); + println!("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 }; - let total_minutes: Option = sqlx::query_scalar( + // 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 - COALESCE(SUM(split_minutes), 0.0) as total_minutes - FROM ( - SELECT - ROUND((julianday( - CASE - WHEN activity_state.end_time > ?2 THEN ?2 - ELSE activity_state.end_time - END - ) - julianday( - CASE - WHEN activity_state.start_time < ?3 THEN ?3 - ELSE activity_state.start_time - END - )) * 24 * 60 / ( - SELECT COUNT(*) - FROM activity_state_tag ast2 - LEFT JOIN tag t - ON t.id = ast2.tag_id - WHERE ast2.activity_state_id = activity_state.id - AND t.tag_type = ?4 - )) as split_minutes - 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 - ) as time_split_activities" + 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(tag_name) .bind(end_time) .bind(start_time) - .bind(&tag_type) - .fetch_one(&self.pool) + .fetch_all(&self.pool) .await?; + let main_query_duration = main_query_start.elapsed(); + println!("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(); + println!("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)> = 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); + } + } - // Debug: Print the actual SQL query with substituted parameters - let debug_sql = format!( - "SELECT - COALESCE(SUM(split_minutes), 0.0) as total_minutes - FROM ( - SELECT - activity_state.id, - ROUND((julianday( - CASE - WHEN activity_state.end_time > '{}' THEN '{}' - ELSE activity_state.end_time - END - ) - julianday( - CASE - WHEN activity_state.start_time < '{}' THEN '{}' - ELSE activity_state.start_time - END - )) * 24 * 60 / ( - SELECT COUNT(*) - FROM activity_state_tag ast2 - LEFT JOIN tag t - ON t.id = ast2.tag_id - WHERE ast2.activity_state_id = activity_state.id - AND t.tag_type = '{}' - )) as split_minutes - 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 = '{}' - AND activity_state.start_time < '{}' - AND activity_state.end_time > '{}' - GROUP BY activity_state.id - ) as time_split_activities;", - end_time, end_time, start_time, start_time, tag_type, tag_name, end_time, start_time - ); - - println!("=== DEBUG SQL QUERY ==="); - println!("{}", debug_sql); - println!("Query parameters: tag_name='{}', end_time={}, start_time={}, tag_type='{}'", tag_name, end_time, start_time, tag_type); - println!("Total minutes result: {:?}", total_minutes); - println!("======================="); - - Ok(total_minutes.unwrap_or(0.0)) + // print out activity_states + println!("Activity states: {:?}", activity_states); + + // print out target_tag_records + println!("Target tag records: {:?}", target_tag_records); + + // 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 overlapping duration for this activity state + let effective_start = if *activity_start < start_time { start_time } else { *activity_start }; + let effective_end = if *activity_end > end_time { end_time } else { *activity_end }; + + // Skip if there's no actual overlap (effective_end <= effective_start) + if effective_end <= effective_start { + println!("Skipping activity state {} - no overlap: effective range {} to {}", + activity_state_id, effective_start, effective_end); + continue; + } + + let duration_minutes = (effective_end - effective_start).whole_minutes() as f64; + + // 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; + + println!("State {} record: {:.2} minutes ÷ {} tags = {:.2} minutes", + activity_state_id, duration_minutes, tag_count, split_minutes); + } + } + } + + let total_function_duration = function_start.elapsed(); + + println!("=== APPLICATION-SIDE CALCULATION ==="); + println!("Target tag: '{}' (type: '{}')", tag_name, tag_type); + println!("Time range: {} to {}", start_time, end_time); + println!("Processed {} activity states", activity_states.len()); + println!("Found {} records with target tag", target_record_count); + println!("Total minutes: {:.2}", total_minutes); + println!("TOTAL FUNCTION TIME: {:?}", total_function_duration); + println!("====================================="); + + Ok(total_minutes) } /// Debug helper: Get raw activity states for a tag to manually verify calculation @@ -570,15 +613,15 @@ mod tests { // - 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.0, "Creating should get sum from states 2, 3, and 4: 15 + 7 + 5 = 27 (SQLite banker's rounding)"); + 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 minutes (2 tags, gets ROUND(15/2) = ROUND(7.5) = 7 with banker's rounding) - // - State 4: 5 minutes (3 tags, gets ROUND(15/3) = ROUND(5.0) = 5) - // - Total: 0 + 0 + 7 + 5 = 12 minutes - assert_eq!(neutral_minutes, 12.0, "Neutral should get sum from states 3 and 4: 7 + 5 = 12 (SQLite banker's rounding)"); + // - 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) @@ -708,7 +751,7 @@ mod tests { datetime!(2025-01-05 10:15:00 UTC), datetime!(2025-01-05 10:45:00 UTC), ).await?; - assert_eq!(overlapping_states_result, 37.0, "Query 10:15-10:45 overlapping both states should get 37 minutes for creating"); + assert_eq!(overlapping_states_result, 37.5, "Query 10:15-10:45 overlapping both states should get 37.5 minutes for creating"); Ok(()) } diff --git a/src-tauri/src/ebb_tide_manager/src/lib.rs b/src-tauri/src/ebb_tide_manager/src/lib.rs index 5fa1887e..a503f831 100644 --- a/src-tauri/src/ebb_tide_manager/src/lib.rs +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -39,7 +39,7 @@ pub struct TideManager { impl TideManager { /// Create a new TideManager with default configuration (60 second intervals) pub async fn new() -> Result { - Self::new_with_interval(15).await + Self::new_with_interval(120).await } /// Create a new TideManager with custom interval @@ -132,7 +132,6 @@ impl TideManager { if progress.should_complete_tide(&tide, evaluation_time).await? { println!("Completing tide {}", tide.id); service.complete_tide(&tide.id).await?; - progress.clear_tide_cache(&tide.id).await; // Clear cache for completed tide } } diff --git a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs index 0e06ee3c..600f007f 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -78,6 +78,7 @@ impl TideProgress { if let Some(cached) = cached_data { // Cache hit - calculate incremental progress + println!("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( @@ -100,6 +101,7 @@ impl TideProgress { } // Cache miss - calculate full range from tide start + println!("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 From 501ba023b4e9d1d14cf1220c5b37d2d0dc7921d8 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Fri, 26 Sep 2025 11:53:04 -0600 Subject: [PATCH 15/40] usage summary changes --- src/components/UsageSummaryWithTides.tsx | 362 +++++++++++++++++++++++ src/components/ui/circular-progress.tsx | 125 ++++++++ src/pages/HomePage.tsx | 12 +- 3 files changed, 489 insertions(+), 10 deletions(-) create mode 100644 src/components/UsageSummaryWithTides.tsx create mode 100644 src/components/ui/circular-progress.tsx diff --git a/src/components/UsageSummaryWithTides.tsx b/src/components/UsageSummaryWithTides.tsx new file mode 100644 index 00000000..c05e3c6f --- /dev/null +++ b/src/components/UsageSummaryWithTides.tsx @@ -0,0 +1,362 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Tooltip, + TooltipContent, + TooltipProvider, + 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 { AppIcon } from '@/components/AppIcon' +import { AnalyticsButton } from '@/components/ui/analytics-button' +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 { CircularProgress } from './ui/circular-progress' + +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[]; + showTopAppsButton?: boolean; + showIdleTime?: boolean; + setShowIdleTime?: (showIdleTime: boolean) => void; + isLoading?: boolean; + yAxisMax?: number; + rangeMode: 'day' | 'week' | 'month'; + date: Date; + lastUpdated?: Date | null; +} + +// Mock tide data - replace with actual API calls later +const getMockTideData = () => { + return { + dailyGoal: { + current: 194, // 3h 14m current progress + goal: 180, // 3h goal (will show as complete + stretch progress) + }, + weeklyGoal: { + current: 420, // 7h current progress + goal: 600, // 10h goal + } + } +} + +export const UsageSummaryWithTides = ({ + chartData, + appUsage, + showTopAppsButton = false, + isLoading = false, + yAxisMax, + showIdleTime, + rangeMode, + date, + lastUpdated, + 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 sortedAppUsage = [...appUsage || []].sort((a, b) => b.duration - a.duration) + const appUsageRef = useRef(null) + + // Get mock tide data + const tideData = getMockTideData() + + 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 scrollToAppUsage = () => { + appUsageRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + 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 ( + <> +
+ + {/* Daily Creating Goal Card */} + + + {/* Weekly Creating Goal Card */} + + + {/* Keep the Top Apps/Websites card as-is */} + + + Top Apps/Websites + + +
+ {sortedAppUsage.slice(0, 3).map((app, index) => ( + + + + ))} +
+
+
+
+
+ + {/* Keep the rest of the component exactly as it was */} + + + {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)}
+
+
+ ) + }} + /> + } /> + + + + + +
+
+
+ )} +
+ {lastUpdated && ( +
+
+ Last updated: {formatLastUpdated(lastUpdated)} +
+
+ )} +
+ +
+ +
+ + ) +} diff --git a/src/components/ui/circular-progress.tsx b/src/components/ui/circular-progress.tsx new file mode 100644 index 00000000..6dfa28bb --- /dev/null +++ b/src/components/ui/circular-progress.tsx @@ -0,0 +1,125 @@ +import React 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: React.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)} + + )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 58e32f3e..d5739dc4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/popover' 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' @@ -27,15 +27,11 @@ export const HomePage = () => { rangeMode, appUsage, setRangeMode, - totalCreating, - totalTime, chartData, isLoading, yAxisMax, showIdleTime, setShowIdleTime, - totalTimeTooltip, - totalTimeLabel, lastUpdated, } = useUsageSummary() @@ -88,11 +84,7 @@ export const HomePage = () => { - Date: Fri, 26 Sep 2025 11:53:16 -0600 Subject: [PATCH 16/40] move daily/weekly into header --- src/components/TideGoalsCard.tsx | 176 ++++++++++++ src/components/UsageSummaryWithTides.tsx | 330 ++++++++++------------- src/components/ui/circular-progress.tsx | 8 +- src/pages/HomePage.tsx | 1 - 4 files changed, 315 insertions(+), 200 deletions(-) create mode 100644 src/components/TideGoalsCard.tsx diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx new file mode 100644 index 00000000..a84104f4 --- /dev/null +++ b/src/components/TideGoalsCard.tsx @@ -0,0 +1,176 @@ +import { type FC, useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { PieChart, Pie, Cell } from 'recharts' + +interface TideGoalsCardProps { + className?: string +} + +// Mock tide data - replace with actual API calls later +const getMockTideData = () => { + return { + dailyGoal: { + current: 194, // 3h 14m current progress + goal: 180, // 3h goal (will show as complete + stretch progress) + }, + weeklyGoal: { + current: 420, // 7h current progress + goal: 600, // 10h goal + } + } +} + +export const TideGoalsCard: FC = ({ className = '' }) => { + const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') + const tideData = getMockTideData() + + // 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` + } + + const renderGoalProgress = (current: number, goal: number) => { + // Calculate progress percentages + const stretchGoal = goal * 1.33 // 133% of base goal for stretch + const baseProgress = Math.min(current / goal, 1) // 0-1 for base goal + const remainingToGoal = Math.max(goal - current, 0) + + // Chart dimensions + const size = 140 + const strokeWidth = 12 + + // Prepare data for pie chart + const baseGoalPortion = 75 // Base goal takes 75% of circle + const stretchPortion = 25 // Stretch goal takes 25% of circle + + // Calculate filled portions + const baseFilledPortion = baseProgress * baseGoalPortion + const baseEmptyPortion = baseGoalPortion - baseFilledPortion + + let stretchFilledPortion = 0 + let stretchEmptyPortion = stretchPortion + + if (current > goal) { + const stretchProgress = Math.min((current - goal) / (stretchGoal - goal), 1) + stretchFilledPortion = stretchProgress * stretchPortion + stretchEmptyPortion = stretchPortion - stretchFilledPortion + } + + const chartData = [ + // Base goal progress (filled) + { name: 'baseFilled', value: baseFilledPortion, color: 'hsl(var(--primary))' }, + // Stretch goal progress (filled) - only if there's progress beyond base goal + ...(stretchFilledPortion > 0 ? [{ name: 'stretchFilled', value: stretchFilledPortion, color: 'hsl(var(--primary))' }] : []), + // Empty base goal portion + ...(baseEmptyPortion > 0 ? [{ name: 'baseEmpty', value: baseEmptyPortion, color: 'hsl(var(--muted))' }] : []), + // Empty stretch portion + ...(stretchEmptyPortion > 0 ? [{ name: 'stretchEmpty', value: stretchEmptyPortion, color: 'hsl(var(--muted))' }] : []), + ] + + return ( +
+
+ + + {chartData.map((entry, index) => ( + + ))} + + + + {/* Center content */} +
+
+ {formatTime(current)} +
+ {remainingToGoal > 0 && ( +
+ {formatTime(remainingToGoal)} until target +
+ )} + {current >= goal && remainingToGoal === 0 && ( +
+ Tide reached! +
+ )} +
+
+ + {/* Goal information - fixed height container */} +
+
+ Target: {formatTime(goal)} +
+
+
+ ) + } + + return ( + + +
+ Tides + {/* Compact Chip Style in Header */} +
+ + +
+
+
+ + + {/* Content */} + {activeTab === 'daily' && ( +
+ {renderGoalProgress(tideData.dailyGoal.current, tideData.dailyGoal.goal)} +
+ )} + {activeTab === 'weekly' && ( +
+ {renderGoalProgress(tideData.weeklyGoal.current, tideData.weeklyGoal.goal)} +
+ )} +
+
+ ) +} diff --git a/src/components/UsageSummaryWithTides.tsx b/src/components/UsageSummaryWithTides.tsx index c05e3c6f..e230b906 100644 --- a/src/components/UsageSummaryWithTides.tsx +++ b/src/components/UsageSummaryWithTides.tsx @@ -1,8 +1,7 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' @@ -14,14 +13,12 @@ import { } from '@/components/ui/chart' import { Skeleton } from './ui/skeleton' import { GraphableTimeByHourBlock, AppsWithTime } from '@/api/monitorApi/monitorApi' -import { AppIcon } from '@/components/AppIcon' -import { AnalyticsButton } from '@/components/ui/analytics-button' 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 { CircularProgress } from './ui/circular-progress' +import { TideGoalsCard } from './TideGoalsCard' type ChartLabel = { label: string @@ -79,7 +76,6 @@ export const formatTimeToDecimalHours = (minutes: number) => { export interface UsageSummaryWithTidesProps { chartData: GraphableTimeByHourBlock[]; appUsage: AppsWithTime[]; - showTopAppsButton?: boolean; showIdleTime?: boolean; setShowIdleTime?: (showIdleTime: boolean) => void; isLoading?: boolean; @@ -89,24 +85,10 @@ export interface UsageSummaryWithTidesProps { lastUpdated?: Date | null; } -// Mock tide data - replace with actual API calls later -const getMockTideData = () => { - return { - dailyGoal: { - current: 194, // 3h 14m current progress - goal: 180, // 3h goal (will show as complete + stretch progress) - }, - weeklyGoal: { - current: 420, // 7h current progress - goal: 600, // 10h goal - } - } -} export const UsageSummaryWithTides = ({ chartData, appUsage, - showTopAppsButton = false, isLoading = false, yAxisMax, showIdleTime, @@ -120,12 +102,8 @@ export const UsageSummaryWithTides = ({ const { data: notificationBySentId } = useGetNotificationBySentId('firefox_not_supported') const [chartDataState, setChartDataState] = useState(chartData) const [chartConfigState, setChartConfigState] = useState(defaultChartConfig) - const sortedAppUsage = [...appUsage || []].sort((a, b) => b.duration - a.duration) const appUsageRef = useRef(null) - // Get mock tide data - const tideData = getMockTideData() - useEffect(() => { if(!appUsage) return const hasFirefox = appUsage.some(app => app.app_external_id === 'org.mozilla.firefox') @@ -153,10 +131,6 @@ export const UsageSummaryWithTides = ({ } }, [showIdleTime, chartData]) - const scrollToAppUsage = () => { - appUsageRef.current?.scrollIntoView({ behavior: 'smooth' }) - } - const formatLastUpdated = (date: Date | null) => { if (!date) return null const now = new Date() @@ -177,182 +151,148 @@ export const UsageSummaryWithTides = ({ return ( <> + {/* Two-column layout: Goals card on left, Chart on right */}
- - {/* Daily Creating Goal Card */} - - - {/* Weekly Creating Goal Card */} - + {/* Tide Goals Card */} + - {/* Keep the Top Apps/Websites card as-is */} - - - Top Apps/Websites - - -
- {sortedAppUsage.slice(0, 3).map((app, index) => ( - - - - ))} + {/* Chart Card - spans 2 columns */} + + + {isLoading ? ( +
+
-
-
- -
- - {/* Keep the rest of the component exactly as it was */} - - - {isLoading ? ( -
- -
- ) : ( -
- - -
-
diff --git a/src/components/ui/circular-progress.tsx b/src/components/ui/circular-progress.tsx index 6dfa28bb..1657b8ae 100644 --- a/src/components/ui/circular-progress.tsx +++ b/src/components/ui/circular-progress.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { type FC } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' interface CircularProgressProps { @@ -8,11 +8,11 @@ interface CircularProgressProps { className?: string } -export const CircularProgress: React.FC = ({ +export const CircularProgress: FC = ({ title, currentValue, goalValue, - className = "" + className = '' }) => { // Calculate progress percentages const stretchGoal = goalValue * 1.33 // 133% of base goal for stretch @@ -122,4 +122,4 @@ export const CircularProgress: React.FC = ({ ) -} \ No newline at end of file +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index d5739dc4..708a4399 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -87,7 +87,6 @@ export const HomePage = () => { Date: Fri, 26 Sep 2025 11:59:24 -0600 Subject: [PATCH 17/40] fill in clockwise --- src/components/TideGoalsCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index a84104f4..fdef0dc0 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -74,13 +74,13 @@ export const TideGoalsCard: FC = ({ className = '' }) => { return (
- + Date: Fri, 26 Sep 2025 13:51:07 -0600 Subject: [PATCH 18/40] add recharts pie chart with segmented hours --- src/components/TideGoalsCard.tsx | 175 +++++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 42 deletions(-) diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index fdef0dc0..83dfee56 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -14,7 +14,7 @@ const getMockTideData = () => { goal: 180, // 3h goal (will show as complete + stretch progress) }, weeklyGoal: { - current: 420, // 7h current progress + current: 280, // 7h current progress goal: 600, // 10h goal } } @@ -34,68 +34,159 @@ export const TideGoalsCard: FC = ({ className = '' }) => { } const renderGoalProgress = (current: number, goal: number) => { - // Calculate progress percentages - const stretchGoal = goal * 1.33 // 133% of base goal for stretch - const baseProgress = Math.min(current / goal, 1) // 0-1 for base goal const remainingToGoal = Math.max(goal - current, 0) // Chart dimensions - const size = 140 + const size = 160 // Increased from 140 to 160 (20px larger) const strokeWidth = 12 - // Prepare data for pie chart - const baseGoalPortion = 75 // Base goal takes 75% of circle - const stretchPortion = 25 // Stretch goal takes 25% of circle + // 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 - // Calculate filled portions - const baseFilledPortion = baseProgress * baseGoalPortion - const baseEmptyPortion = baseGoalPortion - baseFilledPortion + 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 - let stretchFilledPortion = 0 - let stretchEmptyPortion = stretchPortion + // 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 - if (current > goal) { - const stretchProgress = Math.min((current - goal) / (stretchGoal - goal), 1) - stretchFilledPortion = stretchProgress * stretchPortion - stretchEmptyPortion = stretchPortion - stretchFilledPortion - } - const chartData = [ - // Base goal progress (filled) - { name: 'baseFilled', value: baseFilledPortion, color: 'hsl(var(--primary))' }, - // Stretch goal progress (filled) - only if there's progress beyond base goal - ...(stretchFilledPortion > 0 ? [{ name: 'stretchFilled', value: stretchFilledPortion, color: 'hsl(var(--primary))' }] : []), - // Empty base goal portion - ...(baseEmptyPortion > 0 ? [{ name: 'baseEmpty', value: baseEmptyPortion, color: 'hsl(var(--muted))' }] : []), - // Empty stretch portion - ...(stretchEmptyPortion > 0 ? [{ name: 'stretchEmpty', value: stretchEmptyPortion, color: 'hsl(var(--muted))' }] : []), - ] + 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) => ( - - ))} + {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 ( + + ) + })} @@ -105,7 +196,7 @@ export const TideGoalsCard: FC = ({ className = '' }) => { {formatTime(current)}
{remainingToGoal > 0 && ( -
+
{formatTime(remainingToGoal)} until target
)} @@ -157,7 +248,7 @@ export const TideGoalsCard: FC = ({ className = '' }) => {
- + {/* Content */} {activeTab === 'daily' && ( From 5db2bd189b6f6c4f9bfbb922bdf3c22b5100de95 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Fri, 26 Sep 2025 14:10:01 -0600 Subject: [PATCH 19/40] improved animation/contrast completed goals --- src/components/TideGoalsCard.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 83dfee56..cdca298b 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -53,6 +53,7 @@ export const TideGoalsCard: FC = ({ className = '' }) => { 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 @@ -143,8 +144,14 @@ export const TideGoalsCard: FC = ({ className = '' }) => { paddingAngle={0} dataKey="value" > - - + +
{/* Progress overlay */} @@ -183,7 +190,9 @@ export const TideGoalsCard: FC = ({ className = '' }) => { ) })} From eb5f07c98bf4053c1f88040183fa7bf1716c4f03 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Fri, 26 Sep 2025 14:36:04 -0600 Subject: [PATCH 20/40] emphasize tide overflow time --- src/components/TideGoalsCard.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index cdca298b..b60d0166 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -14,7 +14,7 @@ const getMockTideData = () => { goal: 180, // 3h goal (will show as complete + stretch progress) }, weeklyGoal: { - current: 280, // 7h current progress + current: 780, // 7h current progress goal: 600, // 10h goal } } @@ -25,11 +25,11 @@ export const TideGoalsCard: FC = ({ className = '' }) => { const tideData = getMockTideData() // Format time helper - const formatTime = (minutes: number) => { + 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) return `${hours}h` + if (remainingMinutes === 0 && !options.overrideShowHours) return `${hours}h` return `${hours}h ${remainingMinutes}m` } @@ -202,7 +202,7 @@ export const TideGoalsCard: FC = ({ className = '' }) => { {/* Center content */}
- {formatTime(current)} + {formatTime(current, { overrideShowHours: true })}
{remainingToGoal > 0 && (
@@ -210,9 +210,11 @@ export const TideGoalsCard: FC = ({ className = '' }) => {
)} {current >= goal && remainingToGoal === 0 && ( -
- Tide reached! +
+
Tide reached!
+
+{formatTime(current - goal)}
+ )}
From bc5d1f2c1a8eaa7a94c3faf74f559e011c46dd21 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 11:02:53 -0600 Subject: [PATCH 21/40] skip completion check if already completed --- .../src/ebb_db/src/db/activity_state_repo.rs | 627 +++++++++++++----- src-tauri/src/ebb_tide_manager/src/lib.rs | 1 + .../src/ebb_tide_manager/src/tide_progress.rs | 587 +++++++++++++--- 3 files changed, 932 insertions(+), 283 deletions(-) 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 index 40959d78..3d9e9705 100644 --- a/src-tauri/src/ebb_db/src/db/activity_state_repo.rs +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -25,7 +25,7 @@ impl ActivityStateRepo { WHERE state = 'ACTIVE' AND start_time < ?1 AND end_time > ?2 - ORDER BY start_time ASC" + ORDER BY start_time ASC", ) .bind(end_time) .bind(start_time) @@ -51,7 +51,7 @@ impl ActivityStateRepo { AND tag.name = ?1 AND activity_state.start_time < ?2 AND activity_state.end_time > ?3 - ORDER BY activity_state.start_time ASC" + ORDER BY activity_state.start_time ASC", ) .bind(tag_name) .bind(end_time) @@ -71,16 +71,20 @@ impl ActivityStateRepo { 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); + 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: 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(); println!("Tag type query took: {:?}", tag_type_query_duration); @@ -112,14 +116,18 @@ impl ActivityStateRepo { 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" + ORDER BY activity_state.id", ) .bind(end_time) .bind(start_time) .fetch_all(&self.pool) .await?; let main_query_duration = main_query_start.elapsed(); - println!("Main data query took: {:?} and returned {} records", main_query_duration, raw_data.len()); + println!( + "Main data query took: {:?} and returned {} records", + main_query_duration, + raw_data.len() + ); // Convert to structured data let processing_start = std::time::Instant::now(); @@ -139,13 +147,16 @@ impl ActivityStateRepo { // Group by activity_state_id and calculate tag counts per tag_type use std::collections::HashMap; - let mut activity_states: HashMap)> = HashMap::new(); + 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()) - }); + 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; @@ -157,10 +168,10 @@ impl ActivityStateRepo { } // print out activity_states - println!("Activity states: {:?}", activity_states); + // println!("Activity states: {:?}", activity_states); // print out target_tag_records - println!("Target tag records: {:?}", target_tag_records); + // println!("Target tag records: {:?}", target_tag_records); // Calculate the total duration let mut total_minutes = 0.0; @@ -168,15 +179,27 @@ impl ActivityStateRepo { // 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) { + if let Some((activity_start, activity_end, tag_counts)) = + activity_states.get(&activity_state_id) + { // Calculate the overlapping duration for this activity state - let effective_start = if *activity_start < start_time { start_time } else { *activity_start }; - let effective_end = if *activity_end > end_time { end_time } else { *activity_end }; + let effective_start = if *activity_start < start_time { + start_time + } else { + *activity_start + }; + let effective_end = if *activity_end > end_time { + end_time + } else { + *activity_end + }; // Skip if there's no actual overlap (effective_end <= effective_start) if effective_end <= effective_start { - println!("Skipping activity state {} - no overlap: effective range {} to {}", - activity_state_id, effective_start, effective_end); + println!( + "Skipping activity state {} - no overlap: effective range {} to {}", + activity_state_id, effective_start, effective_end + ); continue; } @@ -190,8 +213,8 @@ impl ActivityStateRepo { let split_minutes = duration_minutes / (*tag_count as f64); total_minutes += split_minutes; - println!("State {} record: {:.2} minutes ÷ {} tags = {:.2} minutes", - activity_state_id, duration_minutes, tag_count, split_minutes); + // println!("State {} record: {:.2} minutes ÷ {} tags = {:.2} minutes", + // activity_state_id, duration_minutes, tag_count, split_minutes); } } } @@ -225,7 +248,7 @@ impl ActivityStateRepo { WHERE tag.name = ?1 AND activity_state.start_time < ?2 AND activity_state.end_time > ?3 - ORDER BY activity_state.start_time" + ORDER BY activity_state.start_time", ) .bind(tag_name) .bind(end_time) @@ -241,12 +264,18 @@ impl ActivityStateRepo { 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_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; - println!(" ID: {}, Original: {} to {}, Clipped: {} to {}, Duration: {:.2} min", - id, start, end, actual_start, actual_end, duration); + println!( + " ID: {}, Original: {} to {}, Clipped: {} to {}, Duration: {:.2} min", + id, start, end, actual_start, actual_end, duration + ); total_manual += duration; } } @@ -258,12 +287,11 @@ impl ActivityStateRepo { /// 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?; + let state = + sqlx::query_as::<_, ActivityState>("SELECT * FROM activity_state WHERE id = ?1") + .bind(id) + .fetch_optional(&self.pool) + .await?; Ok(state) } @@ -272,18 +300,24 @@ impl ActivityStateRepo { #[cfg(test)] mod tests { use super::*; + use crate::db_manager; use sqlx::{Pool, Sqlite}; use time::macros::datetime; - use crate::db_manager; /// 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 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?; + sqlx::query("DELETE FROM sqlite_sequence WHERE name IN ('activity_state', 'tag')") + .execute(pool) + .await?; Ok(()) } @@ -316,7 +350,6 @@ mod tests { 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 @@ -325,8 +358,10 @@ mod tests { id TEXT PRIMARY KEY NOT NULL, tag_type TEXT NOT NULL, name TEXT NOT NULL - )" - ).execute(pool).await?; + )", + ) + .execute(pool) + .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS activity_state ( @@ -337,8 +372,10 @@ mod tests { start_time DATETIME NOT NULL, end_time DATETIME, created_at DATETIME NOT NULL - )" - ).execute(pool).await?; + )", + ) + .execute(pool) + .await?; sqlx::query( "CREATE TABLE IF NOT EXISTS activity_state_tag ( @@ -350,8 +387,10 @@ mod tests { 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?; + )", + ) + .execute(pool) + .await?; Ok(()) } @@ -400,14 +439,29 @@ mod tests { 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?; + 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'"); + 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(()) } @@ -448,16 +502,31 @@ mod tests { 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?; + 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?; + 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'"); + 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(()) } @@ -489,7 +558,7 @@ mod tests { let tags = [ ("creating-tag-id", "creating"), ("consuming-tag-id", "consuming"), - ("neutral-tag-id", "neutral") + ("neutral-tag-id", "neutral"), ]; for (tag_id, _tag_name) in tags { @@ -507,22 +576,45 @@ mod tests { 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?; + 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?; + 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'"); + 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"); + assert_eq!( + total, 60.0, + "Sum of all tag times should equal original duration" + ); Ok(()) } @@ -601,10 +693,18 @@ mod tests { 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?; + 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": @@ -613,7 +713,10 @@ mod tests { // - 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)"); + 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) @@ -621,7 +724,10 @@ mod tests { // - 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)"); + 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) @@ -629,12 +735,18 @@ mod tests { // - 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"); + 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"); + 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 @@ -710,48 +822,68 @@ mod tests { // Scenario 1: Query starts before State A and ends during State A // Query 9:30-10:30 should overlap with State A for 10:00-10:30 = 30 minutes - let clip_start_result = repo.calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 9:30:00 UTC), - datetime!(2025-01-05 10:30:00 UTC), - ).await?; - assert_eq!(clip_start_result, 30.0, "Query 9:30-10:30 should clip State A to get 30 minutes for creating"); + let clip_start_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 9:30:00 UTC), + datetime!(2025-01-05 10:30:00 UTC), + ) + .await?; + assert_eq!( + clip_start_result, 30.0, + "Query 9:30-10:30 should clip State A to get 30 minutes for creating" + ); // Scenario 2: Query starts during State A and ends after State A // Query 10:45-11:15 should overlap with: // - State A: 10:45-11:00 = 15 minutes (1 tag, gets full 15 minutes) // - State B: 10:45-11:15 = 30 minutes (2 tags, gets 30/2 = 15 minutes) // Expected: 15 + 15 = 30 minutes - let clip_end_result = repo.calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 10:45:00 UTC), - datetime!(2025-01-05 11:15:00 UTC), - ).await?; - assert_eq!(clip_end_result, 30.0, "Query 10:45-11:15 should clip states to get 30 minutes for creating"); + let clip_end_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 10:45:00 UTC), + datetime!(2025-01-05 11:15:00 UTC), + ) + .await?; + assert_eq!( + clip_end_result, 30.0, + "Query 10:45-11:15 should clip states to get 30 minutes for creating" + ); // Scenario 3: Query fully contains State A // Query 9:30-11:30 should overlap with: // - State A: 10:00-11:00 = 60 minutes (1 tag, gets full 60 minutes) // - State B: 10:30-11:30 = 60 minutes (2 tags, gets 60/2 = 30 minutes) // Expected: 60 + 30 = 90 minutes - let fully_contains_result = repo.calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 9:30:00 UTC), - datetime!(2025-01-05 11:30:00 UTC), - ).await?; - assert_eq!(fully_contains_result, 90.0, "Query 9:30-11:30 should fully contain states and get 90 minutes for creating"); + let fully_contains_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 9:30:00 UTC), + datetime!(2025-01-05 11:30:00 UTC), + ) + .await?; + assert_eq!( + fully_contains_result, 90.0, + "Query 9:30-11:30 should fully contain states and get 90 minutes for creating" + ); // Scenario 4: Query range overlaps with both states // Query 10:15-10:45 should overlap with: // - State A: 10:15-10:45 = 30 minutes (1 tag, gets full 30 minutes) // - State B: 10:30-10:45 = 15 minutes (2 tags, gets 15/2 = 7.5 minutes, rounded to 8) // Expected: 30 + 8 = 38 minutes - let overlapping_states_result = repo.calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 10:15:00 UTC), - datetime!(2025-01-05 10:45:00 UTC), - ).await?; - assert_eq!(overlapping_states_result, 37.5, "Query 10:15-10:45 overlapping both states should get 37.5 minutes for creating"); + let overlapping_states_result = repo + .calculate_tagged_duration_in_range( + "creating", + datetime!(2025-01-05 10:15:00 UTC), + datetime!(2025-01-05 10:45:00 UTC), + ) + .await?; + assert_eq!( + overlapping_states_result, 37.5, + "Query 10:15-10:45 overlapping both states should get 37.5 minutes for creating" + ); Ok(()) } @@ -763,38 +895,58 @@ mod tests { // 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"); + 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"); + 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"); + 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"); + 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(()) } @@ -837,20 +989,40 @@ mod tests { 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?; + 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?; + 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?; + 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'"); + 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(()) } @@ -891,20 +1063,40 @@ mod tests { 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?; + 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?; + 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?; + 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'"); + 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(()) } @@ -948,28 +1140,54 @@ mod tests { 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?; + 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?; + 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?; + 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"); + 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<()> { + 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 @@ -1009,30 +1227,65 @@ mod tests { 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?; + 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?; + 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?; + 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)"); + 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)"); + 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"); + 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(()) } @@ -1080,31 +1333,53 @@ mod tests { 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?; + 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?; + 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"); + 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"); + assert_eq!( + total, 60.0, + "Sum of all tag times should equal original duration" + ); Ok(()) } - // ===== OTHER REPOSITORY TESTS ===== #[tokio::test] @@ -1130,7 +1405,7 @@ mod tests { .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()); @@ -1151,4 +1426,4 @@ mod tests { Ok(()) } -} \ 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 index a503f831..1855554d 100644 --- a/src-tauri/src/ebb_tide_manager/src/lib.rs +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -120,6 +120,7 @@ impl TideManager { // 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 { diff --git a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs index 600f007f..105c12a0 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -1,15 +1,12 @@ +use crate::tide_service::{TideService, TideServiceError}; use ebb_db::{ + db::{activity_state_repo::ActivityStateRepo, models::tide::Tide}, db_manager::{self, DbManager}, - db::{ - activity_state_repo::ActivityStateRepo, - models::tide::Tide, - }, }; -use crate::tide_service::{TideService, TideServiceError}; use std::collections::HashMap; use std::sync::Arc; -use time::OffsetDateTime; use thiserror::Error; +use time::OffsetDateTime; use tokio::sync::Mutex; #[derive(Error, Debug)] @@ -49,9 +46,10 @@ pub struct TideProgress { impl TideProgress { /// Create a new TideProgress instance pub async fn new() -> Result { - let codeclimbers_db = db_manager::DbManager::get_shared_codeclimbers().await + 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())), @@ -67,55 +65,87 @@ impl TideProgress { } /// 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) -> Result { + 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 { - // Cache hit - calculate incremental progress - println!("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)); + if !skip_cache { + // Cache hit - calculate incremental progress + let time_diff = evaluation_time - cached.last_evaluation_time; + println!( + "Cache check: tide_id={}, cached_time={:?}, eval_time={:?}, diff={:?} ({} seconds)", + tide_id, + cached.last_evaluation_time, + evaluation_time, + time_diff, + time_diff.whole_seconds() + ); + println!( + "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); } - - return Ok(new_total); } - + // Cache miss - calculate full range from tide start - println!("Cache miss for tide: calculating full range for tide with start and end times: {:?}, {:?}", tide.start, tide.end); + println!( + "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)); + 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 { + 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 @@ -139,8 +169,19 @@ impl TideProgress { } /// 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?; + pub async fn should_complete_tide( + &self, + tide: &Tide, + evaluation_time: OffsetDateTime, + ) -> Result { + let current_progress = self + .get_tide_progress_cached(tide, evaluation_time, true) + .await?; + + // if tide is completed, return true + if tide.is_completed() { + return Ok(false); + } // If progress meets or exceeds goal, force refresh validation to ensure accuracy if current_progress >= tide.goal_amount { @@ -152,11 +193,20 @@ impl TideProgress { } /// 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?; + 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?; + tide_service + .update_tide_progress(&tide.id, current_progress) + .await?; // Update the local tide object tide.actual_amount = current_progress; @@ -177,10 +227,10 @@ mod tests { 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(()) } @@ -189,10 +239,10 @@ mod tests { 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(()) } @@ -200,7 +250,7 @@ mod tests { 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(()) } @@ -214,7 +264,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -259,7 +309,7 @@ mod tests { // 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)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -270,7 +320,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('2', ?1, ?2, ?3)" + VALUES ('2', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -294,10 +344,16 @@ mod tests { // 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?; - + 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); + assert!( + (progress - 120.0).abs() < 0.01, + "Expected ~120 minutes, got {}", + progress + ); Ok(()) } @@ -312,7 +368,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -354,7 +410,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -365,7 +421,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('2', ?1, ?2, ?3)" + VALUES ('2', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -388,13 +444,25 @@ mod tests { // Test evaluation at 09:30 - should only capture half of first activity (30 minutes) let partial_evaluation = datetime!(2025-01-06 09:30 UTC); - let progress = tide_progress.calculate_tide_progress(&tide, partial_evaluation).await?; - assert!((progress - 30.0).abs() < 0.01, "Expected ~30 minutes, got {}", progress); + let progress = tide_progress + .calculate_tide_progress(&tide, partial_evaluation) + .await?; + assert!( + (progress - 30.0).abs() < 0.01, + "Expected ~30 minutes, got {}", + progress + ); // Test evaluation at 10:30 - should capture first activity + half of second (90 minutes) let mid_evaluation = datetime!(2025-01-06 10:30 UTC); - let progress = tide_progress.calculate_tide_progress(&tide, mid_evaluation).await?; - assert!((progress - 90.0).abs() < 0.01, "Expected ~90 minutes, got {}", progress); + let progress = tide_progress + .calculate_tide_progress(&tide, mid_evaluation) + .await?; + assert!( + (progress - 90.0).abs() < 0.01, + "Expected ~90 minutes, got {}", + progress + ); Ok(()) } @@ -409,7 +477,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -425,7 +493,7 @@ mod tests { // 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)" @@ -439,7 +507,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -461,10 +529,16 @@ mod tests { ); let evaluation_time = datetime!(2025-01-06 12:00 UTC); - let progress = tide_progress.get_tide_progress_cached(&tide, evaluation_time).await?; - + 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); + assert!( + (progress - 60.0).abs() < 0.01, + "Expected ~60 minutes, got {}", + progress + ); Ok(()) } @@ -479,7 +553,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -506,7 +580,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -529,8 +603,14 @@ mod tests { // 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).await?; - assert!((progress1 - 60.0).abs() < 0.01, "Expected ~60 minutes, got {}", progress1); + 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( @@ -546,7 +626,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('2', ?1, ?2, ?3)" + VALUES ('2', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -557,8 +637,14 @@ mod tests { // 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).await?; - assert!((progress2 - 120.0).abs() < 0.01, "Expected ~120 minutes, got {}", progress2); + 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(()) } @@ -573,7 +659,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -599,7 +685,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -621,7 +707,9 @@ mod tests { // 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).await?; + 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 @@ -640,7 +728,9 @@ mod tests { } // Next call should recalculate (cache miss) - let progress2 = tide_progress.get_tide_progress_cached(&tide, evaluation_time).await?; + let progress2 = tide_progress + .get_tide_progress_cached(&tide, evaluation_time, false) + .await?; assert!((progress2 - 60.0).abs() < 0.01); Ok(()) @@ -667,7 +757,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -693,7 +783,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -726,10 +816,14 @@ mod tests { ); 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).await?; - let _progress2 = tide_progress.get_tide_progress_cached(&tide2, evaluation_time).await?; + 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 { @@ -760,7 +854,7 @@ mod tests { // 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; @@ -792,12 +886,14 @@ mod tests { 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)) + 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?; + let should_complete = tide_progress + .should_complete_tide(&tide, evaluation_time) + .await?; // Should return false because cached progress (60) < goal (120) assert!(!should_complete); @@ -815,7 +911,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -831,7 +927,7 @@ mod tests { // 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)" + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", ) .bind(datetime!(2025-01-06 09:00 UTC)) .bind(datetime!(2025-01-06 10:00 UTC)) @@ -842,7 +938,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -867,12 +963,14 @@ mod tests { 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)) + 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?; + 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 @@ -891,7 +989,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -907,7 +1005,7 @@ mod tests { // 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)" + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", ) .bind(datetime!(2025-01-06 09:00 UTC)) .bind(datetime!(2025-01-06 11:30 UTC)) @@ -918,7 +1016,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -943,12 +1041,14 @@ mod tests { 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)) + 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?; + 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); @@ -966,7 +1066,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -982,7 +1082,7 @@ mod tests { // 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)" + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", ) .bind(datetime!(2025-01-06 09:00 UTC)) .bind(datetime!(2025-01-06 10:30 UTC)) @@ -993,7 +1093,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -1040,14 +1140,27 @@ mod tests { 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?; + 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); + 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"); + 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(()) } @@ -1096,19 +1209,279 @@ mod tests { 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)) + 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?; + 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); + 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); + assert!( + (tide.actual_amount - 75.0).abs() < 0.01, + "Tide actual_amount should be ~75, got {}", + tide.actual_amount + ); Ok(()) } -} \ No newline at end of file + + #[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(()) + } +} From dc431095f4c2a2524488f18009b8386e6acc3464 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 11:14:51 -0600 Subject: [PATCH 22/40] add tests for get tide prgress cached --- .../src/ebb_tide_manager/src/tide_progress.rs | 408 +++++++++++++++++- 1 file changed, 399 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs index 105c12a0..24f08e45 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -1276,7 +1276,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -1292,7 +1292,7 @@ mod tests { // 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)" + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", ) .bind(datetime!(2025-01-06 09:00 UTC)) .bind(datetime!(2025-01-06 11:30 UTC)) @@ -1303,7 +1303,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -1348,7 +1348,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -1364,7 +1364,7 @@ mod tests { // 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)" + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", ) .bind(datetime!(2025-01-06 09:00 UTC)) .bind(datetime!(2025-01-06 10:00 UTC)) @@ -1375,7 +1375,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -1420,7 +1420,7 @@ mod tests { 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)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", ) .bind(tag_id) .bind("creating") @@ -1436,7 +1436,7 @@ mod tests { // 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)" + VALUES (1, 'ACTIVE', 0, ?1, ?2, ?3)", ) .bind(datetime!(2025-01-06 09:00 UTC)) .bind(datetime!(2025-01-06 12:00 UTC)) @@ -1447,7 +1447,7 @@ mod tests { sqlx::query( "INSERT INTO activity_state_tag (activity_state_id, tag_id, created_at, updated_at) - VALUES ('1', ?1, ?2, ?3)" + VALUES ('1', ?1, ?2, ?3)", ) .bind(tag_id) .bind(now) @@ -1484,4 +1484,394 @@ mod tests { 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:30 to 10:00 is 30 minutes + assert!( + (progress_cached - 130.0).abs() < 0.01, + "Expected ~130 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(()) + } } From 7bd500e6457ccd14d9f15cc54564c42777c8c37b Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 15:10:24 -0600 Subject: [PATCH 23/40] fix timing problem with counting activity state time --- .../src/ebb_db/src/db/activity_state_repo.rs | 159 +----------------- .../src/ebb_tide_manager/src/tide_progress.rs | 124 ++++++++++++-- 2 files changed, 117 insertions(+), 166 deletions(-) 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 index 3d9e9705..9e267b9c 100644 --- a/src-tauri/src/ebb_db/src/db/activity_state_repo.rs +++ b/src-tauri/src/ebb_db/src/db/activity_state_repo.rs @@ -86,7 +86,7 @@ impl ActivityStateRepo { .fetch_optional(&self.pool) .await?; let tag_type_query_duration = query_start.elapsed(); - println!("Tag type query took: {:?}", tag_type_query_duration); + log::debug!("Tag type query took: {:?}", tag_type_query_duration); let tag_type = match tag_type { Some(t) => t, @@ -123,7 +123,7 @@ impl ActivityStateRepo { .fetch_all(&self.pool) .await?; let main_query_duration = main_query_start.elapsed(); - println!( + log::debug!( "Main data query took: {:?} and returned {} records", main_query_duration, raw_data.len() @@ -142,7 +142,7 @@ impl ActivityStateRepo { }) .collect(); let data_conversion_duration = processing_start.elapsed(); - println!("Data conversion took: {:?}", data_conversion_duration); + log::debug!("Data conversion took: {:?}", data_conversion_duration); // Group by activity_state_id and calculate tag counts per tag_type use std::collections::HashMap; @@ -167,11 +167,7 @@ impl ActivityStateRepo { } } - // print out activity_states - // println!("Activity states: {:?}", activity_states); - // print out target_tag_records - // println!("Target tag records: {:?}", target_tag_records); // Calculate the total duration let mut total_minutes = 0.0; @@ -182,28 +178,9 @@ impl ActivityStateRepo { if let Some((activity_start, activity_end, tag_counts)) = activity_states.get(&activity_state_id) { - // Calculate the overlapping duration for this activity state - let effective_start = if *activity_start < start_time { - start_time - } else { - *activity_start - }; - let effective_end = if *activity_end > end_time { - end_time - } else { - *activity_end - }; - - // Skip if there's no actual overlap (effective_end <= effective_start) - if effective_end <= effective_start { - println!( - "Skipping activity state {} - no overlap: effective range {} to {}", - activity_state_id, effective_start, effective_end - ); - continue; - } - - let duration_minutes = (effective_end - effective_start).whole_minutes() as f64; + // 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); @@ -213,8 +190,6 @@ impl ActivityStateRepo { let split_minutes = duration_minutes / (*tag_count as f64); total_minutes += split_minutes; - // println!("State {} record: {:.2} minutes ÷ {} tags = {:.2} minutes", - // activity_state_id, duration_minutes, tag_count, split_minutes); } } } @@ -765,128 +740,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_calculate_tagged_duration_time_range_clipping() -> Result<()> { - let repo = setup_test_repo().await?; - - // Test that activity states are properly clipped to the queried time range - // Only the overlapping portion should contribute to the total - - // Create custom test data for clipping scenarios - let pool = &repo.pool; - - // Create test activity states with specific times for clipping tests - // State A: 10:00-11:00 (1 hour, "creating" tag) - // State B: 10:30-11:30 (1 hour, "creating" + "neutral" tags) - let state_a_start = datetime!(2025-01-05 10:00:00 UTC); - let state_a_end = datetime!(2025-01-05 11:00:00 UTC); - let state_b_start = datetime!(2025-01-05 10:30:00 UTC); - let state_b_end = datetime!(2025-01-05 11:30:00 UTC); - - // Insert State A (1 tag: creating) - sqlx::query( - "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) - VALUES (999100, 'ACTIVE', 1, ?1, ?2, ?3)" - ) - .bind(state_a_start) - .bind(state_a_end) - .bind(state_a_start) - .execute(pool) - .await?; - - sqlx::query( - "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) - VALUES (999100, 'creating-tag-id', NULL, datetime('now'), datetime('now'))" - ) - .execute(pool) - .await?; - - // Insert State B (2 tags: creating + neutral) - sqlx::query( - "INSERT INTO activity_state (id, state, activity_type, start_time, end_time, created_at) - VALUES (999101, 'ACTIVE', 1, ?1, ?2, ?3)" - ) - .bind(state_b_start) - .bind(state_b_end) - .bind(state_b_start) - .execute(pool) - .await?; - - sqlx::query( - "INSERT INTO activity_state_tag (activity_state_id, tag_id, app_tag_id, created_at, updated_at) - VALUES (999101, 'creating-tag-id', NULL, datetime('now'), datetime('now')), - (999101, 'neutral-tag-id', NULL, datetime('now'), datetime('now'))" - ) - .execute(pool) - .await?; - - // Scenario 1: Query starts before State A and ends during State A - // Query 9:30-10:30 should overlap with State A for 10:00-10:30 = 30 minutes - let clip_start_result = repo - .calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 9:30:00 UTC), - datetime!(2025-01-05 10:30:00 UTC), - ) - .await?; - assert_eq!( - clip_start_result, 30.0, - "Query 9:30-10:30 should clip State A to get 30 minutes for creating" - ); - - // Scenario 2: Query starts during State A and ends after State A - // Query 10:45-11:15 should overlap with: - // - State A: 10:45-11:00 = 15 minutes (1 tag, gets full 15 minutes) - // - State B: 10:45-11:15 = 30 minutes (2 tags, gets 30/2 = 15 minutes) - // Expected: 15 + 15 = 30 minutes - let clip_end_result = repo - .calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 10:45:00 UTC), - datetime!(2025-01-05 11:15:00 UTC), - ) - .await?; - assert_eq!( - clip_end_result, 30.0, - "Query 10:45-11:15 should clip states to get 30 minutes for creating" - ); - - // Scenario 3: Query fully contains State A - // Query 9:30-11:30 should overlap with: - // - State A: 10:00-11:00 = 60 minutes (1 tag, gets full 60 minutes) - // - State B: 10:30-11:30 = 60 minutes (2 tags, gets 60/2 = 30 minutes) - // Expected: 60 + 30 = 90 minutes - let fully_contains_result = repo - .calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 9:30:00 UTC), - datetime!(2025-01-05 11:30:00 UTC), - ) - .await?; - assert_eq!( - fully_contains_result, 90.0, - "Query 9:30-11:30 should fully contain states and get 90 minutes for creating" - ); - - // Scenario 4: Query range overlaps with both states - // Query 10:15-10:45 should overlap with: - // - State A: 10:15-10:45 = 30 minutes (1 tag, gets full 30 minutes) - // - State B: 10:30-10:45 = 15 minutes (2 tags, gets 15/2 = 7.5 minutes, rounded to 8) - // Expected: 30 + 8 = 38 minutes - let overlapping_states_result = repo - .calculate_tagged_duration_in_range( - "creating", - datetime!(2025-01-05 10:15:00 UTC), - datetime!(2025-01-05 10:45:00 UTC), - ) - .await?; - assert_eq!( - overlapping_states_result, 37.5, - "Query 10:15-10:45 overlapping both states should get 37.5 minutes for creating" - ); - - Ok(()) - } #[tokio::test] async fn test_calculate_tagged_duration_empty_result() -> Result<()> { diff --git a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs index 24f08e45..6ebe68c3 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -174,15 +174,15 @@ impl TideProgress { tide: &Tide, evaluation_time: OffsetDateTime, ) -> Result { - let current_progress = self - .get_tide_progress_cached(tide, evaluation_time, true) - .await?; - // if tide is completed, return true if tide.is_completed() { return Ok(false); } + let current_progress = self + .get_tide_progress_cached(tide, evaluation_time, true) + .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?; @@ -442,25 +442,25 @@ mod tests { tide_start, ); - // Test evaluation at 09:30 - should only capture half of first activity (30 minutes) + // 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 - 30.0).abs() < 0.01, - "Expected ~30 minutes, got {}", + (progress - 60.0).abs() < 0.01, + "Expected ~60 minutes, got {}", progress ); - // Test evaluation at 10:30 - should capture first activity + half of second (90 minutes) + // 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 - 90.0).abs() < 0.01, - "Expected ~90 minutes, got {}", + (progress - 120.0).abs() < 0.01, + "Expected ~120 minutes, got {}", progress ); @@ -1559,10 +1559,10 @@ mod tests { .await?; // Should use cached value (100.0) plus any incremental progress since 09:30 - // activity from 09:30 to 10:00 is 30 minutes + // activity from 09:00 to 10:00 is 60 minutes assert!( - (progress_cached - 130.0).abs() < 0.01, - "Expected ~130 minutes from cache, got {}", + (progress_cached - 160.0).abs() < 0.01, + "Expected ~160 minutes from cache, got {}", progress_cached ); @@ -1874,4 +1874,102 @@ mod tests { 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(()) + } } From 7f4506fc9b12e3a53303eb46db1bea08d9ef07a9 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 15:28:17 -0600 Subject: [PATCH 24/40] show actual tide progress data --- src/api/ebbApi/tideApi.ts | 281 +++++++++++++++++++++++++++++++ src/api/hooks/useTides.ts | 204 ++++++++++++++++++++++ src/components/TideGoalsCard.tsx | 59 ++++--- src/db/ebb/tideRepo.ts | 222 ++++++++++++++++++++++++ 4 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 src/api/ebbApi/tideApi.ts create mode 100644 src/api/hooks/useTides.ts create mode 100644 src/db/ebb/tideRepo.ts diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts new file mode 100644 index 00000000..beed1a42 --- /dev/null +++ b/src/api/ebbApi/tideApi.ts @@ -0,0 +1,281 @@ +import { QueryResult } from '@tauri-apps/plugin-sql' +import { + Tide, + TideTemplate, + TideRepo, + TideSchema, + TideTemplateSchema, + TideWithTemplate +} from '@/db/ebb/tideRepo' + +export type { Tide, TideTemplate, TideWithTemplate } + +export interface TideProgress { + current: number + goal: number + isCompleted: boolean + progressPercentage: number + overflowAmount?: number +} + +export interface DailyTideData { + tide?: Tide + progress: TideProgress +} + +export interface WeeklyTideData { + tides: Tide[] + progress: TideProgress +} + +export interface TideOverview { + daily: DailyTideData + weekly: WeeklyTideData +} + +// Tide Template API Functions + +const createTideTemplate = async ( + metricsType: string, + tideFrequency: string, + goalAmount: number, + firstTide: string, + dayOfWeek?: string +): Promise => { + const template: TideTemplateSchema = { + id: self.crypto.randomUUID(), + metrics_type: metricsType, + tide_frequency: tideFrequency, + first_tide: firstTide, + day_of_week: dayOfWeek, + goal_amount: goalAmount, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + + await TideRepo.createTideTemplate(template) + return template.id +} + +const updateTideTemplate = async ( + id: string, + updates: Partial +): Promise => { + const updatedTemplate = { + ...updates, + updated_at: new Date().toISOString(), + } + return TideRepo.updateTideTemplate(id, updatedTemplate) +} + +const getTideTemplates = async (): Promise => { + return TideRepo.getAllTideTemplates() +} + +const getTideTemplateById = async (id: string): Promise => { + return TideRepo.getTideTemplateById(id) +} + +// Tide API Functions + +const createTide = async ( + start: string, + end: string | undefined, + metricsType: string, + tideFrequency: string, + goalAmount: number, + tideTemplateId: string +): Promise => { + const tide: TideSchema = { + id: self.crypto.randomUUID(), + start, + end, + metrics_type: metricsType, + tide_frequency: tideFrequency, + goal_amount: goalAmount, + actual_amount: 0, + tide_template_id: tideTemplateId, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + + await TideRepo.createTide(tide) + return tide.id +} + +const updateTide = async ( + id: string, + updates: Partial +): Promise => { + const updatedTide = { + ...updates, + updated_at: new Date().toISOString(), + } + return TideRepo.updateTide(id, updatedTide) +} + +const getTideById = async (id: string): Promise => { + return TideRepo.getTideById(id) +} + +const getActiveTides = async (): Promise => { + return TideRepo.getActiveTides() +} + +const completeTide = async (id: string): Promise => { + return TideRepo.completeTide(id) +} + +const updateTideProgress = async (id: string, actualAmount: number): Promise => { + return TideRepo.updateTideProgress(id, actualAmount) +} + +// Business Logic Functions + +const getCurrentDailyTide = async (metricsType = 'creating'): Promise => { + const today = new Date() + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1) + + const evaluationTime = new Date().toISOString() + const activeTides = await TideRepo.getActiveTidesForPeriod(evaluationTime) + + // Find today's daily tide for the specific metrics type + const dailyTide = activeTides.find(tide => + tide.tide_frequency === 'daily' && + tide.metrics_type === metricsType && + new Date(tide.start) >= startOfDay && + new Date(tide.start) < endOfDay + ) + + const progress: TideProgress = dailyTide ? { + current: dailyTide.actual_amount, + goal: dailyTide.goal_amount, + isCompleted: !!dailyTide.completed_at, + progressPercentage: Math.min((dailyTide.actual_amount / dailyTide.goal_amount) * 100, 100), + overflowAmount: dailyTide.actual_amount > dailyTide.goal_amount + ? dailyTide.actual_amount - dailyTide.goal_amount + : undefined + } : { + current: 0, + goal: 0, + isCompleted: false, + progressPercentage: 0 + } + + return { + tide: dailyTide, + progress + } +} + +const getCurrentWeeklyTide = async (metricsType = 'creating'): Promise => { + const today = new Date() + const startOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay()) + const endOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay() + 7) + + const evaluationTime = new Date().toISOString() + const activeTides = await TideRepo.getActiveTidesForPeriod(evaluationTime) + + // Find this week's weekly tide for the specific metrics type + const weeklyTides = activeTides.filter(tide => + tide.tide_frequency === 'weekly' && + tide.metrics_type === metricsType && + new Date(tide.start) >= startOfWeek && + new Date(tide.start) < endOfWeek + ) + + // Calculate combined progress for weekly tides + const totalCurrent = weeklyTides.reduce((sum, tide) => sum + tide.actual_amount, 0) + const totalGoal = weeklyTides.reduce((sum, tide) => sum + tide.goal_amount, 0) + const allCompleted = weeklyTides.length > 0 && weeklyTides.every(tide => tide.completed_at) + + const progress: TideProgress = { + current: totalCurrent, + goal: totalGoal, + isCompleted: allCompleted, + progressPercentage: totalGoal > 0 ? Math.min((totalCurrent / totalGoal) * 100, 100) : 0, + overflowAmount: totalCurrent > totalGoal ? totalCurrent - totalGoal : undefined + } + + return { + tides: weeklyTides, + progress + } +} + +const getTideOverview = async (metricsType = 'creating'): Promise => { + const [daily, weekly] = await Promise.all([ + getCurrentDailyTide(metricsType), + getCurrentWeeklyTide(metricsType) + ]) + + return { + daily, + weekly + } +} + +const getRecentTides = async (limit = 10): Promise => { + return TideRepo.getRecentTides(limit) +} + +const getTidesWithTemplates = async (limit = 10): Promise => { + return TideRepo.getTidesWithTemplates(limit) +} + +// Helper Functions + +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 + createTideTemplate, + updateTideTemplate, + getTideTemplates, + getTideTemplateById, + + // Tide operations + createTide, + updateTide, + getTideById, + getActiveTides, + completeTide, + updateTideProgress, + + // Business logic + getCurrentDailyTide, + getCurrentWeeklyTide, + getTideOverview, + getRecentTides, + getTidesWithTemplates, + + // Utilities + formatTime, + calculateDaysBetween, + isWithinTimeRange, +} diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts new file mode 100644 index 00000000..fd0d9552 --- /dev/null +++ b/src/api/hooks/useTides.ts @@ -0,0 +1,204 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { TideApi, Tide, TideTemplate } from '../ebbApi/tideApi' + +const tideKeys = { + all: ['tides'] as const, + overview: (metricsType?: string) => [...tideKeys.all, 'overview', metricsType] as const, + daily: (metricsType?: string) => [...tideKeys.all, 'daily', metricsType] as const, + weekly: (metricsType?: string) => [...tideKeys.all, 'weekly', metricsType] 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, +} + +// Query Hooks + +export const useGetTideOverview = (metricsType = 'creating') => { + return useQuery({ + queryKey: tideKeys.overview(metricsType), + queryFn: () => TideApi.getTideOverview(metricsType), + staleTime: 30000, // 30 seconds - tides update frequently + refetchInterval: 60000, // Refetch every minute for real-time updates + }) +} + +export const useGetCurrentDailyTide = (metricsType = 'creating') => { + return useQuery({ + queryKey: tideKeys.daily(metricsType), + queryFn: () => TideApi.getCurrentDailyTide(metricsType), + staleTime: 30000, + refetchInterval: 60000, + }) +} + +export const useGetCurrentWeeklyTide = (metricsType = 'creating') => { + return useQuery({ + queryKey: tideKeys.weekly(metricsType), + queryFn: () => TideApi.getCurrentWeeklyTide(metricsType), + staleTime: 30000, + refetchInterval: 60000, + }) +} + +export const useGetRecentTides = (limit = 10) => { + return useQuery({ + queryKey: tideKeys.recent(limit), + queryFn: () => TideApi.getRecentTides(limit), + staleTime: 60000, // 1 minute + }) +} + +export const useGetActiveTides = () => { + return useQuery({ + queryKey: tideKeys.active(), + queryFn: () => TideApi.getActiveTides(), + staleTime: 30000, + refetchInterval: 60000, + }) +} + +export const useGetTideTemplates = () => { + return useQuery({ + queryKey: tideKeys.templates(), + queryFn: () => TideApi.getTideTemplates(), + staleTime: 300000, // 5 minutes - templates change less frequently + }) +} + +export const useGetTideById = (id: string) => { + return useQuery({ + queryKey: tideKeys.detail(id), + queryFn: () => TideApi.getTideById(id), + enabled: !!id, + staleTime: 60000, + }) +} + +// Mutation Hooks + +export const useCreateTideTemplate = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + metricsType, + tideFrequency, + goalAmount, + firstTide, + dayOfWeek, + }: { + metricsType: string + tideFrequency: string + goalAmount: number + firstTide: string + dayOfWeek?: string + }) => TideApi.createTideTemplate(metricsType, tideFrequency, goalAmount, firstTide, dayOfWeek), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: tideKeys.templates() }) + }, + }) +} + +export const useUpdateTideTemplate = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, updates }: { id: string; updates: Partial }) => + TideApi.updateTideTemplate(id, updates), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: tideKeys.templates() }) + queryClient.invalidateQueries({ queryKey: tideKeys.detail(id) }) + }, + }) +} + +export const useCreateTide = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + start, + end, + metricsType, + tideFrequency, + goalAmount, + tideTemplateId, + }: { + start: string + end?: string + metricsType: string + tideFrequency: string + goalAmount: number + tideTemplateId: string + }) => TideApi.createTide(start, end, metricsType, tideFrequency, goalAmount, tideTemplateId), + onSuccess: (_, { metricsType }) => { + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: tideKeys.overview(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.daily(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.weekly(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.active() }) + queryClient.invalidateQueries({ queryKey: tideKeys.recent() }) + }, + }) +} + +export const useUpdateTide = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, updates }: { id: string; updates: Partial }) => + TideApi.updateTide(id, updates), + onSuccess: (_, { id, updates }) => { + const metricsType = updates.metrics_type + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: tideKeys.detail(id) }) + if (metricsType) { + queryClient.invalidateQueries({ queryKey: tideKeys.overview(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.daily(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.weekly(metricsType) }) + } + queryClient.invalidateQueries({ queryKey: tideKeys.active() }) + queryClient.invalidateQueries({ queryKey: tideKeys.recent() }) + }, + }) +} + +export const useCompleteTide = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: string) => TideApi.completeTide(id), + onSuccess: () => { + // Invalidate all tide-related queries since completion affects overview + queryClient.invalidateQueries({ queryKey: tideKeys.all }) + }, + }) +} + +export const useUpdateTideProgress = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, actualAmount }: { id: string; actualAmount: number }) => + TideApi.updateTideProgress(id, actualAmount), + onSuccess: () => { + // Invalidate all tide-related queries for real-time updates + queryClient.invalidateQueries({ queryKey: tideKeys.all }) + }, + }) +} + +// Utility hook for formatting time consistently +export const useFormatTime = () => { + return (minutes: number) => TideApi.formatTime(minutes) +} + +// Hook for refetching tide data manually (useful for pull-to-refresh) +export const useRefreshTides = () => { + const queryClient = useQueryClient() + + return () => { + queryClient.invalidateQueries({ queryKey: tideKeys.all }) + } +} diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index b60d0166..348dba3f 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -1,28 +1,26 @@ import { type FC, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { PieChart, Pie, Cell } from 'recharts' +import { useGetCurrentDailyTide, useGetCurrentWeeklyTide } from '@/api/hooks/useTides' +import { Skeleton } from '@/components/ui/skeleton' interface TideGoalsCardProps { className?: string + metricsType?: string } -// Mock tide data - replace with actual API calls later -const getMockTideData = () => { - return { - dailyGoal: { - current: 194, // 3h 14m current progress - goal: 180, // 3h goal (will show as complete + stretch progress) - }, - weeklyGoal: { - current: 780, // 7h current progress - goal: 600, // 10h goal - } - } -} - -export const TideGoalsCard: FC = ({ className = '' }) => { +export const TideGoalsCard: FC = ({ + className = '', + metricsType = 'creating' +}) => { const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') - const tideData = getMockTideData() + + // Fetch real tide data + const { data: dailyTideData, isLoading: isDailyLoading, error: dailyError } = useGetCurrentDailyTide(metricsType) + const { data: weeklyTideData, isLoading: isWeeklyLoading, error: weeklyError } = useGetCurrentWeeklyTide(metricsType) + + const isLoading = isDailyLoading || isWeeklyLoading + const hasError = dailyError || weeklyError // Format time helper const formatTime = (minutes: number, options: { overrideShowHours: boolean } = { overrideShowHours: false }) => { @@ -262,15 +260,30 @@ export const TideGoalsCard: FC = ({ className = '' }) => { {/* Content */} - {activeTab === 'daily' && ( -
- {renderGoalProgress(tideData.dailyGoal.current, tideData.dailyGoal.goal)} + {isLoading ? ( +
+ +
- )} - {activeTab === 'weekly' && ( -
- {renderGoalProgress(tideData.weeklyGoal.current, tideData.weeklyGoal.goal)} + ) : hasError ? ( +
+
+ Unable to load tide data +
+ ) : ( + <> + {activeTab === 'daily' && dailyTideData && ( +
+ {renderGoalProgress(dailyTideData.progress.current, dailyTideData.progress.goal)} +
+ )} + {activeTab === 'weekly' && weeklyTideData && ( +
+ {renderGoalProgress(weeklyTideData.progress.current, weeklyTideData.progress.goal)} +
+ )} + )} diff --git a/src/db/ebb/tideRepo.ts b/src/db/ebb/tideRepo.ts new file mode 100644 index 00000000..c8a03f7d --- /dev/null +++ b/src/db/ebb/tideRepo.ts @@ -0,0 +1,222 @@ +import { QueryResult } from '@tauri-apps/plugin-sql' +import { insert, 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 createTide = async (tide: TideSchema): Promise => { + const ebbDb = await getEbbDb() + return insert(ebbDb, 'tide', tide) +} + +const updateTide = async ( + id: string, + tide: Partial, +): Promise => { + const ebbDb = await getEbbDb() + return update(ebbDb, 'tide', tide, id) +} + +const getTideById = async (id: string): Promise => { + const ebbDb = await getEbbDb() + const [tide] = await ebbDb.select( + 'SELECT * FROM tide WHERE id = ?', + [id] + ) + return tide +} + +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() + + // Get tides that overlap with the evaluation time + const query = ` + SELECT * FROM tide + WHERE start <= ? + AND (end IS NULL OR end > ?) + ORDER BY start DESC + ` + + return await ebbDb.select(query, [evaluationTime, evaluationTime]) +} + +const getTidesByFrequency = async (frequency: string): Promise => { + const ebbDb = await getEbbDb() + + const query = ` + SELECT * FROM tide + WHERE tide_frequency = ? + ORDER BY start DESC + ` + + return await ebbDb.select(query, [frequency]) +} + +const getRecentTides = async (limit = 10): Promise => { + const ebbDb = await getEbbDb() + + const query = ` + SELECT * FROM tide + ORDER BY start DESC + LIMIT ? + ` + + return await ebbDb.select(query, [limit]) +} + +const completeTide = async (id: string): Promise => { + const ebbDb = await getEbbDb() + const completedAt = new Date().toISOString() + const updatedAt = new Date().toISOString() + + return update(ebbDb, 'tide', { + completed_at: completedAt, + updated_at: updatedAt + }, id) +} + +const updateTideProgress = async (id: string, actualAmount: number): Promise => { + const ebbDb = await getEbbDb() + const updatedAt = new Date().toISOString() + + return update(ebbDb, 'tide', { + actual_amount: actualAmount, + updated_at: updatedAt + }, id) +} + +// Tide Template Repository Functions + +const createTideTemplate = async (template: TideTemplateSchema): Promise => { + const ebbDb = await getEbbDb() + return insert(ebbDb, 'tide_template', template) +} + +const updateTideTemplate = async ( + id: string, + template: Partial, +): Promise => { + const ebbDb = await getEbbDb() + return update(ebbDb, 'tide_template', template, id) +} + +const getTideTemplateById = async (id: string): Promise => { + const ebbDb = await getEbbDb() + const [template] = await ebbDb.select( + 'SELECT * FROM tide_template WHERE id = ?', + [id] + ) + return template +} + +const getAllTideTemplates = async (): Promise => { + const ebbDb = await getEbbDb() + return await ebbDb.select( + 'SELECT * FROM tide_template ORDER BY created_at DESC' + ) +} + +const getTideTemplatesByFrequency = async (frequency: string): Promise => { + const ebbDb = await getEbbDb() + return await ebbDb.select( + 'SELECT * FROM tide_template WHERE tide_frequency = ? ORDER BY created_at DESC', + [frequency] + ) +} + +// Combined queries + +const getTidesWithTemplates = async (limit = 10): Promise => { + const ebbDb = await getEbbDb() + + const query = ` + SELECT + t.*, + json_object( + 'id', tt.id, + 'metrics_type', tt.metrics_type, + 'tide_frequency', tt.tide_frequency, + 'first_tide', tt.first_tide, + 'day_of_week', tt.day_of_week, + 'goal_amount', tt.goal_amount, + 'created_at', tt.created_at, + 'updated_at', tt.updated_at + ) as template + FROM tide t + LEFT JOIN tide_template tt ON t.tide_template_id = tt.id + ORDER BY t.start DESC + LIMIT ? + ` + + return await ebbDb.select(query, [limit]) +} + +export const TideRepo = { + // Tide operations + createTide, + updateTide, + getTideById, + getActiveTides, + getActiveTidesForPeriod, + getTidesByFrequency, + getRecentTides, + completeTide, + updateTideProgress, + + // Tide template operations + createTideTemplate, + updateTideTemplate, + getTideTemplateById, + getAllTideTemplates, + getTideTemplatesByFrequency, + + // Combined operations + getTidesWithTemplates, +} From 219b393aacba021d40ca875019896bb00f7596ac Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 15:37:11 -0600 Subject: [PATCH 25/40] get total creating time on day off --- src/api/ebbApi/tideApi.ts | 31 ++++++++++++++++++++++++++- src/components/TideGoalsCard.tsx | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index beed1a42..5a26a59e 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -7,6 +7,8 @@ import { TideTemplateSchema, TideWithTemplate } from '@/db/ebb/tideRepo' +import { MonitorApi } from '@/api/monitorApi/monitorApi' +import { DateTime } from 'luxon' export type { Tide, TideTemplate, TideWithTemplate } @@ -148,6 +150,10 @@ const getCurrentDailyTide = async (metricsType = 'creating'): Promise => // Helper Functions +const getTodaysActivityTime = async (metricsType: string): Promise => { + try { + const today = DateTime.now() + const start = today.startOf('day') + const end = today.endOf('day') + + const chartData = await MonitorApi.getTimeCreatingByHour(start, end) + + if (metricsType === 'creating') { + return chartData.reduce((acc, curr) => acc + curr.creating, 0) + } else if (metricsType === 'neutral') { + return chartData.reduce((acc, curr) => acc + curr.neutral, 0) + } else if (metricsType === 'consuming') { + return chartData.reduce((acc, curr) => acc + curr.consuming, 0) + } + + return 0 + } catch (error) { + console.error(`Error getting today's activity time for ${metricsType}:`, error) + return 0 + } +} + const formatTime = (minutes: number): string => { const hours = Math.floor(minutes / 60) const remainingMinutes = Math.round(minutes % 60) diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 348dba3f..7885b701 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -31,7 +31,43 @@ export const TideGoalsCard: FC = ({ 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 From c487d2deb9920d6e34dd11600952d4b213b91db1 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 15:42:39 -0600 Subject: [PATCH 26/40] make interval check every 30 seconds --- src-tauri/src/ebb_tide_manager/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/ebb_tide_manager/src/lib.rs b/src-tauri/src/ebb_tide_manager/src/lib.rs index 1855554d..2a9409e4 100644 --- a/src-tauri/src/ebb_tide_manager/src/lib.rs +++ b/src-tauri/src/ebb_tide_manager/src/lib.rs @@ -39,7 +39,7 @@ pub struct TideManager { impl TideManager { /// Create a new TideManager with default configuration (60 second intervals) pub async fn new() -> Result { - Self::new_with_interval(120).await + Self::new_with_interval(30).await } /// Create a new TideManager with custom interval From 904ec71255c66bfb107b9e693d4e145326a3f18a Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 15:49:48 -0600 Subject: [PATCH 27/40] refetch goal time automatically --- src/api/hooks/useTides.ts | 6 ++++-- src/components/TideGoalsCard.tsx | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts index fd0d9552..17d0f328 100644 --- a/src/api/hooks/useTides.ts +++ b/src/api/hooks/useTides.ts @@ -28,7 +28,8 @@ export const useGetCurrentDailyTide = (metricsType = 'creating') => { queryKey: tideKeys.daily(metricsType), queryFn: () => TideApi.getCurrentDailyTide(metricsType), staleTime: 30000, - refetchInterval: 60000, + refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) + refetchOnWindowFocus: true, }) } @@ -37,7 +38,8 @@ export const useGetCurrentWeeklyTide = (metricsType = 'creating') => { queryKey: tideKeys.weekly(metricsType), queryFn: () => TideApi.getCurrentWeeklyTide(metricsType), staleTime: 30000, - refetchInterval: 60000, + refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) + refetchOnWindowFocus: true, }) } diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 7885b701..2fecca4f 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -15,7 +15,6 @@ export const TideGoalsCard: FC = ({ }) => { const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') - // Fetch real tide data const { data: dailyTideData, isLoading: isDailyLoading, error: dailyError } = useGetCurrentDailyTide(metricsType) const { data: weeklyTideData, isLoading: isWeeklyLoading, error: weeklyError } = useGetCurrentWeeklyTide(metricsType) From 09e1f9002fc503f1223466186987d5e6db2c8d90 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 27 Sep 2025 15:54:10 -0600 Subject: [PATCH 28/40] only show tides for canary users --- src/lib/utils/environment.util.ts | 4 +++ src/pages/HomePage.tsx | 49 ++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) 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 708a4399..c6af5e28 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -9,10 +9,12 @@ import { } from '@/components/ui/popover' 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() @@ -33,8 +35,14 @@ export const HomePage = () => { showIdleTime, setShowIdleTime, lastUpdated, + totalCreating, + totalTime, + totalTimeTooltip, + totalTimeLabel, } = useUsageSummary() + const isTideGoalsEnabled = isTideGoalsFeatureEnabled(user?.email) + return ( @@ -84,17 +92,36 @@ export const HomePage = () => {
- + {isTideGoalsEnabled ? ( + + ) : ( + + )}
From c86951005b7f2513307e7cf43dea1c877bc4d3d6 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 28 Sep 2025 13:58:43 -0600 Subject: [PATCH 29/40] add category time to kanband board --- src/components/ColumnWrapper.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 && ( From e1f9d81eac27d1fc8251b95b263bf0e1930f9411 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 28 Sep 2025 16:08:02 -0600 Subject: [PATCH 30/40] Make goals data change with updating the day in focus --- src/api/ebbApi/tideApi.ts | 138 ++++++------------- src/api/hooks/useTides.ts | 59 ++++---- src/components/TideEditDialog.tsx | 167 +++++++++++++++++++++++ src/components/TideGoalsCard.tsx | 26 ++-- src/components/UsageSummaryWithTides.tsx | 2 +- 5 files changed, 244 insertions(+), 148 deletions(-) create mode 100644 src/components/TideEditDialog.tsx diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index 5a26a59e..251627f6 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -7,7 +7,7 @@ import { TideTemplateSchema, TideWithTemplate } from '@/db/ebb/tideRepo' -import { MonitorApi } from '@/api/monitorApi/monitorApi' +import { GraphableTimeByHourBlock, MonitorApi } from '@/api/monitorApi/monitorApi' import { DateTime } from 'luxon' export type { Tide, TideTemplate, TideWithTemplate } @@ -20,19 +20,13 @@ export interface TideProgress { overflowAmount?: number } -export interface DailyTideData { +export interface TideProgressData { tide?: Tide progress: TideProgress } - -export interface WeeklyTideData { - tides: Tide[] - progress: TideProgress -} - export interface TideOverview { - daily: DailyTideData - weekly: WeeklyTideData + daily: TideProgressData + weekly: TideProgressData } // Tide Template API Functions @@ -134,86 +128,56 @@ const updateTideProgress = async (id: string, actualAmount: number): Promise => { - const today = new Date() - const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()) - const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1) +const getTideProgress = async (type = 'daily', date = new Date()): Promise => { - const evaluationTime = new Date().toISOString() + const evaluationTime = date.toISOString() const activeTides = await TideRepo.getActiveTidesForPeriod(evaluationTime) - // Find today's daily tide for the specific metrics type - const dailyTide = activeTides.find(tide => - tide.tide_frequency === 'daily' && - tide.metrics_type === metricsType && - new Date(tide.start) >= startOfDay && - new Date(tide.start) < endOfDay + const tide = activeTides.find(tide => + tide.tide_frequency === type ) - // If there's no tide, we still want to get today's activity time - // For now, we'll return 0 but this could be enhanced to query actual activity data - const actualTimeToday = dailyTide ? dailyTide.actual_amount : await getTodaysActivityTime(metricsType) - - const progress: TideProgress = dailyTide ? { - current: dailyTide.actual_amount, - goal: dailyTide.goal_amount, - isCompleted: !!dailyTide.completed_at, - progressPercentage: Math.min((dailyTide.actual_amount / dailyTide.goal_amount) * 100, 100), - overflowAmount: dailyTide.actual_amount > dailyTide.goal_amount - ? dailyTide.actual_amount - dailyTide.goal_amount - : undefined - } : { - current: actualTimeToday, - goal: 0, - isCompleted: false, - progressPercentage: 0 - } - return { - tide: dailyTide, - progress + 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 getCurrentWeeklyTide = async (metricsType = 'creating'): Promise => { - const today = new Date() - const startOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay()) - const endOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay() + 7) - - const evaluationTime = new Date().toISOString() - const activeTides = await TideRepo.getActiveTidesForPeriod(evaluationTime) - - // Find this week's weekly tide for the specific metrics type - const weeklyTides = activeTides.filter(tide => - tide.tide_frequency === 'weekly' && - tide.metrics_type === metricsType && - new Date(tide.start) >= startOfWeek && - new Date(tide.start) < endOfWeek - ) - - // Calculate combined progress for weekly tides - const totalCurrent = weeklyTides.reduce((sum, tide) => sum + tide.actual_amount, 0) - const totalGoal = weeklyTides.reduce((sum, tide) => sum + tide.goal_amount, 0) - const allCompleted = weeklyTides.length > 0 && weeklyTides.every(tide => tide.completed_at) const progress: TideProgress = { - current: totalCurrent, - goal: totalGoal, - isCompleted: allCompleted, - progressPercentage: totalGoal > 0 ? Math.min((totalCurrent / totalGoal) * 100, 100) : 0, - overflowAmount: totalCurrent > totalGoal ? totalCurrent - totalGoal : undefined + 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 { - tides: weeklyTides, + tide, progress } } -const getTideOverview = async (metricsType = 'creating'): Promise => { +const getTideOverview = async (date = new Date()): Promise => { const [daily, weekly] = await Promise.all([ - getCurrentDailyTide(metricsType), - getCurrentWeeklyTide(metricsType) + getTideProgress('daily', date), + getTideProgress('weekly', date) ]) return { @@ -230,31 +194,6 @@ const getTidesWithTemplates = async (limit = 10): Promise => return TideRepo.getTidesWithTemplates(limit) } -// Helper Functions - -const getTodaysActivityTime = async (metricsType: string): Promise => { - try { - const today = DateTime.now() - const start = today.startOf('day') - const end = today.endOf('day') - - const chartData = await MonitorApi.getTimeCreatingByHour(start, end) - - if (metricsType === 'creating') { - return chartData.reduce((acc, curr) => acc + curr.creating, 0) - } else if (metricsType === 'neutral') { - return chartData.reduce((acc, curr) => acc + curr.neutral, 0) - } else if (metricsType === 'consuming') { - return chartData.reduce((acc, curr) => acc + curr.consuming, 0) - } - - return 0 - } catch (error) { - console.error(`Error getting today's activity time for ${metricsType}:`, error) - return 0 - } -} - const formatTime = (minutes: number): string => { const hours = Math.floor(minutes / 60) const remainingMinutes = Math.round(minutes % 60) @@ -297,8 +236,7 @@ export const TideApi = { updateTideProgress, // Business logic - getCurrentDailyTide, - getCurrentWeeklyTide, + getTideProgress, getTideOverview, getRecentTides, getTidesWithTemplates, diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts index 17d0f328..b8a5b808 100644 --- a/src/api/hooks/useTides.ts +++ b/src/api/hooks/useTides.ts @@ -3,9 +3,7 @@ import { TideApi, Tide, TideTemplate } from '../ebbApi/tideApi' const tideKeys = { all: ['tides'] as const, - overview: (metricsType?: string) => [...tideKeys.all, 'overview', metricsType] as const, - daily: (metricsType?: string) => [...tideKeys.all, 'daily', metricsType] as const, - weekly: (metricsType?: string) => [...tideKeys.all, 'weekly', metricsType] 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, @@ -14,34 +12,35 @@ const tideKeys = { // Query Hooks -export const useGetTideOverview = (metricsType = 'creating') => { +export const useGetTideOverview = (date = new Date()) => { + console.log('useGetTideOverview', date) return useQuery({ - queryKey: tideKeys.overview(metricsType), - queryFn: () => TideApi.getTideOverview(metricsType), + queryKey: tideKeys.overview(date), + queryFn: () => TideApi.getTideOverview(date), staleTime: 30000, // 30 seconds - tides update frequently refetchInterval: 60000, // Refetch every minute for real-time updates }) } -export const useGetCurrentDailyTide = (metricsType = 'creating') => { - return useQuery({ - queryKey: tideKeys.daily(metricsType), - queryFn: () => TideApi.getCurrentDailyTide(metricsType), - staleTime: 30000, - refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) - refetchOnWindowFocus: true, - }) -} - -export const useGetCurrentWeeklyTide = (metricsType = 'creating') => { - return useQuery({ - queryKey: tideKeys.weekly(metricsType), - queryFn: () => TideApi.getCurrentWeeklyTide(metricsType), - staleTime: 30000, - refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) - refetchOnWindowFocus: true, - }) -} +// export const useGetCurrentDailyTide = (metricsType = 'creating') => { +// return useQuery({ +// queryKey: tideKeys.daily(metricsType), +// queryFn: () => TideApi.getCurrentDailyTide(metricsType), +// staleTime: 30000, +// refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) +// refetchOnWindowFocus: true, +// }) +// } + +// export const useGetCurrentWeeklyTide = (metricsType = 'creating') => { +// return useQuery({ +// queryKey: tideKeys.weekly(metricsType), +// queryFn: () => TideApi.getCurrentWeeklyTide(metricsType), +// staleTime: 30000, +// refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) +// refetchOnWindowFocus: true, +// }) +// } export const useGetRecentTides = (limit = 10) => { return useQuery({ @@ -134,11 +133,9 @@ export const useCreateTide = () => { goalAmount: number tideTemplateId: string }) => TideApi.createTide(start, end, metricsType, tideFrequency, goalAmount, tideTemplateId), - onSuccess: (_, { metricsType }) => { + onSuccess: () => { // Invalidate related queries - queryClient.invalidateQueries({ queryKey: tideKeys.overview(metricsType) }) - queryClient.invalidateQueries({ queryKey: tideKeys.daily(metricsType) }) - queryClient.invalidateQueries({ queryKey: tideKeys.weekly(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.overview() }) queryClient.invalidateQueries({ queryKey: tideKeys.active() }) queryClient.invalidateQueries({ queryKey: tideKeys.recent() }) }, @@ -156,9 +153,7 @@ export const useUpdateTide = () => { // Invalidate related queries queryClient.invalidateQueries({ queryKey: tideKeys.detail(id) }) if (metricsType) { - queryClient.invalidateQueries({ queryKey: tideKeys.overview(metricsType) }) - queryClient.invalidateQueries({ queryKey: tideKeys.daily(metricsType) }) - queryClient.invalidateQueries({ queryKey: tideKeys.weekly(metricsType) }) + queryClient.invalidateQueries({ queryKey: tideKeys.overview() }) } queryClient.invalidateQueries({ queryKey: tideKeys.active() }) queryClient.invalidateQueries({ queryKey: tideKeys.recent() }) diff --git a/src/components/TideEditDialog.tsx b/src/components/TideEditDialog.tsx new file mode 100644 index 00000000..06edf059 --- /dev/null +++ b/src/components/TideEditDialog.tsx @@ -0,0 +1,167 @@ +import { type FC, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { AnalyticsButton } from '@/components/ui/analytics-button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +interface TideEditDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + tideType: 'daily' | 'weekly' + currentGoal?: number // in minutes + metricsType?: string +} + +export const TideEditDialog: FC = ({ + open, + onOpenChange, + tideType, + currentGoal = 0, + metricsType = 'creating' +}) => { + const [goalHours, setGoalHours] = useState(Math.floor(currentGoal / 60)) + const [goalMinutes, setGoalMinutes] = useState(currentGoal % 60) + const [selectedMetrics, setSelectedMetrics] = useState(metricsType) + + const handleSave = () => { + const totalMinutes = goalHours * 60 + goalMinutes + console.log('Saving tide:', { tideType, totalMinutes, selectedMetrics }) + // TODO: Implement actual save logic using TideApi + onOpenChange(false) + } + + const formatTime = (hours: number, minutes: number) => { + if (hours === 0 && minutes === 0) return '0m' + if (hours === 0) return `${minutes}m` + if (minutes === 0) return `${hours}h` + return `${hours}h ${minutes}m` + } + + return ( + + + + + {currentGoal > 0 ? 'Edit' : 'Create'} {tideType} tide goal + + + +
+ {/* Goal Amount */} +
+ +
+
+ setGoalHours(parseInt(e.target.value) || 0)} + className="w-16" + /> + h +
+
+ setGoalMinutes(parseInt(e.target.value) || 0)} + className="w-16" + /> + m +
+
+

+ Goal: {formatTime(goalHours, goalMinutes)} +

+
+ + {/* Metrics Type */} +
+ + +
+ + {/* Quick Presets */} +
+ +
+ { setGoalHours(1); setGoalMinutes(0) }} + analyticsEvent="get_pro_clicked" + > + 1h + + { setGoalHours(2); setGoalMinutes(0) }} + analyticsEvent="get_pro_clicked" + > + 2h + + { setGoalHours(4); setGoalMinutes(0) }} + analyticsEvent="get_pro_clicked" + > + 4h + + { setGoalHours(0); setGoalMinutes(0) }} + analyticsEvent="get_pro_clicked" + > + No Goal + +
+
+
+ +
+ onOpenChange(false)} + analyticsEvent="get_pro_clicked" + > + Cancel + + + Save Tide + +
+
+
+ ) +} diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 2fecca4f..85840e2f 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -1,25 +1,21 @@ import { type FC, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { PieChart, Pie, Cell } from 'recharts' -import { useGetCurrentDailyTide, useGetCurrentWeeklyTide } from '@/api/hooks/useTides' import { Skeleton } from '@/components/ui/skeleton' - +import { useGetTideOverview } from '../api/hooks/useTides' interface TideGoalsCardProps { - className?: string - metricsType?: string + date?: Date } export const TideGoalsCard: FC = ({ - className = '', - metricsType = 'creating' + date = new Date() }) => { const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') - const { data: dailyTideData, isLoading: isDailyLoading, error: dailyError } = useGetCurrentDailyTide(metricsType) - const { data: weeklyTideData, isLoading: isWeeklyLoading, error: weeklyError } = useGetCurrentWeeklyTide(metricsType) + const { data: tideData, isLoading: isTidesLoading, error: tideError } = useGetTideOverview(date) - const isLoading = isDailyLoading || isWeeklyLoading - const hasError = dailyError || weeklyError + const isLoading = isTidesLoading + const hasError = tideError // Format time helper const formatTime = (minutes: number, options: { overrideShowHours: boolean } = { overrideShowHours: false }) => { @@ -263,7 +259,7 @@ export const TideGoalsCard: FC = ({ } return ( - +
Tides @@ -308,14 +304,14 @@ export const TideGoalsCard: FC = ({
) : ( <> - {activeTab === 'daily' && dailyTideData && ( + {activeTab === 'daily' && tideData && (
- {renderGoalProgress(dailyTideData.progress.current, dailyTideData.progress.goal)} + {renderGoalProgress(tideData.daily.progress.current, tideData.daily.progress.goal)}
)} - {activeTab === 'weekly' && weeklyTideData && ( + {activeTab === 'weekly' && tideData && (
- {renderGoalProgress(weeklyTideData.progress.current, weeklyTideData.progress.goal)} + {renderGoalProgress(tideData.weekly.progress.current, tideData.weekly.progress.goal)}
)} diff --git a/src/components/UsageSummaryWithTides.tsx b/src/components/UsageSummaryWithTides.tsx index e230b906..22f6f9a6 100644 --- a/src/components/UsageSummaryWithTides.tsx +++ b/src/components/UsageSummaryWithTides.tsx @@ -154,7 +154,7 @@ export const UsageSummaryWithTides = ({ {/* Two-column layout: Goals card on left, Chart on right */}
{/* Tide Goals Card */} - + {/* Chart Card - spans 2 columns */} From 62d78dc68e4e05349ef29c13569ef301248f6ccf Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Tue, 30 Sep 2025 19:29:50 -0600 Subject: [PATCH 31/40] Add editable goals and fix timezone mismatch --- .../src/ebb_tide_manager/src/tide_service.rs | 179 +++++--- .../src/ebb_tide_manager/src/time_helpers.rs | 434 ++++++++++-------- src/api/ebbApi/tideApi.ts | 1 - src/api/hooks/useTides.ts | 1 - src/components/TideEditDialog.tsx | 306 +++++++----- src/components/TideGoalsCard.tsx | 37 +- src/components/TimeSelector.tsx | 17 +- 7 files changed, 578 insertions(+), 397 deletions(-) diff --git a/src-tauri/src/ebb_tide_manager/src/tide_service.rs b/src-tauri/src/ebb_tide_manager/src/tide_service.rs index 76d6054f..3fd50a16 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_service.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_service.rs @@ -1,14 +1,14 @@ use ebb_db::{ - db_manager::{self, DbManager}, db::{ + models::{tide::Tide, tide_template::TideTemplate}, tide_repo::TideRepo, tide_template_repo::TideTemplateRepo, - models::{tide::Tide, tide_template::TideTemplate}, }, + db_manager::{self, DbManager}, }; use std::sync::Arc; -use time::OffsetDateTime; use thiserror::Error; +use time::OffsetDateTime; #[derive(Error, Debug)] pub enum TideServiceError { @@ -34,9 +34,10 @@ pub struct TideService { impl TideService { pub async fn new() -> Result { - let db_manager = db_manager::DbManager::get_shared_ebb().await + 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()), @@ -84,28 +85,39 @@ impl TideService { } pub async fn create_template(&self, template: &TideTemplate) -> Result<()> { - self.tide_template_repo.create_tide_template(template).await?; + 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?; + 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?; + 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?; + 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?; + self.tide_repo + .update_actual_amount(tide_id, actual_amount) + .await?; Ok(()) } @@ -136,38 +148,58 @@ impl TideService { /// 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> { + 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); - let new_tide = self.create_tide_from_template(&template.id, Some(tide_start_time)).await?; + 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 { + 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" => { @@ -175,19 +207,23 @@ impl TideService { 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 + } + "weekly" => true, // Always create if no active tide exists "monthly" => true, // Always create if no active tide exists - _ => false, // Unknown frequency + _ => 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_week_start, get_month_start}; + 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 + "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 @@ -227,10 +263,7 @@ mod tests { // Run migrations manually let migrations = get_migrations(); for migration in migrations { - sqlx::query(&migration.sql) - .execute(&pool) - .await - .unwrap(); + sqlx::query(&migration.sql).execute(&pool).await.unwrap(); } Arc::new(DbManager { pool }) @@ -240,7 +273,7 @@ mod tests { 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(()) } @@ -496,10 +529,12 @@ mod tests { 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?; + let active_tides = tide_service + .get_or_create_active_tides_for_period(evaluation_time) + .await?; assert_eq!(active_tides.len(), 2); Ok(()) @@ -525,13 +560,17 @@ mod tests { // 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?; + 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?; + 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); @@ -557,7 +596,9 @@ mod tests { // 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?; + 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); @@ -583,7 +624,9 @@ mod tests { // 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?; + 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(()) @@ -609,7 +652,9 @@ mod tests { // 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?; + 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"); @@ -653,7 +698,9 @@ mod tests { 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?; + 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 @@ -661,7 +708,7 @@ mod tests { .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)); @@ -693,7 +740,9 @@ mod tests { .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?; + 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)); @@ -732,8 +781,12 @@ mod tests { ); tide_service.create_template(&template_with_active).await?; - tide_service.create_template(&template_should_create).await?; - tide_service.create_template(&template_weekdays_only).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 @@ -741,21 +794,23 @@ mod tests { .await?; // Run the function - let active_tides = tide_service.get_or_create_active_tides_for_period(evaluation_time).await?; - + 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)); @@ -780,31 +835,41 @@ mod tests { // 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?; + 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() + let our_tide = active_tides + .iter() .find(|t| t.tide_template_id == template.id) .expect("Should find our tide"); - // The tide should start at the beginning of the day (midnight) in local timezone - assert_eq!(our_tide.start.hour(), 0); - assert_eq!(our_tide.start.minute(), 0); - assert_eq!(our_tide.start.second(), 0); + // 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(our_tide.start.offset()); - assert_eq!(our_tide.start.date(), evaluation_local.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) in local timezone + // 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.hour(), 0); - assert_eq!(end_time.minute(), 0); - assert_eq!(end_time.second(), 0); + 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(()) } -} \ No newline at end of file +} diff --git a/src-tauri/src/ebb_tide_manager/src/time_helpers.rs b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs index fe7b49ba..8c0a3dc0 100644 --- a/src-tauri/src/ebb_tide_manager/src/time_helpers.rs +++ b/src-tauri/src/ebb_tide_manager/src/time_helpers.rs @@ -1,6 +1,7 @@ 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 @@ -13,7 +14,7 @@ pub fn get_week_start(time: OffsetDateTime) -> OffsetDateTime { let weekday = local_time.weekday().number_days_from_sunday() as i64; let days_since_monday = if weekday == 0 { 6 } else { weekday - 1 }; - local_time.replace_hour(0) + let start_of_week_local = local_time.replace_hour(0) .unwrap() .replace_minute(0) .unwrap() @@ -21,10 +22,14 @@ pub fn get_week_start(time: OffsetDateTime) -> OffsetDateTime { .unwrap() .replace_nanosecond(0) .unwrap() - - time::Duration::days(days_since_monday) + - 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 @@ -34,7 +39,7 @@ pub fn get_month_start(time: OffsetDateTime) -> OffsetDateTime { // Convert to local timezone let local_time = time.to_offset(local_offset); - local_time.replace_day(1) + let start_of_month_local = local_time.replace_day(1) .unwrap() .replace_hour(0) .unwrap() @@ -43,10 +48,14 @@ pub fn get_month_start(time: OffsetDateTime) -> OffsetDateTime { .replace_second(0) .unwrap() .replace_nanosecond(0) - .unwrap() + .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 @@ -67,7 +76,8 @@ pub fn get_day_start(time: OffsetDateTime) -> OffsetDateTime { .replace_nanosecond(0) .unwrap(); - start_of_day_local + // Convert back to UTC for storage + start_of_day_local.to_offset(time::UtcOffset::UTC) } #[cfg(test)] @@ -77,89 +87,87 @@ mod tests { #[test] fn test_get_week_start_monday() { - // Monday should return the same day at 00:00:00 + // 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); - // Verify it's the same date and at start of day - assert_eq!(week_start.hour(), 0); - assert_eq!(week_start.minute(), 0); - assert_eq!(week_start.second(), 0); - assert_eq!(week_start.nanosecond(), 0); - - // Convert to UTC for date comparison to avoid timezone issues - let week_start_utc = week_start.to_offset(time::UtcOffset::UTC); - // Should be Monday (or Sunday if timezone shifted it back a day) - let weekday = week_start_utc.weekday(); - assert!(weekday == time::Weekday::Monday || weekday == time::Weekday::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_tuesday() { - // Tuesday should return previous Monday at 00:00:00 in local time + // 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); - // Verify it's at start of day - assert_eq!(week_start.hour(), 0); - assert_eq!(week_start.minute(), 0); - assert_eq!(week_start.second(), 0); - assert_eq!(week_start.nanosecond(), 0); + 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); - // Verify it's Monday when converted to same timezone - let weekday = week_start.weekday(); - assert_eq!(weekday, time::Weekday::Monday); + 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 + // 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); - // Verify it's at start of day - assert_eq!(week_start.hour(), 0); - assert_eq!(week_start.minute(), 0); - assert_eq!(week_start.second(), 0); - assert_eq!(week_start.nanosecond(), 0); + 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); - // Verify it's Monday - let weekday = week_start.weekday(); - assert_eq!(weekday, time::Weekday::Monday); + 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 + // 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); - // Verify it's at start of day - assert_eq!(week_start.hour(), 0); - assert_eq!(week_start.minute(), 0); - assert_eq!(week_start.second(), 0); - assert_eq!(week_start.nanosecond(), 0); + 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); - // Verify it's Monday - let weekday = week_start.weekday(); - assert_eq!(weekday, time::Weekday::Monday); + 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 + // 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); - // Verify it's at start of day - assert_eq!(week_start.hour(), 0); - assert_eq!(week_start.minute(), 0); - assert_eq!(week_start.second(), 0); - assert_eq!(week_start.nanosecond(), 0); + 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); - // Verify it's Monday - let weekday = week_start.weekday(); - assert_eq!(weekday, time::Weekday::Monday); + 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] @@ -168,78 +176,75 @@ mod tests { let wednesday_midnight = datetime!(2025-01-08 00:00:00 UTC); // Wednesday at midnight let week_start = get_week_start(wednesday_midnight); - // Verify it's at start of day - assert_eq!(week_start.hour(), 0); - assert_eq!(week_start.minute(), 0); - assert_eq!(week_start.second(), 0); - assert_eq!(week_start.nanosecond(), 0); + 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); - // Verify it's Monday - let weekday = week_start.weekday(); - assert_eq!(weekday, time::Weekday::Monday); + 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 + // 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); - // Verify it's at start of day - assert_eq!(month_start.hour(), 0); - assert_eq!(month_start.minute(), 0); - assert_eq!(month_start.second(), 0); - assert_eq!(month_start.nanosecond(), 0); - - // Verify it's the first day of the month - assert_eq!(month_start.day(), 1); - - // Verify it's the same month when converted to same timezone - let first_day_local = first_day.to_offset(month_start.offset()); - assert_eq!(month_start.month(), first_day_local.month()); - assert_eq!(month_start.year(), first_day_local.year()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(month_start.hour(), 0); - assert_eq!(month_start.minute(), 0); - assert_eq!(month_start.second(), 0); - assert_eq!(month_start.nanosecond(), 0); - - // Verify it's the first day of the month - assert_eq!(month_start.day(), 1); - - // Verify it's the same month when converted to same timezone - let mid_month_local = mid_month.to_offset(month_start.offset()); - assert_eq!(month_start.month(), mid_month_local.month()); - assert_eq!(month_start.year(), mid_month_local.year()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(month_start.hour(), 0); - assert_eq!(month_start.minute(), 0); - assert_eq!(month_start.second(), 0); - assert_eq!(month_start.nanosecond(), 0); - - // Verify it's the first day of the month - assert_eq!(month_start.day(), 1); - - // Verify it's the same month when converted to same timezone - let end_month_local = end_month.to_offset(month_start.offset()); - assert_eq!(month_start.month(), end_month_local.month()); - assert_eq!(month_start.year(), end_month_local.year()); + 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] @@ -248,19 +253,18 @@ mod tests { let february = datetime!(2025-02-20 14:45:12 UTC); // February 20th let month_start = get_month_start(february); - // Verify it's at start of day - assert_eq!(month_start.hour(), 0); - assert_eq!(month_start.minute(), 0); - assert_eq!(month_start.second(), 0); - assert_eq!(month_start.nanosecond(), 0); - - // Verify it's the first day of the month - assert_eq!(month_start.day(), 1); - - // Verify it's February when converted to same timezone - let february_local = february.to_offset(month_start.offset()); - assert_eq!(month_start.month(), february_local.month()); - assert_eq!(month_start.year(), february_local.year()); + 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] @@ -269,19 +273,18 @@ mod tests { let december = datetime!(2025-12-25 08:15:30 UTC); // December 25th let month_start = get_month_start(december); - // Verify it's at start of day - assert_eq!(month_start.hour(), 0); - assert_eq!(month_start.minute(), 0); - assert_eq!(month_start.second(), 0); - assert_eq!(month_start.nanosecond(), 0); - - // Verify it's the first day of the month - assert_eq!(month_start.day(), 1); - - // Verify it's December when converted to same timezone - let december_local = december.to_offset(month_start.offset()); - assert_eq!(month_start.month(), december_local.month()); - assert_eq!(month_start.year(), december_local.year()); + 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] @@ -290,121 +293,142 @@ mod tests { let first_midnight = datetime!(2025-06-01 00:00:00 UTC); // June 1st at midnight let month_start = get_month_start(first_midnight); - // Verify it's at start of day - assert_eq!(month_start.hour(), 0); - assert_eq!(month_start.minute(), 0); - assert_eq!(month_start.second(), 0); - assert_eq!(month_start.nanosecond(), 0); - - // Verify it's the first day of the month - assert_eq!(month_start.day(), 1); - - // Verify it's June when converted to same timezone - let first_midnight_local = first_midnight.to_offset(month_start.offset()); - assert_eq!(month_start.month(), first_midnight_local.month()); - assert_eq!(month_start.year(), first_midnight_local.year()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + // Result should be in UTC + assert_eq!(day_start.offset(), time::UtcOffset::UTC); - // Verify it's the same day (when converted to same timezone) - let morning_local = morning.to_offset(day_start.offset()); - assert_eq!(day_start.date(), morning_local.date()); + // 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 + // 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); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + // 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); - // Verify it's the same day when converted to same timezone - let afternoon_local = afternoon.to_offset(day_start.offset()); - assert_eq!(day_start.date(), afternoon_local.date()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + 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); - // Verify it's the same day when converted to same timezone - let evening_local = evening.to_offset(day_start.offset()); - assert_eq!(day_start.date(), evening_local.date()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + 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); - // Verify it's the same day when converted to same timezone - let end_of_day_local = end_of_day.to_offset(day_start.offset()); - assert_eq!(day_start.date(), end_of_day_local.date()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + 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); - // Verify it's the same day when converted to same timezone - let midnight_local = midnight.to_offset(day_start.offset()); - assert_eq!(day_start.date(), midnight_local.date()); + 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 + // 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); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + 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); - // Verify it's the same day when converted to same timezone - let precise_time_local = precise_time.to_offset(day_start.offset()); - assert_eq!(day_start.date(), precise_time_local.date()); + let precise_time_local = precise_time.to_offset(local_offset); + assert_eq!(day_start_local.date(), precise_time_local.date()); } #[test] @@ -413,14 +437,16 @@ mod tests { let leap_day = datetime!(2024-02-29 16:20:10 UTC); // February 29th (leap year) let day_start = get_day_start(leap_day); - // Verify it's at start of day - assert_eq!(day_start.hour(), 0); - assert_eq!(day_start.minute(), 0); - assert_eq!(day_start.second(), 0); - assert_eq!(day_start.nanosecond(), 0); + 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); - // Verify it's the same day when converted to same timezone - let leap_day_local = leap_day.to_offset(day_start.offset()); - assert_eq!(day_start.date(), leap_day_local.date()); + 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/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index 251627f6..6dd65730 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -137,7 +137,6 @@ const getTideProgress = async (type = 'daily', date = new Date()): Promise { - console.log('useGetTideOverview', date) return useQuery({ queryKey: tideKeys.overview(date), queryFn: () => TideApi.getTideOverview(date), diff --git a/src/components/TideEditDialog.tsx b/src/components/TideEditDialog.tsx index 06edf059..a4bf5d32 100644 --- a/src/components/TideEditDialog.tsx +++ b/src/components/TideEditDialog.tsx @@ -1,4 +1,4 @@ -import { type FC, useState } from 'react' +import { type FC, useState, useEffect } from 'react' import { Dialog, DialogContent, @@ -6,144 +6,209 @@ import { DialogTitle, } from '@/components/ui/dialog' import { AnalyticsButton } from '@/components/ui/analytics-button' -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { useGetTideTemplates, useUpdateTideTemplate, useGetActiveTides, useUpdateTide } from '@/api/hooks/useTides' +import { Skeleton } from '@/components/ui/skeleton' +import { TimeSelector } from '@/components/TimeSelector' +import { toast } from 'sonner' interface TideEditDialogProps { open: boolean onOpenChange: (open: boolean) => void - tideType: 'daily' | 'weekly' - currentGoal?: number // in minutes - metricsType?: string +} + +interface TemplateEdit { + id: string + goalMinutes: number // Total minutes for the goal + metricsType: string + daysOfWeek: number[] // For daily templates } export const TideEditDialog: FC = ({ open, - onOpenChange, - tideType, - currentGoal = 0, - metricsType = 'creating' + onOpenChange }) => { - const [goalHours, setGoalHours] = useState(Math.floor(currentGoal / 60)) - const [goalMinutes, setGoalMinutes] = useState(currentGoal % 60) - const [selectedMetrics, setSelectedMetrics] = useState(metricsType) - - const handleSave = () => { - const totalMinutes = goalHours * 60 + goalMinutes - console.log('Saving tide:', { tideType, totalMinutes, selectedMetrics }) - // TODO: Implement actual save logic using TideApi - onOpenChange(false) + const { data: templates, isLoading } = useGetTideTemplates() + const { data: activeTides } = useGetActiveTides() + const updateTemplateMutation = useUpdateTideTemplate() + const updateTideMutation = useUpdateTide() + const [editedTemplates, setEditedTemplates] = useState>({}) + + // Initialize edited templates when templates load + useEffect(() => { + if (templates && templates.length > 0) { + const initialEdits: Record = {} + templates.forEach(template => { + initialEdits[template.id] = { + id: template.id, + goalMinutes: template.goal_amount, + metricsType: template.metrics_type, + daysOfWeek: template.day_of_week ? template.day_of_week.split(',').map(Number) : [1,2,3,4,5] // Default weekdays + } + }) + setEditedTemplates(initialEdits) + } + }, [templates]) + + const handleSave = async () => { + try { + const updatePromises = [] + + // Update each modified template + for (const [templateId, editedTemplate] of Object.entries(editedTemplates)) { + const originalTemplate = templates?.find(t => t.id === templateId) + if (!originalTemplate) continue + + const hasChanges = + originalTemplate.goal_amount !== editedTemplate.goalMinutes || + originalTemplate.day_of_week !== editedTemplate.daysOfWeek.join(',') + + if (hasChanges) { + const templateUpdate = { + goal_amount: editedTemplate.goalMinutes, + day_of_week: editedTemplate.daysOfWeek.length > 0 ? editedTemplate.daysOfWeek.join(',') : undefined + } + + updatePromises.push( + updateTemplateMutation.mutateAsync({ + id: templateId, + updates: templateUpdate + }) + ) + + // Update any active tides using this template + const activeTidesForTemplate = activeTides?.filter(tide => + tide.tide_template_id === templateId + ) + + if (activeTidesForTemplate && activeTidesForTemplate.length > 0) { + for (const activeTide of activeTidesForTemplate) { + // Only update the goal amount if it changed, preserve actual progress + if (originalTemplate.goal_amount !== editedTemplate.goalMinutes) { + updatePromises.push( + updateTideMutation.mutateAsync({ + id: activeTide.id, + updates: { + goal_amount: editedTemplate.goalMinutes + } + }) + ) + } + } + } + } + } + + if (updatePromises.length > 0) { + await Promise.all(updatePromises) + toast.success('Tide templates updated successfully') + } + + onOpenChange(false) + } catch (error) { + console.error('Failed to save tide templates:', error) + toast.error('Failed to save tide templates. Please try again.') + } } - const formatTime = (hours: number, minutes: number) => { - if (hours === 0 && minutes === 0) return '0m' - if (hours === 0) return `${minutes}m` - if (minutes === 0) return `${hours}h` - return `${hours}h ${minutes}m` + const updateTemplate = (templateId: string, updates: Partial) => { + setEditedTemplates(prev => ({ + ...prev, + [templateId]: { ...prev[templateId], ...updates } + })) + } + + + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + + if (isLoading) { + return ( + + + + Edit Tide Templates + +
+ + +
+
+
+ ) } return ( - + - - {currentGoal > 0 ? 'Edit' : 'Create'} {tideType} tide goal - + Edit Tides -
- {/* Goal Amount */} -
- -
-
- setGoalHours(parseInt(e.target.value) || 0)} - className="w-16" - /> - h -
-
- setGoalMinutes(parseInt(e.target.value) || 0)} - className="w-16" - /> - m -
-
-

- Goal: {formatTime(goalHours, goalMinutes)} -

-
+
+ {templates?.map(template => { + const edit = editedTemplates[template.id] + if (!edit) return null - {/* Metrics Type */} -
- - -
+ return ( +
+
+

+ {template.tide_frequency} Tide +

+
- {/* Quick Presets */} -
- -
- { setGoalHours(1); setGoalMinutes(0) }} - analyticsEvent="get_pro_clicked" - > - 1h - - { setGoalHours(2); setGoalMinutes(0) }} - analyticsEvent="get_pro_clicked" - > - 2h - - { setGoalHours(4); setGoalMinutes(0) }} - analyticsEvent="get_pro_clicked" - > - 4h - - { setGoalHours(0); setGoalMinutes(0) }} - analyticsEvent="get_pro_clicked" - > - No Goal - -
-
+ {/* Goal Amount */} +
+ updateTemplate(template.id, { goalMinutes: minutes || 0 })} + presets={ + template.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) */} + {template.tide_frequency === 'daily' && ( +
+ +
+ {dayNames.map((day, index) => ( + + ))} +
+
+ )} +
+ ) + })}
@@ -157,8 +222,9 @@ export const TideEditDialog: FC = ({ - Save Tide + {updateTemplateMutation.isPending || updateTideMutation.isPending ? 'Saving...' : 'Save Templates'}
diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 85840e2f..f0fde25e 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { PieChart, Pie, Cell } from 'recharts' import { Skeleton } from '@/components/ui/skeleton' import { useGetTideOverview } from '../api/hooks/useTides' +import { TideEditDialog } from './TideEditDialog' interface TideGoalsCardProps { date?: Date } @@ -11,12 +12,17 @@ export const TideGoalsCard: FC = ({ date = new Date() }) => { const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') + const [editDialogOpen, setEditDialogOpen] = useState(false) const { data: tideData, isLoading: isTidesLoading, error: tideError } = useGetTideOverview(date) const isLoading = isTidesLoading const hasError = tideError + const handleEditClick = () => { + setEditDialogOpen(true) + } + // Format time helper const formatTime = (minutes: number, options: { overrideShowHours: boolean } = { overrideShowHours: false }) => { const hours = Math.floor(minutes / 60) @@ -28,11 +34,15 @@ export const TideGoalsCard: FC = ({ const renderNoGoalProgress = (current: number) => { return ( -
+
{/* Simple circle background for no-goal state */}
{/* Center content */} @@ -129,8 +139,12 @@ export const TideGoalsCard: FC = ({ } return ( -
-
+
+
{/* Diagonal stripe pattern for stretch segments */} @@ -251,7 +265,7 @@ export const TideGoalsCard: FC = ({ {/* Goal information - fixed height container */}
- Target: {formatTime(goal)} + Creating Time Target: {formatTime(goal)}
@@ -262,7 +276,13 @@ export const TideGoalsCard: FC = ({
- Tides + + Tides + {/* Compact Chip Style in Header */}
+ ))} +
+
+ )} +
+ ) } export const TideEditDialog: FC = ({ @@ -31,20 +100,18 @@ export const TideEditDialog: FC = ({ const { data: activeTides } = useGetActiveTides() const updateTemplateMutation = useUpdateTideTemplate() const updateTideMutation = useUpdateTide() - const [editedTemplates, setEditedTemplates] = useState>({}) + const [editedTemplates, setEditedTemplates] = useState([]) // Initialize edited templates when templates load useEffect(() => { if (templates && templates.length > 0) { - const initialEdits: Record = {} - templates.forEach(template => { - initialEdits[template.id] = { - id: template.id, - goalMinutes: template.goal_amount, - metricsType: template.metrics_type, - daysOfWeek: template.day_of_week ? template.day_of_week.split(',').map(Number) : [1,2,3,4,5] // Default weekdays - } - }) + 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]) @@ -54,41 +121,41 @@ export const TideEditDialog: FC = ({ const updatePromises = [] // Update each modified template - for (const [templateId, editedTemplate] of Object.entries(editedTemplates)) { - const originalTemplate = templates?.find(t => t.id === templateId) + for (const editedTemplate of editedTemplates) { + const originalTemplate = templates?.find(t => t.id === editedTemplate.id) if (!originalTemplate) continue const hasChanges = - originalTemplate.goal_amount !== editedTemplate.goalMinutes || - originalTemplate.day_of_week !== editedTemplate.daysOfWeek.join(',') + originalTemplate.goal_amount !== editedTemplate.goal_amount || + originalTemplate.day_of_week !== editedTemplate.days_of_week.join(',') if (hasChanges) { const templateUpdate = { - goal_amount: editedTemplate.goalMinutes, - day_of_week: editedTemplate.daysOfWeek.length > 0 ? editedTemplate.daysOfWeek.join(',') : undefined + goal_amount: editedTemplate.goal_amount, + day_of_week: editedTemplate.days_of_week.length > 0 ? editedTemplate.days_of_week.join(',') : undefined } updatePromises.push( updateTemplateMutation.mutateAsync({ - id: templateId, + id: editedTemplate.id, updates: templateUpdate }) ) // Update any active tides using this template const activeTidesForTemplate = activeTides?.filter(tide => - tide.tide_template_id === templateId + tide.tide_template_id === editedTemplate.id ) if (activeTidesForTemplate && activeTidesForTemplate.length > 0) { for (const activeTide of activeTidesForTemplate) { // Only update the goal amount if it changed, preserve actual progress - if (originalTemplate.goal_amount !== editedTemplate.goalMinutes) { + if (originalTemplate.goal_amount !== editedTemplate.goal_amount) { updatePromises.push( updateTideMutation.mutateAsync({ id: activeTide.id, updates: { - goal_amount: editedTemplate.goalMinutes + goal_amount: editedTemplate.goal_amount } }) ) @@ -100,26 +167,22 @@ export const TideEditDialog: FC = ({ if (updatePromises.length > 0) { await Promise.all(updatePromises) - toast.success('Tide templates updated successfully') + toast.success('Tide updated successfully') } onOpenChange(false) } catch (error) { - console.error('Failed to save tide templates:', error) - toast.error('Failed to save tide templates. Please try again.') + 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, - [templateId]: { ...prev[templateId], ...updates } - })) + setEditedTemplates(prev => + prev.map(t => t.id === templateId ? { ...t, ...updates } : t) + ) } - - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] - if (isLoading) { return ( @@ -144,71 +207,13 @@ export const TideEditDialog: FC = ({
- {templates?.map(template => { - const edit = editedTemplates[template.id] - if (!edit) return null - - return ( -
-
-

- {template.tide_frequency} Tide -

-
- - {/* Goal Amount */} -
- updateTemplate(template.id, { goalMinutes: minutes || 0 })} - presets={ - template.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) */} - {template.tide_frequency === 'daily' && ( -
- -
- {dayNames.map((day, index) => ( - - ))} -
-
- )} -
- ) - })} + {editedTemplates.map(edit => ( + + ))}
From ad3f0e98bf7d71cc86fae9f17bc63c4fa4e9544e Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 13:53:33 -0600 Subject: [PATCH 34/40] move tide update logic to service --- src-tauri/Cargo.lock | 1 + src-tauri/src/ebb_tide_manager/Cargo.toml | 1 + .../src/ebb_tide_manager/src/tide_progress.rs | 13 ++- src-tauri/src/main.rs | 2 +- src/api/ebbApi/tideApi.ts | 92 +++++++++---------- src/api/hooks/useTides.ts | 74 ++++++++------- src/components/TideEditDialog.tsx | 76 ++------------- 7 files changed, 96 insertions(+), 163 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4abf3ef4..c06528b1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1507,6 +1507,7 @@ name = "ebb_tide_manager" version = "0.1.0" dependencies = [ "ebb-db", + "log", "thiserror 2.0.12", "time", "tokio", diff --git a/src-tauri/src/ebb_tide_manager/Cargo.toml b/src-tauri/src/ebb_tide_manager/Cargo.toml index 19432ad3..cd315db9 100644 --- a/src-tauri/src/ebb_tide_manager/Cargo.toml +++ b/src-tauri/src/ebb_tide_manager/Cargo.toml @@ -5,6 +5,7 @@ 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" diff --git a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs index 4052b241..25e888c1 100644 --- a/src-tauri/src/ebb_tide_manager/src/tide_progress.rs +++ b/src-tauri/src/ebb_tide_manager/src/tide_progress.rs @@ -3,6 +3,7 @@ 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; @@ -83,7 +84,7 @@ impl TideProgress { if !skip_cache { // Cache hit - calculate incremental progress let time_diff = evaluation_time - cached.last_evaluation_time; - println!( + log::debug!( "Cache check: tide_id={}, cached_time={:?}, eval_time={:?}, diff={:?} ({} seconds)", tide_id, cached.last_evaluation_time, @@ -91,9 +92,10 @@ impl TideProgress { time_diff, time_diff.whole_seconds() ); - println!( + log::debug!( "Cache hit for tide: incremental progress for tide with start and end times: {:?}, {:?}", - cached.last_evaluation_time, evaluation_time + cached.last_evaluation_time, + evaluation_time ); let delta_minutes = self .activity_state_repo @@ -121,9 +123,10 @@ impl TideProgress { } // Cache miss - calculate full range from tide start - println!( + log::debug!( "Cache miss for tide: calculating full range for tide with start and end times: {:?}, {:?}", - tide.start, tide.end + tide.start, + tide.end ); let total_minutes = self.calculate_tide_progress(tide, evaluation_time).await?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4bdbaae3..10ebfda2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -121,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()), diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index f9997752..873ee8ff 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -57,57 +57,6 @@ const updateTideTemplate = async ( id: string, updates: Partial ): Promise => { - // const updatePromises = [] - - // // Update each modified template - // for (const [templateId, editedTemplate] of Object.entries(editedTemplates)) { - // const originalTemplate = templates?.find(t => t.id === templateId) - // if (!originalTemplate) continue - - // const hasChanges = - // originalTemplate.goal_amount !== editedTemplate.goalMinutes || - // originalTemplate.day_of_week !== editedTemplate.daysOfWeek.join(',') - - // if (hasChanges) { - // const templateUpdate = { - // goal_amount: editedTemplate.goalMinutes, - // day_of_week: editedTemplate.daysOfWeek.length > 0 ? editedTemplate.daysOfWeek.join(',') : undefined - // } - - // updatePromises.push( - // updateTemplateMutation.mutateAsync({ - // id: templateId, - // updates: templateUpdate - // }) - // ) - - // // Update any active tides using this template - // const activeTidesForTemplate = activeTides?.filter(tide => - // tide.tide_template_id === templateId - // ) - - // if (activeTidesForTemplate && activeTidesForTemplate.length > 0) { - // for (const activeTide of activeTidesForTemplate) { - // // Only update the goal amount if it changed, preserve actual progress - // if (originalTemplate.goal_amount !== editedTemplate.goalMinutes) { - // updatePromises.push( - // updateTideMutation.mutateAsync({ - // id: activeTide.id, - // updates: { - // goal_amount: editedTemplate.goalMinutes - // } - // }) - // ) - // } - // } - // } - // } - // } - - // if (updatePromises.length > 0) { - // await Promise.all(updatePromises) - // toast.success('Tide updated successfully') - // } const updatedTemplate = { ...updates, updated_at: new Date().toISOString(), @@ -115,6 +64,46 @@ const updateTideTemplate = async ( 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) { + updatePromises.push( + updateTide(activeTide.id, { + goal_amount: editedTemplate.goal_amount + }) + ) + } + } + + await Promise.all(updatePromises) +} + const getTideTemplates = async (): Promise => { return TideRepo.getAllTideTemplates() } @@ -274,6 +263,7 @@ export const TideApi = { // Template operations createTideTemplate, updateTideTemplate, + updateTideTemplates, getTideTemplates, getTideTemplateById, diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts index 7ed5124f..d52c1253 100644 --- a/src/api/hooks/useTides.ts +++ b/src/api/hooks/useTides.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { TideApi, Tide, TideTemplate } from '../ebbApi/tideApi' +import { TideApi, Tide, TemplateEdit } from '../ebbApi/tideApi' const tideKeys = { all: ['tides'] as const, @@ -12,7 +12,7 @@ const tideKeys = { // Query Hooks -export const useGetTideOverview = (date = new Date()) => { +const useGetTideOverview = (date = new Date()) => { return useQuery({ queryKey: tideKeys.overview(date), queryFn: () => TideApi.getTideOverview(date), @@ -21,27 +21,7 @@ export const useGetTideOverview = (date = new Date()) => { }) } -// export const useGetCurrentDailyTide = (metricsType = 'creating') => { -// return useQuery({ -// queryKey: tideKeys.daily(metricsType), -// queryFn: () => TideApi.getCurrentDailyTide(metricsType), -// staleTime: 30000, -// refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) -// refetchOnWindowFocus: true, -// }) -// } - -// export const useGetCurrentWeeklyTide = (metricsType = 'creating') => { -// return useQuery({ -// queryKey: tideKeys.weekly(metricsType), -// queryFn: () => TideApi.getCurrentWeeklyTide(metricsType), -// staleTime: 30000, -// refetchInterval: 30000, // More frequent refresh (30 seconds like UsageSummary) -// refetchOnWindowFocus: true, -// }) -// } - -export const useGetRecentTides = (limit = 10) => { +const useGetRecentTides = (limit = 10) => { return useQuery({ queryKey: tideKeys.recent(limit), queryFn: () => TideApi.getRecentTides(limit), @@ -49,7 +29,7 @@ export const useGetRecentTides = (limit = 10) => { }) } -export const useGetActiveTides = () => { +const useGetActiveTides = () => { return useQuery({ queryKey: tideKeys.active(), queryFn: () => TideApi.getActiveTides(), @@ -58,7 +38,7 @@ export const useGetActiveTides = () => { }) } -export const useGetTideTemplates = () => { +const useGetTideTemplates = () => { return useQuery({ queryKey: tideKeys.templates(), queryFn: () => TideApi.getTideTemplates(), @@ -66,7 +46,7 @@ export const useGetTideTemplates = () => { }) } -export const useGetTideById = (id: string) => { +const useGetTideById = (id: string) => { return useQuery({ queryKey: tideKeys.detail(id), queryFn: () => TideApi.getTideById(id), @@ -77,7 +57,7 @@ export const useGetTideById = (id: string) => { // Mutation Hooks -export const useCreateTideTemplate = () => { +const useCreateTideTemplate = () => { const queryClient = useQueryClient() return useMutation({ @@ -100,20 +80,22 @@ export const useCreateTideTemplate = () => { }) } -export const useUpdateTideTemplate = () => { + +const useUpdateTideTemplates = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ id, updates }: { id: string; updates: Partial }) => - TideApi.updateTideTemplate(id, updates), - onSuccess: (_, { id }) => { + mutationFn: (editedTemplates: TemplateEdit[]) => + TideApi.updateTideTemplates(editedTemplates), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: tideKeys.templates() }) - queryClient.invalidateQueries({ queryKey: tideKeys.detail(id) }) + queryClient.invalidateQueries({ queryKey: tideKeys.active() }) + queryClient.invalidateQueries({ queryKey: tideKeys.overview() }) }, }) } -export const useCreateTide = () => { +const useCreateTide = () => { const queryClient = useQueryClient() return useMutation({ @@ -141,7 +123,7 @@ export const useCreateTide = () => { }) } -export const useUpdateTide = () => { +const useUpdateTide = () => { const queryClient = useQueryClient() return useMutation({ @@ -160,7 +142,7 @@ export const useUpdateTide = () => { }) } -export const useCompleteTide = () => { +const useCompleteTide = () => { const queryClient = useQueryClient() return useMutation({ @@ -172,7 +154,7 @@ export const useCompleteTide = () => { }) } -export const useUpdateTideProgress = () => { +const useUpdateTideProgress = () => { const queryClient = useQueryClient() return useMutation({ @@ -186,15 +168,31 @@ export const useUpdateTideProgress = () => { } // Utility hook for formatting time consistently -export const useFormatTime = () => { +const useFormatTime = () => { return (minutes: number) => TideApi.formatTime(minutes) } // Hook for refetching tide data manually (useful for pull-to-refresh) -export const useRefreshTides = () => { +const useRefreshTides = () => { const queryClient = useQueryClient() return () => { queryClient.invalidateQueries({ queryKey: tideKeys.all }) } } + +export const useTides = { + useGetTideOverview, + useGetRecentTides, + useGetActiveTides, + useGetTideTemplates, + useGetTideById, + useCreateTideTemplate, + useUpdateTideTemplates, + useCreateTide, + useUpdateTide, + useCompleteTide, + useUpdateTideProgress, + useFormatTime, + useRefreshTides, +} diff --git a/src/components/TideEditDialog.tsx b/src/components/TideEditDialog.tsx index cf9f522c..f3fc2312 100644 --- a/src/components/TideEditDialog.tsx +++ b/src/components/TideEditDialog.tsx @@ -6,25 +6,17 @@ import { DialogTitle, } from '@/components/ui/dialog' import { AnalyticsButton } from '@/components/ui/analytics-button' -import { useGetTideTemplates, useUpdateTideTemplate, useGetActiveTides, useUpdateTide } from '@/api/hooks/useTides' +import { useTides } from '@/api/hooks/useTides' import { Skeleton } from '@/components/ui/skeleton' import { TimeSelector } from '@/components/TimeSelector' import { toast } from 'sonner' -import { TideTemplate } from '@/api/ebbApi/tideApi' +import { TemplateEdit } from '@/api/ebbApi/tideApi' interface TideEditDialogProps { open: boolean onOpenChange: (open: boolean) => void } -interface TemplateEdit { - id: string - goal_amount: number - metrics_type: string - days_of_week: number[] - tide_frequency: string -} - interface TideTemplateItemProps { edit: TemplateEdit onUpdate: (templateId: string, updates: Partial) => void @@ -96,10 +88,8 @@ export const TideEditDialog: FC = ({ open, onOpenChange }) => { - const { data: templates, isLoading } = useGetTideTemplates() - const { data: activeTides } = useGetActiveTides() - const updateTemplateMutation = useUpdateTideTemplate() - const updateTideMutation = useUpdateTide() + const { data: templates, isLoading } = useTides.useGetTideTemplates() + const updateTemplatesMutation = useTides.useUpdateTideTemplates() const [editedTemplates, setEditedTemplates] = useState([]) // Initialize edited templates when templates load @@ -118,58 +108,8 @@ export const TideEditDialog: FC = ({ const handleSave = async () => { try { - const updatePromises = [] - - // Update each modified template - for (const editedTemplate of editedTemplates) { - const originalTemplate = templates?.find(t => t.id === editedTemplate.id) - if (!originalTemplate) continue - - const hasChanges = - originalTemplate.goal_amount !== editedTemplate.goal_amount || - originalTemplate.day_of_week !== editedTemplate.days_of_week.join(',') - - if (hasChanges) { - const templateUpdate = { - goal_amount: editedTemplate.goal_amount, - day_of_week: editedTemplate.days_of_week.length > 0 ? editedTemplate.days_of_week.join(',') : undefined - } - - updatePromises.push( - updateTemplateMutation.mutateAsync({ - id: editedTemplate.id, - updates: templateUpdate - }) - ) - - // Update any active tides using this template - const activeTidesForTemplate = activeTides?.filter(tide => - tide.tide_template_id === editedTemplate.id - ) - - if (activeTidesForTemplate && activeTidesForTemplate.length > 0) { - for (const activeTide of activeTidesForTemplate) { - // Only update the goal amount if it changed, preserve actual progress - if (originalTemplate.goal_amount !== editedTemplate.goal_amount) { - updatePromises.push( - updateTideMutation.mutateAsync({ - id: activeTide.id, - updates: { - goal_amount: editedTemplate.goal_amount - } - }) - ) - } - } - } - } - } - - if (updatePromises.length > 0) { - await Promise.all(updatePromises) - toast.success('Tide updated successfully') - } - + await updateTemplatesMutation.mutateAsync(editedTemplates) + toast.success('Tide updated successfully') onOpenChange(false) } catch (error) { console.error('Failed to save tide:', error) @@ -227,9 +167,9 @@ export const TideEditDialog: FC = ({ - {updateTemplateMutation.isPending || updateTideMutation.isPending ? 'Saving...' : 'Save Templates'} + {updateTemplatesMutation.isPending ? 'Saving...' : 'Save Templates'}
From 228a61278515b535e8fcfcad79607009eb423f48 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 14:05:27 -0600 Subject: [PATCH 35/40] remove unused functions --- src/api/ebbApi/tideApi.ts | 80 ----------------- src/api/hooks/useTides.ts | 148 +------------------------------ src/components/TideGoalsCard.tsx | 24 ++--- src/db/ebb/tideRepo.ts | 120 ------------------------- 4 files changed, 10 insertions(+), 362 deletions(-) diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index 873ee8ff..76808624 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -31,28 +31,6 @@ export interface TideOverview { // Tide Template API Functions -const createTideTemplate = async ( - metricsType: string, - tideFrequency: string, - goalAmount: number, - firstTide: string, - dayOfWeek?: string -): Promise => { - const template: TideTemplateSchema = { - id: self.crypto.randomUUID(), - metrics_type: metricsType, - tide_frequency: tideFrequency, - first_tide: firstTide, - day_of_week: dayOfWeek, - goal_amount: goalAmount, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - - await TideRepo.createTideTemplate(template) - return template.id -} - const updateTideTemplate = async ( id: string, updates: Partial @@ -108,37 +86,8 @@ const getTideTemplates = async (): Promise => { return TideRepo.getAllTideTemplates() } -const getTideTemplateById = async (id: string): Promise => { - return TideRepo.getTideTemplateById(id) -} - // Tide API Functions -const createTide = async ( - start: string, - end: string | undefined, - metricsType: string, - tideFrequency: string, - goalAmount: number, - tideTemplateId: string -): Promise => { - const tide: TideSchema = { - id: self.crypto.randomUUID(), - start, - end, - metrics_type: metricsType, - tide_frequency: tideFrequency, - goal_amount: goalAmount, - actual_amount: 0, - tide_template_id: tideTemplateId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - - await TideRepo.createTide(tide) - return tide.id -} - const updateTide = async ( id: string, updates: Partial @@ -150,22 +99,10 @@ const updateTide = async ( return TideRepo.updateTide(id, updatedTide) } -const getTideById = async (id: string): Promise => { - return TideRepo.getTideById(id) -} - const getActiveTides = async (): Promise => { return TideRepo.getActiveTides() } -const completeTide = async (id: string): Promise => { - return TideRepo.completeTide(id) -} - -const updateTideProgress = async (id: string, actualAmount: number): Promise => { - return TideRepo.updateTideProgress(id, actualAmount) -} - // Business Logic Functions const getTideProgress = async (type = 'daily', date = new Date()): Promise => { @@ -225,14 +162,6 @@ const getTideOverview = async (date = new Date()): Promise => { } } -const getRecentTides = async (limit = 10): Promise => { - return TideRepo.getRecentTides(limit) -} - -const getTidesWithTemplates = async (limit = 10): Promise => { - return TideRepo.getTidesWithTemplates(limit) -} - const formatTime = (minutes: number): string => { const hours = Math.floor(minutes / 60) const remainingMinutes = Math.round(minutes % 60) @@ -261,25 +190,16 @@ const isWithinTimeRange = (checkTime: string, startTime: string, endTime?: strin export const TideApi = { // Template operations - createTideTemplate, updateTideTemplate, updateTideTemplates, getTideTemplates, - getTideTemplateById, // Tide operations - createTide, updateTide, - getTideById, getActiveTides, - completeTide, - updateTideProgress, // Business logic - getTideProgress, getTideOverview, - getRecentTides, - getTidesWithTemplates, // Utilities formatTime, diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts index d52c1253..121f56c6 100644 --- a/src/api/hooks/useTides.ts +++ b/src/api/hooks/useTides.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { TideApi, Tide, TemplateEdit } from '../ebbApi/tideApi' +import { TideApi, TemplateEdit } from '@/api/ebbApi/tideApi' const tideKeys = { all: ['tides'] as const, @@ -21,23 +21,6 @@ const useGetTideOverview = (date = new Date()) => { }) } -const useGetRecentTides = (limit = 10) => { - return useQuery({ - queryKey: tideKeys.recent(limit), - queryFn: () => TideApi.getRecentTides(limit), - staleTime: 60000, // 1 minute - }) -} - -const useGetActiveTides = () => { - return useQuery({ - queryKey: tideKeys.active(), - queryFn: () => TideApi.getActiveTides(), - staleTime: 30000, - refetchInterval: 60000, - }) -} - const useGetTideTemplates = () => { return useQuery({ queryKey: tideKeys.templates(), @@ -46,41 +29,8 @@ const useGetTideTemplates = () => { }) } -const useGetTideById = (id: string) => { - return useQuery({ - queryKey: tideKeys.detail(id), - queryFn: () => TideApi.getTideById(id), - enabled: !!id, - staleTime: 60000, - }) -} - // Mutation Hooks -const useCreateTideTemplate = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: ({ - metricsType, - tideFrequency, - goalAmount, - firstTide, - dayOfWeek, - }: { - metricsType: string - tideFrequency: string - goalAmount: number - firstTide: string - dayOfWeek?: string - }) => TideApi.createTideTemplate(metricsType, tideFrequency, goalAmount, firstTide, dayOfWeek), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: tideKeys.templates() }) - }, - }) -} - - const useUpdateTideTemplates = () => { const queryClient = useQueryClient() @@ -95,104 +45,8 @@ const useUpdateTideTemplates = () => { }) } -const useCreateTide = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: ({ - start, - end, - metricsType, - tideFrequency, - goalAmount, - tideTemplateId, - }: { - start: string - end?: string - metricsType: string - tideFrequency: string - goalAmount: number - tideTemplateId: string - }) => TideApi.createTide(start, end, metricsType, tideFrequency, goalAmount, tideTemplateId), - onSuccess: () => { - // Invalidate related queries - queryClient.invalidateQueries({ queryKey: tideKeys.overview() }) - queryClient.invalidateQueries({ queryKey: tideKeys.active() }) - queryClient.invalidateQueries({ queryKey: tideKeys.recent() }) - }, - }) -} - -const useUpdateTide = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: ({ id, updates }: { id: string; updates: Partial }) => - TideApi.updateTide(id, updates), - onSuccess: (_, { id, updates }) => { - const metricsType = updates.metrics_type - // Invalidate related queries - queryClient.invalidateQueries({ queryKey: tideKeys.detail(id) }) - if (metricsType) { - queryClient.invalidateQueries({ queryKey: tideKeys.overview() }) - } - queryClient.invalidateQueries({ queryKey: tideKeys.active() }) - queryClient.invalidateQueries({ queryKey: tideKeys.recent() }) - }, - }) -} - -const useCompleteTide = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (id: string) => TideApi.completeTide(id), - onSuccess: () => { - // Invalidate all tide-related queries since completion affects overview - queryClient.invalidateQueries({ queryKey: tideKeys.all }) - }, - }) -} - -const useUpdateTideProgress = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: ({ id, actualAmount }: { id: string; actualAmount: number }) => - TideApi.updateTideProgress(id, actualAmount), - onSuccess: () => { - // Invalidate all tide-related queries for real-time updates - queryClient.invalidateQueries({ queryKey: tideKeys.all }) - }, - }) -} - -// Utility hook for formatting time consistently -const useFormatTime = () => { - return (minutes: number) => TideApi.formatTime(minutes) -} - -// Hook for refetching tide data manually (useful for pull-to-refresh) -const useRefreshTides = () => { - const queryClient = useQueryClient() - - return () => { - queryClient.invalidateQueries({ queryKey: tideKeys.all }) - } -} - export const useTides = { useGetTideOverview, - useGetRecentTides, - useGetActiveTides, useGetTideTemplates, - useGetTideById, - useCreateTideTemplate, useUpdateTideTemplates, - useCreateTide, - useUpdateTide, - useCompleteTide, - useUpdateTideProgress, - useFormatTime, - useRefreshTides, } diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index f0fde25e..4188cecd 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -2,8 +2,8 @@ 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 { useGetTideOverview } from '../api/hooks/useTides' -import { TideEditDialog } from './TideEditDialog' +import { TideEditDialog } from '@/components/TideEditDialog' +import { useTides } from '../api/hooks/useTides' interface TideGoalsCardProps { date?: Date } @@ -14,7 +14,7 @@ export const TideGoalsCard: FC = ({ const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') const [editDialogOpen, setEditDialogOpen] = useState(false) - const { data: tideData, isLoading: isTidesLoading, error: tideError } = useGetTideOverview(date) + const { data: tideData, isLoading: isTidesLoading, error: tideError } = useTides.useGetTideOverview(date) const isLoading = isTidesLoading const hasError = tideError @@ -192,7 +192,7 @@ export const TideGoalsCard: FC = ({ fill={isGoalComplete ? 'hsl(var(--primary))' : 'hsl(var(--muted))'} style={{ opacity: 0 }} className={isGoalComplete ? 'animate-pulse' : ''} - + /> @@ -257,7 +257,7 @@ export const TideGoalsCard: FC = ({
Tide reached!
+{formatTime(current - goal)}
- + )}
@@ -287,21 +287,15 @@ export const TideGoalsCard: FC = ({
diff --git a/src/db/ebb/tideRepo.ts b/src/db/ebb/tideRepo.ts index c8a03f7d..d5b67e4c 100644 --- a/src/db/ebb/tideRepo.ts +++ b/src/db/ebb/tideRepo.ts @@ -36,11 +36,6 @@ export type TideWithTemplate = Tide & { // Tide Repository Functions -const createTide = async (tide: TideSchema): Promise => { - const ebbDb = await getEbbDb() - return insert(ebbDb, 'tide', tide) -} - const updateTide = async ( id: string, tide: Partial, @@ -49,15 +44,6 @@ const updateTide = async ( return update(ebbDb, 'tide', tide, id) } -const getTideById = async (id: string): Promise => { - const ebbDb = await getEbbDb() - const [tide] = await ebbDb.select( - 'SELECT * FROM tide WHERE id = ?', - [id] - ) - return tide -} - const getActiveTides = async (): Promise => { const ebbDb = await getEbbDb() const now = new Date().toISOString() @@ -87,58 +73,8 @@ const getActiveTidesForPeriod = async (evaluationTime: string): Promise return await ebbDb.select(query, [evaluationTime, evaluationTime]) } -const getTidesByFrequency = async (frequency: string): Promise => { - const ebbDb = await getEbbDb() - - const query = ` - SELECT * FROM tide - WHERE tide_frequency = ? - ORDER BY start DESC - ` - - return await ebbDb.select(query, [frequency]) -} - -const getRecentTides = async (limit = 10): Promise => { - const ebbDb = await getEbbDb() - - const query = ` - SELECT * FROM tide - ORDER BY start DESC - LIMIT ? - ` - - return await ebbDb.select(query, [limit]) -} - -const completeTide = async (id: string): Promise => { - const ebbDb = await getEbbDb() - const completedAt = new Date().toISOString() - const updatedAt = new Date().toISOString() - - return update(ebbDb, 'tide', { - completed_at: completedAt, - updated_at: updatedAt - }, id) -} - -const updateTideProgress = async (id: string, actualAmount: number): Promise => { - const ebbDb = await getEbbDb() - const updatedAt = new Date().toISOString() - - return update(ebbDb, 'tide', { - actual_amount: actualAmount, - updated_at: updatedAt - }, id) -} - // Tide Template Repository Functions -const createTideTemplate = async (template: TideTemplateSchema): Promise => { - const ebbDb = await getEbbDb() - return insert(ebbDb, 'tide_template', template) -} - const updateTideTemplate = async ( id: string, template: Partial, @@ -147,15 +83,6 @@ const updateTideTemplate = async ( return update(ebbDb, 'tide_template', template, id) } -const getTideTemplateById = async (id: string): Promise => { - const ebbDb = await getEbbDb() - const [template] = await ebbDb.select( - 'SELECT * FROM tide_template WHERE id = ?', - [id] - ) - return template -} - const getAllTideTemplates = async (): Promise => { const ebbDb = await getEbbDb() return await ebbDb.select( @@ -163,60 +90,13 @@ const getAllTideTemplates = async (): Promise => { ) } -const getTideTemplatesByFrequency = async (frequency: string): Promise => { - const ebbDb = await getEbbDb() - return await ebbDb.select( - 'SELECT * FROM tide_template WHERE tide_frequency = ? ORDER BY created_at DESC', - [frequency] - ) -} - -// Combined queries - -const getTidesWithTemplates = async (limit = 10): Promise => { - const ebbDb = await getEbbDb() - - const query = ` - SELECT - t.*, - json_object( - 'id', tt.id, - 'metrics_type', tt.metrics_type, - 'tide_frequency', tt.tide_frequency, - 'first_tide', tt.first_tide, - 'day_of_week', tt.day_of_week, - 'goal_amount', tt.goal_amount, - 'created_at', tt.created_at, - 'updated_at', tt.updated_at - ) as template - FROM tide t - LEFT JOIN tide_template tt ON t.tide_template_id = tt.id - ORDER BY t.start DESC - LIMIT ? - ` - - return await ebbDb.select(query, [limit]) -} - export const TideRepo = { // Tide operations - createTide, updateTide, - getTideById, getActiveTides, getActiveTidesForPeriod, - getTidesByFrequency, - getRecentTides, - completeTide, - updateTideProgress, // Tide template operations - createTideTemplate, updateTideTemplate, - getTideTemplateById, getAllTideTemplates, - getTideTemplatesByFrequency, - - // Combined operations - getTidesWithTemplates, } From a82716bfe0a703bdeaf7e0c6165e8659b702ccd1 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 14:47:58 -0600 Subject: [PATCH 36/40] fix reload on save and tide timezone error --- src/api/ebbApi/tideApi.ts | 3 ++- src/api/hooks/useTides.ts | 4 +--- src/components/TideEditDialog.tsx | 2 +- src/db/ebb/tideRepo.ts | 11 +++++------ 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index 76808624..8812e105 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -70,7 +70,7 @@ const updateTideTemplates = async (editedTemplates: TemplateEdit[]): Promise => { 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 => diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts index 121f56c6..523c071e 100644 --- a/src/api/hooks/useTides.ts +++ b/src/api/hooks/useTides.ts @@ -38,9 +38,7 @@ const useUpdateTideTemplates = () => { mutationFn: (editedTemplates: TemplateEdit[]) => TideApi.updateTideTemplates(editedTemplates), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: tideKeys.templates() }) - queryClient.invalidateQueries({ queryKey: tideKeys.active() }) - queryClient.invalidateQueries({ queryKey: tideKeys.overview() }) + queryClient.invalidateQueries({ queryKey: tideKeys.all }) }, }) } diff --git a/src/components/TideEditDialog.tsx b/src/components/TideEditDialog.tsx index f3fc2312..dd537149 100644 --- a/src/components/TideEditDialog.tsx +++ b/src/components/TideEditDialog.tsx @@ -109,7 +109,7 @@ export const TideEditDialog: FC = ({ const handleSave = async () => { try { await updateTemplatesMutation.mutateAsync(editedTemplates) - toast.success('Tide updated successfully') + toast.success('Tides going forward successfully updated to reflect changes.') onOpenChange(false) } catch (error) { console.error('Failed to save tide:', error) diff --git a/src/db/ebb/tideRepo.ts b/src/db/ebb/tideRepo.ts index d5b67e4c..52a86def 100644 --- a/src/db/ebb/tideRepo.ts +++ b/src/db/ebb/tideRepo.ts @@ -1,5 +1,5 @@ import { QueryResult } from '@tauri-apps/plugin-sql' -import { insert, update } from '@/lib/utils/sql.util' +import { update } from '@/lib/utils/sql.util' import { getEbbDb } from './ebbDb' export interface TideSchema { @@ -62,15 +62,14 @@ const getActiveTides = async (): Promise => { const getActiveTidesForPeriod = async (evaluationTime: string): Promise => { const ebbDb = await getEbbDb() - // Get tides that overlap with the evaluation time const query = ` SELECT * FROM tide - WHERE start <= ? - AND (end IS NULL OR end > ?) + WHERE datetime(start) <= datetime(?) + AND (end IS NULL OR datetime(end) > datetime(?)) ORDER BY start DESC ` - - return await ebbDb.select(query, [evaluationTime, evaluationTime]) + const tides = await ebbDb.select(query, [evaluationTime, evaluationTime]) + return tides } // Tide Template Repository Functions From 046616420af8400b0ad506ba64bdf776560f876f Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 15:50:00 -0600 Subject: [PATCH 37/40] add days of the week to view relative goals --- src/api/ebbApi/tideApi.ts | 49 +++++++++++++++++++++++++ src/api/hooks/useTides.ts | 11 ++++++ src/components/TideGoalsCard.tsx | 61 +++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/api/ebbApi/tideApi.ts b/src/api/ebbApi/tideApi.ts index 8812e105..69711209 100644 --- a/src/api/ebbApi/tideApi.ts +++ b/src/api/ebbApi/tideApi.ts @@ -163,6 +163,54 @@ const getTideOverview = async (date = new Date()): Promise => { } } +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) @@ -201,6 +249,7 @@ export const TideApi = { // Business logic getTideOverview, + getWeeklyDailyHistory, // Utilities formatTime, diff --git a/src/api/hooks/useTides.ts b/src/api/hooks/useTides.ts index 523c071e..28c2558b 100644 --- a/src/api/hooks/useTides.ts +++ b/src/api/hooks/useTides.ts @@ -8,6 +8,7 @@ const tideKeys = { 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 @@ -29,6 +30,15 @@ const useGetTideTemplates = () => { }) } +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 = () => { @@ -46,5 +56,6 @@ const useUpdateTideTemplates = () => { export const useTides = { useGetTideOverview, useGetTideTemplates, + useGetWeeklyDailyHistory, useUpdateTideTemplates, } diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 4188cecd..202308b5 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -4,6 +4,7 @@ import { PieChart, Pie, Cell } from 'recharts' import { Skeleton } from '@/components/ui/skeleton' import { TideEditDialog } from '@/components/TideEditDialog' import { useTides } from '../api/hooks/useTides' +import { DateTime } from 'luxon' interface TideGoalsCardProps { date?: Date } @@ -15,14 +16,70 @@ export const TideGoalsCard: FC = ({ 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 + const isLoading = isTidesLoading || isHistoryLoading const hasError = tideError const handleEditClick = () => { setEditDialogOpen(true) } + const renderWeeklyProgress = () => { + if (!weeklyHistory || weeklyHistory.length === 0) return null + + const dayNames = ['S', 'M', 'Tu', 'W', 'Th', 'F', 'S'] + const today = DateTime.fromJSDate(date).startOf('day') + + return ( +
+ {weeklyHistory.map((day) => { + const dayDate = DateTime.fromISO(day.date).startOf('day') + const isToday = dayDate.hasSame(today, 'day') + const isFuture = dayDate > today + + const fillPercentage = day.progress.goal > 0 + ? Math.min((day.progress.current / day.progress.goal) * 100, 100) + : 0 + + return ( +
+
+ {/* Background circle */} + + + {/* Progress circle */} + {fillPercentage > 0 && ( + + )} + +
+ + {dayNames[day.dayOfWeek]} + +
+ ) + })} +
+ ) + } + // Format time helper const formatTime = (minutes: number, options: { overrideShowHours: boolean } = { overrideShowHours: false }) => { const hours = Math.floor(minutes / 60) @@ -267,6 +324,8 @@ export const TideGoalsCard: FC = ({
Creating Time Target: {formatTime(goal)}
+ {/* Weekly progress for daily goals */} + {renderWeeklyProgress()}
) From 5fa8366f0025fc1c38d7da7017599897b72949eb Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 16:13:01 -0600 Subject: [PATCH 38/40] alllow selecting from the list of days to view your time --- src/components/TideGoalsCard.tsx | 29 +++++++++++++++++++----- src/components/UsageSummaryWithTides.tsx | 4 +++- src/pages/HomePage.tsx | 1 + 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index 202308b5..f3128f72 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -7,10 +7,12 @@ 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() + date = new Date(), + onDateChange }) => { const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily') const [editDialogOpen, setEditDialogOpen] = useState(false) @@ -29,21 +31,36 @@ export const TideGoalsCard: FC = ({ if (!weeklyHistory || weeklyHistory.length === 0) return null const dayNames = ['S', 'M', 'Tu', 'W', 'Th', 'F', 'S'] - const today = DateTime.fromJSDate(date).startOf('day') + 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 isToday = dayDate.hasSame(today, 'day') - const isFuture = dayDate > today + 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 handleDayClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (isFuture) return + if (onDateChange) { + const clickedDate = DateTime.fromISO(day.date).toJSDate() + onDateChange(clickedDate) + } + } + return ( -
+
{/* Background circle */} @@ -70,7 +87,7 @@ export const TideGoalsCard: FC = ({ )}
- + {dayNames[day.dayOfWeek]}
diff --git a/src/components/UsageSummaryWithTides.tsx b/src/components/UsageSummaryWithTides.tsx index 22f6f9a6..1287a02d 100644 --- a/src/components/UsageSummaryWithTides.tsx +++ b/src/components/UsageSummaryWithTides.tsx @@ -82,6 +82,7 @@ export interface UsageSummaryWithTidesProps { yAxisMax?: number; rangeMode: 'day' | 'week' | 'month'; date: Date; + setDate?: (date: Date) => void; lastUpdated?: Date | null; } @@ -94,6 +95,7 @@ export const UsageSummaryWithTides = ({ showIdleTime, rangeMode, date, + setDate, lastUpdated, setShowIdleTime, }: UsageSummaryWithTidesProps) => { @@ -154,7 +156,7 @@ export const UsageSummaryWithTides = ({ {/* Two-column layout: Goals card on left, Chart on right */}
{/* Tide Goals Card */} - + {/* Chart Card - spans 2 columns */} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index c6af5e28..2482694b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -102,6 +102,7 @@ export const HomePage = () => { setShowIdleTime={setShowIdleTime} rangeMode={rangeMode} date={date} + setDate={setDate} lastUpdated={lastUpdated} /> ) : ( From bcbe66361bf7d0d6e98f85374bd31e994883c001 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 16:43:07 -0600 Subject: [PATCH 39/40] Add the tides completed badge --- src/components/TideGoalsCard.tsx | 50 +++++++++--------- src/components/icons/TideCompletedBadge.tsx | 56 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 src/components/icons/TideCompletedBadge.tsx diff --git a/src/components/TideGoalsCard.tsx b/src/components/TideGoalsCard.tsx index f3128f72..958b40df 100644 --- a/src/components/TideGoalsCard.tsx +++ b/src/components/TideGoalsCard.tsx @@ -3,6 +3,7 @@ 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 { @@ -30,7 +31,7 @@ export const TideGoalsCard: FC = ({ const renderWeeklyProgress = () => { if (!weeklyHistory || weeklyHistory.length === 0) return null - const dayNames = ['S', 'M', 'Tu', 'W', 'Th', 'F', 'S'] + const dayNames = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'] const selectedDay = DateTime.fromJSDate(date).startOf('day') const currentDay = DateTime.now().startOf('day') @@ -44,6 +45,7 @@ export const TideGoalsCard: FC = ({ 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() @@ -61,31 +63,27 @@ export const TideGoalsCard: FC = ({ onClick={handleDayClick} title={isFuture ? 'Future date' : `View ${dayNames[day.dayOfWeek]} (${day.date})`} > -
- {/* Background circle */} - - - {/* Progress circle */} - {fillPercentage > 0 && ( - - )} - +
+ {isCompleted ? ( + + ) : ( + // Regular progress circle for incomplete days + + + {fillPercentage > 0 && ( + + )} + + )}
{dayNames[day.dayOfWeek]} 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 */} + + + +) From 2adce248f4c81566bae0be7cd30bd8882ed56f34 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Thu, 2 Oct 2025 16:51:09 -0600 Subject: [PATCH 40/40] Add total time to graph --- src/components/UsageSummaryWithTides.tsx | 17 +++++++++++------ src/pages/HomePage.tsx | 1 + 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/UsageSummaryWithTides.tsx b/src/components/UsageSummaryWithTides.tsx index 1287a02d..9c362cb6 100644 --- a/src/components/UsageSummaryWithTides.tsx +++ b/src/components/UsageSummaryWithTides.tsx @@ -84,6 +84,7 @@ export interface UsageSummaryWithTidesProps { date: Date; setDate?: (date: Date) => void; lastUpdated?: Date | null; + totalTime: { value: number; trend: { percent: number; direction: 'up' | 'down' | 'none' } }; } @@ -97,6 +98,7 @@ export const UsageSummaryWithTides = ({ date, setDate, lastUpdated, + totalTime, setShowIdleTime, }: UsageSummaryWithTidesProps) => { const { user } = useAuth() @@ -286,13 +288,16 @@ export const UsageSummaryWithTides = ({
)} - {lastUpdated && ( -
-
- Last updated: {formatLastUpdated(lastUpdated)} -
+
+
+ Total Time Active: {formatTime(totalTime.value)}
- )} + {lastUpdated && ( +
+ Last updated: {formatLastUpdated(lastUpdated)} +
+ )} +
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 2482694b..26ab6ed4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -104,6 +104,7 @@ export const HomePage = () => { date={date} setDate={setDate} lastUpdated={lastUpdated} + totalTime={totalTime} /> ) : (