mirror of
https://codeberg.org/Kankys/doxa.git
synced 2026-11-11 06:44:36 +01:00
847 lines
20 KiB
Go
847 lines
20 KiB
Go
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)
|
|
}
|