Commit 4bb4441afd8a

Vincent Demeester <vincent@sbr.pm>
2026-07-02 11:57:18
feat(daily-plan): surface unscheduled upcoming deadlines
Added an UpcomingDeadlines org parser that collects non-DONE items with a DEADLINE within the next 7 days, tracking whether each is also scheduled. The show command now exposes these as a deadlines JSON section and prints an unscheduled-deadlines block, so items with a near deadline that never got scheduled surface before they slip. Extended the Emacs interface to render the same section.
1 parent ff527f1
Changed files (4)
tools
daily-plan
cmd
daily-plan
internal
tools/daily-plan/cmd/daily-plan/main.go
@@ -104,14 +104,15 @@ func run(args []string) error {
 
 // JSON output types for Emacs integration.
 type jsonShow struct {
-	Date           string     `json:"date"`
-	Agenda         []jsonOrg  `json:"agenda"`
-	JiraInProgress []jsonJira `json:"jira_in_progress"`
-	JiraBacklog    []jsonJira `json:"jira_backlog"`
-	GHIssues       []jsonGH   `json:"github_issues"`
-	GHAssignedPRs  []jsonGH   `json:"github_assigned_prs"`
-	GHReviews      []jsonGH   `json:"github_reviews"`
-	GHPRs          []jsonGH   `json:"github_prs"`
+	Date           string         `json:"date"`
+	Agenda         []jsonOrg      `json:"agenda"`
+	Deadlines      []jsonDeadline `json:"deadlines"`
+	JiraInProgress []jsonJira     `json:"jira_in_progress"`
+	JiraBacklog    []jsonJira     `json:"jira_backlog"`
+	GHIssues       []jsonGH       `json:"github_issues"`
+	GHAssignedPRs  []jsonGH       `json:"github_assigned_prs"`
+	GHReviews      []jsonGH       `json:"github_reviews"`
+	GHPRs          []jsonGH       `json:"github_prs"`
 }
 
 type jsonInbox struct {
@@ -152,6 +153,13 @@ type jsonOrg struct {
 	Heading string `json:"heading"`
 }
 
+type jsonDeadline struct {
+	State     string `json:"state"`
+	Heading   string `json:"heading"`
+	Deadline  string `json:"deadline"`
+	Scheduled bool   `json:"scheduled"`
+}
+
 type jsonJira struct {
 	Key      string `json:"key"`
 	Summary  string `json:"summary"`
@@ -259,10 +267,23 @@ func emitJSON(v any) error {
 	return enc.Encode(v)
 }
 
+// filterUnscheduledDeadlines keeps deadline items that are not already
+// scheduled (so they surface as things that might slip).
+func filterUnscheduledDeadlines(items []org.DeadlineItem) []org.DeadlineItem {
+	out := make([]org.DeadlineItem, 0, len(items))
+	for _, d := range items {
+		if !d.Scheduled {
+			out = append(out, d)
+		}
+	}
+	return out
+}
+
 func cmdShow(ctx context.Context, cfg *config.Config) error {
 	today := time.Now().Format("2006-01-02")
 
 	agenda, _ := org.TodayItems(cfg.Org.File)
+	deadlines, _ := org.UpcomingDeadlines(cfg.Org.File, 7)
 	inprog, _ := cache.GetOrFetch(apiCache, "show:inprog:"+today, func() ([]jira.Issue, error) {
 		return jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
 	})
@@ -291,9 +312,14 @@ func cmdShow(ctx context.Context, cfg *config.Config) error {
 		for _, a := range agenda {
 			orgItems = append(orgItems, jsonOrg{State: a.State, Heading: a.Heading})
 		}
+		dl := make([]jsonDeadline, 0, len(deadlines))
+		for _, d := range deadlines {
+			dl = append(dl, jsonDeadline{State: d.State, Heading: d.Heading, Deadline: d.Deadline, Scheduled: d.Scheduled})
+		}
 		return emitJSON(jsonShow{
 			Date:           today,
 			Agenda:         orgItems,
+			Deadlines:      dl,
 			JiraInProgress: jiraToJSON(inprog, cfg.Jira.BaseURL),
 			JiraBacklog:    jiraToJSON(todo, cfg.Jira.BaseURL),
 			GHIssues:       ghToJSON(ghIssues),
@@ -306,6 +332,11 @@ func cmdShow(ctx context.Context, cfg *config.Config) error {
 	display.Header(fmt.Sprintf("Today's Org Agenda (%s)", today))
 	display.OrgItems(agenda)
 
+	if unscheduled := filterUnscheduledDeadlines(deadlines); len(unscheduled) > 0 {
+		display.Header("Upcoming Deadlines (unscheduled, next 7 days)")
+		display.DeadlineItems(unscheduled)
+	}
+
 	display.Header("Jira — In Progress / Code Review")
 	display.JiraIssues(inprog, "active")
 
tools/daily-plan/internal/display/display.go
@@ -48,6 +48,17 @@ func OrgItems(items []org.ScheduledItem) {
 	}
 }
 
+// DeadlineItems prints org items with an upcoming deadline.
+func DeadlineItems(items []org.DeadlineItem) {
+	for _, item := range items {
+		state := item.State
+		if state != "" {
+			state += " "
+		}
+		fmt.Printf("  %s⚑ %s%s %s%s%s\n", red, item.Deadline, reset, state, item.Heading, reset)
+	}
+}
+
 // JiraIssues prints a list of Jira issues.
 func JiraIssues(issues []jira.Issue, style string) {
 	if len(issues) == 0 {
tools/daily-plan/internal/org/org.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"regexp"
 	"strings"
 	"time"
 )
@@ -23,6 +24,80 @@ func TodayItems(orgFile string) ([]ScheduledItem, error) {
 	return itemsForDate(orgFile, today)
 }
 
+// DeadlineItem represents an org TODO with a DEADLINE in a date range.
+type DeadlineItem struct {
+	Heading   string
+	State     string    // TODO, NEXT, STRT, WAIT
+	Deadline  string    // YYYY-MM-DD
+	Scheduled bool      // whether the item is also SCHEDULED
+	Date      time.Time // parsed deadline, for sorting
+}
+
+var deadlineRe = regexp.MustCompile(`DEADLINE:\s+<(\d{4}-\d{2}-\d{2})`)
+
+// UpcomingDeadlines returns non-DONE org items whose DEADLINE falls within
+// [today, today+days]. The Scheduled flag reports whether the same item also
+// carries a SCHEDULED stamp (org emits planning lines on the line following the
+// heading, scheduled/deadline possibly combined on one line).
+func UpcomingDeadlines(orgFile string, days int) ([]DeadlineItem, error) {
+	f, err := os.Open(orgFile)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	now := time.Now()
+	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+	until := today.AddDate(0, 0, days)
+
+	var items []DeadlineItem
+	scanner := bufio.NewScanner(f)
+	var prevLine string
+	for scanner.Scan() {
+		line := scanner.Text()
+		m := deadlineRe.FindStringSubmatch(line)
+		if m == nil {
+			prevLine = line
+			continue
+		}
+		d, perr := time.ParseInLocation("2006-01-02", m[1], now.Location())
+		if perr != nil || d.Before(today) || d.After(until) {
+			prevLine = line
+			continue
+		}
+		heading := strings.TrimSpace(prevLine)
+		if !strings.HasPrefix(heading, "*") {
+			prevLine = line
+			continue
+		}
+		for strings.HasPrefix(heading, "*") {
+			heading = strings.TrimPrefix(heading, "*")
+		}
+		heading = strings.TrimSpace(heading)
+		state := ""
+		for _, s := range []string{"TODO", "DONE", "NEXT", "STRT", "WAIT", "CANX"} {
+			if strings.HasPrefix(heading, s+" ") {
+				state = s
+				heading = strings.TrimPrefix(heading, s+" ")
+				break
+			}
+		}
+		if state == "DONE" || state == "CANX" {
+			prevLine = line
+			continue
+		}
+		items = append(items, DeadlineItem{
+			Heading:   strings.TrimSpace(heading),
+			State:     state,
+			Deadline:  m[1],
+			Scheduled: strings.Contains(line, "SCHEDULED:"),
+			Date:      d,
+		})
+		prevLine = line
+	}
+	return items, scanner.Err()
+}
+
 func itemsForDate(orgFile, date string) ([]ScheduledItem, error) {
 	f, err := os.Open(orgFile)
 	if err != nil {
tools/daily-plan/daily-plan.el
@@ -150,6 +150,11 @@
       (insert (format " =[%s]=" .project)))
     (insert (format " /(%s)/\n" .date))))
 
+(defun daily-plan--insert-deadline-item (item)
+  "Insert an org ITEM with an upcoming DEADLINE."
+  (let-alist item
+    (insert (format "- <%s> %s %s\n" .deadline (or .state "") .heading))))
+
 ;; ── Render: show ──
 
 (defun daily-plan--render-show (data)
@@ -164,6 +169,15 @@
             (insert (format "- %s %s\n" (or .state "") .heading))))
       (insert "- (nothing scheduled)\n"))
 
+    ;; Upcoming deadlines that are not yet scheduled (candidates to slip).
+    (let ((unscheduled (seq-remove
+                        (lambda (it) (eq t (alist-get 'scheduled it)))
+                        .deadlines)))
+      (when unscheduled
+        (insert "\n** Upcoming Deadlines — unscheduled (next 7 days)  :deadline:\n")
+        (dolist (item unscheduled)
+          (daily-plan--insert-deadline-item item))))
+
     (insert "\n** Jira — In Progress / Code Review\n")
     (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)