main
  1// Package display provides terminal output formatting.
  2package display
  3
  4import (
  5	"fmt"
  6	"strings"
  7
  8	"github.com/vdemeester/home/tools/daily-plan/internal/ai"
  9	"github.com/vdemeester/home/tools/daily-plan/internal/github"
 10	"github.com/vdemeester/home/tools/daily-plan/internal/jira"
 11	"github.com/vdemeester/home/tools/daily-plan/internal/org"
 12)
 13
 14const (
 15	bold    = "\033[1m"
 16	dim     = "\033[2m"
 17	red     = "\033[0;31m"
 18	green   = "\033[0;32m"
 19	yellow  = "\033[0;33m"
 20	blue    = "\033[0;34m"
 21	magenta = "\033[0;35m"
 22	cyan    = "\033[0;36m"
 23	reset   = "\033[0m"
 24)
 25
 26// Header prints a section header.
 27func Header(title string) {
 28	fmt.Printf("\n%s%s── %s ──%s\n", bold, cyan, title, reset)
 29}
 30
 31// SubHeader prints a subsection header.
 32func SubHeader(title string) {
 33	fmt.Printf("  %s%s%s%s\n", bold, magenta, title, reset)
 34}
 35
 36// OrgItems prints scheduled org items.
 37func OrgItems(items []org.ScheduledItem) {
 38	if len(items) == 0 {
 39		fmt.Printf("  %s(nothing scheduled)%s\n", dim, reset)
 40		return
 41	}
 42	for _, item := range items {
 43		state := item.State
 44		if state != "" {
 45			state += " "
 46		}
 47		fmt.Printf("  %s%s%s\n", state, item.Heading, reset)
 48	}
 49}
 50
 51// JiraIssues prints a list of Jira issues.
 52func JiraIssues(issues []jira.Issue, style string) {
 53	if len(issues) == 0 {
 54		fmt.Printf("  %s(none)%s\n", dim, reset)
 55		return
 56	}
 57	for _, issue := range issues {
 58		color := yellow
 59		switch style {
 60		case "dim":
 61			color = dim
 62		case "alert":
 63			color = red
 64		case "done":
 65			color = green
 66		}
 67
 68		summary := issue.Summary
 69		if len(summary) > 70 {
 70			summary = summary[:67] + "..."
 71		}
 72
 73		if style == "alert" {
 74			fmt.Printf("  %s⚠ %-14s%s %s %s[%s]%s\n", color, issue.Key, reset, summary, dim, issue.Status, reset)
 75		} else {
 76			fmt.Printf("  %s%-14s%s %-70s %s[%s]%s\n", color, issue.Key, reset, summary, dim, issue.Status, reset)
 77		}
 78	}
 79}
 80
 81// JiraIssuesLimited prints issues with a limit and "N more" indicator.
 82func JiraIssuesLimited(issues []jira.Issue, limit int, style string) {
 83	if len(issues) <= limit {
 84		JiraIssues(issues, style)
 85		return
 86	}
 87	JiraIssues(issues[:limit], style)
 88	fmt.Printf("  %s... and %d more%s\n", dim, len(issues)-limit, reset)
 89}
 90
 91// CVEIssues prints CVE issues grouped by CVE ID with total count.
 92func CVEIssues(grouped []jira.Issue, totalCount int) {
 93	if len(grouped) == 0 {
 94		fmt.Printf("  %s(none)%s\n", dim, reset)
 95		return
 96	}
 97	JiraIssues(grouped, "alert")
 98	if totalCount > len(grouped) {
 99		fmt.Printf("  %s(%d total issues across images — showing first per CVE)%s\n", dim, totalCount, reset)
100	}
101}
102
103// GitHubItems prints GitHub issues or PRs.
104func GitHubItems(items []github.Item, style string) {
105	if len(items) == 0 {
106		fmt.Printf("  %s(none)%s\n", dim, reset)
107		return
108	}
109	for _, item := range items {
110		color := blue
111		switch style {
112		case "review":
113			color = red
114		case "pr":
115			color = green
116		case "done":
117			color = green
118		}
119
120		ref := item.Ref()
121		title := item.Title
122		if len(title) > 60 {
123			title = title[:57] + "..."
124		}
125
126		extra := ""
127		if item.Author != "" {
128			extra = fmt.Sprintf("@%s", item.Author)
129		}
130		dateStr := item.CreatedAt.Format("2006-01-02")
131		if item.ClosedAt != nil {
132			dateStr = item.ClosedAt.Format("2006-01-02")
133		}
134
135		parts := []string{}
136		if extra != "" {
137			parts = append(parts, extra)
138		}
139		parts = append(parts, dateStr)
140
141		fmt.Printf("  %s%-40s%s %s %s(%s)%s\n", color, ref, reset, title, dim, strings.Join(parts, ", "), reset)
142	}
143}
144
145// SecurityAdvisories prints GitHub security advisories.
146func SecurityAdvisories(advisories []github.SecurityAdvisory) {
147	if len(advisories) == 0 {
148		fmt.Printf("  %s(none)%s\n", dim, reset)
149		return
150	}
151	for _, a := range advisories {
152		sevColor := yellow
153		switch a.Severity {
154		case "critical":
155			sevColor = red
156		case "high":
157			sevColor = red
158		case "low":
159			sevColor = dim
160		}
161		ghsa := a.GHSAID
162		if a.CVEID != "" {
163			ghsa = a.CVEID
164		}
165		summary := a.Summary
166		if len(summary) > 60 {
167			summary = summary[:57] + "..."
168		}
169		fmt.Printf("  %s⚠ %-20s%s %-60s %s[%s, %s]%s\n",
170			sevColor, ghsa, reset, summary, dim, a.Severity, a.Repo, reset)
171	}
172}
173
174// DependabotAlerts prints dependabot alerts.
175func DependabotAlerts(alerts []github.DependabotAlert) {
176	if len(alerts) == 0 {
177		fmt.Printf("  %s(none)%s\n", dim, reset)
178		return
179	}
180	for _, a := range alerts {
181		sevColor := yellow
182		switch a.Severity {
183		case "critical", "high":
184			sevColor = red
185		case "low":
186			sevColor = dim
187		}
188		id := a.CVE
189		if id == "" {
190			id = fmt.Sprintf("#%d", a.Number)
191		}
192		summary := a.Summary
193		if len(summary) > 55 {
194			summary = summary[:52] + "..."
195		}
196		fmt.Printf("  %s⚠ %-20s%s %-55s %s[%s, %s, %s]%s\n",
197			sevColor, id, reset, summary, dim, a.Severity, a.Package, a.Repo, reset)
198	}
199}
200
201// OrgDoneItems prints completed org tasks, grouped by section.
202func OrgDoneItems(items []org.DoneItem) {
203	if len(items) == 0 {
204		fmt.Printf("  %s(none)%s\n", dim, reset)
205		return
206	}
207	// Group by section
208	bySection := make(map[string][]org.DoneItem)
209	var order []string
210	for _, item := range items {
211		section := item.Section
212		if section == "" {
213			section = "(uncategorized)"
214		}
215		if _, exists := bySection[section]; !exists {
216			order = append(order, section)
217		}
218		bySection[section] = append(bySection[section], item)
219	}
220	for _, section := range order {
221		sectionItems := bySection[section]
222		fmt.Printf("  %s%s%s%s\n", dim, bold, section, reset)
223		for _, item := range sectionItems {
224			title := item.Title
225			if len(title) > 65 {
226				title = title[:62] + "..."
227			}
228			fmt.Printf("    %s✓%s %s %s(%s)%s\n",
229				green, reset, title, dim, item.CompletedAt.Format("2006-01-02 15:04"), reset)
230		}
231	}
232}
233
234// AIItems prints AI session/learning/research items grouped by type.
235// Sessions are shown as a compact summary (count per day + notable titles).
236// Learnings and research are shown individually.
237func AIItems(items []ai.Item) {
238	if len(items) == 0 {
239		fmt.Printf("  %s(none)%s\n", dim, reset)
240		return
241	}
242	// Separate by type
243	var sessions, learnings, research []ai.Item
244	for _, item := range items {
245		// Skip auto-recovered noise
246		if strings.Contains(strings.ToLower(item.Title), "auto-recovered") {
247			continue
248		}
249		switch item.Type {
250		case "session":
251			sessions = append(sessions, item)
252		case "learning":
253			learnings = append(learnings, item)
254		case "research":
255			research = append(research, item)
256		}
257	}
258
259	if len(sessions) > 0 {
260		// Group sessions by date and show counts + notable titles
261		byDate := make(map[string][]ai.Item)
262		var dates []string
263		for _, s := range sessions {
264			d := s.Date.Format("2006-01-02")
265			if _, exists := byDate[d]; !exists {
266				dates = append(dates, d)
267			}
268			byDate[d] = append(byDate[d], s)
269		}
270		fmt.Printf("  %s%sSessions (%d total)%s\n", dim, bold, len(sessions), reset)
271		for _, d := range dates {
272			daySessions := byDate[d]
273			// Show up to 3 notable titles per day
274			fmt.Printf("    %s%s%s — %d sessions\n", cyan, d, reset, len(daySessions))
275			shown := 0
276			for _, s := range daySessions {
277				if shown >= 3 {
278					remaining := len(daySessions) - shown
279					if remaining > 0 {
280						fmt.Printf("      %s... and %d more%s\n", dim, remaining, reset)
281					}
282					break
283				}
284				title := s.Title
285				// Strip "Session: " prefix if present
286				title = strings.TrimPrefix(title, "Session: ")
287				if len(title) > 55 {
288					title = title[:52] + "..."
289				}
290				project := ""
291				if s.Project != "" {
292					project = fmt.Sprintf(" %s[%s]%s", dim, s.Project, reset)
293				}
294				fmt.Printf("      • %s%s\n", title, project)
295				shown++
296			}
297		}
298	}
299
300	printAIList := func(label string, items []ai.Item) {
301		if len(items) == 0 {
302			return
303		}
304		fmt.Printf("  %s%s%s (%d)%s\n", dim, bold, label, len(items), reset)
305		for _, item := range items {
306			title := item.Title
307			if len(title) > 60 {
308				title = title[:57] + "..."
309			}
310			fmt.Printf("    %s•%s %s %s%s%s\n",
311				cyan, reset, title, dim, item.Date.Format("2006-01-02"), reset)
312		}
313	}
314
315	printAIList("Learnings", learnings)
316	printAIList("Research", research)
317}
318
319// DiscussionItems prints GitHub discussions.
320func DiscussionItems(items []github.DiscussionItem) {
321	if len(items) == 0 {
322		fmt.Printf("  %s(none)%s\n", dim, reset)
323		return
324	}
325	for _, item := range items {
326		color := cyan
327		if item.Type == "discussion_comment" {
328			color = blue
329		}
330		title := item.Title
331		if len(title) > 60 {
332			title = title[:57] + "..."
333		}
334		cat := ""
335		if item.Category != "" {
336			cat = fmt.Sprintf("[%s] ", item.Category)
337		}
338		fmt.Printf("  %s%-30s%s %s%s %s(%s)%s\n",
339			color, item.Repo, reset, cat, title, dim, item.CreatedAt.Format("2006-01-02"), reset)
340	}
341}
342
343// CommentItems prints GitHub issue/PR comments.
344func CommentItems(items []github.CommentItem) {
345	if len(items) == 0 {
346		fmt.Printf("  %s(none)%s\n", dim, reset)
347		return
348	}
349	for _, item := range items {
350		title := item.IssueTitle
351		if len(title) > 55 {
352			title = title[:52] + "..."
353		}
354		ref := fmt.Sprintf("%s#%d", item.Repo, item.IssueNumber)
355		fmt.Printf("  %s%-40s%s %s %s(%s)%s\n",
356			blue, ref, reset, title, dim, item.CreatedAt.Format("2006-01-02"), reset)
357	}
358}
359
360// Hint prints a dim hint line.
361func Hint(msg string) {
362	fmt.Printf("\n%s%s%s\n", dim, msg, reset)
363}