mirror of
https://codeberg.org/Kankys/doxa.git
synced 2026-11-11 06:44:36 +01:00
habit+timer
This commit is contained in:
1 parent
6356981825
commit
f19af63d91
31 files changed
+2767
-106
No files matched your search
@@ -0,0 +1,526 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
appStyle = lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62"))
|
||||
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FAFAFA")).
|
||||
Background(lipgloss.Color("#7D56F4")).
|
||||
Padding(0, 1).
|
||||
MarginBottom(1)
|
||||
|
||||
statusStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("205"))
|
||||
|
||||
timerFocusStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#7D56F4"))
|
||||
|
||||
timerPauseStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("205"))
|
||||
|
||||
timerBreakStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#2ECC71"))
|
||||
|
||||
timerDoneStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#F39C12"))
|
||||
|
||||
labelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
|
||||
valueStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FAFAFA"))
|
||||
|
||||
settingActiveStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#7D56F4"))
|
||||
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AppState int
|
||||
|
||||
const (
|
||||
StateStartup AppState = iota
|
||||
StateIdle
|
||||
StateRunning
|
||||
StatePaused
|
||||
StateBreak
|
||||
StateLongBreak
|
||||
StateSettings
|
||||
StateStats
|
||||
)
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Model ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type model struct {
|
||||
state AppState
|
||||
stats TimerStats
|
||||
lang string
|
||||
width int
|
||||
height int
|
||||
statusMsg string
|
||||
// Timer countdown
|
||||
remaining int // seconds remaining
|
||||
totalSeconds int // total seconds for current session
|
||||
// Settings edit fields (temporary values while editing)
|
||||
editFocus int
|
||||
editShort int
|
||||
editLong int
|
||||
editAfter int
|
||||
settingCursor int // 0=focus,1=short,2=long,3=after
|
||||
}
|
||||
|
||||
func NewModel() model {
|
||||
stats := loadStats()
|
||||
return model{
|
||||
state: StateStartup,
|
||||
stats: stats,
|
||||
lang: "cs",
|
||||
remaining: stats.FocusDuration * 60,
|
||||
totalSeconds: stats.FocusDuration * 60,
|
||||
editFocus: stats.FocusDuration,
|
||||
editShort: stats.ShortBreak,
|
||||
editLong: stats.LongBreak,
|
||||
editAfter: stats.LongBreakAfter,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.EnterAltScreen
|
||||
}
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
tr := Translations[m.lang]
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case tickMsg:
|
||||
if m.state == StateRunning || m.state == StateBreak || m.state == StateLongBreak {
|
||||
m.remaining--
|
||||
if m.remaining <= 0 {
|
||||
return m.handleTimerComplete()
|
||||
}
|
||||
return m, tickCmd()
|
||||
}
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
|
||||
case "q", "esc":
|
||||
switch m.state {
|
||||
case StateStartup:
|
||||
return m, tea.Quit
|
||||
case StateIdle:
|
||||
return m, tea.Quit
|
||||
case StateRunning, StatePaused:
|
||||
return m, tea.Quit
|
||||
case StateSettings, StateStats:
|
||||
m.state = StateIdle
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
default:
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "l":
|
||||
if m.state != StateSettings {
|
||||
if m.lang == "cs" {
|
||||
m.lang = "en"
|
||||
} else {
|
||||
m.lang = "cs"
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
switch m.state {
|
||||
case StateStartup:
|
||||
m.state = StateIdle
|
||||
m.remaining = m.stats.FocusDuration * 60
|
||||
m.totalSeconds = m.stats.FocusDuration * 60
|
||||
return m, nil
|
||||
case StateSettings:
|
||||
// Save settings
|
||||
m.stats.FocusDuration = m.editFocus
|
||||
m.stats.ShortBreak = m.editShort
|
||||
m.stats.LongBreak = m.editLong
|
||||
m.stats.LongBreakAfter = m.editAfter
|
||||
saveStats(m.stats)
|
||||
m.state = StateIdle
|
||||
m.remaining = m.stats.FocusDuration * 60
|
||||
m.totalSeconds = m.stats.FocusDuration * 60
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case " ":
|
||||
switch m.state {
|
||||
case StateIdle:
|
||||
m.state = StateRunning
|
||||
m.statusMsg = ""
|
||||
return m, tickCmd()
|
||||
case StateRunning:
|
||||
m.state = StatePaused
|
||||
return m, nil
|
||||
case StatePaused:
|
||||
m.state = StateRunning
|
||||
return m, tickCmd()
|
||||
case StateBreak, StateLongBreak:
|
||||
m.state = StateRunning
|
||||
return m, tickCmd()
|
||||
}
|
||||
|
||||
case "r":
|
||||
if m.state == StateRunning || m.state == StatePaused || m.state == StateIdle {
|
||||
m.state = StateIdle
|
||||
m.remaining = m.stats.FocusDuration * 60
|
||||
m.totalSeconds = m.stats.FocusDuration * 60
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
}
|
||||
if m.state == StateBreak || m.state == StateLongBreak {
|
||||
m.state = StateIdle
|
||||
m.remaining = m.stats.FocusDuration * 60
|
||||
m.totalSeconds = m.stats.FocusDuration * 60
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "s":
|
||||
if m.state == StateIdle || m.state == StatePaused {
|
||||
m.state = StateSettings
|
||||
m.editFocus = m.stats.FocusDuration
|
||||
m.editShort = m.stats.ShortBreak
|
||||
m.editLong = m.stats.LongBreak
|
||||
m.editAfter = m.stats.LongBreakAfter
|
||||
m.settingCursor = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "t":
|
||||
if m.state == StateIdle || m.state == StatePaused {
|
||||
m.state = StateStats
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Settings navigation
|
||||
case "up", "k":
|
||||
if m.state == StateSettings {
|
||||
m.settingCursor--
|
||||
if m.settingCursor < 0 {
|
||||
m.settingCursor = 3
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if m.state == StateSettings {
|
||||
m.settingCursor++
|
||||
if m.settingCursor > 3 {
|
||||
m.settingCursor = 0
|
||||
}
|
||||
}
|
||||
case "+", "=":
|
||||
if m.state == StateSettings {
|
||||
switch m.settingCursor {
|
||||
case 0:
|
||||
if m.editFocus < 60 { m.editFocus++ }
|
||||
case 1:
|
||||
if m.editShort < 30 { m.editShort++ }
|
||||
case 2:
|
||||
if m.editLong < 60 { m.editLong++ }
|
||||
case 3:
|
||||
if m.editAfter < 10 { m.editAfter++ }
|
||||
}
|
||||
}
|
||||
case "-":
|
||||
if m.state == StateSettings {
|
||||
switch m.settingCursor {
|
||||
case 0:
|
||||
if m.editFocus > 1 { m.editFocus-- }
|
||||
case 1:
|
||||
if m.editShort > 1 { m.editShort-- }
|
||||
case 2:
|
||||
if m.editLong > 1 { m.editLong-- }
|
||||
case 3:
|
||||
if m.editAfter > 1 { m.editAfter-- }
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tr
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleTimerComplete() (tea.Model, tea.Cmd) {
|
||||
tr := Translations[m.lang]
|
||||
|
||||
if m.state == StateRunning {
|
||||
// Pomodoro complete
|
||||
m.stats.TotalPomodoros++
|
||||
m.stats.TodayPomodoros++
|
||||
m.stats.LastDate = time.Now().Format("2006-01-02")
|
||||
saveStats(m.stats)
|
||||
m.statusMsg = tr.SessionComplete
|
||||
|
||||
// Decide: long break or short break
|
||||
if m.stats.TodayPomodoros%m.stats.LongBreakAfter == 0 {
|
||||
m.state = StateLongBreak
|
||||
m.remaining = m.stats.LongBreak * 60
|
||||
m.totalSeconds = m.stats.LongBreak * 60
|
||||
} else {
|
||||
m.state = StateBreak
|
||||
m.remaining = m.stats.ShortBreak * 60
|
||||
m.totalSeconds = m.stats.ShortBreak * 60
|
||||
}
|
||||
return m, tickCmd()
|
||||
}
|
||||
|
||||
// Break complete → go idle
|
||||
m.statusMsg = tr.BreakComplete
|
||||
m.state = StateIdle
|
||||
m.remaining = m.stats.FocusDuration * 60
|
||||
m.totalSeconds = m.stats.FocusDuration * 60
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ─── View ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) View() string {
|
||||
tr := Translations[m.lang]
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render(tr.Title) + "\n")
|
||||
|
||||
if m.statusMsg != "" {
|
||||
b.WriteString(statusStyle.Render(m.statusMsg) + "\n\n")
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case StateStartup:
|
||||
eagle := `
|
||||
_
|
||||
( ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("`") + `
|
||||
\ ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("\\") + `
|
||||
/ / _
|
||||
__ / / ( \
|
||||
( "---" / / /
|
||||
\ / / /
|
||||
\ /-----' /
|
||||
\ / /
|
||||
\ / /
|
||||
'-------'
|
||||
`
|
||||
b.WriteString(lipgloss.Place(m.width-12, 14, lipgloss.Center, lipgloss.Center,
|
||||
eagle+"\n"+
|
||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4")).Render("DOXA-TIMER 🦅")+"\n\n"+
|
||||
"Local-First Pomodoro Timer\n\n"+
|
||||
"Enter: Start | q: Quit\n\n"+
|
||||
"License: MIT | © 2026 Kankys"))
|
||||
|
||||
case StateIdle, StateRunning, StatePaused, StateBreak, StateLongBreak:
|
||||
// State label
|
||||
stateLabel := m.getStateLabel()
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Render(stateLabel) + "\n\n")
|
||||
|
||||
// Big timer
|
||||
timerStr := formatDuration(m.remaining)
|
||||
timerRendered := m.renderTimer(timerStr)
|
||||
b.WriteString(lipgloss.PlaceHorizontal(m.width-10, lipgloss.Center, timerRendered) + "\n\n")
|
||||
|
||||
// Progress bar
|
||||
progressBar := renderProgressBar(m.remaining, m.totalSeconds, 40)
|
||||
b.WriteString(lipgloss.PlaceHorizontal(m.width-10, lipgloss.Center, progressBar) + "\n\n")
|
||||
|
||||
// Stats row
|
||||
pomLine := fmt.Sprintf(" %s %s %s %s",
|
||||
labelStyle.Render(tr.LabelTodayCount),
|
||||
valueStyle.Render(fmt.Sprintf("%d", m.stats.TodayPomodoros)),
|
||||
labelStyle.Render(tr.LabelTotalCount),
|
||||
valueStyle.Render(fmt.Sprintf("%d", m.stats.TotalPomodoros)),
|
||||
)
|
||||
b.WriteString(pomLine + "\n\n")
|
||||
|
||||
// Next long break
|
||||
remaining := m.stats.LongBreakAfter - (m.stats.TodayPomodoros % m.stats.LongBreakAfter)
|
||||
if remaining == m.stats.LongBreakAfter {
|
||||
remaining = 0
|
||||
}
|
||||
if remaining > 0 {
|
||||
b.WriteString(labelStyle.Render(fmt.Sprintf(" %s %d pomodoro(s)", tr.LabelNextBreak, remaining)) + "\n\n")
|
||||
}
|
||||
|
||||
// Help
|
||||
helpText := tr.HelpIdle
|
||||
if m.state == StateRunning {
|
||||
helpText = tr.HelpRunning
|
||||
} else if m.state == StatePaused {
|
||||
helpText = tr.HelpIdle
|
||||
}
|
||||
b.WriteString(helpStyle.Render(helpText))
|
||||
|
||||
case StateSettings:
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Render(tr.SettingsTitle) + "\n\n")
|
||||
|
||||
settings := []struct {
|
||||
label string
|
||||
value int
|
||||
unit string
|
||||
}{
|
||||
{tr.LabelFocus, m.editFocus, tr.MinutesShort},
|
||||
{tr.LabelShortBreak, m.editShort, tr.MinutesShort},
|
||||
{tr.LabelLongBreak, m.editLong, tr.MinutesShort},
|
||||
{tr.LabelLongAfter, m.editAfter, "pomodorů"},
|
||||
}
|
||||
if m.lang == "en" {
|
||||
settings[3].unit = "pomodoros"
|
||||
}
|
||||
|
||||
for i, s := range settings {
|
||||
cursor := " "
|
||||
lStyle := labelStyle
|
||||
vStyle := valueStyle
|
||||
if i == m.settingCursor {
|
||||
cursor = "> "
|
||||
lStyle = settingActiveStyle
|
||||
vStyle = settingActiveStyle
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s%-22s %s %s\n",
|
||||
cursor,
|
||||
lStyle.Render(s.label),
|
||||
vStyle.Render(fmt.Sprintf("%d", s.value)),
|
||||
labelStyle.Render(s.unit),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n" + helpStyle.Render(tr.HelpSettings))
|
||||
|
||||
case StateStats:
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Render(tr.StatsTitle) + "\n\n")
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n",
|
||||
labelStyle.Render(tr.PomodorosToday),
|
||||
valueStyle.Render(fmt.Sprintf("%d 🍅", m.stats.TodayPomodoros)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n",
|
||||
labelStyle.Render(tr.PomodorosTotal),
|
||||
valueStyle.Render(fmt.Sprintf("%d 🍅", m.stats.TotalPomodoros)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf("\n %-28s %d %s\n",
|
||||
labelStyle.Render(tr.LabelFocus),
|
||||
m.stats.FocusDuration,
|
||||
tr.MinutesShort,
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %d %s\n",
|
||||
labelStyle.Render(tr.LabelShortBreak),
|
||||
m.stats.ShortBreak,
|
||||
tr.MinutesShort,
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %d %s\n",
|
||||
labelStyle.Render(tr.LabelLongBreak),
|
||||
m.stats.LongBreak,
|
||||
tr.MinutesShort,
|
||||
))
|
||||
b.WriteString("\n" + helpStyle.Render(tr.HelpStats))
|
||||
}
|
||||
|
||||
content := b.String()
|
||||
if m.width > 0 && m.height > 0 {
|
||||
return appStyle.Width(m.width - 6).Height(m.height - 4).Render(content)
|
||||
}
|
||||
return appStyle.Render(content)
|
||||
}
|
||||
|
||||
func (m model) getStateLabel() string {
|
||||
tr := Translations[m.lang]
|
||||
switch m.state {
|
||||
case StateIdle:
|
||||
return tr.StateIdle
|
||||
case StateRunning:
|
||||
return tr.StateRunning
|
||||
case StatePaused:
|
||||
return tr.StatePaused
|
||||
case StateBreak:
|
||||
return tr.StateBreak
|
||||
case StateLongBreak:
|
||||
return tr.StateLongBreak
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) renderTimer(s string) string {
|
||||
large := lipgloss.NewStyle().Bold(true).PaddingLeft(1).PaddingRight(1)
|
||||
switch m.state {
|
||||
case StateRunning:
|
||||
return timerFocusStyle.Copy().Inherit(large).Render(s)
|
||||
case StatePaused:
|
||||
return timerPauseStyle.Copy().Inherit(large).Render(s)
|
||||
case StateBreak, StateLongBreak:
|
||||
return timerBreakStyle.Copy().Inherit(large).Render(s)
|
||||
default:
|
||||
return timerPauseStyle.Copy().Inherit(large).Render(s)
|
||||
}
|
||||
}
|
||||
|
||||
// renderProgressBar renders an ASCII progress bar
|
||||
func renderProgressBar(remaining, total, width int) string {
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
elapsed := total - remaining
|
||||
filled := int(float64(elapsed) / float64(total) * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
bar := "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]"
|
||||
pct := int(float64(elapsed) / float64(total) * 100)
|
||||
return fmt.Sprintf("%s %3d%%", bar, pct)
|
||||
}
|
||||
|
||||
// formatDuration formats seconds as MM:SS
|
||||
func formatDuration(secs int) string {
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
m := secs / 60
|
||||
s := secs % 60
|
||||
return fmt.Sprintf("%02d:%02d", m, s)
|
||||
}
|
||||
Reference in new issue
Block a user