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:
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, ¤tStage); 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(¤tStage); 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))
}