diff --git a/internal/view/DESIGN.md b/internal/view/DESIGN.md index ee18cdb..2f9ced4 100644 --- a/internal/view/DESIGN.md +++ b/internal/view/DESIGN.md @@ -12,17 +12,17 @@ package view Issue: https://github.com/golang/go/issues/19412 proposal: spec: add sum types / discriminated unions [LanguageChange] [v2] [Proposal] [NeedsInvestigation] - - ╭───────┬─────┬─────────┬────────┬─────────╮ - │ Alice │ Bob │ Charlie │ Didukh │ Sirotin │ Recommended: 3 - ├───────┼─────┼─────────┼────────┼─────────┤ Acceptable: x - too big votes variety - │ 1 │ 3 │ 8 │ 13 │ 3 │ What to do: Listen to Alice and Didukh arguments - ╰───────┴─────┴─────────┴────────┴─────────╯ - ╭───╮ - ╭───╮ ╭───╮ │ 3 │ ╭───╮ ╭───╮ ╭────╮ ╭────╮ ╭────╮ - │ 1 │ │ 2 │ ╰───╯ │ 5 │ │ 8 │ │ 13 │ │ 21 │ │ 34 │ - ╰───╯ ╰───╯ ╰───╯ ╰───╯ ╰────╯ ╰────╯ ╰────╯ - ^ + + ╭───────┬───────────┬─────────┬───────┬──────╮ + │ Alice │ Bob (You) │ Charlie │ David │ Erin │ Recommended: 8 + ├───────┼───────────┼─────────┼───────┼──────┤ Acceptable: ✓ + │ 8 │ 8 │ 5 │ 5 │ 8 │ > Not bad. + ╰───────┴───────────┴─────────┴───────┴──────╯ + ╭───╮ + ╭───╮ ╭───╮ ╭───╮ ╭───╮ │ 8 │ ╭────╮ ╭────╮ ╭────╮ + │ 1 │ │ 2 │ │ 3 │ │ 5 │ ╰───╯ │ 13 │ │ 21 │ │ 34 │ + ╰───╯ ╰───╯ ╰───╯ ╰───╯ ╰────╯ ╰────╯ ╰────╯ + ^ Use [←] and [→] arrows to select a card and press [Enter] [Tab] To switch to issues list view @@ -242,4 +242,4 @@ For example for a Fibbonacci deck - https://github.com/six78/2-story-points-cli/issues/1 - https://github.com/six78/2-story-points-cli/issues/34 ... 7 more issues -``` \ No newline at end of file +``` diff --git a/internal/view/components/hintview/model.go b/internal/view/components/hintview/model.go index 8a32aa5..897b2ee 100644 --- a/internal/view/components/hintview/model.go +++ b/internal/view/components/hintview/model.go @@ -1,12 +1,9 @@ package hintview import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/six78/2-story-points-cli/internal/config" "github.com/six78/2-story-points-cli/internal/view/components/voteview" "github.com/six78/2-story-points-cli/internal/view/messages" "github.com/six78/2-story-points-cli/pkg/protocol" @@ -16,8 +13,7 @@ var ( headerStyle = lipgloss.NewStyle() // .Foreground(lipgloss.Color("#FAFAFA")) acceptableStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")) unacceptableStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) - textStyle = lipgloss.NewStyle() // .Foreground(lipgloss.Color("#FAFAFA")) - MentionStyle = textStyle.Copy().Italic(true).Foreground(config.UserColor) + textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) ) type Model struct { @@ -56,23 +52,18 @@ func (m Model) View() string { return "" } - verdictStyle := unacceptableStyle - verdictText := "x" - if m.hint.Acceptable { - verdictStyle = acceptableStyle - verdictText = "✓" - } - - rejectionReason := "" - if !m.hint.Acceptable { - rejectionReason = fmt.Sprintf(" (%s)", textStyle.Render(m.hint.RejectReason)) - } - return lipgloss.JoinVertical(lipgloss.Top, "", headerStyle.Render("Recommended:")+""+voteview.Render(m.hint.Value), - headerStyle.Render("Acceptable:")+" "+verdictStyle.Render(verdictText)+rejectionReason, - headerStyle.Render("What to do:")+" "+textStyle.Render(m.hint.Advice), + headerStyle.Render("Acceptable:")+" "+renderAcceptanceIcon(m.hint.Acceptable), + headerStyle.Render(">")+" "+textStyle.Render(m.hint.Description), "", ) } + +func renderAcceptanceIcon(acceptable bool) string { + if acceptable { + return acceptableStyle.Render("✓") + } + return unacceptableStyle.Render("x") +} diff --git a/internal/view/components/hintview/model_test.go b/internal/view/components/hintview/model_test.go index 3b54990..6d874b9 100644 --- a/internal/view/components/hintview/model_test.go +++ b/internal/view/components/hintview/model_test.go @@ -1,7 +1,6 @@ package hintview import ( - "fmt" "strings" "testing" @@ -70,9 +69,9 @@ func TestUpdateAcceptableVote(t *testing.T) { issue := protocol.Issue{ ID: protocol.IssueID(gofakeit.UUID()), Hint: &protocol.Hint{ - Acceptable: tc.acceptable, - Value: protocol.VoteValue(gofakeit.LetterN(5)), - RejectReason: gofakeit.LetterN(10), + Acceptable: tc.acceptable, + Value: protocol.VoteValue(gofakeit.LetterN(5)), + Description: gofakeit.LetterN(10), }, } model, cmd = model.Update(messages.GameStateMessage{ @@ -86,16 +85,16 @@ func TestUpdateAcceptableVote(t *testing.T) { require.NotNil(t, model.hint) require.Equal(t, *issue.Hint, *model.hint) - expectedVerdict := "✓" + expectedAcceptableIcon := "✓" if !tc.acceptable { - expectedVerdict = "x" + fmt.Sprintf(" (%s)", issue.Hint.RejectReason) + expectedAcceptableIcon = "x" } expectedLines := []string{ "", "Recommended: " + string(issue.Hint.Value), - "Acceptable: " + expectedVerdict, - "What to do:", + "Acceptable: " + expectedAcceptableIcon, + "> " + issue.Hint.Description, "", } diff --git a/pkg/game/hints.go b/pkg/game/hints.go index 30a5f13..386a808 100644 --- a/pkg/game/hints.go +++ b/pkg/game/hints.go @@ -23,11 +23,19 @@ type hintMeasurements struct { } const ( + // thresholds maxAcceptableMaximumDeviation = 1 maxAcceptableMeanDeviation = 0.5 // Rejection reasons - varietyOfVotesIsTooHigh = "Variety of votes is too high" - maximumDeviationIsTooHigh = "Maximum deviation is too high" + varietyOfVotesIsTooHigh = "No strong consensus among the players" + maximumDeviationIsTooHigh = "Maximum deviation threshold exceeded" + // Advices + descriptionBingo = "BINGO! 🎉💃" + descriptionGoodJob = "Good job 😎" + descriptionNotBad = "Not bad 🤞" + descriptionYouCanDoBetter = "You can do better 💪" + // internal consts + float64Epsilon = 1e-9 ) var ( @@ -50,18 +58,30 @@ func GetResultHint(deck protocol.Deck, issueVotes protocol.IssueVotes) (*protoco // Build the hint based on the measures hint := &protocol.Hint{ Value: medianValue, - Advice: "", Acceptable: true, } if resultMeasures.maxDeviation > maxAcceptableMaximumDeviation { hint.Acceptable = false - hint.RejectReason = maximumDeviationIsTooHigh + hint.Description = maximumDeviationIsTooHigh } - if resultMeasures.meanDeviation >= maxAcceptableMeanDeviation { + if resultMeasures.meanDeviation > maxAcceptableMeanDeviation { hint.Acceptable = false - hint.RejectReason = varietyOfVotesIsTooHigh + hint.Description = varietyOfVotesIsTooHigh + } + + if hint.Acceptable { + switch { + case resultMeasures.meanDeviation == 0: + hint.Description = descriptionBingo + case resultMeasures.meanDeviation < maxAcceptableMeanDeviation/2: + hint.Description = descriptionGoodJob + case resultMeasures.meanDeviation < maxAcceptableMeanDeviation: + hint.Description = descriptionNotBad + case compareFloats(resultMeasures.meanDeviation, maxAcceptableMeanDeviation): + hint.Description = descriptionYouCanDoBetter + } } return hint, nil @@ -93,19 +113,17 @@ func getMeasures(values []int) hintMeasurements { // Maximum deviation r.maxDeviation = 0 for _, v := range values { - deviation := math.Abs(float64(r.median) - float64(v)) - r.maxDeviation = math.Max(r.maxDeviation, deviation) + r.maxDeviation = math.Max(r.maxDeviation, deviation(v, r.median)) } // Average deviation sum := 0 for _, v := range values { - sum += int(math.Abs(float64(r.median) - float64(v))) + sum += int(deviation(v, r.median)) } r.meanDeviation = float64(sum) / float64(len(values)) return r - } func median(values []int) int { @@ -119,3 +137,11 @@ func median(values []int) int { center := len(values) / 2 return values[center] } + +func deviation(value int, median int) float64 { + return math.Abs(float64(median) - float64(value)) +} + +func compareFloats(a, b float64) bool { + return math.Abs(a-b) < float64Epsilon +} diff --git a/pkg/game/hints_test.go b/pkg/game/hints_test.go index 1705536..46d0414 100644 --- a/pkg/game/hints_test.go +++ b/pkg/game/hints_test.go @@ -34,7 +34,6 @@ func TestMedian(t *testing.T) { func TestHint(t *testing.T) { deck := protocol.Deck{"1", "2", "3", "5", "8", "13", "21"} - t.Log("deck:", deck) type Case struct { values []protocol.VoteValue @@ -46,67 +45,72 @@ func TestHint(t *testing.T) { // But the intention was also to see how the algorithm behaves for different scenarios. testCases := []Case{ + { + values: []protocol.VoteValue{"3", "3", "3", "3", "3"}, + measurements: hintMeasurements{median: 2, meanDeviation: 0, maxDeviation: 0}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionBingo}, + }, { values: []protocol.VoteValue{"3", "3", "3", "3", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 0.2, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: true, Value: "3"}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionGoodJob}, }, { values: []protocol.VoteValue{"3", "3", "3", "3", "8"}, measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 2}, - expectedHint: protocol.Hint{Acceptable: false, Value: "3", RejectReason: maximumDeviationIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: false, Value: "3", Description: maximumDeviationIsTooHigh}, }, { values: []protocol.VoteValue{"3", "3", "3", "3", "13"}, measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 3}, // Test: varietyOfVotesIsTooHigh takes precedence over maximumDeviationIsTooHigh - expectedHint: protocol.Hint{Acceptable: false, Value: "3", RejectReason: varietyOfVotesIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: false, Value: "3", Description: varietyOfVotesIsTooHigh}, }, { values: []protocol.VoteValue{"3", "3", "3", "3", "21"}, measurements: hintMeasurements{median: 2, meanDeviation: 0.8, maxDeviation: 4}, - expectedHint: protocol.Hint{Acceptable: false, Value: "3", RejectReason: varietyOfVotesIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: false, Value: "3", Description: varietyOfVotesIsTooHigh}, }, { values: []protocol.VoteValue{"3", "3", "3", "5", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: true, Value: "3"}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionNotBad}, }, { values: []protocol.VoteValue{"3", "3", "3", "5", "8"}, measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 2}, - expectedHint: protocol.Hint{Acceptable: false, Value: "3", RejectReason: varietyOfVotesIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: false, Value: "3", Description: varietyOfVotesIsTooHigh}, }, { values: []protocol.VoteValue{"2", "3", "3", "3", "3", "3", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 2 / 7.0, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: true, Value: "3"}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionNotBad}, }, { values: []protocol.VoteValue{"2", "3", "3", "3", "3", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 2 / 6.0, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: true, Value: "3"}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionNotBad}, }, { values: []protocol.VoteValue{"2", "3", "3", "3", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 2 / 5.0, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: true, Value: "3"}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionNotBad}, }, { values: []protocol.VoteValue{"2", "3", "3", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 2 / 4.0, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: false, Value: "3", RejectReason: varietyOfVotesIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: true, Value: "3", Description: descriptionYouCanDoBetter}, }, { values: []protocol.VoteValue{"2", "3", "5"}, measurements: hintMeasurements{median: 2, meanDeviation: 2 / 3.0, maxDeviation: 1}, - expectedHint: protocol.Hint{Acceptable: false, Value: "3", RejectReason: varietyOfVotesIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: false, Value: "3", Description: varietyOfVotesIsTooHigh}, }, { // This also tests round up median when even number of votes values: []protocol.VoteValue{"2", "3", "5", "8"}, measurements: hintMeasurements{median: 3, meanDeviation: 1, maxDeviation: 2}, - expectedHint: protocol.Hint{Acceptable: false, Value: "5", RejectReason: varietyOfVotesIsTooHigh}, + expectedHint: protocol.Hint{Acceptable: false, Value: "5", Description: varietyOfVotesIsTooHigh}, }, } diff --git a/pkg/protocol/hint.go b/pkg/protocol/hint.go index f9fe698..d393a25 100644 --- a/pkg/protocol/hint.go +++ b/pkg/protocol/hint.go @@ -6,15 +6,12 @@ type Hint struct { // a suggestion to discuss and re-vote. Acceptable bool - // RejectReason contains an explanation of why the vote is not acceptable. - // When Acceptable is true, RejectReason is empty. - RejectReason string - // Value is the recommended value for the issue. // It's guaranteed to be one of the values from the deck. Value VoteValue - // Advice is a text advice for the team about current vote. - // It might contain players mentions in form "@", where a particular player ID. - Advice string + // Description contains text message for the team. + // When Acceptable is false, Description explaining the reject reasoning. + // When Acceptable is true, Description contains some congratulatory message. + Description string }