Compare commits

..

4 Commits

Author SHA1 Message Date
ddbc923314 Fixed multi blocker end time. WIP on broken elgantt 2026-02-11 16:46:03 -05:00
dfef1e6dfa Working blocker 2026-02-09 17:22:28 -05:00
6939353f9c Committing before i loose it again! 2026-02-09 14:57:11 -05:00
28abca7572 WIP range for task instead of scheduled + ranges
This is to fix the double entry in the agenda.
One for the scheduled + one for the range.
We want only the range. But right now the code duplicate
Ranges and PROPERTIES drawer.
2026-02-05 10:22:28 -05:00
2 changed files with 973 additions and 800 deletions

View File

@@ -280,7 +280,7 @@ change ~org-directory~. It must be set before org loads!
(setq org-global-properties
'(("Effort_ALL" . "0:10 0:30 1:00 2:00 4:00 8:00")
("ASSIGNEE_ALL" . "Thierry.P Cath.F Martin.K Michel.B Gabriel.C Silvie.L George.G Miguel.A Réjean.S Dominique Adnane Helper1 Helper2")))
("ASSIGNEE_ALL" . "Thierry.P Cath.F Martin.K Michel.B Gabriel.C Silvie.L George.G Miguel.A Réjean.S Dominique Adnane Hatim.K Marc-Antoine.P")))
(setq org-stuck-projects
'("TODO=\"PROJ\"" ("NEXT") nil ""))
@@ -321,27 +321,27 @@ change ~org-directory~. It must be set before org loads!
(sequence "Y/N(M)" "|" "YES(Y)" "NOP(N)")
)
)
;; Not there yet.. Need better integration with org-modern and gruvbox theme
;; %a : org-store-link
;; %i : insert from selection
;; %? : cursor position at the end
;; %u : unactive date
;; %t : ative date
;; %:subject : subject of the message
;; %:from : The full sender string including name and address
;; %:fromname : The display name of the sender
;; %:fromaddress : The email address of the sender
;; %:to, %:toname, %toaddress : Same for the recipient
;; %:date : Date of the message
;; %:date-timestamp : The date of the message as a active org timestamp
;;
;; Original Doom capture:
;; ("p" "Templates for projects")
;; ("pt" "Project-local todo" entry (file+headline +org-capture-project-todo-file "Inbox") "* TODO %?\n %i\n %a" :prepend t)
;; ("pn" "Project-local notes" entry (file+headline +org-capture-project-notes-file "Inbox") "* %U %?\n %i\n %a" :prepend t)
;; ("o" "Centralized templates for projects")
;; ("ot" "Project todo" entry #'+org-capture-central-project-todo-file "* TODO %?\n %i\n %a" :heading "Tasks" :prepend nil)
;; ("on" "Project notes" entry #'+org-capture-central-project-notes-file "* %U %?\n %i\n %a" :heading "Notes" :prepend t)
;; Not there yet.. Need better integration with org-modern and gruvbox theme
;; %a : org-store-link
;; %i : insert from selection
;; %? : cursor position at the end
;; %u : unactive date
;; %t : ative date
;; %:subject : subject of the message
;; %:from : The full sender string including name and address
;; %:fromname : The display name of the sender
;; %:fromaddress : The email address of the sender
;; %:to, %:toname, %toaddress : Same for the recipient
;; %:date : Date of the message
;; %:date-timestamp : The date of the message as a active org timestamp
;;
;; Original Doom capture:
;; ("p" "Templates for projects")
;; ("pt" "Project-local todo" entry (file+headline +org-capture-project-todo-file "Inbox") "* TODO %?\n %i\n %a" :prepend t)
;; ("pn" "Project-local notes" entry (file+headline +org-capture-project-notes-file "Inbox") "* %U %?\n %i\n %a" :prepend t)
;; ("o" "Centralized templates for projects")
;; ("ot" "Project todo" entry #'+org-capture-central-project-todo-file "* TODO %?\n %i\n %a" :heading "Tasks" :prepend nil)
;; ("on" "Project notes" entry #'+org-capture-central-project-notes-file "* %U %?\n %i\n %a" :heading "Notes" :prepend t)
;; Code to automate task status, but i think some package exist that do a better job
;; (defun org-summary-todo (n-done n-not-done)
@@ -464,17 +464,21 @@ change ~org-directory~. It must be set before org loads!
)
((org-agenda-tag-filter-preset '("+perso")))
)
("wP" "THE PLAN"
("wP" "Installation Bombardier"
((agenda ""
((org-agenda-span 'month)
(org-agenda-start-day nil)
(org-agenda-overriding-header "📅 THE PLAN"))
)
)
((org-agenda-tag-filter-preset '("+work")))
)
((org-agenda-span 60)
(org-agenda-start-day "2026-01-29")
(org-agenda-overriding-header "📅 Installation Bombardier")
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-time-grid nil)
)))
((org-agenda-tag-filter-preset '("+BA_ON_SITE")))
)
)
)
)
#+end_src
@@ -499,6 +503,9 @@ change ~org-directory~. It must be set before org loads!
:matchers ("begin" "$1" "$" "$$" "\\(" "\\["))
)
)
(after! ox-latex
(setq org-latex-compiler "xelatex"))
#+end_src
** Org Capture
@@ -564,17 +571,29 @@ fc --> UC3
[[/tmp/babel-pg1Nry/plantuml-SHtP1g.png][/tmp/babel-pg1Nry/plantuml-SHtP1g.png]]
* Elgantt
#+begin_src emacs-lisp
Broken for now..
#+begin_src emacs-lisp :tangle no
(require 'cl-lib)
(require 'dash)
(require 'elgantt)
(unless (fboundp 'first) (defalias 'first #'car))
;; Clear rules to ensure the new global color logic takes effect immediately
(setq elgantt--display-rules nil)
(defface gortium/elgantt-weekend-face
'((t (:background "#332222" :extend nil)))
"Face for weekend vertical columns in ElGantt.")
'((t (:background "#32302f" :extend nil)))
"Gruvbox Dark0_Hard/Soft mix for subtle weekend stripes.")
(defun gortium/internal--month-to-num (name)
"Convert month string to number safely."
(let ((case-fold-search t))
(cond ((string-match-p "Jan" name) 1) ((string-match-p "Feb" name) 2)
((string-match-p "Mar" name) 3) ((string-match-p "Apr" name) 4)
((string-match-p "May" name) 5) ((string-match-p "Jun" name) 6)
((string-match-p "Jul" name) 7) ((string-match-p "Aug" name) 8)
((string-match-p "Sep" name) 9) ((string-match-p "Oct" name) 10)
((string-match-p "Nov" name) 11) ((string-match-p "Dec" name) 12) (t 1))))
(defun gortium/elgantt-draw-weekend-guides ()
"Draw weekend guides for the ENTIRE buffer once to prevent scroll lag."
@@ -646,16 +665,40 @@ fc --> UC3
(setq elgantt-start-date "2026-01-01")
(setq elgantt-header-column-offset 40
elgantt-header-type 'outline
elgantt-header-type 'root
elgantt-show-header-depth t
elgantt-insert-blank-line-between-top-level-header t
elgantt-startup-folded nil
elgantt-draw-overarching-headers nil
elgantt-scroll-to-current-month-at-startup t)
elgantt-scroll-to-current-month-at-startup nil)
(setq elgantt-user-set-color-priority-counter 0)
;; --- 2. Effort Rule (With Weekend Extension) ---
;; --- Rule 1: Active Timestamp Range ---
(elgantt-create-display-rule draw-active-timestamp-range
:parser ((override-color . ((when-let ((colors (org-entry-get (point) "ELGANTT-COLOR")))
(split-string colors " "))))
(range-dates . ((save-excursion
(org-back-to-heading t)
(let ((limit (save-excursion (outline-next-heading) (point))))
(when (re-search-forward "<\\([^>]+\\)>--<\\([^>]+\\)>" limit t)
(list (match-string 1) (match-string 2))))))))
:args (elgantt-org-id)
:body ((when (and elgantt-org-id range-dates)
(let* ((colors (or override-color '("#fabd2f" "#fe8019")))
(s-str (substring (car range-dates) 0 10))
(e-str (substring (cadr range-dates) 0 10))
(p1 (save-excursion (when (elgantt--goto-date s-str) (point))))
(p2 (save-excursion (when (elgantt--goto-date e-str) (point)))))
(when (and (numberp p1) (numberp p2))
(elgantt--draw-gradient
(car colors) (cadr colors)
(truncate p1) (truncate p2) nil
`(priority ,(setq elgantt-user-set-color-priority-counter
(1- elgantt-user-set-color-priority-counter))
:elgantt-user-overlay ,elgantt-org-id)))))))
;; --- Rule 2: Effort Rule ---
(elgantt-create-display-rule draw-scheduled-to-effort-end
:parser ((override-color . ((when-let ((colors (org-entry-get (point) "ELGANTT-COLOR")))
(split-string colors " "))))
@@ -666,7 +709,6 @@ fc --> UC3
:body ((when (and elgantt-scheduled elgantt-effort)
(let* ((start-ts (ts-parse elgantt-scheduled))
(raw-mins (org-duration-to-minutes elgantt-effort))
;; Add the weekend jump days to the visual length
(total-days (+ (ceiling (/ (float raw-mins) 1440.0)) (or wknd-days 0)))
(p1 (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" start-ts))
@@ -676,11 +718,9 @@ fc --> UC3
(if (<= total-days 1)
(elgantt--create-overlay (truncate p1) (1+ (truncate p1))
`(face (:background ,(car colors))
priority ,(setq elgantt-user-set-color-priority-counter
(1- elgantt-user-set-color-priority-counter))
:elgantt-user-overlay ,elgantt-org-id))
;; FIX 1: compute p2 by date (handles "|" separators)
;; FIX 2: keep original "Rule of 2" behavior to avoid +1 day overshoot
priority ,(setq elgantt-user-set-color-priority-counter
(1- elgantt-user-set-color-priority-counter))
:elgantt-user-overlay ,elgantt-org-id))
(let* ((end-ts (ts-adjust 'day (- total-days 2) start-ts))
(p2 (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" end-ts))
@@ -691,89 +731,50 @@ fc --> UC3
(truncate p1) (1+ (truncate p2)) nil
`(priority ,(setq elgantt-user-set-color-priority-counter
(1- elgantt-user-set-color-priority-counter))
:elgantt-user-overlay ,elgantt-org-id))))))))))
:elgantt-user-overlay ,elgantt-org-id)))))))))))
;; --- 3. Progress Bar ---
(elgantt-create-display-rule pages-read-progress
:parser ((total-pages . ((--when-let (org-entry-get (point) "TOTAL_PAGES") (string-to-number it))))
(pages-read . ((--when-let (org-entry-get (point) "PAGES_READ") (string-to-number it)))))
:args (elgantt-deadline elgantt-scheduled)
:body ((when (and elgantt-deadline elgantt-scheduled total-pages pages-read)
(let* ((start (save-excursion (elgantt--goto-date elgantt-scheduled) (point)))
(end (save-excursion (elgantt--goto-date elgantt-deadline) (point)))
(percent (/ (float pages-read) (float total-pages))))
(when (and (numberp start) (numberp end))
(elgantt--draw-progress-bar "#98be65" "#ff6c6b"
(truncate start) (truncate end) percent))))))
;; --- 4. Blocker Interaction (Smart Append) ---
(require 'elgantt-interaction)
(elgantt--selection-rule :name mark-blocker
:selection-number 2
:selection-messages ((1 . "Select the BLOCKING task (Cause)")
(2 . "Select the BLOCKED task (Effect)"))
:execution-functions
((1 . ((elgantt-with-point-at-orig-entry nil (org-id-get-create))))
(2 . ((let* ((new-id return-val)
(current (elgantt-with-point-at-orig-entry nil (org-entry-get (point) "BLOCKER"))))
(elgantt-with-point-at-orig-entry nil
(if (and current (string-match "ids(\\(.*?\\))" current))
(let ((existing (match-string 1 current)))
(org-set-property "BLOCKER" (format "ids(%s %s)" existing new-id)))
(org-set-property "BLOCKER" (format "ids(%s)" new-id)))
(message "Added blocker: %s" new-id)))))))
;; --- 5. Blocker Lines (Multi-ID & Sync Support) ---
;; --- Rule 3: Blocker Lines ---
(elgantt-create-display-rule draw-blocker-lines
:parser ((blocker-raw . ((org-entry-get (point) "BLOCKER"))))
:args (elgantt-org-id)
:body ((when (and blocker-raw (not (string-empty-p blocker-raw)))
(let* ((p-blocked (point))
(ids-string (if (string-match "ids(\\(.*?\\))" blocker-raw)
(match-string 1 blocker-raw)
blocker-raw))
;; Supports spaces or commas as separators
(id-list (split-string ids-string "[ ,]+" t)))
(dolist (blocker-id id-list)
(save-excursion
(when (elgantt--goto-id blocker-id)
(let* ((blocker-data (elgantt-with-point-at-orig-entry nil
(list (org-entry-get (point) "SCHEDULED")
(org-entry-get (point) "EFFORT"))))
(b-sched (car blocker-data))
(b-effort (cadr blocker-data)))
(when (and b-sched b-effort)
(let* ((start-ts (ts-parse b-sched))
(raw-mins (org-duration-to-minutes b-effort))
(days-count (ceiling (/ (float raw-mins) 1440.0)))
(p-start-base (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" start-ts))
(point))))
(when (and (numberp p-start-base) (numberp p-blocked))
;; FIX 1: compute p-line-start by date (handles "|" separators)
;; FIX 2: keep original "Rule of 2" behavior to avoid +1 day overshoot
(let* ((end-ts (ts-adjust 'day (- days-count 2) start-ts))
(p-line-start (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" end-ts))
(point))))
(when (numberp p-line-start)
(elgantt--draw-line (truncate p-line-start)
(truncate p-blocked)
"#b8bb26"))))))))))))))
;; --- 6. Hashtag Navigation ---
(elgantt-create-action follow-hashtag-link-forward
:args (elgantt-alltags) :binding "C-M-f"
:body ((when-let* ((hashtag (--first (s-starts-with-p "#" it) elgantt-alltags))
(match (elgantt--next-match :elgantt-alltags hashtag)))
(goto-char (car match)))))
(elgantt-create-action follow-hashtag-link-backward
:args (elgantt-alltags) :binding "C-M-b"
:body ((when-let* ((hashtag (--first (s-starts-with-p "#" it) elgantt-alltags))
(match (elgantt--previous-match :elgantt-alltags hashtag)))
(goto-char (car match)))))
)
:args (elgantt-org-id elgantt-scheduled)
:body ((when (and elgantt-org-id blocker-raw (not (string-empty-p blocker-raw)))
(let* ((p-dest (save-excursion
(let ((d-start (or (when (stringp elgantt-scheduled) (substring elgantt-scheduled 0 10))
(elgantt-with-point-at-orig-entry nil
(save-excursion
(org-back-to-heading t)
(when (re-search-forward "<\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" (line-end-position) t)
(match-string 1)))))))
(when (and d-start (elgantt--goto-date d-start)) (point))))))
(when (numberp p-dest)
(let ((id-list (split-string (if (string-match "ids(\\(.*?\\))" blocker-raw) (match-string 1 blocker-raw) blocker-raw) "[ ,]+" t)))
(dolist (blocker-id id-list)
(save-excursion
(when (elgantt--goto-id blocker-id)
(let ((d-end-str nil)
(row-start (line-beginning-position))
(row-end (line-end-position)))
(elgantt-with-point-at-orig-entry nil
(save-excursion
(org-back-to-heading t)
(let ((limit (save-excursion (outline-next-heading) (point))))
(if (re-search-forward "<[^>]+>--<\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" limit t)
(setq d-end-str (match-string 1))
(let ((s (org-entry-get (point) "SCHEDULED"))
(e (org-entry-get (point) "EFFORT"))
(w (string-to-number (or (org-entry-get (point) "WEEKEND_DAYS") "0"))))
(when (and s e)
(setq d-end-str (ts-format "%Y-%m-%d" (ts-adjust 'day (1- (+ (ceiling (/ (float (org-duration-to-minutes e)) 1440.0)) w)) (ts-parse s))))))))))
(when d-end-str
(save-excursion
(elgantt--goto-date d-end-str)
(let ((p-source (point)))
(if (and (>= p-source row-start) (<= p-source row-end))
(elgantt--draw-line (truncate p-source) (truncate p-dest) "#b8bb26")
(let ((col-offset (- p-source (save-excursion (goto-char p-source) (line-beginning-position)))))
(goto-char row-start)
(forward-char col-offset)
(elgantt--draw-line (point) (truncate p-dest) "#b8bb26")))))))))))))))))
(defun elgantt-open-current-org-file ()
(interactive)
@@ -786,7 +787,7 @@ fc --> UC3
#+end_src
#+RESULTS:
: elgantt--action-rule-follow-hashtag-link-backward
: elgantt-open-current-org-file
* Org Roam
@@ -2069,6 +2070,9 @@ www.tdnde.com \\\\
#+end_src
** TODO Fix noter if needed
:PROPERTIES:
:ID: 150ae9d2-df33-41d2-9cc1-f22d95659fed
:END:
* PDF-Tools
Really useful to be able to have a buffer with notes, and another with the official PDF doc
@@ -2191,241 +2195,433 @@ If FILE is nil, refile in the current file."
* Custom function for reschedulling
#+begin_src emacs-lisp
(defun gortium/add-trigger-scheduling-next ()
"Add scheduled chain for this entry."
(interactive)
(org-set-property "TRIGGER" "next-sibling scheduled!(\"++0d\") todo!(NEXT) chain!(\"TRIGGER\")"))
;;; ============================================================
;;; GORTIUM — Org chain scheduler (Safe & Optimized with Debugging)
;;; ============================================================
(defun gortium/org-schedule-after-previous-sibling ()
"Schedule the current task right after its previous sibling.
If the sibling is DONE, use its CLOSED time.
Otherwise, use SCHEDULED + EFFORT."
(interactive)
(unless (org-at-heading-p)
(org-back-to-heading t))
(let ((current-level (org-current-level))
(current-point (point))
prev-end-time)
(save-excursion
;; Find previous sibling of same level
(let ((found nil))
(while (and (not found) (outline-previous-heading))
(when (= (org-current-level) current-level)
(setq found t)))
(unless found
(user-error "No previous sibling found"))
;; At previous sibling now
(let* ((state (org-get-todo-state))
(scheduled (org-entry-get nil "SCHEDULED"))
(closed (org-entry-get nil "CLOSED"))
(effort (org-entry-get nil "EFFORT")))
(cond
;; If task is DONE and CLOSED exists
((and (member state org-done-keywords) closed)
(setq prev-end-time (org-time-string-to-time closed)))
;; Else use SCHEDULED + EFFORT
((and scheduled effort)
(let* ((sched-time (org-time-string-to-time scheduled))
(duration-min (org-duration-to-minutes effort)))
(setq prev-end-time
(time-add sched-time (seconds-to-time (* 60 duration-min))))))
(t
(user-error "Previous sibling missing SCHEDULED/CLOSED or EFFORT"))))))
;; Schedule current task
(goto-char current-point)
(org-schedule nil (format-time-string (org-time-stamp-format t) prev-end-time))))
;; --- Helper: Snap to working hours (8-16) ---
;; --- Helper: Snap to working hours (8-16) ---
(defun gortium/internal--snap-to-working-hours (time)
(let* ((day-start 8) (day-end 16)
(t1 (gortium/org--skip-weekend time))
(d (decode-time t1)) (h (nth 2 d)))
(cond
((< h day-start) (apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 d))))
((>= h day-end)
(let ((next (time-add t1 (days-to-time 1))))
(gortium/org--skip-weekend (apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 (decode-time next)))))))
(t t1))))
;; --- Helper: Calculate End Time & Weekend Jump (THE WALKER FIX) ---
(defun gortium/internal--calculate-task-span (start-time effort-str)
(if (or (null effort-str) (string-empty-p effort-str))
(list start-time 0)
(let* ((eff-mins (org-duration-to-minutes effort-str))
;; SMART MATH: If string has "d", divide by 1440 (24h). If only "h", divide by 480 (8h).
(divisor (if (string-match-p "d" effort-str) 1440.0 480.0))
(eff-days (max 1 (ceiling (/ (float eff-mins) divisor))))
(cursor start-time)
(days-worked 0)
(wknd-count 0))
;; THE WALKER: Simulate the task day by day
(while (< days-worked eff-days)
(let* ((dow (nth 6 (decode-time cursor))))
(cond
;; If Sat (6) or Sun (0), it's a weekend. Count it, but don't advance work.
((or (= dow 6) (= dow 0))
(setq wknd-count (1+ wknd-count))
(setq cursor (time-add cursor (days-to-time 1))))
;; If Mon-Fri, it's a work day. Advance work count.
(t
(setq days-worked (1+ days-worked))
;; Move cursor to start of next day
(setq cursor (time-add cursor (days-to-time 1)))))))
;; Return [End-Time, Weekend-Days-Count]
(list cursor wknd-count))))
;; --- Helper: Find Blocker End Time ---
(defun gortium/internal--get-blocker-end (blocker-str current-pos task-end-map)
(let ((clean (s-trim blocker-str)) (latest-time nil))
(cond
((string-match-p "previous-sibling" clean)
(save-excursion
(goto-char current-pos)
(let ((found nil))
(while (and (not found) (org-get-last-sibling))
(let* ((sid (org-id-get)) (end (when sid (gethash sid task-end-map))))
(when end (setq latest-time end) (setq found t)))))))
((string-match-p "parent" clean)
(save-excursion (goto-char current-pos)
(when (org-up-heading-safe)
(setq latest-time (gethash (org-id-get) task-end-map)))))
((string-match "ids(\\(.*?\\))" clean)
(dolist (tid (split-string (match-string 1 clean) "[ ,]+" t))
(let ((tend (gethash (replace-regexp-in-string "[\"']\\|id:" "" tid) task-end-map)))
(when (and tend (or (null latest-time) (time-less-p latest-time tend)))
(setq latest-time tend))))))
latest-time))
;; --- Helper: Write Properties (Safe) ---
(defun gortium/internal--update-properties (pos start wknd id end task-end-map)
(save-excursion
(goto-char pos) ;; POS is a MARKER
(if (and wknd (> wknd 0))
(org-entry-put (point) "WEEKEND_DAYS" (number-to-string wknd))
(org-entry-put (point) "WEEKEND_DAYS" nil))
(org-schedule nil (format-time-string "<%Y-%m-%d %a %H:%M>" start))
(puthash id end task-end-map)))
(require 'org)
(require 'org-id)
(require 'cl-lib)
(require 's)
;; ------------------------------------------------------------
;; Helper: Skip weekends (snap to next weekday at 08:00)
;; ------------------------------------------------------------
(defun gortium/org--skip-weekend (time)
"Advance TIME to the next Monday morning if it falls on a weekend."
(let* ((decoded (decode-time time))
(dow (nth 6 decoded)))
(cond
((= dow 6) ;; Saturday
(let ((next (time-add time (days-to-time 2))))
(apply #'encode-time (append '(0 0 8) (nthcdr 3 (decode-time next))))))
(apply #'encode-time
(append '(0 0 8)
(nthcdr 3 (decode-time (time-add time (days-to-time 2)))))))
((= dow 0) ;; Sunday
(let ((next (time-add time (days-to-time 1))))
(apply #'encode-time (append '(0 0 8) (nthcdr 3 (decode-time next))))))
(apply #'encode-time
(append '(0 0 8)
(nthcdr 3 (decode-time (time-add time (days-to-time 1)))))))
(t time))))
;; --- MAIN FUNCTION ---
(defun gortium/org-schedule-subtree-chains ()
"Schedule tasks using MARKERS and WALKER logic."
(interactive)
;; ------------------------------------------------------------
;; Helper: Snap to working hours (08:0016:00)
;; ------------------------------------------------------------
(defun gortium/internal--snap-to-working-hours (time)
(let* ((day-start 8)
(day-end 16)
(t1 (gortium/org--skip-weekend time))
(d (decode-time t1))
(h (nth 2 d)))
(cond
((< h day-start)
(apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 d))))
((>= h day-end)
(let ((next (time-add t1 (days-to-time 1))))
(gortium/org--skip-weekend
(apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time next)))))))
(t t1))))
(defun gortium/org--get-range-end (pos)
"Extract the END timestamp from an existing range like <A>--<B> at POS.
POS can be a marker or a cons cell (file . position)."
(let ((marker (if (markerp pos) pos
(let ((file (car pos))
(p (cdr pos)))
(with-current-buffer (find-file-noselect file)
(copy-marker p))))))
(with-current-buffer (marker-buffer marker)
(save-excursion
(goto-char marker)
(org-back-to-heading t)
(let ((subtree-end (save-excursion (org-end-of-subtree t) (point)))
(end-time nil))
(save-restriction
(narrow-to-region (point) subtree-end)
(goto-char (point-min))
(when (re-search-forward "<[^>]+>--<\\([^>]+\\)>" nil t)
(condition-case nil
(setq end-time (org-time-string-to-time (match-string 1)))
(error nil))))
end-time)))))
(defun gortium/org--get-range-start (pos)
"Extract the start timestamp from an existing range like <A>--<B>.
Returns nil if no range found (safe, non-blocking)."
(save-excursion
(message "--- Starting Gantt Scheduler ---")
(let* ((all-tasks '()) (task-end-times (make-hash-table :test 'equal)))
(goto-char pos)
(org-back-to-heading t)
(let ((end (save-excursion
(or (ignore-errors (outline-next-heading))
(point-max))
(point)))
(start-time nil))
(save-restriction
(narrow-to-region (point) end)
(goto-char (point-min))
;; Look for range in first 50 lines only (safety limit)
(let ((search-limit (save-excursion
(forward-line 50)
(point))))
(when (re-search-forward "<\\([^>]+\\)>--<[^>]+>" search-limit t)
(condition-case nil
(setq start-time (org-time-string-to-time (match-string 1)))
(error nil)))))
start-time)))
;; Collection
(org-map-entries
(lambda ()
(when (org-get-todo-state) ;; Strict TODO check
(let ((effort (org-entry-get (point) "EFFORT" nil))
(blocker (org-entry-get (point) "BLOCKER" nil))
(fixed (org-entry-get (point) "FIXED" nil))
(scheduled (org-entry-get (point) "SCHEDULED")))
(when (or effort blocker fixed)
(push (list (point-marker) (org-id-get-create) (org-get-heading t t t t)
effort (when blocker (s-trim blocker)) fixed scheduled
(org-entry-get (point) "OFFSET_DAYS" nil)) all-tasks)))))
nil (if (org-at-heading-p) 'tree nil))
(setq all-tasks (nreverse all-tasks))
(defun gortium/internal--calculate-task-span (start-time effort-str)
"Return a list (END-TIME WEEKEND-DAYS) for given START-TIME and EFFORT string."
(if (or (null effort-str) (string-empty-p effort-str))
(list start-time 0)
(let* ((eff-mins (org-duration-to-minutes effort-str))
(total-work-mins (if (string-match-p "d" effort-str)
(* (/ eff-mins 1440.0) 480)
eff-mins))
(cursor start-time)
(wknd-count 0)
(day-start 8)
(day-end 16)
(safety-counter 0)
(max-iterations 1000)) ;; Safety limit
(while (and (> total-work-mins 0) (< safety-counter max-iterations))
(setq safety-counter (1+ safety-counter))
(let* ((decoded (decode-time cursor))
(h (nth 2 decoded))
(m (nth 1 decoded))
(dow (nth 6 decoded))
(current-abs-min (+ (* h 60) m))
(day-end-abs-min (* day-end 60))
(mins-left-today (- day-end-abs-min current-abs-min)))
(cond
((or (= dow 6) (= dow 0)) ;; weekend
(setq wknd-count (1+ wknd-count))
(setq cursor (apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time
(time-add cursor (days-to-time 1))))))))
((<= mins-left-today 0) ;; after hours
(setq cursor (apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time
(time-add cursor (days-to-time 1))))))))
((<= total-work-mins mins-left-today)
(setq cursor (time-add cursor (seconds-to-time (* total-work-mins 60))))
(setq total-work-mins 0))
(t ;; spill to next day
(setq total-work-mins (- total-work-mins mins-left-today))
(setq cursor (time-add cursor (seconds-to-time (* mins-left-today 60))))))))
;; Pass 1: FIXED
(dolist (task all-tasks)
(pcase-let ((`(,pos ,id ,heading ,effort ,blocker ,fixed ,scheduled ,offset) task))
(when (and fixed (member (downcase fixed) '("t" "true" "yes")))
(if scheduled
(let* ((start (org-time-string-to-time scheduled))
(span (gortium/internal--calculate-task-span start effort)))
(save-excursion
(goto-char pos)
(if (and (cadr span) (> (cadr span) 0))
(org-entry-put (point) "WEEKEND_DAYS" (number-to-string (cadr span)))
(org-entry-put (point) "WEEKEND_DAYS" nil))
(puthash id (car span) task-end-times)))
(message "WARNING: Fixed task '%s' missing date." heading)))))
(when (>= safety-counter max-iterations)
(message "WARNING: calculate-task-span hit iteration limit for effort %s" effort-str))
;; Pass 2: CHAINS
(let* ((remaining (cl-remove-if (lambda (tk) (or (nth 5 tk) (not (nth 4 tk)))) all-tasks))
(iter 0) (limit (* 5 (length all-tasks))))
(while (and remaining (< iter limit))
(cl-incf iter)
(let ((scheduled-this-loop '()))
(dolist (task remaining)
(pcase-let ((`(,pos ,id ,heading ,effort ,blocker ,fixed ,scheduled ,offset) task))
(let ((dep-end (gortium/internal--get-blocker-end blocker pos task-end-times)))
(when dep-end
(let* ((off-val (if offset (string-to-number offset) 0))
(base-start (time-add dep-end (days-to-time off-val)))
(start (gortium/internal--snap-to-working-hours base-start))
(span (gortium/internal--calculate-task-span start effort)))
(gortium/internal--update-properties pos start (cadr span) id (car span) task-end-times)
(push task scheduled-this-loop))))))
(setq remaining (cl-remove-if (lambda (tk) (member tk scheduled-this-loop)) remaining))))
(list cursor wknd-count))))
(dolist (task all-tasks) (set-marker (car task) nil)) ;; Clean markers
(message "--- Scheduler Finished ---")))))
;; ------------------------------------------------------------
;; Helper: Find dependency end time
;; ------------------------------------------------------------
(defun gortium/internal--get-blocker-end (blocker-str task-end-map)
"Return the latest end time of all blockers if they are all resolved."
(let ((clean (s-trim (format "%s" blocker-str)))
(latest-time nil)
(all-resolved t))
(when (and (string-match "ids(\\(.*?\\))" clean)
(not (string-empty-p (s-trim (match-string 1 clean)))))
(dolist (tid (split-string (match-string 1 clean) "[ ,]+" t))
(let* ((clean-id (replace-regexp-in-string "[\"']\\|id: " "" tid))
(pos (org-id-find clean-id t))
(computed-end (gethash clean-id task-end-map))
(blocker-end nil))
;; USAGE:
;; 1. Set up your tasks with either:
;; - :FIXED: t and a SCHEDULED date, OR
;; - :BLOCKER: previous-sibling / ids(UUID) / ids("id:UUID") / parent
;; 2. Put cursor on "Planning" heading
;; 3. M-x gortium/org-schedule-subtree-chains
;;
;; The function will:
;; - Use FIXED tasks as anchors
;; - Calculate all other tasks from their dependencies
;; - Warn about tasks without BLOCKER or FIXED
;; - Detect circular dependencies
;; - Respect 8-hour workday limits
;; - Skip weekends
;;
;; Supported BLOCKER types (standard EDNA format):
;; - previous-sibling
;; - ids(UUID) - e.g., ids(70d8f844-1952-43f0-8043-da51e8c8bc69)
;; - ids("id:UUID") - e.g., ids("id:70d8f844-1952-43f0-8043-da51e8c8bc69")
;; - parent
;; 1. Determine this specific blocker's end time
(setq blocker-end
(cond
(computed-end computed-end) ;; Use what we just calculated in this session
(pos (let ((m (if (markerp pos) pos
(set-marker (make-marker) (cdr pos)
(find-file-noselect (car pos))))))
(with-current-buffer (marker-buffer m)
(org-with-point-at m
(cond
;; Priority 1: Use actual CLOSED timestamp if DONE
((org-entry-get nil "CLOSED")
(org-time-string-to-time (org-entry-get nil "CLOSED")))
;; Priority 2: Use range end if FIXED
((string-equal "t" (org-entry-get nil "FIXED"))
(gortium/org--get-range-end m))
(t nil))))))
(t nil)))
(defun gortium/add-ids-to-subtree ()
"Add IDs to all headings in current subtree."
(interactive)
(save-excursion
(org-back-to-heading t)
(org-map-entries
(lambda () (org-id-get-create))
nil 'tree)))
;; 2. Update the "Latest" tracker
(if blocker-end
(when (or (null latest-time) (time-less-p latest-time blocker-end))
(setq latest-time blocker-end))
;; If ANY blocker is not resolved/found, the whole task is not ready
(setq all-resolved nil)))))
;; Custom function to shift projects
(defun gortium/org-shift-subtree-schedules (days)
"Shift all SCHEDULED dates in the current subtree by DAYS."
(interactive "nDays to shift: ")
;; Only return a time if EVERY ID in the list was successfully resolved
(when all-resolved latest-time)))
(defun gortium/internal--update-properties (pos start wknd id end task-end-map)
"Heals the property drawer, updates values, and fixes vertical spacing without leaking newlines."
(message "[DEBUG] enter update-properties for %s" id)
(save-excursion
(goto-char pos)
(org-back-to-heading t)
(let* ((subtree-start (point))
(subtree-end (save-excursion (org-end-of-subtree t) (point))))
(save-restriction
(narrow-to-region subtree-start subtree-end)
;; --- STEP 1: HEAL THE DRAWER ---
(goto-char (point-min))
(forward-line 1)
(while (looking-at "^[ \t]*\\(CLOSED:\\|SCHEDULED:\\|DEADLINE:\\)")
(forward-line 1))
(when (looking-at "^[ \t]*:PROPERTIES:[ \t]*$")
(let ((drawer-start (point)))
(when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
(let ((drawer-end (match-end 0)))
(save-restriction
(narrow-to-region drawer-start drawer-end)
(goto-char (point-min))
(forward-line 1)
(while (re-search-forward "^[ \t]*$" nil t)
(delete-region (line-beginning-position)
(min (1+ (line-end-position)) (point-max)))))))))
;; --- STEP 2: UPDATE PROPERTY ---
(org-entry-put nil "WEEKEND_DAYS" (number-to-string (or wknd 0)))
;; --- STEP 3: REMOVE OLD RANGE ---
(goto-char (point-min))
(while (re-search-forward "^[ \t]*<.+>--<.+>[ \t]*\n?" nil t)
(replace-match ""))
;; --- STEP 4: FIND INSERTION POINT ---
(goto-char (point-min))
(forward-line 1)
(while (looking-at "^[ \t]*\\(CLOSED:\\|SCHEDULED:\\|DEADLINE:\\)")
(forward-line 1))
(while (looking-at "^[ \t]*:\\([A-Z_]+\\):[ \t]*$")
(when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
(forward-line 1)))
;; Delete any existing blank lines at the insertion point
(while (and (looking-at "^[ \t]*$") (not (eobp)))
(delete-region (line-beginning-position) (line-beginning-position 2)))
;; --- STEP 5: INSERT RANGE ---
;; Ensure range starts on a new line and ends with exactly one newline
(unless (bolp) (insert "\n"))
(insert (format "<%s>--<%s>\n"
(format-time-string "%Y-%m-%d %a %H:%M" start)
(format-time-string "%Y-%m-%d %a %H:%M" end)))
;; --- STEP 6: CLEAN UP REMAINING WHITESPACE WITHIN TASK ---
(while (and (looking-at "^[ \t]*$") (not (eobp)))
(delete-region (line-beginning-position) (line-beginning-position 2))))
;; --- STEP 7: NORMALIZE SPACING BETWEEN TASKS ---
;; Instead of inserting a newline blindly, we ensure exactly one blank line
;; exists between this subtree's end and the next heading.
(goto-char (org-end-of-subtree t))
(let ((post-subtree (point)))
(save-restriction
(widen)
(goto-char post-subtree)
(delete-blank-lines)
;; Only insert a blank line if we aren't at the end of the buffer
(unless (eobp)
(insert "\n")))))
(message "[DEBUG] exit update-properties")
(puthash id end task-end-map)))
;; ------------------------------------------------------------
;; Helper: Detect circular dependencies
;; ------------------------------------------------------------
(defun gortium/internal--detect-circular-deps (tasks)
"Check for circular dependencies in TASKS.
Returns list of task IDs involved in cycles, or nil if no cycles found."
(let ((graph (make-hash-table :test 'equal))
(visiting (make-hash-table :test 'equal))
(visited (make-hash-table :test 'equal))
(cycles nil))
;; Build dependency graph
(dolist (task tasks)
(pcase-let ((`(,_pos ,id ,_effort ,blocker ,_fixed ,_sched ,_rng-start ,_offset ,_state) task))
(when (and blocker (not (string-empty-p (s-trim blocker))))
(when (string-match "ids(\\(.*?\\))" blocker)
(let ((deps (split-string (match-string 1 blocker) "[ ,]+" t)))
(puthash id (mapcar (lambda (tid)
(replace-regexp-in-string "[\"']\\|id:" "" tid))
deps)
graph))))))
;; DFS to detect cycles
(cl-labels ((dfs (node path)
(cond
((gethash node visiting)
;; Found a cycle
(push node cycles)
t)
((gethash node visited)
nil)
(t
(puthash node t visiting)
(dolist (dep (gethash node graph))
(when (dfs dep (cons node path))
(push node cycles)))
(remhash node visiting)
(puthash node t visited)
nil))))
(maphash (lambda (node _deps)
(unless (gethash node visited)
(dfs node nil)))
graph))
(delete-dups cycles)))
(advice-add 'org-roam-db-sync :before
(lambda (&rest _)
(message "[DEBUG] org-roam-db-sync invoked")))
;; --- MAIN SCHEDULER ---
(defun gortium/org-schedule-subtree-chains ()
"Standard Gortium scheduler: Correctly calculates Finish-to-Start dependencies."
(interactive)
(message "=== Starting Gortium Scheduler ===")
(let ((all-tasks '())
(task-end-times (make-hash-table :test 'equal))
(start-time (current-time))
(org-element-use-cache nil)) ;; Disable buggy cache
;; 1. COLLECT
(org-map-entries
(lambda ()
(let ((scheduled (org-entry-get nil "SCHEDULED")))
(when scheduled
(let* ((time (org-time-string-to-time scheduled))
(new-time (time-add time (days-to-time days)))
(new-date (format-time-string (org-time-stamp-format) new-time)))
(org-entry-put nil "SCHEDULED" new-date)))))
nil 'tree))
(when (org-get-todo-state)
(let* ((pos (point-marker))
(id (or (org-id-get) (org-id-get-create))))
(push (list (current-buffer) pos id
(org-entry-get pos "EFFORT")
(org-entry-get pos "BLOCKER")
(org-entry-get pos "FIXED")
(org-get-scheduled-time pos)
(org-entry-get pos "OFFSET_DAYS"))
all-tasks))))
nil nil)
(setq all-tasks (nreverse all-tasks))
;; 2. ITERATE
(let* ((remaining all-tasks)
(limit (* 20 (length remaining)))
(iter 0))
(while (and remaining (< iter limit))
(setq iter (1+ iter))
(let ((done-this-loop '()))
(dolist (task remaining)
(pcase-let ((`(,buf ,pos ,id ,effort ,blocker ,fixed ,sched ,offset) task))
(let* ((blocker-end (gortium/internal--get-blocker-end blocker task-end-times))
(has-blocker (and blocker (not (string-empty-p (s-trim blocker)))))
(is-fixed (string-equal fixed "t"))
(ready (or is-fixed (not has-blocker) blocker-end)))
(when ready
(with-current-buffer buf
(org-element-with-disabled-cache
(let* ((off-days (if (stringp offset) (string-to-number offset) 0))
(base-start (cond
;; 1. If FIXED, use its own defined start
(is-fixed (or (gortium/org--get-range-start pos) sched (current-time)))
;; 2. If it HAS a blocker, it MUST use blocker-end.
;; If blocker-end is nil, this task isn't 'ready' yet.
(has-blocker blocker-end)
;; 3. If no blocker and not fixed, use current schedule or now
(t (or sched (current-time)))))
(final-start (if is-fixed base-start
(gortium/internal--snap-to-working-hours
(time-add base-start (days-to-time off-days)))))
(span (gortium/internal--calculate-task-span final-start effort))
(final-end (car span))
(wknd (cadr span)))
(gortium/internal--update-properties pos final-start wknd id final-end task-end-times)
(push task done-this-loop))))))))
(setq remaining (cl-set-difference remaining done-this-loop))))
;; 3. CLEANUP
(setq org-element-use-cache t)
(org-element-cache-reset 'all)
(message "=== Scheduler completed (%d tasks, %d iterations) ===" (length all-tasks) iter))))
;; ---------------------------------------------
(defun gortium/org-ensure-task-properties ()
"Iterate through all tasks (TODO, NEXT, STRT, WAIT, HOLD, DONE, etc.)
and ensure the standard property drawer exists without overwriting existing data."
(interactive)
(save-excursion
(message "--- Initializing Task Properties for all states ---")
(let ((count 0)
;; List of properties to ensure exist
(props '("EFFORT" "BLOCKER" "FIXED" "WEEKEND_DAYS"
"ASSIGNEE" "RESOURCES" "CATEGORY"
"DIMENTIONS" "WEIGHT" "OFFSET_DAYS")))
(org-map-entries
(lambda ()
;; This check returns true if the heading has ANY todo keyword
(let ((todo-state (org-get-todo-state)))
(when todo-state
(cl-incf count)
;; 1. Handle ID (builtin handles 'do not replace' logic)
(org-id-get-create)
;; 2. Ensure all other keys exist
(dolist (prop props)
;; Only add the property if it's currently nil/missing
(unless (org-entry-get (point) prop)
(org-entry-put (point) prop
(if (string= prop "FIXED") "nil" "")))))))
nil nil)
(message "--- Finished: Processed %d tasks across all states ---" count))))
(defun gortium/add-ids-to-subtree ()
"Add IDs to all headings in current subtree."
(interactive)
(save-excursion
(org-back-to-heading t)
(org-map-entries
(lambda () (org-id-get-create))
nil 'tree)))
#+end_src
#+RESULTS:
: gortium/add-ids-to-subtree
* Custom function Open link in other frame
#+begin_src emacs-lisp

View File

@@ -150,7 +150,7 @@
(setq org-global-properties
'(("Effort_ALL" . "0:10 0:30 1:00 2:00 4:00 8:00")
("ASSIGNEE_ALL" . "Thierry.P Cath.F Martin.K Michel.B Gabriel.C Silvie.L George.G Miguel.A Réjean.S Dominique Adnane Helper1 Helper2")))
("ASSIGNEE_ALL" . "Thierry.P Cath.F Martin.K Michel.B Gabriel.C Silvie.L George.G Miguel.A Réjean.S Dominique Adnane Hatim.K Marc-Antoine.P")))
(setq org-stuck-projects
'("TODO=\"PROJ\"" ("NEXT") nil ""))
@@ -191,27 +191,27 @@
(sequence "Y/N(M)" "|" "YES(Y)" "NOP(N)")
)
)
;; Not there yet.. Need better integration with org-modern and gruvbox theme
;; %a : org-store-link
;; %i : insert from selection
;; %? : cursor position at the end
;; %u : unactive date
;; %t : ative date
;; %:subject : subject of the message
;; %:from : The full sender string including name and address
;; %:fromname : The display name of the sender
;; %:fromaddress : The email address of the sender
;; %:to, %:toname, %toaddress : Same for the recipient
;; %:date : Date of the message
;; %:date-timestamp : The date of the message as a active org timestamp
;;
;; Original Doom capture:
;; ("p" "Templates for projects")
;; ("pt" "Project-local todo" entry (file+headline +org-capture-project-todo-file "Inbox") "* TODO %?\n %i\n %a" :prepend t)
;; ("pn" "Project-local notes" entry (file+headline +org-capture-project-notes-file "Inbox") "* %U %?\n %i\n %a" :prepend t)
;; ("o" "Centralized templates for projects")
;; ("ot" "Project todo" entry #'+org-capture-central-project-todo-file "* TODO %?\n %i\n %a" :heading "Tasks" :prepend nil)
;; ("on" "Project notes" entry #'+org-capture-central-project-notes-file "* %U %?\n %i\n %a" :heading "Notes" :prepend t)
;; Not there yet.. Need better integration with org-modern and gruvbox theme
;; %a : org-store-link
;; %i : insert from selection
;; %? : cursor position at the end
;; %u : unactive date
;; %t : ative date
;; %:subject : subject of the message
;; %:from : The full sender string including name and address
;; %:fromname : The display name of the sender
;; %:fromaddress : The email address of the sender
;; %:to, %:toname, %toaddress : Same for the recipient
;; %:date : Date of the message
;; %:date-timestamp : The date of the message as a active org timestamp
;;
;; Original Doom capture:
;; ("p" "Templates for projects")
;; ("pt" "Project-local todo" entry (file+headline +org-capture-project-todo-file "Inbox") "* TODO %?\n %i\n %a" :prepend t)
;; ("pn" "Project-local notes" entry (file+headline +org-capture-project-notes-file "Inbox") "* %U %?\n %i\n %a" :prepend t)
;; ("o" "Centralized templates for projects")
;; ("ot" "Project todo" entry #'+org-capture-central-project-todo-file "* TODO %?\n %i\n %a" :heading "Tasks" :prepend nil)
;; ("on" "Project notes" entry #'+org-capture-central-project-notes-file "* %U %?\n %i\n %a" :heading "Notes" :prepend t)
;; Code to automate task status, but i think some package exist that do a better job
;; (defun org-summary-todo (n-done n-not-done)
@@ -334,17 +334,21 @@
)
((org-agenda-tag-filter-preset '("+perso")))
)
("wP" "THE PLAN"
("wP" "Installation Bombardier"
((agenda ""
((org-agenda-span 'month)
(org-agenda-start-day nil)
(org-agenda-overriding-header "📅 THE PLAN"))
)
)
((org-agenda-tag-filter-preset '("+work")))
)
((org-agenda-span 60)
(org-agenda-start-day "2026-01-29")
(org-agenda-overriding-header "📅 Installation Bombardier")
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-time-grid nil)
)))
((org-agenda-tag-filter-preset '("+BA_ON_SITE")))
)
)
)
)
(after! org
@@ -362,6 +366,9 @@
)
)
(after! ox-latex
(setq org-latex-compiler "xelatex"))
(after! org
(setq
org-capture-templates
@@ -393,225 +400,6 @@
;; Enable plantuml-mode for PlantUML files
(add-to-list 'auto-mode-alist '("\\.plantuml\\'" . plantuml-mode))
(require 'cl-lib)
(require 'dash)
(unless (fboundp 'first) (defalias 'first #'car))
;; Clear rules to ensure the new global color logic takes effect immediately
(setq elgantt--display-rules nil)
(defface gortium/elgantt-weekend-face
'((t (:background "#332222" :extend nil)))
"Face for weekend vertical columns in ElGantt.")
(defun gortium/elgantt-draw-weekend-guides ()
"Draw weekend guides for the ENTIRE buffer once to prevent scroll lag."
(interactive)
(when (derived-mode-p 'elgantt-mode)
(let* ((inhibit-modification-hooks t)
(header-line-1 (save-excursion
(goto-char (point-min))
(buffer-substring-no-properties (line-beginning-position) (line-end-position))))
(col-indices '())
(search-pos 0))
(save-excursion
(save-restriction
(widen)
;; 1. Clear ALL weekend overlays in the entire buffer
(remove-overlays (point-min) (point-max) 'gortium-weekend t)
;; 2. Parse header once to find column indexes (Fast)
(while (string-match "|[[:space:]]*\\([[:alpha:]]+\\)[[:space:]]+\\([0-9]\\{4\\}\\)" header-line-1 search-pos)
(let* ((month-start-col (match-beginning 0))
(month-name (match-string 1 header-line-1))
(year (string-to-number (match-string 2 header-line-1)))
(month-num (gortium/internal--month-to-num month-name))
(next-pipe (string-match "|" header-line-1 (1+ month-start-col)))
(month-width (if next-pipe (- next-pipe month-start-col 1) 31)))
(dotimes (d month-width)
(let* ((day (1+ d))
(time (condition-case nil (encode-time 0 0 12 day month-num year) (error nil))))
(when time
(let ((dow (nth 6 (decode-time time)))
(actual-col (+ month-start-col 1 d)))
(when (member dow '(0 6))
(push actual-col col-indices))))))
(setq search-pos (or next-pipe (length header-line-1)))))
;; 3. Apply to the WHOLE buffer line by line
(unless (null col-indices)
(goto-char (point-min))
(forward-line 2) ;; Skip headers
(while (not (eobp))
(let ((line-end (line-end-position)))
(dolist (col col-indices)
(move-to-column col)
(let ((p (point)))
;; Ensure we are still on the same line and at the correct column
(when (and (< p line-end) (= (current-column) col))
(let ((ov (make-overlay p (1+ p))))
(overlay-put ov 'face 'gortium/elgantt-weekend-face)
(overlay-put ov 'gortium-weekend t)
(overlay-put ov 'priority 100)
(overlay-put ov 'evaporate t))))))
(forward-line 1)))))
(message "Weekend guides rendered for the whole buffer."))))
;; Run it only once when the buffer is loaded
(add-hook 'elgantt-mode-hook #'gortium/elgantt-draw-weekend-guides)
(use-package! elgantt
:commands (elgantt-open elgantt-open-current-org-file)
:config
;; --- 1. Environment & UI ---
(add-hook 'elgantt-mode-hook
(lambda ()
(setq-local org-phscroll-mode nil)
(setq-local image-roll-mode nil)
(setq truncate-lines t)))
(setq elgantt-start-date "2026-01-01")
(setq elgantt-header-column-offset 40
elgantt-header-type 'outline
elgantt-show-header-depth t
elgantt-insert-blank-line-between-top-level-header t
elgantt-startup-folded nil
elgantt-draw-overarching-headers nil
elgantt-scroll-to-current-month-at-startup t)
(setq elgantt-user-set-color-priority-counter 0)
;; --- 2. Effort Rule (With Weekend Extension) ---
(elgantt-create-display-rule draw-scheduled-to-effort-end
:parser ((override-color . ((when-let ((colors (org-entry-get (point) "ELGANTT-COLOR")))
(split-string colors " "))))
(elgantt-effort . ((org-entry-get (point) "EFFORT")))
(wknd-days . ((when-let ((val (org-entry-get (point) "WEEKEND_DAYS")))
(string-to-number val)))))
:args (elgantt-scheduled elgantt-effort elgantt-org-id)
:body ((when (and elgantt-scheduled elgantt-effort)
(let* ((start-ts (ts-parse elgantt-scheduled))
(raw-mins (org-duration-to-minutes elgantt-effort))
;; Add the weekend jump days to the visual length
(total-days (+ (ceiling (/ (float raw-mins) 1440.0)) (or wknd-days 0)))
(p1 (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" start-ts))
(point)))
(colors (or override-color '("#8ec07c" "#458588"))))
(when (numberp p1)
(if (<= total-days 1)
(elgantt--create-overlay (truncate p1) (1+ (truncate p1))
`(face (:background ,(car colors))
priority ,(setq elgantt-user-set-color-priority-counter
(1- elgantt-user-set-color-priority-counter))
:elgantt-user-overlay ,elgantt-org-id))
;; FIX 1: compute p2 by date (handles "|" separators)
;; FIX 2: keep original "Rule of 2" behavior to avoid +1 day overshoot
(let* ((end-ts (ts-adjust 'day (- total-days 2) start-ts))
(p2 (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" end-ts))
(point))))
(when (numberp p2)
(elgantt--draw-gradient
(car colors) (cadr colors)
(truncate p1) (1+ (truncate p2)) nil
`(priority ,(setq elgantt-user-set-color-priority-counter
(1- elgantt-user-set-color-priority-counter))
:elgantt-user-overlay ,elgantt-org-id))))))))))
;; --- 3. Progress Bar ---
(elgantt-create-display-rule pages-read-progress
:parser ((total-pages . ((--when-let (org-entry-get (point) "TOTAL_PAGES") (string-to-number it))))
(pages-read . ((--when-let (org-entry-get (point) "PAGES_READ") (string-to-number it)))))
:args (elgantt-deadline elgantt-scheduled)
:body ((when (and elgantt-deadline elgantt-scheduled total-pages pages-read)
(let* ((start (save-excursion (elgantt--goto-date elgantt-scheduled) (point)))
(end (save-excursion (elgantt--goto-date elgantt-deadline) (point)))
(percent (/ (float pages-read) (float total-pages))))
(when (and (numberp start) (numberp end))
(elgantt--draw-progress-bar "#98be65" "#ff6c6b"
(truncate start) (truncate end) percent))))))
;; --- 4. Blocker Interaction (Smart Append) ---
(require 'elgantt-interaction)
(elgantt--selection-rule :name mark-blocker
:selection-number 2
:selection-messages ((1 . "Select the BLOCKING task (Cause)")
(2 . "Select the BLOCKED task (Effect)"))
:execution-functions
((1 . ((elgantt-with-point-at-orig-entry nil (org-id-get-create))))
(2 . ((let* ((new-id return-val)
(current (elgantt-with-point-at-orig-entry nil (org-entry-get (point) "BLOCKER"))))
(elgantt-with-point-at-orig-entry nil
(if (and current (string-match "ids(\\(.*?\\))" current))
(let ((existing (match-string 1 current)))
(org-set-property "BLOCKER" (format "ids(%s %s)" existing new-id)))
(org-set-property "BLOCKER" (format "ids(%s)" new-id)))
(message "Added blocker: %s" new-id)))))))
;; --- 5. Blocker Lines (Multi-ID & Sync Support) ---
(elgantt-create-display-rule draw-blocker-lines
:parser ((blocker-raw . ((org-entry-get (point) "BLOCKER"))))
:args (elgantt-org-id)
:body ((when (and blocker-raw (not (string-empty-p blocker-raw)))
(let* ((p-blocked (point))
(ids-string (if (string-match "ids(\\(.*?\\))" blocker-raw)
(match-string 1 blocker-raw)
blocker-raw))
;; Supports spaces or commas as separators
(id-list (split-string ids-string "[ ,]+" t)))
(dolist (blocker-id id-list)
(save-excursion
(when (elgantt--goto-id blocker-id)
(let* ((blocker-data (elgantt-with-point-at-orig-entry nil
(list (org-entry-get (point) "SCHEDULED")
(org-entry-get (point) "EFFORT"))))
(b-sched (car blocker-data))
(b-effort (cadr blocker-data)))
(when (and b-sched b-effort)
(let* ((start-ts (ts-parse b-sched))
(raw-mins (org-duration-to-minutes b-effort))
(days-count (ceiling (/ (float raw-mins) 1440.0)))
(p-start-base (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" start-ts))
(point))))
(when (and (numberp p-start-base) (numberp p-blocked))
;; FIX 1: compute p-line-start by date (handles "|" separators)
;; FIX 2: keep original "Rule of 2" behavior to avoid +1 day overshoot
(let* ((end-ts (ts-adjust 'day (- days-count 2) start-ts))
(p-line-start (save-excursion
(elgantt--goto-date (ts-format "%Y-%m-%d" end-ts))
(point))))
(when (numberp p-line-start)
(elgantt--draw-line (truncate p-line-start)
(truncate p-blocked)
"#b8bb26"))))))))))))))
;; --- 6. Hashtag Navigation ---
(elgantt-create-action follow-hashtag-link-forward
:args (elgantt-alltags) :binding "C-M-f"
:body ((when-let* ((hashtag (--first (s-starts-with-p "#" it) elgantt-alltags))
(match (elgantt--next-match :elgantt-alltags hashtag)))
(goto-char (car match)))))
(elgantt-create-action follow-hashtag-link-backward
:args (elgantt-alltags) :binding "C-M-b"
:body ((when-let* ((hashtag (--first (s-starts-with-p "#" it) elgantt-alltags))
(match (elgantt--previous-match :elgantt-alltags hashtag)))
(goto-char (car match)))))
)
(defun elgantt-open-current-org-file ()
(interactive)
(if-let ((file (buffer-file-name)))
(progn
(setq elgantt-agenda-files (list file))
(elgantt--reset-org-ql-cache)
(elgantt-open))
(message "No file!")))
(setq org-roam-directory (file-truename "~/ExoKortex/")
org-roam-db-location (file-truename "~/ExoKortex/2-Areas/IT/Roam/org-roam.db")
org-attach-id-dir "assets/"
@@ -1540,239 +1328,428 @@ If FILE is nil, refile in the current file."
(add-hook 'ediff-prepare-buffer-hook 'org-ediff-prepare-buffer)
(defun gortium/add-trigger-scheduling-next ()
"Add scheduled chain for this entry."
(interactive)
(org-set-property "TRIGGER" "next-sibling scheduled!(\"++0d\") todo!(NEXT) chain!(\"TRIGGER\")"))
;;; ============================================================
;;; GORTIUM — Org chain scheduler (Safe & Optimized with Debugging)
;;; ============================================================
(defun gortium/org-schedule-after-previous-sibling ()
"Schedule the current task right after its previous sibling.
If the sibling is DONE, use its CLOSED time.
Otherwise, use SCHEDULED + EFFORT."
(interactive)
(unless (org-at-heading-p)
(org-back-to-heading t))
(let ((current-level (org-current-level))
(current-point (point))
prev-end-time)
(save-excursion
;; Find previous sibling of same level
(let ((found nil))
(while (and (not found) (outline-previous-heading))
(when (= (org-current-level) current-level)
(setq found t)))
(unless found
(user-error "No previous sibling found"))
;; At previous sibling now
(let* ((state (org-get-todo-state))
(scheduled (org-entry-get nil "SCHEDULED"))
(closed (org-entry-get nil "CLOSED"))
(effort (org-entry-get nil "EFFORT")))
(cond
;; If task is DONE and CLOSED exists
((and (member state org-done-keywords) closed)
(setq prev-end-time (org-time-string-to-time closed)))
;; Else use SCHEDULED + EFFORT
((and scheduled effort)
(let* ((sched-time (org-time-string-to-time scheduled))
(duration-min (org-duration-to-minutes effort)))
(setq prev-end-time
(time-add sched-time (seconds-to-time (* 60 duration-min))))))
(t
(user-error "Previous sibling missing SCHEDULED/CLOSED or EFFORT"))))))
;; Schedule current task
(goto-char current-point)
(org-schedule nil (format-time-string (org-time-stamp-format t) prev-end-time))))
;; --- Helper: Snap to working hours (8-16) ---
;; --- Helper: Snap to working hours (8-16) ---
(defun gortium/internal--snap-to-working-hours (time)
(let* ((day-start 8) (day-end 16)
(t1 (gortium/org--skip-weekend time))
(d (decode-time t1)) (h (nth 2 d)))
(cond
((< h day-start) (apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 d))))
((>= h day-end)
(let ((next (time-add t1 (days-to-time 1))))
(gortium/org--skip-weekend (apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 (decode-time next)))))))
(t t1))))
;; --- Helper: Calculate End Time & Weekend Jump (THE WALKER FIX) ---
(defun gortium/internal--calculate-task-span (start-time effort-str)
(if (or (null effort-str) (string-empty-p effort-str))
(list start-time 0)
(let* ((eff-mins (org-duration-to-minutes effort-str))
;; SMART MATH: If string has "d", divide by 1440 (24h). If only "h", divide by 480 (8h).
(divisor (if (string-match-p "d" effort-str) 1440.0 480.0))
(eff-days (max 1 (ceiling (/ (float eff-mins) divisor))))
(cursor start-time)
(days-worked 0)
(wknd-count 0))
;; THE WALKER: Simulate the task day by day
(while (< days-worked eff-days)
(let* ((dow (nth 6 (decode-time cursor))))
(cond
;; If Sat (6) or Sun (0), it's a weekend. Count it, but don't advance work.
((or (= dow 6) (= dow 0))
(setq wknd-count (1+ wknd-count))
(setq cursor (time-add cursor (days-to-time 1))))
;; If Mon-Fri, it's a work day. Advance work count.
(t
(setq days-worked (1+ days-worked))
;; Move cursor to start of next day
(setq cursor (time-add cursor (days-to-time 1)))))))
;; Return [End-Time, Weekend-Days-Count]
(list cursor wknd-count))))
;; --- Helper: Find Blocker End Time ---
(defun gortium/internal--get-blocker-end (blocker-str current-pos task-end-map)
(let ((clean (s-trim blocker-str)) (latest-time nil))
(cond
((string-match-p "previous-sibling" clean)
(save-excursion
(goto-char current-pos)
(let ((found nil))
(while (and (not found) (org-get-last-sibling))
(let* ((sid (org-id-get)) (end (when sid (gethash sid task-end-map))))
(when end (setq latest-time end) (setq found t)))))))
((string-match-p "parent" clean)
(save-excursion (goto-char current-pos)
(when (org-up-heading-safe)
(setq latest-time (gethash (org-id-get) task-end-map)))))
((string-match "ids(\\(.*?\\))" clean)
(dolist (tid (split-string (match-string 1 clean) "[ ,]+" t))
(let ((tend (gethash (replace-regexp-in-string "[\"']\\|id:" "" tid) task-end-map)))
(when (and tend (or (null latest-time) (time-less-p latest-time tend)))
(setq latest-time tend))))))
latest-time))
;; --- Helper: Write Properties (Safe) ---
(defun gortium/internal--update-properties (pos start wknd id end task-end-map)
(save-excursion
(goto-char pos) ;; POS is a MARKER
(if (and wknd (> wknd 0))
(org-entry-put (point) "WEEKEND_DAYS" (number-to-string wknd))
(org-entry-put (point) "WEEKEND_DAYS" nil))
(org-schedule nil (format-time-string "<%Y-%m-%d %a %H:%M>" start))
(puthash id end task-end-map)))
(require 'org)
(require 'org-id)
(require 'cl-lib)
(require 's)
;; ------------------------------------------------------------
;; Helper: Skip weekends (snap to next weekday at 08:00)
;; ------------------------------------------------------------
(defun gortium/org--skip-weekend (time)
"Advance TIME to the next Monday morning if it falls on a weekend."
(let* ((decoded (decode-time time))
(dow (nth 6 decoded)))
(cond
((= dow 6) ;; Saturday
(let ((next (time-add time (days-to-time 2))))
(apply #'encode-time (append '(0 0 8) (nthcdr 3 (decode-time next))))))
(apply #'encode-time
(append '(0 0 8)
(nthcdr 3 (decode-time (time-add time (days-to-time 2)))))))
((= dow 0) ;; Sunday
(let ((next (time-add time (days-to-time 1))))
(apply #'encode-time (append '(0 0 8) (nthcdr 3 (decode-time next))))))
(apply #'encode-time
(append '(0 0 8)
(nthcdr 3 (decode-time (time-add time (days-to-time 1)))))))
(t time))))
;; --- MAIN FUNCTION ---
(defun gortium/org-schedule-subtree-chains ()
"Schedule tasks using MARKERS and WALKER logic."
(interactive)
;; ------------------------------------------------------------
;; Helper: Snap to working hours (08:0016:00)
;; ------------------------------------------------------------
(defun gortium/internal--snap-to-working-hours (time)
(let* ((day-start 8)
(day-end 16)
(t1 (gortium/org--skip-weekend time))
(d (decode-time t1))
(h (nth 2 d)))
(cond
((< h day-start)
(apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 d))))
((>= h day-end)
(let ((next (time-add t1 (days-to-time 1))))
(gortium/org--skip-weekend
(apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time next)))))))
(t t1))))
(defun gortium/org--get-range-end (pos)
"Extract the END timestamp from an existing range like <A>--<B> at POS.
POS can be a marker or a cons cell (file . position)."
(let ((marker (if (markerp pos) pos
(let ((file (car pos))
(p (cdr pos)))
(with-current-buffer (find-file-noselect file)
(copy-marker p))))))
(with-current-buffer (marker-buffer marker)
(save-excursion
(goto-char marker)
(org-back-to-heading t)
(let ((subtree-end (save-excursion (org-end-of-subtree t) (point)))
(end-time nil))
(save-restriction
(narrow-to-region (point) subtree-end)
(goto-char (point-min))
(when (re-search-forward "<[^>]+>--<\\([^>]+\\)>" nil t)
(condition-case nil
(setq end-time (org-time-string-to-time (match-string 1)))
(error nil))))
end-time)))))
(defun gortium/org--get-range-start (pos)
"Extract the start timestamp from an existing range like <A>--<B>.
Returns nil if no range found (safe, non-blocking)."
(save-excursion
(message "--- Starting Gantt Scheduler ---")
(let* ((all-tasks '()) (task-end-times (make-hash-table :test 'equal)))
(goto-char pos)
(org-back-to-heading t)
(let ((end (save-excursion
(or (ignore-errors (outline-next-heading))
(point-max))
(point)))
(start-time nil))
(save-restriction
(narrow-to-region (point) end)
(goto-char (point-min))
;; Look for range in first 50 lines only (safety limit)
(let ((search-limit (save-excursion
(forward-line 50)
(point))))
(when (re-search-forward "<\\([^>]+\\)>--<[^>]+>" search-limit t)
(condition-case nil
(setq start-time (org-time-string-to-time (match-string 1)))
(error nil)))))
start-time)))
;; Collection
(org-map-entries
(lambda ()
(when (org-get-todo-state) ;; Strict TODO check
(let ((effort (org-entry-get (point) "EFFORT" nil))
(blocker (org-entry-get (point) "BLOCKER" nil))
(fixed (org-entry-get (point) "FIXED" nil))
(scheduled (org-entry-get (point) "SCHEDULED")))
(when (or effort blocker fixed)
(push (list (point-marker) (org-id-get-create) (org-get-heading t t t t)
effort (when blocker (s-trim blocker)) fixed scheduled
(org-entry-get (point) "OFFSET_DAYS" nil)) all-tasks)))))
nil (if (org-at-heading-p) 'tree nil))
(setq all-tasks (nreverse all-tasks))
(defun gortium/internal--calculate-task-span (start-time effort-str)
"Return a list (END-TIME WEEKEND-DAYS) for given START-TIME and EFFORT string."
(if (or (null effort-str) (string-empty-p effort-str))
(list start-time 0)
(let* ((eff-mins (org-duration-to-minutes effort-str))
(total-work-mins (if (string-match-p "d" effort-str)
(* (/ eff-mins 1440.0) 480)
eff-mins))
(cursor start-time)
(wknd-count 0)
(day-start 8)
(day-end 16)
(safety-counter 0)
(max-iterations 1000)) ;; Safety limit
(while (and (> total-work-mins 0) (< safety-counter max-iterations))
(setq safety-counter (1+ safety-counter))
(let* ((decoded (decode-time cursor))
(h (nth 2 decoded))
(m (nth 1 decoded))
(dow (nth 6 decoded))
(current-abs-min (+ (* h 60) m))
(day-end-abs-min (* day-end 60))
(mins-left-today (- day-end-abs-min current-abs-min)))
(cond
((or (= dow 6) (= dow 0)) ;; weekend
(setq wknd-count (1+ wknd-count))
(setq cursor (apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time
(time-add cursor (days-to-time 1))))))))
((<= mins-left-today 0) ;; after hours
(setq cursor (apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time
(time-add cursor (days-to-time 1))))))))
((<= total-work-mins mins-left-today)
(setq cursor (time-add cursor (seconds-to-time (* total-work-mins 60))))
(setq total-work-mins 0))
(t ;; spill to next day
(setq total-work-mins (- total-work-mins mins-left-today))
(setq cursor (time-add cursor (seconds-to-time (* mins-left-today 60))))))))
;; Pass 1: FIXED
(dolist (task all-tasks)
(pcase-let ((`(,pos ,id ,heading ,effort ,blocker ,fixed ,scheduled ,offset) task))
(when (and fixed (member (downcase fixed) '("t" "true" "yes")))
(if scheduled
(let* ((start (org-time-string-to-time scheduled))
(span (gortium/internal--calculate-task-span start effort)))
(save-excursion
(goto-char pos)
(if (and (cadr span) (> (cadr span) 0))
(org-entry-put (point) "WEEKEND_DAYS" (number-to-string (cadr span)))
(org-entry-put (point) "WEEKEND_DAYS" nil))
(puthash id (car span) task-end-times)))
(message "WARNING: Fixed task '%s' missing date." heading)))))
(when (>= safety-counter max-iterations)
(message "WARNING: calculate-task-span hit iteration limit for effort %s" effort-str))
;; Pass 2: CHAINS
(let* ((remaining (cl-remove-if (lambda (tk) (or (nth 5 tk) (not (nth 4 tk)))) all-tasks))
(iter 0) (limit (* 5 (length all-tasks))))
(while (and remaining (< iter limit))
(cl-incf iter)
(let ((scheduled-this-loop '()))
(dolist (task remaining)
(pcase-let ((`(,pos ,id ,heading ,effort ,blocker ,fixed ,scheduled ,offset) task))
(let ((dep-end (gortium/internal--get-blocker-end blocker pos task-end-times)))
(when dep-end
(let* ((off-val (if offset (string-to-number offset) 0))
(base-start (time-add dep-end (days-to-time off-val)))
(start (gortium/internal--snap-to-working-hours base-start))
(span (gortium/internal--calculate-task-span start effort)))
(gortium/internal--update-properties pos start (cadr span) id (car span) task-end-times)
(push task scheduled-this-loop))))))
(setq remaining (cl-remove-if (lambda (tk) (member tk scheduled-this-loop)) remaining))))
(list cursor wknd-count))))
(dolist (task all-tasks) (set-marker (car task) nil)) ;; Clean markers
(message "--- Scheduler Finished ---")))))
;; ------------------------------------------------------------
;; Helper: Find dependency end time
;; ------------------------------------------------------------
(defun gortium/internal--get-blocker-end (blocker-str task-end-map)
"Return the latest end time of all blockers if they are all resolved."
(let ((clean (s-trim (format "%s" blocker-str)))
(latest-time nil)
(all-resolved t))
(when (and (string-match "ids(\\(.*?\\))" clean)
(not (string-empty-p (s-trim (match-string 1 clean)))))
(dolist (tid (split-string (match-string 1 clean) "[ ,]+" t))
(let* ((clean-id (replace-regexp-in-string "[\"']\\|id: " "" tid))
(pos (org-id-find clean-id t))
(computed-end (gethash clean-id task-end-map))
(blocker-end nil))
;; USAGE:
;; 1. Set up your tasks with either:
;; - :FIXED: t and a SCHEDULED date, OR
;; - :BLOCKER: previous-sibling / ids(UUID) / ids("id:UUID") / parent
;; 2. Put cursor on "Planning" heading
;; 3. M-x gortium/org-schedule-subtree-chains
;;
;; The function will:
;; - Use FIXED tasks as anchors
;; - Calculate all other tasks from their dependencies
;; - Warn about tasks without BLOCKER or FIXED
;; - Detect circular dependencies
;; - Respect 8-hour workday limits
;; - Skip weekends
;;
;; Supported BLOCKER types (standard EDNA format):
;; - previous-sibling
;; - ids(UUID) - e.g., ids(70d8f844-1952-43f0-8043-da51e8c8bc69)
;; - ids("id:UUID") - e.g., ids("id:70d8f844-1952-43f0-8043-da51e8c8bc69")
;; - parent
;; 1. Determine this specific blocker's end time
(setq blocker-end
(cond
(computed-end computed-end) ;; Use what we just calculated in this session
(pos (let ((m (if (markerp pos) pos
(set-marker (make-marker) (cdr pos)
(find-file-noselect (car pos))))))
(with-current-buffer (marker-buffer m)
(org-with-point-at m
(cond
;; Priority 1: Use actual CLOSED timestamp if DONE
((org-entry-get nil "CLOSED")
(org-time-string-to-time (org-entry-get nil "CLOSED")))
;; Priority 2: Use range end if FIXED
((string-equal "t" (org-entry-get nil "FIXED"))
(gortium/org--get-range-end m))
(t nil))))))
(t nil)))
(defun gortium/add-ids-to-subtree ()
"Add IDs to all headings in current subtree."
(interactive)
(save-excursion
(org-back-to-heading t)
(org-map-entries
(lambda () (org-id-get-create))
nil 'tree)))
;; 2. Update the "Latest" tracker
(if blocker-end
(when (or (null latest-time) (time-less-p latest-time blocker-end))
(setq latest-time blocker-end))
;; If ANY blocker is not resolved/found, the whole task is not ready
(setq all-resolved nil)))))
;; Custom function to shift projects
(defun gortium/org-shift-subtree-schedules (days)
"Shift all SCHEDULED dates in the current subtree by DAYS."
(interactive "nDays to shift: ")
;; Only return a time if EVERY ID in the list was successfully resolved
(when all-resolved latest-time)))
(defun gortium/internal--update-properties (pos start wknd id end task-end-map)
"Heals the property drawer, updates values, and fixes vertical spacing without leaking newlines."
(message "[DEBUG] enter update-properties for %s" id)
(save-excursion
(goto-char pos)
(org-back-to-heading t)
(let* ((subtree-start (point))
(subtree-end (save-excursion (org-end-of-subtree t) (point))))
(save-restriction
(narrow-to-region subtree-start subtree-end)
;; --- STEP 1: HEAL THE DRAWER ---
(goto-char (point-min))
(forward-line 1)
(while (looking-at "^[ \t]*\\(CLOSED:\\|SCHEDULED:\\|DEADLINE:\\)")
(forward-line 1))
(when (looking-at "^[ \t]*:PROPERTIES:[ \t]*$")
(let ((drawer-start (point)))
(when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
(let ((drawer-end (match-end 0)))
(save-restriction
(narrow-to-region drawer-start drawer-end)
(goto-char (point-min))
(forward-line 1)
(while (re-search-forward "^[ \t]*$" nil t)
(delete-region (line-beginning-position)
(min (1+ (line-end-position)) (point-max)))))))))
;; --- STEP 2: UPDATE PROPERTY ---
(org-entry-put nil "WEEKEND_DAYS" (number-to-string (or wknd 0)))
;; --- STEP 3: REMOVE OLD RANGE ---
(goto-char (point-min))
(while (re-search-forward "^[ \t]*<.+>--<.+>[ \t]*\n?" nil t)
(replace-match ""))
;; --- STEP 4: FIND INSERTION POINT ---
(goto-char (point-min))
(forward-line 1)
(while (looking-at "^[ \t]*\\(CLOSED:\\|SCHEDULED:\\|DEADLINE:\\)")
(forward-line 1))
(while (looking-at "^[ \t]*:\\([A-Z_]+\\):[ \t]*$")
(when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
(forward-line 1)))
;; Delete any existing blank lines at the insertion point
(while (and (looking-at "^[ \t]*$") (not (eobp)))
(delete-region (line-beginning-position) (line-beginning-position 2)))
;; --- STEP 5: INSERT RANGE ---
;; Ensure range starts on a new line and ends with exactly one newline
(unless (bolp) (insert "\n"))
(insert (format "<%s>--<%s>\n"
(format-time-string "%Y-%m-%d %a %H:%M" start)
(format-time-string "%Y-%m-%d %a %H:%M" end)))
;; --- STEP 6: CLEAN UP REMAINING WHITESPACE WITHIN TASK ---
(while (and (looking-at "^[ \t]*$") (not (eobp)))
(delete-region (line-beginning-position) (line-beginning-position 2))))
;; --- STEP 7: NORMALIZE SPACING BETWEEN TASKS ---
;; Instead of inserting a newline blindly, we ensure exactly one blank line
;; exists between this subtree's end and the next heading.
(goto-char (org-end-of-subtree t))
(let ((post-subtree (point)))
(save-restriction
(widen)
(goto-char post-subtree)
(delete-blank-lines)
;; Only insert a blank line if we aren't at the end of the buffer
(unless (eobp)
(insert "\n")))))
(message "[DEBUG] exit update-properties")
(puthash id end task-end-map)))
;; ------------------------------------------------------------
;; Helper: Detect circular dependencies
;; ------------------------------------------------------------
(defun gortium/internal--detect-circular-deps (tasks)
"Check for circular dependencies in TASKS.
Returns list of task IDs involved in cycles, or nil if no cycles found."
(let ((graph (make-hash-table :test 'equal))
(visiting (make-hash-table :test 'equal))
(visited (make-hash-table :test 'equal))
(cycles nil))
;; Build dependency graph
(dolist (task tasks)
(pcase-let ((`(,_pos ,id ,_effort ,blocker ,_fixed ,_sched ,_rng-start ,_offset ,_state) task))
(when (and blocker (not (string-empty-p (s-trim blocker))))
(when (string-match "ids(\\(.*?\\))" blocker)
(let ((deps (split-string (match-string 1 blocker) "[ ,]+" t)))
(puthash id (mapcar (lambda (tid)
(replace-regexp-in-string "[\"']\\|id:" "" tid))
deps)
graph))))))
;; DFS to detect cycles
(cl-labels ((dfs (node path)
(cond
((gethash node visiting)
;; Found a cycle
(push node cycles)
t)
((gethash node visited)
nil)
(t
(puthash node t visiting)
(dolist (dep (gethash node graph))
(when (dfs dep (cons node path))
(push node cycles)))
(remhash node visiting)
(puthash node t visited)
nil))))
(maphash (lambda (node _deps)
(unless (gethash node visited)
(dfs node nil)))
graph))
(delete-dups cycles)))
(advice-add 'org-roam-db-sync :before
(lambda (&rest _)
(message "[DEBUG] org-roam-db-sync invoked")))
;; --- MAIN SCHEDULER ---
(defun gortium/org-schedule-subtree-chains ()
"Standard Gortium scheduler: Correctly calculates Finish-to-Start dependencies."
(interactive)
(message "=== Starting Gortium Scheduler ===")
(let ((all-tasks '())
(task-end-times (make-hash-table :test 'equal))
(start-time (current-time))
(org-element-use-cache nil)) ;; Disable buggy cache
;; 1. COLLECT
(org-map-entries
(lambda ()
(let ((scheduled (org-entry-get nil "SCHEDULED")))
(when scheduled
(let* ((time (org-time-string-to-time scheduled))
(new-time (time-add time (days-to-time days)))
(new-date (format-time-string (org-time-stamp-format) new-time)))
(org-entry-put nil "SCHEDULED" new-date)))))
nil 'tree))
(when (org-get-todo-state)
(let* ((pos (point-marker))
(id (or (org-id-get) (org-id-get-create))))
(push (list (current-buffer) pos id
(org-entry-get pos "EFFORT")
(org-entry-get pos "BLOCKER")
(org-entry-get pos "FIXED")
(org-get-scheduled-time pos)
(org-entry-get pos "OFFSET_DAYS"))
all-tasks))))
nil nil)
(setq all-tasks (nreverse all-tasks))
;; 2. ITERATE
(let* ((remaining all-tasks)
(limit (* 20 (length remaining)))
(iter 0))
(while (and remaining (< iter limit))
(setq iter (1+ iter))
(let ((done-this-loop '()))
(dolist (task remaining)
(pcase-let ((`(,buf ,pos ,id ,effort ,blocker ,fixed ,sched ,offset) task))
(let* ((blocker-end (gortium/internal--get-blocker-end blocker task-end-times))
(has-blocker (and blocker (not (string-empty-p (s-trim blocker)))))
(is-fixed (string-equal fixed "t"))
(ready (or is-fixed (not has-blocker) blocker-end)))
(when ready
(with-current-buffer buf
(org-element-with-disabled-cache
(let* ((off-days (if (stringp offset) (string-to-number offset) 0))
(base-start (cond
;; 1. If FIXED, use its own defined start
(is-fixed (or (gortium/org--get-range-start pos) sched (current-time)))
;; 2. If it HAS a blocker, it MUST use blocker-end.
;; If blocker-end is nil, this task isn't 'ready' yet.
(has-blocker blocker-end)
;; 3. If no blocker and not fixed, use current schedule or now
(t (or sched (current-time)))))
(final-start (if is-fixed base-start
(gortium/internal--snap-to-working-hours
(time-add base-start (days-to-time off-days)))))
(span (gortium/internal--calculate-task-span final-start effort))
(final-end (car span))
(wknd (cadr span)))
(gortium/internal--update-properties pos final-start wknd id final-end task-end-times)
(push task done-this-loop))))))))
(setq remaining (cl-set-difference remaining done-this-loop))))
;; 3. CLEANUP
(setq org-element-use-cache t)
(org-element-cache-reset 'all)
(message "=== Scheduler completed (%d tasks, %d iterations) ===" (length all-tasks) iter))))
;; ---------------------------------------------
(defun gortium/org-ensure-task-properties ()
"Iterate through all tasks (TODO, NEXT, STRT, WAIT, HOLD, DONE, etc.)
and ensure the standard property drawer exists without overwriting existing data."
(interactive)
(save-excursion
(message "--- Initializing Task Properties for all states ---")
(let ((count 0)
;; List of properties to ensure exist
(props '("EFFORT" "BLOCKER" "FIXED" "WEEKEND_DAYS"
"ASSIGNEE" "RESOURCES" "CATEGORY"
"DIMENTIONS" "WEIGHT" "OFFSET_DAYS")))
(org-map-entries
(lambda ()
;; This check returns true if the heading has ANY todo keyword
(let ((todo-state (org-get-todo-state)))
(when todo-state
(cl-incf count)
;; 1. Handle ID (builtin handles 'do not replace' logic)
(org-id-get-create)
;; 2. Ensure all other keys exist
(dolist (prop props)
;; Only add the property if it's currently nil/missing
(unless (org-entry-get (point) prop)
(org-entry-put (point) prop
(if (string= prop "FIXED") "nil" "")))))))
nil nil)
(message "--- Finished: Processed %d tasks across all states ---" count))))
(defun gortium/add-ids-to-subtree ()
"Add IDs to all headings in current subtree."
(interactive)
(save-excursion
(org-back-to-heading t)
(org-map-entries
(lambda () (org-id-get-create))
nil 'tree)))
;; Open link in another frame
(defun gortium/org-open-link-in-other-frame ()