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