Skip to content
40 changes: 40 additions & 0 deletions cmd/break-reminder/check.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package main

import (
"context"
"time"

"github.com/rs/zerolog/log"
"github.com/spf13/cobra"

"github.com/devlikebear/break-reminder/internal/ai"
"github.com/devlikebear/break-reminder/internal/breakscreen"
"github.com/devlikebear/break-reminder/internal/idle"
"github.com/devlikebear/break-reminder/internal/insights"
"github.com/devlikebear/break-reminder/internal/logging"
"github.com/devlikebear/break-reminder/internal/notify"
"github.com/devlikebear/break-reminder/internal/schedule"
Expand Down Expand Up @@ -105,7 +109,43 @@ func executeActions(actions []timer.Action, s state.State, daySummary *timer.Day
BreakMin: daySummary.BreakSeconds / 60,
HourlyWork: hourlyMin,
})

if cfg.AIEnabled {
go generateDailyInsights()
}
}
}
}
}

func generateDailyInsights() {
client := ai.NewClient(cfg.AICLI)
if !client.Available() {
log.Warn().Str("cli", cfg.AICLI).Msg("AI CLI unavailable, skipping insights generation")
return
}

history, err := ai.LoadHistory()
if err != nil {
log.Warn().Err(err).Msg("Load history for insights")
return
}
recent := history
if len(recent) > 7 {
recent = recent[len(recent)-7:]
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

report, err := insights.Generate(ctx, client, recent, time.Now())
if err != nil {
log.Warn().Err(err).Msg("Generate insights")
return
}
if err := insights.Save(report); err != nil {
log.Warn().Err(err).Msg("Save insights")
return
}
log.Info().Msg("Insights auto-generated")
}
91 changes: 91 additions & 0 deletions cmd/break-reminder/insights.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"context"
"fmt"
"time"

"github.com/rs/zerolog/log"
"github.com/spf13/cobra"

"github.com/devlikebear/break-reminder/internal/ai"
"github.com/devlikebear/break-reminder/internal/insights"
)

func newInsightsCmd() *cobra.Command {
var refresh bool

cmd := &cobra.Command{
Use: "insights",
Short: "Show or refresh AI insights",
RunE: func(cmd *cobra.Command, args []string) error {
if refresh {
return refreshInsights()
}
return showInsights()
},
}

cmd.Flags().BoolVar(&refresh, "refresh", false, "Force regenerate insights via AI CLI")
return cmd
}

func refreshInsights() error {
if !cfg.AIEnabled {
return fmt.Errorf("AI is disabled in config (set ai_enabled: true)")
}

client := ai.NewClient(cfg.AICLI)
if !client.Available() {
return fmt.Errorf("AI CLI %q not found in PATH", cfg.AICLI)
}

history, err := ai.LoadHistory()
if err != nil {
return fmt.Errorf("load history: %w", err)
}

recent := trimRecentHistory(history, 7)

log.Info().Int("entries", len(recent)).Msg("Generating AI insights")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

report, err := insights.Generate(ctx, client, recent, time.Now())
if err != nil {
return fmt.Errorf("generate: %w", err)
}

if err := insights.Save(report); err != nil {
return fmt.Errorf("save: %w", err)
}

fmt.Println("Insights refreshed:")
fmt.Println(report.DailyReport)
return nil
}

func showInsights() error {
report, err := insights.Load()
if err != nil {
return err
}
if report == nil {
fmt.Println("No insights yet. Run with --refresh to generate.")
return nil
}
fmt.Printf("Generated: %s\n\n", report.GeneratedAt)
fmt.Println(report.DailyReport)
fmt.Println()
for _, p := range report.Patterns {
fmt.Printf("[%s] %s\n %s\n → %s\n\n", p.Type, p.Title, p.Description, p.Suggestion)
}
return nil
}

func trimRecentHistory(history []ai.DailySummary, days int) []ai.DailySummary {
if len(history) <= days {
return history
}
return history[len(history)-days:]
}
1 change: 1 addition & 0 deletions cmd/break-reminder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func newRootCmd() *cobra.Command {
newPauseCmd(),
newResumeCmd(),
newDoctorCmd(),
newInsightsCmd(),
newServiceCmd(),
newBreakCmd(),
newSnoozeCmd(),
Expand Down
2 changes: 1 addition & 1 deletion helpers/Sources/DashboardApp/DashboardAppMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ struct DashboardContentView: View {
case .stats:
StatsTabView(vm: vm)
case .insights:
InsightsTabView()
InsightsTabView(vm: vm)
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions helpers/Sources/DashboardApp/DashboardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ final class DashboardViewModel: ObservableObject {
@Published var launchdStatusText: String = "Unknown"
@Published var selectedTab: DashboardTab = .timer
@Published var history: [HistoryEntry] = []
@Published var insights: InsightsReport?
@Published var isRefreshingInsights = false

private var timer: Timer?

Expand Down Expand Up @@ -61,6 +63,7 @@ final class DashboardViewModel: ObservableObject {
func start() {
refresh()
loadHistory()
loadInsights()
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refresh()
Expand All @@ -79,12 +82,50 @@ final class DashboardViewModel: ObservableObject {
idleSeconds = getIdleSecondsFromSystem()
launchdStatusText = queryLaunchdStatus()
loadHistory()
loadInsights()
}

func loadHistory() {
history = loadHistoryFromDisk()
}

func loadInsights() {
insights = loadInsightsFromDisk()
}

func refreshInsights() {
guard !isRefreshingInsights else { return }
isRefreshingInsights = true

Task.detached { [weak self] in
await self?.runInsightsRefresh()
}
}

@MainActor
private func runInsightsRefresh() async {
defer { isRefreshingInsights = false }

guard let cli = findHelper("break-reminder") else {
return
}

let process = Process()
process.launchPath = cli
process.arguments = ["insights", "--refresh"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice

do {
try process.run()
process.waitUntilExit()
} catch {
return
}

loadInsights()
}

func resetTimer() {
let totals = dailyTotals
var s = AppState()
Expand Down
Loading
Loading