diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..bd19b4f --- /dev/null +++ b/db/db.go @@ -0,0 +1,209 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +type Alarm struct { + ID int + Name string + Description string + Time string // HH:MM format for simple alarms + Trigger string // Cron expression or "once" + SoundPath string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time + LastTriggered *time.Time + SnoozeCount int +} + +type AlarmHistory struct { + ID int + AlarmID int + TriggeredAt time.Time + DismissedAt *time.Time + SnoozedCount int +} + +type Store struct { + db *sql.DB +} + +func Open() (*Store, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("get home dir: %w", err) + } + + dir := filepath.Join(home, ".config", "woke") + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create config dir: %w", err) + } + + dbPath := filepath.Join(dir, "woke.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + store := &Store{db: db} + if err := store.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return store, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS alarms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + time TEXT DEFAULT '', + trigger TEXT NOT NULL, + sound_path TEXT NOT NULL DEFAULT 'default', + enabled BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_triggered TIMESTAMP, + snooze_count INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS alarm_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alarm_id INTEGER, + triggered_at TIMESTAMP, + dismissed_at TIMESTAMP, + snoozed_count INTEGER DEFAULT 0, + FOREIGN KEY (alarm_id) REFERENCES alarms(id) + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + `) + return err +} + +func (s *Store) ListAlarms() ([]Alarm, error) { + rows, err := s.db.Query(` + SELECT id, name, description, time, trigger, sound_path, enabled, + created_at, updated_at, last_triggered, snooze_count + FROM alarms ORDER BY time ASC, name ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var alarms []Alarm + for rows.Next() { + var a Alarm + var lastTriggered sql.NullTime + err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Time, &a.Trigger, + &a.SoundPath, &a.Enabled, &a.CreatedAt, &a.UpdatedAt, + &lastTriggered, &a.SnoozeCount) + if err != nil { + return nil, err + } + if lastTriggered.Valid { + a.LastTriggered = &lastTriggered.Time + } + alarms = append(alarms, a) + } + return alarms, rows.Err() +} + +func (s *Store) GetAlarm(id int) (Alarm, error) { + var a Alarm + var lastTriggered sql.NullTime + err := s.db.QueryRow(` + SELECT id, name, description, time, trigger, sound_path, enabled, + created_at, updated_at, last_triggered, snooze_count + FROM alarms WHERE id = ? + `, id).Scan(&a.ID, &a.Name, &a.Description, &a.Time, &a.Trigger, + &a.SoundPath, &a.Enabled, &a.CreatedAt, &a.UpdatedAt, + &lastTriggered, &a.SnoozeCount) + if lastTriggered.Valid { + a.LastTriggered = &lastTriggered.Time + } + return a, err +} + +func (s *Store) CreateAlarm(a Alarm) (int, error) { + result, err := s.db.Exec(` + INSERT INTO alarms (name, description, time, trigger, sound_path, enabled) + VALUES (?, ?, ?, ?, ?, ?) + `, a.Name, a.Description, a.Time, a.Trigger, a.SoundPath, a.Enabled) + if err != nil { + return 0, err + } + id, err := result.LastInsertId() + return int(id), err +} + +func (s *Store) UpdateAlarm(a Alarm) error { + _, err := s.db.Exec(` + UPDATE alarms SET name=?, description=?, time=?, trigger=?, sound_path=?, + enabled=?, updated_at=CURRENT_TIMESTAMP + WHERE id=? + `, a.Name, a.Description, a.Time, a.Trigger, a.SoundPath, a.Enabled, a.ID) + return err +} + +func (s *Store) DeleteAlarm(id int) error { + _, err := s.db.Exec(`DELETE FROM alarms WHERE id=?`, id) + return err +} + +func (s *Store) ToggleAlarm(id int) error { + _, err := s.db.Exec(` + UPDATE alarms SET enabled = NOT enabled, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, id) + return err +} + +func (s *Store) MarkTriggered(id int) error { + now := time.Now() + _, err := s.db.Exec(` + UPDATE alarms SET last_triggered = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, now, id) + if err != nil { + return err + } + _, err = s.db.Exec(` + INSERT INTO alarm_history (alarm_id, triggered_at) VALUES (?, ?) + `, id, now) + return err +} + +func (s *Store) GetSetting(key, defaultVal string) string { + var val string + err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&val) + if err != nil { + return defaultVal + } + return val +} + +func (s *Store) SetSetting(key, value string) error { + _, err := s.db.Exec(` + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `, key, value) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2e666a8 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module woke + +go 1.25.5 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.3.8 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.44.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a7d12 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..df122b3 --- /dev/null +++ b/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + "woke/db" + "woke/player" + "woke/scheduler" + "woke/ui" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + store, err := db.Open() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err) + os.Exit(1) + } + defer store.Close() + + pl := player.New() + sched := scheduler.New(store) + sched.Start() + defer sched.Stop() + + model := ui.NewModel(store, sched, pl) + p := tea.NewProgram(model, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/player/player.go b/player/player.go new file mode 100644 index 0000000..832eeee --- /dev/null +++ b/player/player.go @@ -0,0 +1,102 @@ +package player + +import ( + "os" + "os/exec" + "sync" +) + +// Player handles alarm sound playback by shelling out to system audio tools. +type Player struct { + mu sync.Mutex + cmd *exec.Cmd + playing bool +} + +func New() *Player { + return &Player{} +} + +// Play starts playing a sound file. If path is "default", it uses a system beep. +func (p *Player) Play(path string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.stop() + + if path == "default" || path == "" { + // Try common system sounds, fall back to terminal bell + candidates := []string{ + "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + "/usr/share/sounds/gnome/default/alerts/bark.ogg", + "/usr/share/sounds/sound-icons/trumpet-12.wav", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + path = c + break + } + } + if path == "default" || path == "" { + // No sound files found — use terminal bell in a loop + go func() { + for i := 0; i < 10; i++ { + os.Stdout.WriteString("\a") + } + }() + return + } + } + + // Try players in order of preference + players := []struct { + name string + args func(string) []string + }{ + {"paplay", func(f string) []string { return []string{f} }}, + {"aplay", func(f string) []string { return []string{f} }}, + {"mpv", func(f string) []string { return []string{"--no-video", "--loop=inf", f} }}, + {"ffplay", func(f string) []string { return []string{"-nodisp", "-autoexit", "-loop", "0", f} }}, + } + + for _, pl := range players { + if _, err := exec.LookPath(pl.name); err == nil { + p.cmd = exec.Command(pl.name, pl.args(path)...) + p.cmd.Stdout = nil + p.cmd.Stderr = nil + p.playing = true + go func() { + _ = p.cmd.Run() + p.mu.Lock() + p.playing = false + p.mu.Unlock() + }() + return + } + } + + // Absolute fallback: terminal bell + os.Stdout.WriteString("\a") +} + +// Stop stops any currently playing sound. +func (p *Player) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + p.stop() +} + +func (p *Player) stop() { + if p.cmd != nil && p.cmd.Process != nil && p.playing { + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() + p.playing = false + p.cmd = nil + } +} + +func (p *Player) IsPlaying() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.playing +} diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go new file mode 100644 index 0000000..d8abcb3 --- /dev/null +++ b/scheduler/scheduler.go @@ -0,0 +1,128 @@ +package scheduler + +import ( + "sync" + "time" + "woke/db" + + "github.com/robfig/cron/v3" +) + +// AlarmEvent is sent when an alarm triggers. +type AlarmEvent struct { + Alarm db.Alarm +} + +// Scheduler checks alarms every second and fires events when they trigger. +type Scheduler struct { + store *db.Store + events chan AlarmEvent + stop chan struct{} + mu sync.Mutex + parser cron.Parser +} + +func New(store *db.Store) *Scheduler { + return &Scheduler{ + store: store, + events: make(chan AlarmEvent, 10), + stop: make(chan struct{}), + parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow), + } +} + +// Events returns the channel that receives alarm trigger events. +func (s *Scheduler) Events() <-chan AlarmEvent { + return s.events +} + +// Start begins the alarm checking loop. +func (s *Scheduler) Start() { + go s.loop() +} + +// Stop halts the scheduler. +func (s *Scheduler) Stop() { + close(s.stop) +} + +func (s *Scheduler) loop() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + // Track which alarms already fired this minute to avoid duplicates + firedThisMinute := make(map[int]string) // alarm ID -> "HH:MM" when last fired + + for { + select { + case <-s.stop: + return + case now := <-ticker.C: + s.check(now, firedThisMinute) + } + } +} + +func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) { + alarms, err := s.store.ListAlarms() + if err != nil { + return + } + + currentMinute := now.Format("15:04") + + // Clean up old entries from firedThisMinute + for id, minute := range firedThisMinute { + if minute != currentMinute { + delete(firedThisMinute, id) + } + } + + for _, a := range alarms { + if !a.Enabled { + continue + } + + // Already fired this minute + if firedThisMinute[a.ID] == currentMinute { + continue + } + + if s.shouldTrigger(a, now) { + firedThisMinute[a.ID] = currentMinute + _ = s.store.MarkTriggered(a.ID) + + // Disable one-shot alarms after triggering + if a.Trigger == "once" { + _ = s.store.ToggleAlarm(a.ID) + } + + select { + case s.events <- AlarmEvent{Alarm: a}: + default: + // Don't block if channel is full + } + } + } +} + +func (s *Scheduler) shouldTrigger(a db.Alarm, now time.Time) bool { + if a.Trigger == "once" { + // Simple time match: HH:MM + return now.Format("15:04") == a.Time + } + + // Cron expression + sched, err := s.parser.Parse(a.Trigger) + if err != nil { + return false + } + + // Check if now falls within the current minute of the cron schedule. + // We truncate to the minute and check if the next time after (now - 1min) is now. + truncated := now.Truncate(time.Minute) + prev := truncated.Add(-time.Minute) + next := sched.Next(prev) + + return next.Equal(truncated) +} diff --git a/ui/clock.go b/ui/clock.go new file mode 100644 index 0000000..1fa7796 --- /dev/null +++ b/ui/clock.go @@ -0,0 +1,161 @@ +package ui + +import ( + "fmt" + "strings" + "time" +) + +// Each digit is 8 chars wide x 7 lines tall, using block characters. +var bigDigits = [10][]string{ + // 0 + { + " ██████ ", + "██ ██", + "██ ████", + "██ ██ ██", + "████ ██", + "██ ██", + " ██████ ", + }, + // 1 + { + " ██ ", + " ████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██████", + }, + // 2 + { + " ██████ ", + "██ ██", + " ██", + " █████ ", + "██ ", + "██ ██", + "████████", + }, + // 3 + { + " ██████ ", + "██ ██", + " ██", + " █████ ", + " ██", + "██ ██", + " ██████ ", + }, + // 4 + { + "██ ██", + "██ ██", + "██ ██", + "████████", + " ██", + " ██", + " ██", + }, + // 5 + { + "████████", + "██ ", + "██ ", + "███████ ", + " ██", + "██ ██", + " ██████ ", + }, + // 6 + { + " ██████ ", + "██ ", + "██ ", + "███████ ", + "██ ██", + "██ ██", + " ██████ ", + }, + // 7 + { + "████████", + "██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + }, + // 8 + { + " ██████ ", + "██ ██", + "██ ██", + " ██████ ", + "██ ██", + "██ ██", + " ██████ ", + }, + // 9 + { + " ██████ ", + "██ ██", + "██ ██", + " ███████", + " ██", + " ██", + " ██████ ", + }, +} + +var bigColon = []string{ + " ", + " ██ ", + " ██ ", + " ", + " ██ ", + " ██ ", + " ", +} + +var bigColonBlink = []string{ + " ", + " ", + " ", + " ", + " ", + " ", + " ", +} + +// RenderBigClock renders the current time as massive ASCII block digits. +// Format: HH:MM:SS in 24h. The colon blinks every second. +func RenderBigClock(t time.Time) string { + h := t.Hour() + m := t.Minute() + s := t.Second() + + timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s) + + var lines [7]string + for i := range lines { + var parts []string + for _, ch := range timeStr { + switch { + case ch >= '0' && ch <= '9': + parts = append(parts, bigDigits[ch-'0'][i]) + case ch == ':': + if s%2 == 0 { + parts = append(parts, bigColon[i]) + } else { + parts = append(parts, bigColonBlink[i]) + } + } + } + lines[i] = strings.Join(parts, " ") + } + + return strings.Join(lines[:], "\n") +} diff --git a/ui/form.go b/ui/form.go new file mode 100644 index 0000000..daadc3e --- /dev/null +++ b/ui/form.go @@ -0,0 +1,215 @@ +package ui + +import ( + "fmt" + "strings" + "woke/db" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/robfig/cron/v3" +) + +type formField int + +const ( + fieldName formField = iota + fieldDescription + fieldTime + fieldTrigger + fieldSound + fieldCount // sentinel +) + +type formModel struct { + editing *db.Alarm // nil = creating new + fields [fieldCount]string + active formField + err string +} + +func newFormModel(alarm *db.Alarm) *formModel { + f := &formModel{} + if alarm != nil { + f.editing = alarm + f.fields[fieldName] = alarm.Name + f.fields[fieldDescription] = alarm.Description + f.fields[fieldTime] = alarm.Time + f.fields[fieldTrigger] = alarm.Trigger + f.fields[fieldSound] = alarm.SoundPath + } else { + f.fields[fieldTrigger] = "once" + f.fields[fieldSound] = "default" + } + return f +} + +func (f *formModel) Update(msg tea.Msg, m *Model) (tea.Model, tea.Cmd) { + return *m, nil +} + +func (f *formModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) { + key := msg.String() + + switch key { + case "esc", "escape": + m.mode = viewMain + m.form = nil + m.statusMsg = "" + return *m, nil + + case "tab", "down": + f.active = (f.active + 1) % fieldCount + return *m, nil + + case "shift+tab", "up": + f.active = (f.active - 1 + fieldCount) % fieldCount + return *m, nil + + case "enter": + return f.save(m) + + case "backspace": + field := &f.fields[f.active] + if len(*field) > 0 { + *field = (*field)[:len(*field)-1] + } + f.err = "" + return *m, nil + + case "ctrl+u": + f.fields[f.active] = "" + f.err = "" + return *m, nil + + default: + // Only accept printable characters + if len(key) == 1 { + f.fields[f.active] += key + f.err = "" + } + return *m, nil + } +} + +func (f *formModel) save(m *Model) (tea.Model, tea.Cmd) { + name := strings.TrimSpace(f.fields[fieldName]) + if name == "" { + f.err = "Name is required" + return *m, nil + } + + timeStr := strings.TrimSpace(f.fields[fieldTime]) + trigger := strings.TrimSpace(f.fields[fieldTrigger]) + + if trigger == "once" || trigger == "" { + trigger = "once" + // Validate HH:MM format + if !isValidTime(timeStr) { + f.err = "Time must be in HH:MM format (00:00-23:59)" + return *m, nil + } + } else { + // Validate cron expression + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + if _, err := parser.Parse(trigger); err != nil { + f.err = fmt.Sprintf("Invalid cron expression: %v", err) + return *m, nil + } + } + + soundPath := strings.TrimSpace(f.fields[fieldSound]) + if soundPath == "" { + soundPath = "default" + } + + alarm := db.Alarm{ + Name: name, + Description: strings.TrimSpace(f.fields[fieldDescription]), + Time: timeStr, + Trigger: trigger, + SoundPath: soundPath, + Enabled: true, + } + + if f.editing != nil { + alarm.ID = f.editing.ID + alarm.Enabled = f.editing.Enabled + if err := m.store.UpdateAlarm(alarm); err != nil { + f.err = fmt.Sprintf("Save failed: %v", err) + return *m, nil + } + m.statusMsg = "Alarm updated" + } else { + if _, err := m.store.CreateAlarm(alarm); err != nil { + f.err = fmt.Sprintf("Save failed: %v", err) + return *m, nil + } + m.statusMsg = "Alarm created" + } + + m.refreshAlarms() + m.mode = viewMain + m.form = nil + return *m, nil +} + +func (f *formModel) View() string { + title := "Add Alarm" + if f.editing != nil { + title = "Edit Alarm" + } + + labels := [fieldCount]string{ + "Name: ", + "Description: ", + "Time (HH:MM):", + "Trigger: ", + "Sound: ", + } + + hints := [fieldCount]string{ + "", + "(optional)", + "e.g. 07:30", + "'once' or cron: 30 7 * * 1-5", + "'default' or path to sound file", + } + + var lines []string + lines = append(lines, TitleStyle.Render(title), "") + + for i := formField(0); i < fieldCount; i++ { + label := FormLabelStyle.Render(labels[i]) + value := f.fields[i] + hint := HelpStyle.Render(hints[i]) + + var style lipgloss.Style + if i == f.active { + style = FormActiveStyle + value += "█" // cursor + } else { + style = FormLabelStyle + } + + line := fmt.Sprintf(" %s %s %s", label, style.Render(value), hint) + lines = append(lines, line) + } + + if f.err != "" { + lines = append(lines, "", FormErrorStyle.Render(" Error: "+f.err)) + } + + return strings.Join(lines, "\n") +} + +func isValidTime(s string) bool { + if len(s) != 5 || s[2] != ':' { + return false + } + h := (s[0]-'0')*10 + (s[1] - '0') + m := (s[3]-'0')*10 + (s[4] - '0') + return h <= 23 && m <= 59 && s[0] >= '0' && s[0] <= '9' && + s[1] >= '0' && s[1] <= '9' && s[3] >= '0' && s[3] <= '9' && + s[4] >= '0' && s[4] <= '9' +} diff --git a/ui/model.go b/ui/model.go new file mode 100644 index 0000000..10b17fe --- /dev/null +++ b/ui/model.go @@ -0,0 +1,347 @@ +package ui + +import ( + "fmt" + "strings" + "time" + "woke/db" + "woke/player" + "woke/scheduler" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// View modes +type viewMode int + +const ( + viewMain viewMode = iota + viewForm + viewConfirmDelete +) + +// Messages +type tickMsg time.Time +type alarmFiredMsg scheduler.AlarmEvent + +// Model is the main bubbletea model. +type Model struct { + store *db.Store + scheduler *scheduler.Scheduler + player *player.Player + + // State + alarms []db.Alarm + cursor int + mode viewMode + width int + height int + now time.Time + + // Alarm firing state + firingAlarm *db.Alarm + firingBlink bool + + // Form state + form *formModel + + // Error/status + statusMsg string +} + +func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model { + return Model{ + store: store, + scheduler: sched, + player: pl, + now: time.Now(), + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + tickEverySecond(), + listenForAlarms(m.scheduler), + m.loadAlarms, + ) +} + +func tickEverySecond() tea.Cmd { + return tea.Every(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func listenForAlarms(sched *scheduler.Scheduler) tea.Cmd { + return func() tea.Msg { + event := <-sched.Events() + return alarmFiredMsg(event) + } +} + +func (m *Model) loadAlarms() tea.Msg { + alarms, err := m.store.ListAlarms() + if err != nil { + return nil + } + m.alarms = alarms + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tickMsg: + m.now = time.Time(msg) + if m.firingAlarm != nil { + m.firingBlink = !m.firingBlink + } + return m, tickEverySecond() + + case alarmFiredMsg: + alarm := msg.Alarm + m.firingAlarm = &alarm + m.firingBlink = true + m.player.Play(alarm.SoundPath) + m.refreshAlarms() + return m, listenForAlarms(m.scheduler) + + case tea.KeyMsg: + return m.handleKey(msg) + } + + // Forward to form if active + if m.mode == viewForm && m.form != nil { + return m.form.Update(msg, &m) + } + + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // Global: quit + if key == "ctrl+c" { + return m, tea.Quit + } + + // If alarm is firing, handle dismiss/snooze + if m.firingAlarm != nil { + switch key { + case "enter", " ", "d": + m.player.Stop() + m.firingAlarm = nil + m.statusMsg = "Alarm dismissed" + case "s": + m.player.Stop() + m.firingAlarm = nil + m.statusMsg = "Snoozed for 5 minutes (not yet implemented)" + // TODO: implement snooze by creating a one-shot alarm 5min from now + } + return m, nil + } + + // Form mode + if m.mode == viewForm && m.form != nil { + return m.form.HandleKey(msg, &m) + } + + // Confirm delete mode + if m.mode == viewConfirmDelete { + switch key { + case "y", "Y": + if m.cursor < len(m.alarms) { + _ = m.store.DeleteAlarm(m.alarms[m.cursor].ID) + m.refreshAlarms() + if m.cursor >= len(m.alarms) && m.cursor > 0 { + m.cursor-- + } + m.statusMsg = "Alarm deleted" + } + m.mode = viewMain + case "n", "N", "escape", "esc": + m.mode = viewMain + m.statusMsg = "" + } + return m, nil + } + + // Main view keys + switch key { + case "q": + return m, tea.Quit + case "j", "down": + if m.cursor < len(m.alarms)-1 { + m.cursor++ + } + case "k", "up": + if m.cursor > 0 { + m.cursor-- + } + case "g": + m.cursor = 0 + case "G": + if len(m.alarms) > 0 { + m.cursor = len(m.alarms) - 1 + } + case " ": + // Toggle enabled + if m.cursor < len(m.alarms) { + _ = m.store.ToggleAlarm(m.alarms[m.cursor].ID) + m.refreshAlarms() + } + case "a": + m.form = newFormModel(nil) + m.mode = viewForm + m.statusMsg = "" + case "e": + if m.cursor < len(m.alarms) { + alarm := m.alarms[m.cursor] + m.form = newFormModel(&alarm) + m.mode = viewForm + m.statusMsg = "" + } + case "d": + if m.cursor < len(m.alarms) { + m.mode = viewConfirmDelete + m.statusMsg = fmt.Sprintf("Delete '%s'? (y/n)", m.alarms[m.cursor].Name) + } + } + return m, nil +} + +func (m *Model) refreshAlarms() { + alarms, err := m.store.ListAlarms() + if err == nil { + m.alarms = alarms + } +} + +func (m Model) View() string { + if m.width == 0 { + return "Loading..." + } + + var sections []string + + // Big clock + clockStr := RenderBigClock(m.now) + if m.firingAlarm != nil && m.firingBlink { + clockStr = ClockAlarmStyle.Render(clockStr) + } else { + clockStr = ClockStyle.Render(clockStr) + } + clockStr = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, clockStr) + sections = append(sections, clockStr) + + // Date line + dateLine := StatusStyle.Render(m.now.Format("Monday, 02 January 2006")) + dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine) + sections = append(sections, dateLine) + + // Firing alarm overlay + if m.firingAlarm != nil { + firingText := fmt.Sprintf("🔔 ALARM: %s 🔔", m.firingAlarm.Name) + if m.firingAlarm.Description != "" { + firingText += "\n" + m.firingAlarm.Description + } + firingText += "\n\n[Enter/Space/d] Dismiss [s] Snooze" + styled := AlarmFiringStyle.Render(firingText) + styled = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, styled) + sections = append(sections, "", styled) + } + + // Divider + divider := DividerStyle.Render(strings.Repeat("─", min(m.width, 80))) + divider = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, divider) + sections = append(sections, "", divider, "") + + // Form view + if m.mode == viewForm && m.form != nil { + formView := m.form.View() + formView = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, formView) + sections = append(sections, formView) + } else { + // Alarm list + alarmList := m.renderAlarmList() + alarmList = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, alarmList) + sections = append(sections, alarmList) + } + + // Status message + if m.statusMsg != "" { + status := StatusStyle.Render(m.statusMsg) + sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, status)) + } + + // Help bar at the bottom + help := m.renderHelp() + sections = append(sections, "", lipgloss.PlaceHorizontal(m.width, lipgloss.Center, help)) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Top, + strings.Join(sections, "\n"), + ) +} + +func (m Model) renderAlarmList() string { + if len(m.alarms) == 0 { + return StatusStyle.Render("No alarms. Press 'a' to add one.") + } + + title := TitleStyle.Render("Alarms") + var lines []string + lines = append(lines, title, "") + + for i, a := range m.alarms { + cursor := " " + if i == m.cursor { + cursor = "▸ " + } + + status := "●" + var style lipgloss.Style + if a.Enabled { + style = AlarmEnabledStyle + } else { + style = AlarmDisabledStyle + status = "○" + } + + if i == m.cursor { + style = AlarmSelectedStyle + } + + trigger := a.Time + if a.Trigger != "once" { + trigger = a.Trigger + } + + line := fmt.Sprintf("%s%s %s %-20s %s", cursor, status, trigger, a.Name, a.Description) + lines = append(lines, style.Render(line)) + } + + return strings.Join(lines, "\n") +} + +func (m Model) renderHelp() string { + if m.mode == viewForm { + return HelpStyle.Render("Tab: next field Shift+Tab: prev field Enter: save Esc: cancel") + } + if m.mode == viewConfirmDelete { + return HelpStyle.Render("y: confirm delete n: cancel") + } + return HelpStyle.Render("j/k: navigate a: add e: edit d: delete space: toggle q: quit") +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/ui/styles.go b/ui/styles.go new file mode 100644 index 0000000..85e6601 --- /dev/null +++ b/ui/styles.go @@ -0,0 +1,57 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +var ( + // Clock colors + ClockStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF88")). + Bold(true) + + ClockAlarmStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF4444")). + Bold(true) + + // Alarm list + AlarmEnabledStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF88")) + + AlarmDisabledStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")) + + AlarmSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFF00")). + Bold(true) + + // Status bar + StatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) + + HelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#555555")) + + // Alarm firing overlay + AlarmFiringStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Bold(true). + Blink(true) + + // Form + FormLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")) + + FormActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF88")). + Bold(true) + + FormErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF4444")) + + TitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FF88")). + Bold(true). + Underline(true) + + DividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#333333")) +) diff --git a/woke b/woke new file mode 100755 index 0000000..922ae3c Binary files /dev/null and b/woke differ