Commit 4d62628193b8
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)
+}