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) }