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}