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.
+41
View File
@@ -0,0 +1,41 @@
# DOXA Habit User Manual 🎯
This manual describes how to navigate and manage habits in the DOXA Habit tracker.
## Getting Started
Start the application by running:
```bash
doxa-habit
```
Press `Enter` on the splash screen to enter the main interface.
## Interface States
1. **MAIN VIEW**: A list showing all your habits, their current streaks (e.g., `🔥3` for 3 consecutive days, or `❄️0`), and a mini calendar heatmap showing the last 7 days (`▓` for completed, `░` for missed).
2. **DETAIL MODAL**: Displays description, frequency, stats for last 7/30 days, and a detailed heatmap.
3. **STATS VIEW**: Table summarizing all your habits' statistics.
4. **ADD / EDIT FORM**: Text inputs to add or modify habits.
## Controls
### Main Screen
- `1`: Add a new habit.
- `2`: Edit the selected habit.
- `3`: Export completion history to `~/.doxa-habit/export/<habit_name>_history.csv`.
- `4`: Delete selected habit.
- `t`: Toggle today's status (Completed ✅ vs Not Completed ⬜).
- `s`: Show statistics of all habits.
- `l`: Switch UI language (English/Czech).
- `/`: Filter habits by search term.
- `Enter`: Open details modal.
- `q` / `Esc`: Exit the program.
### Form Fields (Add/Edit)
- `Tab`: Jump between the "Name" and "Description" fields.
- `f`: Toggle habit frequency between "Daily" and "Weekly".
- `Enter`: Save and create/modify the habit.
- `q` / `Esc`: Go back to the main list without saving.
### Modal & Stats Views
- `q` / `Esc`: Return to the main list view.
+41
View File
@@ -0,0 +1,41 @@
# Uživatelský manuál DOXA Habit 🎯
Tento manuál popisuje použití aplikace DOXA Habit pro sledování návyků.
## Spuštění programu
Spusťte aplikaci příkazem:
```bash
doxa-habit
```
Na úvodní obrazovce stiskněte `Enter` pro přechod do hlavního rozhraní.
## Rozhraní aplikace
1. **SEZNAM NÁVYKŮ**: Zobrazuje všechny vaše návyky, aktuální streak (např. `🔥3` nebo `❄️0`) a mini heatmapu posledních 7 dní (`▓` splněno, `░` nesplněno).
2. **DETAIL MODAL**: Zobrazí podrobnější popis návyku, frekvenci a úspěšnost v procentech za 7/30 dní.
3. **STATISTIKY**: Tabulkový přehled všech vašich návyků a jejich úspěšnosti.
4. **PŘIDAT / UPRAVIT**: Formulář pro zakládání či editaci návyků.
## Ovládání
### Hlavní obrazovka
- `1`: Přidat nový návyk.
- `2`: Upravit vybraný návyk.
- `3`: Exportovat historii splnění do souboru `~/.doxa-habit/export/<název>_history.csv`.
- `4`: Smazat vybraný návyk.
- `t`: Označit návyk jako dnes splněný (střídá ✅ a ⬜).
- `s`: Zobrazit celkové statistiky všech návyků.
- `l`: Přepne jazyk rozhraní (čeština/angličtina).
- `/`: Vyhledat/filtrovat návyky podle názvu.
- `Enter`: Otevřít detail návyku v modálním okně.
- `q` / `Esc`: Ukončit program.
### Formulář (Přidat / Upravit)
- `Tab`: Přechází mezi poli "Název" a "Popis".
- `f`: Přepíná frekvenci návyku (Denní / Týdenní).
- `Enter`: Uloží data.
- `q` / `Esc`: Zruší změny a vrátí se zpět na hlavní seznam.
### Okna detailu a statistik
- `q` / `Esc`: Vrátí se na hlavní seznam návyků.
+36
View File
@@ -0,0 +1,36 @@
# DOXA Habit 🎯
**Minimalist habit tracker for the terminal with streak tracking**
DOXA Habit is a terminal-based tool to organize, record, and maintain daily and weekly habits. It calculates streaks and offers a visual 7-day mini heatmap of your progress.
## Features
- **Daily & Weekly Habits**: Setup name, description, and execution frequency.
- **Completion toggle**: Mark today as completed or not.
- **Streak & Heatmap**: Displays your current streak (🔥/❄️) and a mini calendar (▓/░) of the last 7 days.
- **Overall Statistics**: Success rates over 7 and 30 days.
- **Local JSON Storage**: Kept securely offline at `~/.doxa-habit/doxa_habit.json`.
- **Export to CSV**: Export your habit completion log history.
- **Bilingual**: CZ/EN language support via the `l` key.
## Installation
```bash
cd doxa-habit
chmod +x install.sh
./install.sh
```
## Hotkeys
- `1` - Add Habit
- `2` - Edit selected Habit
- `3` - Export selected Habit history to CSV
- `4` - Delete Habit
- `t` - Toggle completed state for today
- `s` - View Overall Statistics
- `l` - Toggle language (EN/CS)
- `/` - Search habits by name
- `Enter` - View Habit Details Modal
- `q` / `Esc` - Go back / Quit
+36
View File
@@ -0,0 +1,36 @@
# DOXA Habit 🎯
**Minimalistický tracker návyků pro terminál se sledováním streaku**
DOXA Habit je terminálová aplikace pro organizaci, zaznamenávání a udržování denních i týdenních návyků. Počítá aktuální sérii splnění (streak) a vizualizuje pokrok.
## Vlastnosti
- **Denní a týdenní návyky**: Volitelný název, popis a frekvence plnění.
- **Označení splnění**: Rychlé označení dnešního splnění (toggle).
- **Streak & Heatmap**: Zobrazení aktuální série (🔥/❄️) a 7denního mini kalendáře (▓/░).
- **Celkové statistiky**: Úspěšnost plnění za posledních 7 a 30 dní.
- **Local-first**: Data jsou uložena v offline JSON databázi `~/.doxa-habit/doxa_habit.json`.
- **Export do CSV**: Uložení kompletní historie plnění návyku.
- **Dvojjazyčnost**: Podpora češtiny a angličtiny přepínatelná klávesou `l`.
## Instalace
```bash
cd doxa-habit
chmod +x install.sh
./install.sh
```
## Klávesové zkratky
- `1` - Přidat návyk
- `2` - Upravit vybraný návyk
- `3` - Exportovat historii návyku do CSV
- `4` - Smazat návyk
- `t` - Změnit stav splnění pro dnešní den
- `s` - Zobrazit celkové statistiky
- `l` - Přepnout jazyk (CZ/EN)
- `/` - Vyhledávání návyků podle názvu
- `Enter` - Zobrazit detail návyku v modálním okně
- `q` / `Esc` - Zpět / Konec
BIN
View File
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
module doxa-habit
go 1.26.5
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
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
)
+52
View File
@@ -0,0 +1,52 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
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 v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
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-HABIT Installation Script
# --- Nastavení ---
APP_NAME="doxa-habit"
INSTALL_DIR="$HOME/.local/bin"
CONFIG_DIR="$HOME/.doxa-habit"
CONFIG_FILE="doxa_habit.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-habit" 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-habit."
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)
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func getStoragePath() string {
home, err := os.UserHomeDir()
if err != nil {
return "doxa_habit.json"
}
configDir := filepath.Join(home, ".doxa-habit")
if _, err := os.Stat(configDir); os.IsNotExist(err) {
os.MkdirAll(configDir, 0755)
}
return filepath.Join(configDir, "doxa_habit.json")
}
func loadHabits() []Habit {
path := getStoragePath()
file, err := os.ReadFile(path)
if err != nil {
return []Habit{}
}
var habits []Habit
if err := json.Unmarshal(file, &habits); err != nil {
return []Habit{}
}
return habits
}
func saveHabits(habits []Habit) {
path := getStoragePath()
data, err := json.MarshalIndent(habits, "", " ")
if err != nil {
fmt.Println("Chyba při ukládání dat:", err)
return
}
os.WriteFile(path, data, 0644)
}
+128
View File
@@ -0,0 +1,128 @@
package main
type Translation struct {
Title string
MenuAdd string
MenuEdit string
MenuExport string
MenuDelete string
MenuToggle string
MenuStats string
MenuLang string
MenuQuit string
ListTitle string
NoHabits string
AddTitle string
EditTitle string
LabelName string
LabelDesc string
LabelFreq string
PlaceholderName string
PlaceholderDesc string
FreqDaily string
FreqWeekly string
HelpForm string
HelpList string
HelpModal string
StatusSaved string
StatusEdited string
StatusDeleted string
StatusExported string
ConfirmDelete string
ConfirmYes string
ConfirmNo string
StatsTitle string
LabelStreak string
LabelSuccess7 string
LabelSuccess30 string
LabelCreated string
LabelCompleted string
LabelNotCompleted string
ErrNameRequired string
HelpStats string
}
var Translations = map[string]Translation{
"cs": {
Title: "DOXA-HABIT 🦅",
MenuAdd: "1: Přidat",
MenuEdit: "2: Upravit",
MenuExport: "3: Export CSV",
MenuDelete: "4: Smazat",
MenuToggle: "t: Splnit dnes",
MenuStats: "s: Statistiky",
MenuLang: "l: EN",
MenuQuit: "q: Konec",
ListTitle: "--- SEZNAM NÁVYKŮ ---",
NoHabits: " (Žádné návyky...)",
AddTitle: "PŘIDAT NÁVYK",
EditTitle: "UPRAVIT NÁVYK",
LabelName: "Název:",
LabelDesc: "Popis:",
LabelFreq: "Frekvence:",
PlaceholderName: "Název (povinné)",
PlaceholderDesc: "Popis (nepovinný)",
FreqDaily: "Denní",
FreqWeekly: "Týdenní",
HelpForm: "[Tab] Další pole | [Enter] Uložit | [f] Změnit frekvenci | [q] Zrušit",
HelpList: "↑/↓: Vybrat | Enter: Detail | t: Splnit | s: Statistiky | /: Hledat | q: Konec",
HelpModal: "q: Zavřít | 2: Editovat | ↑/↓: Posun",
StatusSaved: "Návyk byl uložen.",
StatusEdited: "Změny byly uloženy.",
StatusDeleted: "Návyk byl smazán.",
StatusExported: "Statistiky exportovány do CSV.",
ConfirmDelete: "Opravdu chcete smazat tento návyk?",
ConfirmYes: "y: Ano",
ConfirmNo: "n: Ne / Esc",
StatsTitle: "CELKOVÉ STATISTIKY",
LabelStreak: "Aktuální streak:",
LabelSuccess7: "Úspěšnost 7 dní:",
LabelSuccess30: "Úspěšnost 30 dní:",
LabelCreated: "Vytvořeno:",
LabelCompleted: "Splněno",
LabelNotCompleted: "Nesplněno",
ErrNameRequired: "Chyba: Název je povinný!",
HelpStats: "q/Esc: Zpět",
},
"en": {
Title: "DOXA-HABIT 🦅",
MenuAdd: "1: Add",
MenuEdit: "2: Edit",
MenuExport: "3: Export CSV",
MenuDelete: "4: Delete",
MenuToggle: "t: Complete today",
MenuStats: "s: Stats",
MenuLang: "l: CS",
MenuQuit: "q: Quit",
ListTitle: "--- HABITS LIST ---",
NoHabits: " (No habits...)",
AddTitle: "ADD HABIT",
EditTitle: "EDIT HABIT",
LabelName: "Name:",
LabelDesc: "Description:",
LabelFreq: "Frequency:",
PlaceholderName: "Name (required)",
PlaceholderDesc: "Description (optional)",
FreqDaily: "Daily",
FreqWeekly: "Weekly",
HelpForm: "[Tab] Next field | [Enter] Save | [f] Change frequency | [q] Cancel",
HelpList: "↑/↓: Select | Enter: Detail | t: Toggle Done | s: Stats | /: Search | q: Quit",
HelpModal: "q: Close | 2: Edit | ↑/↓: Scroll",
StatusSaved: "Habit saved.",
StatusEdited: "Changes saved.",
StatusDeleted: "Habit deleted.",
StatusExported: "Stats exported to CSV.",
ConfirmDelete: "Are you sure you want to delete this habit?",
ConfirmYes: "y: Yes",
ConfirmNo: "n: No / Esc",
StatsTitle: "OVERALL STATISTICS",
LabelStreak: "Current streak:",
LabelSuccess7: "Success 7 days:",
LabelSuccess30: "Success 30 days:",
LabelCreated: "Created:",
LabelCompleted: "Done",
LabelNotCompleted: "Not done",
ErrNameRequired: "Error: Name is required!",
HelpStats: "q/Esc: Back",
},
}
+846
View File
@@ -0,0 +1,846 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
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)
selectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205"))
completedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#2ECC71"))
streakStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F39C12"))
labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
valueStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA"))
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
cursorStyle = focusedStyle.Copy()
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("205")).
Padding(1, 2).Align(lipgloss.Center)
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
)
// ─── Data Structure ──────────────────────────────────────────────────────────
type Habit struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Frequency string `json:"frequency"` // "daily" or "weekly"
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
CompletedDates []string `json:"completed_dates"` // format "2006-01-02"
}
type AppState int
const (
StateStartup AppState = iota
StateViewHabits
StateViewHabitModal
StateAddHabit
StateEditHabit
StateStats
StateConfirmDelete
StateSearch
)
// ─── Model ────────────────────────────────────────────────────────────────────
type model struct {
habits []Habit
filteredHabits []Habit
cursor int
state AppState
filterText string
textInputs []textinput.Model // 0=name, 1=desc
searchInput textinput.Model
viewport viewport.Model
ready bool
focusIndex int
selectedHabitID int64
statusMsg string
lang string
width int
height int
editFrequency string // "daily" or "weekly"
}
func NewModel() model {
inputs := make([]textinput.Model, 2)
for i := range inputs {
t := textinput.New()
t.Cursor.Style = cursorStyle
inputs[i] = t
}
inputs[0].Focus()
inputs[0].PromptStyle = focusedStyle
inputs[0].TextStyle = focusedStyle
si := textinput.New()
si.Prompt = "🔍 "
si.Placeholder = "Hledat..."
m := model{
habits: loadHabits(),
cursor: 0,
state: StateStartup,
textInputs: inputs,
searchInput: si,
focusIndex: 0,
lang: "cs",
editFrequency: "daily",
}
m.applyFilter()
m.updatePlaceholders()
return m
}
func (m model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.EnterAltScreen)
}
func (m *model) applyFilter() {
if m.filterText == "" {
m.filteredHabits = m.habits
} else {
m.filteredHabits = []Habit{}
query := strings.ToLower(m.filterText)
for _, h := range m.habits {
if strings.Contains(strings.ToLower(h.Name), query) ||
strings.Contains(strings.ToLower(h.Description), query) {
m.filteredHabits = append(m.filteredHabits, h)
}
}
}
if m.cursor >= len(m.filteredHabits) {
m.cursor = 0
if len(m.filteredHabits) > 0 {
m.cursor = len(m.filteredHabits) - 1
}
}
}
func (m *model) updatePlaceholders() {
tr := Translations[m.lang]
m.textInputs[0].Placeholder = tr.PlaceholderName
m.textInputs[1].Placeholder = tr.PlaceholderDesc
m.searchInput.Placeholder = "..."
}
// ─── Update ───────────────────────────────────────────────────────────────────
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
tr := Translations[m.lang]
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
headerHeight := 8
footerHeight := 3
vpHeight := msg.Height - headerHeight - footerHeight
if vpHeight < 1 {
vpHeight = 1
}
if !m.ready {
m.viewport = viewport.New(msg.Width-6, vpHeight)
m.ready = true
} else {
m.viewport.Width = msg.Width - 6
m.viewport.Height = vpHeight
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.state == StateConfirmDelete {
m.state = StateViewHabits
m.statusMsg = ""
return m, nil
}
if m.state == StateViewHabits {
if m.filterText != "" {
m.filterText = ""
m.applyFilter()
return m, nil
}
return m, tea.Quit
}
m.resetToView()
return m, nil
case "q":
if m.state == StateStartup {
return m, tea.Quit
}
if m.state == StateConfirmDelete {
m.state = StateViewHabits
m.statusMsg = ""
return m, nil
}
if m.state == StateViewHabitModal {
m.state = StateViewHabits
return m, nil
}
if m.state == StateViewHabits {
if m.filterText != "" {
m.filterText = ""
m.applyFilter()
return m, nil
}
return m, tea.Quit
}
m.resetToView()
return m, nil
case "l":
if m.state == StateViewHabits {
if m.lang == "cs" {
m.lang = "en"
} else {
m.lang = "cs"
}
m.updatePlaceholders()
return m, nil
}
case "/":
if m.state == StateViewHabits {
m.state = StateSearch
m.searchInput.Focus()
m.searchInput.SetValue("")
return m, nil
}
case "tab", "shift+tab":
if m.state == StateAddHabit || m.state == StateEditHabit {
if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > 1 {
m.focusIndex = 0
}
if m.focusIndex < 0 {
m.focusIndex = 1
}
return m.updateFocus()
}
case "up", "k":
if m.state == StateViewHabits && m.cursor > 0 {
m.cursor--
m.viewport.LineUp(1)
}
if m.state == StateViewHabitModal {
m.viewport.LineUp(1)
}
case "down", "j":
if m.state == StateViewHabits {
max := len(m.filteredHabits)
if m.cursor < max-1 {
m.cursor++
m.viewport.LineDown(1)
}
}
if m.state == StateViewHabitModal {
m.viewport.LineDown(1)
}
case "f":
if m.state == StateAddHabit || m.state == StateEditHabit {
if m.editFrequency == "daily" {
m.editFrequency = "weekly"
} else {
m.editFrequency = "daily"
}
return m, nil
}
case "enter":
switch m.state {
case StateStartup:
m.state = StateViewHabits
case StateViewHabits:
if len(m.filteredHabits) > 0 {
m.state = StateViewHabitModal
m.selectedHabitID = m.filteredHabits[m.cursor].ID
m.updateModalContent()
}
case StateSearch:
m.filterText = m.searchInput.Value()
m.applyFilter()
m.state = StateViewHabits
case StateAddHabit:
m.handleAddHabit()
case StateEditHabit:
m.handleEditHabit()
case StateConfirmDelete:
if m.focusIndex == 0 {
m.handleDeleteHabit()
m.state = StateViewHabits
m.statusMsg = tr.StatusDeleted
return m, nil
} else {
m.state = StateViewHabits
m.statusMsg = ""
return m, nil
}
}
case "1":
if m.state == StateViewHabits {
m.state = StateAddHabit
m.resetInputs()
m.editFrequency = "daily"
return m, nil
}
case "2":
if m.state == StateViewHabits && len(m.filteredHabits) > 0 {
m.state = StateEditHabit
m.selectedHabitID = m.filteredHabits[m.cursor].ID
m.populateInputs(m.filteredHabits[m.cursor])
return m, nil
}
if m.state == StateViewHabitModal {
for _, h := range m.habits {
if h.ID == m.selectedHabitID {
m.state = StateEditHabit
m.populateInputs(h)
break
}
}
return m, nil
}
case "3":
if m.state == StateViewHabits && len(m.filteredHabits) > 0 {
m.exportCSV(m.filteredHabits[m.cursor])
}
case "4":
if m.state == StateViewHabits && len(m.filteredHabits) > 0 {
m.state = StateConfirmDelete
m.selectedHabitID = m.filteredHabits[m.cursor].ID
m.focusIndex = 0
}
case "t":
if m.state == StateViewHabits && len(m.filteredHabits) > 0 {
m.toggleHabitComplete(m.filteredHabits[m.cursor].ID)
}
case "s":
if m.state == StateViewHabits {
m.state = StateStats
return m, nil
}
}
}
if m.state == StateSearch {
m.searchInput, cmd = m.searchInput.Update(msg)
return m, cmd
}
if m.state == StateAddHabit || m.state == StateEditHabit {
m.textInputs[m.focusIndex], cmd = m.textInputs[m.focusIndex].Update(msg)
return m, cmd
}
if m.state == StateConfirmDelete {
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "y":
m.handleDeleteHabit()
m.state = StateViewHabits
m.statusMsg = tr.StatusDeleted
return m, nil
case "n", "esc":
m.state = StateViewHabits
m.statusMsg = ""
return m, nil
}
}
}
return m, nil
}
func (m *model) updateFocus() (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
for i := range m.textInputs {
m.textInputs[i].Blur()
m.textInputs[i].PromptStyle = lipgloss.NewStyle()
}
cmds = append(cmds, m.textInputs[m.focusIndex].Focus())
m.textInputs[m.focusIndex].PromptStyle = focusedStyle
return m, tea.Batch(cmds...)
}
func (m *model) resetToView() {
m.state = StateViewHabits
m.statusMsg = ""
m.filterText = ""
m.applyFilter()
m.resetInputs()
}
func (m *model) resetInputs() {
for i := range m.textInputs {
m.textInputs[i].SetValue("")
m.textInputs[i].Blur()
}
m.focusIndex = 0
m.updateFocus()
}
func (m *model) populateInputs(h Habit) {
m.textInputs[0].SetValue(h.Name)
m.textInputs[1].SetValue(h.Description)
m.editFrequency = h.Frequency
m.focusIndex = 0
m.updateFocus()
}
func (m *model) toggleHabitComplete(id int64) {
today := time.Now().Format("2006-01-02")
for i, h := range m.habits {
if h.ID == id {
found := -1
for idx, d := range h.CompletedDates {
if d == today {
found = idx
break
}
}
if found != -1 {
// Remove completion
m.habits[i].CompletedDates = append(h.CompletedDates[:found], h.CompletedDates[found+1:]...)
} else {
// Complete
m.habits[i].CompletedDates = append(h.CompletedDates, today)
}
m.habits[i].ModifiedAt = time.Now()
break
}
}
saveHabits(m.habits)
m.applyFilter()
}
func (m *model) handleAddHabit() {
tr := Translations[m.lang]
name := m.textInputs[0].Value()
desc := m.textInputs[1].Value()
if name == "" {
m.statusMsg = tr.ErrNameRequired
return
}
now := time.Now()
m.habits = append(m.habits, Habit{
ID: now.UnixMilli(),
Name: name,
Description: desc,
Frequency: m.editFrequency,
CreatedAt: now,
ModifiedAt: now,
CompletedDates: []string{},
})
saveHabits(m.habits)
m.applyFilter()
m.statusMsg = tr.StatusSaved
m.state = StateViewHabits
}
func (m *model) handleEditHabit() {
tr := Translations[m.lang]
name := m.textInputs[0].Value()
desc := m.textInputs[1].Value()
if name == "" {
m.statusMsg = tr.ErrNameRequired
return
}
for i, h := range m.habits {
if h.ID == m.selectedHabitID {
m.habits[i].Name = name
m.habits[i].Description = desc
m.habits[i].Frequency = m.editFrequency
m.habits[i].ModifiedAt = time.Now()
break
}
}
saveHabits(m.habits)
m.applyFilter()
m.statusMsg = tr.StatusEdited
m.state = StateViewHabits
}
func (m *model) handleDeleteHabit() {
found := -1
for i, h := range m.habits {
if h.ID == m.selectedHabitID {
found = i
break
}
}
if found != -1 {
m.habits = append(m.habits[:found], m.habits[found+1:]...)
saveHabits(m.habits)
}
m.applyFilter()
}
func (m *model) updateModalContent() {
tr := Translations[m.lang]
var h Habit
for _, habit := range m.habits {
if habit.ID == m.selectedHabitID {
h = habit
break
}
}
freqLabel := tr.FreqDaily
if h.Frequency == "weekly" {
freqLabel = tr.FreqWeekly
}
streak := getCurrentStreak(h)
success7 := getSuccessRate(h, 7)
success30 := getSuccessRate(h, 30)
modalContent := fmt.Sprintf("%s\n---\n[%d] %s\n\n%s\n\n%s %s\n%s %d\n%s %.0f%%\n%s %.0f%%\n%s %s\n\n Heatmap (Last 7 Days):\n %s",
tr.HelpModal,
h.ID, h.Name, h.Description,
tr.LabelFreq, freqLabel,
tr.LabelStreak, streak,
tr.LabelSuccess7, success7,
tr.LabelSuccess30, success30,
tr.LabelCreated, h.CreatedAt.Format("2006-01-02 15:04"),
renderMiniCalendar(h),
)
m.viewport.Width = m.width - 20
m.viewport.Height = m.height - 10
m.viewport.SetContent(modalContent)
}
func (m *model) exportCSV(h Habit) {
home, err := os.UserHomeDir()
if err != nil {
m.statusMsg = "Chyba zjištění domovského adresáře"
return
}
exportDir := filepath.Join(home, ".doxa-habit", "export")
os.MkdirAll(exportDir, 0755)
safeName := strings.ReplaceAll(h.Name, " ", "_")
filename := filepath.Join(exportDir, fmt.Sprintf("%s_history.csv", safeName))
file, err := os.Create(filename)
if err != nil {
m.statusMsg = "Chyba při zápisu souboru"
return
}
defer file.Close()
file.WriteString("Date,Completed\n")
// Write all dates
for _, d := range h.CompletedDates {
file.WriteString(fmt.Sprintf("%s,1\n", d))
}
m.statusMsg = fmt.Sprintf("Exportováno do %s", filename)
}
// ─── Helper Functions ────────────────────────────────────────────────────────
func isCompletedToday(h Habit) bool {
today := time.Now().Format("2006-01-02")
for _, d := range h.CompletedDates {
if d == today {
return true
}
}
return false
}
func getCurrentStreak(h Habit) int {
if len(h.CompletedDates) == 0 {
return 0
}
streak := 0
checkDate := time.Now()
// Parse completed dates into a map for fast lookup
completedMap := make(map[string]bool)
for _, d := range h.CompletedDates {
completedMap[d] = true
}
// If not completed today, check if it was completed yesterday to maintain streak
todayStr := checkDate.Format("2006-01-02")
if !completedMap[todayStr] {
checkDate = checkDate.AddDate(0, 0, -1)
}
for {
dateStr := checkDate.Format("2006-01-02")
if completedMap[dateStr] {
streak++
checkDate = checkDate.AddDate(0, 0, -1)
} else {
break
}
}
return streak
}
func getSuccessRate(h Habit, days int) float64 {
if len(h.CompletedDates) == 0 {
return 0
}
count := 0
now := time.Now()
completedMap := make(map[string]bool)
for _, d := range h.CompletedDates {
completedMap[d] = true
}
for i := 0; i < days; i++ {
dateStr := now.AddDate(0, 0, -i).Format("2006-01-02")
if completedMap[dateStr] {
count++
}
}
return (float64(count) / float64(days)) * 100
}
func renderProgressBar(percent float64, width int) string {
filled := int(percent / 100 * float64(width))
if filled > width {
filled = width
}
if filled < 0 {
filled = 0
}
return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]"
}
func renderMiniCalendar(h Habit) string {
var sb strings.Builder
now := time.Now()
completedMap := make(map[string]bool)
for _, d := range h.CompletedDates {
completedMap[d] = true
}
// Last 7 days in order (oldest to newest)
for i := 6; i >= 0; i-- {
day := now.AddDate(0, 0, -i)
dateStr := day.Format("2006-01-02")
if completedMap[dateStr] {
sb.WriteString("▓")
} else {
sb.WriteString("░")
}
}
return sb.String()
}
// ─── 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-HABIT 🦅")+"\n\n"+
"Local-First Habit Tracker\n\n"+
"Enter: Start | q: Quit\n\n"+
"License: MIT | © 2026 Kankys"))
case StateSearch:
b.WriteString("HLEDAT: \n" + m.searchInput.View() + "\n\n" + tr.HelpForm)
case StateViewHabits:
filterInfo := ""
if m.filterText != "" {
filterInfo = fmt.Sprintf(" [Filtr: %s]", m.filterText)
}
var listBuilder strings.Builder
if len(m.filteredHabits) == 0 {
listBuilder.WriteString(tr.NoHabits + "\n")
} else {
for i, h := range m.filteredHabits {
status := "⬜"
if isCompletedToday(h) {
status = "✅"
}
streak := getCurrentStreak(h)
streakStr := fmt.Sprintf("🔥%d", streak)
if streak == 0 {
streakStr = "❄️0"
}
cursor := " "
if m.cursor == i {
cursor = "> "
}
line := fmt.Sprintf("%s%s %-20s %-5s %s",
cursor,
status,
h.Name,
streakStyle.Render(streakStr),
renderMiniCalendar(h),
)
if m.cursor == i {
listBuilder.WriteString(selectedStyle.Render(line) + "\n")
} else {
listBuilder.WriteString(line + "\n")
}
}
}
if m.ready {
m.viewport.SetContent(listBuilder.String())
}
b.WriteString(fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s\n\n",
tr.MenuAdd, tr.MenuEdit, tr.MenuExport, tr.MenuDelete, tr.MenuToggle, tr.MenuStats, tr.MenuLang))
b.WriteString(tr.ListTitle + filterInfo + "\n")
if m.ready {
b.WriteString(m.viewport.View() + "\n")
} else {
b.WriteString(listBuilder.String())
}
b.WriteString("\n" + tr.HelpList)
case StateAddHabit, StateEditHabit:
title := tr.AddTitle
if m.state == StateEditHabit {
title = fmt.Sprintf("%s (%d)", tr.EditTitle, m.selectedHabitID)
}
b.WriteString(title + "\n\n")
b.WriteString(fmt.Sprintf("%-12s %s\n", tr.LabelName, m.textInputs[0].View()))
b.WriteString(fmt.Sprintf("%-12s %s\n", tr.LabelDesc, m.textInputs[1].View()))
freqLabel := tr.FreqDaily
if m.editFrequency == "weekly" {
freqLabel = tr.FreqWeekly
}
b.WriteString(fmt.Sprintf("%-12s %s\n", tr.LabelFreq, valueStyle.Render(freqLabel)))
b.WriteString("\n" + tr.HelpForm)
case StateViewHabitModal:
if m.ready {
b.WriteString(confirmBoxStyle.Render(m.viewport.View()))
} else {
b.WriteString(confirmBoxStyle.Render("Načítání..."))
}
case StateConfirmDelete:
var hToDelete Habit
for _, h := range m.habits {
if h.ID == m.selectedHabitID {
hToDelete = h
break
}
}
confirmText := fmt.Sprintf("%s\n\n[%d] %s\n\n%s | %s",
tr.ConfirmDelete,
hToDelete.ID,
hToDelete.Name,
tr.ConfirmYes, tr.ConfirmNo,
)
b.WriteString(confirmBoxStyle.Render(confirmText))
case StateStats:
b.WriteString(lipgloss.NewStyle().Bold(true).Render(tr.StatsTitle) + "\n\n")
b.WriteString(fmt.Sprintf(" %-25s %-8s %-12s\n", "Návyk", "Streak", "Úspěšnost (30 dní)"))
b.WriteString(" " + strings.Repeat("-", 50) + "\n")
for _, h := range m.habits {
streak := getCurrentStreak(h)
success30 := getSuccessRate(h, 30)
b.WriteString(fmt.Sprintf(" %-25s 🔥%-7d %.0f%%\n", h.Name, streak, success30))
}
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)
}