diff --git a/.gitignore b/.gitignore index bba35db..9223769 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ swim -*.json -*.swim diff --git a/board.go b/board.go index 0b00bd8..b92705a 100644 --- a/board.go +++ b/board.go @@ -41,6 +41,13 @@ func (b *Board) PollForTermSize() { b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].offset = 0 b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Draw(b) } else { + for l := range b.Lanes { + if len(b.Lanes[l].Stories) == 0 { + continue + } + b.Lanes[l].Current = 0 + b.Lanes[l].storyOff = 0 + } b.Draw() } } @@ -64,9 +71,9 @@ func (b *Board) Run() { case 'Q': Quit() case 'N': - b.CreateLane() + b.CreateLane("") case 'n': - b.CreateStory() + b.CreateStory("") case '\n': b.StoryOpen = true b.ViewStory() @@ -98,26 +105,28 @@ func (b *Board) SetMessage(msg string, isError bool) { b.msgErr = isError } -func (b *Board) CreateLane() { - laneTitle, err := GetAndConfirmCommandLine("Lane Title: ") - if err != nil { - b.SetMessage(err.Error(), true) - return +func (b *Board) CreateLane(name string) { + var err error + if name == "" { + name, err = GetAndConfirmCommandLine("Lane Title: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } } - b.Lanes = append(b.Lanes, MakeLane(laneTitle)) + b.Lanes = append(b.Lanes, MakeLane(name)) if b.Current < 0 { b.Current = 0 } b.SetMessage("Lane created", false) } -func (b *Board) CreateStory() { +func (b *Board) CreateStory(name string) { if b.Current < 0 { b.SetMessage("You must create a lane first", true) return } - b.Lanes[b.Current].CreateStory(b) - b.SetMessage("Story created", false) + b.Lanes[b.Current].CreateStory(name, b) } func (b Board) PrintHeader() string { @@ -201,6 +210,45 @@ func (b *Board) EnterCommand() { } case "q", "quit": Quit() + case "c", "create": + if len(f) >= 2 { + target := strings.ToLower(f[1]) + name := "" + if len(f) > 2 { + name = strings.Join(f[2:], " ") + } + switch target { + case "lane", "l", "la", "lan": + b.CreateLane(name) + case "story", "s", "st", "sto", "stor": + b.CreateStory(name) + case "task", "tas", "ta", "t": + b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].AddTask(name, b) + case "comment", "com", "c": + b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].AddComment(name, b) + } + } + case "d", "del", "delete": + if len(f) > 1 { + target := strings.ToLower(f[1]) + switch target { + case "lane", "l", "la", "lan": + b.DeleteLane() + case "story", "s", "st", "sto", "stor": + b.Lanes[b.Current].DeleteStory(b) + case "t", "ta", "tas", "task": + val := "" + if len(f) > 2 { + val = f[2] + } + b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].DeleteTask(val, b) + // TODO + default: + b.SetMessage(fmt.Sprintf("Unknown target %q", f[1]), true) + } + } else { + b.SetMessage("More info needed: 'update [target] [location]'", true) + } case "set", "s": if len(f) > 2 { target := strings.ToLower(f[1]) @@ -215,7 +263,7 @@ func (b *Board) EnterCommand() { b.SetMessage(fmt.Sprintf("Unknown target %q", f[1]), true) } } else { - b.SetMessage("More info needed: 'update [target] [location]'", true) + b.SetMessage("More info needed: 'set [target] [location] [[value]]'", true) } case "user", "toggle", "t", "u": b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Update(f, b) @@ -228,10 +276,16 @@ func (b *Board) Update(args []string) { location := strings.ToLower(args[0]) switch location { case "title", "t", "name", "n": - title, err := GetAndConfirmCommandLine("New board title: ") - if err != nil { - b.SetMessage(err.Error(), true) - break + var title string + var err error + if len(args) == 1 { + title, err = GetAndConfirmCommandLine("New board title: ") + if err != nil { + b.SetMessage(err.Error(), true) + break + } + } else { + title = strings.Join(args[1:], " ") } b.Title = title b.SetMessage("Board title updated", false) @@ -304,7 +358,7 @@ func (b *Board) Move(ch rune) { // move selection down a story if b.Lanes[b.Current].Current < len(b.Lanes[b.Current].Stories)-1 { b.Lanes[b.Current].Current += 1 - if b.Lanes[b.Current].Current * 2 + 1 > b.height-5 { + if b.Lanes[b.Current].Current * 3 + 1 > b.height-5 { b.Lanes[b.Current].storyOff += 1 } } else { @@ -384,6 +438,40 @@ func (b *Board) Move(ch rune) { } } +func (b *Board) DeleteLane() { + if len(b.Lanes) < 1 { + b.SetMessage("There are no lanes to delete", true) + return + } + cont, err := GetConfirmation("Are you sure? Type 'yes' to delete: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } else if !cont { + b.SetMessage("Deletion canceled", true) + return + } + if len(b.Lanes[b.Current].Stories) > 0 { + cont, err = GetConfirmation("Are you really sure? There are stories in the lane... ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } else if !cont { + b.SetMessage("Deletion canceled", true) + return + } + } + if b.Current == len(b.Lanes)-1 { + b.Lanes = b.Lanes[:len(b.Lanes)-1] + } else { + b.Lanes = append(b.Lanes[:b.Current], b.Lanes[b.Current+1:]...) + } + if b.Current > len(b.Lanes)-1 { + b.Current -= 1 + } + b.SetMessage("Lane deleted", false) +} + func (b *Board) ViewStory() { if b.Current > -1 { if b.Lanes[b.Current].Current > -1 { diff --git a/lane.go b/lane.go index 4310310..8130f26 100644 --- a/lane.go +++ b/lane.go @@ -13,16 +13,21 @@ type Lane struct { storyOff int // offset for the lane slice } -func (l *Lane) CreateStory(b *Board) { - storyTitle, err := GetAndConfirmCommandLine("Story Title: ") - if err != nil { - b.SetMessage(err.Error(), true) - return +func (l *Lane) CreateStory(name string, b *Board) { + var err error + if name == "" { + name, err = GetAndConfirmCommandLine("Story Title: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } } - l.Stories = append(l.Stories, MakeStory(storyTitle)) + + l.Stories = append(l.Stories, MakeStory(name)) if l.Current < 0 { l.Current = 0 } + b.SetMessage("Story created", false) } // Zeroes out the offset and current values @@ -55,20 +60,30 @@ func (l Lane) Header(width int, selected bool) string { func (l Lane) StringSlice(width int, selected bool) []string { out := make([]string, 0, len(l.Stories) * 3 + 1) for i := l.storyOff; i < len(l.Stories); i++ { + // Top spacer + out = append(out, fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff)) + + // First card row + title := l.Stories[i].Title + if len(title) > width-4 { + title = title[:width-5] + "…" + } leadIn := " " if selected && l.Current == i { leadIn = "\033[1m➜ " } - out = append(out, fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff)) - title := l.Stories[i].Title - pts := strconv.Itoa(l.Stories[i].Points) + out = append(out, fmt.Sprintf("%s %s%s%-*.*s%s %s", style.Lane, style.Input, leadIn, width-4, width-4, title, style.Lane, styleOff)) + + // Second card row + pts := strconv.Itoa(l.Stories[i].Points) if pts == "0" || pts[0] == '-' { pts = "" } - if len(title) > width-4 { - title = title[:width-5] + "…" + users := l.Stories[i].GetUserAbbrString() + if len(users) > width-4 { + users = users[:width-5] + "…" } - out = append(out, fmt.Sprintf("%s %s%s%-*.*s\033[7m%*s\033[27m%s %s", style.Lane, style.Input, leadIn, width-7, width-7, title, 3, pts, style.Lane, styleOff)) + out = append(out, fmt.Sprintf("%s %s %-*.*s\033[7m%*s\033[27m%s %s", style.Lane, style.Input, width-7, width-7, users, 3, pts, style.Lane, styleOff)) } if len(out) > 0 { out = append(out, fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff)) @@ -76,6 +91,31 @@ func (l Lane) StringSlice(width int, selected bool) []string { return out } +func (l *Lane) DeleteStory(b *Board) { + if len(l.Stories) < 1 { + b.SetMessage("There are no stories to delete in this lane", true) + return + } + cont, err := GetConfirmation("Are you sure? Type 'yes' to delete: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + if !cont { + b.SetMessage("Deletion canceled", true) + return + } + if l.Current == len(l.Stories)-1 { + l.Stories = l.Stories[:len(l.Stories)-1] + } else { + l.Stories = append(l.Stories[:l.Current], l.Stories[l.Current+1:]...) + } + if l.Current > len(l.Stories)-1 { + l.Current -= 1 + } + b.SetMessage("Story deleted", false) +} + func (l *Lane) Update(args []string, b *Board) { location := strings.ToLower(args[0]) switch location { diff --git a/main.go b/main.go index dd98d56..273f659 100644 --- a/main.go +++ b/main.go @@ -73,13 +73,26 @@ func GetLine(prefix string) (string, error) { } func GetCommandLine(prefix string) (string, error) { - fmt.Print(upAndLeft) // Move up one and over all - fmt.Print(style.Input) + fmt.Printf("%s%s\033[2K", upAndLeft, style.Input) line, err := GetLine(prefix) fmt.Print(cursorEnd) return line, err } +func GetConfirmation(prefix string) (bool, error) { + ln, err := GetCommandLine(prefix) + if err != nil { + return false, err + } + ln = strings.ToLower(ln) + switch ln { + case "y", "yes", "yeah", "yup": + return true, nil + default: + return false, nil + } +} + func GetAndConfirmCommandLine(prefix string) (string, error) { var conf rune var err error diff --git a/notes.md b/notes.md index 18298f8..b513e4d 100644 --- a/notes.md +++ b/notes.md @@ -1,27 +1,76 @@ -# swim +Current command list: -A project planning board for the terminal. +[variable-value] [[optional-value]] +:w, +:write, +:wq + [[path]] -## Structs +:q, +:quit + +:c, +:create + l, + lane + [[title]] + s, + story, + [[title]] + t, + task, + [[value]] + c, + com, + comment + [[value]] + +:s, +:set + b, + board + t, + title + [[value]] + l, + lane + t, + title + [[value]] + s, + story + title + [[value]] + d, + desc, + description + [[value]] + p, + pts, + points + [[point-value]] + u, + user + [[space separated user list to toggle]] +:t, +:toggle + [[task-id]] + +:u, +:user + [[space separated user list to toggle]] + +:d, +:del, +:delete + l, + lane + + s, + story + + t, + task + [[task-id]] -1. story - - title string - - body string - - points int - - tag int // enum representing a color - - users []string - - comments []comment - - created time.time // the time the story was created -2. comment - - user string - - body string - - created time.time -3. lane - - title string - - stories []story -4. board - - title string - - body string - - created time.time - - lanes []lane diff --git a/story.go b/story.go index 990476b..874d820 100644 --- a/story.go +++ b/story.go @@ -44,11 +44,13 @@ func (s *Story) View(b *Board) { case 'Q': Quit() case 'c', 'C': - s.AddComment(b) + s.AddComment("", b) case 't': - s.AddTask(b) + s.AddTask("", b) case 'd': s.Update([]string{"description"}, b) + case 'D': + s.DeleteTask("", b) case 'T': s.Update([]string{"title"}, b) case 'u': @@ -65,11 +67,14 @@ func (s *Story) View(b *Board) { } } -func (s *Story) AddComment(b *Board) { - comment, err := GetAndConfirmCommandLine("Comment: ") - if err != nil { - b.SetMessage(err.Error(), true) - return +func (s *Story) AddComment(comment string, b *Board) { + var err error + if comment == "" { + comment, err = GetAndConfirmCommandLine("Comment: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } } u, err := user.Current() if err != nil { @@ -179,24 +184,33 @@ func (s *Story) Scroll(dir rune, b *Board) { func (s *Story) Update(args []string, b *Board) { location := strings.ToLower(args[0]) + var stringVal string + var err error + if len(args) > 1 { + stringVal = strings.Join(args[1:], " ") + } switch location { case "title": - title, err := GetAndConfirmCommandLine("New story title: ") - if err != nil { - b.SetMessage(err.Error(), true) - return + if stringVal == "" { + stringVal, err = GetAndConfirmCommandLine("New story title: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } } - s.Title = title + s.Title = stringVal s.Updated = time.Now() b.SetMessage("Story title updated", false) s.BuildStorySlice(b.width) case "description", "d", "desc", "body", "b": - body, err := GetAndConfirmCommandLine("New story description: ") - if err != nil { - b.SetMessage(err.Error(), true) - return + if stringVal == "" { + stringVal, err = GetAndConfirmCommandLine("New story description: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } } - s.Body = body + s.Body = stringVal s.Updated = time.Now() b.SetMessage("Story body updated", false) s.BuildStorySlice(b.width) @@ -241,11 +255,11 @@ func (s *Story) Update(args []string, b *Board) { args = append(args, n) } num, err := strconv.Atoi(args[1]) - num -= 1 - if err != nil || num < 0 || num >= len(s.Tasks) { + if err != nil || num < 1 || num > len(s.Tasks) { b.SetMessage("Invalid task number", true) return } + num -= 1 s.Tasks[num].Complete = !s.Tasks[num].Complete s.Updated = time.Now() b.SetMessage("Task state updated", false) @@ -276,11 +290,14 @@ func (s *Story) AddRemoveUser(users []string, b *Board) { b.SetMessage("Updated user list", false) } -func (s *Story) AddTask(b *Board) { - body, err := GetAndConfirmCommandLine("New task: ") - if err != nil { - b.SetMessage(err.Error(), true) - return +func (s *Story) AddTask(body string, b *Board) { + var err error + if body == "" { + body, err = GetAndConfirmCommandLine("New task: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } } s.Tasks = append(s.Tasks, Task{body, false}) s.Updated = time.Now() @@ -288,6 +305,56 @@ func (s *Story) AddTask(b *Board) { b.SetMessage("Task added", false) } +func (s *Story) DeleteTask(id string, b *Board) { + var err error + if id == "" { + id, err = GetCommandLine("Task # to delete: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + } + num, err := strconv.Atoi(id) + if err != nil || num < 1 || num > len(s.Tasks) { + b.SetMessage("Invalid task number", true) + return + } + num -= 1 + var cont bool + cont, err = GetConfirmation("Are you sure? Type 'yes' to delete: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } else if !cont { + b.SetMessage("Deletion canceled", true) + return + } + if num == len(s.Tasks)-1 { + s.Tasks = s.Tasks[:len(s.Tasks)-1] + } else { + s.Tasks = append(s.Tasks[:num], s.Tasks[num+1:]...) + } + s.Updated = time.Now() + s.BuildStorySlice(b.width) + b.SetMessage("Task deleted", false) +} + + +func (s Story) GetUserAbbrString() string { + var out strings.Builder + for i := range s.Users { + if len(s.Users[i]) > 1 { + out.WriteString(strings.ToUpper(s.Users[i][:2])) + } else { + out.WriteString(strings.ToUpper(string(s.Users[i][0]))) + } + if i < len(s.Users)-1 { + out.WriteString(", ") + } + } + return out.String() +} + func (s Story) Duplicate() Story { out := Story{} out.Title = s.Title diff --git a/swim.json b/swim.json new file mode 100644 index 0000000..4af5fac --- /dev/null +++ b/swim.json @@ -0,0 +1 @@ +{"BoardTitle":"Working on swim","Lanes":[{"LaneTitle":"Backlog","Stories":[{"StoryTitle":"Create Man Page","StoryBody":"","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[],"StoryCreated":"2021-03-26T12:54:08.267784673-07:00","StoryUpdated":"2021-03-26T14:04:51.984348517-07:00","StoryPoints":1},{"StoryTitle":"Add Web/Gemini Docs","StoryBody":"","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[],"StoryCreated":"2021-03-26T12:54:20.343386682-07:00","StoryUpdated":"2021-03-26T14:05:02.15458881-07:00","StoryPoints":1},{"StoryTitle":"Add 'g' and 'G' handling for lane view","StoryBody":"","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[],"StoryCreated":"2021-03-26T09:22:55.368822582-07:00","StoryUpdated":"2021-03-26T14:05:08.362154894-07:00","StoryPoints":1},{"StoryTitle":"Write Full Readme","StoryBody":"","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[],"StoryCreated":"2021-03-26T12:53:59.797558294-07:00","StoryUpdated":"2021-03-26T14:04:39.411318131-07:00","StoryPoints":1}],"CurrentStory":0},{"LaneTitle":"Active","Stories":[{"StoryTitle":"Rework file writing","StoryBody":"Fixes an issue with mangled writes to json files","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Make sure existing permissions carry over and new files get a sane default","TaskComplete":true},{"TaskBody":"Make sure files get truncated on open for write","TaskComplete":true},{"TaskBody":"Make a backup file while writing, the delete it if the file write worked","TaskComplete":false}],"StoryComments":[],"StoryCreated":"2021-03-25T14:32:46.780453566-07:00","StoryUpdated":"2021-03-26T14:05:39.464143008-07:00","StoryPoints":1},{"StoryTitle":"Fix text clearing when selecting 'n' at a verification prompt","StoryBody":"Currently if you select 'n', intending to rewrite your input, ghosting of the previous messaging is left.","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[],"StoryCreated":"2021-03-26T09:16:42.336417437-07:00","StoryUpdated":"2021-03-26T14:05:34.014292773-07:00","StoryPoints":1}],"CurrentStory":1},{"LaneTitle":"Completed","Stories":[{"StoryTitle":"Add quick mode for toggling tasks","StoryBody":"Make this work like Bombadillo's quick link navigation. Pressing '1' would toggle on or off the first task in a tasklist. This only functions on the story view and is not a part of the overall listener as provided by *Board. Make sure '0' functions as \"10\".","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Add runes to story listener loop","TaskComplete":true},{"TaskBody":"Call update toggle on story when pressed","TaskComplete":true},{"TaskBody":"If 0 is entered, make sure the string \"10\" is sent","TaskComplete":true}],"StoryComments":[],"StoryCreated":"2021-03-25T12:58:09.871463382-07:00","StoryUpdated":"2021-03-25T14:14:15.97075787-07:00","StoryPoints":1},{"StoryTitle":"Add redraw on resume from job control","StoryBody":"Make signals behave in a clean way and provide expected behavior.","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Copy over signal handler from tally","TaskComplete":true},{"TaskBody":"Update the SIGCONT handler to call board.Draw()","TaskComplete":true},{"TaskBody":"Make sure other signals are handled properly","TaskComplete":true}],"StoryComments":[{"CommentUser":"sloum","CommentBody":"This is mostly done. Do a cursory look at the other signals and then start handling the terminal resize story.","CommentCreated":"2021-03-25T12:49:58.234148077-07:00"}],"StoryCreated":"2021-03-25T09:21:22.815587777-07:00","StoryUpdated":"2021-03-25T15:39:22.781661841-07:00","StoryPoints":1},{"StoryTitle":"Add resize/draw on terminal resize","StoryBody":"When the terminal is resized the main *Board should have its width and height updated","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Create listener for resize","TaskComplete":true},{"TaskBody":"Update *Board with new dimensions on resize","TaskComplete":true},{"TaskBody":"Redraw screen on resize","TaskComplete":true},{"TaskBody":"Make sure if a story is being viewed that the redraw is a story redraw not a board redraw","TaskComplete":true}],"StoryComments":[{"CommentUser":"sloum","CommentBody":"Some of this code should already be present in Bombadillo. It can be borrowed from there.","CommentCreated":"2021-03-25T09:23:39.245527096-07:00"},{"CommentUser":"sloum","CommentBody":"Individuak stories should automatically reflow their content when they are viewed. With the exception of a currently open story. That could create problems, so maybe always reflow that story.","CommentCreated":"2021-03-25T12:51:42.948281369-07:00"}],"StoryCreated":"2021-03-25T09:21:08.303781384-07:00","StoryUpdated":"2021-03-25T15:01:37.428712763-07:00","StoryPoints":1},{"StoryTitle":"Make zoom adjust the lane offset","StoryBody":"When you zoom in it is possible for the currently selected column to be off the screen. The lane offset should be adjusted to account for this and keep the selected lane on the screen.","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Update zoom in to adjust offset","TaskComplete":true},{"TaskBody":"Update zoom out to adjust offset","TaskComplete":true},{"TaskBody":"Make sure offset cannot be \u003c 0","TaskComplete":true}],"StoryComments":[],"StoryCreated":"2021-03-25T14:40:01.870503824-07:00","StoryUpdated":"2021-03-25T14:49:18.990485621-07:00","StoryPoints":1},{"StoryTitle":"Add story points to card lane display","StoryBody":"Currently, story cards only display the title. It would be nice to also display points.","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[],"StoryCreated":"2021-03-25T14:52:00.470752912-07:00","StoryUpdated":"2021-03-25T14:53:24.025863477-07:00","StoryPoints":1},{"StoryTitle":"Terminal color detection","StoryBody":"Terminal color avialability is tricky. Set up some kind of best guess scenario, falling back to 8 bit when necessary.","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Check for $COLORTERM var and set to true if its value is 24bit || truecolor","TaskComplete":true},{"TaskBody":"Check the $TERM var for 256 anywhere in it. If found set to 256","TaskComplete":true},{"TaskBody":"When in doubt fall back to 8bit","TaskComplete":true},{"TaskBody":"Have this auto-set on run","TaskComplete":true},{"TaskBody":"Create a flag to manually override to any of the three values","TaskComplete":true}],"StoryComments":[],"StoryCreated":"2021-03-25T22:46:50.10507806-07:00","StoryUpdated":"2021-03-25T22:49:34.18340222-07:00","StoryPoints":1},{"StoryTitle":"Add -color \"none\" mode","StoryBody":"Add a mode that does not do any color additions. This will result in a 2bit color mode (fg/bg and color on/off for each as set by the terminal).","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[{"CommentUser":"sloum","CommentBody":"This was set up using the flag: -color none || -color off","CommentCreated":"2021-03-26T09:20:51.555905306-07:00"}],"StoryCreated":"2021-03-26T09:18:22.711096199-07:00","StoryUpdated":"2021-03-26T09:25:04.391052303-07:00","StoryPoints":1},{"StoryTitle":"Add 2nd Row to cards w/ pts and users","StoryBody":"Should make the board a bit more readable and useful for working in groups","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[{"TaskBody":"Add second row to cards","TaskComplete":true},{"TaskBody":"Remove story points from first row and add it to second","TaskComplete":true}],"StoryComments":[],"StoryCreated":"2021-03-26T13:38:33.776434147-07:00","StoryUpdated":"2021-03-26T14:06:33.086132452-07:00","StoryPoints":1},{"StoryTitle":"Have selected story remain in view when term resized","StoryBody":"","StoryUsers":["sloum"],"StoryTag":-1,"StoryTasks":[],"StoryComments":[{"CommentUser":"sloum","CommentBody":"This ran into some trouble. I didnt want to spend a lot of time on it so for now I have it resetting the selected story to the first story in the lane on resize. This is not great, but will function for the moment to not cause weird breakages.","CommentCreated":"2021-03-26T14:18:29.872684519-07:00"}],"StoryCreated":"2021-03-26T09:22:00.450326411-07:00","StoryUpdated":"2021-03-26T14:05:22.684602202-07:00","StoryPoints":1}],"CurrentStory":8}],"CurrentLane":1,"Zoom":3,"StoryOpen":true} \ No newline at end of file