Skip to content

Commit

Permalink
⏰ Persistent Stopwatch
Browse files Browse the repository at this point in the history
The time is persisted when the program is stopped and resumed, now.
Additionally, the elapsed time is saved every 30 seconds.
  • Loading branch information
gorillamoe committed May 8, 2024
1 parent ed48c82 commit 9f89362
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 51 deletions.
11 changes: 0 additions & 11 deletions cmd/styles.go

This file was deleted.

35 changes: 0 additions & 35 deletions cmd/utils.go

This file was deleted.

1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
27 changes: 22 additions & 5 deletions internal/counter/counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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"),
Expand All @@ -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)
Expand Down
148 changes: 148 additions & 0 deletions internal/stopwatch/stopwatch.go
Original file line number Diff line number Diff line change
@@ -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}
})
}
4 changes: 4 additions & 0 deletions internal/utils/db.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS counters (
name TEXT NOT NULL UNIQUE,
elapsed INTEGER NOT NULL DEFAULT 0
);
89 changes: 89 additions & 0 deletions internal/utils/utils.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 9f89362

Please sign in to comment.