kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

commit a0e097c56ab4948663919d0d1994cd45c9888c95
parent df3b7ae44135c377700d6dbce06167a52a6356a4
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Fri,  5 Feb 2021 16:15:37 +0800

ref: introduce project schema concept

Diffstat:
Mcmd/kanban/main.go | 353++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcmd/kanban/map.go | 18++++++++++++------
Mcmd/kanban/widget.go | 20++++++++++++++------
Mkanban.go | 399+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
4 files changed, 454 insertions(+), 336 deletions(-)

diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -33,10 +33,6 @@ func main() { db, err := func() (*storm.DB, error) { path := filepath.Join(os.TempDir(), "kanban.db") fmt.Printf("%s\n", path) - var init = false - if _, err := os.Stat(path); os.IsNotExist(err) { - init = true - } db, err := storm.Open(path) if err != nil { return nil, fmt.Errorf("opening data file: %w", err) @@ -44,15 +40,11 @@ func main() { if err := db.Init(&kanban.Stage{}); err != nil { return nil, err } - if err := db.ReIndex(&kanban.Stage{}); err != nil { + if err := db.Init(&kanban.Ticket{}); err != nil { return nil, err } - if init { - for ii, stage := range []string{"Todo", "In Progress", "Testing", "Done"} { - if err := db.Save(&kanban.Stage{ID: ii + 1, Name: stage}); err != nil { - return nil, fmt.Errorf("creating default stages: %w", err) - } - } + if err := db.Init(&kanban.Project{}); err != nil { + return nil, err } return db, nil }() @@ -67,29 +59,29 @@ func main() { Kanban: &kanban.Kanban{ Store: db, }, - // TODO: render dynamically from storage. - Panels: []Panel{ - { - Label: "Todo", - Color: color.NRGBA{R: 0x91, G: 0x81, B: 0x8a, A: 220}, - Thickness: unit.Dp(50), - }, - { - Label: "In Progress", - Color: color.NRGBA{R: 0, G: 100, B: 200, A: 220}, - Thickness: unit.Dp(50), - }, - { - Label: "Testing", - Color: color.NRGBA{R: 200, G: 100, B: 0, A: 220}, - Thickness: unit.Dp(50), - }, - { - Label: "Done", - Color: color.NRGBA{R: 50, G: 200, B: 100, A: 220}, - Thickness: unit.Dp(50), - }, - }, + // // TODO: render dynamically from storage. + // Panels: []Panel{ + // { + // Label: "Todo", + // Color: color.NRGBA{R: 0x91, G: 0x81, B: 0x8a, A: 220}, + // Thickness: unit.Dp(50), + // }, + // { + // Label: "In Progress", + // Color: color.NRGBA{R: 0, G: 100, B: 200, A: 220}, + // Thickness: unit.Dp(50), + // }, + // { + // Label: "Testing", + // Color: color.NRGBA{R: 200, G: 100, B: 0, A: 220}, + // Thickness: unit.Dp(50), + // }, + // { + // Label: "Done", + // Color: color.NRGBA{R: 50, G: 200, B: 100, A: 220}, + // Thickness: unit.Dp(50), + // }, + // }, } if err := ui.Loop(); err != nil { log.Fatalf("error: %v", err) @@ -109,8 +101,12 @@ type ( // this object. type UI struct { *app.Window - Kanban *kanban.Kanban - Th *material.Theme + Kanban *kanban.Kanban + Th *material.Theme + + // ActiveProject is the project being operated on. + ActiveProject kanban.ID + Panels []Panel Rail Rail TicketStates Map @@ -119,9 +115,9 @@ type UI struct { TicketDetails TicketDetails DeleteDialog DeleteDialog FocusedTicket struct { - ID int + ID kanban.ID Index int - Stage int + Stage kanban.ID } CreateProjectButton widget.Clickable ProjectForm ProjectForm @@ -156,18 +152,13 @@ func (ui *UI) Update(gtx C) { case key.NameEscape: ui.Clear() case key.NameEnter, key.NameReturn: - // TODO: query for a single Ticket by ID, quickly. - tickets, err := ui.Kanban.Tickets() - if err != nil { - fmt.Printf("error: %v", err) - break - } - for _, t := range tickets { - t := t - if t.ID == ui.FocusedTicket.ID { - ui.InspectTicket(t) - break - } + var ( + t kanban.Ticket + ) + if err := ui.Kanban.Store.Find("ID", ui.FocusedTicket, &t); err != nil { + fmt.Printf("error: %v\n", err) + } else { + ui.InspectTicket(t) } case key.NameDownArrow: ui.Refocus(NextTicket) @@ -186,7 +177,7 @@ func (ui *UI) Update(gtx C) { ui.AddTicket(panel.Label) } } - for s, ok := ui.TicketStates.Next(); ok; s, ok = ui.TicketStates.Next() { + for _, s := ui.TicketStates.Next(); ui.TicketStates.More(); _, s = ui.TicketStates.Next() { t := (*Ticket)(s) if ui.Modal != nil { continue @@ -234,7 +225,7 @@ func (ui *UI) Update(gtx C) { ui.Clear() } if ui.DeleteDialog.Ok.Clicked() { - if err := ui.Kanban.Delete(ui.DeleteDialog.ID); err != nil { + if err := ui.Kanban.Finalize(ui.DeleteDialog.ID); err != nil { fmt.Printf("error: %s\n", err) } ui.Clear() @@ -254,6 +245,18 @@ func (ui *UI) Update(gtx C) { if ui.ProjectForm.Cancel.Clicked() { ui.Clear() } + if ui.ProjectForm.Submit.Clicked() { + if err := ui.Kanban.Store.Save(&kanban.Project{ + Name: ui.ProjectForm.Name.Text(), + }); err != nil { + log.Printf("saving new project: %v", err) + } + } + if p, ok := ui.Rail.Selected(); ok { + if projectID, err := strconv.Atoi(p); err == nil { + ui.ActiveProject = kanban.ID(projectID) + } + } } func (ui *UI) Layout(gtx C) D { @@ -261,11 +264,42 @@ func (ui *UI) Layout(gtx C) D { return layout.Flex{Axis: layout.Horizontal}.Layout( gtx, layout.Rigid(func(gtx C) D { - // TODO: render "active" destination in rail. - // TODO: model project with data primitive. + // @Todo: render "active" destination in rail. gtx.Constraints.Min.Y = gtx.Constraints.Max.Y gtx.Constraints.Max.X = gtx.Px(unit.Dp(80)) gtx.Constraints.Min.X = 0 + var ( + projects []kanban.Project + rc []RailChild + ) + if err := ui.Kanban.Store.AllByIndex("ID", &projects); err != nil { + log.Printf("error: loading projects: %v", err) + } + for _, p := range projects { + p := p + rc = append(rc, Destination(p.ID.String(), func(gtx C) D { + return layout.Stack{ + Alignment: layout.Center, + }.Layout( + gtx, + layout.Stacked(func(gtx C) D { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D { + return material.Label(ui.Th, unit.Dp(16), p.Name).Layout(gtx) + }) + }), + layout.Expanded(func(gtx C) D { + cs := gtx.Constraints + if p.ID == ui.ActiveProject { + return Rect{ + Color: color.NRGBA{A: 100}, + Size: f32.Pt(float32(cs.Max.X), float32(cs.Min.Y)), + }.Layout(gtx) + } + return D{Size: image.Point{X: cs.Max.X, Y: cs.Min.Y}} + }), + ) + })) + } return ui.Rail.Layout( gtx, func(gtx C) D { @@ -276,50 +310,52 @@ func (ui *UI) Layout(gtx C) D { return btn.Layout(gtx) }) }, - Destination("kanban", func(gtx C) D { - return material.Label(ui.Th, unit.Dp(16), "Kanban").Layout(gtx) - }), - Destination("avisha", func(gtx C) D { - return material.Label(ui.Th, unit.Dp(16), "Avisha").Layout(gtx) - }), - Destination("watch", func(gtx C) D { - return material.Label(ui.Th, unit.Dp(16), "Watch").Layout(gtx) - }), - Destination("gopack", func(gtx C) D { - return material.Label(ui.Th, unit.Dp(16), "GoPack").Layout(gtx) - }), + rc..., ) }), layout.Flexed(1, func(gtx C) D { return layout.Stack{}.Layout( gtx, layout.Stacked(func(gtx C) D { + if ui.ActiveProject.None() { + return D{} + } ui.TicketStates.Begin() - var panels = make([]layout.FlexChild, len(ui.Panels)) - for kk := range ui.Panels { - panel := &ui.Panels[kk] - panels[kk] = layout.Flexed(1, func(gtx C) D { - stage, _ := ui.Kanban.Stage(panel.Label) - var cards = make([]layout.ListElement, len(stage.Tickets)) - for ii, ticket := range stage.Tickets { - id := strconv.Itoa(ticket.ID) - cards[ii] = func(gtx C, ii int) D { - t := (*Ticket)(ui.TicketStates.New(id, unsafe.Pointer(&Ticket{}))) - t.Ticket = stage.Tickets[ii] - t.Stage = stage.Name - if ui.FocusedTicket.ID == t.ID { - return widget.Border{ - Color: color.NRGBA{B: 200, A: 200}, - Width: unit.Dp(2), - }.Layout(gtx, func(gtx C) D { - return t.Layout(gtx, ui.Th) - }) - } - return t.Layout(gtx, ui.Th) - } + var ( + project kanban.Project + stage kanban.Stage + ticket kanban.Ticket + t *Ticket + panels []layout.FlexChild + ) + // @fixme show project creation hint when there are no projects. + if err := ui.Kanban.Store.One("ID", ui.ActiveProject, &project); err != nil { + log.Printf("error: project %v", err) + } + for _, id := range project.Stages { + if err := ui.Kanban.Store.One("ID", id, &stage); err != nil { + log.Printf("error: stage %v", err) + } + // render the stage panel. + for _, id := range stage.Tickets { + if err := ui.Kanban.Store.One("ID", id, &ticket); err != nil { + log.Printf("error: ticket %v", err) } - return panel.Layout(gtx, ui.Th, cards...) - }) + t = (*Ticket)(ui.TicketStates.New(strconv.Itoa(int(id)), unsafe.Pointer(&Ticket{}))) + t.Ticket = ticket + t.Stage = stage.Name + panels = append(panels, layout.Flexed(1, func(gtx C) D { + if ui.FocusedTicket.ID == id { + return widget.Border{ + Color: color.NRGBA{B: 200, A: 200}, + Width: unit.Dp(2), + }.Layout(gtx, func(gtx C) D { + return t.Layout(gtx, ui.Th) + }) + } + return t.Layout(gtx, ui.Th) + })) + } } return layout.Flex{ Axis: layout.Horizontal, @@ -354,49 +390,54 @@ const ( // Refocus to the ticket in the given direction. // Allows movement between tickets and stages in sequential order. func (ui *UI) Refocus(d Direction) { - stages, err := ui.Kanban.ListStages() - if err != nil { - fmt.Printf("error: querying stages: %v", err) - return - } - for { - switch d { - case NextTicket: - ui.FocusedTicket.Index++ - if ui.FocusedTicket.Index > len(stages[ui.FocusedTicket.Stage].Tickets) { - ui.FocusedTicket.Index = 1 - ui.FocusedTicket.Stage++ - if ui.FocusedTicket.Stage > len(stages)-1 { - ui.FocusedTicket.Stage = 0 - } - } - case PreviousTicket: - ui.FocusedTicket.Index-- - if ui.FocusedTicket.Index < 1 { - ui.FocusedTicket.Stage-- - if ui.FocusedTicket.Stage < 0 { - ui.FocusedTicket.Stage = len(stages) - 1 - } - ui.FocusedTicket.Index = len(stages[ui.FocusedTicket.Stage].Tickets) - } - case NextStage: - ui.FocusedTicket.Index = 1 - ui.FocusedTicket.Stage++ - if ui.FocusedTicket.Stage > len(stages)-1 { - ui.FocusedTicket.Stage = 0 - } - case PreviousStage: - ui.FocusedTicket.Index = 1 - ui.FocusedTicket.Stage-- - if ui.FocusedTicket.Stage < 0 { - ui.FocusedTicket.Stage = len(stages) - 1 - } - } - if stage := stages[ui.FocusedTicket.Stage]; !stage.Empty() { - break - } - } - ui.FocusedTicket.ID = stages[ui.FocusedTicket.Stage].Tickets[ui.FocusedTicket.Index-1].ID + // var ( + // project kanban.Project + // stage kanban.Stage + // ) + // if err := ui.Kanban.Store.Find("ID", ui.ActiveProject, &project); err != nil { + // log.Printf("error: %v", err) + // return + // } + // if err := ui.Kanban.Store.Find("ID", projet.St) + + // for { + // switch d { + // case NextTicket: + // ui.FocusedTicket.Index++ + // if ui.FocusedTicket.Index > len(stages[ui.FocusedTicket.Stage].Tickets) { + // ui.FocusedTicket.Index = 1 + // ui.FocusedTicket.Stage++ + // if ui.FocusedTicket.Stage > len(stages)-1 { + // ui.FocusedTicket.Stage = 0 + // } + // } + // case PreviousTicket: + // ui.FocusedTicket.Index-- + // if ui.FocusedTicket.Index < 1 { + // ui.FocusedTicket.Stage-- + // if ui.FocusedTicket.Stage < 0 { + // ui.FocusedTicket.Stage = len(stages) - 1 + // } + // ui.FocusedTicket.Index = len(stages[ui.FocusedTicket.Stage].Tickets) + // } + // case NextStage: + // ui.FocusedTicket.Index = 1 + // ui.FocusedTicket.Stage++ + // if ui.FocusedTicket.Stage > len(stages)-1 { + // ui.FocusedTicket.Stage = 0 + // } + // case PreviousStage: + // ui.FocusedTicket.Index = 1 + // ui.FocusedTicket.Stage-- + // if ui.FocusedTicket.Stage < 0 { + // ui.FocusedTicket.Stage = len(stages) - 1 + // } + // } + // if stage := stages[ui.FocusedTicket.Stage]; !stage.Empty() { + // break + // } + // } + // ui.FocusedTicket.ID = stages[ui.FocusedTicket.Stage].Tickets[ui.FocusedTicket.Index-1].ID } // Clear resets navigational state. @@ -405,9 +446,9 @@ func (ui *UI) Clear() { ui.TicketForm = TicketForm{} ui.ProjectForm = ProjectForm{} ui.FocusedTicket = struct { - ID int + ID kanban.ID Index int - Stage int + Stage kanban.ID }{} } @@ -473,20 +514,18 @@ func (ui *UI) DeleteTicket(t kanban.Ticket) { // // TODO: tab navigation through form fields. type TicketForm struct { - Stage string - Data kanban.Ticket - Title component.TextField - Category component.TextField - Summary component.TextField - Details component.TextField - Submit widget.Clickable - Cancel widget.Clickable + Stage string + Data kanban.Ticket + Title component.TextField + Summary component.TextField + Details component.TextField + Submit widget.Clickable + Cancel widget.Clickable } func (form *TicketForm) Set(t kanban.Ticket) { form.Data = t form.Title.SetText(t.Title) - form.Category.SetText(t.Category) form.Summary.SetText(t.Summary) form.Details.SetText(t.Details) // form.References.SetText(t.References) @@ -496,12 +535,13 @@ func (form *TicketForm) Set(t kanban.Ticket) { // Note: No actual validation is done yet. func (form TicketForm) Validate() (kanban.Ticket, error) { ticket := kanban.Ticket{ - ID: form.Data.ID, - Created: form.Data.Created, - Title: form.Title.Text(), - Details: form.Details.Text(), - Summary: form.Summary.Text(), - Category: form.Category.Text(), + Entity: kanban.Entity{ + ID: form.Data.ID, + Created: form.Data.Created, + }, + Title: form.Title.Text(), + Details: form.Details.Text(), + Summary: form.Summary.Text(), } return ticket, nil } @@ -516,9 +556,6 @@ func (form *TicketForm) Layout(gtx C, th *material.Theme, stage string) D { return form.Title.Layout(gtx, th, "Title") }), layout.Rigid(func(gtx C) D { - return form.Category.Layout(gtx, th, "Category") - }), - layout.Rigid(func(gtx C) D { return form.Summary.Layout(gtx, th, "Summary") }), layout.Rigid(func(gtx C) D { @@ -779,13 +816,6 @@ func (t *Ticket) content(gtx C, th *material.Theme) D { return material.Label(th, unit.Dp(20), t.Title).Layout(gtx) }), layout.Rigid(func(gtx C) D { - return layout.Inset{ - Top: unit.Dp(2), - }.Layout(gtx, func(gtx C) D { - return material.Label(th, unit.Dp(14), t.Category).Layout(gtx) - }) - }), - layout.Rigid(func(gtx C) D { return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, func(gtx C) D { return material.Body1(th, t.Summary).Layout(gtx) }) @@ -927,9 +957,6 @@ func (t *TicketDetails) Layout(gtx C, th *material.Theme) D { }.Layout( gtx, layout.Rigid(func(gtx C) D { - return material.Body2(th, t.Category).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { return material.Body1(th, t.Summary).Layout(gtx) }), layout.Rigid(func(gtx C) D { diff --git a/cmd/kanban/map.go b/cmd/kanban/map.go @@ -54,17 +54,23 @@ func (m *Map) New(k string, init unsafe.Pointer) unsafe.Pointer { return m.data[k] } -// Next iterates over the collection, returning the data and a boolean to indicate -// the end. +// Next iterates over the collection, returning the key-value pair. // -// for v, ok := m.Next(); ok; v, ok = m.Next() { +// for key, value := m.Next(); m.More(); key, value = m.Next() { // t := (*T)(v) // } // -func (m *Map) Next() (unsafe.Pointer, bool) { +func (m *Map) Next() (key string, value unsafe.Pointer) { if m.current >= len(m.index) { - return nil, false + return key, value } defer func() { m.current++ }() - return m.data[m.index[m.current]], m.current <= len(m.index)-1 + key = m.index[m.current] + value = m.data[key] + return key, value +} + +// More reports whether there is more data to iterate. +func (m *Map) More() bool { + return m.current <= len(m.index)-1 } diff --git a/cmd/kanban/widget.go b/cmd/kanban/widget.go @@ -124,7 +124,7 @@ type RailChild struct { } // Destination is a rail item that represents a navigatable object. -// Destinations are padded by default. +// Destinations are pab+dded by default. func Destination(name string, w layout.Widget) RailChild { return RailChild{ Name: name, @@ -136,6 +136,17 @@ func (r *Rail) next(key string) *widget.Clickable { return (*widget.Clickable)(r.Map.New(key, unsafe.Pointer(&widget.Clickable{}))) } +// Selected reports which rail child was selected, if any. +// Reports the first click encountered. +func (r *Rail) Selected() (string, bool) { + for k, v := r.Map.Next(); r.Map.More(); k, v = r.Map.Next() { + if (*widget.Clickable)(v).Clicked() { + return k, true + } + } + return "", false +} + // Layout the rail with the given items. func (r *Rail) Layout(gtx C, action layout.Widget, items ...RailChild) D { r.List.Axis = layout.Vertical @@ -163,10 +174,7 @@ func (r *Rail) Layout(gtx C, action layout.Widget, items ...RailChild) D { return r.List.Layout(gtx, len(items), func(gtx C, ii int) D { rc := items[ii] return material.Clickable(gtx, r.next(rc.Name), func(gtx C) D { - return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return rc.W(gtx) - }) + return rc.W(gtx) }) }) }), @@ -182,7 +190,7 @@ type Div struct { } func (d Div) Layout(gtx C) D { - // Draw a line as a very line. + // Draw a line as a very thin rectangle. var sz image.Point switch d.Axis { case layout.Horizontal: diff --git a/kanban.go b/kanban.go @@ -1,7 +1,9 @@ package kanban import ( + "errors" "fmt" + "strconv" "time" "github.com/asdine/storm/v3" @@ -9,231 +11,306 @@ import ( // Kanban manipulates the model. type Kanban struct { + // Data access layer for querying and mutating data. Store *storm.DB } +// ID is a unique identifier encoded as an integer. +type ID int + +// Entity is unique schema object that changes over time. +type Entity struct { + ID ID `storm:"id,index,increment"` + Created time.Time +} + +// Project is a context for a given set of tickets. +type Project struct { + Entity `storm:"inline"` + Name string `storm:"unique"` + // Stages lists stage IDs in order. + Stages Stages +} + +type Stages []ID + +func (stages *Stages) Swap(id ID, dir Direction) { + for ii := range *stages { + if (*stages)[ii] == id { + if bounds := ii + dir.Next(); bounds < 0 || bounds > len(*stages)-1 { + return + } + (*stages)[ii], (*stages)[ii+dir.Next()] = (*stages)[ii+dir.Next()], (*stages)[ii] + } + } +} + // Stage in the kanban pipeline, can hold a number of tickets. type Stage struct { - ID int `storm:"id,index"` - Name string - Tickets []Ticket + Entity `storm:"inline"` + Name string + // Tickest lists ticket IDs in order. + Tickets []ID // @Todo abstract into "reorderable list", to use with project stage list as well. } -func (s Stage) Empty() bool { - return len(s.Tickets) == 0 +func (s *Stage) Assign(ticket ID) { + for _, t := range s.Tickets { + if t == ticket { + return + } + } + s.Tickets = append(s.Tickets, ticket) } -// Ticket in a stage. -type Ticket struct { - ID int - Title string - Category string - Summary string - Details string - References []int - Created time.Time -} - -func (k Kanban) Tickets() ([]Ticket, error) { - stages, err := k.ListStages() - if err != nil { - return nil, err - } - var tickets []Ticket - for _, stage := range stages { - for _, t := range stage.Tickets { - tickets = append(tickets, t) +func (s *Stage) UnAssign(ticket ID) { + for ii, t := range s.Tickets { + if t == ticket { + s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) } } - return tickets, nil +} + +// FinalisedTicket is an inactive ticket kept for analytic purposes. +type FinalisedTicket = Ticket + +// Ticket in a stage. +type Ticket struct { + Entity `storm:"inline"` + Project ID + Stage ID + + // Title of the ticket. + Title string + // Summary contains short and concise overview of the ticket. + Summary string + // Details contains the full details of the ticket. + Details string } // ListStages returns a list of stages. -func (k Kanban) ListStages() ([]Stage, error) { - var stages []Stage - if err := k.Store.AllByIndex("ID", &stages); err != nil { - return nil, fmt.Errorf("collecting stage: %w", err) +func (k Kanban) ListStages(projectID ID) (stages []Stage, err error) { + var ( + project Project + ) + if err := k.Store.Find("ID", projectID, &project); err != nil { + return nil, fmt.Errorf("loading project: %v", err) + } + for _, stageID := range project.Stages { + var ( + stage Stage + ) + if err := k.Store.Find("ID", stageID, &stage); err != nil { + return stages, fmt.Errorf("loading stage: %v", err) + } + stages = append(stages, stage) } return stages, nil } // NextStage returns the stage that follows the specified one. -func (k Kanban) NextStage(current string) (string, error) { - stages, err := k.ListStages() - if err != nil { - return "", err - } - for ii, stage := range stages { - if stage.Name == current && ii < len(stages)-1 { - return stages[ii+1].Name, nil - } - } - return current, fmt.Errorf("no more stages after: %q", current) +func (k Kanban) NextStage(projectID ID, current ID) (string, error) { + stage, err := k.NextStageForDirection(projectID, current, Forward) + return stage.Name, err } // NextStage returns the stage that preceeds the specified one. -func (k Kanban) PreviousStage(current string) (string, error) { - stages, err := k.ListStages() - if err != nil { - return "", err - } - for ii, stage := range stages { - if stage.Name == current && ii > 0 { - return stages[ii-1].Name, nil +func (k Kanban) PreviousStage(projectID ID, current ID) (string, error) { + stage, err := k.NextStageForDirection(projectID, current, Backward) + return stage.Name, err +} + +// NextStageForDirection gets the next stage in the given direction. +func (k Kanban) NextStageForDirection(projectID ID, current ID, dir Direction) (next Stage, err error) { + var ( + project Project + ) + if err := k.Store.Find("ID", projectID, &project); err != nil { + return Stage{}, fmt.Errorf("finding project: %v", err) + } + for ii, stage := range project.Stages { + if stage == current { + // @Todo bounds check. + return next, k.Store.Find("ID", project.Stages[ii+dir.Next()], &next) } } - return current, fmt.Errorf("no more stages before: %q", current) + return next, err +} + +// MoveStage moves a stage one place in the given direction. +func (k Kanban) MoveStage(projectID ID, id ID, dir Direction) error { + var ( + project Project + ) + if err := k.Store.Find("ID", projectID, &project); err != nil { + return fmt.Errorf("finding project: %v", err) + } + project.Stages.Swap(id, dir) + if err := k.Store.Save(&project); err != nil { + return fmt.Errorf("saving project: %v", err) + } + return nil } // Stage returns a stage by the given name. // Creates an empty stage if it doesn't exist. -func (k *Kanban) Stage(name string) (Stage, error) { - stages, err := k.ListStages() - if err != nil { - return Stage{}, err - } - for _, stage := range stages { - if stage.Name == name { - return stage, nil +func (k *Kanban) Stage(name string) (stage Stage, err error) { + err = k.Store.Find("Name", name, &stage) + if errors.Is(err, storm.ErrNotFound) { + if err := k.Store.Save(&stage); err != nil { + return stage, err } + return k.Stage(name) } - id, err := k.nextID() - if err != nil { - return Stage{}, err - } - stage := Stage{ID: id, Name: name} - if err := k.Store.Save(&stage); err != nil { - return Stage{}, err - } - return stage, nil + return stage, err } -func (k *Kanban) Move(stage string, ticket int) error { - stages, err := k.ListStages() - if err != nil { +// Move a ticket to the specified stage. +// Assigns to the bottom of the target stage. +func (k *Kanban) Move(stageID ID, ticketID ID) error { + var ( + ticket Ticket + currentStage Stage + targetStage Stage + ) + if err := k.Store.Find("ID", ticketID, &ticket); err != nil { return err } - for ii := range stages { - s := stages[ii] - for kk := range s.Tickets { - t := s.Tickets[kk] - if t.ID == ticket { - if err := k.Delete(ticket); err != nil { - return fmt.Errorf("deleting: %w", err) - } - return k.Assign(stage, t) - } - } + if err := k.Store.Find("ID", ticket.Stage, &currentStage); err != nil { + return err } - return fmt.Errorf("ticket %q does not exist", ticket) -} - -// Progress a ticket to the next stage. -func (k *Kanban) Progress(ticket int) error { - stage, err := k.StageFor(ticket) - if err != nil { - return fmt.Errorf("finding stage: %w", err) + if err := k.Store.Find("ID", stageID, &targetStage); err != nil { + return err + } + ticket.Stage = targetStage.ID + currentStage.UnAssign(ticketID) + targetStage.Assign(ticketID) + if err := k.Store.Save(&ticket); err != nil { + return err } - next, err := k.NextStage(stage) - if err != nil { - return fmt.Errorf("loading stage: %w", err) + if err := k.Store.Save(&currentStage); err != nil { + return err } - if err := k.Move(next, ticket); err != nil { - return fmt.Errorf("moving ticket: %w", err) + if err := k.Store.Save(&targetStage); err != nil { + return err } return nil } -// Regress a ticket to the previous stage. -func (k *Kanban) Regress(ticket int) error { - stage, err := k.StageFor(ticket) - if err != nil { +// Progress a ticket to the next stage. +func (k *Kanban) Progress(ticketID ID) error { + var ( + ticket Ticket + project Project + stageID ID + ) + if err := k.Store.Find("ID", ticketID, &ticket); err != nil { return err } - next, err := k.PreviousStage(stage) - if err != nil { + if err := k.Store.Find("ID", ticket.Project, &project); err != nil { return err } - return k.Move(next, ticket) + for ii, id := range project.Stages { + if id == ticket.Stage { + stageID = project.Stages[ii+1] + } + } + return k.Move(stageID, ticket.ID) } -func (k *Kanban) StageFor(ticket int) (string, error) { - stages, err := k.ListStages() - if err != nil { - return "", err +// Regress a ticket to the previous stage. +func (k *Kanban) Regress(ticketID ID) error { + var ( + ticket Ticket + project Project + stageID ID + ) + if err := k.Store.Find("ID", ticketID, &ticket); err != nil { + return err } - for _, s := range stages { - for _, t := range s.Tickets { - if t.ID == ticket { - return s.Name, nil - } + if err := k.Store.Find("ID", ticket.Project, &project); err != nil { + return err + } + for ii, id := range project.Stages { + if id == ticket.Stage { + stageID = project.Stages[ii-1] } } - return "", fmt.Errorf("ticket %q does not exist", ticket) + return k.Move(stageID, ticket.ID) } +// Assign a ticket to a stage. func (k *Kanban) Assign(name string, ticket Ticket) error { - if ticket.ID == 0 { - id, err := k.nextID() - if err != nil { - return fmt.Errorf("generating ID: %w", err) - } - ticket.ID = id - ticket.Created = time.Now() + var ( + stage Stage + ) + if err := k.Store.Find("Name", name, &stage); err != nil { + return fmt.Errorf("finding stage %q: %v", name, err) } - stage, err := k.Stage(name) - if err != nil { - return fmt.Errorf("finding stage: %w", err) + stage.Assign(ticket.ID) + ticket.Stage = stage.ID + if err := k.Store.Save(&ticket); err != nil { + return fmt.Errorf("saving ticket: %v", err) } - stage.Tickets = append(stage.Tickets, ticket) - return k.Store.Update(&stage) + if err := k.Store.Update(&stage); err != nil { + return fmt.Errorf("saving stage: %v", err) + } + return nil } -func (k *Kanban) Delete(ticket int) error { - stages, err := k.ListStages() - if err != nil { - return err +// Finalize a ticket. +// Either the ticket was completed, made irrelevant, or faulty in some manner. +func (k *Kanban) Finalize(ticketID ID) error { + var ( + ticket Ticket + ) + if err := k.Store.Find("ID", ticketID, &ticket); err != nil { + return fmt.Errorf("ticket not exist: %v", err) } - for kk := range stages { - s := &stages[kk] - for ii := range s.Tickets { - if s.Tickets[ii].ID == ticket { - s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...) - return k.Store.Update(s) - } - } + if err := k.Store.DeleteStruct(&ticket); err != nil { + return fmt.Errorf("deleting active ticket: %v", err) } - return fmt.Errorf("ticket %q does not exist", ticket) + return k.Store.Save(FinalisedTicket(ticket)) } func (k *Kanban) Update(ticket Ticket) error { - stages, err := k.ListStages() - if err != nil { - return err - } - for _, s := range stages { - for ii := range s.Tickets { - if s.Tickets[ii].ID == ticket.ID { - s.Tickets[ii] = ticket - return k.Store.Update(&s) - } - } - } - return fmt.Errorf("ticket %q does not exist", ticket) + return k.Store.Update(&ticket) } -func (k *Kanban) nextID() (int, error) { - stages, err := k.ListStages() - if err != nil { - return -1, err +// Direction encodes mutually exclusive directions. +type Direction int8 + +const ( + Forward Direction = iota + Backward +) + +// Next returns the direction as a signed integer, where positive is forward. +func (dir Direction) Next() int { + switch dir { + case Forward: + return 1 + case Backward: + return -1 } - var max int - for _, stage := range stages { - for _, t := range stage.Tickets { - if int(t.ID) > max { - max = int(t.ID) - } - } + return 0 +} + +// Invert returns the inverse of dir. +func (dir Direction) Invert() Direction { + switch dir { + case Forward: + return Backward + case Backward: + return Forward } - return max + 1, nil + return dir +} + +// None reports whether the ID represents a valid entity or is a zero value. +func (id ID) None() bool { + return id < 1 +} + +func (id ID) String() string { + return strconv.Itoa(int(id)) }