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,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.
|
||||
@@ -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.
|
||||
@@ -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ů.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
Executable
+99
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user