Commit 4d62628193b8

Vincent Demeester <vincent@sbr.pm>
2026-03-27 11:00:06
feat(daily-plan): add org DONE items to weekly review
Ported org-mode state change parser from review-tool. Scans todos.org and archive directory for tasks transitioned to DONE within the week. Groups by section (Work, Systems, Personal, Routines) for clear retrospective output.
1 parent 6aeb9d7
Changed files (4)
tools
daily-plan
cmd
daily-plan
internal
tools/daily-plan/cmd/daily-plan/main.go
@@ -188,6 +188,18 @@ func ghToJSON(items []github.Item) []jsonGH {
 	return out
 }
 
+func orgDoneToJSON(items []org.DoneItem) []jsonOrgDone {
+	result := make([]jsonOrgDone, 0, len(items))
+	for _, d := range items {
+		result = append(result, jsonOrgDone{
+			Title:       d.Title,
+			Section:     d.Section,
+			CompletedAt: d.CompletedAt.Format("2006-01-02 15:04"),
+		})
+	}
+	return result
+}
+
 func discussionsToJSON(items []github.DiscussionItem) []jsonDiscussion {
 	result := make([]jsonDiscussion, 0, len(items))
 	for _, d := range items {
@@ -521,9 +533,16 @@ type jsonComment struct {
 	Date        string `json:"date"`
 }
 
+type jsonOrgDone struct {
+	Title       string `json:"title"`
+	Section     string `json:"section"`
+	CompletedAt string `json:"completed_at"`
+}
+
 type jsonWeekly struct {
 	Week            string           `json:"week"`
 	NextMonday      string           `json:"next_monday"`
+	OrgDone         []jsonOrgDone    `json:"org_done,omitempty"`
 	JiraCompleted   []jsonJira       `json:"jira_completed"`
 	GHMerged        []jsonGH         `json:"github_merged"`
 	GHReviewed      []jsonGH         `json:"github_reviewed"`
@@ -566,6 +585,10 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 		return github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
 	})
 
+	orgDone, _ := cache.GetOrFetch(apiCache, "org:done:"+mondayStr, func() ([]org.DoneItem, error) {
+		return org.FetchDoneItems(cfg.Org.Files, cfg.Org.ArchiveDir, monday, nextMonday)
+	})
+
 	var discussions []github.DiscussionItem
 	var comments []github.CommentItem
 	if !noDiscussions {
@@ -583,6 +606,7 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 		w := jsonWeekly{
 			Week:            monday.Format("2006-01-02"),
 			NextMonday:      nextMonday.Format("2006-01-02"),
+			OrgDone:         orgDoneToJSON(orgDone),
 			JiraCompleted:   jiraToJSON(completed, cfg.Jira.BaseURL),
 			GHMerged:        ghToJSON(merged),
 			GHReviewed:      ghToJSON(reviewed),
@@ -602,6 +626,11 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 
 	display.Header(fmt.Sprintf("Weekly Review (week of %s)", monday.Format("2006-01-02")))
 
+	if len(orgDone) > 0 {
+		display.SubHeader("Completed This Week (Org)")
+		display.OrgDoneItems(orgDone)
+	}
+
 	display.SubHeader("Completed This Week (Jira)")
 	display.JiraIssues(completed, "done")
 
tools/daily-plan/internal/config/config.go
@@ -41,8 +41,10 @@ type GitHubConfig struct {
 
 // OrgConfig configures org-mode integration.
 type OrgConfig struct {
-	File    string // Path to todos.org
-	Section string // Section to add TODOs under (e.g. "Work")
+	File       string   // Path to todos.org (for scheduling)
+	Section    string   // Section to add TODOs under (e.g. "Work")
+	Files      []string // All org files to scan for done items
+	ArchiveDir string   // Archive directory for historical done items
 }
 
 // DefaultConfig returns sensible defaults.
@@ -78,6 +80,10 @@ func DefaultConfig() *Config {
 		Org: OrgConfig{
 			File:    filepath.Join(home, "desktop", "org", "todos.org"),
 			Section: "Work",
+			Files: []string{
+				filepath.Join(home, "desktop", "org", "todos.org"),
+			},
+			ArchiveDir: filepath.Join(home, "desktop", "org", "archive"),
 		},
 		StateDir: filepath.Join(home, ".local", "share", "daily-plan"),
 	}
tools/daily-plan/internal/display/display.go
@@ -197,6 +197,39 @@ func DependabotAlerts(alerts []github.DependabotAlert) {
 	}
 }
 
+// OrgDoneItems prints completed org tasks, grouped by section.
+func OrgDoneItems(items []org.DoneItem) {
+	if len(items) == 0 {
+		fmt.Printf("  %s(none)%s\n", dim, reset)
+		return
+	}
+	// Group by section
+	bySection := make(map[string][]org.DoneItem)
+	var order []string
+	for _, item := range items {
+		section := item.Section
+		if section == "" {
+			section = "(uncategorized)"
+		}
+		if _, exists := bySection[section]; !exists {
+			order = append(order, section)
+		}
+		bySection[section] = append(bySection[section], item)
+	}
+	for _, section := range order {
+		sectionItems := bySection[section]
+		fmt.Printf("  %s%s%s%s\n", dim, bold, section, reset)
+		for _, item := range sectionItems {
+			title := item.Title
+			if len(title) > 65 {
+				title = title[:62] + "..."
+			}
+			fmt.Printf("    %s✓%s %s %s(%s)%s\n",
+				green, reset, title, dim, item.CompletedAt.Format("2006-01-02 15:04"), reset)
+		}
+	}
+}
+
 // DiscussionItems prints GitHub discussions.
 func DiscussionItems(items []github.DiscussionItem) {
 	if len(items) == 0 {
tools/daily-plan/internal/org/done.go
@@ -0,0 +1,174 @@
+package org
+
+import (
+	"bufio"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+)
+
+var (
+	// ** DONE [#2] Some task title  :tag1:tag2:
+	orgHeadingRe = regexp.MustCompile(`^(\*+)\s+(TODO|DONE|STRT|NEXT|WAIT|CANX)\s+(?:\[#[^\]]*\]\s+)?(.+)$`)
+	// - State "DONE"       from "TODO"       [2026-01-25 Sat 15:30]
+	stateChangeRe = regexp.MustCompile(`^-\s+State\s+"(\w+)"\s+from\s+"(\w+)"\s+\[(\d{4}-\d{2}-\d{2}\s+\w{3}\s+\d{2}:\d{2})\]`)
+	// Org-mode links: [[url][description]]
+	orgLinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\[([^\]]+)\]\]`)
+	// Org-mode tags at end of heading: :tag1:tag2:
+	orgTagsRe = regexp.MustCompile(`\s+:[A-Za-z0-9_@#%:]+:\s*$`)
+)
+
+// DoneItem represents a completed org task.
+type DoneItem struct {
+	Title       string
+	Section     string // top-level section (Work, Systems, Personal, etc.)
+	CompletedAt time.Time
+	File        string
+}
+
+// FetchDoneItems returns tasks completed (transitioned to DONE) within the time range.
+// Scans the given files and optional archive directory.
+func FetchDoneItems(files []string, archiveDir string, start, end time.Time) ([]DoneItem, error) {
+	var items []DoneItem
+
+	for _, f := range files {
+		fileItems, err := parseDoneFromFile(f, start, end)
+		if err != nil {
+			continue
+		}
+		items = append(items, fileItems...)
+	}
+
+	if archiveDir != "" {
+		archiveItems, err := parseDoneFromDir(archiveDir, start, end)
+		if err == nil {
+			items = append(items, archiveItems...)
+		}
+	}
+
+	return items, nil
+}
+
+func parseDoneFromDir(dir string, start, end time.Time) ([]DoneItem, error) {
+	var items []DoneItem
+
+	resolved, err := filepath.EvalSymlinks(dir)
+	if err != nil {
+		resolved = dir
+	}
+
+	err = filepath.Walk(resolved, func(path string, info os.FileInfo, err error) error {
+		if err != nil || info.IsDir() {
+			return nil
+		}
+		ext := strings.ToLower(filepath.Ext(path))
+		if ext != ".org" && ext != "" {
+			return nil
+		}
+		// Skip backup/temp files
+		base := filepath.Base(path)
+		if strings.HasSuffix(base, "~") || strings.Contains(base, "#") {
+			return nil
+		}
+
+		fileItems, err := parseDoneFromFile(path, start, end)
+		if err == nil {
+			items = append(items, fileItems...)
+		}
+		return nil
+	})
+
+	return items, err
+}
+
+func parseDoneFromFile(filePath string, start, end time.Time) ([]DoneItem, error) {
+	f, err := os.Open(filePath)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	var items []DoneItem
+	var currentHeading string
+	var currentSection string
+	seen := make(map[string]bool)
+
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+
+		// Track top-level section (single * heading without TODO state)
+		if strings.HasPrefix(line, "* ") && !strings.HasPrefix(line, "** ") {
+			if !orgHeadingRe.MatchString(line) {
+				section := strings.TrimPrefix(line, "* ")
+				section = orgTagsRe.ReplaceAllString(section, "")
+				currentSection = strings.TrimSpace(section)
+			}
+		}
+
+		// Track current heading
+		if matches := orgHeadingRe.FindStringSubmatch(line); len(matches) > 0 {
+			currentHeading = strings.TrimSpace(matches[3])
+		}
+
+		// Parse state changes to DONE
+		if matches := stateChangeRe.FindStringSubmatch(line); len(matches) > 0 {
+			if matches[1] != "DONE" || currentHeading == "" {
+				continue
+			}
+
+			ts, err := parseOrgTimestamp(matches[3])
+			if err != nil || ts.Before(start) || ts.After(end) {
+				continue
+			}
+
+			key := currentHeading + ts.Format("2006-01-02-15:04")
+			if seen[key] {
+				continue
+			}
+			seen[key] = true
+
+			items = append(items, DoneItem{
+				Title:       cleanTitle(currentHeading),
+				Section:     currentSection,
+				CompletedAt: ts,
+				File:        filePath,
+			})
+		}
+	}
+
+	return items, scanner.Err()
+}
+
+func parseOrgTimestamp(s string) (time.Time, error) {
+	s = strings.TrimSpace(s)
+	formats := []string{
+		"2006-01-02 Mon 15:04",
+		"2006-01-02 Mon",
+		"2006-01-02",
+	}
+	// Normalize day names (org uses locale-dependent short names)
+	for _, dayName := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun",
+		"lun", "mar", "mer", "jeu", "ven", "sam", "dim",
+		"lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."} {
+		s = strings.ReplaceAll(s, " "+dayName+" ", " Mon ")
+		s = strings.ReplaceAll(s, " "+dayName+"]", " Mon]")
+	}
+	for _, format := range formats {
+		if t, err := time.Parse(format, s); err == nil {
+			return t, nil
+		}
+	}
+	return time.Time{}, nil
+}
+
+// cleanTitle removes org artifacts from a heading title.
+func cleanTitle(s string) string {
+	// Remove trailing tags
+	s = orgTagsRe.ReplaceAllString(s, "")
+	// Convert [[url][desc]] links to just desc
+	s = orgLinkRe.ReplaceAllString(s, "$2")
+	return strings.TrimSpace(s)
+}