habit+timer

This commit is contained in:
kankys committed 2026-08-16 21:13:50 +02:00
1 parent 6356981825
commit f19af63d91
31 files changed
+2767 -106

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kankys
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# DOXA Timer User Manual ⏱️
This manual explains how to use the DOXA Timer application.
## Getting Started
Run the program with:
```bash
doxa-timer
```
When you first launch the app, you will see a startup screen. Press `Enter` to access the main interface.
## Interface States
1. **READY**: The timer is set and waiting for you to start.
2. **FOCUS**: Work interval. Focus on your tasks.
3. **SHORT BREAK / LONG BREAK**: Relax. The countdown signals the break duration.
4. **SETTINGS**: Configuration menu to set durations.
5. **STATS**: Historical data.
## Controls
### Main Screen
- `Space`: Starts or pauses the countdown.
- `r`: Resets the countdown back to the configured focus or break time.
- `s`: Opens Settings.
- `t`: Opens Stats.
- `l`: Toggles UI translation (English/Czech).
- `q` / `Esc`: Exits the application.
### Settings Screen
- `up`/`down` or `k`/`j`: Navigate between different settings options:
- Focus time
- Short break time
- Long break time
- Long break after X pomodoros
- `+` / `-`: Increase or decrease the value of the active option.
- `Enter`: Save settings and return to the main screen.
- `q` / `Esc`: Go back to the main screen without saving changes.
### Stats Screen
- `q` / `Esc`: Return to the main screen.
+43
View File
@@ -0,0 +1,43 @@
# Uživatelský manuál DOXA Timer ⏱️
Tento manuál popisuje použití aplikace DOXA Timer.
## Spuštění programu
Spusťte aplikaci příkazem:
```bash
doxa-timer
```
Při prvním spuštění se zobrazí úvodní logo a text. Stiskněte `Enter` pro přechod do hlavního rozhraní.
## Stavy aplikace
1. **PŘIPRAVEN (READY)**: Časovač čeká na spuštění.
2. **FOKUS (FOCUS)**: Pracovní interval. Soustřeďte se na práci.
3. **KRÁTKÁ / DLOUHÁ PŘESTÁVKA**: Čas na relaxaci a odpočinek.
4. **NASTAVENÍ (SETTINGS)**: Konfigurace délek časovačů.
5. **STATISTIKY (STATS)**: Přehled o dokončených sezeních.
## Ovládání
### Hlavní obrazovka
- `Mezerník (Space)`: Spustí nebo pozastaví aktuální odpočet.
- `r`: Obnoví (resetuje) aktuální časovač zpět na plnou délku.
- `s`: Přejde do Nastavení.
- `t`: Přejde do Statistik.
- `l`: Přepne jazyk mezi češtinou a angličtinou.
- `q` / `Esc`: Ukončí program.
### Obrazovka Nastavení
- `šipka nahoru/dolů` nebo `k`/`j`: Pohyb mezi položkami nastavení:
- Délka fokusu
- Krátká přestávka
- Dlouhá přestávka
- Dlouhá přestávka po X pomodorech
- `+` / `-`: Zvýší nebo sníží hodnotu zvolené položky.
- `Enter`: Uloží konfiguraci a vrátí se na hlavní obrazovku.
- `q` / `Esc`: Vrátí se na hlavní obrazovku bez uložení změn.
### Obrazovka Statistiky
- `q` / `Esc`: Vrátí se na hlavní obrazovku.
+32
View File
@@ -0,0 +1,32 @@
# DOXA Timer ⏱️
**Minimalist Pomodoro timer for the terminal with local statistics**
DOXA Timer is a terminal-based Pomodoro app. It alternates focus work sessions with short and long breaks to optimize concentration, while tracking your sessions locally.
## Features
- **Pomodoro Technique**: Standard Focus (25m), Short Break (5m), and Long Break (15m).
- **Live Countdown & Progress**: Interactive ASCII progress bar.
- **Persistent Local Stats**: Tracking of today's and total Pomodoros.
- **Customizable Intervals**: Adjust times inside the app settings.
- **Sovereign & Offline**: Everything is saved locally at `~/.doxa-timer/doxa_timer.json`.
- **Bilingual TUI**: Switch between English and Czech using the `l` key.
## Installation
Go to the `doxa-timer` folder and run the install script:
```bash
chmod +x install.sh
./install.sh
```
## Hotkeys
- `Space` - Start / Pause / Resume
- `r` - Reset current timer
- `s` - Open Settings (press `+`/`-` to adjust times, `Enter` to Save, `q` to Cancel)
- `t` - Show Statistics
- `l` - Toggle language (EN/CS)
- `q` or `Esc` - Go back / Quit
+32
View File
@@ -0,0 +1,32 @@
# DOXA Timer ⏱️
**Minimalistický Pomodoro časovač do terminálu s lokální statistikou**
DOXA Timer je terminálová Pomodoro aplikace. Střídá intervaly plného soustředění na práci s krátkými a dlouhými přestávkami pro zvýšení produktivity.
## Vlastnosti
- **Metoda Pomodoro**: Fokus (25 min), Krátká přestávka (5 min) a Dlouhá přestávka (15 min).
- **Živý odpočet**: ASCII progress bar s procentuálním vyjádřením pokroku.
- **Lokální statistika**: Počítadlo celkových a dnešních dokončených Pomodorů.
- **Nastavitelnost**: Změna délek jednotlivých fází v menu Nastavení.
- **Local-first**: Data se ukládají lokálně do `~/.doxa-timer/doxa_timer.json`.
- **Dvojjazyčnost**: Přepínání češtiny a angličtiny pomocí klávesy `l`.
## Instalace
Přejděte do složky `doxa-timer` a spusťte instalační skript:
```bash
chmod +x install.sh
./install.sh
```
## Klávesové zkratky
- `Space` - Start / Pauza / Pokračovat
- `r` - Reset aktuálního odpočtu
- `s` - Otevřít Nastavení (klávesy `+`/`-` mění hodnotu, `Enter` uloží, `q` zruší změny)
- `t` - Zobrazit Statistiky
- `l` - Přepnutí jazyka (CZ/EN)
- `q` nebo `Esc` - Zpět / Konec
BIN
View File
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
module doxa-timer
go 1.26.5
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.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.19 // 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/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+48
View File
@@ -0,0 +1,48 @@
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/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.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=
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# DOXA-TIMER Installation Script
# --- Nastavení ---
APP_NAME="doxa-timer"
INSTALL_DIR="$HOME/.local/bin"
CONFIG_DIR="$HOME/.doxa-timer"
CONFIG_FILE="doxa_timer.json"
REPO_URL="https://codeberg.org/Kankys/doxa.git"
# --- Funkce ---
error_exit() {
echo -e "\033[1;31mERROR: $1\033[0m" >&2
exit 1
}
success_message() {
echo -e "\033[1;32mSUCCESS: $1\033[0m"
}
info_message() {
echo -e "\033[1;34mINFO: $1\033[0m"
}
# --- Hlavní část skriptu ---
info_message "Kontrola Go instalace..."
if ! command -v go &> /dev/null
then
error_exit "Go není nainstalováno. Prosím, nainstalujte Go před spuštěním tohoto skriptu."
fi
success_message "Go je nainstalováno."
info_message "Kontrola aktuálního adresáře..."
if [ -f "main.go" ] && [ -f "go.mod" ] && grep -q "module doxa-timer" go.mod; then
info_message "Jste již v adresáři $APP_NAME, používám místní verzi."
else
error_exit "Tento skript spouštějte z adresáře doxa-timer."
fi
info_message "Stažení závislostí projektu..."
if ! go mod tidy
then
error_exit "Nepodařilo se stáhnout závislosti Go."
fi
success_message "Závislosti projektu úspěšně staženy."
info_message "Kompilace aplikace..."
APP_BINARY="$APP_NAME"
if ! go build -o "$APP_BINARY" .
then
error_exit "Nepodařilo se zkompilovat aplikaci."
fi
success_message "Aplikace úspěšně zkompilována do './$APP_BINARY'."
info_message "Instalace aplikace..."
if ! mkdir -p "$INSTALL_DIR"
then
error_exit "Nepodařilo se vytvořit cílový adresář '$INSTALL_DIR'."
fi
if ! cp "$APP_BINARY" "$INSTALL_DIR/"
then
error_exit "Nepodařilo se zkopírovat binárku do '$INSTALL_DIR'."
fi
chmod +x "$INSTALL_DIR/$APP_NAME"
success_message "Aplikace '$APP_NAME' úspěšně nainstalována do '$INSTALL_DIR'."
info_message "Nastavení konfiguračního adresáře..."
if ! mkdir -p "$CONFIG_DIR"
then
error_exit "Nepodařilo se vytvořit konfigurační adresář '$CONFIG_DIR'."
fi
if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]
then
echo "{}" > "$CONFIG_DIR/$CONFIG_FILE"
if [ $? -ne 0 ]
then
error_exit "Nepodařilo se vytvořit konfigurační soubor '$CONFIG_DIR/$CONFIG_FILE'."
fi
success_message "Konfigurační soubor '$CONFIG_DIR/$CONFIG_FILE' vytvořen."
else
info_message "Konfigurační soubor '$CONFIG_DIR/$CONFIG_FILE' již existuje."
fi
info_message "Instalace dokončena."
success_message "Nyní můžete spustit aplikaci '$APP_NAME' z jakéhokoli místa v terminálu."
echo ""
echo -e "\033[1;33mPRO TIP:\nPokud \`$APP_NAME\` nefunguje z jakéhokoli místa, přidejte '$INSTALL_DIR' do vašeho PATH.\nexport PATH=\$PATH:$INSTALL_DIR\033[0m"
echo ""
exit 0
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
m := NewModel()
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
fmt.Printf("Chyba při spuštění programu: %v\n", err)
os.Exit(1)
}
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// TimerStats holds persistent statistics and settings
type TimerStats struct {
TotalPomodoros int `json:"total_pomodoros"`
TodayPomodoros int `json:"today_pomodoros"`
LastDate string `json:"last_date"`
FocusDuration int `json:"focus_duration"` // minutes
ShortBreak int `json:"short_break"` // minutes
LongBreak int `json:"long_break"` // minutes
LongBreakAfter int `json:"long_break_after"` // after how many pomodoros
}
func getStoragePath() string {
home, err := os.UserHomeDir()
if err != nil {
return "doxa_timer.json"
}
configDir := filepath.Join(home, ".doxa-timer")
if _, err := os.Stat(configDir); os.IsNotExist(err) {
os.MkdirAll(configDir, 0755)
}
return filepath.Join(configDir, "doxa_timer.json")
}
func loadStats() TimerStats {
path := getStoragePath()
file, err := os.ReadFile(path)
if err != nil {
return defaultStats()
}
var stats TimerStats
if err := json.Unmarshal(file, &stats); err != nil {
return defaultStats()
}
// Reset today's count if it's a new day
today := time.Now().Format("2006-01-02")
if stats.LastDate != today {
stats.TodayPomodoros = 0
stats.LastDate = today
}
// Ensure sane defaults if values are zero
if stats.FocusDuration == 0 {
stats.FocusDuration = 25
}
if stats.ShortBreak == 0 {
stats.ShortBreak = 5
}
if stats.LongBreak == 0 {
stats.LongBreak = 15
}
if stats.LongBreakAfter == 0 {
stats.LongBreakAfter = 4
}
return stats
}
func saveStats(stats TimerStats) {
path := getStoragePath()
data, err := json.MarshalIndent(stats, "", " ")
if err != nil {
fmt.Println("Chyba při ukládání dat:", err)
return
}
os.WriteFile(path, data, 0644)
}
func defaultStats() TimerStats {
return TimerStats{
TotalPomodoros: 0,
TodayPomodoros: 0,
LastDate: time.Now().Format("2006-01-02"),
FocusDuration: 25,
ShortBreak: 5,
LongBreak: 15,
LongBreakAfter: 4,
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
// Translation holds all UI strings for one language
type Translation struct {
Title string
MenuStart string
MenuPause string
MenuReset string
MenuSettings string
MenuStats string
MenuLang string
MenuQuit string
StateIdle string
StateRunning string
StatePaused string
StateBreak string
StateLongBreak string
StateDone string
LabelFocus string
LabelShortBreak string
LabelLongBreak string
LabelLongAfter string
LabelTodayCount string
LabelTotalCount string
LabelNextBreak string
HelpIdle string
HelpRunning string
HelpSettings string
HelpStats string
SettingsTitle string
StatsTitle string
SessionComplete string
BreakComplete string
PomodorosToday string
PomodorosTotal string
NextLongBreakIn string
MinutesShort string
}
var Translations = map[string]Translation{
"cs": {
Title: "DOXA-TIMER 🦅",
MenuStart: "Space: Start",
MenuPause: "Space: Pauza",
MenuReset: "r: Reset",
MenuSettings: "s: Nastavení",
MenuStats: "t: Statistiky",
MenuLang: "l: EN",
MenuQuit: "q: Konec",
StateIdle: "PŘIPRAVEN",
StateRunning: "FOKUS",
StatePaused: "PAUZA",
StateBreak: "KRÁTKÁ PŘESTÁVKA",
StateLongBreak: "DLOUHÁ PŘESTÁVKA",
StateDone: "DOKONČENO!",
LabelFocus: "Fokus:",
LabelShortBreak: "Krátká přestávka:",
LabelLongBreak: "Dlouhá přestávka:",
LabelLongAfter: "Dlouhá přestávka po:",
LabelTodayCount: "Dnes:",
LabelTotalCount: "Celkem:",
LabelNextBreak: "Příští dlouhá přestávka za:",
HelpIdle: "Space: Spustit | r: Reset | s: Nastavení | t: Statistiky | l: EN | q: Konec",
HelpRunning: "Space: Pauza | r: Reset | q: Konec",
HelpSettings: "+/-: Změnit fokus | b/n: Změnit přestávky | Enter: Uložit | q: Zpět",
HelpStats: "q/Esc: Zpět",
SettingsTitle: "NASTAVENÍ ČASOVAČŮ",
StatsTitle: "STATISTIKY",
SessionComplete: "🎉 Sezení dokončeno! Čas na přestávku.",
BreakComplete: "⏰ Přestávka skončila! Čas na práci.",
PomodorosToday: "Dnes dokončeno:",
PomodorosTotal: "Celkem dokončeno:",
NextLongBreakIn: "Dlouhá přestávka za:",
MinutesShort: "min",
},
"en": {
Title: "DOXA-TIMER 🦅",
MenuStart: "Space: Start",
MenuPause: "Space: Pause",
MenuReset: "r: Reset",
MenuSettings: "s: Settings",
MenuStats: "t: Stats",
MenuLang: "l: CS",
MenuQuit: "q: Quit",
StateIdle: "READY",
StateRunning: "FOCUS",
StatePaused: "PAUSED",
StateBreak: "SHORT BREAK",
StateLongBreak: "LONG BREAK",
StateDone: "DONE!",
LabelFocus: "Focus:",
LabelShortBreak: "Short break:",
LabelLongBreak: "Long break:",
LabelLongAfter: "Long break after:",
LabelTodayCount: "Today:",
LabelTotalCount: "Total:",
LabelNextBreak: "Next long break in:",
HelpIdle: "Space: Start | r: Reset | s: Settings | t: Stats | l: CS | q: Quit",
HelpRunning: "Space: Pause | r: Reset | q: Quit",
HelpSettings: "+/-: Change focus | b/n: Change breaks | Enter: Save | q: Back",
HelpStats: "q/Esc: Back",
SettingsTitle: "TIMER SETTINGS",
StatsTitle: "STATISTICS",
SessionComplete: "🎉 Session complete! Time for a break.",
BreakComplete: "⏰ Break over! Time to focus.",
PomodorosToday: "Completed today:",
PomodorosTotal: "Completed total:",
NextLongBreakIn: "Long break in:",
MinutesShort: "min",
},
}
+526
View File
@@ -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)
}