From 87bcd00a5dec7c97c2bde7310e9a89ef6128c9eb Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:19:23 +0200 Subject: [PATCH 1/8] feat(config): normalize default project setting --- src/agiladmin/config.clj | 27 ++++++++++++++--- test/agiladmin/config_test.clj | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/agiladmin/config.clj b/src/agiladmin/config.clj index 11b8019..66f30ed 100644 --- a/src/agiladmin/config.clj +++ b/src/agiladmin/config.clj @@ -18,7 +18,7 @@ (ns agiladmin.config (:require [clojure.pprint :refer [pprint]] - [clojure.string :as str :refer [upper-case]] + [clojure.string :as str :refer [blank? trim upper-case]] [clojure.java.io :as io] [clojure.walk :refer [keywordize-keys]] [auxiliary.core :as aux] @@ -28,6 +28,9 @@ [yaml.core :as yaml] [cheshire.core :refer :all])) +(def NonBlankString + (s/constrained s/Str #(not (blank? %)) 'non-blank-string)) + (s/defschema Config {s/Keyword {:budgets {:git s/Str @@ -35,6 +38,7 @@ :path s/Str} (s/optional-key :projects) [s/Str] (s/optional-key :cache) s/Bool + (s/optional-key :default-project) NonBlankString (s/optional-key :voluntary-hours) s/Bool (s/optional-key :vat-percentage) s/Num (s/optional-key :webserver) {(s/optional-key :port) s/Num @@ -246,6 +250,20 @@ ;; (f/fail (log/spy :error ["Invalid configuration: " conf ex])))) (get-in conf path)) +(defn default-project + "Return the configured default project name in canonical uppercase form." + [conf] + (get-in conf [:agiladmin :default-project])) + +(defn- normalize-default-project + [conf] + (let [project (get-in conf [:agiladmin :default-project])] + (cond + (nil? project) conf + (string? project) (assoc-in conf [:agiladmin :default-project] + (-> project trim upper-case)) + :else conf))) + (defn- project-file? [conf file] (let [name (.getName file) @@ -288,9 +306,10 @@ conf (if (f/failed? conf) conf (let [app-key (keyword (:appname conf))] - (update-in conf - [app-key :webserver] - #(merge (:webserver default-settings) %)))) + (-> conf + (update-in [app-key :webserver] + #(merge (:webserver default-settings) %)) + normalize-default-project))) loaded-paths (->> (:paths conf) (filter #(.exists (io/as-file %))) vec) diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index 8bcb98e..4d8b965 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -207,6 +207,33 @@ (f/failed? conf) => false (get-in conf [:agiladmin :cache]) => true)) +(fact "Application config loader leaves default project unset when omitted" + (let [path "/tmp/agiladmin-default-project-absent.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (conf/default-project conf) => nil)) + +(fact "Application config loader normalizes an explicit default project" + (let [path "/tmp/agiladmin-default-project-explicit.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " default-project: \" infra \"\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (conf/default-project conf) => "INFRA")) + (fact "Application config loader preserves personnel display settings" (let [path "/tmp/agiladmin-personnel-display.yaml" _ (spit path @@ -360,3 +387,31 @@ conf (conf/load-config path conf/default-settings)] (f/failed? conf) => true (f/message conf) => (contains ":upload-max-size"))) + +(fact "Application config loader rejects a blank default project" + (let [path "/tmp/agiladmin-invalid-default-project-blank.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " default-project: \" \"\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => true + (f/message conf) => (contains ":default-project"))) + +(fact "Application config loader rejects a non-string default project" + (let [path "/tmp/agiladmin-invalid-default-project-type.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " default-project: 42\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => true + (f/message conf) => (contains ":default-project"))) From 1ea2cfdcb15b101098655745ebc730d7132291c7 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:25:31 +0200 Subject: [PATCH 2/8] feat(config): validate default project --- src/agiladmin/config.clj | 15 ++++++++++++++ src/agiladmin/ring.clj | 4 ++++ test/agiladmin/config_test.clj | 28 +++++++++++++++++++++++++ test/agiladmin/ring_test.clj | 37 ++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/src/agiladmin/config.clj b/src/agiladmin/config.clj index 66f30ed..51d3551 100644 --- a/src/agiladmin/config.clj +++ b/src/agiladmin/config.clj @@ -103,6 +103,8 @@ (def project-defaults {}) +(declare load-project) + (defn- project-entry-map? [value] (and (map? value) @@ -255,6 +257,19 @@ [conf] (get-in conf [:agiladmin :default-project])) +(defn validate-default-project + "Ensure the configured default project resolves to a valid project configuration." + [conf] + (if-let [project-name (default-project conf)] + (let [project (load-project conf project-name)] + (if (f/failed? project) + (f/fail (str "Configured default project " + project-name + " cannot be loaded: " + (f/message project))) + conf)) + conf)) + (defn- normalize-default-project [conf] (let [project (get-in conf [:agiladmin :default-project])] diff --git a/src/agiladmin/ring.clj b/src/agiladmin/ring.clj index d5238a7..430d68e 100644 --- a/src/agiladmin/ring.clj +++ b/src/agiladmin/ring.clj @@ -48,6 +48,10 @@ (reset! config (conf/load-config (or (System/getenv "AGILADMIN_CONF") "agiladmin") conf/default-settings)) + (when (f/failed? @config) + (throw (ex-info (f/message @config) + {:type ::config-load-failed}))) + (reset! config (conf/validate-default-project @config)) (when (f/failed? @config) (throw (ex-info (f/message @config) {:type ::config-load-failed}))) diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index 4d8b965..6762059 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -234,6 +234,34 @@ (f/failed? conf) => false (conf/default-project conf) => "INFRA")) +(fact "Default project validation leaves config unchanged when absent" + (let [conf {:agiladmin {:budgets {:path "test/assets/"}} + :filename "agiladmin.yaml"}] + (conf/validate-default-project conf) => conf)) + +(fact "Default project validation accepts a valid project" + (let [conf {:agiladmin {:budgets {:path "test/assets/"} + :default-project "UNO"} + :filename "agiladmin.yaml"}] + (conf/validate-default-project conf) => conf)) + +(fact "Default project validation rejects a missing project" + (let [conf {:agiladmin {:budgets {:path "test/assets/"} + :default-project "MISSING"} + :filename "agiladmin.yaml"} + validated (conf/validate-default-project conf)] + (f/failed? validated) => true + (f/message validated) => (contains "Configured default project MISSING cannot be loaded"))) + +(fact "Default project validation rejects an invalid project file" + (let [conf {:agiladmin {:budgets {:path "test/assets/"} + :default-project "BADFIELDS"} + :filename "agiladmin.yaml"} + validated (conf/validate-default-project conf)] + (f/failed? validated) => true + (f/message validated) => (contains "Configured default project BADFIELDS cannot be loaded") + (f/message validated) => (contains "Invalid project configuration"))) + (fact "Application config loader preserves personnel display settings" (let [path "/tmp/agiladmin-personnel-display.yaml" _ (spit path diff --git a/test/agiladmin/ring_test.clj b/test/agiladmin/ring_test.clj index b016bc7..c2d1852 100644 --- a/test/agiladmin/ring_test.clj +++ b/test/agiladmin/ring_test.clj @@ -13,6 +13,8 @@ :users-collection "users" :superuser-email "admin@example.org" :superuser-password "secret"}}}) + agiladmin.config/validate-default-project + identity clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) @@ -55,6 +57,8 @@ :manage-process true :binary "pocketbase" :dir "/tmp/pb"}}}) + agiladmin.config/validate-default-project + identity clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) @@ -100,6 +104,8 @@ (with-redefs [agiladmin.config/load-config (fn [_ _] {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"}}}) + agiladmin.config/validate-default-project + identity clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) @@ -142,6 +148,8 @@ :users-collection "users" :superuser-email "admin@example.org" :superuser-password "secret"}}}) + agiladmin.config/validate-default-project + identity clojure.java.io/as-file (fn [_] (proxy [java.io.File] ["test/assets/id_rsa"] (exists [] true))) @@ -160,3 +168,32 @@ false => true (catch clojure.lang.ExceptionInfo ex (.getMessage ex) => (contains "Authentication backend health check failed"))))) + +(fact "Ring init validates the configured default project before side effects" + (let [key-checked? (atom false) + original-config @ring/config] + (with-redefs [agiladmin.config/load-config + (fn [_ _] + {:agiladmin {:budgets {:ssh-key "test/assets/id_rsa"} + :default-project "INFRA"}}) + agiladmin.config/validate-default-project + (fn [_] + (failjure.core/fail + "Configured default project INFRA cannot be loaded: Project not found in budgets path: INFRA")) + clojure.java.io/as-file + (fn [_] + (proxy [java.io.File] ["test/assets/id_rsa"] + (exists [] + (reset! key-checked? true) + true))) + auxiliary.translation/init + (fn [& _] true)] + (try + (try + (ring/init) + false => true + (catch clojure.lang.ExceptionInfo ex + (.getMessage ex) => (contains "Configured default project INFRA cannot be loaded") + @key-checked? => false)) + (finally + (reset! ring/config original-config)))))) From 30bfd1eae58ea021eceacbaf8a84421b433c2108 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:29:10 +0200 Subject: [PATCH 3/8] feat(timesheets): add default project fallback rule --- src/agiladmin/core.clj | 11 +++++++++++ test/agiladmin/core_test.clj | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/agiladmin/core.clj b/src/agiladmin/core.clj index 0aa277d..bee5ca7 100644 --- a/src/agiladmin/core.clj +++ b/src/agiladmin/core.clj @@ -103,6 +103,17 @@ (log/spy :error) f/fail) cell))) +(defn effective-project + "Return the explicit project when present, otherwise the configured default." + [project default-project] + (if (f/failed? project) + project + (let [project (some-> project trim)] + (cond + (blank? project) default-project + (strcasecmp project "total") nil + :else project)))) + (defn load-monthly-hours "load hours from a timesheet month if conditions match" ([timesheet month] diff --git a/test/agiladmin/core_test.clj b/test/agiladmin/core_test.clj index a074eec..a96d38a 100644 --- a/test/agiladmin/core_test.clj +++ b/test/agiladmin/core_test.clj @@ -160,6 +160,16 @@ :tag "VOL" :hours 8.0}])) +(fact "Effective project prefers explicit values and falls back only for blanks" + (core/effective-project " CORE " "INFRA") => "CORE" + (core/effective-project "total" "INFRA") => nil + (core/effective-project " ToTaL " "INFRA") => nil + (core/effective-project "" "INFRA") => "INFRA" + (core/effective-project " " "INFRA") => "INFRA" + (core/effective-project "" nil) => nil + (let [failure (f/fail "bad project cell")] + (core/effective-project failure "INFRA") => failure)) + (fact "Timesheet loads are uncached by default" (let [calls (atom 0)] (core/invalidate-timesheet-cache!) From 361819f8e613c4a314c10d5e86b046f723437c4c Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:31:31 +0200 Subject: [PATCH 4/8] feat(timesheets): support default project loader --- src/agiladmin/core.clj | 75 +++++++++++++++++------------- test/agiladmin/core_test.clj | 90 ++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/src/agiladmin/core.clj b/src/agiladmin/core.clj index bee5ca7..82652d4 100644 --- a/src/agiladmin/core.clj +++ b/src/agiladmin/core.clj @@ -114,42 +114,55 @@ (strcasecmp project "total") nil :else project)))) +(defn- load-monthly-hours* + [timesheet month cond-fn default-project] + (if-let [sheet (select-sheet month (:xls timesheet))] + (loop [[n & cols] timesheet-cols-projects + res []] + (let [proj (f/ok-> (get-cell sheet n "7") str trim) ;; row project + task (f/ok-> (get-cell sheet n "8") str trim) ;; row task + tag (f/ok-> (get-cell sheet n "9") str trim) ;; row tag(s) (TODO: support multiple tags) + ;; take lowest in row totals starting from 42 (as month lenght varies) + hours (first (for [i timesheet-rows-hourtots + :let [cell (get-cell sheet n i)] + :when (not (nil? cell))] + cell)) + project (effective-project proj default-project) + info {:project project + :task task + :tag tag + :hours hours} + entry (if (and (not (nil? hours)) + (> hours 0.0) + (not (f/failed? project)) + (not (nil? project)) + (cond-fn info)) + {:month month + :name (:name timesheet) + :project (upper-case project) + :task (if-not (blank? task) (upper-case task) "") ;; uppercase all tasks + :tag (if-not (blank? tag) (upper-case tag) "") ;; uppercase all tags + :hours hours} nil)] + ;; check for errors + (map #(when (f/failed? %) (log/error (f/message %))) + [proj task tag project]) + + (if (empty? cols) (if (nil? entry) res (conj res entry)) + (recur cols (if (nil? entry) res (conj res entry)))))))) + +(defn monthly-hours-loader + "Return a load-monthly-hours compatible function using configuration defaults." + [conf] + (let [default-project (conf/default-project conf)] + (fn [timesheet month cond-fn] + (load-monthly-hours* timesheet month cond-fn default-project)))) + (defn load-monthly-hours "load hours from a timesheet month if conditions match" ([timesheet month] (load-monthly-hours timesheet month #(true))) ([timesheet month cond-fn] - (if-let [sheet (select-sheet month (:xls timesheet))] - (loop [[n & cols] timesheet-cols-projects - res []] - (let [proj (f/ok-> (get-cell sheet n "7") str trim) ;; row project - task (f/ok-> (get-cell sheet n "8") str trim) ;; row task - tag (f/ok-> (get-cell sheet n "9") str trim) ;; row tag(s) (TODO: support multiple tags) - ;; take lowest in row totals starting from 42 (as month lenght varies) - hours (first (for [i timesheet-rows-hourtots - :let [cell (get-cell sheet n i)] - :when (not (nil? cell))] - cell)) - entry (if (and (not (nil? hours)) - (> hours 0.0) - (not (blank? proj)) - (not (strcasecmp proj "total")) - (cond-fn {:project proj - :task task - :tag tag - :hours hours})) - {:month month - :name (:name timesheet) - :project (upper-case proj) - :task (if-not (blank? task) (upper-case task) "") ;; uppercase all tasks - :tag (if-not (blank? tag) (upper-case tag) "") ;; uppercase all tags - :hours hours} nil)] - ;; check for errors - (map #(when (f/failed? %) (log/error (f/message %))) - [proj task tag]) - - (if (empty? cols) (if (nil? entry) res (conj res entry)) - (recur cols (if (nil? entry) res (conj res entry))))))))) + (load-monthly-hours* timesheet month cond-fn nil))) (defn map-timesheets "Map a function across all loaded timesheets. The function prototype diff --git a/test/agiladmin/core_test.clj b/test/agiladmin/core_test.clj index a96d38a..4dff3a5 100644 --- a/test/agiladmin/core_test.clj +++ b/test/agiladmin/core_test.clj @@ -170,6 +170,96 @@ (let [failure (f/fail "bad project cell")] (core/effective-project failure "INFRA") => failure)) +(fact "Monthly hours loader applies the configured default project to blank assignments" + (with-redefs [dk.ative.docjure.spreadsheet/select-sheet (fn [_ _] :sheet) + agiladmin.core/get-cell + (fn [_ col row] + (case [col row] + ["B" "7"] " " + ["B" "8"] " task-1 " + ["B" "9"] " vol " + ["B" 43] 8.0 + nil))] + (let [loader (core/monthly-hours-loader {:agiladmin {:default-project "INFRA"}})] + (loader {:xls :book + :name "Alice"} + "2026-1" + (fn [info] + (= (:project info) "INFRA"))) + => [{:month "2026-1" + :name "Alice" + :project "INFRA" + :task "TASK-1" + :tag "VOL" + :hours 8.0}]))) + +(fact "Monthly hours loader keeps explicit projects and omits rows without a fallback" + (with-redefs [dk.ative.docjure.spreadsheet/select-sheet (fn [_ _] :sheet) + agiladmin.core/get-cell + (fn [_ col row] + (case [col row] + ["B" "7"] " CORE " + ["B" "8"] " task-1 " + ["B" "9"] "" + ["B" 43] 8.0 + ["C" "7"] " " + ["C" "8"] " task-2 " + ["C" "9"] "" + ["C" 43] 5.0 + nil))] + (let [loader (core/monthly-hours-loader {:agiladmin {:default-project "INFRA"}})] + (loader {:xls :book + :name "Alice"} + "2026-1" + (fn [_] true)) + => [{:month "2026-1" + :name "Alice" + :project "CORE" + :task "TASK-1" + :tag "" + :hours 8.0} + {:month "2026-1" + :name "Alice" + :project "INFRA" + :task "TASK-2" + :tag "" + :hours 5.0}]) + (core/load-monthly-hours {:xls :book + :name "Alice"} + "2026-1" + (fn [_] true)) + => [{:month "2026-1" + :name "Alice" + :project "CORE" + :task "TASK-1" + :tag "" + :hours 8.0}])) + +(fact "Monthly hours loader still omits totals and non-positive fallback rows" + (with-redefs [dk.ative.docjure.spreadsheet/select-sheet (fn [_ _] :sheet) + agiladmin.core/get-cell + (fn [_ col row] + (case [col row] + ["B" "7"] " total " + ["B" "8"] "" + ["B" "9"] "" + ["B" 43] 8.0 + ["C" "7"] " " + ["C" "8"] "" + ["C" "9"] "" + ["C" 43] 0.0 + ["D" "7"] " " + ["D" "8"] "" + ["D" "9"] "" + ["D" 43] nil + nil))] + ((core/monthly-hours-loader {:agiladmin {:default-project "INFRA"}}) + {:xls :book + :name "Alice"} + "2026-1" + (fn [_] true)) + => [])) + (fact "Timesheet loads are uncached by default" (let [calls (atom 0)] (core/invalidate-timesheet-cache!) From ab401140d6fb4969f7de78e915230c0d998a3ebc Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:35:20 +0200 Subject: [PATCH 5/8] feat(reports): use default project in hours views --- src/agiladmin/core.clj | 15 ++++++---- src/agiladmin/view_person.clj | 2 +- src/agiladmin/view_project.clj | 2 +- test/agiladmin/view_person_test.clj | 43 ++++++++++++++++++++++++++++ test/agiladmin/view_project_test.clj | 38 ++++++++++++++++++++++-- 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/agiladmin/core.clj b/src/agiladmin/core.clj index 82652d4..aeac6e5 100644 --- a/src/agiladmin/core.clj +++ b/src/agiladmin/core.clj @@ -188,12 +188,15 @@ (defn load-project-monthly-hours "load the named project hours from a sequence of timesheets and return a bidimensional vector: [\"Name\" \"Date\" \"Task\" \"Hours\"]" - [timesheets pname] - (log/info (str "Loading project hours: " pname)) - (map-timesheets timesheets load-monthly-hours - (fn [info] - (and (not (strcasecmp (:tag info) "VOL")) - (strcasecmp (:project info) pname))))) + ([timesheets pname] + (load-project-monthly-hours nil timesheets pname)) + ([conf timesheets pname] + (log/info (str "Loading project hours: " pname)) + (map-timesheets timesheets + (monthly-hours-loader conf) + (fn [info] + (and (not (strcasecmp (:tag info) "VOL")) + (strcasecmp (:project info) pname)))))) (def time-format (tf/formatter "dd-MM-yyyy")) (defn current-proj-month [conf] diff --git a/src/agiladmin/view_person.clj b/src/agiladmin/view_person.clj index 1e58dc9..f39a909 100644 --- a/src/agiladmin/view_person.clj +++ b/src/agiladmin/view_person.clj @@ -203,7 +203,7 @@ ts-file (util/name-year-to-timesheet person year) timesheet (load-timesheet (str ts-path ts-file)) projects (load-all-projects config) - hours (map-timesheets [timesheet] load-monthly-hours (fn [_] true))] + hours (map-timesheets [timesheet] (monthly-hours-loader config) (fn [_] true))] {:ts-file ts-file :timesheet timesheet :projects projects diff --git a/src/agiladmin/view_project.clj b/src/agiladmin/view_project.clj index 4801e10..bcaf42d 100644 --- a/src/agiladmin/view_project.clj +++ b/src/agiladmin/view_project.clj @@ -214,7 +214,7 @@ [config projname] (let [ts-path (conf/q config [:agiladmin :budgets :path]) timesheets (load-all-timesheets config ts-path #".*_timesheet_.*xlsx$")] - (load-project-monthly-hours timesheets projname))) + (load-project-monthly-hours config timesheets projname))) (defn project-costs [config project-conf projname] diff --git a/test/agiladmin/view_person_test.clj b/test/agiladmin/view_person_test.clj index 4f45457..af76595 100644 --- a/test/agiladmin/view_person_test.clj +++ b/test/agiladmin/view_person_test.clj @@ -138,6 +138,49 @@ (:status response) => 200 (:body response) => "User Name:2026:user@example.org"))) +(fact "Personnel page data uses the config-aware monthly loader" + (let [calls (atom [])] + (with-redefs [agiladmin.utils/name-year-to-timesheet + (fn [_ _] "2026_timesheet_Manager-User.xlsx") + agiladmin.core/load-timesheet + (fn [path] + (swap! calls conj [:load-timesheet path]) + {:name "Manager User" + :sheets [{:month "2026-1"}]}) + agiladmin.core/load-all-projects + (fn [_] + {:INFRA {:idx {}}}) + agiladmin.core/monthly-hours-loader + (fn [config] + (swap! calls conj [:loader config]) + (fn [_ month cond-fn] + (let [row {:month month + :project "INFRA" + :task "" + :tag "" + :hours 6}] + (swap! calls conj [:cond-result (cond-fn row)]) + [row])))] + (#'agiladmin.view-person/load-person-page-data + {:agiladmin {:budgets {:path "budgets/"} + :default-project "INFRA"}} + "Manager User" + 2026) + => {:ts-file "2026_timesheet_Manager-User.xlsx" + :timesheet {:name "Manager User" + :sheets [{:month "2026-1"}]} + :projects {:INFRA {:idx {}}} + :hours {:column-names [:month :project :task :tag :hours] + :rows [{:month "2026-1" + :project "INFRA" + :task "" + :tag "" + :hours 6}]}} + @calls => [[:load-timesheet "budgets/2026_timesheet_Manager-User.xlsx"] + [:loader {:agiladmin {:budgets {:path "budgets/"} + :default-project "INFRA"}}] + [:cond-result true]]))) + (fact "Manager personnel view omits cost output and yearly export controls" (with-redefs [agiladmin.view-person/load-person-page-data (fn [_ _ _] diff --git a/test/agiladmin/view_project_test.clj b/test/agiladmin/view_project_test.clj index 08aceb6..38f91db 100644 --- a/test/agiladmin/view_project_test.clj +++ b/test/agiladmin/view_project_test.clj @@ -257,7 +257,7 @@ agiladmin.config/q (fn [_ _] "ignored/") agiladmin.core/load-all-timesheets (fn [& _] []) agiladmin.core/load-project-monthly-hours - (fn [_ _] + (fn [_ _ _] {:column-names [:month :name :project :task :tag :hours] :rows [{:month "2026-01" :name "Manager User" @@ -331,7 +331,7 @@ agiladmin.config/q (fn [_ _] "ignored/") agiladmin.core/load-all-timesheets (fn [& _] []) agiladmin.core/load-project-monthly-hours - (fn [_ _] + (fn [_ _ _] {:column-names [:month :name :project :task :tag :hours] :rows [{:month "2026-01" :name "Admin User" @@ -360,3 +360,37 @@ :role "admin"})] (:body response) => (contains ">cost<") (:body response) => (contains ">1500<")))) + +(fact "Project hours pass configuration into project monthly loading" + (let [calls (atom [])] + (with-redefs [agiladmin.config/q + (fn [_ _] "budgets/") + agiladmin.core/load-all-timesheets + (fn [config path regex] + (swap! calls conj [:timesheets config path regex]) + [:ts]) + agiladmin.core/load-project-monthly-hours + (fn [config timesheets projname] + (swap! calls conj [:project-hours config timesheets projname]) + {:column-names [:month :project :hours] + :rows [{:month "2026-01" + :project projname + :hours 4}]})] + (view-project/project-hours + {:agiladmin {:budgets {:path "budgets/"} + :default-project "INFRA"}} + "CORE") + => {:column-names [:month :project :hours] + :rows [{:month "2026-01" + :project "CORE" + :hours 4}]} + (count @calls) => 2 + (first @calls) => (contains [:timesheets + {:agiladmin {:budgets {:path "budgets/"} + :default-project "INFRA"}} + "budgets/"]) + (nth @calls 1) => [:project-hours + {:agiladmin {:budgets {:path "budgets/"} + :default-project "INFRA"}} + [:ts] + "CORE"]))) From 5201ef4d1e877d71a685d4ba0616aef87da39e0e Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:42:38 +0200 Subject: [PATCH 6/8] feat(timesheets): use default project in upload flows --- src/agiladmin/core.clj | 2 +- src/agiladmin/view_timesheet.clj | 4 +- test/agiladmin/core_test.clj | 25 ++++++++ test/agiladmin/view_timesheet_test.clj | 79 ++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/agiladmin/core.clj b/src/agiladmin/core.clj index aeac6e5..6f9fcd9 100644 --- a/src/agiladmin/core.clj +++ b/src/agiladmin/core.clj @@ -426,7 +426,7 @@ [conf path current-year] (let [recent-years #{current-year (dec current-year)}] (->> (map-timesheets (load-all-timesheets conf path #".*_timesheet_.*xlsx$") - load-monthly-hours + (monthly-hours-loader conf) (fn [info] (when-let [month (:month info)] (contains? recent-years diff --git a/src/agiladmin/view_timesheet.clj b/src/agiladmin/view_timesheet.clj index 14d1ed3..67ba548 100644 --- a/src/agiladmin/view_timesheet.clj +++ b/src/agiladmin/view_timesheet.clj @@ -398,7 +398,7 @@ (f/attempt-all [_ (require-upload-ownership account filename path) ts (load-timesheet path) - hours (map-timesheets [ts])] + hours (map-timesheets [ts] (monthly-hours-loader config))] (render-workspace request account @@ -433,7 +433,7 @@ (load-timesheet (str (conf/q config [:agiladmin :budgets :path]) (fs/base-name filename))) - old-hours (map-timesheets [old-ts])] + old-hours (map-timesheets [old-ts] (monthly-hours-loader config))] (timesheet-diff old-hours hours) (f/when-failed [e] (web/render-error diff --git a/test/agiladmin/core_test.clj b/test/agiladmin/core_test.clj index 4dff3a5..760473c 100644 --- a/test/agiladmin/core_test.clj +++ b/test/agiladmin/core_test.clj @@ -335,3 +335,28 @@ "budgets/" #".*_timesheet_.*xlsx$") @calls => 2))) + +(fact "Recent project discovery uses the config-aware monthly loader" + (let [calls (atom [])] + (with-redefs [agiladmin.core/load-all-timesheets + (fn [_ _ _] + [:timesheet]) + agiladmin.core/monthly-hours-loader + (fn [config] + (swap! calls conj [:loader config]) + (fn [_ _ _] + [{:month "2026-1" + :project "INFRA" + :hours 3}])) + agiladmin.core/map-timesheets + (fn [timesheets loop-fn cond-fn] + (swap! calls conj [:map timesheets]) + {:column-names [:month :project :hours] + :rows (loop-fn nil "2026-1" cond-fn)})] + (core/recent-project-names + {:agiladmin {:default-project "INFRA"}} + "budgets/" + 2026) + => #{"INFRA"} + @calls => [[:loader {:agiladmin {:default-project "INFRA"}}] + [:map [:timesheet]]]))) diff --git a/test/agiladmin/view_timesheet_test.clj b/test/agiladmin/view_timesheet_test.clj index 76704e9..459a16f 100644 --- a/test/agiladmin/view_timesheet_test.clj +++ b/test/agiladmin/view_timesheet_test.clj @@ -241,6 +241,85 @@ (:body response) => (contains "Uploaded: 2026_timesheet_A.Example.xlsx") (:body response) => (contains "This is a new timesheet, no historical information available to compare")))) +(fact "Timesheet upload uses the config-aware loader for both preview and diff" + (let [calls (atom [])] + (with-redefs [clojure.java.io/copy (fn [& _] nil) + clojure.java.io/delete-file (fn [& _] nil) + clojure.java.io/file + (fn + ([path] + (proxy [java.io.File] [path] + (exists [] (or (= path "/tmp/2026_timesheet_A.Example.xlsx") + (= path "budgets/2026_timesheet_A.Example.xlsx"))))) + ([parent child] + (proxy [java.io.File] [(str parent "/" child)] + (exists [] false)))) + agiladmin.view-timesheet/load-timesheet-owner + (fn [_] "Alice Example") + agiladmin.core/load-timesheet + (fn [path] + {:name path + :sheets [{:month "2026-1"}]}) + agiladmin.core/monthly-hours-loader + (fn [config] + (swap! calls conj [:loader config]) + (fn [timesheet month cond-fn] + (let [row {:month month + :project "INFRA" + :task "" + :tag "" + :hours (if (= (:name timesheet) "/tmp/2026_timesheet_A.Example.xlsx") 6 4)}] + (swap! calls conj [:cond-result (:name timesheet) (cond-fn row)]) + [row]))) + agiladmin.core/map-timesheets + (fn + ([timesheets loop-fn] + {:column-names [:month :project :task :tag :hours] + :rows (mapcat #(loop-fn % "2026-1" (fn [_] true)) timesheets)}) + ([timesheets loop-fn cond-fn] + {:column-names [:month :project :task :tag :hours] + :rows (mapcat #(loop-fn % "2026-1" cond-fn) timesheets)})) + agiladmin.view-timesheet/timesheet-diff + (fn [old-hours new-hours] + (swap! calls conj [:diff old-hours new-hours]) + [:div "diff"]) + agiladmin.graphics/to-table + (fn [hours] + (swap! calls conj [:table hours]) + [:table "hours"])] + (let [config {:agiladmin {:budgets {:path "budgets/"} + :default-project "INFRA"}} + response (view-timesheet/upload + {:params {:file {:size 1024 + :filename "2026_timesheet_A.Example.xlsx" + :tempfile "/tmp/upload.xlsx"}}} + config + {:name "Alice Example" + :role "manager"})] + (:body response) => (contains "Uploaded: 2026_timesheet_A.Example.xlsx") + @calls => [[:loader config] + [:cond-result "/tmp/2026_timesheet_A.Example.xlsx" true] + [:loader config] + [:cond-result "budgets/2026_timesheet_A.Example.xlsx" true] + [:diff {:column-names [:month :project :task :tag :hours] + :rows [{:month "2026-1" + :project "INFRA" + :task "" + :tag "" + :hours 4}]} + {:column-names [:month :project :task :tag :hours] + :rows [{:month "2026-1" + :project "INFRA" + :task "" + :tag "" + :hours 6}]}] + [:table {:column-names [:month :project :task :tag :hours] + :rows [{:month "2026-1" + :project "INFRA" + :task "" + :tag "" + :hours 6}]}]])))) + (fact "Timesheet upload explains when there is no historical file to diff against" (with-redefs [clojure.java.io/copy (fn [& _] nil) clojure.java.io/delete-file (fn [& _] nil) From 0263d350a3ef8bd68ecfc97015e497f43ab50228 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:50:39 +0200 Subject: [PATCH 7/8] test(e2e): cover default project fallback --- scripts/e2e/generate-manager-fixture.clj | 10 +++++++--- scripts/e2e/start-agiladmin.mjs | 15 ++++++++++++--- test/e2e/timesheet-upload.spec.js | 13 +++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/e2e/generate-manager-fixture.clj b/scripts/e2e/generate-manager-fixture.clj index c66eb09..a9d4917 100644 --- a/scripts/e2e/generate-manager-fixture.clj +++ b/scripts/e2e/generate-manager-fixture.clj @@ -8,17 +8,21 @@ (System/exit 1)) (defn -main [& args] - (let [[src dst owner] args + (let [[src dst owner blank-project-col] args owner-name (or owner "Manager")] (when (str/blank? src) (fail! "Missing src path")) (when (str/blank? dst) (fail! "Missing dst path")) (let [workbook (xls/load-workbook src) - sheet (first (xls/sheet-seq workbook))] + sheets (xls/sheet-seq workbook) + sheet (first sheets)] (when (nil? sheet) (fail! "Workbook has no sheets")) - (xls/set-cell! (xls/select-cell "B3" sheet) owner-name) + (doseq [current-sheet sheets] + (xls/set-cell! (xls/select-cell "B3" current-sheet) owner-name) + (when-not (str/blank? blank-project-col) + (xls/set-cell! (xls/select-cell (str (str/upper-case blank-project-col) "7") current-sheet) ""))) (xls/save-workbook! dst workbook)))) (when (= *file* (System/getProperty "babashka.file")) diff --git a/scripts/e2e/start-agiladmin.mjs b/scripts/e2e/start-agiladmin.mjs index a5b7964..48ef4ee 100644 --- a/scripts/e2e/start-agiladmin.mjs +++ b/scripts/e2e/start-agiladmin.mjs @@ -38,6 +38,7 @@ function yamlConfig(budgetsPath, sshKeyPath) { " source:", " git: https://github.com/dyne/agiladmin", " update: true", + " default-project: DIRECT", " webserver:", " host: 127.0.0.1", " port: 18080", @@ -52,7 +53,7 @@ function yamlConfig(budgetsPath, sshKeyPath) { async function copyBudgetFixtures(targetDir) { const srcDir = path.join(REPO_ROOT, "test", "assets"); const entries = await fs.readdir(srcDir, { withFileTypes: true }); - const whitelist = new Set(["UNO.yaml", "DUE.yaml", "TRE.yaml", "BADFIELDS.yaml", "BROKEN.yaml", "INVALIDYAML.yaml"]); + const whitelist = new Set(["UNO.yaml", "DIRECT.yaml", "DUE.yaml", "TRE.yaml", "BADFIELDS.yaml", "BROKEN.yaml", "INVALIDYAML.yaml"]); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".yaml")) continue; if (!whitelist.has(entry.name)) continue; @@ -60,10 +61,14 @@ async function copyBudgetFixtures(targetDir) { } } -async function generateOwnedFixture(sourceFixturePath, targetFixturePath, ownerName) { +function toCljStringLiteral(value) { + return value == null ? "nil" : JSON.stringify(value); +} + +async function generateOwnedFixture(sourceFixturePath, targetFixturePath, ownerName, blankProjectCol = null) { const expr = [ "(load-file \"scripts/e2e/generate-manager-fixture.clj\")", - `(agiladmin.e2e.generate-manager-fixture/-main ${JSON.stringify(sourceFixturePath)} ${JSON.stringify(targetFixturePath)} ${JSON.stringify(ownerName)})`, + `(agiladmin.e2e.generate-manager-fixture/-main ${toCljStringLiteral(sourceFixturePath)} ${toCljStringLiteral(targetFixturePath)} ${toCljStringLiteral(ownerName)} ${toCljStringLiteral(blankProjectCol)})`, ].join(" "); await new Promise((resolve, reject) => { const child = spawn(CLOJURE_CMD, ["-M", "-e", expr], { @@ -103,9 +108,11 @@ async function prepareEnv() { const adminFixturePath = path.join(fixturesDir, "2016_timesheet_Luca-Pacioli.xlsx"); const managerFixturePath = path.join(fixturesDir, "2016_timesheet_Manager.xlsx"); const guestFixturePath = path.join(fixturesDir, "2016_timesheet_Guest.xlsx"); + const unassignedFixturePath = path.join(fixturesDir, "2016_timesheet_Luca-Pacioli-unassigned.xlsx"); await fs.copyFile(path.join(REPO_ROOT, "test", "assets", "2016_timesheet_Luca-Pacioli.xlsx"), adminFixturePath); await generateOwnedFixture(adminFixturePath, managerFixturePath, "Manager"); await generateOwnedFixture(adminFixturePath, guestFixturePath, "Guest"); + await generateOwnedFixture(adminFixturePath, unassignedFixturePath, "L.Pacioli", "B"); await fs.copyFile(managerFixturePath, path.join(budgetsDir, "2026_timesheet_Manager.xlsx")); await fs.copyFile(guestFixturePath, path.join(budgetsDir, "2026_timesheet_Guest.xlsx")); @@ -121,6 +128,7 @@ async function prepareEnv() { admin: adminFixturePath, manager: managerFixturePath, guest: guestFixturePath, + unassigned: unassignedFixturePath, }, basePath: E2E_BASE_PATH, logPath: LOG_PATH, @@ -135,6 +143,7 @@ async function prepareEnv() { console.error("[DEBUG_E2E] fixture_admin:", adminFixturePath); console.error("[DEBUG_E2E] fixture_manager:", managerFixturePath); console.error("[DEBUG_E2E] fixture_guest:", guestFixturePath); + console.error("[DEBUG_E2E] fixture_unassigned:", unassignedFixturePath); } return state; } diff --git a/test/e2e/timesheet-upload.spec.js b/test/e2e/timesheet-upload.spec.js index 0e186f3..2545513 100644 --- a/test/e2e/timesheet-upload.spec.js +++ b/test/e2e/timesheet-upload.spec.js @@ -29,6 +29,19 @@ test("admin can login and upload a real timesheet", async ({ page }) => { await expect(page.getByText("Error parsing timesheet")).toHaveCount(0); }); +test("upload assigns blank project columns to the configured default project", async ({ page }) => { + const state = await readE2EState(); + await loginAs(page, "admin"); + await openTimesheetUpload(page); + await uploadTimesheet(page, state.fixtures.unassigned); + + await expect(page.getByText("Uploaded: 2016_timesheet_Luca-Pacioli-unassigned.xlsx")).toBeVisible(); + await page.getByRole("button", { name: "Contents" }).click(); + const table = page.locator("#timesheet-workspace table"); + await expect(table).toContainText("DIRECT"); + await expect(table).not.toContainText("UNO"); +}); + test("upload uses local progress without the page-loading overlay", async ({ page }) => { const state = await readE2EState(); let releaseUpload; From 7884920f896858f653d58bcdc7f27f9e977d4061 Mon Sep 17 00:00:00 2001 From: Jaromil Date: Wed, 10 Jun 2026 11:52:20 +0200 Subject: [PATCH 8/8] docs(config): describe default project fallback --- README.md | 4 ++++ doc/agiladmin.pocketbase.yaml | 1 + 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index b6d9189..fee301c 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ agiladmin: cache: false voluntary-hours: false vat-percentage: 0 + default-project: DIRECT webserver: host: localhost @@ -244,12 +245,15 @@ Notes: - `budgets.ssh-key` is the private key path used for Git access; if it does not exist, Agiladmin generates a new keypair and exposes the public key in the `/config` page - project names are discovered from `*.yaml` files in `budgets.path`, using the part of the filename before the first `.` - `cache` enables runtime in-memory caches when `true`; it defaults to `false` +- `default-project` is optional; when set, positive hours from assignment columns with a blank project cell are billed to that project +- if `default-project` is omitted, blank project cells keep the previous behavior and are ignored - `voluntary-hours` controls whether personnel monthly summaries mention voluntary hours; it defaults to `false` - `vat-percentage` controls personnel VAT display; it defaults to `0`, which hides the VAT sentence - `pocketbase` is optional only if you are using dev auth locally - `webserver.upload-max-size` is in bytes and defaults to `500000` - `webserver.base-path` is the browser-visible mount prefix; if you publish under a subpath such as `/agiladmin`, your reverse proxy must strip that prefix before forwarding to Jetty routes - when TLS terminates at Caddy or another reverse proxy, keep `webserver.ssl-redirect: false` and let the proxy handle HTTP to HTTPS redirects +- startup fails fast if `default-project` does not match a loadable project file under `budgets.path`; the error names the configured project and the reason it could not be loaded ## Project Configuration diff --git a/doc/agiladmin.pocketbase.yaml b/doc/agiladmin.pocketbase.yaml index b52b719..162afa0 100644 --- a/doc/agiladmin.pocketbase.yaml +++ b/doc/agiladmin.pocketbase.yaml @@ -1,6 +1,7 @@ appname: agiladmin agiladmin: + default-project: DIRECT webserver: anti-forgery: false ssl-redirect: false