First draft

This commit is contained in:
2026-02-01 21:15:17 +02:00
parent 191ec01e73
commit 06d5a6a779
11 changed files with 1352 additions and 0 deletions

209
db/db.go Normal file
View File

@@ -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
}

36
go.mod Normal file
View File

@@ -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
)

63
go.sum Normal file
View File

@@ -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=

34
main.go Normal file
View File

@@ -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)
}
}

102
player/player.go Normal file
View File

@@ -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
}

128
scheduler/scheduler.go Normal file
View File

@@ -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)
}

161
ui/clock.go Normal file
View File

@@ -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")
}

215
ui/form.go Normal file
View File

@@ -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'
}

347
ui/model.go Normal file
View File

@@ -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
}

57
ui/styles.go Normal file
View File

@@ -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"))
)

BIN
woke Executable file

Binary file not shown.