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