@@ -5,14 +5,18 @@ package app
55
66import (
77 "cmp"
8+ "context"
9+ "encoding/json"
810 "fmt"
911 "slices"
1012 "strings"
1113 "time"
1214
15+ tea "github.com/charmbracelet/bubbletea"
1316 "github.com/charmbracelet/lipgloss"
1417 "github.com/charmbracelet/x/ansi"
1518 "github.com/cpcloud/micasa/internal/data"
19+ "github.com/cpcloud/micasa/internal/llm"
1620)
1721
1822// Dashboard section title constants.
@@ -22,6 +26,7 @@ const (
2226 dashSectionUpcoming = "Upcoming"
2327 dashSectionProjects = "Active Projects"
2428 dashSectionExpiring = "Expiring Soon"
29+ dashSectionInsights = "Insights"
2530)
2631
2732// ---------------------------------------------------------------------------
@@ -398,6 +403,19 @@ func (m *Model) buildDashNav() {
398403 }
399404 add (dashSectionExpiring , expiring )
400405
406+ // Insights: LLM-generated items with navigation targets.
407+ if insRows := m .dashInsightsRows (); len (insRows ) > 0 {
408+ entries := make ([]dashNavEntry , len (insRows ))
409+ for i , row := range insRows {
410+ if row .Target != nil {
411+ entries [i ] = * row .Target
412+ } else {
413+ entries [i ] = dashNavEntry {Section : dashSectionInsights , InfoOnly : true }
414+ }
415+ }
416+ add (dashSectionInsights , entries )
417+ }
418+
401419 var nav []dashNavEntry
402420 for _ , g := range groups {
403421 nav = append (nav , dashNavEntry {
@@ -491,7 +509,19 @@ func (m *Model) dashboardView(budget, maxWidth int) string {
491509 })
492510 }
493511
494- if len (sections ) == 0 {
512+ if insRows := m .dashInsightsRows (); len (insRows ) > 0 {
513+ sections = append (sections , dashSection {
514+ title : dashSectionInsights ,
515+ headers : []string {"" , "tab" },
516+ rows : insRows ,
517+ })
518+ }
519+
520+ // Show loading/error state for insights even when there are no items yet.
521+ showInsightsLoading := m .insightsEnabled && m .dash .insights .loading
522+ showInsightsError := m .insightsEnabled && m .dash .insights .err != nil && ! m .dash .insights .loading
523+
524+ if len (sections ) == 0 && ! showInsightsLoading && ! showInsightsError {
495525 return ""
496526 }
497527
@@ -552,6 +582,37 @@ func (m *Model) dashboardView(budget, maxWidth int) string {
552582 navIdx = dataNavIdx
553583 }
554584
585+ // Insights loading/error indicator (shown even when no insight items exist yet).
586+ if showInsightsLoading {
587+ if len (lines ) > 0 {
588+ lines = append (lines , "" )
589+ }
590+ dimmed := cursorSection != "" && cursorSection != dashSectionInsights
591+ hdr := m .dashSectionHeader (dashSectionInsights , 0 , dimmed )
592+ lines = append (lines , hdr , " " + m .dash .spinner .View ()+ " analyzing..." )
593+ } else if showInsightsError {
594+ if len (lines ) > 0 {
595+ lines = append (lines , "" )
596+ }
597+ dimmed := cursorSection != "" && cursorSection != dashSectionInsights
598+ hdr := m .dashSectionHeader (dashSectionInsights , 0 , dimmed )
599+ lines = append (lines , hdr , " " + m .styles .Error ().Render ("error: " + m .dash .insights .err .Error ()))
600+ }
601+
602+ // Staleness indicator for insights section header.
603+ if m .insightsEnabled && ! m .dash .insights .loading && len (m .dash .insights .items ) > 0 {
604+ // Append staleness to the insights header line (already in lines).
605+ staleness := m .insightsStaleness ()
606+ if staleness != "" {
607+ for i := len (lines ) - 1 ; i >= 0 ; i -- {
608+ if strings .Contains (lines [i ], dashSectionInsights ) {
609+ lines [i ] += " " + m .styles .DashLabel ().Render (staleness )
610+ break
611+ }
612+ }
613+ }
614+ }
615+
555616 // Scroll windowing: show only `budget` lines, following the cursor.
556617 // Reserve lines for scroll indicators (▲/▼) so content is never clipped
557618 // without feedback. Iterate to convergence since reserving indicator
@@ -869,6 +930,187 @@ func (m *Model) dashToggleAll() {
869930 }
870931}
871932
933+ // ---------------------------------------------------------------------------
934+ // Proactive insights (LLM-powered)
935+ // ---------------------------------------------------------------------------
936+
937+ // insightsResultMsg delivers the result of an async insights fetch.
938+ type insightsResultMsg struct {
939+ Items []insightItem
940+ Err error
941+ }
942+
943+ // insightsWanted reports whether insights should be shown on the dashboard.
944+ func (m * Model ) insightsWanted () bool {
945+ return m .insightsEnabled && m .llmClient != nil
946+ }
947+
948+ // fetchInsights starts an async LLM call to generate proactive insights.
949+ // Returns a tea.Cmd that resolves to insightsResultMsg. The returned cmd
950+ // runs in a background goroutine.
951+ func (m * Model ) fetchInsights () tea.Cmd {
952+ if ! m .insightsWanted () {
953+ return nil
954+ }
955+
956+ // Cancel any in-flight request.
957+ if m .dash .insights .cancel != nil {
958+ m .dash .insights .cancel ()
959+ }
960+
961+ m .dash .insights .loading = true
962+ m .dash .insights .err = nil
963+
964+ client := m .llmClient
965+ store := m .store
966+ extraContext := m .llmExtraContext
967+
968+ ctx , cancel := context .WithCancel (context .Background ())
969+ m .dash .insights .cancel = cancel
970+
971+ return func () tea.Msg {
972+ dataSummary := ""
973+ if store != nil {
974+ dataSummary = store .DataDump ()
975+ }
976+ if dataSummary == "" {
977+ return insightsResultMsg {Err : fmt .Errorf ("no data to analyze" )}
978+ }
979+
980+ prompt := llm .BuildInsightsPrompt (dataSummary , time .Now (), extraContext )
981+ messages := []llm.Message {
982+ {Role : "system" , Content : prompt },
983+ {Role : "user" , Content : "Analyze my home data and provide proactive insights." },
984+ }
985+
986+ raw , err := client .ChatComplete (
987+ ctx , messages ,
988+ llm .WithJSONSchema ("insights" , llm .InsightsJSONSchema ()),
989+ )
990+ if err != nil {
991+ return insightsResultMsg {Err : err }
992+ }
993+
994+ var result struct {
995+ Insights []insightItem `json:"insights"`
996+ }
997+ if err := json .Unmarshal ([]byte (raw ), & result ); err != nil {
998+ return insightsResultMsg {Err : fmt .Errorf ("parse insights: %w" , err )}
999+ }
1000+
1001+ return insightsResultMsg {Items : result .Insights }
1002+ }
1003+ }
1004+
1005+ // refreshInsights cancels any in-flight request and starts a fresh fetch.
1006+ func (m * Model ) refreshInsights () tea.Cmd {
1007+ m .dash .insights .stale = true
1008+ return m .maybeStartInsights ()
1009+ }
1010+
1011+ // maybeStartInsights starts an insights fetch if needed (stale/absent and
1012+ // not already loading).
1013+ func (m * Model ) maybeStartInsights () tea.Cmd {
1014+ if ! m .insightsWanted () {
1015+ return nil
1016+ }
1017+ if m .dash .insights .loading {
1018+ return nil
1019+ }
1020+ // Already have fresh results.
1021+ if len (m .dash .insights .items ) > 0 && ! m .dash .insights .stale {
1022+ return nil
1023+ }
1024+ return tea .Batch (m .fetchInsights (), m .dash .spinner .Tick )
1025+ }
1026+
1027+ // markInsightsStale flags insights for refresh on next dashboard open.
1028+ func (m * Model ) markInsightsStale () {
1029+ m .dash .insights .stale = true
1030+ }
1031+
1032+ // tabKindFromString maps a tab name string to a TabKind.
1033+ func tabKindFromString (s string ) (TabKind , bool ) {
1034+ switch s {
1035+ case "projects" :
1036+ return tabProjects , true
1037+ case "quotes" :
1038+ return tabQuotes , true
1039+ case "maintenance" :
1040+ return tabMaintenance , true
1041+ case "incidents" :
1042+ return tabIncidents , true
1043+ case "appliances" :
1044+ return tabAppliances , true
1045+ case "vendors" :
1046+ return tabVendors , true
1047+ case "documents" :
1048+ return tabDocuments , true
1049+ }
1050+ return 0 , false
1051+ }
1052+
1053+ // tabAbbrev returns a short 5-char abbreviation for a tab name.
1054+ func tabAbbrev (tab string ) string {
1055+ switch tab {
1056+ case "projects" :
1057+ return "Proj."
1058+ case "quotes" :
1059+ return "Quot."
1060+ case "maintenance" :
1061+ return "Mnt."
1062+ case "incidents" :
1063+ return "Inc."
1064+ case "appliances" :
1065+ return "Appl."
1066+ case "vendors" :
1067+ return "Vend."
1068+ case "documents" :
1069+ return "Docs."
1070+ }
1071+ return tab
1072+ }
1073+
1074+ // dashInsightsRows returns dashboard rows for the insights section.
1075+ func (m * Model ) dashInsightsRows () []dashRow {
1076+ ins := m .dash .insights
1077+ if ins .loading || len (ins .items ) == 0 {
1078+ return nil
1079+ }
1080+ rows := make ([]dashRow , 0 , len (ins .items ))
1081+ for _ , item := range ins .items {
1082+ target := & dashNavEntry {Section : dashSectionInsights , InfoOnly : true }
1083+ if tab , ok := tabKindFromString (item .Tab ); ok {
1084+ target = & dashNavEntry {
1085+ Tab : tab ,
1086+ ID : item .EntityID ,
1087+ Section : dashSectionInsights ,
1088+ }
1089+ if item .EntityID == 0 {
1090+ target .InfoOnly = true
1091+ }
1092+ }
1093+ rows = append (rows , dashRow {
1094+ Cells : []dashCell {
1095+ {Text : item .Text , Style : m .styles .DashValue ()},
1096+ {Text : tabAbbrev (item .Tab ), Style : m .styles .DashLabel (), Align : alignRight },
1097+ },
1098+ Target : target ,
1099+ })
1100+ }
1101+ return rows
1102+ }
1103+
1104+ // insightsStaleness returns a human-readable staleness indicator.
1105+ func (m * Model ) insightsStaleness () string {
1106+ ins := m .dash .insights
1107+ if ins .generatedAt .IsZero () {
1108+ return ""
1109+ }
1110+ d := time .Since (ins .generatedAt )
1111+ return shortDur (d ) + " ago"
1112+ }
1113+
8721114// ---------------------------------------------------------------------------
8731115// Utility helpers
8741116// ---------------------------------------------------------------------------
0 commit comments