wip
This commit is contained in:
parent
08b69851cd
commit
5e160ae11c
65 changed files with 3569 additions and 315 deletions
426
Cargo.lock
generated
426
Cargo.lock
generated
|
|
@ -226,6 +226,23 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
|
|
@ -310,6 +327,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
|
|
@ -678,8 +704,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -689,8 +717,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -851,6 +882,23 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -859,13 +907,21 @@ version = "0.1.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1013,6 +1069,12 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
|
|
@ -1111,6 +1173,12 @@ version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -1189,7 +1257,7 @@ dependencies = [
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
@ -1397,6 +1465,62 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.3",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|
@ -1420,7 +1544,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1430,7 +1565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1442,6 +1577,21 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -1497,6 +1647,58 @@ version = "0.8.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
|
|
@ -1524,7 +1726,7 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -1541,6 +1743,12 @@ dependencies = [
|
||||||
"ordered-multimap",
|
"ordered-multimap",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
|
|
@ -1554,6 +1762,41 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -1717,7 +1960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1728,7 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1764,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1927,7 +2170,7 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|
@ -1966,7 +2209,7 @@ dependencies = [
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.7",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
@ -2035,6 +2278,7 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
|
"reqwest",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -2080,6 +2324,9 @@ name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
|
|
@ -2195,6 +2442,16 @@ dependencies = [
|
||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
|
|
@ -2287,8 +2544,10 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2365,6 +2624,12 @@ dependencies = [
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|
@ -2422,6 +2687,12 @@ version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
|
|
@ -2458,6 +2729,15 @@ version = "0.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|
@ -2483,6 +2763,16 @@ dependencies = [
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.126"
|
version = "0.2.126"
|
||||||
|
|
@ -2515,6 +2805,35 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
|
|
@ -2590,7 +2909,16 @@ version = "0.48.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2608,13 +2936,29 @@ version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.48.5",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.48.5",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.48.5",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.48.5",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
"windows_x86_64_msvc",
|
"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",
|
||||||
|
"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]]
|
[[package]]
|
||||||
|
|
@ -2623,42 +2967,90 @@ version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
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_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
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]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ axum = { version = "0.8.9", features = ["macros"] }
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
config = "0.15.23"
|
config = "0.15.23"
|
||||||
schemars = { version = "0.9.0", features = ["chrono04"] }
|
schemars = { version = "0.9.0", features = ["chrono04"] }
|
||||||
|
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
sqlx = { version = "0.8.6", features = [
|
sqlx = { version = "0.8.6", features = [
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ key, keep it up to date. Every value can also come from the environment
|
||||||
│ ├── services/ implementations of the repositories (postgres/sqlx)
|
│ ├── services/ implementations of the repositories (postgres/sqlx)
|
||||||
│ └── utils/ configuration
|
│ └── utils/ configuration
|
||||||
├── LaTeX_pdf/ template of the lecture notes pdf (pandoc + pdflatex)
|
├── LaTeX_pdf/ template of the lecture notes pdf (pandoc + pdflatex)
|
||||||
├── storage/ files of the courses, stands in for the NAS (gitignored)
|
├── storage/ files of the courses and associations, one folder each,
|
||||||
|
│ stands in for the NAS (gitignored)
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── migrations/ dbmate migrations
|
│ ├── migrations/ dbmate migrations
|
||||||
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
|
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ postgres:
|
||||||
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
# Directory containing the built frontend (frontend/dist after `npm run build`)
|
||||||
frontend_dir: frontend/dist
|
frontend_dir: frontend/dist
|
||||||
|
|
||||||
# Where the files of the courses are stored (lecture notes and their pdf...).
|
# Where the files of the courses and associations are stored (lecture notes and their
|
||||||
|
# pdf...), one folder each: Cours/CS-477 Advanced OS/Notes/..., Associations/...
|
||||||
# A local directory standing in for the NAS for now.
|
# A local directory standing in for the NAS for now.
|
||||||
storage_dir: storage
|
storage_dir: storage
|
||||||
|
|
||||||
|
|
@ -27,3 +28,12 @@ notes:
|
||||||
# Shown in the header of the pdf, both optional
|
# Shown in the header of the pdf, both optional
|
||||||
student_name: Jane Doe
|
student_name: Jane Doe
|
||||||
student_id: "123456"
|
student_id: "123456"
|
||||||
|
|
||||||
|
# Optional: the focus sessions of the pomodoro are also counted in TimeTagger, tagged
|
||||||
|
# with the course code, the tags and the name of the task. Without it, the time is only
|
||||||
|
# counted in the app.
|
||||||
|
# timetagger:
|
||||||
|
# # https://timetagger.app/api/v2 by default, or the api of your own server
|
||||||
|
# url: https://timetagger.app/api/v2
|
||||||
|
# # Api token, from the account page of TimeTagger
|
||||||
|
# token: ...
|
||||||
|
|
|
||||||
41
db/migrations/20260929180000_associations_focus.sql
Normal file
41
db/migrations/20260929180000_associations_focus.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
-- Associations are managed like courses: same tasks, files and slots, without grades.
|
||||||
|
ALTER TABLE courses ADD COLUMN kind TEXT NOT NULL DEFAULT 'course'
|
||||||
|
CHECK (kind IN ('course', 'association'));
|
||||||
|
ALTER TABLE courses ALTER COLUMN kind DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Folder of the course in the storage (the NAS), relative to its root, named after the
|
||||||
|
-- course: `Cours/CS-477 Advanced operating systems`. Moved when the course is renamed.
|
||||||
|
-- The existing folders were named after the ids and are not moved by this migration.
|
||||||
|
ALTER TABLE courses ADD COLUMN folder TEXT;
|
||||||
|
UPDATE courses SET folder = 'Cours/' || trim(regexp_replace(
|
||||||
|
regexp_replace(COALESCE(code || ' ', '') || "name", '[/\\:*?"<>|]', '-', 'g'),
|
||||||
|
'\s+', ' ', 'g'));
|
||||||
|
ALTER TABLE courses ALTER COLUMN folder SET NOT NULL;
|
||||||
|
ALTER TABLE courses ADD CONSTRAINT courses_folder_key UNIQUE (folder);
|
||||||
|
|
||||||
|
-- Path of a file in the folder of its course, without extension: `Notes/Lecture 1 Notes`
|
||||||
|
-- (the note is `Notes/Lecture 1 Notes.md` and its pdf `Notes/Lecture 1 Notes.pdf`)
|
||||||
|
ALTER TABLE course_files ADD COLUMN "path" TEXT;
|
||||||
|
UPDATE course_files SET "path" = 'Notes/' || trim(regexp_replace(
|
||||||
|
regexp_replace("name", '[/\\:*?"<>|]', '-', 'g'), '\s+', ' ', 'g'));
|
||||||
|
ALTER TABLE course_files ALTER COLUMN "path" SET NOT NULL;
|
||||||
|
ALTER TABLE course_files ADD CONSTRAINT course_files_course_id_path_key UNIQUE (course_id, "path");
|
||||||
|
|
||||||
|
-- Time spent working on a task, counted by the pomodoro
|
||||||
|
ALTER TABLE tasks ADD COLUMN time_spent INTEGER NOT NULL DEFAULT 0 CHECK (time_spent >= 0);
|
||||||
|
|
||||||
|
-- Kinds of the slots and tasks of the associations
|
||||||
|
INSERT INTO tags ("name", color, scopes, slug) VALUES
|
||||||
|
('{"fr": "Réunion", "en": "Meeting"}', '#db2777', '{slot,task}', 'meeting'),
|
||||||
|
('{"fr": "Événement", "en": "Event"}', '#4f46e5', '{slot,task}', 'event');
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DELETE FROM courses WHERE kind = 'association';
|
||||||
|
DELETE FROM time_slots WHERE tag_id IN (SELECT id FROM tags WHERE slug IN ('meeting', 'event'));
|
||||||
|
DELETE FROM tags WHERE slug IN ('meeting', 'event');
|
||||||
|
ALTER TABLE tasks DROP COLUMN time_spent;
|
||||||
|
ALTER TABLE course_files DROP COLUMN "path";
|
||||||
|
ALTER TABLE courses DROP COLUMN folder;
|
||||||
|
ALTER TABLE courses DROP COLUMN kind;
|
||||||
|
|
@ -31,6 +31,7 @@ CREATE TABLE public.course_files (
|
||||||
created_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_created_at_not_null NOT NULL,
|
created_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_created_at_not_null NOT NULL,
|
||||||
updated_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_updated_at_not_null NOT NULL,
|
updated_at timestamp with time zone DEFAULT now() CONSTRAINT lecture_notes_updated_at_not_null NOT NULL,
|
||||||
kind text NOT NULL,
|
kind text NOT NULL,
|
||||||
|
path text NOT NULL,
|
||||||
CONSTRAINT course_files_kind_check CHECK ((kind = ANY (ARRAY['note'::text, 'upload'::text])))
|
CONSTRAINT course_files_kind_check CHECK ((kind = ANY (ARRAY['note'::text, 'upload'::text])))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -64,7 +65,10 @@ CREATE TABLE public.courses (
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
code text,
|
code text,
|
||||||
teachers text[] DEFAULT '{}'::text[] NOT NULL,
|
teachers text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
main_page_url text
|
main_page_url text,
|
||||||
|
kind text NOT NULL,
|
||||||
|
folder text NOT NULL,
|
||||||
|
CONSTRAINT courses_kind_check CHECK ((kind = ANY (ARRAY['course'::text, 'association'::text])))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -201,7 +205,9 @@ CREATE TABLE public.tasks (
|
||||||
description text,
|
description text,
|
||||||
links text[] DEFAULT '{}'::text[] NOT NULL,
|
links text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
deadline timestamp with time zone,
|
deadline timestamp with time zone,
|
||||||
done boolean DEFAULT false NOT NULL
|
done boolean DEFAULT false NOT NULL,
|
||||||
|
time_spent integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT tasks_time_spent_check CHECK ((time_spent >= 0))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -303,6 +309,14 @@ ALTER TABLE ONLY public.tasks ALTER COLUMN id SET DEFAULT nextval('public.tasks_
|
||||||
ALTER TABLE ONLY public.time_slots ALTER COLUMN id SET DEFAULT nextval('public.time_slots_id_seq'::regclass);
|
ALTER TABLE ONLY public.time_slots ALTER COLUMN id SET DEFAULT nextval('public.time_slots_id_seq'::regclass);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: course_files course_files_course_id_path_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.course_files
|
||||||
|
ADD CONSTRAINT course_files_course_id_path_key UNIQUE (course_id, path);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: course_files course_files_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
-- Name: course_files course_files_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -311,6 +325,14 @@ ALTER TABLE ONLY public.course_files
|
||||||
ADD CONSTRAINT course_files_pkey PRIMARY KEY (id);
|
ADD CONSTRAINT course_files_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: courses courses_folder_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.courses
|
||||||
|
ADD CONSTRAINT courses_folder_key UNIQUE (folder);
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
@ -505,4 +527,5 @@ ALTER TABLE ONLY public.time_slots
|
||||||
INSERT INTO public.schema_migrations (version) VALUES
|
INSERT INTO public.schema_migrations (version) VALUES
|
||||||
('20260928120000'),
|
('20260928120000'),
|
||||||
('20260928150000'),
|
('20260928150000'),
|
||||||
('20260929090000');
|
('20260929090000'),
|
||||||
|
('20260929180000');
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,14 @@ import {
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||||
|
import { formatDuration, useFocus } from '@/services/focus'
|
||||||
import { locales, setLocale } from '@/services/i18n'
|
import { locales, setLocale } from '@/services/i18n'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Pause, Timer } from '@lucide/vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { locale: currentLocale } = useI18n()
|
const { locale: currentLocale } = useI18n()
|
||||||
|
const { session, elapsed, isRunning } = useFocus()
|
||||||
|
|
||||||
const selectedLocale = computed(
|
const selectedLocale = computed(
|
||||||
() => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
|
() => locales.find((l) => l.lang === currentLocale.value) ?? locales[0],
|
||||||
|
|
@ -30,7 +33,21 @@ const selectedLocale = computed(
|
||||||
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<h1 class="font-medium">{{ route.meta.title ? $t(route.meta.title) : '' }}</h1>
|
<h1 class="font-medium">{{ route.meta.title ? $t(route.meta.title) : '' }}</h1>
|
||||||
|
|
||||||
<ThemeToggle class="ml-auto" />
|
<!-- The session running, from any other page -->
|
||||||
|
<RouterLink
|
||||||
|
v-if="session && route.name !== 'pomodoro'"
|
||||||
|
:to="{ name: 'pomodoro' }"
|
||||||
|
class="ml-auto flex max-w-56 items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors hover:bg-accent"
|
||||||
|
:title="$t('focus.back')"
|
||||||
|
>
|
||||||
|
<Timer v-if="isRunning" class="size-4 shrink-0 animate-pulse text-primary" />
|
||||||
|
<Pause v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span class="font-mono tabular-nums">{{ formatDuration(elapsed) }}</span>
|
||||||
|
<span class="hidden truncate text-muted-foreground sm:inline">
|
||||||
|
{{ session.task?.name ?? $t('focus.free') }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
<ThemeToggle :class="{ 'ml-auto': !session || route.name === 'pomodoro' }" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="outline" class="rounded-lg">
|
<Button variant="outline" class="rounded-lg">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Month calendar of the deadlines of every course. On a phone the days only show dots:
|
* Month calendar of the deadlines of the courses and associations of `kinds`. On a phone
|
||||||
* the tasks of the selected day are listed under the calendar, on every screen.
|
* the days only show dots: the tasks of the selected day are listed under the calendar,
|
||||||
|
* on every screen. `create` asks for a new task due on the selected day.
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { CalendarCheck, ChevronLeft, ChevronRight } from '@lucide/vue'
|
import { CalendarCheck, ChevronLeft, ChevronRight, Plus, Timer } from '@lucide/vue'
|
||||||
|
|
||||||
import TagBadge from '@/components/course/TagBadge.vue'
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -14,7 +15,11 @@ import { useTags } from '@/services/api/tags'
|
||||||
import { useCalendarTasks } from '@/services/api/tasks'
|
import { useCalendarTasks } from '@/services/api/tasks'
|
||||||
import { dayKey, monthWeeks, weeksPeriod } from '@/utils/calendar'
|
import { dayKey, monthWeeks, weeksPeriod } from '@/utils/calendar'
|
||||||
import { isPast } from '@/utils/dates'
|
import { isPast } from '@/utils/dates'
|
||||||
import type { CalendarTask } from '@/utils/types'
|
import { coursePageRoute, focusRoute, tasksTab } from '@/utils/routes'
|
||||||
|
import type { CalendarTask, CourseKind } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ kinds: CourseKind[] }>()
|
||||||
|
const emit = defineEmits<{ create: [day: Date] }>()
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
const { tagsById } = useTags()
|
const { tagsById } = useTags()
|
||||||
|
|
@ -38,6 +43,7 @@ const {
|
||||||
const tasksByDay = computed(() => {
|
const tasksByDay = computed(() => {
|
||||||
const byDay = new Map<string, CalendarTask[]>()
|
const byDay = new Map<string, CalendarTask[]>()
|
||||||
for (const task of tasks.value ?? []) {
|
for (const task of tasks.value ?? []) {
|
||||||
|
if (!props.kinds.includes(task.course_kind)) continue
|
||||||
const key = dayKey(new Date(task.deadline))
|
const key = dayKey(new Date(task.deadline))
|
||||||
byDay.set(key, [...(byDay.get(key) ?? []), task])
|
byDay.set(key, [...(byDay.get(key) ?? []), task])
|
||||||
}
|
}
|
||||||
|
|
@ -54,12 +60,13 @@ const weekdayLabels = computed(() =>
|
||||||
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(day),
|
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(day),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const selectedLabel = computed(() => {
|
const selectedDate = computed(() => {
|
||||||
const [year, m, d] = selectedDay.value.split('-').map(Number)
|
const [year, m, d] = selectedDay.value.split('-').map(Number)
|
||||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'full' }).format(
|
return new Date(year!, m! - 1, d)
|
||||||
new Date(year!, m! - 1, d),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
const selectedLabel = computed(() =>
|
||||||
|
new Intl.DateTimeFormat(locale.value, { dateStyle: 'full' }).format(selectedDate.value),
|
||||||
|
)
|
||||||
|
|
||||||
function moveMonth(offset: number) {
|
function moveMonth(offset: number) {
|
||||||
const next = new Date(month.value.getFullYear(), month.value.getMonth() + offset, 1)
|
const next = new Date(month.value.getFullYear(), month.value.getMonth() + offset, 1)
|
||||||
|
|
@ -91,7 +98,7 @@ function time(task: CalendarTask) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskLink(task: CalendarTask) {
|
function taskLink(task: CalendarTask) {
|
||||||
return { name: 'course', params: { id: task.course_id }, query: { tab: 'tasks' } }
|
return coursePageRoute({ id: task.course_id, kind: task.course_kind }, tasksTab(task.course_kind))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -208,8 +215,12 @@ function taskLink(task: CalendarTask) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="flex flex-row items-center justify-between gap-2">
|
||||||
<CardTitle class="text-base first-letter:uppercase">{{ selectedLabel }}</CardTitle>
|
<CardTitle class="text-base first-letter:uppercase">{{ selectedLabel }}</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" @click="emit('create', selectedDate)">
|
||||||
|
<Plus />
|
||||||
|
{{ $t('calendar.add-that-day') }}
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<div
|
||||||
|
|
@ -220,10 +231,10 @@ function taskLink(task: CalendarTask) {
|
||||||
{{ $t('calendar.nothing') }}
|
{{ $t('calendar.nothing') }}
|
||||||
</div>
|
</div>
|
||||||
<ul v-else class="flex flex-col gap-3">
|
<ul v-else class="flex flex-col gap-3">
|
||||||
<li v-for="task in selectedTasks" :key="task.task_id">
|
<li v-for="task in selectedTasks" :key="task.task_id" class="flex items-start gap-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="taskLink(task)"
|
:to="taskLink(task)"
|
||||||
class="flex flex-col gap-1 border-l-2 pl-3 hover:underline"
|
class="flex min-w-0 flex-1 flex-col gap-1 border-l-2 pl-3 hover:underline"
|
||||||
:style="{ borderLeftColor: taskColor(task) }"
|
:style="{ borderLeftColor: taskColor(task) }"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -245,6 +256,19 @@ function taskLink(task: CalendarTask) {
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<Button
|
||||||
|
v-if="!task.done"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="shrink-0"
|
||||||
|
:title="$t('focus.start')"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink :to="focusRoute(task.task_id)">
|
||||||
|
<Timer />
|
||||||
|
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
185
frontend/src/components/calendar/TodoList.vue
Normal file
185
frontend/src/components/calendar/TodoList.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Everything left to do in the courses and associations of `kinds`: the late tasks,
|
||||||
|
* today's, this week's, the later ones and the ones without deadline. Each task (and
|
||||||
|
* subtask) can be checked, or worked on with the pomodoro.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { ListTodo, Timer } from '@lucide/vue'
|
||||||
|
|
||||||
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { toTaskBody, useAnyTaskMutations, useTodoTasks } from '@/services/api/tasks'
|
||||||
|
import { useFocus } from '@/services/focus'
|
||||||
|
import { dayKey } from '@/utils/calendar'
|
||||||
|
import { formatDateTime, formatRelative, isPast } from '@/utils/dates'
|
||||||
|
import { coursePageRoute, focusRoute, tasksTab } from '@/utils/routes'
|
||||||
|
import type { CourseKind, Task, TodoTask } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ kinds: CourseKind[] }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
const { data, isPending, isError, refetch } = useTodoTasks()
|
||||||
|
const { update } = useAnyTaskMutations()
|
||||||
|
const { session } = useFocus()
|
||||||
|
|
||||||
|
const GROUPS = ['late', 'today', 'week', 'later', 'undated'] as const
|
||||||
|
type Group = (typeof GROUPS)[number]
|
||||||
|
|
||||||
|
function groupOf(task: Task): Group {
|
||||||
|
if (!task.deadline) return 'undated'
|
||||||
|
if (isPast(task.deadline)) return 'late'
|
||||||
|
const deadline = new Date(task.deadline)
|
||||||
|
if (dayKey(deadline) === dayKey(new Date())) return 'today'
|
||||||
|
return deadline.getTime() - Date.now() < 7 * 24 * 3600 * 1000 ? 'week' : 'later'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Already sorted by deadline by the backend */
|
||||||
|
const groups = computed(() => {
|
||||||
|
const byGroup = new Map<Group, TodoTask[]>()
|
||||||
|
for (const todo of data.value ?? []) {
|
||||||
|
if (!props.kinds.includes(todo.course.kind)) continue
|
||||||
|
const group = groupOf(todo.task)
|
||||||
|
byGroup.set(group, [...(byGroup.get(group) ?? []), todo])
|
||||||
|
}
|
||||||
|
return GROUPS.filter((group) => byGroup.has(group)).map((group) => ({
|
||||||
|
group,
|
||||||
|
todos: byGroup.get(group)!,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function setDone(courseId: number, task: Task, done: boolean) {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ courseId, id: task.id, body: toTaskBody(task, { done }) })
|
||||||
|
} catch {
|
||||||
|
toast.error(t('tasks.save-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inFocus(task: Task) {
|
||||||
|
return session.value?.task?.task_id === task.id
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-if="isPending" class="flex flex-col gap-2">
|
||||||
|
<Skeleton v-for="i in 4" :key="i" class="h-14 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="isError" class="text-sm text-destructive">
|
||||||
|
{{ $t('tasks.load-error') }}
|
||||||
|
<Button variant="link" size="sm" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!groups.length"
|
||||||
|
class="flex flex-col items-center gap-2 py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ListTodo class="size-6" />
|
||||||
|
{{ $t('calendar.todo.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-for="{ group, todos } in groups" v-else :key="group" class="flex flex-col gap-2">
|
||||||
|
<h3
|
||||||
|
class="text-xs font-semibold tracking-wide uppercase"
|
||||||
|
:class="group === 'late' ? 'text-destructive' : 'text-muted-foreground'"
|
||||||
|
>
|
||||||
|
{{ $t(`calendar.todo.${group}`) }}
|
||||||
|
<span class="font-normal">· {{ todos.length }}</span>
|
||||||
|
</h3>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="{ course, task } in todos"
|
||||||
|
:key="task.id"
|
||||||
|
class="flex flex-col gap-1.5 rounded-lg border bg-card p-2.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2.5">
|
||||||
|
<Checkbox
|
||||||
|
class="mt-0.5"
|
||||||
|
:model-value="task.done"
|
||||||
|
:aria-label="$t('tasks.done')"
|
||||||
|
@update:model-value="setDone(course.id, task, $event === true)"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<RouterLink
|
||||||
|
:to="coursePageRoute(course, tasksTab(course.kind))"
|
||||||
|
class="text-sm leading-snug font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{{ task.name }}
|
||||||
|
</RouterLink>
|
||||||
|
<span class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span class="font-mono">{{ course.code ?? course.name }}</span>
|
||||||
|
<template v-if="task.deadline">
|
||||||
|
·
|
||||||
|
<span
|
||||||
|
:class="{ 'font-medium text-destructive': group === 'late' }"
|
||||||
|
:title="formatDateTime(task.deadline, locale)"
|
||||||
|
>
|
||||||
|
{{ formatRelative(task.deadline, locale) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="shrink-0"
|
||||||
|
:class="{ 'text-primary': inFocus(task) }"
|
||||||
|
:title="$t('focus.start')"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink :to="focusRoute(task.id)">
|
||||||
|
<Timer :class="{ 'animate-pulse': inFocus(task) }" />
|
||||||
|
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="task.subtasks.length" class="ml-6.5 flex flex-col gap-0.5 border-l pl-2.5">
|
||||||
|
<li
|
||||||
|
v-for="subtask in task.subtasks"
|
||||||
|
:key="subtask.id"
|
||||||
|
class="group flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:model-value="subtask.done"
|
||||||
|
:aria-label="$t('tasks.done')"
|
||||||
|
@update:model-value="setDone(course.id, subtask, $event === true)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 truncate"
|
||||||
|
:class="{ 'text-muted-foreground line-through': subtask.done }"
|
||||||
|
>
|
||||||
|
{{ subtask.name }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="!subtask.done"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-7 shrink-0 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||||
|
:class="{ 'text-primary opacity-100': inFocus(subtask) }"
|
||||||
|
:title="$t('focus.start')"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink :to="focusRoute(subtask.id)">
|
||||||
|
<Timer :class="{ 'animate-pulse': inFocus(subtask) }" />
|
||||||
|
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
190
frontend/src/components/course/AssociationDeadlinesTab.vue
Normal file
190
frontend/src/components/course/AssociationDeadlinesTab.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Deadlines of an association: its week, with its slots (added by selecting a time range)
|
||||||
|
* and the deadlines of each day, then the list of its tasks.
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
|
import CourseTasksTab from '@/components/course/CourseTasksTab.vue'
|
||||||
|
import SlotFormDialog from '@/components/schedule/SlotFormDialog.vue'
|
||||||
|
import WeekTimetable from '@/components/timetable/WeekTimetable.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDeleteSlot } from '@/services/api/courses'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { useCalendarTasks } from '@/services/api/tasks'
|
||||||
|
import { dayKey, startOfWeek, weeksPeriod } from '@/utils/calendar'
|
||||||
|
import { isPast } from '@/utils/dates'
|
||||||
|
import { toHourMinutes, type SlotSelection } from '@/utils/timetable'
|
||||||
|
import type { CalendarTask, Course, TimeSlot } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ course: Course }>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
const deleteSlot = useDeleteSlot()
|
||||||
|
|
||||||
|
const courses = computed(() => [props.course])
|
||||||
|
|
||||||
|
/** Monday of the week shown */
|
||||||
|
const monday = ref(startOfWeek(new Date()))
|
||||||
|
/** The 7 days of the week, the timetable shows the first 5 */
|
||||||
|
const week = computed(() =>
|
||||||
|
Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const day = new Date(monday.value)
|
||||||
|
day.setDate(day.getDate() + i)
|
||||||
|
return day
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function moveWeek(offset: number) {
|
||||||
|
const next = new Date(monday.value)
|
||||||
|
next.setDate(next.getDate() + offset * 7)
|
||||||
|
monday.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekLabel = computed(() => {
|
||||||
|
const format = new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'long' })
|
||||||
|
return `${format.format(week.value[0]!)} – ${format.format(week.value[6]!)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: tasks } = useCalendarTasks(() => weeksPeriod([week.value]))
|
||||||
|
|
||||||
|
const tasksByDay = computed(() => {
|
||||||
|
const byDay = new Map<string, CalendarTask[]>()
|
||||||
|
for (const task of tasks.value ?? []) {
|
||||||
|
if (task.course_id !== props.course.id) continue
|
||||||
|
const key = dayKey(new Date(task.deadline))
|
||||||
|
byDay.set(key, [...(byDay.get(key) ?? []), task])
|
||||||
|
}
|
||||||
|
return byDay
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Deadlines of the weekend, not shown by the timetable */
|
||||||
|
const weekendTasks = computed(() =>
|
||||||
|
week.value.slice(5).flatMap((day) => tasksByDay.value.get(dayKey(day)) ?? []),
|
||||||
|
)
|
||||||
|
|
||||||
|
function taskColor(task: CalendarTask) {
|
||||||
|
return tagsById.value.get(task.tag_ids[0] ?? -1)?.color ?? 'var(--primary)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function time(task: CalendarTask) {
|
||||||
|
return new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(
|
||||||
|
new Date(task.deadline),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotFormOpen = ref(false)
|
||||||
|
const slotSelection = ref<SlotSelection>()
|
||||||
|
|
||||||
|
function onSelect(selection: SlotSelection) {
|
||||||
|
slotSelection.value = selection
|
||||||
|
slotFormOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteOpen = ref(false)
|
||||||
|
const deletedSlot = ref<TimeSlot>()
|
||||||
|
const deletedSlotLabel = computed(() => {
|
||||||
|
const slot = deletedSlot.value
|
||||||
|
if (!slot) return ''
|
||||||
|
return `${t(`weekdays.${slot.weekday}`)} ${toHourMinutes(slot.start_time)}–${toHourMinutes(slot.end_time)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function askDeleteSlot(slot: TimeSlot) {
|
||||||
|
deletedSlot.value = slot
|
||||||
|
deleteOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteSlot() {
|
||||||
|
if (!deletedSlot.value) return
|
||||||
|
try {
|
||||||
|
await deleteSlot.mutateAsync({ courseId: props.course.id, slotId: deletedSlot.value.id })
|
||||||
|
toast.success(t('schedule.slot-deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('schedule.slot-delete-error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
|
<section class="flex min-w-0 flex-col gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="mr-auto font-medium first-letter:uppercase">{{ weekLabel }}</h3>
|
||||||
|
<Button variant="outline" size="sm" @click="monday = startOfWeek(new Date())">
|
||||||
|
{{ $t('calendar.this-week') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('calendar.previous-week')"
|
||||||
|
@click="moveWeek(-1)"
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
<span class="sr-only">{{ $t('calendar.previous-week') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('calendar.next-week')"
|
||||||
|
@click="moveWeek(1)"
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
<span class="sr-only">{{ $t('calendar.next-week') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ $t('association.deadlines.hint') }}</p>
|
||||||
|
|
||||||
|
<WeekTimetable
|
||||||
|
:courses="courses"
|
||||||
|
:dates="week.slice(0, 5)"
|
||||||
|
height="34rem"
|
||||||
|
selectable
|
||||||
|
deletable
|
||||||
|
@select="onSelect"
|
||||||
|
@delete="askDeleteSlot"
|
||||||
|
>
|
||||||
|
<template #day="{ date }">
|
||||||
|
<span v-if="date" class="flex min-w-0 flex-col gap-0.5 text-left font-normal">
|
||||||
|
<span
|
||||||
|
v-for="task in tasksByDay.get(dayKey(date)) ?? []"
|
||||||
|
:key="task.task_id"
|
||||||
|
class="truncate rounded border-l-2 px-1 py-0.5 text-xs"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground line-through': task.done,
|
||||||
|
'font-medium text-destructive': !task.done && isPast(task.deadline),
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
borderLeftColor: taskColor(task),
|
||||||
|
backgroundColor: `color-mix(in oklab, ${taskColor(task)} 12%, transparent)`,
|
||||||
|
}"
|
||||||
|
:title="`${time(task)} · ${task.name}`"
|
||||||
|
>
|
||||||
|
<span class="tabular-nums opacity-70">{{ time(task) }}</span>
|
||||||
|
{{ task.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</WeekTimetable>
|
||||||
|
<p v-if="weekendTasks.length" class="text-sm text-muted-foreground">
|
||||||
|
{{ $t('association.deadlines.weekend') }}
|
||||||
|
<span v-for="task in weekendTasks" :key="task.task_id" class="ml-2 text-foreground">
|
||||||
|
{{ task.name }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CourseTasksTab :course-id="course.id" />
|
||||||
|
|
||||||
|
<SlotFormDialog v-model:open="slotFormOpen" :selection="slotSelection" :course-id="course.id" />
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model:open="deleteOpen"
|
||||||
|
:title="$t('schedule.delete-slot-title', { slot: deletedSlotLabel })"
|
||||||
|
@confirm="confirmDeleteSlot"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -18,14 +18,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||||
import { useCourseFiles, useLectureNoteMutations } from '@/services/api/course-files'
|
import { useCourseFiles, useLectureNoteMutations } from '@/services/api/course-files'
|
||||||
import { useTags } from '@/services/api/tags'
|
import { useTags } from '@/services/api/tags'
|
||||||
import { formatDate } from '@/utils/dates'
|
import { formatDate } from '@/utils/dates'
|
||||||
import type { CourseFile } from '@/utils/types'
|
import { noteRoute } from '@/utils/routes'
|
||||||
|
import type { Course, CourseFile } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ courseId: number }>()
|
const props = defineProps<{ course: Course }>()
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { tagsById } = useTags()
|
const { tagsById } = useTags()
|
||||||
const { data: files, isPending, isError, refetch } = useCourseFiles(() => props.courseId)
|
const { data: files, isPending, isError, refetch } = useCourseFiles(() => props.course.id)
|
||||||
const { remove } = useLectureNoteMutations(() => props.courseId)
|
const { remove } = useLectureNoteMutations(() => props.course.id)
|
||||||
|
|
||||||
const deleteOpen = ref(false)
|
const deleteOpen = ref(false)
|
||||||
const deletedFile = ref<CourseFile>()
|
const deletedFile = ref<CourseFile>()
|
||||||
|
|
@ -50,7 +51,7 @@ async function confirmDelete() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row flex-wrap items-start justify-between gap-3">
|
<CardHeader class="flex flex-row flex-wrap items-start justify-between gap-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<CardTitle class="text-base">{{ $t('files.title') }}</CardTitle>
|
<CardTitle class="text-base">{{ $t(`files.title.${course.kind}`) }}</CardTitle>
|
||||||
<CardDescription>{{ $t('files.description') }}</CardDescription>
|
<CardDescription>{{ $t('files.description') }}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
@ -67,7 +68,7 @@ async function confirmDelete() {
|
||||||
<TooltipContent>{{ $t('files.coming-soon') }}</TooltipContent>
|
<TooltipContent>{{ $t('files.coming-soon') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button size="sm" as-child>
|
<Button size="sm" as-child>
|
||||||
<RouterLink :to="{ name: 'course-note', params: { id: courseId, noteId: 'new' } }">
|
<RouterLink :to="noteRoute(course, 'new')">
|
||||||
<Plus />
|
<Plus />
|
||||||
{{ $t('notes.new') }}
|
{{ $t('notes.new') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
@ -105,7 +106,7 @@ async function confirmDelete() {
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'course-note', params: { id: courseId, noteId: file.id } }"
|
:to="noteRoute(course, file.id)"
|
||||||
class="truncate text-sm font-medium after:absolute after:inset-0 after:content-['']"
|
class="truncate text-sm font-medium after:absolute after:inset-0 after:content-['']"
|
||||||
>
|
>
|
||||||
{{ file.name }}
|
{{ file.name }}
|
||||||
|
|
@ -134,13 +135,7 @@ async function confirmDelete() {
|
||||||
:title="$t('common.edit')"
|
:title="$t('common.edit')"
|
||||||
as-child
|
as-child
|
||||||
>
|
>
|
||||||
<RouterLink
|
<RouterLink :to="noteRoute(course, file.id, true)">
|
||||||
:to="{
|
|
||||||
name: 'course-note',
|
|
||||||
params: { id: courseId, noteId: file.id },
|
|
||||||
query: { edit: '1' },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Pencil />
|
<Pencil />
|
||||||
<span class="sr-only">{{ $t('common.edit') }}</span>
|
<span class="sr-only">{{ $t('common.edit') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Homework, exams and anything to do for the course, with their subtasks. The backend
|
* Homework, exams and anything to do for the course, with their subtasks. The backend
|
||||||
* sorts them (not done first, then by deadline) and nests the subtasks.
|
* sorts them (not done first, then by deadline) and nests the subtasks. Each one can be
|
||||||
|
* worked on with the pomodoro.
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { CalendarClock, ClipboardList, ExternalLink, Pencil, Plus, Trash2 } from '@lucide/vue'
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
ClipboardList,
|
||||||
|
ExternalLink,
|
||||||
|
Hourglass,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Timer,
|
||||||
|
Trash2,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'
|
||||||
import TagBadge from '@/components/course/TagBadge.vue'
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
|
|
@ -18,7 +28,9 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useTags } from '@/services/api/tags'
|
import { useTags } from '@/services/api/tags'
|
||||||
import { toTaskBody, useCourseTasks, useTaskMutations } from '@/services/api/tasks'
|
import { toTaskBody, useCourseTasks, useTaskMutations } from '@/services/api/tasks'
|
||||||
|
import { formatSpent, useFocus } from '@/services/focus'
|
||||||
import { formatDateTime, isPast } from '@/utils/dates'
|
import { formatDateTime, isPast } from '@/utils/dates'
|
||||||
|
import { focusRoute } from '@/utils/routes'
|
||||||
import type { Task } from '@/utils/types'
|
import type { Task } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ courseId: number }>()
|
const props = defineProps<{ courseId: number }>()
|
||||||
|
|
@ -27,6 +39,11 @@ const { t, locale } = useI18n()
|
||||||
const { tagsById } = useTags()
|
const { tagsById } = useTags()
|
||||||
const { data, isPending, isError, refetch } = useCourseTasks(() => props.courseId)
|
const { data, isPending, isError, refetch } = useCourseTasks(() => props.courseId)
|
||||||
const { create, update, remove } = useTaskMutations(() => props.courseId)
|
const { create, update, remove } = useTaskMutations(() => props.courseId)
|
||||||
|
const { session } = useFocus()
|
||||||
|
|
||||||
|
function inFocus(task: Task) {
|
||||||
|
return session.value?.task?.task_id === task.id
|
||||||
|
}
|
||||||
|
|
||||||
const formOpen = ref(false)
|
const formOpen = ref(false)
|
||||||
const editedTask = ref<Task>()
|
const editedTask = ref<Task>()
|
||||||
|
|
@ -141,6 +158,13 @@ function isLate(task: Task) {
|
||||||
{{ formatDateTime(task.deadline, locale) }}
|
{{ formatDateTime(task.deadline, locale) }}
|
||||||
<template v-if="isLate(task)">· {{ $t('tasks.late') }}</template>
|
<template v-if="isLate(task)">· {{ $t('tasks.late') }}</template>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="task.time_spent >= 60"
|
||||||
|
class="flex items-center gap-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Hourglass class="size-3.5" />
|
||||||
|
{{ $t('tasks.time-spent', { time: formatSpent(task.time_spent) }) }}
|
||||||
|
</span>
|
||||||
<p v-if="task.description" class="text-sm whitespace-pre-line text-muted-foreground">
|
<p v-if="task.description" class="text-sm whitespace-pre-line text-muted-foreground">
|
||||||
{{ task.description }}
|
{{ task.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -159,6 +183,19 @@ function isLate(task: Task) {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-1">
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="!task.done"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:class="{ 'text-primary': inFocus(task) }"
|
||||||
|
:title="$t('focus.start')"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink :to="focusRoute(task.id)">
|
||||||
|
<Timer :class="{ 'animate-pulse': inFocus(task) }" />
|
||||||
|
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
|
@ -207,8 +244,23 @@ function isLate(task: Task) {
|
||||||
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
class="ml-auto flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
|
class="ml-auto flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||||
|
:class="{ 'opacity-100': inFocus(subtask) }"
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
v-if="!subtask.done && !task.done"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-7"
|
||||||
|
:class="{ 'text-primary': inFocus(subtask) }"
|
||||||
|
:title="$t('focus.start')"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<RouterLink :to="focusRoute(subtask.id)">
|
||||||
|
<Timer :class="{ 'animate-pulse': inFocus(subtask) }" />
|
||||||
|
<span class="sr-only">{{ $t('focus.start') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Creates a task (or a subtask of `parentId`), or edits `task` when given. A task needs
|
* Creates a task (or a subtask of `parentId`), or edits `task` when given. A task needs
|
||||||
* at least one tag, a subtask may have none.
|
* at least one tag, a subtask may have none. Without `courseId` the course (or the
|
||||||
|
* association) is picked in the form. `defaultDeadline` (ISO) prefills a new task.
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
@ -18,6 +19,15 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
TagsInput,
|
TagsInput,
|
||||||
TagsInputInput,
|
TagsInputInput,
|
||||||
|
|
@ -26,17 +36,33 @@ import {
|
||||||
TagsInputItemText,
|
TagsInputItemText,
|
||||||
} from '@/components/ui/tags-input'
|
} from '@/components/ui/tags-input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { useCourses } from '@/services/api/courses'
|
||||||
import { useTags } from '@/services/api/tags'
|
import { useTags } from '@/services/api/tags'
|
||||||
import { useTaskMutations } from '@/services/api/tasks'
|
import { useAnyTaskMutations } from '@/services/api/tasks'
|
||||||
import { fromDateTimeInput, toDateTimeInput } from '@/utils/dates'
|
import { fromDateTimeInput, toDateTimeInput } from '@/utils/dates'
|
||||||
import type { Task, TaskBody } from '@/utils/types'
|
import type { Task, TaskBody } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ courseId: number; task?: Task; parentId?: number }>()
|
const props = defineProps<{
|
||||||
|
courseId?: number
|
||||||
|
task?: Task
|
||||||
|
parentId?: number
|
||||||
|
defaultDeadline?: string
|
||||||
|
}>()
|
||||||
const open = defineModel<boolean>('open', { required: true })
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { data: tags } = useTags('task')
|
const { data: tags } = useTags('task')
|
||||||
const { create, update } = useTaskMutations(() => props.courseId)
|
const { data: courses } = useCourses()
|
||||||
|
const { create, update } = useAnyTaskMutations()
|
||||||
|
|
||||||
|
/** Picked in the form when not given */
|
||||||
|
const pickedCourseId = ref<number>()
|
||||||
|
const courseId = computed(() => props.task?.course_id ?? props.courseId ?? pickedCourseId.value)
|
||||||
|
const courseGroups = computed(() =>
|
||||||
|
(['course', 'association'] as const)
|
||||||
|
.map((kind) => ({ kind, courses: courses.value?.filter((c) => c.kind === kind) ?? [] }))
|
||||||
|
.filter((group) => group.courses.length),
|
||||||
|
)
|
||||||
|
|
||||||
const isSubtask = computed(() => (props.task ? props.task.parent_id != null : !!props.parentId))
|
const isSubtask = computed(() => (props.task ? props.task.parent_id != null : !!props.parentId))
|
||||||
|
|
||||||
|
|
@ -53,7 +79,8 @@ watch(
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
name.value = props.task?.name ?? ''
|
name.value = props.task?.name ?? ''
|
||||||
tagIds.value = [...(props.task?.tag_ids ?? [])]
|
tagIds.value = [...(props.task?.tag_ids ?? [])]
|
||||||
deadline.value = props.task?.deadline ? toDateTimeInput(props.task.deadline) : ''
|
const initialDeadline = props.task ? props.task.deadline : props.defaultDeadline
|
||||||
|
deadline.value = initialDeadline ? toDateTimeInput(initialDeadline) : ''
|
||||||
description.value = props.task?.description ?? ''
|
description.value = props.task?.description ?? ''
|
||||||
links.value = [...(props.task?.links ?? [])]
|
links.value = [...(props.task?.links ?? [])]
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
|
|
@ -69,6 +96,7 @@ function toggleTag(id: number) {
|
||||||
|
|
||||||
// Same rules as the backend, checked here to point at the faulty field
|
// Same rules as the backend, checked here to point at the faulty field
|
||||||
const errors = computed(() => ({
|
const errors = computed(() => ({
|
||||||
|
course: courseId.value === undefined,
|
||||||
name: !name.value.trim(),
|
name: !name.value.trim(),
|
||||||
tags: !isSubtask.value && tagIds.value.length === 0,
|
tags: !isSubtask.value && tagIds.value.length === 0,
|
||||||
links: links.value.some((link) => !/^https?:\/\//.test(link.trim())),
|
links: links.value.some((link) => !/^https?:\/\//.test(link.trim())),
|
||||||
|
|
@ -78,7 +106,7 @@ const isPending = computed(() => create.isPending.value || update.isPending.valu
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
if (Object.values(errors.value).some(Boolean)) return
|
if (Object.values(errors.value).some(Boolean) || courseId.value === undefined) return
|
||||||
|
|
||||||
const body: TaskBody = {
|
const body: TaskBody = {
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
|
|
@ -90,10 +118,13 @@ async function submit() {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (props.task) {
|
if (props.task) {
|
||||||
await update.mutateAsync({ id: props.task.id, body })
|
await update.mutateAsync({ courseId: courseId.value, id: props.task.id, body })
|
||||||
toast.success(t('tasks.updated'))
|
toast.success(t('tasks.updated'))
|
||||||
} else {
|
} else {
|
||||||
await create.mutateAsync({ ...body, parent_id: props.parentId ?? null })
|
await create.mutateAsync({
|
||||||
|
courseId: courseId.value,
|
||||||
|
body: { ...body, parent_id: props.parentId ?? null },
|
||||||
|
})
|
||||||
toast.success(t('tasks.created'))
|
toast.success(t('tasks.created'))
|
||||||
}
|
}
|
||||||
open.value = false
|
open.value = false
|
||||||
|
|
@ -116,6 +147,30 @@ async function submit() {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form id="task-form" class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
<form id="task-form" class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
||||||
|
<div v-if="!task && props.courseId === undefined" class="flex flex-col gap-2">
|
||||||
|
<Label for="task-course">{{ $t('tasks.form.course') }}</Label>
|
||||||
|
<Select v-model="pickedCourseId">
|
||||||
|
<SelectTrigger
|
||||||
|
id="task-course"
|
||||||
|
class="w-full"
|
||||||
|
:aria-invalid="submitted && errors.course"
|
||||||
|
>
|
||||||
|
<SelectValue :placeholder="$t('tasks.form.course-placeholder')" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup v-for="group in courseGroups" :key="group.kind">
|
||||||
|
<SelectLabel>{{ $t(`nav.${group.kind}s`) }}</SelectLabel>
|
||||||
|
<SelectItem v-for="course in group.courses" :key="course.id" :value="course.id">
|
||||||
|
{{ course.name }}
|
||||||
|
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
|
||||||
|
{{ course.code }}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Label for="task-name">{{ $t('tasks.form.name') }}</Label>
|
<Label for="task-name">{{ $t('tasks.form.name') }}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Creates a course, or edits `course` when given. Slots are not edited here but by
|
* Creates a course or an association, or edits `course` when given. Slots are not edited
|
||||||
* selecting a time range in the schedule.
|
* here but by selecting a time range in the schedule. An association has no teacher.
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
@ -26,12 +26,16 @@ import {
|
||||||
TagsInputItemText,
|
TagsInputItemText,
|
||||||
} from '@/components/ui/tags-input'
|
} from '@/components/ui/tags-input'
|
||||||
import { useCreateCourse, useUpdateCourse } from '@/services/api/courses'
|
import { useCreateCourse, useUpdateCourse } from '@/services/api/courses'
|
||||||
import type { Course, CourseBody } from '@/utils/types'
|
import type { Course, CourseBody, CourseKind } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ course?: Course }>()
|
const props = defineProps<{ kind: CourseKind; course?: Course }>()
|
||||||
const open = defineModel<boolean>('open', { required: true })
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/** Prefix of the texts of the form */
|
||||||
|
const k = computed(() => (props.kind === 'course' ? 'courses' : 'associations'))
|
||||||
|
const isCourse = computed(() => props.kind === 'course')
|
||||||
const createCourse = useCreateCourse()
|
const createCourse = useCreateCourse()
|
||||||
const updateCourse = useUpdateCourse()
|
const updateCourse = useUpdateCourse()
|
||||||
|
|
||||||
|
|
@ -61,7 +65,7 @@ const errors = computed(() => {
|
||||||
const url = mainPageUrl.value.trim()
|
const url = mainPageUrl.value.trim()
|
||||||
return {
|
return {
|
||||||
name: !name.value.trim(),
|
name: !name.value.trim(),
|
||||||
teachers: !teachers.value.some((teacher) => teacher.trim()),
|
teachers: isCourse.value && !teachers.value.some((teacher) => teacher.trim()),
|
||||||
mainPage: url !== '' && !/^https?:\/\//.test(url),
|
mainPage: url !== '' && !/^https?:\/\//.test(url),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -74,6 +78,7 @@ async function submit() {
|
||||||
if (!isValid.value) return
|
if (!isValid.value) return
|
||||||
|
|
||||||
const body: CourseBody = {
|
const body: CourseBody = {
|
||||||
|
kind: props.kind,
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
code: code.value.trim() || null,
|
code: code.value.trim() || null,
|
||||||
teachers: teachers.value.map((teacher) => teacher.trim()).filter(Boolean),
|
teachers: teachers.value.map((teacher) => teacher.trim()).filter(Boolean),
|
||||||
|
|
@ -83,14 +88,14 @@ async function submit() {
|
||||||
try {
|
try {
|
||||||
if (props.course) {
|
if (props.course) {
|
||||||
await updateCourse.mutateAsync({ id: props.course.id, body })
|
await updateCourse.mutateAsync({ id: props.course.id, body })
|
||||||
toast.success(t('courses.updated'))
|
toast.success(t(`${k.value}.updated`))
|
||||||
} else {
|
} else {
|
||||||
await createCourse.mutateAsync(body)
|
await createCourse.mutateAsync(body)
|
||||||
toast.success(t('courses.created'))
|
toast.success(t(`${k.value}.created`))
|
||||||
}
|
}
|
||||||
open.value = false
|
open.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('courses.save-error'), {
|
toast.error(t(`${k.value}.save-error`), {
|
||||||
description: err instanceof Error ? err.message : undefined,
|
description: err instanceof Error ? err.message : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -101,8 +106,8 @@ async function submit() {
|
||||||
<Dialog v-model:open="open">
|
<Dialog v-model:open="open">
|
||||||
<DialogContent class="max-h-[90svh] overflow-y-auto sm:max-w-lg">
|
<DialogContent class="max-h-[90svh] overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ course ? $t('courses.edit') : $t('courses.new') }}</DialogTitle>
|
<DialogTitle>{{ course ? $t(`${k}.edit`) : $t(`${k}.new`) }}</DialogTitle>
|
||||||
<DialogDescription>{{ $t('courses.form.description') }}</DialogDescription>
|
<DialogDescription>{{ $t(`${k}.form.description`) }}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form id="course-form" class="flex flex-col gap-5" novalidate @submit.prevent="submit">
|
<form id="course-form" class="flex flex-col gap-5" novalidate @submit.prevent="submit">
|
||||||
|
|
@ -112,7 +117,7 @@ async function submit() {
|
||||||
<Input
|
<Input
|
||||||
id="course-name"
|
id="course-name"
|
||||||
v-model="name"
|
v-model="name"
|
||||||
:placeholder="$t('courses.form.name-placeholder')"
|
:placeholder="$t(`${k}.form.name-placeholder`)"
|
||||||
:aria-invalid="submitted && errors.name"
|
:aria-invalid="submitted && errors.name"
|
||||||
/>
|
/>
|
||||||
<p v-if="submitted && errors.name" class="text-sm text-destructive">
|
<p v-if="submitted && errors.name" class="text-sm text-destructive">
|
||||||
|
|
@ -121,7 +126,7 @@ async function submit() {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Label for="course-code">
|
<Label for="course-code">
|
||||||
{{ $t('courses.form.code') }}
|
{{ $t(`${k}.form.code`) }}
|
||||||
<span class="font-normal text-muted-foreground">
|
<span class="font-normal text-muted-foreground">
|
||||||
({{ $t('courses.form.optional') }})
|
({{ $t('courses.form.optional') }})
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -129,12 +134,12 @@ async function submit() {
|
||||||
<Input
|
<Input
|
||||||
id="course-code"
|
id="course-code"
|
||||||
v-model="code"
|
v-model="code"
|
||||||
:placeholder="$t('courses.form.code-placeholder')"
|
:placeholder="$t(`${k}.form.code-placeholder`)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div v-if="isCourse" class="flex flex-col gap-2">
|
||||||
<Label for="course-teachers">{{ $t('courses.form.teachers') }}</Label>
|
<Label for="course-teachers">{{ $t('courses.form.teachers') }}</Label>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
v-model="teachers"
|
v-model="teachers"
|
||||||
|
|
@ -168,7 +173,7 @@ async function submit() {
|
||||||
id="course-main-page"
|
id="course-main-page"
|
||||||
v-model="mainPageUrl"
|
v-model="mainPageUrl"
|
||||||
type="url"
|
type="url"
|
||||||
:placeholder="$t('courses.form.main-page-placeholder')"
|
:placeholder="$t(`${k}.form.main-page-placeholder`)"
|
||||||
:aria-invalid="submitted && errors.mainPage"
|
:aria-invalid="submitted && errors.mainPage"
|
||||||
/>
|
/>
|
||||||
<p v-if="submitted && errors.mainPage" class="text-sm text-destructive">
|
<p v-if="submitted && errors.mainPage" class="text-sm text-destructive">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** Adds a slot to a course, prefilled with the time range selected in the schedule */
|
/**
|
||||||
|
* Adds a slot to a course or an association, prefilled with the time range selected in
|
||||||
|
* the schedule. `courseId` picks the course.
|
||||||
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
@ -18,7 +21,9 @@ import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
@ -33,7 +38,7 @@ import {
|
||||||
type SlotSelection,
|
type SlotSelection,
|
||||||
} from '@/utils/timetable'
|
} from '@/utils/timetable'
|
||||||
|
|
||||||
const props = defineProps<{ selection?: SlotSelection }>()
|
const props = defineProps<{ selection?: SlotSelection; courseId?: number }>()
|
||||||
const open = defineModel<boolean>('open', { required: true })
|
const open = defineModel<boolean>('open', { required: true })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -41,7 +46,13 @@ const { data: courses } = useCourses()
|
||||||
const { data: tags } = useTags('slot')
|
const { data: tags } = useTags('slot')
|
||||||
const addSlot = useAddSlot()
|
const addSlot = useAddSlot()
|
||||||
|
|
||||||
const courseId = ref<number>()
|
const groups = computed(() =>
|
||||||
|
(['course', 'association'] as const)
|
||||||
|
.map((kind) => ({ kind, courses: courses.value?.filter((c) => c.kind === kind) ?? [] }))
|
||||||
|
.filter((group) => group.courses.length),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pickedCourseId = ref<number>()
|
||||||
const tagId = ref<number>()
|
const tagId = ref<number>()
|
||||||
const weekday = ref(1)
|
const weekday = ref(1)
|
||||||
const start = ref('')
|
const start = ref('')
|
||||||
|
|
@ -56,8 +67,10 @@ watch(
|
||||||
weekday.value = props.selection?.weekday ?? 1
|
weekday.value = props.selection?.weekday ?? 1
|
||||||
start.value = props.selection?.start ?? '08:15'
|
start.value = props.selection?.start ?? '08:15'
|
||||||
end.value = props.selection?.end ?? '10:00'
|
end.value = props.selection?.end ?? '10:00'
|
||||||
if (!courses.value?.some((course) => course.id === courseId.value)) {
|
if (props.courseId !== undefined) {
|
||||||
courseId.value = courses.value?.[0]?.id
|
pickedCourseId.value = props.courseId
|
||||||
|
} else if (!courses.value?.some((course) => course.id === pickedCourseId.value)) {
|
||||||
|
pickedCourseId.value = courses.value?.[0]?.id
|
||||||
}
|
}
|
||||||
tagId.value ??= tags.value[0]?.id
|
tagId.value ??= tags.value[0]?.id
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
|
|
@ -70,7 +83,7 @@ const errors = computed(() => {
|
||||||
const startMinutes = toMinutes(start.value)
|
const startMinutes = toMinutes(start.value)
|
||||||
const endMinutes = toMinutes(end.value)
|
const endMinutes = toMinutes(end.value)
|
||||||
return {
|
return {
|
||||||
course: courseId.value === undefined,
|
course: pickedCourseId.value === undefined,
|
||||||
time:
|
time:
|
||||||
!start.value ||
|
!start.value ||
|
||||||
!end.value ||
|
!end.value ||
|
||||||
|
|
@ -83,11 +96,11 @@ const errors = computed(() => {
|
||||||
async function submit() {
|
async function submit() {
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
if (errors.value.course || errors.value.time) return
|
if (errors.value.course || errors.value.time) return
|
||||||
if (courseId.value === undefined || tagId.value === undefined) return
|
if (pickedCourseId.value === undefined || tagId.value === undefined) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addSlot.mutateAsync({
|
await addSlot.mutateAsync({
|
||||||
courseId: courseId.value,
|
courseId: pickedCourseId.value,
|
||||||
body: {
|
body: {
|
||||||
tag_id: tagId.value,
|
tag_id: tagId.value,
|
||||||
weekday: weekday.value,
|
weekday: weekday.value,
|
||||||
|
|
@ -116,17 +129,20 @@ async function submit() {
|
||||||
<form id="slot-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
|
<form id="slot-form" class="grid grid-cols-2 gap-4" novalidate @submit.prevent="submit">
|
||||||
<div class="col-span-2 flex flex-col gap-2">
|
<div class="col-span-2 flex flex-col gap-2">
|
||||||
<Label for="slot-course">{{ $t('schedule.form.course') }}</Label>
|
<Label for="slot-course">{{ $t('schedule.form.course') }}</Label>
|
||||||
<Select v-model="courseId">
|
<Select v-model="pickedCourseId">
|
||||||
<SelectTrigger id="slot-course" class="w-full">
|
<SelectTrigger id="slot-course" class="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="course in courses" :key="course.id" :value="course.id">
|
<SelectGroup v-for="group in groups" :key="group.kind">
|
||||||
|
<SelectLabel>{{ $t(`nav.${group.kind}s`) }}</SelectLabel>
|
||||||
|
<SelectItem v-for="course in group.courses" :key="course.id" :value="course.id">
|
||||||
{{ course.name }}
|
{{ course.name }}
|
||||||
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
|
<span v-if="course.code" class="font-mono text-xs text-muted-foreground">
|
||||||
{{ course.code }}
|
{{ course.code }}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@
|
||||||
* - `selectable`: a time range can be selected with the pointer, emitted as `select`
|
* - `selectable`: a time range can be selected with the pointer, emitted as `select`
|
||||||
* - `linkToCourse`: clicking a slot opens the page of its course
|
* - `linkToCourse`: clicking a slot opens the page of its course
|
||||||
* - `deletable`: each slot gets a button emitting `delete` (not with `linkToCourse`)
|
* - `deletable`: each slot gets a button emitting `delete` (not with `linkToCourse`)
|
||||||
|
* - `dates`: the days of a real week (monday to friday), shown in the header. The `day`
|
||||||
|
* slot adds content under the header of each day (e.g. its deadlines).
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { X } from '@lucide/vue'
|
import { X } from '@lucide/vue'
|
||||||
|
|
||||||
|
|
@ -22,6 +25,7 @@ import {
|
||||||
toMinutes,
|
toMinutes,
|
||||||
type SlotSelection,
|
type SlotSelection,
|
||||||
} from '@/utils/timetable'
|
} from '@/utils/timetable'
|
||||||
|
import { coursePageRoute } from '@/utils/routes'
|
||||||
import type { Course, Tag, TimeSlot } from '@/utils/types'
|
import type { Course, Tag, TimeSlot } from '@/utils/types'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
|
@ -32,6 +36,7 @@ const props = withDefaults(
|
||||||
deletable?: boolean
|
deletable?: boolean
|
||||||
/** Css height of the day columns */
|
/** Css height of the day columns */
|
||||||
height?: string
|
height?: string
|
||||||
|
dates?: Date[]
|
||||||
}>(),
|
}>(),
|
||||||
{ height: '55rem' },
|
{ height: '55rem' },
|
||||||
)
|
)
|
||||||
|
|
@ -41,8 +46,20 @@ const emit = defineEmits<{
|
||||||
delete: [slot: TimeSlot, course: Course]
|
delete: [slot: TimeSlot, course: Course]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineSlots<{ day?: (props: { weekday: number; date?: Date }) => unknown }>()
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
const { tagsById } = useTags()
|
const { tagsById } = useTags()
|
||||||
|
|
||||||
|
/** "5 oct." */
|
||||||
|
function dayOfMonth(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'short' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(date: Date) {
|
||||||
|
return date.toDateString() === new Date().toDateString()
|
||||||
|
}
|
||||||
|
|
||||||
const HOURS = Array.from(
|
const HOURS = Array.from(
|
||||||
{ length: DAY_END_HOUR - DAY_START_HOUR + 1 },
|
{ length: DAY_END_HOUR - DAY_START_HOUR + 1 },
|
||||||
(_, i) => DAY_START_HOUR + i,
|
(_, i) => DAY_START_HOUR + i,
|
||||||
|
|
@ -160,11 +177,25 @@ function endSelection() {
|
||||||
<!-- Header: weekdays -->
|
<!-- Header: weekdays -->
|
||||||
<div class="border-b" />
|
<div class="border-b" />
|
||||||
<div
|
<div
|
||||||
v-for="day in days"
|
v-for="(day, index) in days"
|
||||||
:key="day.weekday"
|
:key="day.weekday"
|
||||||
class="border-b border-l px-2 py-3 text-center text-sm font-medium"
|
class="flex min-w-0 flex-col gap-1.5 border-b border-l px-2 py-3 text-center text-sm font-medium"
|
||||||
>
|
>
|
||||||
|
<span>
|
||||||
{{ $t(`weekdays.${day.weekday}`) }}
|
{{ $t(`weekdays.${day.weekday}`) }}
|
||||||
|
<span
|
||||||
|
v-if="dates?.[index]"
|
||||||
|
class="ml-1 rounded-full px-1.5 py-0.5 text-xs tabular-nums"
|
||||||
|
:class="
|
||||||
|
isToday(dates[index]!)
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'font-normal text-muted-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ dayOfMonth(dates[index]!) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<slot name="day" :weekday="day.weekday" :date="dates?.[index]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hours column -->
|
<!-- Hours column -->
|
||||||
|
|
@ -202,7 +233,7 @@ function endSelection() {
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<component
|
<component
|
||||||
:is="linkToCourse ? RouterLink : 'div'"
|
:is="linkToCourse ? RouterLink : 'div'"
|
||||||
:to="linkToCourse ? { name: 'course', params: { id: block.course.id } } : undefined"
|
:to="linkToCourse ? coursePageRoute(block.course) : undefined"
|
||||||
class="group absolute flex flex-col gap-0.5 overflow-hidden rounded-md border-l-4 px-2 py-1 text-foreground transition-shadow hover:z-10 hover:shadow-md"
|
class="group absolute flex flex-col gap-0.5 overflow-hidden rounded-md border-l-4 px-2 py-1 text-foreground transition-shadow hover:z-10 hover:shadow-md"
|
||||||
:class="{ 'cursor-pointer': linkToCourse, 'cursor-default': !linkToCourse }"
|
:class="{ 'cursor-pointer': linkToCourse, 'cursor-default': !linkToCourse }"
|
||||||
:style="blockStyle(block, lane, lanes)"
|
:style="blockStyle(block, lane, lanes)"
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
'fixed left-1/2 top-1/2 z-50 grid w-full grid-cols-1 max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
'fixed left-1/2 top-1/2 z-50 grid w-full grid-cols-1 max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
274
frontend/src/lib/api.d.ts
vendored
274
frontend/src/lib/api.d.ts
vendored
|
|
@ -75,7 +75,7 @@ export interface paths {
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
/** @description A course, without its slots: they are added and removed one by one */
|
/** @description A course or an association, without its slots: they are added and removed one by one */
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
'application/json': components['schemas']['CourseBody']
|
'application/json': components['schemas']['CourseBody']
|
||||||
|
|
@ -127,6 +127,10 @@ export interface paths {
|
||||||
}
|
}
|
||||||
requestBody?: never
|
requestBody?: never
|
||||||
responses: {
|
responses: {
|
||||||
|
/**
|
||||||
|
* @description A course, or an association: both have tasks, files and slots, only the courses have
|
||||||
|
* teachers and grades.
|
||||||
|
*/
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
|
|
@ -156,13 +160,17 @@ export interface paths {
|
||||||
}
|
}
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
/** @description A course, without its slots: they are added and removed one by one */
|
/** @description A course or an association, without its slots: they are added and removed one by one */
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
'application/json': components['schemas']['CourseBody']
|
'application/json': components['schemas']['CourseBody']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
responses: {
|
responses: {
|
||||||
|
/**
|
||||||
|
* @description A course, or an association: both have tasks, files and slots, only the courses have
|
||||||
|
* teachers and grades.
|
||||||
|
*/
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
|
|
@ -252,6 +260,10 @@ export interface paths {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
responses: {
|
responses: {
|
||||||
|
/**
|
||||||
|
* @description A course, or an association: both have tasks, files and slots, only the courses have
|
||||||
|
* teachers and grades.
|
||||||
|
*/
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
|
|
@ -310,6 +322,10 @@ export interface paths {
|
||||||
}
|
}
|
||||||
requestBody?: never
|
requestBody?: never
|
||||||
responses: {
|
responses: {
|
||||||
|
/**
|
||||||
|
* @description A course, or an association: both have tasks, files and slots, only the courses have
|
||||||
|
* teachers and grades.
|
||||||
|
*/
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
|
|
@ -603,6 +619,41 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
'/api/tasks/todo': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
/** Get the tasks not done of every course and association, with their subtasks, the closest deadline first */
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['TodoTask'][]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put?: never
|
||||||
|
post?: never
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
'/api/courses/{id}/tasks': {
|
'/api/courses/{id}/tasks': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|
@ -785,6 +836,117 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
'/api/tasks/{id}/focus': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
get?: never
|
||||||
|
put?: never
|
||||||
|
/**
|
||||||
|
* Start a focus session on a task, and its TimeTagger record
|
||||||
|
* @description The session starts even when TimeTagger fails: see `timetagger_error`.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path: {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
/** @description A focus session started on a task */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['FocusStart']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @description The task does not exist */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'text/plain; charset=utf-8': unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
|
'/api/tasks/{id}/focus/stop': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
get?: never
|
||||||
|
put?: never
|
||||||
|
/** End or pause a focus session: its time is added to the task */
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path: {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
/** @description End (or pause) of a focus session */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['FocusStopBody']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
/** @description End of a focus session */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['FocusStop']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @description The session is too long */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'text/plain; charset=utf-8': unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @description The task does not exist */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'text/plain; charset=utf-8': unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
'/api/courses/{id}/files': {
|
'/api/courses/{id}/files': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|
@ -1064,6 +1226,7 @@ export interface components {
|
||||||
course_code?: string | null
|
course_code?: string | null
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
course_id: number
|
course_id: number
|
||||||
|
course_kind: components['schemas']['CourseKind']
|
||||||
course_name: string
|
course_name: string
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
deadline: string
|
deadline: string
|
||||||
|
|
@ -1076,32 +1239,45 @@ export interface components {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
task_id: number
|
task_id: number
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @description A course, or an association: both have tasks, files and slots, only the courses have
|
||||||
|
* teachers and grades.
|
||||||
|
*/
|
||||||
Course: {
|
Course: {
|
||||||
/** @description e.g. "CS-477" */
|
/** @description e.g. "CS-477", or the acronym of an association */
|
||||||
code?: string | null
|
code?: string | null
|
||||||
|
/** @description Folder of its files in the storage, e.g. `Cours/CS-477 Advanced operating systems` */
|
||||||
|
folder: string
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
id: number
|
id: number
|
||||||
|
kind: components['schemas']['CourseKind']
|
||||||
/** @description Main page of the course, usually on moodle */
|
/** @description Main page of the course, usually on moodle */
|
||||||
main_page_url?: string | null
|
main_page_url?: string | null
|
||||||
/** @description e.g. "Advanced operating systems" */
|
/** @description e.g. "Advanced operating systems" */
|
||||||
name: string
|
name: string
|
||||||
/** @description Weekly slots, sorted by weekday then start time */
|
/** @description Weekly slots, sorted by weekday then start time */
|
||||||
slots: components['schemas']['TimeSlot'][]
|
slots: components['schemas']['TimeSlot'][]
|
||||||
/** @description At least one */
|
/** @description At least one for a course, none for an association */
|
||||||
teachers: string[]
|
teachers: string[]
|
||||||
}
|
}
|
||||||
/** @description A course, without its slots: they are added and removed one by one */
|
/** @description A course or an association, without its slots: they are added and removed one by one */
|
||||||
CourseBody: {
|
CourseBody: {
|
||||||
code?: string | null
|
code?: string | null
|
||||||
|
/** @description `course` by default. Cannot be changed once created. */
|
||||||
|
kind?: components['schemas']['CourseKind'] | null
|
||||||
/** @description http(s) url of the main page of the course */
|
/** @description http(s) url of the main page of the course */
|
||||||
main_page_url?: string | null
|
main_page_url?: string | null
|
||||||
name: string
|
name: string
|
||||||
/** @description At least one */
|
/**
|
||||||
|
* @description At least one for a course
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
teachers: string[]
|
teachers: string[]
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @description A file of a course: lecture notes written in the app, or (soon) an uploaded file.
|
* @description A file of a course: lecture notes written in the app, or (soon) an uploaded file.
|
||||||
* Only the metadata is in the database, the content is in the storage (the NAS).
|
* Only the metadata is in the database, the content is in the storage (the NAS), in the
|
||||||
|
* folder of the course.
|
||||||
*/
|
*/
|
||||||
CourseFile: {
|
CourseFile: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
|
|
@ -1115,6 +1291,8 @@ export interface components {
|
||||||
kind: components['schemas']['FileKind']
|
kind: components['schemas']['FileKind']
|
||||||
/** @description For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf */
|
/** @description For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf */
|
||||||
name: string
|
name: string
|
||||||
|
/** @description Path in the folder of the course, without extension, e.g. `Notes/Lecture 1 Notes` */
|
||||||
|
path: string
|
||||||
/** @description e.g. slides, notes */
|
/** @description e.g. slides, notes */
|
||||||
tag_ids: number[]
|
tag_ids: number[]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
|
|
@ -1134,10 +1312,20 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
total_weight: number
|
total_weight: number
|
||||||
}
|
}
|
||||||
|
/** @enum {string} */
|
||||||
|
CourseKind: 'course' | 'association'
|
||||||
CoursePath: {
|
CoursePath: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
/** @description What a task needs to show of its course */
|
||||||
|
CourseSummary: {
|
||||||
|
code?: string | null
|
||||||
|
/** Format: int32 */
|
||||||
|
id: number
|
||||||
|
kind: components['schemas']['CourseKind']
|
||||||
|
name: string
|
||||||
|
}
|
||||||
/** @description Every task of a course */
|
/** @description Every task of a course */
|
||||||
CourseTasks: {
|
CourseTasks: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -1170,6 +1358,49 @@ export interface components {
|
||||||
tag_ids: number[]
|
tag_ids: number[]
|
||||||
}
|
}
|
||||||
FileKind: 'note' | 'upload'
|
FileKind: 'note' | 'upload'
|
||||||
|
/** @description A focus session started on a task */
|
||||||
|
FocusStart: {
|
||||||
|
course: components['schemas']['CourseSummary']
|
||||||
|
name: string
|
||||||
|
/** @description Name of the parent task, for a subtask */
|
||||||
|
parent_name?: string | null
|
||||||
|
/** Format: date-time */
|
||||||
|
started_at: string
|
||||||
|
/** @description Tags of the task, or of its parent for a subtask without any */
|
||||||
|
tag_ids: number[]
|
||||||
|
/** Format: int32 */
|
||||||
|
task_id: number
|
||||||
|
/**
|
||||||
|
* Format: int32
|
||||||
|
* @description Seconds already spent on the task before this session
|
||||||
|
*/
|
||||||
|
time_spent: number
|
||||||
|
/** @description The running record, `None` when TimeTagger is not configured or failed */
|
||||||
|
timetagger?: components['schemas']['TimetaggerRecord'] | null
|
||||||
|
/** @description Whether TimeTagger is configured */
|
||||||
|
timetagger_enabled: boolean
|
||||||
|
/** @description Why TimeTagger failed, the session runs anyway */
|
||||||
|
timetagger_error?: string | null
|
||||||
|
}
|
||||||
|
/** @description End of a focus session */
|
||||||
|
FocusStop: {
|
||||||
|
/**
|
||||||
|
* Format: int32
|
||||||
|
* @description Seconds spent on the task, this session included
|
||||||
|
*/
|
||||||
|
time_spent: number
|
||||||
|
timetagger_error?: string | null
|
||||||
|
}
|
||||||
|
/** @description End (or pause) of a focus session */
|
||||||
|
FocusStopBody: {
|
||||||
|
/**
|
||||||
|
* Format: uint32
|
||||||
|
* @description Length of the session, at most 24 hours
|
||||||
|
*/
|
||||||
|
seconds: number
|
||||||
|
/** @description Record returned when the session started, to stop it */
|
||||||
|
timetagger?: components['schemas']['TimetaggerRecord'] | null
|
||||||
|
}
|
||||||
/** @description A grade on any scale: `score` out of `max_score` */
|
/** @description A grade on any scale: `score` out of `max_score` */
|
||||||
Grade: {
|
Grade: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
|
|
@ -1234,6 +1465,8 @@ export interface components {
|
||||||
kind: components['schemas']['FileKind']
|
kind: components['schemas']['FileKind']
|
||||||
/** @description For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf */
|
/** @description For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf */
|
||||||
name: string
|
name: string
|
||||||
|
/** @description Path in the folder of the course, without extension, e.g. `Notes/Lecture 1 Notes` */
|
||||||
|
path: string
|
||||||
/** @description e.g. slides, notes */
|
/** @description e.g. slides, notes */
|
||||||
tag_ids: number[]
|
tag_ids: number[]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
|
|
@ -1279,6 +1512,8 @@ export interface components {
|
||||||
name: components['schemas']['LocalizedString']
|
name: components['schemas']['LocalizedString']
|
||||||
/** @description What the tag can label */
|
/** @description What the tag can label */
|
||||||
scopes: components['schemas']['TagScope'][]
|
scopes: components['schemas']['TagScope'][]
|
||||||
|
/** @description Stable name, e.g. `homework` */
|
||||||
|
slug?: string | null
|
||||||
}
|
}
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
TagScope: 'slot' | 'task' | 'file'
|
TagScope: 'slot' | 'task' | 'file'
|
||||||
|
|
@ -1307,6 +1542,11 @@ export interface components {
|
||||||
/** @description Sorted like the tasks. Always empty for a subtask: there is only one level. */
|
/** @description Sorted like the tasks. Always empty for a subtask: there is only one level. */
|
||||||
subtasks: components['schemas']['Task'][]
|
subtasks: components['schemas']['Task'][]
|
||||||
tag_ids: number[]
|
tag_ids: number[]
|
||||||
|
/**
|
||||||
|
* Format: int32
|
||||||
|
* @description Seconds spent working on it, counted by the pomodoro
|
||||||
|
*/
|
||||||
|
time_spent: number
|
||||||
}
|
}
|
||||||
TaskBody: {
|
TaskBody: {
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
|
@ -1362,6 +1602,26 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
weekday: number
|
weekday: number
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @description Record of TimeTagger (https://timetagger.app) counting a focus session. Kept by the
|
||||||
|
* client while the session runs, and sent back to stop it.
|
||||||
|
*/
|
||||||
|
TimetaggerRecord: {
|
||||||
|
/** @description Description holding the tags, e.g. `#cs-477 #homework #exercise-set-3` */
|
||||||
|
ds: string
|
||||||
|
key: string
|
||||||
|
/**
|
||||||
|
* Format: int64
|
||||||
|
* @description Start, unix timestamp in seconds
|
||||||
|
*/
|
||||||
|
t1: number
|
||||||
|
}
|
||||||
|
/** @description A task not done yet, with its course, for the list of the things to do */
|
||||||
|
TodoTask: {
|
||||||
|
course: components['schemas']['CourseSummary']
|
||||||
|
/** @description With all its subtasks, the ones done included */
|
||||||
|
task: components['schemas']['Task']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
responses: never
|
responses: never
|
||||||
parameters: never
|
parameters: never
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ nav:
|
||||||
home: Home
|
home: Home
|
||||||
schedule: Timetable
|
schedule: Timetable
|
||||||
courses: Courses
|
courses: Courses
|
||||||
|
associations: Associations
|
||||||
pomodoro: Pomodoro
|
pomodoro: Pomodoro
|
||||||
calendar: Calendar
|
calendar: Calendar
|
||||||
settings: Settings
|
settings: Settings
|
||||||
|
|
@ -16,7 +17,8 @@ home:
|
||||||
sections:
|
sections:
|
||||||
schedule: Check your weekly timetable.
|
schedule: Check your weekly timetable.
|
||||||
courses: Find your courses and their resources.
|
courses: Find your courses and their resources.
|
||||||
pomodoro: Work in focused sessions with regular breaks.
|
associations: Follow the tasks and meetings of your associations.
|
||||||
|
pomodoro: Focus on a task, the time is counted for you.
|
||||||
calendar: Keep an eye on your exams and deadlines.
|
calendar: Keep an eye on your exams and deadlines.
|
||||||
theme:
|
theme:
|
||||||
dark: Switch to dark mode
|
dark: Switch to dark mode
|
||||||
|
|
@ -47,7 +49,7 @@ courses:
|
||||||
save-error: Unable to save the course
|
save-error: Unable to save the course
|
||||||
delete-error: Unable to delete the course
|
delete-error: Unable to delete the course
|
||||||
delete-title: Do you really want to delete “{name}”?
|
delete-title: Do you really want to delete “{name}”?
|
||||||
delete-description: The course and all its slots will be permanently deleted.
|
delete-description: The course, its slots, tasks and files will be permanently deleted.
|
||||||
form:
|
form:
|
||||||
description: Slots are then added from the timetable.
|
description: Slots are then added from the timetable.
|
||||||
name: Name
|
name: Name
|
||||||
|
|
@ -68,9 +70,38 @@ courses:
|
||||||
teachers: Add at least one teacher.
|
teachers: Add at least one teacher.
|
||||||
main-page: The address must start with http:// or https://.
|
main-page: The address must start with http:// or https://.
|
||||||
slot-time: Each slot must start before it ends, between 8:00 and 19:00.
|
slot-time: Each slot must start before it ends, between 8:00 and 19:00.
|
||||||
|
associations:
|
||||||
|
new: New association
|
||||||
|
edit: Edit association
|
||||||
|
empty: No association yet.
|
||||||
|
empty-hint: Add your associations to follow their tasks and meetings.
|
||||||
|
load-error: Unable to load the associations.
|
||||||
|
created: Association created
|
||||||
|
updated: Association updated
|
||||||
|
deleted: Association deleted
|
||||||
|
save-error: Unable to save the association
|
||||||
|
delete-error: Unable to delete the association
|
||||||
|
delete-title: Do you really want to delete “{name}”?
|
||||||
|
delete-description: The association, its slots, tasks and files will be permanently deleted.
|
||||||
|
form:
|
||||||
|
description: Its meetings are then added from the Deadlines tab or the timetable.
|
||||||
|
name-placeholder: Cultural committee
|
||||||
|
code: Acronym
|
||||||
|
code-placeholder: CoCu
|
||||||
|
main-page-placeholder: https://…
|
||||||
|
association:
|
||||||
|
back: Back to associations
|
||||||
|
not-found: This association does not exist.
|
||||||
|
tabs:
|
||||||
|
files: Notes
|
||||||
|
deadlines: Deadlines
|
||||||
|
deadlines:
|
||||||
|
hint: Click or drag on the week to add a weekly slot (meeting…).
|
||||||
|
weekend: 'This weekend:'
|
||||||
course:
|
course:
|
||||||
back: Back to courses
|
back: Back to courses
|
||||||
not-found: This course does not exist.
|
not-found: This course does not exist.
|
||||||
|
folder: Folder of the files on the NAS
|
||||||
tabs:
|
tabs:
|
||||||
files: Lectures
|
files: Lectures
|
||||||
tasks: Deadlines
|
tasks: Deadlines
|
||||||
|
|
@ -139,8 +170,11 @@ tasks:
|
||||||
add-subtask: Add a subtask…
|
add-subtask: Add a subtask…
|
||||||
delete-title: Do you really want to delete “{name}”?
|
delete-title: Do you really want to delete “{name}”?
|
||||||
delete-description: Its subtasks will be deleted too.
|
delete-description: Its subtasks will be deleted too.
|
||||||
|
time-spent: '{time} of work'
|
||||||
form:
|
form:
|
||||||
description: Homework, an exam or anything else to do.
|
description: Homework, an exam or anything else to do.
|
||||||
|
course: Course or association
|
||||||
|
course-placeholder: Choose…
|
||||||
name: Name
|
name: Name
|
||||||
name-placeholder: Exercise set 3
|
name-placeholder: Exercise set 3
|
||||||
tags: Tags
|
tags: Tags
|
||||||
|
|
@ -193,7 +227,9 @@ notes:
|
||||||
theorem: Theorem
|
theorem: Theorem
|
||||||
example: Example
|
example: Example
|
||||||
files:
|
files:
|
||||||
title: Course files
|
title:
|
||||||
|
course: Course files
|
||||||
|
association: Notes and files
|
||||||
description: Slides, lecture notes and documents, soon stored on your NAS.
|
description: Slides, lecture notes and documents, soon stored on your NAS.
|
||||||
upload: Upload
|
upload: Upload
|
||||||
coming-soon: File uploads are coming soon.
|
coming-soon: File uploads are coming soon.
|
||||||
|
|
@ -205,17 +241,52 @@ files:
|
||||||
delete-title: Do you really want to delete “{name}”?
|
delete-title: Do you really want to delete “{name}”?
|
||||||
delete-description: The file will be permanently deleted.
|
delete-description: The file will be permanently deleted.
|
||||||
calendar:
|
calendar:
|
||||||
tabs:
|
show: Show
|
||||||
courses: Courses
|
nothing-shown: Turn on the courses or the associations to see their tasks.
|
||||||
associations: Associations
|
|
||||||
today: Today
|
today: Today
|
||||||
previous: Previous month
|
previous: Previous month
|
||||||
next: Next month
|
next: Next month
|
||||||
|
this-week: This week
|
||||||
|
previous-week: Previous week
|
||||||
|
next-week: Next week
|
||||||
more: '+{n} more'
|
more: '+{n} more'
|
||||||
nothing: Nothing due that day.
|
nothing: Nothing due that day.
|
||||||
|
add-that-day: Add
|
||||||
load-error: Unable to load the deadlines.
|
load-error: Unable to load the deadlines.
|
||||||
associations:
|
todo:
|
||||||
title: Association tasks
|
title: To do
|
||||||
coming-soon: The tasks of your associations will show up here. Associations are coming soon.
|
empty: Nothing to do, enjoy!
|
||||||
|
late: Late
|
||||||
|
today: Today
|
||||||
|
week: This week
|
||||||
|
later: Later
|
||||||
|
undated: No deadline
|
||||||
|
focus:
|
||||||
|
start: Work on it (pomodoro)
|
||||||
|
back: Back to the pomodoro
|
||||||
|
pick: What do you want to work on?
|
||||||
|
no-task: No task to do.
|
||||||
|
start-free: Free session
|
||||||
|
free: Free session
|
||||||
|
elapsed: Focus time
|
||||||
|
pause: Pause
|
||||||
|
resume: Resume
|
||||||
|
stop: Finish
|
||||||
|
paused: Paused for {time}
|
||||||
|
suggested-break: suggested break {time}
|
||||||
|
cycles: '{n} cycle of 25 min | {n} cycles of 25 min'
|
||||||
|
cycle-done: '{n} × 25 min of focus'
|
||||||
|
cycle-hint: Well done! Take a break when you reach a good stopping point.
|
||||||
|
total: '{time} in total on this task'
|
||||||
|
space-hint: Space to pause
|
||||||
|
stopped: Session finished · {time}
|
||||||
|
resources: Resources
|
||||||
|
no-resource: No notes or slides for this course.
|
||||||
|
open-tab: Open in a new tab
|
||||||
|
close-resource: Close
|
||||||
|
fullscreen: Full screen
|
||||||
|
exit-fullscreen: Exit full screen
|
||||||
|
error: Unable to reach the server, the session did not change
|
||||||
|
timetagger-error: TimeTagger could not be updated
|
||||||
placeholder:
|
placeholder:
|
||||||
coming-soon: This page is coming soon.
|
coming-soon: This page is coming soon.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ nav:
|
||||||
home: Accueil
|
home: Accueil
|
||||||
schedule: Emploi du temps
|
schedule: Emploi du temps
|
||||||
courses: Cours
|
courses: Cours
|
||||||
|
associations: Associations
|
||||||
pomodoro: Pomodoro
|
pomodoro: Pomodoro
|
||||||
calendar: Calendrier
|
calendar: Calendrier
|
||||||
settings: Configuration
|
settings: Configuration
|
||||||
|
|
@ -16,7 +17,8 @@ home:
|
||||||
sections:
|
sections:
|
||||||
schedule: Consulte ton emploi du temps de la semaine.
|
schedule: Consulte ton emploi du temps de la semaine.
|
||||||
courses: Retrouve tes cours et leurs ressources.
|
courses: Retrouve tes cours et leurs ressources.
|
||||||
pomodoro: Travaille par sessions concentrées avec des pauses régulières.
|
associations: Suis les tâches et réunions de tes associations.
|
||||||
|
pomodoro: Concentre-toi sur une tâche, le temps est compté pour toi.
|
||||||
calendar: Garde un œil sur tes examens et échéances.
|
calendar: Garde un œil sur tes examens et échéances.
|
||||||
theme:
|
theme:
|
||||||
dark: Passer en mode sombre
|
dark: Passer en mode sombre
|
||||||
|
|
@ -47,7 +49,7 @@ courses:
|
||||||
save-error: Impossible d'enregistrer le cours
|
save-error: Impossible d'enregistrer le cours
|
||||||
delete-error: Impossible de supprimer le cours
|
delete-error: Impossible de supprimer le cours
|
||||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||||
delete-description: Le cours et tous ses créneaux seront supprimés définitivement.
|
delete-description: Le cours, ses créneaux, ses tâches et ses fichiers seront supprimés définitivement.
|
||||||
form:
|
form:
|
||||||
description: Les créneaux s'ajoutent ensuite depuis l'emploi du temps.
|
description: Les créneaux s'ajoutent ensuite depuis l'emploi du temps.
|
||||||
name: Nom
|
name: Nom
|
||||||
|
|
@ -68,9 +70,38 @@ courses:
|
||||||
teachers: Ajoute au moins un·e enseignant·e.
|
teachers: Ajoute au moins un·e enseignant·e.
|
||||||
main-page: L'adresse doit commencer par http:// ou https://.
|
main-page: L'adresse doit commencer par http:// ou https://.
|
||||||
slot-time: Chaque créneau doit commencer avant de finir, entre 8h et 19h.
|
slot-time: Chaque créneau doit commencer avant de finir, entre 8h et 19h.
|
||||||
|
associations:
|
||||||
|
new: Nouvelle association
|
||||||
|
edit: Modifier l'association
|
||||||
|
empty: Aucune association pour le moment.
|
||||||
|
empty-hint: Ajoute tes associations pour suivre leurs tâches et réunions.
|
||||||
|
load-error: Impossible de charger les associations.
|
||||||
|
created: Association créée
|
||||||
|
updated: Association modifiée
|
||||||
|
deleted: Association supprimée
|
||||||
|
save-error: Impossible d'enregistrer l'association
|
||||||
|
delete-error: Impossible de supprimer l'association
|
||||||
|
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||||
|
delete-description: L'association, ses créneaux, ses tâches et ses fichiers seront supprimés définitivement.
|
||||||
|
form:
|
||||||
|
description: Ses réunions s'ajoutent ensuite depuis l'onglet Échéances ou l'emploi du temps.
|
||||||
|
name-placeholder: Commission culturelle
|
||||||
|
code: Acronyme
|
||||||
|
code-placeholder: CoCu
|
||||||
|
main-page-placeholder: https://…
|
||||||
|
association:
|
||||||
|
back: Retour aux associations
|
||||||
|
not-found: Cette association n'existe pas.
|
||||||
|
tabs:
|
||||||
|
files: Notes
|
||||||
|
deadlines: Échéances
|
||||||
|
deadlines:
|
||||||
|
hint: Clique ou glisse sur la semaine pour ajouter un créneau récurrent (réunion…).
|
||||||
|
weekend: 'Ce week-end :'
|
||||||
course:
|
course:
|
||||||
back: Retour aux cours
|
back: Retour aux cours
|
||||||
not-found: Ce cours n'existe pas.
|
not-found: Ce cours n'existe pas.
|
||||||
|
folder: Dossier des fichiers sur le NAS
|
||||||
tabs:
|
tabs:
|
||||||
files: Cours
|
files: Cours
|
||||||
tasks: Échéances
|
tasks: Échéances
|
||||||
|
|
@ -139,8 +170,11 @@ tasks:
|
||||||
add-subtask: Ajouter une sous-tâche…
|
add-subtask: Ajouter une sous-tâche…
|
||||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||||
delete-description: Ses sous-tâches seront aussi supprimées.
|
delete-description: Ses sous-tâches seront aussi supprimées.
|
||||||
|
time-spent: '{time} de travail'
|
||||||
form:
|
form:
|
||||||
description: Un devoir, un examen ou toute autre chose à faire.
|
description: Un devoir, un examen ou toute autre chose à faire.
|
||||||
|
course: Cours ou association
|
||||||
|
course-placeholder: Choisis…
|
||||||
name: Nom
|
name: Nom
|
||||||
name-placeholder: Série 3
|
name-placeholder: Série 3
|
||||||
tags: Étiquettes
|
tags: Étiquettes
|
||||||
|
|
@ -193,7 +227,9 @@ notes:
|
||||||
theorem: Théorème
|
theorem: Théorème
|
||||||
example: Exemple
|
example: Exemple
|
||||||
files:
|
files:
|
||||||
title: Fichiers du cours
|
title:
|
||||||
|
course: Fichiers du cours
|
||||||
|
association: Notes et fichiers
|
||||||
description: Slides, notes de cours et documents, bientôt stockés sur ton NAS.
|
description: Slides, notes de cours et documents, bientôt stockés sur ton NAS.
|
||||||
upload: Importer
|
upload: Importer
|
||||||
coming-soon: L'import de fichiers arrive bientôt.
|
coming-soon: L'import de fichiers arrive bientôt.
|
||||||
|
|
@ -205,17 +241,52 @@ files:
|
||||||
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
delete-title: Voulez-vous vraiment supprimer « {name} » ?
|
||||||
delete-description: Le fichier sera supprimé définitivement.
|
delete-description: Le fichier sera supprimé définitivement.
|
||||||
calendar:
|
calendar:
|
||||||
tabs:
|
show: Afficher
|
||||||
courses: Cours
|
nothing-shown: Active les cours ou les associations pour voir leurs tâches.
|
||||||
associations: Associatif
|
|
||||||
today: Aujourd'hui
|
today: Aujourd'hui
|
||||||
previous: Mois précédent
|
previous: Mois précédent
|
||||||
next: Mois suivant
|
next: Mois suivant
|
||||||
|
this-week: Cette semaine
|
||||||
|
previous-week: Semaine précédente
|
||||||
|
next-week: Semaine suivante
|
||||||
more: '+{n} autre | +{n} autres'
|
more: '+{n} autre | +{n} autres'
|
||||||
nothing: Rien à rendre ce jour-là.
|
nothing: Rien à rendre ce jour-là.
|
||||||
|
add-that-day: Ajouter
|
||||||
load-error: Impossible de charger les échéances.
|
load-error: Impossible de charger les échéances.
|
||||||
associations:
|
todo:
|
||||||
title: Tâches associatives
|
title: À faire
|
||||||
coming-soon: Les tâches de tes associations apparaîtront ici. Les associations arrivent bientôt.
|
empty: Rien à faire, profite !
|
||||||
|
late: En retard
|
||||||
|
today: Aujourd'hui
|
||||||
|
week: Cette semaine
|
||||||
|
later: Plus tard
|
||||||
|
undated: Sans échéance
|
||||||
|
focus:
|
||||||
|
start: Travailler dessus (pomodoro)
|
||||||
|
back: Revenir au pomodoro
|
||||||
|
pick: Sur quoi veux-tu travailler ?
|
||||||
|
no-task: Aucune tâche à faire.
|
||||||
|
start-free: Séance libre
|
||||||
|
free: Séance libre
|
||||||
|
elapsed: Temps de concentration
|
||||||
|
pause: Pause
|
||||||
|
resume: Reprendre
|
||||||
|
stop: Terminer
|
||||||
|
paused: En pause depuis {time}
|
||||||
|
suggested-break: pause conseillée {time}
|
||||||
|
cycles: '{n} cycle de 25 min | {n} cycles de 25 min'
|
||||||
|
cycle-done: '{n} × 25 min de concentration'
|
||||||
|
cycle-hint: Bravo ! Fais une pause quand tu arrives à un bon moment.
|
||||||
|
total: '{time} au total sur cette tâche'
|
||||||
|
space-hint: Espace pour mettre en pause
|
||||||
|
stopped: Séance terminée · {time}
|
||||||
|
resources: Ressources
|
||||||
|
no-resource: Aucune note ni slide pour ce cours.
|
||||||
|
open-tab: Ouvrir dans un nouvel onglet
|
||||||
|
close-resource: Fermer
|
||||||
|
fullscreen: Plein écran
|
||||||
|
exit-fullscreen: Quitter le plein écran
|
||||||
|
error: Impossible de joindre le serveur, la séance n'a pas changé
|
||||||
|
timetagger-error: TimeTagger n'a pas pu être mis à jour
|
||||||
placeholder:
|
placeholder:
|
||||||
coming-soon: Cette page arrive bientôt.
|
coming-soon: Cette page arrive bientôt.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||||
|
|
||||||
import HomeView from '@/views/HomeView.vue'
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
|
||||||
|
|
@ -21,30 +21,37 @@ const router = createRouter({
|
||||||
component: () => import('@/views/ScheduleView.vue'),
|
component: () => import('@/views/ScheduleView.vue'),
|
||||||
meta: { title: 'nav.schedule' },
|
meta: { title: 'nav.schedule' },
|
||||||
},
|
},
|
||||||
|
// The associations share the pages of the courses: see `utils/routes.ts`
|
||||||
|
...(['course', 'association'] as const).flatMap((kind) => {
|
||||||
|
const list = `${kind}s`
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
name: 'courses',
|
name: list,
|
||||||
path: '/courses',
|
path: `/${list}`,
|
||||||
component: () => import('@/views/CoursesView.vue'),
|
component: () => import('@/views/CoursesView.vue'),
|
||||||
meta: { title: 'nav.courses' },
|
props: { kind },
|
||||||
|
meta: { title: `nav.${list}` },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'course',
|
name: kind,
|
||||||
path: '/courses/:id(\\d+)',
|
path: `/${list}/:id(\\d+)`,
|
||||||
component: () => import('@/views/CourseView.vue'),
|
component: () => import('@/views/CourseView.vue'),
|
||||||
props: (route) => ({ id: Number(route.params.id) }),
|
props: (route: RouteLocationNormalized) => ({ id: Number(route.params.id) }),
|
||||||
meta: { title: 'nav.courses', section: 'courses' },
|
meta: { title: `nav.${list}`, section: list },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'course-note',
|
name: `${kind}-note`,
|
||||||
// `new` to write a new note
|
// `new` to write a new note
|
||||||
path: '/courses/:id(\\d+)/notes/:noteId(\\d+|new)',
|
path: `/${list}/:id(\\d+)/notes/:noteId(\\d+|new)`,
|
||||||
component: () => import('@/views/LectureNoteView.vue'),
|
component: () => import('@/views/LectureNoteView.vue'),
|
||||||
props: (route) => ({
|
props: (route: RouteLocationNormalized) => ({
|
||||||
id: Number(route.params.id),
|
id: Number(route.params.id),
|
||||||
noteId: route.params.noteId === 'new' ? undefined : Number(route.params.noteId),
|
noteId: route.params.noteId === 'new' ? undefined : Number(route.params.noteId),
|
||||||
}),
|
}),
|
||||||
meta: { title: 'nav.courses', section: 'courses' },
|
meta: { title: `nav.${list}`, section: list },
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
name: 'pomodoro',
|
name: 'pomodoro',
|
||||||
path: '/pomodoro',
|
path: '/pomodoro',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { BookOpen, CalendarClock, CalendarDays, Timer } from '@lucide/vue'
|
import { BookOpen, CalendarClock, CalendarDays, Timer, Users } from '@lucide/vue'
|
||||||
|
|
||||||
export interface NavigationEntry {
|
export interface NavigationEntry {
|
||||||
/** Route name, see `router/index.ts` */
|
/** Route name, see `router/index.ts` */
|
||||||
|
|
@ -21,6 +21,12 @@ export const sections: NavigationEntry[] = [
|
||||||
icon: CalendarClock,
|
icon: CalendarClock,
|
||||||
},
|
},
|
||||||
{ name: 'courses', title: 'nav.courses', description: 'home.sections.courses', icon: BookOpen },
|
{ name: 'courses', title: 'nav.courses', description: 'home.sections.courses', icon: BookOpen },
|
||||||
|
{
|
||||||
|
name: 'associations',
|
||||||
|
title: 'nav.associations',
|
||||||
|
description: 'home.sections.associations',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
{ name: 'pomodoro', title: 'nav.pomodoro', description: 'home.sections.pomodoro', icon: Timer },
|
{ name: 'pomodoro', title: 'nav.pomodoro', description: 'home.sections.pomodoro', icon: Timer },
|
||||||
{
|
{
|
||||||
name: 'calendar',
|
name: 'calendar',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
import type { CourseFile, LectureNoteBody } from '@/utils/types'
|
import type { CourseFile, LectureNoteBody } from '@/utils/types'
|
||||||
import { getClient } from './client'
|
import { getClient } from './client'
|
||||||
|
|
||||||
function filesKey(courseId: MaybeRefOrGetter<number>) {
|
function filesKey(courseId: MaybeRefOrGetter<number | undefined>) {
|
||||||
return ['course', courseId, 'files']
|
return ['course', courseId, 'files']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,13 +22,14 @@ export function lectureNotePdfUrl(note: CourseFile) {
|
||||||
return `/api/notes/${note.id}/pdf?v=${encodeURIComponent(note.updated_at)}`
|
return `/api/notes/${note.id}/pdf?v=${encodeURIComponent(note.updated_at)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Files of a course (lecture notes...), without their content */
|
/** Files of a course (lecture notes...), without their content. Disabled without course. */
|
||||||
export function useCourseFiles(courseId: MaybeRefOrGetter<number>) {
|
export function useCourseFiles(courseId: MaybeRefOrGetter<number | undefined>) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: filesKey(courseId),
|
queryKey: filesKey(courseId),
|
||||||
|
enabled: () => toValue(courseId) !== undefined,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, response } = await getClient().GET('/api/courses/{id}/files', {
|
const { data, response } = await getClient().GET('/api/courses/{id}/files', {
|
||||||
params: { path: { id: toValue(courseId) } },
|
params: { path: { id: toValue(courseId)! } },
|
||||||
})
|
})
|
||||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query'
|
||||||
import { HttpStatus } from 'http-status-ts'
|
import { HttpStatus } from 'http-status-ts'
|
||||||
import { toValue, type MaybeRefOrGetter } from 'vue'
|
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
import type { Course, CourseBody, TimeSlotBody } from '@/utils/types'
|
import type { Course, CourseBody, CourseKind, TimeSlotBody } from '@/utils/types'
|
||||||
import { getClient } from './client'
|
import { getClient } from './client'
|
||||||
|
|
||||||
const COURSES_KEY = ['courses']
|
const COURSES_KEY = ['courses']
|
||||||
|
|
@ -23,6 +23,7 @@ function storeCourse(queryClient: QueryClient, course: Course) {
|
||||||
queryClient.setQueryData(courseKey(course.id), course)
|
queryClient.setQueryData(courseKey(course.id), course)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every course and association, sorted by name */
|
||||||
export function useCourses() {
|
export function useCourses() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: COURSES_KEY,
|
queryKey: COURSES_KEY,
|
||||||
|
|
@ -36,6 +37,13 @@ export function useCourses() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only the courses, or only the associations */
|
||||||
|
export function useCoursesOfKind(kind: MaybeRefOrGetter<CourseKind>) {
|
||||||
|
const query = useCourses()
|
||||||
|
const data = computed(() => query.data.value?.filter((c) => c.kind === toValue(kind)))
|
||||||
|
return { ...query, data }
|
||||||
|
}
|
||||||
|
|
||||||
export function useCourse(id: MaybeRefOrGetter<number>) {
|
export function useCourse(id: MaybeRefOrGetter<number>) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { HttpStatus } from 'http-status-ts'
|
import { HttpStatus } from 'http-status-ts'
|
||||||
import { toValue, type MaybeRefOrGetter } from 'vue'
|
import { toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
import type { CourseTasks, CreateTaskBody, Task, TaskBody } from '@/utils/types'
|
import type {
|
||||||
|
CourseTasks,
|
||||||
|
CreateTaskBody,
|
||||||
|
FocusStart,
|
||||||
|
FocusStop,
|
||||||
|
Task,
|
||||||
|
TaskBody,
|
||||||
|
TimetaggerRecord,
|
||||||
|
} from '@/utils/types'
|
||||||
import { getClient } from './client'
|
import { getClient } from './client'
|
||||||
|
|
||||||
// Every mutation answers with all the tasks of the course (nested, sorted) and the
|
// Every mutation answers with all the tasks of the course (nested, sorted) and the
|
||||||
|
|
@ -12,6 +20,8 @@ function tasksKey(courseId: MaybeRefOrGetter<number>) {
|
||||||
return ['course', courseId, 'tasks']
|
return ['course', courseId, 'tasks']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TODO_KEY = ['todo-tasks']
|
||||||
|
|
||||||
function unexpected(status: number): never {
|
function unexpected(status: number): never {
|
||||||
throw new Error(`Unexpected status code received: ${status}`)
|
throw new Error(`Unexpected status code received: ${status}`)
|
||||||
}
|
}
|
||||||
|
|
@ -57,18 +67,34 @@ export function useCalendarTasks(period: MaybeRefOrGetter<{ from: string; to: st
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
/** Tasks not done of every course and association, the closest deadline first */
|
||||||
|
export function useTodoTasks() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: TODO_KEY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/tasks/todo')
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutations of the tasks of any course: the course is given with each call. See
|
||||||
|
* `useTaskMutations` for the tasks of a single course.
|
||||||
|
*/
|
||||||
|
export function useAnyTaskMutations() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const onSuccess = (tasks: CourseTasks) => {
|
const onSuccess = (tasks: CourseTasks, { courseId }: { courseId: number }) => {
|
||||||
queryClient.setQueryData(tasksKey(toValue(courseId)), tasks)
|
queryClient.setQueryData(tasksKey(courseId), tasks)
|
||||||
// Refetched when the calendar is shown again
|
// Refetched when they are shown again
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-tasks'] })
|
queryClient.invalidateQueries({ queryKey: ['calendar-tasks'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: TODO_KEY })
|
||||||
}
|
}
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async (body: CreateTaskBody) => {
|
mutationFn: async ({ courseId, body }: { courseId: number; body: CreateTaskBody }) => {
|
||||||
const { data, response } = await getClient().POST('/api/courses/{id}/tasks', {
|
const { data, response } = await getClient().POST('/api/courses/{id}/tasks', {
|
||||||
params: { path: { id: toValue(courseId) } },
|
params: { path: { id: courseId } },
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
|
@ -77,7 +103,7 @@ export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: async ({ id, body }: { id: number; body: TaskBody }) => {
|
mutationFn: async ({ id, body }: { courseId: number; id: number; body: TaskBody }) => {
|
||||||
const { data, response } = await getClient().PUT('/api/tasks/{id}', {
|
const { data, response } = await getClient().PUT('/api/tasks/{id}', {
|
||||||
params: { path: { id } },
|
params: { path: { id } },
|
||||||
body,
|
body,
|
||||||
|
|
@ -88,7 +114,7 @@ export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async ({ id }: { courseId: number; id: number }) => {
|
||||||
const { data, response } = await getClient().DELETE('/api/tasks/{id}', {
|
const { data, response } = await getClient().DELETE('/api/tasks/{id}', {
|
||||||
params: { path: { id } },
|
params: { path: { id } },
|
||||||
})
|
})
|
||||||
|
|
@ -99,3 +125,54 @@ export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
|
||||||
return { create, update, remove }
|
return { create, update, remove }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mutations of the tasks of a single course */
|
||||||
|
export function useTaskMutations(courseId: MaybeRefOrGetter<number>) {
|
||||||
|
const { create, update, remove } = useAnyTaskMutations()
|
||||||
|
return {
|
||||||
|
create: {
|
||||||
|
...create,
|
||||||
|
mutateAsync: (body: CreateTaskBody) =>
|
||||||
|
create.mutateAsync({ courseId: toValue(courseId), body }),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...update,
|
||||||
|
mutateAsync: ({ id, body }: { id: number; body: TaskBody }) =>
|
||||||
|
update.mutateAsync({ courseId: toValue(courseId), id, body }),
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
...remove,
|
||||||
|
mutateAsync: (id: number) => remove.mutateAsync({ courseId: toValue(courseId), id }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a focus session on a task, and its TimeTagger record */
|
||||||
|
export async function startFocus(taskId: number): Promise<FocusStart> {
|
||||||
|
const { data, response } = await getClient().POST('/api/tasks/{id}/focus', {
|
||||||
|
params: { path: { id: taskId } },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends (or pauses) a focus session: its time is added to the task */
|
||||||
|
export async function stopFocus(
|
||||||
|
taskId: number,
|
||||||
|
seconds: number,
|
||||||
|
timetagger: TimetaggerRecord | null,
|
||||||
|
): Promise<FocusStop> {
|
||||||
|
const { data, response } = await getClient().POST('/api/tasks/{id}/focus/stop', {
|
||||||
|
params: { path: { id: taskId } },
|
||||||
|
body: { seconds, timetagger },
|
||||||
|
})
|
||||||
|
return response.status === HttpStatus.OK && data ? data : unexpected(response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The time spent on the tasks changed: shown again from the server */
|
||||||
|
export function useRefreshTasks() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return (courseId: number) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: tasksKey(courseId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: TODO_KEY })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
204
frontend/src/services/focus.ts
Normal file
204
frontend/src/services/focus.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* Focus session of the pomodoro, shared by every page and kept across reloads. The
|
||||||
|
* pomodoro counts up: the session runs until it is paused or stopped.
|
||||||
|
*
|
||||||
|
* On a task, every run (from a start or a resume to a pause or a stop) is sent to the
|
||||||
|
* backend, which adds its time to the task and counts it in TimeTagger. A free session,
|
||||||
|
* without task, stays in the browser.
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { startFocus, stopFocus } from '@/services/api/tasks'
|
||||||
|
import type { FocusStart, TimetaggerRecord } from '@/utils/types'
|
||||||
|
|
||||||
|
/** One cycle of the classic pomodoro, in seconds: a break is suggested after each */
|
||||||
|
export const CYCLE_SECONDS = 25 * 60
|
||||||
|
|
||||||
|
export interface FocusSession {
|
||||||
|
/** The task, `null` for a free session */
|
||||||
|
task: FocusStart | null
|
||||||
|
/** Seconds of the runs before the current one */
|
||||||
|
accumulated: number
|
||||||
|
/** Start of the current run (ms), `null` while paused */
|
||||||
|
runningSince: number | null
|
||||||
|
/** Start of the pause (ms), `null` while running */
|
||||||
|
pausedAt: number | null
|
||||||
|
/** TimeTagger record of the current run */
|
||||||
|
record: TimetaggerRecord | null
|
||||||
|
/** Seconds spent on the task before the session */
|
||||||
|
spentBefore: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'focus-session'
|
||||||
|
|
||||||
|
function load(): FocusSession | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as FocusSession) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = ref<FocusSession | null>(load())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
session,
|
||||||
|
(value) => {
|
||||||
|
try {
|
||||||
|
if (value) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||||
|
else window.localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// Private browsing: the session is only lost on reload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Current time, refreshed every second */
|
||||||
|
const now = ref(Date.now())
|
||||||
|
window.setInterval(() => (now.value = Date.now()), 1000)
|
||||||
|
|
||||||
|
/** Seconds of the current run */
|
||||||
|
function runSeconds(value: FocusSession, at = now.value) {
|
||||||
|
return value.runningSince === null ? 0 : Math.max(0, Math.round((at - value.runningSince) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds of focus of the session */
|
||||||
|
const elapsed = computed(() =>
|
||||||
|
session.value ? session.value.accumulated + runSeconds(session.value) : 0,
|
||||||
|
)
|
||||||
|
/** Seconds of the current pause */
|
||||||
|
const pauseElapsed = computed(() =>
|
||||||
|
session.value?.pausedAt
|
||||||
|
? Math.max(0, Math.round((now.value - session.value.pausedAt) / 1000))
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
const isRunning = computed(() => session.value?.runningSince != null)
|
||||||
|
|
||||||
|
/** Busy talking to the backend */
|
||||||
|
const pending = ref(false)
|
||||||
|
|
||||||
|
async function withPending<T>(action: () => Promise<T>): Promise<T> {
|
||||||
|
pending.value = true
|
||||||
|
try {
|
||||||
|
return await action()
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a run of `seconds` to the backend. Returns the TimeTagger error, if any. */
|
||||||
|
async function endRun(value: FocusSession, seconds: number): Promise<string | null> {
|
||||||
|
if (!value.task) return null
|
||||||
|
const result = await stopFocus(value.task.task_id, seconds, value.record)
|
||||||
|
return result.timetagger_error ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a session on a task (`null`: a free session), ending the running one. Returns
|
||||||
|
* the TimeTagger error, if any: the session runs anyway.
|
||||||
|
*/
|
||||||
|
async function start(taskId: number | null): Promise<string | null> {
|
||||||
|
return withPending(async () => {
|
||||||
|
if (session.value) await stopNow()
|
||||||
|
if (taskId === null) {
|
||||||
|
session.value = {
|
||||||
|
task: null,
|
||||||
|
accumulated: 0,
|
||||||
|
runningSince: Date.now(),
|
||||||
|
pausedAt: null,
|
||||||
|
record: null,
|
||||||
|
spentBefore: 0,
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const task = await startFocus(taskId)
|
||||||
|
session.value = {
|
||||||
|
task,
|
||||||
|
accumulated: 0,
|
||||||
|
runningSince: new Date(task.started_at).getTime(),
|
||||||
|
pausedAt: null,
|
||||||
|
record: task.timetagger ?? null,
|
||||||
|
spentBefore: task.time_spent,
|
||||||
|
}
|
||||||
|
return task.timetagger_error ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(): Promise<string | null> {
|
||||||
|
const value = session.value
|
||||||
|
if (!value || value.runningSince === null) return null
|
||||||
|
return withPending(async () => {
|
||||||
|
const seconds = runSeconds(value, Date.now())
|
||||||
|
const error = await endRun(value, seconds)
|
||||||
|
value.accumulated += seconds
|
||||||
|
value.runningSince = null
|
||||||
|
value.pausedAt = Date.now()
|
||||||
|
value.record = null
|
||||||
|
return error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume(): Promise<string | null> {
|
||||||
|
const value = session.value
|
||||||
|
if (!value || value.runningSince !== null) return null
|
||||||
|
return withPending(async () => {
|
||||||
|
let error: string | null = null
|
||||||
|
if (value.task) {
|
||||||
|
const task = await startFocus(value.task.task_id)
|
||||||
|
value.task = task
|
||||||
|
value.record = task.timetagger ?? null
|
||||||
|
error = task.timetagger_error ?? null
|
||||||
|
}
|
||||||
|
value.runningSince = Date.now()
|
||||||
|
value.pausedAt = null
|
||||||
|
return error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopNow(): Promise<string | null> {
|
||||||
|
const value = session.value
|
||||||
|
if (!value) return null
|
||||||
|
const error =
|
||||||
|
value.runningSince === null ? null : await endRun(value, runSeconds(value, Date.now()))
|
||||||
|
session.value = null
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the session. Returns the TimeTagger error, if any. */
|
||||||
|
async function stop(): Promise<string | null> {
|
||||||
|
return withPending(stopNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFocus() {
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
elapsed,
|
||||||
|
pauseElapsed,
|
||||||
|
isRunning,
|
||||||
|
pending,
|
||||||
|
now,
|
||||||
|
start,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3725 -> "1:02:05", 125 -> "02:05" */
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const rest = seconds % 60
|
||||||
|
return hours ? `${hours}:${pad(minutes)}:${pad(rest)}` : `${pad(minutes)}:${pad(rest)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5400 -> "1 h 30", 600 -> "10 min" */
|
||||||
|
export function formatSpent(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (!hours) return `${minutes} min`
|
||||||
|
return `${hours} h ${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ export function dayKey(date: Date): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monday of the week of `date`, at midnight */
|
/** Monday of the week of `date`, at midnight */
|
||||||
function startOfWeek(date: Date): Date {
|
export function startOfWeek(date: Date): Date {
|
||||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
const offset = (day.getDay() + 6) % 7
|
const offset = (day.getDay() + 6) % 7
|
||||||
day.setDate(day.getDate() - offset)
|
day.setDate(day.getDate() - offset)
|
||||||
|
|
|
||||||
43
frontend/src/utils/routes.ts
Normal file
43
frontend/src/utils/routes.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/** Routes of the courses and the associations, which share their pages */
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import type { CourseKind } from '@/utils/types'
|
||||||
|
|
||||||
|
const NAMES = {
|
||||||
|
course: { list: 'courses', page: 'course', note: 'course-note' },
|
||||||
|
association: { list: 'associations', page: 'association', note: 'association-note' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function listRoute(kind: CourseKind): RouteLocationRaw {
|
||||||
|
return { name: NAMES[kind].list }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coursePageRoute(
|
||||||
|
course: { id: number; kind: CourseKind },
|
||||||
|
tab?: string,
|
||||||
|
): RouteLocationRaw {
|
||||||
|
return { name: NAMES[course.kind].page, params: { id: course.id }, query: tab ? { tab } : {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `noteId` 'new' to write a new note */
|
||||||
|
export function noteRoute(
|
||||||
|
course: { id: number; kind: CourseKind },
|
||||||
|
noteId: number | 'new',
|
||||||
|
edit = false,
|
||||||
|
): RouteLocationRaw {
|
||||||
|
return {
|
||||||
|
name: NAMES[course.kind].note,
|
||||||
|
params: { id: course.id, noteId },
|
||||||
|
query: edit ? { edit: '1' } : {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab listing the tasks, which differs between courses and associations */
|
||||||
|
export function tasksTab(kind: CourseKind) {
|
||||||
|
return kind === 'course' ? 'tasks' : 'deadlines'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the pomodoro on a task */
|
||||||
|
export function focusRoute(taskId: number): RouteLocationRaw {
|
||||||
|
return { name: 'pomodoro', query: { task: taskId } }
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ type Schemas = components['schemas']
|
||||||
|
|
||||||
export type LocalizedString = Schemas['LocalizedString']
|
export type LocalizedString = Schemas['LocalizedString']
|
||||||
export type Course = Schemas['Course']
|
export type Course = Schemas['Course']
|
||||||
|
export type CourseKind = Schemas['CourseKind']
|
||||||
|
export type CourseSummary = Schemas['CourseSummary']
|
||||||
export type CourseBody = Schemas['CourseBody']
|
export type CourseBody = Schemas['CourseBody']
|
||||||
export type TimeSlot = Schemas['TimeSlot']
|
export type TimeSlot = Schemas['TimeSlot']
|
||||||
export type TimeSlotBody = Schemas['TimeSlotBody']
|
export type TimeSlotBody = Schemas['TimeSlotBody']
|
||||||
|
|
@ -16,6 +18,10 @@ export type TaskBody = Schemas['TaskBody']
|
||||||
export type CreateTaskBody = Schemas['CreateTaskBody']
|
export type CreateTaskBody = Schemas['CreateTaskBody']
|
||||||
export type CourseTasks = Schemas['CourseTasks']
|
export type CourseTasks = Schemas['CourseTasks']
|
||||||
export type CalendarTask = Schemas['CalendarTask']
|
export type CalendarTask = Schemas['CalendarTask']
|
||||||
|
export type TodoTask = Schemas['TodoTask']
|
||||||
|
export type FocusStart = Schemas['FocusStart']
|
||||||
|
export type FocusStop = Schemas['FocusStop']
|
||||||
|
export type TimetaggerRecord = Schemas['TimetaggerRecord']
|
||||||
export type CourseFile = Schemas['CourseFile']
|
export type CourseFile = Schemas['CourseFile']
|
||||||
export type TagScope = Schemas['TagScope']
|
export type TagScope = Schemas['TagScope']
|
||||||
export type LectureNoteBody = Schemas['LectureNoteBody']
|
export type LectureNoteBody = Schemas['LectureNoteBody']
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,113 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** Deadlines of every course in a month calendar, and (soon) the ones of the associations */
|
/**
|
||||||
import { computed } from 'vue'
|
* Deadlines of the courses and associations in a month calendar, next to everything left
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
* to do. Courses and associations can each be hidden, and tasks created from here.
|
||||||
import { Users } from '@lucide/vue'
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { BookOpen, Plus, Users } from '@lucide/vue'
|
||||||
|
|
||||||
import CoursesCalendar from '@/components/calendar/CoursesCalendar.vue'
|
import TasksCalendar from '@/components/calendar/TasksCalendar.vue'
|
||||||
import { Card, CardDescription, CardTitle } from '@/components/ui/card'
|
import TodoList from '@/components/calendar/TodoList.vue'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import TaskFormDialog from '@/components/course/TaskFormDialog.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import type { CourseKind } from '@/utils/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const KINDS: { kind: CourseKind; icon: typeof BookOpen }[] = [
|
||||||
const router = useRouter()
|
{ kind: 'course', icon: BookOpen },
|
||||||
|
{ kind: 'association', icon: Users },
|
||||||
|
]
|
||||||
|
|
||||||
const TABS = ['courses', 'associations'] as const
|
// Remembered in the browser: only a convenience
|
||||||
type Tab = (typeof TABS)[number]
|
const STORAGE_KEY = 'calendar-kinds'
|
||||||
|
|
||||||
const tab = computed<Tab>({
|
function loadKinds(): CourseKind[] {
|
||||||
get: () => TABS.find((tab) => tab === route.query.tab) ?? 'courses',
|
try {
|
||||||
set: (tab) => router.replace({ query: { ...route.query, tab } }),
|
const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null')
|
||||||
|
if (Array.isArray(saved)) return saved.filter((k) => k === 'course' || k === 'association')
|
||||||
|
} catch {
|
||||||
|
// Unavailable or invalid: everything is shown
|
||||||
|
}
|
||||||
|
return ['course', 'association']
|
||||||
|
}
|
||||||
|
|
||||||
|
const kinds = ref<CourseKind[]>(loadKinds())
|
||||||
|
watch(kinds, (value) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// Not remembered
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toggle(kind: CourseKind) {
|
||||||
|
kinds.value = kinds.value.includes(kind)
|
||||||
|
? kinds.value.filter((k) => k !== kind)
|
||||||
|
: [...kinds.value, kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const defaultDeadline = ref<string>()
|
||||||
|
|
||||||
|
/** New task, due at the end of `day` when given */
|
||||||
|
function openForm(day?: Date) {
|
||||||
|
if (day) {
|
||||||
|
const end = new Date(day)
|
||||||
|
end.setHours(23, 59, 0, 0)
|
||||||
|
defaultDeadline.value = end.toISOString()
|
||||||
|
} else {
|
||||||
|
defaultDeadline.value = undefined
|
||||||
|
}
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const nothingShown = computed(() => !kinds.value.length)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Tabs v-model="tab" class="mx-auto max-w-6xl gap-4">
|
<div class="mx-auto flex max-w-7xl flex-col gap-4">
|
||||||
<TabsList>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<TabsTrigger v-for="name in TABS" :key="name" :value="name">
|
<div class="flex gap-2" role="group" :aria-label="$t('calendar.show')">
|
||||||
{{ $t(`calendar.tabs.${name}`) }}
|
<button
|
||||||
</TabsTrigger>
|
v-for="{ kind, icon } in KINDS"
|
||||||
</TabsList>
|
:key="kind"
|
||||||
<TabsContent value="courses" class="min-w-0">
|
type="button"
|
||||||
<CoursesCalendar />
|
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors"
|
||||||
</TabsContent>
|
:class="
|
||||||
<TabsContent value="associations">
|
kinds.includes(kind)
|
||||||
<Card class="mx-auto flex max-w-md flex-col items-center gap-2 p-8 text-center">
|
? 'border-primary bg-primary/10 font-medium text-primary'
|
||||||
<Users class="mb-2 size-8 text-muted-foreground" />
|
: 'text-muted-foreground hover:bg-accent'
|
||||||
<CardTitle class="text-base">{{ $t('calendar.associations.title') }}</CardTitle>
|
"
|
||||||
<CardDescription>{{ $t('calendar.associations.coming-soon') }}</CardDescription>
|
:aria-pressed="kinds.includes(kind)"
|
||||||
|
@click="toggle(kind)"
|
||||||
|
>
|
||||||
|
<component :is="icon" class="size-4" />
|
||||||
|
{{ $t(`nav.${kind}s`) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button class="ml-auto" @click="openForm()">
|
||||||
|
<Plus />
|
||||||
|
{{ $t('tasks.new') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="nothingShown" class="text-sm text-muted-foreground">
|
||||||
|
{{ $t('calendar.nothing-shown') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
|
<TasksCalendar :kinds="kinds" class="min-w-0" @create="openForm" />
|
||||||
|
|
||||||
|
<Card class="lg:sticky lg:top-4 lg:max-h-[calc(100svh-6rem)] lg:overflow-y-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">{{ $t('calendar.todo.title') }}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TodoList :kinds="kinds" />
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</div>
|
||||||
</Tabs>
|
|
||||||
|
<TaskFormDialog v-model:open="formOpen" :default-deadline="defaultDeadline" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** Personal page of a course: its files, deadlines, timetable and grades */
|
/**
|
||||||
import { computed } from 'vue'
|
* Personal page of a course: its files, deadlines, timetable and grades. An association
|
||||||
|
* has its notes, and its deadlines on top of its timetable.
|
||||||
|
*/
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, ExternalLink } from '@lucide/vue'
|
import { ArrowLeft, ExternalLink, Folder } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AssociationDeadlinesTab from '@/components/course/AssociationDeadlinesTab.vue'
|
||||||
import CourseFilesTab from '@/components/course/CourseFilesTab.vue'
|
import CourseFilesTab from '@/components/course/CourseFilesTab.vue'
|
||||||
import CourseGradesTab from '@/components/course/CourseGradesTab.vue'
|
import CourseGradesTab from '@/components/course/CourseGradesTab.vue'
|
||||||
import CourseScheduleTab from '@/components/course/CourseScheduleTab.vue'
|
import CourseScheduleTab from '@/components/course/CourseScheduleTab.vue'
|
||||||
|
|
@ -14,6 +18,7 @@ import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { useCourse } from '@/services/api/courses'
|
import { useCourse } from '@/services/api/courses'
|
||||||
|
import { coursePageRoute, listRoute } from '@/utils/routes'
|
||||||
|
|
||||||
const props = defineProps<{ id: number }>()
|
const props = defineProps<{ id: number }>()
|
||||||
|
|
||||||
|
|
@ -21,22 +26,36 @@ const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: course, isPending, isError, refetch } = useCourse(() => props.id)
|
const { data: course, isPending, isError, refetch } = useCourse(() => props.id)
|
||||||
|
|
||||||
const TABS = ['files', 'tasks', 'schedule', 'grades'] as const
|
/** Kind of the page, from the route: the course may not be loaded yet */
|
||||||
type Tab = (typeof TABS)[number]
|
const kind = computed(() => (route.name === 'association' ? 'association' : 'course'))
|
||||||
|
const tabs = computed(() =>
|
||||||
|
kind.value === 'course' ? ['files', 'tasks', 'schedule', 'grades'] : ['files', 'deadlines'],
|
||||||
|
)
|
||||||
|
|
||||||
// In the url, so that coming back from a note opens the files again
|
// In the url, so that coming back from a note opens the files again
|
||||||
const tab = computed<Tab>({
|
const tab = computed<string>({
|
||||||
get: () => TABS.find((tab) => tab === route.query.tab) ?? 'files',
|
get: () => tabs.value.find((tab) => tab === route.query.tab) ?? 'files',
|
||||||
set: (tab) => router.replace({ query: { ...route.query, tab } }),
|
set: (tab) => router.replace({ query: { ...route.query, tab } }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A course opened as an association (or the other way round): to its own page
|
||||||
|
watch(
|
||||||
|
() => course.value?.kind,
|
||||||
|
(courseKind) => {
|
||||||
|
if (courseKind && courseKind !== kind.value) {
|
||||||
|
router.replace(coursePageRoute(course.value!, route.query.tab as string | undefined))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto flex max-w-6xl flex-col gap-4">
|
<div class="mx-auto flex max-w-6xl flex-col gap-4">
|
||||||
<Button variant="ghost" class="self-start" as-child>
|
<Button variant="ghost" class="self-start" as-child>
|
||||||
<RouterLink :to="{ name: 'courses' }">
|
<RouterLink :to="listRoute(kind)">
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
{{ $t('course.back') }}
|
{{ $t(`${kind}.back`) }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
@ -44,7 +63,7 @@ const tab = computed<Tab>({
|
||||||
|
|
||||||
<Card v-else-if="isError || !course" class="mx-auto max-w-md text-center">
|
<Card v-else-if="isError || !course" class="mx-auto max-w-md text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ $t('course.not-found') }}</CardTitle>
|
<CardTitle>{{ $t(`${kind}.not-found`) }}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter class="justify-center">
|
<CardFooter class="justify-center">
|
||||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
|
@ -60,7 +79,16 @@ const tab = computed<Tab>({
|
||||||
{{ course.code }}
|
{{ course.code }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">{{ course.teachers.join(', ') }}</p>
|
<p v-if="course.teachers.length" class="text-sm text-muted-foreground">
|
||||||
|
{{ course.teachers.join(', ') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
:title="$t('course.folder')"
|
||||||
|
>
|
||||||
|
<Folder class="size-3.5" />
|
||||||
|
<span class="font-mono">{{ course.folder }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="course.main_page_url" variant="outline" as-child>
|
<Button v-if="course.main_page_url" variant="outline" as-child>
|
||||||
<a :href="course.main_page_url" target="_blank" rel="noopener noreferrer">
|
<a :href="course.main_page_url" target="_blank" rel="noopener noreferrer">
|
||||||
|
|
@ -72,12 +100,15 @@ const tab = computed<Tab>({
|
||||||
|
|
||||||
<Tabs v-model="tab" class="gap-4">
|
<Tabs v-model="tab" class="gap-4">
|
||||||
<TabsList class="w-full justify-start overflow-x-auto sm:w-auto">
|
<TabsList class="w-full justify-start overflow-x-auto sm:w-auto">
|
||||||
<TabsTrigger v-for="name in TABS" :key="name" :value="name">
|
<TabsTrigger v-for="name in tabs" :key="name" :value="name">
|
||||||
{{ $t(`course.tabs.${name}`) }}
|
{{ $t(`${kind}.tabs.${name}`) }}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="files">
|
<TabsContent value="files">
|
||||||
<CourseFilesTab :course-id="course.id" />
|
<CourseFilesTab :course="course" />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="deadlines" class="min-w-0">
|
||||||
|
<AssociationDeadlinesTab :course="course" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="tasks">
|
<TabsContent value="tasks">
|
||||||
<CourseTasksTab :course-id="course.id" />
|
<CourseTasksTab :course-id="course.id" />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
/** List of the courses, or of the associations: both are managed the same way */
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { BookOpen, Pencil, Plus, Trash2 } from '@lucide/vue'
|
import { BookOpen, Pencil, Plus, Trash2, Users } from '@lucide/vue'
|
||||||
|
|
||||||
import CourseFormDialog from '@/components/courses/CourseFormDialog.vue'
|
import CourseFormDialog from '@/components/courses/CourseFormDialog.vue'
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,11 +20,17 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useCourses, useDeleteCourse } from '@/services/api/courses'
|
import { useCoursesOfKind, useDeleteCourse } from '@/services/api/courses'
|
||||||
import type { Course } from '@/utils/types'
|
import { coursePageRoute } from '@/utils/routes'
|
||||||
|
import type { Course, CourseKind } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ kind: CourseKind }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { data: courses, isPending, isError, refetch } = useCourses()
|
const { data: courses, isPending, isError, refetch } = useCoursesOfKind(() => props.kind)
|
||||||
|
|
||||||
|
/** Prefix of the texts of the page */
|
||||||
|
const k = computed(() => (props.kind === 'course' ? 'courses' : 'associations'))
|
||||||
const deleteCourse = useDeleteCourse()
|
const deleteCourse = useDeleteCourse()
|
||||||
|
|
||||||
const formOpen = ref(false)
|
const formOpen = ref(false)
|
||||||
|
|
@ -46,9 +53,9 @@ async function confirmDelete() {
|
||||||
if (!deletedCourse.value) return
|
if (!deletedCourse.value) return
|
||||||
try {
|
try {
|
||||||
await deleteCourse.mutateAsync(deletedCourse.value.id)
|
await deleteCourse.mutateAsync(deletedCourse.value.id)
|
||||||
toast.success(t('courses.deleted'))
|
toast.success(t(`${k.value}.deleted`))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('courses.delete-error'))
|
toast.error(t(`${k.value}.delete-error`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -58,7 +65,7 @@ async function confirmDelete() {
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<Button @click="openForm()">
|
<Button @click="openForm()">
|
||||||
<Plus />
|
<Plus />
|
||||||
{{ $t('courses.new') }}
|
{{ $t(`${k}.new`) }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -68,7 +75,7 @@ async function confirmDelete() {
|
||||||
|
|
||||||
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
|
<Card v-else-if="isError" class="mx-auto max-w-md text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ $t('courses.load-error') }}</CardTitle>
|
<CardTitle>{{ $t(`${k}.load-error`) }}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter class="justify-center">
|
<CardFooter class="justify-center">
|
||||||
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
<Button variant="outline" @click="refetch()">{{ $t('common.retry') }}</Button>
|
||||||
|
|
@ -77,14 +84,17 @@ async function confirmDelete() {
|
||||||
|
|
||||||
<Card v-else-if="!courses?.length" class="mx-auto max-w-md text-center">
|
<Card v-else-if="!courses?.length" class="mx-auto max-w-md text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<BookOpen class="mx-auto mb-2 size-8 text-muted-foreground" />
|
<component
|
||||||
<CardTitle>{{ $t('courses.empty') }}</CardTitle>
|
:is="kind === 'course' ? BookOpen : Users"
|
||||||
<CardDescription>{{ $t('courses.empty-hint') }}</CardDescription>
|
class="mx-auto mb-2 size-8 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<CardTitle>{{ $t(`${k}.empty`) }}</CardTitle>
|
||||||
|
<CardDescription>{{ $t(`${k}.empty-hint`) }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter class="justify-center">
|
<CardFooter class="justify-center">
|
||||||
<Button @click="openForm()">
|
<Button @click="openForm()">
|
||||||
<Plus />
|
<Plus />
|
||||||
{{ $t('courses.new') }}
|
{{ $t(`${k}.new`) }}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -100,7 +110,7 @@ async function confirmDelete() {
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<CardTitle class="text-lg leading-snug">
|
<CardTitle class="text-lg leading-snug">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'course', params: { id: course.id } }"
|
:to="coursePageRoute(course)"
|
||||||
class="outline-none after:absolute after:inset-0 after:content-['']"
|
class="outline-none after:absolute after:inset-0 after:content-['']"
|
||||||
>
|
>
|
||||||
{{ course.name }}
|
{{ course.name }}
|
||||||
|
|
@ -110,7 +120,9 @@ async function confirmDelete() {
|
||||||
{{ course.code }}
|
{{ course.code }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{{ course.teachers.join(', ') }}</CardDescription>
|
<CardDescription v-if="course.teachers.length">
|
||||||
|
{{ course.teachers.join(', ') }}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 flex shrink-0 gap-1">
|
<div class="relative z-10 flex shrink-0 gap-1">
|
||||||
|
|
@ -132,15 +144,15 @@ async function confirmDelete() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CourseFormDialog v-model:open="formOpen" :course="editedCourse" />
|
<CourseFormDialog v-model:open="formOpen" :kind="kind" :course="editedCourse" />
|
||||||
|
|
||||||
<AlertDialog v-model:open="deleteOpen">
|
<AlertDialog v-model:open="deleteOpen">
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{{ $t('courses.delete-title', { name: deletedCourse?.name }) }}
|
{{ $t(`${k}.delete-title`, { name: deletedCourse?.name }) }}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{{ $t('courses.delete-description') }}</AlertDialogDescription>
|
<AlertDialogDescription>{{ $t(`${k}.delete-description`) }}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
|
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { sections } from '@/router/navigation'
|
||||||
<p class="text-muted-foreground">{{ $t('home.subtitle') }}</p>
|
<p class="text-muted-foreground">{{ $t('home.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.name"
|
:key="section.name"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
useLectureNoteMutations,
|
useLectureNoteMutations,
|
||||||
useCourseFiles,
|
useCourseFiles,
|
||||||
} from '@/services/api/course-files'
|
} from '@/services/api/course-files'
|
||||||
|
import { coursePageRoute, noteRoute } from '@/utils/routes'
|
||||||
import type { CourseFile } from '@/utils/types'
|
import type { CourseFile } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ id: number; noteId?: number }>()
|
const props = defineProps<{ id: number; noteId?: number }>()
|
||||||
|
|
@ -78,11 +79,8 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const backTo = computed(() => ({
|
const kind = computed(() => (route.name === 'association-note' ? 'association' : 'course'))
|
||||||
name: 'course',
|
const backTo = computed(() => coursePageRoute({ id: props.id, kind: kind.value }, 'files'))
|
||||||
params: { id: props.id },
|
|
||||||
query: { tab: 'files' },
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!title.value.trim()) {
|
if (!title.value.trim()) {
|
||||||
|
|
@ -97,11 +95,9 @@ async function submit() {
|
||||||
saved.value = { title: title.value, content: content.value }
|
saved.value = { title: title.value, content: content.value }
|
||||||
pdfError.value = result.pdf_error ?? undefined
|
pdfError.value = result.pdf_error ?? undefined
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
await router.replace({
|
await router.replace(
|
||||||
name: 'course-note',
|
noteRoute({ id: props.id, kind: kind.value }, result.note.id, !!result.pdf_error),
|
||||||
params: { id: props.id, noteId: result.note.id },
|
)
|
||||||
query: result.pdf_error ? { edit: '1' } : {},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (result.pdf_error) {
|
if (result.pdf_error) {
|
||||||
toast.warning(t('notes.saved-without-pdf'))
|
toast.warning(t('notes.saved-without-pdf'))
|
||||||
|
|
@ -153,7 +149,7 @@ function pdfUrl(note: CourseFile) {
|
||||||
<Button variant="ghost" as-child>
|
<Button variant="ghost" as-child>
|
||||||
<RouterLink :to="backTo">
|
<RouterLink :to="backTo">
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
{{ course?.name ?? $t('course.back') }}
|
{{ course?.name ?? $t(`${kind}.back`) }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,461 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
/**
|
||||||
|
* Reverse pomodoro: a timer counting up the focus on a task, with the time of day, in a
|
||||||
|
* quiet page. A ring fills up every 25 minutes and suggests a break, a fifth of the
|
||||||
|
* focus time. The notes and slides of the course open next to the clock.
|
||||||
|
*
|
||||||
|
* `?task=<id>` starts a session on the task (see `services/focus.ts`).
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { onKeyStroke, useFullscreen } from '@vueuse/core'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Timer,
|
||||||
|
X,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
import TagBadge from '@/components/course/TagBadge.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { lectureNotePdfUrl, useCourseFiles } from '@/services/api/course-files'
|
||||||
|
import { useTags } from '@/services/api/tags'
|
||||||
|
import { useRefreshTasks, useTodoTasks } from '@/services/api/tasks'
|
||||||
|
import { CYCLE_SECONDS, formatDuration, formatSpent, useFocus } from '@/services/focus'
|
||||||
|
import { formatRelative } from '@/utils/dates'
|
||||||
|
import { coursePageRoute, tasksTab } from '@/utils/routes'
|
||||||
|
import type { CourseFile } from '@/utils/types'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { tagsById } = useTags()
|
||||||
|
const refreshTasks = useRefreshTasks()
|
||||||
|
const focus = useFocus()
|
||||||
|
const { session, elapsed, pauseElapsed, isRunning, pending, now } = focus
|
||||||
|
|
||||||
|
const task = computed(() => session.value?.task ?? null)
|
||||||
|
|
||||||
|
/** Shows the errors of an action: TimeTagger ones as a warning, the session runs anyway */
|
||||||
|
async function run(action: () => Promise<string | null>) {
|
||||||
|
const courseId = task.value?.course.id
|
||||||
|
try {
|
||||||
|
const timetaggerError = await action()
|
||||||
|
if (timetaggerError) {
|
||||||
|
toast.warning(t('focus.timetagger-error'), { description: timetaggerError })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('focus.error'), { description: err instanceof Error ? err.message : undefined })
|
||||||
|
} finally {
|
||||||
|
if (courseId !== undefined) refreshTasks(courseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `?task=<id>`: starts working on the task, then leaves the url clean so a reload does
|
||||||
|
// not start it again
|
||||||
|
watch(
|
||||||
|
() => route.query.task,
|
||||||
|
async (value) => {
|
||||||
|
const taskId = Number(value)
|
||||||
|
if (!value || !Number.isInteger(taskId)) return
|
||||||
|
await router.replace({ query: {} })
|
||||||
|
if (task.value?.task_id === taskId) {
|
||||||
|
if (!isRunning.value) await run(focus.resume)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await run(() => focus.start(taskId))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function togglePause() {
|
||||||
|
if (pending.value || !session.value) return
|
||||||
|
run(isRunning.value ? focus.pause : focus.resume)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
const seconds = elapsed.value
|
||||||
|
const name = task.value?.name
|
||||||
|
await run(focus.stop)
|
||||||
|
if (!session.value) {
|
||||||
|
toast.success(t('focus.stopped', { time: formatSpent(seconds) }), { description: name })
|
||||||
|
}
|
||||||
|
openedFile.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyStroke(' ', (event) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target.closest('input, textarea, button, a, [contenteditable]')) return
|
||||||
|
event.preventDefault()
|
||||||
|
togglePause()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The ring fills up once per cycle: a break is suggested each time it is full
|
||||||
|
const cycles = computed(() => Math.floor(elapsed.value / CYCLE_SECONDS))
|
||||||
|
const cycleProgress = computed(() => (elapsed.value % CYCLE_SECONDS) / CYCLE_SECONDS)
|
||||||
|
const suggestedBreak = computed(() => Math.max(5 * 60, Math.round(elapsed.value / 5)))
|
||||||
|
|
||||||
|
watch(cycles, (count, previous) => {
|
||||||
|
if (isRunning.value && previous !== undefined && count > previous) {
|
||||||
|
toast(t('focus.cycle-done', { n: count }), { description: t('focus.cycle-hint') })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const RADIUS = 46
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||||
|
|
||||||
|
const clock = computed(() =>
|
||||||
|
new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(now.value),
|
||||||
|
)
|
||||||
|
const today = computed(() =>
|
||||||
|
new Intl.DateTimeFormat(locale.value, { weekday: 'long', day: 'numeric', month: 'long' }).format(
|
||||||
|
now.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Time spent on the task, this session included */
|
||||||
|
const totalSpent = computed(() => (session.value?.spentBefore ?? 0) + elapsed.value)
|
||||||
|
|
||||||
|
// The time in the tab title, visible from the other tabs
|
||||||
|
const originalTitle = document.title
|
||||||
|
watch(
|
||||||
|
[elapsed, isRunning, session],
|
||||||
|
() => {
|
||||||
|
document.title = session.value
|
||||||
|
? `${isRunning.value ? '⏱' : '⏸'} ${formatDuration(elapsed.value)} · ${originalTitle}`
|
||||||
|
: originalTitle
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
onBeforeUnmount(() => (document.title = originalTitle))
|
||||||
|
|
||||||
|
const container = useTemplateRef<HTMLElement>('container')
|
||||||
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(container)
|
||||||
|
|
||||||
|
// Resources of the course: its notes (and soon its slides), opened next to the clock
|
||||||
|
const showResources = ref(true)
|
||||||
|
const { data: files, isPending: filesPending } = useCourseFiles(() => task.value?.course.id)
|
||||||
|
const openedFile = ref<CourseFile>()
|
||||||
|
|
||||||
|
function canOpen(file: CourseFile) {
|
||||||
|
return file.kind === 'note' && file.has_pdf
|
||||||
|
}
|
||||||
|
|
||||||
|
// The small clock is next to the resources: no resource without them
|
||||||
|
watch([() => task.value?.course.id, showResources], () => (openedFile.value = undefined))
|
||||||
|
|
||||||
|
// Nothing running: pick a task
|
||||||
|
const { data: todos, isPending: todosPending } = useTodoTasks()
|
||||||
|
const choices = computed(() =>
|
||||||
|
(todos.value ?? []).flatMap(({ course, task }) => [
|
||||||
|
{ course, task, parent: undefined as string | undefined },
|
||||||
|
...task.subtasks
|
||||||
|
.filter((subtask) => !subtask.done)
|
||||||
|
.map((subtask) => ({ course, task: subtask, parent: task.name })),
|
||||||
|
]),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagePlaceholder title="nav.pomodoro" />
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="relative -m-4 flex min-h-[calc(100svh-3.5rem)] flex-col bg-gradient-to-b from-muted/40 to-background md:-m-6"
|
||||||
|
>
|
||||||
|
<!-- Top bar: quiet, out of the way -->
|
||||||
|
<div class="flex items-center gap-2 px-4 pt-4 md:px-6">
|
||||||
|
<span class="text-sm text-muted-foreground first-letter:uppercase">{{ today }}</span>
|
||||||
|
<div class="ml-auto flex gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="task"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:class="{ 'bg-accent': showResources }"
|
||||||
|
:aria-pressed="showResources"
|
||||||
|
@click="showResources = !showResources"
|
||||||
|
>
|
||||||
|
<FolderOpen />
|
||||||
|
<span class="hidden sm:inline">{{ $t('focus.resources') }}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="isFullscreen ? $t('focus.exit-fullscreen') : $t('focus.fullscreen')"
|
||||||
|
@click="toggleFullscreen()"
|
||||||
|
>
|
||||||
|
<Minimize v-if="isFullscreen" />
|
||||||
|
<Maximize v-else />
|
||||||
|
<span class="sr-only">{{ $t('focus.fullscreen') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid flex-1 gap-4 p-4 md:p-6"
|
||||||
|
:class="{ 'lg:grid-cols-[minmax(0,1fr)_20rem]': task && showResources }"
|
||||||
|
>
|
||||||
|
<!-- A resource opened: it takes the room, the clock moves to the side -->
|
||||||
|
<div v-if="openedFile" class="flex min-h-[70svh] flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span class="truncate text-sm font-medium">{{ openedFile.name }}</span>
|
||||||
|
<Button variant="ghost" size="icon-sm" class="ml-auto" as-child>
|
||||||
|
<a
|
||||||
|
:href="lectureNotePdfUrl(openedFile)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="$t('focus.open-tab')"
|
||||||
|
>
|
||||||
|
<ExternalLink />
|
||||||
|
<span class="sr-only">{{ $t('focus.open-tab') }}</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:title="$t('focus.close-resource')"
|
||||||
|
@click="openedFile = undefined"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<span class="sr-only">{{ $t('focus.close-resource') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
:src="lectureNotePdfUrl(openedFile)"
|
||||||
|
:title="openedFile.name"
|
||||||
|
class="w-full flex-1 rounded-lg border bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The clock -->
|
||||||
|
<div v-show="!openedFile" class="flex flex-col items-center justify-center gap-6 text-center">
|
||||||
|
<template v-if="session">
|
||||||
|
<div class="flex max-w-xl flex-col items-center gap-2">
|
||||||
|
<template v-if="task">
|
||||||
|
<RouterLink
|
||||||
|
:to="coursePageRoute(task.course, tasksTab(task.course.kind))"
|
||||||
|
class="text-sm text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
<span v-if="task.course.code" class="font-mono">{{ task.course.code }} · </span>
|
||||||
|
{{ task.course.name }}
|
||||||
|
</RouterLink>
|
||||||
|
<h2 class="text-2xl font-semibold text-balance md:text-3xl">
|
||||||
|
<span v-if="task.parent_name" class="text-muted-foreground">
|
||||||
|
{{ task.parent_name }} ›
|
||||||
|
</span>
|
||||||
|
{{ task.name }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap justify-center gap-1">
|
||||||
|
<template v-for="tagId in task.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<h2 v-else class="text-2xl font-semibold md:text-3xl">{{ $t('focus.free') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ring of the current cycle, the focus time inside -->
|
||||||
|
<div class="relative size-72 sm:size-96">
|
||||||
|
<svg viewBox="0 0 100 100" class="size-full -rotate-90" aria-hidden="true">
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
:r="RADIUS"
|
||||||
|
fill="none"
|
||||||
|
class="stroke-muted"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
:r="RADIUS"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||||
|
:class="isRunning ? 'stroke-primary' : 'stroke-muted-foreground/40'"
|
||||||
|
:stroke-dasharray="CIRCUMFERENCE"
|
||||||
|
:stroke-dashoffset="CIRCUMFERENCE * (1 - cycleProgress)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||||
|
<span
|
||||||
|
class="font-mono text-6xl font-light tracking-tight tabular-nums sm:text-7xl"
|
||||||
|
:class="{ 'text-muted-foreground': !isRunning }"
|
||||||
|
role="timer"
|
||||||
|
:aria-label="$t('focus.elapsed')"
|
||||||
|
>
|
||||||
|
{{ formatDuration(elapsed) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-lg text-muted-foreground tabular-nums">{{ clock }}</span>
|
||||||
|
<span
|
||||||
|
v-if="cycles"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
:title="$t('focus.cycles', { n: cycles })"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="i in Math.min(cycles, 8)"
|
||||||
|
:key="i"
|
||||||
|
class="size-2 rounded-full bg-primary/70"
|
||||||
|
/>
|
||||||
|
<span v-if="cycles > 8" class="text-xs text-muted-foreground"
|
||||||
|
>+{{ cycles - 8 }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!isRunning" class="text-sm text-muted-foreground">
|
||||||
|
{{ $t('focus.paused', { time: formatDuration(pauseElapsed) }) }}
|
||||||
|
· {{ $t('focus.suggested-break', { time: formatSpent(suggestedBreak) }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
class="h-14 min-w-40 rounded-full text-base"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="togglePause"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="pending" class="animate-spin" />
|
||||||
|
<Pause v-else-if="isRunning" />
|
||||||
|
<Play v-else />
|
||||||
|
{{ isRunning ? $t('focus.pause') : $t('focus.resume') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
class="h-14 rounded-full"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="stop"
|
||||||
|
>
|
||||||
|
<Square />
|
||||||
|
{{ $t('focus.stop') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="flex flex-wrap justify-center gap-x-3 text-xs text-muted-foreground">
|
||||||
|
<span v-if="task">{{ $t('focus.total', { time: formatSpent(totalSpent) }) }}</span>
|
||||||
|
<span v-if="task?.timetagger_enabled" class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
class="size-1.5 rounded-full"
|
||||||
|
:class="session.record ? 'bg-green-500' : 'bg-muted-foreground/50'"
|
||||||
|
/>
|
||||||
|
TimeTagger
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">{{ $t('focus.space-hint') }}</span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Nothing running -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<span class="font-mono text-7xl font-light tracking-tight tabular-nums sm:text-8xl">
|
||||||
|
{{ clock }}
|
||||||
|
</span>
|
||||||
|
<p class="text-muted-foreground">{{ $t('focus.pick') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full max-w-lg flex-col gap-2 text-left">
|
||||||
|
<div v-if="todosPending" class="flex flex-col gap-2">
|
||||||
|
<Skeleton v-for="i in 3" :key="i" class="h-12 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<ul v-else class="flex max-h-[45svh] flex-col gap-1.5 overflow-y-auto">
|
||||||
|
<li v-for="choice in choices" :key="choice.task.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-lg border bg-card px-3 py-2 text-left transition-colors hover:bg-accent disabled:opacity-60"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="run(() => focus.start(choice.task.id))"
|
||||||
|
>
|
||||||
|
<Timer class="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate text-sm font-medium">
|
||||||
|
<span v-if="choice.parent" class="text-muted-foreground">
|
||||||
|
{{ choice.parent }} ›
|
||||||
|
</span>
|
||||||
|
{{ choice.task.name }}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-xs text-muted-foreground">
|
||||||
|
<span class="font-mono">{{ choice.course.code ?? choice.course.name }}</span>
|
||||||
|
<template v-if="choice.task.deadline">
|
||||||
|
· {{ formatRelative(choice.task.deadline, locale) }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="!choices.length" class="py-2 text-center text-sm text-muted-foreground">
|
||||||
|
{{ $t('focus.no-task') }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Button variant="outline" :disabled="pending" @click="run(() => focus.start(null))">
|
||||||
|
<Play />
|
||||||
|
{{ $t('focus.start-free') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side: the clock when a resource is opened, and the resources -->
|
||||||
|
<aside v-if="task && showResources" class="flex min-w-0 flex-col gap-4">
|
||||||
|
<div v-if="openedFile" class="flex items-center gap-3 rounded-xl border bg-card p-3">
|
||||||
|
<span
|
||||||
|
class="font-mono text-3xl font-light tabular-nums"
|
||||||
|
:class="{ 'text-muted-foreground': !isRunning }"
|
||||||
|
>
|
||||||
|
{{ formatDuration(elapsed) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted-foreground tabular-nums">{{ clock }}</span>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
class="ml-auto rounded-full"
|
||||||
|
:disabled="pending"
|
||||||
|
:title="isRunning ? $t('focus.pause') : $t('focus.resume')"
|
||||||
|
@click="togglePause"
|
||||||
|
>
|
||||||
|
<Pause v-if="isRunning" />
|
||||||
|
<Play v-else />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 rounded-xl border bg-card p-3">
|
||||||
|
<h3 class="text-sm font-medium">{{ $t('focus.resources') }}</h3>
|
||||||
|
<div v-if="filesPending" class="flex flex-col gap-2">
|
||||||
|
<Skeleton v-for="i in 3" :key="i" class="h-9" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!files?.length" class="text-sm text-muted-foreground">
|
||||||
|
{{ $t('focus.no-resource') }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="-mx-1 flex flex-col">
|
||||||
|
<li v-for="file in files" :key="file.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:class="{ 'bg-accent font-medium': openedFile?.id === file.id }"
|
||||||
|
:disabled="!canOpen(file)"
|
||||||
|
@click="openedFile = file"
|
||||||
|
>
|
||||||
|
<FileText class="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
|
||||||
|
<template v-for="tagId in file.tag_ids" :key="tagId">
|
||||||
|
<TagBadge v-if="tagsById.get(tagId)" :tag="tagsById.get(tagId)!" />
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ pub fn error_response<T>(fn_name: &str, err: ControllerError) -> Result<T, (Stat
|
||||||
ControllerError::Grade(err) => Some(err.to_string()),
|
ControllerError::Grade(err) => Some(err.to_string()),
|
||||||
ControllerError::Task(err) => Some(err.to_string()),
|
ControllerError::Task(err) => Some(err.to_string()),
|
||||||
ControllerError::LectureNote(err) => Some(err.to_string()),
|
ControllerError::LectureNote(err) => Some(err.to_string()),
|
||||||
|
ControllerError::Focus(err) => Some(err.to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
match (invalid, err) {
|
match (invalid, err) {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::core::models::{
|
use crate::core::models::{
|
||||||
course::{CourseData, CourseId},
|
course::{CourseData, CourseId, CourseKind},
|
||||||
course_file::CourseFileId,
|
course_file::CourseFileId,
|
||||||
|
focus::TimetaggerRecord,
|
||||||
grade::{GradeData, GradeId},
|
grade::{GradeData, GradeId},
|
||||||
tag::TagId,
|
tag::TagId,
|
||||||
task::{TaskData, TaskId},
|
task::{TaskData, TaskId},
|
||||||
|
|
@ -48,12 +49,15 @@ pub struct PeriodQuery {
|
||||||
pub to: DateTime<Utc>,
|
pub to: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A course, without its slots: they are added and removed one by one
|
/// A course or an association, without its slots: they are added and removed one by one
|
||||||
#[derive(Deserialize, JsonSchema)]
|
#[derive(Deserialize, JsonSchema)]
|
||||||
pub struct CourseBody {
|
pub struct CourseBody {
|
||||||
|
/// `course` by default. Cannot be changed once created.
|
||||||
|
pub kind: Option<CourseKind>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
/// At least one
|
/// At least one for a course
|
||||||
|
#[serde(default)]
|
||||||
pub teachers: Vec<String>,
|
pub teachers: Vec<String>,
|
||||||
/// http(s) url of the main page of the course
|
/// http(s) url of the main page of the course
|
||||||
pub main_page_url: Option<String>,
|
pub main_page_url: Option<String>,
|
||||||
|
|
@ -104,6 +108,15 @@ pub struct CreateTaskBody {
|
||||||
pub task: TaskBody,
|
pub task: TaskBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End (or pause) of a focus session
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct FocusStopBody {
|
||||||
|
/// Length of the session, at most 24 hours
|
||||||
|
pub seconds: u32,
|
||||||
|
/// Record returned when the session started, to stop it
|
||||||
|
pub timetagger: Option<TimetaggerRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, JsonSchema)]
|
#[derive(Deserialize, JsonSchema)]
|
||||||
pub struct LectureNoteBody {
|
pub struct LectureNoteBody {
|
||||||
/// Subtitle of the pdf, e.g. "Lecture 2 Notes: Memory subsystem"
|
/// Subtitle of the pdf, e.g. "Lecture 2 Notes: Memory subsystem"
|
||||||
|
|
@ -115,6 +128,7 @@ pub struct LectureNoteBody {
|
||||||
impl From<CourseBody> for CourseData {
|
impl From<CourseBody> for CourseData {
|
||||||
fn from(value: CourseBody) -> Self {
|
fn from(value: CourseBody) -> Self {
|
||||||
CourseData {
|
CourseData {
|
||||||
|
kind: value.kind.unwrap_or(CourseKind::Course),
|
||||||
name: value.name,
|
name: value.name,
|
||||||
code: value.code,
|
code: value.code,
|
||||||
teachers: value.teachers,
|
teachers: value.teachers,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
//! Tasks of a course. Every change returns all the tasks of the course, nested and
|
//! Tasks of a course. Every change returns all the tasks of the course, nested and
|
||||||
//! sorted, with the deadlines: the page never needs a second request.
|
//! sorted, with the deadlines: the page never needs a second request.
|
||||||
|
//!
|
||||||
|
//! The focus sessions of the pomodoro are counted on the tasks, and in TimeTagger.
|
||||||
|
|
||||||
use aide::{
|
use aide::{
|
||||||
axum::{
|
axum::{
|
||||||
ApiRouter,
|
ApiRouter,
|
||||||
routing::{get_with, put_with},
|
routing::{get_with, post_with, put_with},
|
||||||
},
|
},
|
||||||
transform::TransformOperation,
|
transform::TransformOperation,
|
||||||
};
|
};
|
||||||
|
|
@ -17,11 +19,14 @@ use axum::{
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
helpers::{desc, error_response},
|
helpers::{desc, error_response},
|
||||||
models::{CoursePath, CreateTaskBody, PeriodQuery, TaskBody, TaskPath},
|
models::{CoursePath, CreateTaskBody, FocusStopBody, PeriodQuery, TaskBody, TaskPath},
|
||||||
},
|
},
|
||||||
core::{
|
core::{
|
||||||
controller::AppController,
|
controller::AppController,
|
||||||
models::task::{CalendarTask, CourseTasks},
|
models::{
|
||||||
|
focus::{FocusStart, FocusStop},
|
||||||
|
task::{CalendarTask, CourseTasks, TodoTask},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,6 +36,10 @@ pub fn routes() -> ApiRouter {
|
||||||
"/api/tasks",
|
"/api/tasks",
|
||||||
get_with(get_calendar_tasks, get_calendar_tasks_docs),
|
get_with(get_calendar_tasks, get_calendar_tasks_docs),
|
||||||
)
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/tasks/todo",
|
||||||
|
get_with(get_todo_tasks, get_todo_tasks_docs),
|
||||||
|
)
|
||||||
.api_route(
|
.api_route(
|
||||||
"/api/courses/{id}/tasks",
|
"/api/courses/{id}/tasks",
|
||||||
get_with(get_course_tasks, get_course_tasks_docs)
|
get_with(get_course_tasks, get_course_tasks_docs)
|
||||||
|
|
@ -40,6 +49,68 @@ pub fn routes() -> ApiRouter {
|
||||||
"/api/tasks/{id}",
|
"/api/tasks/{id}",
|
||||||
put_with(update_task, update_task_docs).delete_with(delete_task, delete_task_docs),
|
put_with(update_task, update_task_docs).delete_with(delete_task, delete_task_docs),
|
||||||
)
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/tasks/{id}/focus",
|
||||||
|
post_with(start_focus, start_focus_docs),
|
||||||
|
)
|
||||||
|
.api_route(
|
||||||
|
"/api/tasks/{id}/focus/stop",
|
||||||
|
post_with(stop_focus, stop_focus_docs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_todo_tasks(
|
||||||
|
controller: AppController,
|
||||||
|
) -> Result<Json<Vec<TodoTask>>, (StatusCode, String)> {
|
||||||
|
match controller.get_todo_tasks().await {
|
||||||
|
Ok(tasks) => Ok(Json(tasks)),
|
||||||
|
Err(err) => error_response("get_todo_tasks", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_todo_tasks_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks").summary(
|
||||||
|
"Get the tasks not done of every course and association, with their subtasks, \
|
||||||
|
the closest deadline first",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_focus(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<TaskPath>,
|
||||||
|
) -> Result<Json<FocusStart>, (StatusCode, String)> {
|
||||||
|
match controller.start_focus(path.id).await {
|
||||||
|
Ok(focus) => Ok(Json(focus)),
|
||||||
|
Err(err) => error_response("start_focus", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_focus_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("Start a focus session on a task, and its TimeTagger record")
|
||||||
|
.description("The session starts even when TimeTagger fails: see `timetagger_error`.")
|
||||||
|
.response_with::<404, String, _>(desc("The task does not exist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_focus(
|
||||||
|
controller: AppController,
|
||||||
|
Path(path): Path<TaskPath>,
|
||||||
|
Json(body): Json<FocusStopBody>,
|
||||||
|
) -> Result<Json<FocusStop>, (StatusCode, String)> {
|
||||||
|
match controller
|
||||||
|
.stop_focus(path.id, body.seconds, body.timetagger)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(focus) => Ok(Json(focus)),
|
||||||
|
Err(err) => error_response("stop_focus", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_focus_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Tasks")
|
||||||
|
.summary("End or pause a focus session: its time is added to the task")
|
||||||
|
.response_with::<400, String, _>(desc("The session is too long"))
|
||||||
|
.response_with::<404, String, _>(desc("The task does not exist"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_calendar_tasks(
|
async fn get_calendar_tasks(
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
//! Files of a course. For now only the lecture notes: markdown written in the app,
|
//! Files of a course. For now only the lecture notes: markdown written in the app,
|
||||||
//! rendered to pdf with the course template, and tagged `notes`.
|
//! rendered to pdf with the course template, and tagged `notes`.
|
||||||
//!
|
//!
|
||||||
//! Both files of a note live in the storage, next to the other files of the course:
|
//! Both files of a note live in the folder of the course, named after the note:
|
||||||
//! `courses/{course}/notes/{note}.md` and `.pdf`. The `.md` holds the front matter
|
//! `Cours/CS-477 Advanced OS/Notes/Lecture 1 Notes - Intro.md` and `.pdf`. They are
|
||||||
//! expected by the template, so it can also be rendered by hand with `mdpdf`. The
|
//! moved when the note is renamed. The `.md` holds the front matter expected by the
|
||||||
//! editor only sees what comes after it.
|
//! template, so it can also be rendered by hand with `mdpdf`. The editor only sees what
|
||||||
|
//! comes after it.
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
controller::{ControllerError, courses::course_dir},
|
controller::{ControllerError, courses::file_name},
|
||||||
models::{
|
models::{
|
||||||
course::{Course, CourseId},
|
course::{Course, CourseId},
|
||||||
course_file::{CourseFile, CourseFileId, FileKind, LectureNoteContent, SavedLectureNote},
|
course_file::{CourseFile, CourseFileId, FileKind, LectureNoteContent, SavedLectureNote},
|
||||||
|
|
@ -28,12 +29,20 @@ pub struct NotesSettings {
|
||||||
pub logo: Option<String>,
|
pub logo: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn markdown_path(note: &CourseFile) -> String {
|
/// Sub folder of the course holding the notes
|
||||||
format!("{}/notes/{}.md", course_dir(note.course_id), note.id)
|
const NOTES_FOLDER: &str = "Notes";
|
||||||
|
|
||||||
|
fn markdown_path(course: &Course, path: &str) -> String {
|
||||||
|
format!("{}/{path}.md", course.folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pdf_path(note: &CourseFile) -> String {
|
fn pdf_path(course: &Course, path: &str) -> String {
|
||||||
format!("{}/notes/{}.pdf", course_dir(note.course_id), note.id)
|
format!("{}/{path}.pdf", course.folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of a note in the folder of its course, without extension
|
||||||
|
fn note_path(title: &str) -> String {
|
||||||
|
format!("{NOTES_FOLDER}/{}", file_name(title))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl super::AppController {
|
impl super::AppController {
|
||||||
|
|
@ -63,7 +72,12 @@ impl super::AppController {
|
||||||
id: CourseFileId,
|
id: CourseFileId,
|
||||||
) -> Result<LectureNoteContent, ControllerError> {
|
) -> Result<LectureNoteContent, ControllerError> {
|
||||||
let note = self.get_note(id).await?;
|
let note = self.get_note(id).await?;
|
||||||
let content = match self.storage.read_file(&markdown_path(¬e)).await {
|
let course = self.db.get_course(note.course_id).await?;
|
||||||
|
let content = match self
|
||||||
|
.storage
|
||||||
|
.read_file(&markdown_path(&course, ¬e.path))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
||||||
// Lost file: the note can still be rewritten
|
// Lost file: the note can still be rewritten
|
||||||
Err(RepositoryError::NotFound(_)) => String::new(),
|
Err(RepositoryError::NotFound(_)) => String::new(),
|
||||||
|
|
@ -78,8 +92,9 @@ impl super::AppController {
|
||||||
/// `NotFound` if the note does not exist or its last rendering failed
|
/// `NotFound` if the note does not exist or its last rendering failed
|
||||||
pub async fn get_lecture_note_pdf(&self, id: CourseFileId) -> Result<Vec<u8>, ControllerError> {
|
pub async fn get_lecture_note_pdf(&self, id: CourseFileId) -> Result<Vec<u8>, ControllerError> {
|
||||||
let note = self.get_note(id).await?;
|
let note = self.get_note(id).await?;
|
||||||
|
let course = self.db.get_course(note.course_id).await?;
|
||||||
self.storage
|
self.storage
|
||||||
.read_file(&pdf_path(¬e))
|
.read_file(&pdf_path(&course, ¬e.path))
|
||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
@ -92,10 +107,12 @@ impl super::AppController {
|
||||||
) -> Result<SavedLectureNote, ControllerError> {
|
) -> Result<SavedLectureNote, ControllerError> {
|
||||||
let title = validate_title(title)?;
|
let title = validate_title(title)?;
|
||||||
let course = self.db.get_course(course_id).await?;
|
let course = self.db.get_course(course_id).await?;
|
||||||
|
let path = note_path(&title);
|
||||||
|
self.check_path_free(course_id, &path, None).await?;
|
||||||
let notes_tag = self.db.get_tag_id(NOTES_TAG).await?;
|
let notes_tag = self.db.get_tag_id(NOTES_TAG).await?;
|
||||||
let note = self
|
let note = self
|
||||||
.db
|
.db
|
||||||
.create_lecture_note(course_id, title.clone(), &[notes_tag])
|
.create_lecture_note(course_id, title.clone(), path, &[notes_tag])
|
||||||
.await?;
|
.await?;
|
||||||
self.save_lecture_note(&course, note, title, content).await
|
self.save_lecture_note(&course, note, title, content).await
|
||||||
}
|
}
|
||||||
|
|
@ -109,14 +126,55 @@ impl super::AppController {
|
||||||
let title = validate_title(title)?;
|
let title = validate_title(title)?;
|
||||||
let note = self.get_note(id).await?;
|
let note = self.get_note(id).await?;
|
||||||
let course = self.db.get_course(note.course_id).await?;
|
let course = self.db.get_course(note.course_id).await?;
|
||||||
|
|
||||||
|
// Renamed: its files follow
|
||||||
|
let path = note_path(&title);
|
||||||
|
if path != note.path {
|
||||||
|
self.check_path_free(course.id, &path, Some(id)).await?;
|
||||||
|
for (from, to) in [
|
||||||
|
(
|
||||||
|
markdown_path(&course, ¬e.path),
|
||||||
|
markdown_path(&course, &path),
|
||||||
|
),
|
||||||
|
(pdf_path(&course, ¬e.path), pdf_path(&course, &path)),
|
||||||
|
] {
|
||||||
|
match self.storage.rename(&from, &to).await {
|
||||||
|
Ok(()) | Err(RepositoryError::NotFound(_)) => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let note = CourseFile { path, ..note };
|
||||||
self.save_lecture_note(&course, note, title, content).await
|
self.save_lecture_note(&course, note, title, content).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_lecture_note(&self, id: CourseFileId) -> Result<(), ControllerError> {
|
pub async fn delete_lecture_note(&self, id: CourseFileId) -> Result<(), ControllerError> {
|
||||||
let note = self.get_note(id).await?;
|
let note = self.get_note(id).await?;
|
||||||
|
let course = self.db.get_course(note.course_id).await?;
|
||||||
self.db.delete_course_file(id).await?;
|
self.db.delete_course_file(id).await?;
|
||||||
self.storage.delete_file(&markdown_path(¬e)).await?;
|
self.storage
|
||||||
self.storage.delete_file(&pdf_path(¬e)).await?;
|
.delete_file(&markdown_path(&course, ¬e.path))
|
||||||
|
.await?;
|
||||||
|
self.storage
|
||||||
|
.delete_file(&pdf_path(&course, ¬e.path))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NameTaken` if another file of the course (than `except`) already has this path
|
||||||
|
async fn check_path_free(
|
||||||
|
&self,
|
||||||
|
course_id: CourseId,
|
||||||
|
path: &str,
|
||||||
|
except: Option<CourseFileId>,
|
||||||
|
) -> Result<(), ControllerError> {
|
||||||
|
let files = self.db.get_course_files(course_id).await?;
|
||||||
|
if files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == path && Some(file.id) != except)
|
||||||
|
{
|
||||||
|
return Err(LectureNotesControllerError::NameTaken.into());
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,18 +188,19 @@ impl super::AppController {
|
||||||
content: String,
|
content: String,
|
||||||
) -> Result<SavedLectureNote, ControllerError> {
|
) -> Result<SavedLectureNote, ControllerError> {
|
||||||
let markdown = format!("{}{}", self.front_matter(course, &title), content);
|
let markdown = format!("{}{}", self.front_matter(course, &title), content);
|
||||||
|
let pdf = pdf_path(course, ¬e.path);
|
||||||
self.storage
|
self.storage
|
||||||
.write_file(&markdown_path(¬e), markdown.as_bytes())
|
.write_file(&markdown_path(course, ¬e.path), markdown.as_bytes())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let pdf_error = match self.renderer.render(&markdown).await {
|
let pdf_error = match self.renderer.render(&markdown).await {
|
||||||
Ok(pdf) => {
|
Ok(content) => {
|
||||||
self.storage.write_file(&pdf_path(¬e), &pdf).await?;
|
self.storage.write_file(&pdf, &content).await?;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// No stale pdf that would not match the markdown anymore
|
// No stale pdf that would not match the markdown anymore
|
||||||
self.storage.delete_file(&pdf_path(¬e)).await?;
|
self.storage.delete_file(&pdf).await?;
|
||||||
Some(match err {
|
Some(match err {
|
||||||
RenderError::Failed(log) => log,
|
RenderError::Failed(log) => log,
|
||||||
RenderError::Internal(message) => {
|
RenderError::Internal(message) => {
|
||||||
|
|
@ -154,7 +213,7 @@ impl super::AppController {
|
||||||
|
|
||||||
let note = self
|
let note = self
|
||||||
.db
|
.db
|
||||||
.update_lecture_note(note.id, title, pdf_error.is_none())
|
.update_lecture_note(note.id, title, note.path, pdf_error.is_none())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(SavedLectureNote { note, pdf_error })
|
Ok(SavedLectureNote { note, pdf_error })
|
||||||
}
|
}
|
||||||
|
|
@ -209,6 +268,8 @@ fn validate_title(title: String) -> Result<String, ControllerError> {
|
||||||
pub enum LectureNotesControllerError {
|
pub enum LectureNotesControllerError {
|
||||||
#[error("The title of the note is empty")]
|
#[error("The title of the note is empty")]
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
|
#[error("Another file of the course already has this name")]
|
||||||
|
NameTaken,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
//! Courses and their weekly time slots.
|
//! Courses and associations, and their weekly time slots.
|
||||||
|
//!
|
||||||
|
//! Each one has a folder in the storage named after it, where its files live:
|
||||||
|
//! `Cours/CS-477 Advanced operating systems`, `Associations/AGEPoly`. The folder is
|
||||||
|
//! moved when the course is renamed.
|
||||||
|
|
||||||
use chrono::NaiveTime;
|
use chrono::NaiveTime;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
controller::{ControllerError, is_http_url, non_blank},
|
controller::{ControllerError, is_http_url, non_blank},
|
||||||
models::{
|
models::{
|
||||||
course::{Course, CourseData, CourseId},
|
course::{Course, CourseData, CourseId, CourseKind},
|
||||||
tag::{TagId, TagScope},
|
tag::{TagId, TagScope},
|
||||||
time_slot::{TimeSlotData, TimeSlotId},
|
time_slot::{TimeSlotData, TimeSlotId},
|
||||||
},
|
},
|
||||||
|
repositories::RepositoryError,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The timetable shows monday to friday...
|
/// The timetable shows monday to friday...
|
||||||
|
|
@ -19,9 +25,43 @@ const LAST_WEEKDAY: u8 = 5;
|
||||||
const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
|
const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
|
||||||
const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap();
|
const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap();
|
||||||
|
|
||||||
/// Directory of the files of a course in the storage
|
/// Longest name of a file or folder in the storage, in characters
|
||||||
pub(crate) fn course_dir(id: CourseId) -> String {
|
const MAX_FILE_NAME: usize = 120;
|
||||||
format!("courses/{id}")
|
|
||||||
|
/// Folder of a course in the storage, e.g. `Cours/CS-477 Advanced operating systems`
|
||||||
|
fn course_folder(course: &CourseData) -> String {
|
||||||
|
let root = match course.kind {
|
||||||
|
CourseKind::Course => "Cours",
|
||||||
|
CourseKind::Association => "Associations",
|
||||||
|
};
|
||||||
|
let name = match &course.code {
|
||||||
|
Some(code) => format!("{code} {}", course.name),
|
||||||
|
None => course.name.clone(),
|
||||||
|
};
|
||||||
|
format!("{root}/{}", file_name(&name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name usable as a file or folder name on any NAS (samba included): without the
|
||||||
|
/// characters Windows forbids, e.g. "Notes: memory/cache" -> "Notes - memory-cache"
|
||||||
|
pub(crate) fn file_name(name: &str) -> String {
|
||||||
|
let replaced: String = name
|
||||||
|
.replace(':', " - ")
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
||||||
|
c if c.is_control() => ' ',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let collapsed = replaced.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let truncated: String = collapsed.chars().take(MAX_FILE_NAME).collect();
|
||||||
|
// A trailing dot or space is dropped by windows
|
||||||
|
let trimmed = truncated.trim_matches(|c: char| c == '.' || c == ' ');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"Sans titre".to_owned()
|
||||||
|
} else {
|
||||||
|
trimmed.to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl super::AppController {
|
impl super::AppController {
|
||||||
|
|
@ -35,22 +75,73 @@ impl super::AppController {
|
||||||
|
|
||||||
pub async fn create_course(&self, course: CourseData) -> Result<Course, ControllerError> {
|
pub async fn create_course(&self, course: CourseData) -> Result<Course, ControllerError> {
|
||||||
let course = validate_course(course)?;
|
let course = validate_course(course)?;
|
||||||
self.db.create_course(course).await.map_err(Into::into)
|
let folder = course_folder(&course);
|
||||||
|
self.check_folder_free(&folder, None).await?;
|
||||||
|
self.db
|
||||||
|
.create_course(course, &folder)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces a course, keeping its kind. Its folder follows its new name.
|
||||||
pub async fn update_course(
|
pub async fn update_course(
|
||||||
&self,
|
&self,
|
||||||
id: CourseId,
|
id: CourseId,
|
||||||
course: CourseData,
|
course: CourseData,
|
||||||
) -> Result<Course, ControllerError> {
|
) -> Result<Course, ControllerError> {
|
||||||
let course = validate_course(course)?;
|
let existing = self.db.get_course(id).await?;
|
||||||
self.db.update_course(id, course).await.map_err(Into::into)
|
let course = validate_course(CourseData {
|
||||||
|
kind: existing.kind,
|
||||||
|
..course
|
||||||
|
})?;
|
||||||
|
let folder = course_folder(&course);
|
||||||
|
if folder == existing.folder {
|
||||||
|
return self
|
||||||
|
.db
|
||||||
|
.update_course(id, course, &folder)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.check_folder_free(&folder, Some(id)).await?;
|
||||||
|
let moved = match self.storage.rename(&existing.folder, &folder).await {
|
||||||
|
Ok(()) => true,
|
||||||
|
// No file yet
|
||||||
|
Err(RepositoryError::NotFound(_)) => false,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
match self.db.update_course(id, course, &folder).await {
|
||||||
|
Ok(course) => Ok(course),
|
||||||
|
Err(err) => {
|
||||||
|
if moved && let Err(err) = self.storage.rename(&folder, &existing.folder).await {
|
||||||
|
error!("Unable to move {folder} back to {}: {err}", existing.folder);
|
||||||
|
}
|
||||||
|
Err(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the course with everything attached to it, its files included
|
/// Deletes the course with everything attached to it, its files included
|
||||||
pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> {
|
pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> {
|
||||||
|
let course = self.db.get_course(id).await?;
|
||||||
self.db.delete_course(id).await?;
|
self.db.delete_course(id).await?;
|
||||||
self.storage.delete_dir(&course_dir(id)).await?;
|
self.storage.delete_dir(&course.folder).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FolderTaken` if another course (than `except`) already has this folder
|
||||||
|
async fn check_folder_free(
|
||||||
|
&self,
|
||||||
|
folder: &str,
|
||||||
|
except: Option<CourseId>,
|
||||||
|
) -> Result<(), ControllerError> {
|
||||||
|
let courses = self.db.get_courses().await?;
|
||||||
|
if courses
|
||||||
|
.iter()
|
||||||
|
.any(|course| course.folder == folder && Some(course.id) != except)
|
||||||
|
{
|
||||||
|
return Err(CoursesControllerError::FolderTaken(folder.to_owned()).into());
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,7 +189,7 @@ fn validate_course(course: CourseData) -> Result<CourseData, ControllerError> {
|
||||||
.map(|teacher| teacher.trim().to_owned())
|
.map(|teacher| teacher.trim().to_owned())
|
||||||
.filter(|teacher| !teacher.is_empty())
|
.filter(|teacher| !teacher.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if teachers.is_empty() {
|
if teachers.is_empty() && course.kind == CourseKind::Course {
|
||||||
return Err(CoursesControllerError::NoTeacher.into());
|
return Err(CoursesControllerError::NoTeacher.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +202,7 @@ fn validate_course(course: CourseData) -> Result<CourseData, ControllerError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CourseData {
|
Ok(CourseData {
|
||||||
|
kind: course.kind,
|
||||||
name,
|
name,
|
||||||
code: non_blank(course.code),
|
code: non_blank(course.code),
|
||||||
teachers,
|
teachers,
|
||||||
|
|
@ -133,4 +225,39 @@ pub enum CoursesControllerError {
|
||||||
InvalidSlotTime,
|
InvalidSlotTime,
|
||||||
#[error("Unknown tag {0}")]
|
#[error("Unknown tag {0}")]
|
||||||
UnknownTag(TagId),
|
UnknownTag(TagId),
|
||||||
|
#[error("Another course or association already uses the folder {0}")]
|
||||||
|
FolderTaken(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_names_are_safe() {
|
||||||
|
assert_eq!(
|
||||||
|
file_name("Lecture 2 Notes: Memory/cache"),
|
||||||
|
"Lecture 2 Notes - Memory-cache"
|
||||||
|
);
|
||||||
|
assert_eq!(file_name(" a *b?. "), "a -b-");
|
||||||
|
assert_eq!(file_name("..."), "Sans titre");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folder_of_a_course() {
|
||||||
|
let course = CourseData {
|
||||||
|
kind: CourseKind::Course,
|
||||||
|
name: "Advanced OS".to_owned(),
|
||||||
|
code: Some("CS-477".to_owned()),
|
||||||
|
teachers: Vec::new(),
|
||||||
|
main_page_url: None,
|
||||||
|
};
|
||||||
|
assert_eq!(course_folder(&course), "Cours/CS-477 Advanced OS");
|
||||||
|
let association = CourseData {
|
||||||
|
kind: CourseKind::Association,
|
||||||
|
code: None,
|
||||||
|
..course
|
||||||
|
};
|
||||||
|
assert_eq!(course_folder(&association), "Associations/Advanced OS");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
183
src/core/controller/focus.rs
Normal file
183
src/core/controller/focus.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
//! Focus sessions of the pomodoro. The pomodoro counts up: a session runs from its start
|
||||||
|
//! until it is paused or stopped. Its time is added to the task, and counted in
|
||||||
|
//! TimeTagger when it is configured, tagged with the course, the tags and the name of the
|
||||||
|
//! task (`#cs-477 #homework #exercise-set-3`).
|
||||||
|
//!
|
||||||
|
//! TimeTagger never blocks a session: when it fails, the error is returned next to the
|
||||||
|
//! result, to be shown.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
controller::ControllerError,
|
||||||
|
models::{
|
||||||
|
focus::{FocusStart, FocusStop, TimetaggerRecord},
|
||||||
|
tag::Tag,
|
||||||
|
task::{Task, TaskId},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Longest session that can be added at once
|
||||||
|
const MAX_SESSION_SECONDS: u32 = 24 * 3600;
|
||||||
|
|
||||||
|
impl super::AppController {
|
||||||
|
/// Starts a session on a task or a subtask
|
||||||
|
pub async fn start_focus(&self, task_id: TaskId) -> Result<FocusStart, ControllerError> {
|
||||||
|
let task = self.db.get_task(task_id).await?;
|
||||||
|
let parent = match task.parent_id {
|
||||||
|
Some(parent_id) => Some(self.db.get_task(parent_id).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let course = self.db.get_course(task.course_id).await?;
|
||||||
|
let tag_ids = match &parent {
|
||||||
|
Some(parent) if task.tag_ids.is_empty() => parent.tag_ids.clone(),
|
||||||
|
_ => task.tag_ids.clone(),
|
||||||
|
};
|
||||||
|
let tags: Vec<Tag> = self
|
||||||
|
.db
|
||||||
|
.get_tags(None)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tag| tag_ids.contains(&tag.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let started_at = Utc::now();
|
||||||
|
let (timetagger, timetagger_error) = match &self.time_tracker {
|
||||||
|
None => (None, None),
|
||||||
|
Some(tracker) => {
|
||||||
|
let course_tag = course.code.as_deref().unwrap_or(&course.name);
|
||||||
|
let description = description(course_tag, &tags, parent.as_ref(), &task);
|
||||||
|
match tracker.start(&description, started_at).await {
|
||||||
|
Ok(record) => (Some(record), None),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Unable to start a TimeTagger record: {err}");
|
||||||
|
(None, Some(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FocusStart {
|
||||||
|
course: (&course).into(),
|
||||||
|
task_id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
parent_name: parent.map(|parent| parent.name),
|
||||||
|
tag_ids,
|
||||||
|
time_spent: task.time_spent,
|
||||||
|
started_at,
|
||||||
|
timetagger_enabled: self.time_tracker.is_some(),
|
||||||
|
timetagger,
|
||||||
|
timetagger_error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends a session (or pauses it: resuming starts a new one): adds its `seconds` to
|
||||||
|
/// the task and stops its TimeTagger record
|
||||||
|
pub async fn stop_focus(
|
||||||
|
&self,
|
||||||
|
task_id: TaskId,
|
||||||
|
seconds: u32,
|
||||||
|
record: Option<TimetaggerRecord>,
|
||||||
|
) -> Result<FocusStop, ControllerError> {
|
||||||
|
if seconds > MAX_SESSION_SECONDS {
|
||||||
|
return Err(FocusControllerError::TooLong.into());
|
||||||
|
}
|
||||||
|
let time_spent = self.db.add_time_spent(task_id, seconds as i32).await?;
|
||||||
|
|
||||||
|
let timetagger_error = match (&self.time_tracker, record) {
|
||||||
|
(Some(tracker), Some(record)) => match tracker.stop(&record, Utc::now()).await {
|
||||||
|
Ok(()) => None,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Unable to stop the TimeTagger record {}: {err}", record.key);
|
||||||
|
Some(err.to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Ok(FocusStop {
|
||||||
|
time_spent,
|
||||||
|
timetagger_error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#cs-477 #homework #exercise-set-3`, the parent task before the subtask
|
||||||
|
fn description(course: &str, tags: &[Tag], parent: Option<&Task>, task: &Task) -> String {
|
||||||
|
let mut words: Vec<String> = Vec::new();
|
||||||
|
let names = std::iter::once(course)
|
||||||
|
.chain(
|
||||||
|
tags.iter()
|
||||||
|
.map(|tag| tag.slug.as_deref().unwrap_or(&tag.name.en)),
|
||||||
|
)
|
||||||
|
.chain(parent.map(|parent| parent.name.as_str()))
|
||||||
|
.chain(std::iter::once(task.name.as_str()));
|
||||||
|
for name in names {
|
||||||
|
let word = tag_word(name);
|
||||||
|
if !word.is_empty() && !words.contains(&word) {
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
words
|
||||||
|
.iter()
|
||||||
|
.map(|word| format!("#{word}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A TimeTagger tag is a single lowercase word: "Exercise set 3" -> "exercise-set-3"
|
||||||
|
fn tag_word(name: &str) -> String {
|
||||||
|
name.to_lowercase()
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum FocusControllerError {
|
||||||
|
#[error("A session lasts at most 24 hours")]
|
||||||
|
TooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::models::{localized_string::LocalizedString, tag::TagScope};
|
||||||
|
|
||||||
|
fn task(name: &str) -> Task {
|
||||||
|
Task {
|
||||||
|
id: 1,
|
||||||
|
course_id: 1,
|
||||||
|
parent_id: None,
|
||||||
|
name: name.to_owned(),
|
||||||
|
description: None,
|
||||||
|
links: Vec::new(),
|
||||||
|
deadline: None,
|
||||||
|
done: false,
|
||||||
|
tag_ids: Vec::new(),
|
||||||
|
time_spent: 0,
|
||||||
|
subtasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describes_a_subtask() {
|
||||||
|
let tag = Tag {
|
||||||
|
id: 4,
|
||||||
|
name: LocalizedString {
|
||||||
|
fr: "Devoir".to_owned(),
|
||||||
|
en: "Homework".to_owned(),
|
||||||
|
},
|
||||||
|
color: "#000".to_owned(),
|
||||||
|
slug: Some("homework".to_owned()),
|
||||||
|
scopes: vec![TagScope::Task],
|
||||||
|
};
|
||||||
|
let parent = task("Série 3");
|
||||||
|
assert_eq!(
|
||||||
|
description("CS-477", &[tag], Some(&parent), &task("Exercice 2 (b)")),
|
||||||
|
"#cs-477 #homework #série-3 #exercice-2-b"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,17 +13,19 @@ use crate::core::{
|
||||||
controller::{
|
controller::{
|
||||||
course_files::{LectureNotesControllerError, NotesSettings},
|
course_files::{LectureNotesControllerError, NotesSettings},
|
||||||
courses::CoursesControllerError,
|
courses::CoursesControllerError,
|
||||||
|
focus::FocusControllerError,
|
||||||
grades::GradesControllerError,
|
grades::GradesControllerError,
|
||||||
tasks::TasksControllerError,
|
tasks::TasksControllerError,
|
||||||
},
|
},
|
||||||
repositories::{
|
repositories::{
|
||||||
DatabaseRepository, RepositoryError, pdf_renderer::PdfRenderer,
|
DatabaseRepository, RepositoryError, pdf_renderer::PdfRenderer,
|
||||||
storage_repository::StorageRepository,
|
storage_repository::StorageRepository, time_tracker::TimeTracker,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod course_files;
|
pub mod course_files;
|
||||||
pub mod courses;
|
pub mod courses;
|
||||||
|
pub mod focus;
|
||||||
pub mod grades;
|
pub mod grades;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
|
|
@ -35,6 +37,8 @@ pub struct AppController {
|
||||||
storage: Arc<dyn StorageRepository>,
|
storage: Arc<dyn StorageRepository>,
|
||||||
renderer: Arc<dyn PdfRenderer>,
|
renderer: Arc<dyn PdfRenderer>,
|
||||||
notes: Arc<NotesSettings>,
|
notes: Arc<NotesSettings>,
|
||||||
|
/// `None` when TimeTagger is not configured
|
||||||
|
time_tracker: Option<Arc<dyn TimeTracker>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppController {
|
impl AppController {
|
||||||
|
|
@ -43,12 +47,14 @@ impl AppController {
|
||||||
storage: Arc<dyn StorageRepository>,
|
storage: Arc<dyn StorageRepository>,
|
||||||
renderer: Arc<dyn PdfRenderer>,
|
renderer: Arc<dyn PdfRenderer>,
|
||||||
notes: NotesSettings,
|
notes: NotesSettings,
|
||||||
|
time_tracker: Option<Arc<dyn TimeTracker>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
storage,
|
storage,
|
||||||
renderer,
|
renderer,
|
||||||
notes: Arc::new(notes),
|
notes: Arc::new(notes),
|
||||||
|
time_tracker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +77,8 @@ pub enum ControllerError {
|
||||||
Task(#[from] TasksControllerError),
|
Task(#[from] TasksControllerError),
|
||||||
#[error("Lecture note specific error: {0}")]
|
#[error("Lecture note specific error: {0}")]
|
||||||
LectureNote(#[from] LectureNotesControllerError),
|
LectureNote(#[from] LectureNotesControllerError),
|
||||||
|
#[error("Focus specific error: {0}")]
|
||||||
|
Focus(#[from] FocusControllerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trims a text, `None` when it is blank
|
/// Trims a text, `None` when it is blank
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use crate::core::{
|
||||||
course::Course,
|
course::Course,
|
||||||
course::CourseId,
|
course::CourseId,
|
||||||
tag::TagScope,
|
tag::TagScope,
|
||||||
task::{CalendarTask, CourseTasks, Task, TaskData, TaskId},
|
task::{CalendarTask, CourseTasks, Task, TaskData, TaskId, TodoTask},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -35,6 +35,29 @@ impl super::AppController {
|
||||||
Ok(calendar(tasks, &courses, from, to))
|
Ok(calendar(tasks, &courses, from, to))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tasks not done of every course, with their subtasks: the ones with a deadline
|
||||||
|
/// first, the closest first
|
||||||
|
pub async fn get_todo_tasks(&self) -> Result<Vec<TodoTask>, ControllerError> {
|
||||||
|
let tasks = organize(self.db.get_todo_tasks().await?).tasks;
|
||||||
|
let courses: HashMap<_, Course> = self
|
||||||
|
.db
|
||||||
|
.get_courses()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|course| (course.id, course))
|
||||||
|
.collect();
|
||||||
|
Ok(tasks
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|task| {
|
||||||
|
let course = courses.get(&task.course_id)?;
|
||||||
|
Some(TodoTask {
|
||||||
|
course: course.into(),
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_course_tasks(
|
pub async fn get_course_tasks(
|
||||||
&self,
|
&self,
|
||||||
course_id: CourseId,
|
course_id: CourseId,
|
||||||
|
|
@ -189,6 +212,7 @@ fn calendar(
|
||||||
Some(CalendarTask {
|
Some(CalendarTask {
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
course_id: course.id,
|
course_id: course.id,
|
||||||
|
course_kind: course.kind,
|
||||||
course_name: course.name.clone(),
|
course_name: course.name.clone(),
|
||||||
course_code: course.code.clone(),
|
course_code: course.code.clone(),
|
||||||
name: match parent {
|
name: match parent {
|
||||||
|
|
@ -229,6 +253,7 @@ mod tests {
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::core::models::course::CourseKind;
|
||||||
|
|
||||||
fn task(id: TaskId, parent_id: Option<TaskId>, day: Option<u32>, done: bool) -> Task {
|
fn task(id: TaskId, parent_id: Option<TaskId>, day: Option<u32>, done: bool) -> Task {
|
||||||
Task {
|
Task {
|
||||||
|
|
@ -241,6 +266,7 @@ mod tests {
|
||||||
deadline: day.map(|day| Utc.with_ymd_and_hms(2026, 10, day, 12, 0, 0).unwrap()),
|
deadline: day.map(|day| Utc.with_ymd_and_hms(2026, 10, day, 12, 0, 0).unwrap()),
|
||||||
done,
|
done,
|
||||||
tag_ids: vec![1],
|
tag_ids: vec![1],
|
||||||
|
time_spent: 0,
|
||||||
subtasks: Vec::new(),
|
subtasks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -264,10 +290,12 @@ mod tests {
|
||||||
fn calendar_names_and_tags_subtasks_after_their_parent() {
|
fn calendar_names_and_tags_subtasks_after_their_parent() {
|
||||||
let course = Course {
|
let course = Course {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
kind: CourseKind::Course,
|
||||||
name: "OS".to_owned(),
|
name: "OS".to_owned(),
|
||||||
code: None,
|
code: None,
|
||||||
teachers: Vec::new(),
|
teachers: Vec::new(),
|
||||||
main_page_url: None,
|
main_page_url: None,
|
||||||
|
folder: "Cours/OS".to_owned(),
|
||||||
slots: Vec::new(),
|
slots: Vec::new(),
|
||||||
};
|
};
|
||||||
let from = Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap();
|
let from = Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,68 @@ use crate::core::models::time_slot::TimeSlot;
|
||||||
|
|
||||||
pub type CourseId = i32;
|
pub type CourseId = i32;
|
||||||
|
|
||||||
|
/// A course, or an association: both have tasks, files and slots, only the courses have
|
||||||
|
/// teachers and grades.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct Course {
|
pub struct Course {
|
||||||
pub id: CourseId,
|
pub id: CourseId,
|
||||||
|
pub kind: CourseKind,
|
||||||
/// e.g. "Advanced operating systems"
|
/// e.g. "Advanced operating systems"
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// e.g. "CS-477"
|
/// e.g. "CS-477", or the acronym of an association
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
/// At least one
|
/// At least one for a course, none for an association
|
||||||
pub teachers: Vec<String>,
|
pub teachers: Vec<String>,
|
||||||
/// Main page of the course, usually on moodle
|
/// Main page of the course, usually on moodle
|
||||||
pub main_page_url: Option<String>,
|
pub main_page_url: Option<String>,
|
||||||
|
/// Folder of its files in the storage, e.g. `Cours/CS-477 Advanced operating systems`
|
||||||
|
pub folder: String,
|
||||||
/// Weekly slots, sorted by weekday then start time
|
/// Weekly slots, sorted by weekday then start time
|
||||||
pub slots: Vec<TimeSlot>,
|
pub slots: Vec<TimeSlot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CourseKind {
|
||||||
|
Course,
|
||||||
|
Association,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CourseKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
CourseKind::Course => "course",
|
||||||
|
CourseKind::Association => "association",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Content of a course, used to create or replace one. The slots are managed apart.
|
/// Content of a course, used to create or replace one. The slots are managed apart.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CourseData {
|
pub struct CourseData {
|
||||||
|
pub kind: CourseKind,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub teachers: Vec<String>,
|
pub teachers: Vec<String>,
|
||||||
pub main_page_url: Option<String>,
|
pub main_page_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a task needs to show of its course
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct CourseSummary {
|
||||||
|
pub id: CourseId,
|
||||||
|
pub kind: CourseKind,
|
||||||
|
pub name: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Course> for CourseSummary {
|
||||||
|
fn from(course: &Course) -> Self {
|
||||||
|
CourseSummary {
|
||||||
|
id: course.id,
|
||||||
|
kind: course.kind,
|
||||||
|
name: course.name.clone(),
|
||||||
|
code: course.code.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ use crate::core::models::{course::CourseId, tag::TagId};
|
||||||
pub type CourseFileId = i32;
|
pub type CourseFileId = i32;
|
||||||
|
|
||||||
/// A file of a course: lecture notes written in the app, or (soon) an uploaded file.
|
/// A file of a course: lecture notes written in the app, or (soon) an uploaded file.
|
||||||
/// Only the metadata is in the database, the content is in the storage (the NAS).
|
/// Only the metadata is in the database, the content is in the storage (the NAS), in the
|
||||||
|
/// folder of the course.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct CourseFile {
|
pub struct CourseFile {
|
||||||
pub id: CourseFileId,
|
pub id: CourseFileId,
|
||||||
|
|
@ -15,6 +16,8 @@ pub struct CourseFile {
|
||||||
/// For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf
|
/// For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: FileKind,
|
pub kind: FileKind,
|
||||||
|
/// Path in the folder of the course, without extension, e.g. `Notes/Lecture 1 Notes`
|
||||||
|
pub path: String,
|
||||||
/// e.g. slides, notes
|
/// e.g. slides, notes
|
||||||
pub tag_ids: Vec<TagId>,
|
pub tag_ids: Vec<TagId>,
|
||||||
/// For a note: false when the last rendering failed
|
/// For a note: false when the last rendering failed
|
||||||
|
|
|
||||||
45
src/core/models/focus.rs
Normal file
45
src/core/models/focus.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::core::models::{course::CourseSummary, tag::TagId, task::TaskId};
|
||||||
|
|
||||||
|
/// Record of TimeTagger (https://timetagger.app) counting a focus session. Kept by the
|
||||||
|
/// client while the session runs, and sent back to stop it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct TimetaggerRecord {
|
||||||
|
pub key: String,
|
||||||
|
/// Start, unix timestamp in seconds
|
||||||
|
pub t1: i64,
|
||||||
|
/// Description holding the tags, e.g. `#cs-477 #homework #exercise-set-3`
|
||||||
|
pub ds: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A focus session started on a task
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct FocusStart {
|
||||||
|
pub course: CourseSummary,
|
||||||
|
pub task_id: TaskId,
|
||||||
|
pub name: String,
|
||||||
|
/// Name of the parent task, for a subtask
|
||||||
|
pub parent_name: Option<String>,
|
||||||
|
/// Tags of the task, or of its parent for a subtask without any
|
||||||
|
pub tag_ids: Vec<TagId>,
|
||||||
|
/// Seconds already spent on the task before this session
|
||||||
|
pub time_spent: i32,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
/// Whether TimeTagger is configured
|
||||||
|
pub timetagger_enabled: bool,
|
||||||
|
/// The running record, `None` when TimeTagger is not configured or failed
|
||||||
|
pub timetagger: Option<TimetaggerRecord>,
|
||||||
|
/// Why TimeTagger failed, the session runs anyway
|
||||||
|
pub timetagger_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End of a focus session
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct FocusStop {
|
||||||
|
/// Seconds spent on the task, this session included
|
||||||
|
pub time_spent: i32,
|
||||||
|
pub timetagger_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod course;
|
pub mod course;
|
||||||
pub mod course_file;
|
pub mod course_file;
|
||||||
|
pub mod focus;
|
||||||
pub mod grade;
|
pub mod grade;
|
||||||
pub mod localized_string;
|
pub mod localized_string;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ pub struct Tag {
|
||||||
pub name: LocalizedString,
|
pub name: LocalizedString,
|
||||||
/// Hex color used to display what is tagged, e.g. `#2563eb`
|
/// Hex color used to display what is tagged, e.g. `#2563eb`
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
/// Stable name, e.g. `homework`
|
||||||
|
pub slug: Option<String>,
|
||||||
/// What the tag can label
|
/// What the tag can label
|
||||||
pub scopes: Vec<TagScope>,
|
pub scopes: Vec<TagScope>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ use chrono::{DateTime, Utc};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::core::models::{course::CourseId, tag::TagId};
|
use crate::core::models::{
|
||||||
|
course::{CourseId, CourseKind, CourseSummary},
|
||||||
|
tag::TagId,
|
||||||
|
};
|
||||||
|
|
||||||
pub type TaskId = i32;
|
pub type TaskId = i32;
|
||||||
|
|
||||||
|
|
@ -20,6 +23,8 @@ pub struct Task {
|
||||||
pub deadline: Option<DateTime<Utc>>,
|
pub deadline: Option<DateTime<Utc>>,
|
||||||
pub done: bool,
|
pub done: bool,
|
||||||
pub tag_ids: Vec<TagId>,
|
pub tag_ids: Vec<TagId>,
|
||||||
|
/// Seconds spent working on it, counted by the pomodoro
|
||||||
|
pub time_spent: i32,
|
||||||
/// Sorted like the tasks. Always empty for a subtask: there is only one level.
|
/// Sorted like the tasks. Always empty for a subtask: there is only one level.
|
||||||
pub subtasks: Vec<Task>,
|
pub subtasks: Vec<Task>,
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +53,7 @@ pub struct CourseTasks {
|
||||||
pub struct CalendarTask {
|
pub struct CalendarTask {
|
||||||
pub task_id: TaskId,
|
pub task_id: TaskId,
|
||||||
pub course_id: CourseId,
|
pub course_id: CourseId,
|
||||||
|
pub course_kind: CourseKind,
|
||||||
pub course_name: String,
|
pub course_name: String,
|
||||||
pub course_code: Option<String>,
|
pub course_code: Option<String>,
|
||||||
/// Name of the task, prefixed by the one of its parent for a subtask
|
/// Name of the task, prefixed by the one of its parent for a subtask
|
||||||
|
|
@ -58,3 +64,11 @@ pub struct CalendarTask {
|
||||||
/// Tags of the task, or of its parent for a subtask without any
|
/// Tags of the task, or of its parent for a subtask without any
|
||||||
pub tag_ids: Vec<TagId>,
|
pub tag_ids: Vec<TagId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A task not done yet, with its course, for the list of the things to do
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct TodoTask {
|
||||||
|
pub course: CourseSummary,
|
||||||
|
/// With all its subtasks, the ones done included
|
||||||
|
pub task: Task,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,16 @@ pub trait CourseFilesRepository {
|
||||||
&self,
|
&self,
|
||||||
course_id: CourseId,
|
course_id: CourseId,
|
||||||
name: String,
|
name: String,
|
||||||
|
path: String,
|
||||||
tag_ids: &[TagId],
|
tag_ids: &[TagId],
|
||||||
) -> Result<CourseFile, RepositoryError>;
|
) -> Result<CourseFile, RepositoryError>;
|
||||||
/// Sets the name and `has_pdf`, and bumps `updated_at`. `NotFound` if the note does
|
/// Sets the name, path and `has_pdf`, and bumps `updated_at`. `NotFound` if the note
|
||||||
/// not exist.
|
/// does not exist.
|
||||||
async fn update_lecture_note(
|
async fn update_lecture_note(
|
||||||
&self,
|
&self,
|
||||||
id: CourseFileId,
|
id: CourseFileId,
|
||||||
name: String,
|
name: String,
|
||||||
|
path: String,
|
||||||
has_pdf: bool,
|
has_pdf: bool,
|
||||||
) -> Result<CourseFile, RepositoryError>;
|
) -> Result<CourseFile, RepositoryError>;
|
||||||
/// `NotFound` if the file does not exist
|
/// `NotFound` if the file does not exist
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,22 @@ use crate::core::{
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait CoursesRepository {
|
pub trait CoursesRepository {
|
||||||
/// Every course with its slots, sorted by name
|
/// Every course and association with its slots, sorted by name
|
||||||
async fn get_courses(&self) -> Result<Vec<Course>, RepositoryError>;
|
async fn get_courses(&self) -> Result<Vec<Course>, RepositoryError>;
|
||||||
/// `NotFound` if the course does not exist
|
/// `NotFound` if the course does not exist
|
||||||
async fn get_course(&self, id: CourseId) -> Result<Course, RepositoryError>;
|
async fn get_course(&self, id: CourseId) -> Result<Course, RepositoryError>;
|
||||||
/// Creates a course without any slot
|
/// Creates a course without any slot, its files in `folder`
|
||||||
async fn create_course(&self, course: CourseData) -> Result<Course, RepositoryError>;
|
async fn create_course(
|
||||||
/// Replaces the course, keeping its slots. `NotFound` if the course does not exist.
|
&self,
|
||||||
|
course: CourseData,
|
||||||
|
folder: &str,
|
||||||
|
) -> Result<Course, RepositoryError>;
|
||||||
|
/// Replaces the course, keeping its slots and its kind. `NotFound` if the course does not exist.
|
||||||
async fn update_course(
|
async fn update_course(
|
||||||
&self,
|
&self,
|
||||||
id: CourseId,
|
id: CourseId,
|
||||||
course: CourseData,
|
course: CourseData,
|
||||||
|
folder: &str,
|
||||||
) -> Result<Course, RepositoryError>;
|
) -> Result<Course, RepositoryError>;
|
||||||
/// Deletes the course and everything attached to it. `NotFound` if it does not exist.
|
/// Deletes the course and everything attached to it. `NotFound` if it does not exist.
|
||||||
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError>;
|
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError>;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub mod pdf_renderer;
|
||||||
pub mod storage_repository;
|
pub mod storage_repository;
|
||||||
pub mod tags_repository;
|
pub mod tags_repository;
|
||||||
pub mod tasks_repository;
|
pub mod tasks_repository;
|
||||||
|
pub mod time_tracker;
|
||||||
|
|
||||||
/// Add every new repository trait here so the controller can use it through `db`
|
/// Add every new repository trait here so the controller can use it through `db`
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@ pub trait StorageRepository: Send + Sync {
|
||||||
async fn delete_file(&self, path: &str) -> Result<(), RepositoryError>;
|
async fn delete_file(&self, path: &str) -> Result<(), RepositoryError>;
|
||||||
/// Deletes a directory and everything in it. Does nothing if it does not exist.
|
/// Deletes a directory and everything in it. Does nothing if it does not exist.
|
||||||
async fn delete_dir(&self, path: &str) -> Result<(), RepositoryError>;
|
async fn delete_dir(&self, path: &str) -> Result<(), RepositoryError>;
|
||||||
|
/// Moves a file or a directory, creating the missing parents of `to`. `NotFound` if
|
||||||
|
/// `from` does not exist, an error if `to` already exists.
|
||||||
|
async fn rename(&self, from: &str, to: &str) -> Result<(), RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ pub trait TasksRepository {
|
||||||
from: DateTime<Utc>,
|
from: DateTime<Utc>,
|
||||||
to: DateTime<Utc>,
|
to: DateTime<Utc>,
|
||||||
) -> Result<Vec<Task>, RepositoryError>;
|
) -> Result<Vec<Task>, RepositoryError>;
|
||||||
|
/// Tasks not done of every course, with all their subtasks. Flat: `subtasks` is always
|
||||||
|
/// empty.
|
||||||
|
async fn get_todo_tasks(&self) -> Result<Vec<Task>, RepositoryError>;
|
||||||
/// A single task, `subtasks` empty. `NotFound` if the task does not exist.
|
/// A single task, `subtasks` empty. `NotFound` if the task does not exist.
|
||||||
async fn get_task(&self, id: TaskId) -> Result<Task, RepositoryError>;
|
async fn get_task(&self, id: TaskId) -> Result<Task, RepositoryError>;
|
||||||
async fn create_task(
|
async fn create_task(
|
||||||
|
|
@ -30,6 +33,9 @@ pub trait TasksRepository {
|
||||||
) -> Result<(), RepositoryError>;
|
) -> Result<(), RepositoryError>;
|
||||||
/// Replaces the task and its tags. `NotFound` if the task does not exist.
|
/// Replaces the task and its tags. `NotFound` if the task does not exist.
|
||||||
async fn update_task(&self, id: TaskId, task: TaskData) -> Result<(), RepositoryError>;
|
async fn update_task(&self, id: TaskId, task: TaskData) -> Result<(), RepositoryError>;
|
||||||
|
/// Adds `seconds` to the time spent on the task, returns the new total. `NotFound` if
|
||||||
|
/// the task does not exist.
|
||||||
|
async fn add_time_spent(&self, id: TaskId, seconds: i32) -> Result<i32, RepositoryError>;
|
||||||
/// Deletes the task and its subtasks. `NotFound` if the task does not exist.
|
/// Deletes the task and its subtasks. `NotFound` if the task does not exist.
|
||||||
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError>;
|
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/core/repositories/time_tracker.rs
Normal file
21
src/core/repositories/time_tracker.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use crate::core::{models::focus::TimetaggerRecord, repositories::RepositoryError};
|
||||||
|
|
||||||
|
/// Where the focus sessions are also counted: TimeTagger
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TimeTracker: Send + Sync {
|
||||||
|
/// Starts a running record described by `description` (text and `#tags`)
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
description: &str,
|
||||||
|
start: DateTime<Utc>,
|
||||||
|
) -> Result<TimetaggerRecord, RepositoryError>;
|
||||||
|
/// Stops a running record at `end`
|
||||||
|
async fn stop(
|
||||||
|
&self,
|
||||||
|
record: &TimetaggerRecord,
|
||||||
|
end: DateTime<Utc>,
|
||||||
|
) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
20
src/main.rs
20
src/main.rs
|
|
@ -5,8 +5,14 @@ use tracing::info;
|
||||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
core::controller::{AppController, course_files::NotesSettings},
|
core::{
|
||||||
services::{database::SqlxDatabase, pandoc::PandocRenderer, storage::LocalStorage},
|
controller::{AppController, course_files::NotesSettings},
|
||||||
|
repositories::time_tracker::TimeTracker,
|
||||||
|
},
|
||||||
|
services::{
|
||||||
|
database::SqlxDatabase, pandoc::PandocRenderer, storage::LocalStorage,
|
||||||
|
timetagger::Timetagger,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
|
|
@ -44,11 +50,21 @@ async fn main() {
|
||||||
.map(|logo| template_dir.join(logo).display().to_string()),
|
.map(|logo| template_dir.join(logo).display().to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let time_tracker = config.timetagger.as_ref().map(|timetagger| {
|
||||||
|
let url = timetagger
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("https://timetagger.app/api/v2");
|
||||||
|
info!("Focus sessions are counted in TimeTagger ({url})");
|
||||||
|
Arc::new(Timetagger::new(url, timetagger.token.clone())) as Arc<dyn TimeTracker>
|
||||||
|
});
|
||||||
|
|
||||||
let controller = AppController::new(
|
let controller = AppController::new(
|
||||||
Arc::new(Box::new(db)),
|
Arc::new(Box::new(db)),
|
||||||
Arc::new(LocalStorage::new(&config.storage_dir)),
|
Arc::new(LocalStorage::new(&config.storage_dir)),
|
||||||
Arc::new(PandocRenderer::new(template_dir)),
|
Arc::new(PandocRenderer::new(template_dir)),
|
||||||
notes,
|
notes,
|
||||||
|
time_tracker,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Anything that is not an api route is served from the built frontend,
|
// Anything that is not an api route is served from the built frontend,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ struct CourseFileDB {
|
||||||
pub course_id: i32,
|
pub course_id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
pub path: String,
|
||||||
pub tag_ids: Vec<i32>,
|
pub tag_ids: Vec<i32>,
|
||||||
pub has_pdf: bool,
|
pub has_pdf: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
|
@ -42,6 +43,7 @@ impl TryFrom<CourseFileDB> for CourseFile {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
path: value.path,
|
||||||
tag_ids: value.tag_ids,
|
tag_ids: value.tag_ids,
|
||||||
has_pdf: value.has_pdf,
|
has_pdf: value.has_pdf,
|
||||||
created_at: value.created_at,
|
created_at: value.created_at,
|
||||||
|
|
@ -62,7 +64,7 @@ impl CourseFilesRepository for SqlxDatabase {
|
||||||
) -> Result<Vec<CourseFile>, RepositoryError> {
|
) -> Result<Vec<CourseFile>, RepositoryError> {
|
||||||
query_as!(
|
query_as!(
|
||||||
CourseFileDB,
|
CourseFileDB,
|
||||||
r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
|
r#"SELECT f.id, f.course_id, f.name, f.kind, f.path, f.has_pdf, f.created_at, f.updated_at,
|
||||||
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
|
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
|
||||||
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
FROM course_files f
|
FROM course_files f
|
||||||
|
|
@ -82,7 +84,7 @@ impl CourseFilesRepository for SqlxDatabase {
|
||||||
async fn get_course_file(&self, id: CourseFileId) -> Result<CourseFile, RepositoryError> {
|
async fn get_course_file(&self, id: CourseFileId) -> Result<CourseFile, RepositoryError> {
|
||||||
query_as!(
|
query_as!(
|
||||||
CourseFileDB,
|
CourseFileDB,
|
||||||
r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
|
r#"SELECT f.id, f.course_id, f.name, f.kind, f.path, f.has_pdf, f.created_at, f.updated_at,
|
||||||
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
|
COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
|
||||||
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
FROM course_files f
|
FROM course_files f
|
||||||
|
|
@ -101,14 +103,16 @@ impl CourseFilesRepository for SqlxDatabase {
|
||||||
&self,
|
&self,
|
||||||
course_id: CourseId,
|
course_id: CourseId,
|
||||||
name: String,
|
name: String,
|
||||||
|
path: String,
|
||||||
tag_ids: &[TagId],
|
tag_ids: &[TagId],
|
||||||
) -> Result<CourseFile, RepositoryError> {
|
) -> Result<CourseFile, RepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
let id = query!(
|
let id = query!(
|
||||||
r#"INSERT INTO course_files (course_id, name, kind) VALUES ($1, $2, 'note')
|
r#"INSERT INTO course_files (course_id, name, kind, path) VALUES ($1, $2, 'note', $3)
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
course_id,
|
course_id,
|
||||||
name,
|
name,
|
||||||
|
path,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -129,13 +133,15 @@ impl CourseFilesRepository for SqlxDatabase {
|
||||||
&self,
|
&self,
|
||||||
id: CourseFileId,
|
id: CourseFileId,
|
||||||
name: String,
|
name: String,
|
||||||
|
path: String,
|
||||||
has_pdf: bool,
|
has_pdf: bool,
|
||||||
) -> Result<CourseFile, RepositoryError> {
|
) -> Result<CourseFile, RepositoryError> {
|
||||||
let result = query!(
|
let result = query!(
|
||||||
r#"UPDATE course_files SET name = $2, has_pdf = $3, updated_at = now()
|
r#"UPDATE course_files SET name = $2, path = $3, has_pdf = $4, updated_at = now()
|
||||||
WHERE id = $1 AND kind = 'note'"#,
|
WHERE id = $1 AND kind = 'note'"#,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
path,
|
||||||
has_pdf,
|
has_pdf,
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use sqlx::{PgExecutor, query, query_as};
|
||||||
use crate::{
|
use crate::{
|
||||||
core::{
|
core::{
|
||||||
models::{
|
models::{
|
||||||
course::{Course, CourseData, CourseId},
|
course::{Course, CourseData, CourseId, CourseKind},
|
||||||
time_slot::{TimeSlot, TimeSlotData, TimeSlotId},
|
time_slot::{TimeSlot, TimeSlotData, TimeSlotId},
|
||||||
},
|
},
|
||||||
repositories::{RepositoryError, courses_repository::CoursesRepository},
|
repositories::{RepositoryError, courses_repository::CoursesRepository},
|
||||||
|
|
@ -20,22 +20,34 @@ use crate::{
|
||||||
|
|
||||||
struct CourseDB {
|
struct CourseDB {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
pub kind: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub teachers: Vec<String>,
|
pub teachers: Vec<String>,
|
||||||
pub main_page_url: Option<String>,
|
pub main_page_url: Option<String>,
|
||||||
|
pub folder: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CourseDB {
|
impl CourseDB {
|
||||||
fn with_slots(self, slots: Vec<TimeSlot>) -> Course {
|
fn with_slots(self, slots: Vec<TimeSlot>) -> Result<Course, RepositoryError> {
|
||||||
Course {
|
Ok(Course {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
|
kind: match self.kind.as_str() {
|
||||||
|
"course" => CourseKind::Course,
|
||||||
|
"association" => CourseKind::Association,
|
||||||
|
kind => {
|
||||||
|
return Err(RepositoryError::TypeConversion(format!(
|
||||||
|
"invalid course kind {kind}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
name: self.name,
|
name: self.name,
|
||||||
code: self.code,
|
code: self.code,
|
||||||
teachers: self.teachers,
|
teachers: self.teachers,
|
||||||
main_page_url: self.main_page_url,
|
main_page_url: self.main_page_url,
|
||||||
|
folder: self.folder,
|
||||||
slots,
|
slots,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +99,8 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
async fn get_courses(&self) -> Result<Vec<Course>, RepositoryError> {
|
async fn get_courses(&self) -> Result<Vec<Course>, RepositoryError> {
|
||||||
let courses = query_as!(
|
let courses = query_as!(
|
||||||
CourseDB,
|
CourseDB,
|
||||||
r#"SELECT id, name, code, teachers, main_page_url FROM courses ORDER BY name, id"#
|
r#"SELECT id, kind, name, code, teachers, main_page_url, folder
|
||||||
|
FROM courses ORDER BY name, id"#
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -108,65 +121,75 @@ impl CoursesRepository for SqlxDatabase {
|
||||||
.push(slot.try_into()?);
|
.push(slot.try_into()?);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(courses
|
courses
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|course| {
|
.map(|course| {
|
||||||
let slots = slots_by_course.remove(&course.id).unwrap_or_default();
|
let slots = slots_by_course.remove(&course.id).unwrap_or_default();
|
||||||
course.with_slots(slots)
|
course.with_slots(slots)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_course(&self, id: CourseId) -> Result<Course, RepositoryError> {
|
async fn get_course(&self, id: CourseId) -> Result<Course, RepositoryError> {
|
||||||
let row = query_as!(
|
let row = query_as!(
|
||||||
CourseDB,
|
CourseDB,
|
||||||
r#"SELECT id, name, code, teachers, main_page_url FROM courses WHERE id = $1"#,
|
r#"SELECT id, kind, name, code, teachers, main_page_url, folder
|
||||||
|
FROM courses WHERE id = $1"#,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
||||||
let slots = get_slots(&self.pool, id).await?;
|
let slots = get_slots(&self.pool, id).await?;
|
||||||
Ok(row.with_slots(slots))
|
row.with_slots(slots)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_course(&self, course: CourseData) -> Result<Course, RepositoryError> {
|
async fn create_course(
|
||||||
|
&self,
|
||||||
|
course: CourseData,
|
||||||
|
folder: &str,
|
||||||
|
) -> Result<Course, RepositoryError> {
|
||||||
let row = query_as!(
|
let row = query_as!(
|
||||||
CourseDB,
|
CourseDB,
|
||||||
r#"INSERT INTO courses (name, code, teachers, main_page_url)
|
r#"INSERT INTO courses (kind, name, code, teachers, main_page_url, folder)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, name, code, teachers, main_page_url"#,
|
RETURNING id, kind, name, code, teachers, main_page_url, folder"#,
|
||||||
|
course.kind.as_str(),
|
||||||
course.name,
|
course.name,
|
||||||
course.code,
|
course.code,
|
||||||
&course.teachers,
|
&course.teachers,
|
||||||
course.main_page_url,
|
course.main_page_url,
|
||||||
|
folder,
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.with_slots(Vec::new()))
|
row.with_slots(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_course(
|
async fn update_course(
|
||||||
&self,
|
&self,
|
||||||
id: CourseId,
|
id: CourseId,
|
||||||
course: CourseData,
|
course: CourseData,
|
||||||
|
folder: &str,
|
||||||
) -> Result<Course, RepositoryError> {
|
) -> Result<Course, RepositoryError> {
|
||||||
let row = query_as!(
|
let row = query_as!(
|
||||||
CourseDB,
|
CourseDB,
|
||||||
r#"UPDATE courses SET name = $2, code = $3, teachers = $4, main_page_url = $5
|
r#"UPDATE courses
|
||||||
|
SET name = $2, code = $3, teachers = $4, main_page_url = $5, folder = $6
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, code, teachers, main_page_url"#,
|
RETURNING id, kind, name, code, teachers, main_page_url, folder"#,
|
||||||
id,
|
id,
|
||||||
course.name,
|
course.name,
|
||||||
course.code,
|
course.code,
|
||||||
&course.teachers,
|
&course.teachers,
|
||||||
course.main_page_url,
|
course.main_page_url,
|
||||||
|
folder,
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
|
||||||
let slots = get_slots(&self.pool, id).await?;
|
let slots = get_slots(&self.pool, id).await?;
|
||||||
Ok(row.with_slots(slots))
|
row.with_slots(slots)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError> {
|
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError> {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ struct TagDB {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: Value,
|
pub name: Value,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ impl TryFrom<TagDB> for Tag {
|
||||||
id: value.id,
|
id: value.id,
|
||||||
name: serde_json::from_value(value.name)?,
|
name: serde_json::from_value(value.name)?,
|
||||||
color: value.color,
|
color: value.color,
|
||||||
|
slug: value.slug,
|
||||||
scopes: value
|
scopes: value
|
||||||
.scopes
|
.scopes
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -50,7 +52,7 @@ impl TagsRepository for SqlxDatabase {
|
||||||
async fn get_tags(&self, scope: Option<TagScope>) -> Result<Vec<Tag>, RepositoryError> {
|
async fn get_tags(&self, scope: Option<TagScope>) -> Result<Vec<Tag>, RepositoryError> {
|
||||||
query_as!(
|
query_as!(
|
||||||
TagDB,
|
TagDB,
|
||||||
r#"SELECT id, name, color, scopes FROM tags
|
r#"SELECT id, name, color, slug, scopes FROM tags
|
||||||
WHERE $1::TEXT IS NULL OR $1 = ANY(scopes)
|
WHERE $1::TEXT IS NULL OR $1 = ANY(scopes)
|
||||||
ORDER BY id"#,
|
ORDER BY id"#,
|
||||||
scope.map(TagScope::as_str),
|
scope.map(TagScope::as_str),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ struct TaskDB {
|
||||||
pub links: Vec<String>,
|
pub links: Vec<String>,
|
||||||
pub deadline: Option<DateTime<Utc>>,
|
pub deadline: Option<DateTime<Utc>>,
|
||||||
pub done: bool,
|
pub done: bool,
|
||||||
|
pub time_spent: i32,
|
||||||
pub tag_ids: Vec<i32>,
|
pub tag_ids: Vec<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ impl From<TaskDB> for Task {
|
||||||
deadline: value.deadline,
|
deadline: value.deadline,
|
||||||
done: value.done,
|
done: value.done,
|
||||||
tag_ids: value.tag_ids,
|
tag_ids: value.tag_ids,
|
||||||
|
time_spent: value.time_spent,
|
||||||
subtasks: Vec::new(),
|
subtasks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +75,7 @@ impl TasksRepository for SqlxDatabase {
|
||||||
Ok(query_as!(
|
Ok(query_as!(
|
||||||
TaskDB,
|
TaskDB,
|
||||||
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
t.deadline, t.done,
|
t.deadline, t.done, t.time_spent,
|
||||||
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
|
|
@ -98,7 +100,7 @@ impl TasksRepository for SqlxDatabase {
|
||||||
Ok(query_as!(
|
Ok(query_as!(
|
||||||
TaskDB,
|
TaskDB,
|
||||||
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
t.deadline, t.done,
|
t.deadline, t.done, t.time_spent,
|
||||||
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
|
|
@ -118,11 +120,32 @@ impl TasksRepository for SqlxDatabase {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_todo_tasks(&self) -> Result<Vec<Task>, RepositoryError> {
|
||||||
|
Ok(query_as!(
|
||||||
|
TaskDB,
|
||||||
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
|
t.deadline, t.done, t.time_spent,
|
||||||
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||||
|
WHERE t.id IN (SELECT id FROM tasks WHERE parent_id IS NULL AND NOT done)
|
||||||
|
OR t.parent_id IN (SELECT id FROM tasks WHERE parent_id IS NULL AND NOT done)
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_task(&self, id: TaskId) -> Result<Task, RepositoryError> {
|
async fn get_task(&self, id: TaskId) -> Result<Task, RepositoryError> {
|
||||||
query_as!(
|
query_as!(
|
||||||
TaskDB,
|
TaskDB,
|
||||||
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
|
||||||
t.deadline, t.done,
|
t.deadline, t.done, t.time_spent,
|
||||||
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
|
||||||
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
|
|
@ -189,6 +212,19 @@ impl TasksRepository for SqlxDatabase {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_time_spent(&self, id: TaskId, seconds: i32) -> Result<i32, RepositoryError> {
|
||||||
|
query!(
|
||||||
|
r#"UPDATE tasks SET time_spent = time_spent + $2 WHERE id = $1
|
||||||
|
RETURNING time_spent"#,
|
||||||
|
id,
|
||||||
|
seconds,
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|row| row.time_spent)
|
||||||
|
.ok_or_else(|| not_found(id))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError> {
|
async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError> {
|
||||||
// The subtasks and the tags are removed by the `ON DELETE CASCADE`
|
// The subtasks and the tags are removed by the `ON DELETE CASCADE`
|
||||||
let result = query!("DELETE FROM tasks WHERE id = $1", id)
|
let result = query!("DELETE FROM tasks WHERE id = $1", id)
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod pandoc;
|
pub mod pandoc;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod timetagger;
|
||||||
|
|
|
||||||
|
|
@ -75,4 +75,30 @@ impl StorageRepository for LocalStorage {
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn rename(&self, from: &str, to: &str) -> Result<(), RepositoryError> {
|
||||||
|
let source = self.resolve(from)?;
|
||||||
|
let target = self.resolve(to)?;
|
||||||
|
if !fs::try_exists(&source)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(from, err))?
|
||||||
|
{
|
||||||
|
return Err(RepositoryError::NotFound(format!("file {from}")));
|
||||||
|
}
|
||||||
|
// `rename` would silently replace a file
|
||||||
|
if fs::try_exists(&target)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(to, err))?
|
||||||
|
{
|
||||||
|
return Err(RepositoryError::Internal(format!("{to} already exists")));
|
||||||
|
}
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(to, err))?;
|
||||||
|
}
|
||||||
|
fs::rename(source, target)
|
||||||
|
.await
|
||||||
|
.map_err(|err| io_error(from, err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
135
src/services/timetagger.rs
Normal file
135
src/services/timetagger.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
//! Client of the TimeTagger api (https://timetagger.app/api_docs). A record is running
|
||||||
|
//! while its start `t1` equals its end `t2`: it is created that way, then written again
|
||||||
|
//! with its end to stop it.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
hash::{BuildHasher, RandomState},
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
models::focus::TimetaggerRecord,
|
||||||
|
repositories::{RepositoryError, time_tracker::TimeTracker},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
pub struct Timetagger {
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// e.g. `https://timetagger.app/api/v2`
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Timetagger {
|
||||||
|
pub fn new(url: &str, token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.expect("Unable to build the http client"),
|
||||||
|
url: url.trim_end_matches('/').to_owned(),
|
||||||
|
token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(&self, record: Record<'_>) -> Result<(), RepositoryError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.put(format!("{}/records", self.url))
|
||||||
|
.header("authtoken", &self.token)
|
||||||
|
.json(&[record])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::Internal(format!("TimeTagger unreachable: {err}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(RepositoryError::Internal(format!(
|
||||||
|
"TimeTagger answered {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let result: PutResult = response.json().await.map_err(|err| {
|
||||||
|
RepositoryError::Internal(format!("Unexpected answer of TimeTagger: {err}"))
|
||||||
|
})?;
|
||||||
|
if !result.failed.is_empty() {
|
||||||
|
return Err(RepositoryError::Internal(format!(
|
||||||
|
"TimeTagger refused the record: {}",
|
||||||
|
result.errors.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Record<'a> {
|
||||||
|
key: &'a str,
|
||||||
|
t1: i64,
|
||||||
|
t2: i64,
|
||||||
|
ds: &'a str,
|
||||||
|
/// Modification time
|
||||||
|
mt: i64,
|
||||||
|
/// Server time, set by the server
|
||||||
|
st: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PutResult {
|
||||||
|
#[serde(default)]
|
||||||
|
failed: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unique enough key for a record
|
||||||
|
fn random_key() -> String {
|
||||||
|
format!("{:016x}", RandomState::new().hash_one(SystemTime::now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TimeTracker for Timetagger {
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
description: &str,
|
||||||
|
start: DateTime<Utc>,
|
||||||
|
) -> Result<TimetaggerRecord, RepositoryError> {
|
||||||
|
let key = random_key();
|
||||||
|
let t1 = start.timestamp();
|
||||||
|
self.put(Record {
|
||||||
|
key: &key,
|
||||||
|
t1,
|
||||||
|
t2: t1,
|
||||||
|
ds: description,
|
||||||
|
mt: Utc::now().timestamp(),
|
||||||
|
st: 0.0,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(TimetaggerRecord {
|
||||||
|
key,
|
||||||
|
t1,
|
||||||
|
ds: description.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(
|
||||||
|
&self,
|
||||||
|
record: &TimetaggerRecord,
|
||||||
|
end: DateTime<Utc>,
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
self.put(Record {
|
||||||
|
key: &record.key,
|
||||||
|
t1: record.t1,
|
||||||
|
// A record of zero second would stay running
|
||||||
|
t2: end.timestamp().max(record.t1 + 1),
|
||||||
|
ds: &record.ds,
|
||||||
|
mt: Utc::now().timestamp(),
|
||||||
|
st: 0.0,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,16 +30,27 @@ pub struct NotesConfig {
|
||||||
pub student_id: Option<String>,
|
pub student_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TimeTagger account the focus sessions are counted in
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct TimetaggerConfig {
|
||||||
|
/// Api of the TimeTagger server, `https://timetagger.app/api/v2` by default
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Api token, from the account page of TimeTagger
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub postgres: PostgresConfig,
|
pub postgres: PostgresConfig,
|
||||||
/// Directory containing the built frontend
|
/// Directory containing the built frontend
|
||||||
pub frontend_dir: String,
|
pub frontend_dir: String,
|
||||||
/// Where the files of the courses are stored (lecture notes, pdf...). A local
|
/// Where the files of the courses and associations are stored (lecture notes,
|
||||||
/// directory standing in for the NAS for now.
|
/// pdf...), one folder each. A local directory standing in for the NAS for now.
|
||||||
pub storage_dir: String,
|
pub storage_dir: String,
|
||||||
pub notes: NotesConfig,
|
pub notes: NotesConfig,
|
||||||
|
/// Optional: without it the pomodoro only counts the time in the app
|
||||||
|
pub timetagger: Option<TimetaggerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue