From 9f89362fc2e1f562ad966fa6bd3426c946b10155 Mon Sep 17 00:00:00 2001 From: Marco Kellershoff Date: Wed, 8 May 2024 12:22:38 +0200 Subject: [PATCH] =?UTF-8?q?=E2=8F=B0=20Persistent=20Stopwatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The time is persisted when the program is stopped and resumed, now. Additionally, the elapsed time is saved every 30 seconds. --- cmd/styles.go | 11 --- cmd/utils.go | 35 -------- go.mod | 1 + go.sum | 2 + internal/counter/counter.go | 27 ++++-- internal/stopwatch/stopwatch.go | 148 ++++++++++++++++++++++++++++++++ internal/utils/db.sql | 4 + internal/utils/utils.go | 89 +++++++++++++++++++ 8 files changed, 266 insertions(+), 51 deletions(-) delete mode 100644 cmd/styles.go delete mode 100644 cmd/utils.go create mode 100644 internal/stopwatch/stopwatch.go create mode 100644 internal/utils/db.sql create mode 100644 internal/utils/utils.go diff --git a/cmd/styles.go b/cmd/styles.go deleted file mode 100644 index 7bc1297..0000000 --- a/cmd/styles.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import "github.com/charmbracelet/lipgloss" - -var style = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(lipgloss.Color("#7D56F4")). - PaddingTop(2). - PaddingLeft(4). - Width(22) \ No newline at end of file diff --git a/cmd/utils.go b/cmd/utils.go deleted file mode 100644 index 79deef5..0000000 --- a/cmd/utils.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "os" -) - -var ps = string(os.PathSeparator) - -func getCurrentWorkingDirectory() string { - dir, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - return dir -} - -func getJsonRoot() JsonRoot { - currentDir := getCurrentWorkingDirectory() - file, err := os.Open(currentDir + ps + "packages.json") - if err != nil { - log.Fatal(err) - } - defer file.Close() - - decoder := json.NewDecoder(file) - var jsonRoot JsonRoot - err = decoder.Decode(&jsonRoot) - if err != nil { - log.Fatal(err) - } - - return jsonRoot -} \ No newline at end of file diff --git a/go.mod b/go.mod index 6f2d4d3..cc3fb78 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( 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.15 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect diff --git a/go.sum b/go.sum index 373b824..a67658a 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= diff --git a/internal/counter/counter.go b/internal/counter/counter.go index 68e99bf..968ed94 100644 --- a/internal/counter/counter.go +++ b/internal/counter/counter.go @@ -8,9 +8,10 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/stopwatch" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mistweaverco/countup.sh/internal/stopwatch" + "github.com/mistweaverco/countup.sh/internal/utils" ) const ( @@ -47,7 +48,7 @@ type keymap struct { } func (m model) Init() tea.Cmd { - return m.stopwatch.Init() + return nil } func getFormattedTimeString(d time.Duration) string { @@ -57,12 +58,22 @@ func getFormattedTimeString(d time.Duration) string { return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } +func (m model) BackupDBPeriodic() { + elapsed := int(m.stopwatch.Elapsed().Seconds()) + if elapsed%30 == 0 { + m.UpdateElapsedInDB() + } +} + func (m model) View() string { s := "" sw := getFormattedTimeString(m.stopwatch.Elapsed()) + "\n" if !m.quitting { + m.BackupDBPeriodic() s = timerNameStyle.Render(m.timerName) + " " + sw s += m.helpView() + } else { + m.UpdateElapsedInDB() } return s } @@ -96,10 +107,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m model) UpdateElapsedInDB() { + elapsed := int(m.stopwatch.Elapsed().Seconds()) + utils.DBSetCounter(m.timerName, elapsed) +} + func Start(timerName string) { + utils.DBNew() + t := utils.DBGetCounter(timerName) m := model{ timerName: timerName, - stopwatch: stopwatch.NewWithInterval(time.Second), + stopwatch: stopwatch.NewWithInterval(time.Second, t.Elapsed), keymap: keymap{ start: key.NewBinding( key.WithKeys("s"), @@ -120,8 +138,7 @@ func Start(timerName string) { }, help: help.New(), } - - m.keymap.start.SetEnabled(false) + m.keymap.stop.SetEnabled(false) if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Oh no, it didn't work:", err) diff --git a/internal/stopwatch/stopwatch.go b/internal/stopwatch/stopwatch.go new file mode 100644 index 0000000..7aece61 --- /dev/null +++ b/internal/stopwatch/stopwatch.go @@ -0,0 +1,148 @@ +package stopwatch + +import ( + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +var ( + lastID int + idMtx sync.Mutex +) + +func nextID() int { + idMtx.Lock() + defer idMtx.Unlock() + lastID++ + return lastID +} + +// TickMsg is a message that is sent on every timer tick. +type TickMsg struct { + // ID is the identifier of the stopwatch that sends the message. This makes + // it possible to determine which stopwatch a tick belongs to when there + // are multiple stopwatches running. + // + // Note, however, that a stopwatch will reject ticks from other + // stopwatches, so it's safe to flow all TickMsgs through all stopwatches + // and have them still behave appropriately. + ID int +} + +// StartStopMsg is sent when the stopwatch should start or stop. +type StartStopMsg struct { + ID int + running bool +} + +// ResetMsg is sent when the stopwatch should reset. +type ResetMsg struct { + ID int +} + +// Model for the stopwatch component. +type Model struct { + d time.Duration + id int + running bool + + // How long to wait before every tick. Defaults to 1 second. + Interval time.Duration +} + +// NewWithInterval creates a new stopwatch with the given timeout and tick +// interval. +func NewWithInterval(interval time.Duration, elapsed int) Model { + t := time.Duration(elapsed) * interval + return Model{ + d: t, + Interval: interval, + id: nextID(), + } +} + +// New creates a new stopwatch with 1s interval. +func New(elapsed int) Model { + return NewWithInterval(time.Second, elapsed) +} + +// ID returns the unique ID of the model. +func (m Model) ID() int { + return m.id +} + +// Start starts the stopwatch. +func (m Model) Start() tea.Cmd { + return tea.Batch(func() tea.Msg { + return StartStopMsg{ID: m.id, running: true} + }, tick(m.id, m.Interval)) +} + +// Stop stops the stopwatch. +func (m Model) Stop() tea.Cmd { + return func() tea.Msg { + return StartStopMsg{ID: m.id, running: false} + } +} + +// Toggle stops the stopwatch if it is running and starts it if it is stopped. +func (m Model) Toggle() tea.Cmd { + if m.Running() { + return m.Stop() + } + return m.Start() +} + +// Reset resets the stopwatch to 0. +func (m Model) Reset() tea.Cmd { + return func() tea.Msg { + return ResetMsg{ID: m.id} + } +} + +// Running returns true if the stopwatch is running or false if it is stopped. +func (m Model) Running() bool { + return m.running +} + +// Update handles the timer tick. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case StartStopMsg: + if msg.ID != m.id { + return m, nil + } + m.running = msg.running + case ResetMsg: + if msg.ID != m.id { + return m, nil + } + m.d = 0 + case TickMsg: + if !m.running || msg.ID != m.id { + break + } + m.d += m.Interval + return m, tick(m.id, m.Interval) + } + + return m, nil +} + +// Elapsed returns the time elapsed. +func (m Model) Elapsed() time.Duration { + return m.d +} + +// View of the timer component. +func (m Model) View() string { + return m.d.String() +} + +func tick(id int, d time.Duration) tea.Cmd { + return tea.Tick(d, func(_ time.Time) tea.Msg { + return TickMsg{ID: id} + }) +} \ No newline at end of file diff --git a/internal/utils/db.sql b/internal/utils/db.sql new file mode 100644 index 0000000..ef07d46 --- /dev/null +++ b/internal/utils/db.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS counters ( + name TEXT NOT NULL UNIQUE, + elapsed INTEGER NOT NULL DEFAULT 0 +); diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..cf031ee --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,89 @@ +package utils + +import ( + "database/sql" + "embed" + "log" + "os" + + _ "github.com/mattn/go-sqlite3" +) + +const DBFile string = "countup.sh.db" + +var DBInstance *sql.DB + +var ps = string(os.PathSeparator) + +func GetDataDirectory() string { + dir, err := os.UserConfigDir() + if err != nil { + log.Fatal(err) + } + fullpath := dir + ps + "countup.sh" + ps + mkdirerr := os.MkdirAll(fullpath, os.ModePerm) + if mkdirerr != nil { + log.Fatal(mkdirerr) + } + return fullpath +} + +func DBNew() { + userConfigDir := GetDataDirectory() + fullPath := userConfigDir + DBFile + _, staterr := os.Stat(fullPath) + + instance, err := sql.Open("sqlite3", fullPath) + if err != nil { + log.Fatal("Error opening database: ", err) + } + if DBInstance == nil { + DBInstance = instance + } + if os.IsNotExist(staterr) { + createAndPrefillDatabase() + } +} + +func DBClose() { + DBInstance.Close() +} + +//go:embed db.sql +var sqlfile embed.FS + +func createAndPrefillDatabase() { + readfile, err := sqlfile.ReadFile("db.sql") + if err != nil { + log.Fatal("Error reading db.sql: ", err) + } + str := string(readfile) + _, err = DBInstance.Exec(str) + if err != nil { + log.Fatal("Error creating database: ", err, DBInstance.Stats()) + } +} + +type DBCounter struct { + Name string + Elapsed int +} + +func DBGetCounter(name string) DBCounter { + var counter DBCounter + err := DBInstance.QueryRow("SELECT name, elapsed FROM counters WHERE name = ?", name).Scan(&counter.Name, &counter.Elapsed) + if err != nil { + counter = DBCounter{ + Name: name, + Elapsed: 0, + } + } + return counter +} + +func DBSetCounter(name string, elapsed int) { + _, err := DBInstance.Exec("INSERT OR REPLACE INTO counters (name, elapsed) VALUES (?, ?)", name, elapsed) + if err != nil { + log.Fatal("Error setting counter: ", err) + } +} \ No newline at end of file