areas.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package init
  6
  7import (
  8	"errors"
  9	"fmt"
 10
 11	"github.com/charmbracelet/huh"
 12
 13	"git.secluded.site/lune/internal/config"
 14)
 15
 16func manageAreas(cfg *config.Config) error {
 17	nav := manageAreasAsStep(cfg)
 18	if nav == navQuit {
 19		return errQuit
 20	}
 21
 22	return nil
 23}
 24
 25// manageAreasAsStep runs areas management as a wizard step with Back/Next navigation.
 26func manageAreasAsStep(cfg *config.Config) wizardNav {
 27	for {
 28		options := buildAreaStepOptions(cfg.Areas)
 29
 30		choice, err := runListSelect(
 31			"Areas & Goals",
 32			"Areas organize your life (Work, Health, etc). Goals belong to areas.",
 33			options,
 34		)
 35		if err != nil {
 36			return navQuit
 37		}
 38
 39		switch choice {
 40		case choiceBack:
 41			return navBack
 42		case choiceNext:
 43			return navNext
 44		case choiceAdd:
 45			if err := addArea(cfg); errors.Is(err, errQuit) {
 46				return navQuit
 47			}
 48		default:
 49			idx, ok := parseEditIndex(choice)
 50			if !ok {
 51				continue
 52			}
 53
 54			if err := manageAreaActions(cfg, idx); errors.Is(err, errQuit) {
 55				return navQuit
 56			}
 57		}
 58	}
 59}
 60
 61func buildAreaStepOptions(areas []config.Area) []huh.Option[string] {
 62	options := []huh.Option[string]{
 63		huh.NewOption("Add new area", choiceAdd),
 64	}
 65
 66	for idx, area := range areas {
 67		goalCount := len(area.Goals)
 68		label := fmt.Sprintf("%s (%s) - %d goal(s)", area.Name, area.Key, goalCount)
 69		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
 70	}
 71
 72	options = append(options,
 73		huh.NewOption("← Back", choiceBack),
 74		huh.NewOption("Next →", choiceNext),
 75	)
 76
 77	return options
 78}
 79
 80func addArea(cfg *config.Config) error {
 81	area, err := editArea(nil, cfg)
 82	if err != nil {
 83		if errors.Is(err, errBack) {
 84			return nil
 85		}
 86
 87		return err
 88	}
 89
 90	cfg.Areas = append(cfg.Areas, *area)
 91
 92	return maybeManageGoals(cfg, len(cfg.Areas)-1)
 93}
 94
 95func manageAreaActions(cfg *config.Config, idx int) error {
 96	if idx < 0 || idx >= len(cfg.Areas) {
 97		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
 98	}
 99
100	area := &cfg.Areas[idx]
101
102	action, err := runActionSelect(fmt.Sprintf("Area: %s (%s)", area.Name, area.Key), true)
103	if err != nil {
104		return err
105	}
106
107	return handleAreaAction(cfg, idx, area, action)
108}
109
110func handleAreaAction(cfg *config.Config, idx int, area *config.Area, action itemAction) error {
111	switch action {
112	case itemActionEdit:
113		updated, err := editArea(area, cfg)
114		if err != nil {
115			if errors.Is(err, errBack) {
116				return nil
117			}
118
119			return err
120		}
121
122		if updated != nil {
123			cfg.Areas[idx] = *updated
124		}
125	case itemActionGoals:
126		return manageGoals(cfg, idx)
127	case itemActionDelete:
128		return deleteArea(cfg, idx)
129	case itemActionNone:
130		// User cancelled or went back
131	}
132
133	return nil
134}
135
136func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) {
137	area := config.Area{}
138	if existing != nil {
139		area = *existing
140	}
141
142	err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{
143		itemType:         "area",
144		namePlaceholder:  "Personal",
145		keyPlaceholder:   "personal",
146		keyValidator:     validateAreaKey(cfg, existing),
147		supportsDeepLink: true,
148	})
149	if err != nil {
150		return nil, err
151	}
152
153	return &area, nil
154}
155
156func validateAreaKey(cfg *config.Config, existing *config.Area) func(string) error {
157	return func(input string) error {
158		if err := validateKeyFormat(input); err != nil {
159			return err
160		}
161
162		for idx := range cfg.Areas {
163			if existing != nil && &cfg.Areas[idx] == existing {
164				continue
165			}
166
167			if cfg.Areas[idx].Key == input {
168				return errKeyDuplicate
169			}
170		}
171
172		return nil
173	}
174}
175
176func maybeManageGoals(cfg *config.Config, areaIdx int) error {
177	var manage bool
178
179	err := huh.NewConfirm().
180		Title("Add goals for this area?").
181		Affirmative("Yes").
182		Negative("Not now").
183		Value(&manage).
184		Run()
185	if err != nil {
186		if errors.Is(err, huh.ErrUserAborted) {
187			return errQuit
188		}
189
190		return err
191	}
192
193	if manage {
194		return manageGoals(cfg, areaIdx)
195	}
196
197	return nil
198}
199
200func manageGoals(cfg *config.Config, areaIdx int) error {
201	if areaIdx < 0 || areaIdx >= len(cfg.Areas) {
202		return fmt.Errorf("%w: area %d", errIndexOutRange, areaIdx)
203	}
204
205	area := &cfg.Areas[areaIdx]
206
207	for {
208		options := buildGoalOptions(area.Goals)
209
210		choice, err := runListSelect(
211			fmt.Sprintf("Goals for: %s (%s)", area.Name, area.Key),
212			"",
213			options,
214		)
215		if err != nil {
216			return err
217		}
218
219		if choice == choiceDone {
220			return nil
221		}
222
223		if choice == choiceAdd {
224			if err := addGoal(area); err != nil {
225				return err
226			}
227
228			continue
229		}
230
231		idx, ok := parseEditIndex(choice)
232		if !ok {
233			continue
234		}
235
236		if err := manageGoalActions(area, idx); err != nil {
237			return err
238		}
239	}
240}
241
242func buildGoalOptions(goals []config.Goal) []huh.Option[string] {
243	options := []huh.Option[string]{
244		huh.NewOption("Add new goal", choiceAdd),
245	}
246
247	for idx, goal := range goals {
248		label := fmt.Sprintf("%s (%s)", goal.Name, goal.Key)
249		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
250	}
251
252	options = append(options, huh.NewOption("Done", choiceDone))
253
254	return options
255}
256
257func addGoal(area *config.Area) error {
258	goal, err := editGoal(nil, area)
259	if err != nil {
260		if errors.Is(err, errBack) {
261			return nil
262		}
263
264		return err
265	}
266
267	area.Goals = append(area.Goals, *goal)
268
269	return nil
270}
271
272func manageGoalActions(area *config.Area, idx int) error {
273	if idx < 0 || idx >= len(area.Goals) {
274		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
275	}
276
277	goal := &area.Goals[idx]
278
279	action, err := runActionSelect(fmt.Sprintf("Goal: %s (%s)", goal.Name, goal.Key), false)
280	if err != nil {
281		return err
282	}
283
284	switch action {
285	case itemActionEdit:
286		updated, err := editGoal(goal, area)
287		if err != nil {
288			if errors.Is(err, errBack) {
289				return nil
290			}
291
292			return err
293		}
294
295		if updated != nil {
296			area.Goals[idx] = *updated
297		}
298	case itemActionDelete:
299		return deleteGoal(area, idx)
300	case itemActionNone, itemActionGoals:
301		// User cancelled or went back; goals not applicable here
302	}
303
304	return nil
305}
306
307func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) {
308	goal := config.Goal{}
309	if existing != nil {
310		goal = *existing
311	}
312
313	err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{
314		itemType:         "goal",
315		namePlaceholder:  "Learn Gaelic",
316		keyPlaceholder:   "gaelic",
317		keyValidator:     validateGoalKey(area, existing),
318		supportsDeepLink: true,
319	})
320	if err != nil {
321		return nil, err
322	}
323
324	return &goal, nil
325}
326
327func validateGoalKey(area *config.Area, existing *config.Goal) func(string) error {
328	return func(input string) error {
329		if err := validateKeyFormat(input); err != nil {
330			return err
331		}
332
333		for idx := range area.Goals {
334			if existing != nil && &area.Goals[idx] == existing {
335				continue
336			}
337
338			if area.Goals[idx].Key == input {
339				return errKeyDuplicate
340			}
341		}
342
343		return nil
344	}
345}
346
347func deleteArea(cfg *config.Config, idx int) error {
348	if idx < 0 || idx >= len(cfg.Areas) {
349		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
350	}
351
352	area := cfg.Areas[idx]
353
354	var confirm bool
355
356	err := huh.NewConfirm().
357		Title(fmt.Sprintf("Delete area '%s'?", area.Name)).
358		Description(fmt.Sprintf("This will also remove %d goal(s). This cannot be undone.", len(area.Goals))).
359		Affirmative("Delete").
360		Negative("Cancel").
361		Value(&confirm).
362		Run()
363	if err != nil {
364		if errors.Is(err, huh.ErrUserAborted) {
365			return errQuit
366		}
367
368		return err
369	}
370
371	if confirm {
372		cfg.Areas = append(cfg.Areas[:idx], cfg.Areas[idx+1:]...)
373		if cfg.Defaults.Area == area.Key {
374			cfg.Defaults.Area = ""
375		}
376	}
377
378	return nil
379}
380
381func deleteGoal(area *config.Area, idx int) error {
382	if idx < 0 || idx >= len(area.Goals) {
383		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
384	}
385
386	goal := area.Goals[idx]
387
388	var confirm bool
389
390	err := huh.NewConfirm().
391		Title(fmt.Sprintf("Delete goal '%s'?", goal.Name)).
392		Description("This cannot be undone.").
393		Affirmative("Delete").
394		Negative("Cancel").
395		Value(&confirm).
396		Run()
397	if err != nil {
398		if errors.Is(err, huh.ErrUserAborted) {
399			return errQuit
400		}
401
402		return err
403	}
404
405	if confirm {
406		area.Goals = append(area.Goals[:idx], area.Goals[idx+1:]...)
407	}
408
409	return nil
410}