diff --git a/README.md b/README.md new file mode 100644 index 0000000..c59ef0c --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# GERS PO Dashboard + +The GERS Software PO Dashboard is intended to be a reusable board for displaying remaining time in a given work interval as well as tallying scores on accepted Jira issues. Written in go. Leverages ebitengine. Assets are a combination of original work in conjunction with free assets from itch.io. + +## Features +* displays countdown timer (in working days, hours, minutes, seconds) until a given deadline +* animated score bars and overall leaderboard +* configurable teams, score tallying, score givers +* customizable Jira criteria for score keeping +* celebration/completion screen, with winners identified +* 24hr-remaining mode (ominous background) +* offline mode (for simulation) + +## Future Improvements: +* configurable/customizable assets (the idea being teams can create their own) + +## Build Steps: + +Make sure you've got go/git installed. +``` +git clone https://gitscm.mda.ca/brm_cg/sw-prototypes/gers-dashboard.git +``` + +``` +cd gers-dashboard +``` + +Retrieve module dependencies. +``` +go mod tidy +``` + +And lastly build. +``` +go build +``` + +Alternatively you don't have to build and can run/debug in your favorite IDE. If you're using VS Code the following launch configuration may prove useful. Note the external console; required if you are attempting to connect to a live jira instance in order to enter credentials. + +``` +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + "console": "externalTerminal" + } + ] +} +``` + +If you'd like to run this without connecting to Jira (simulated behaviours by keyboard only), you can clear the url entry in the config.json file (missing field or empty string will suffice). Keyboard inputs as follows: + +| Key | Description | +| ----------- | ----------- | +| Q | Quit | +| P | Add points to all teams randomly | +| L | Assign 200 points to all teams | +| R | Advance deadline to next 24hrs 5s (to preview 24hr-remaining mode) | +| E | Transitions to end-screen (expires timer immediately) | +| . | Randomly select new background gradient (countdown only) | +| ArrowUp | Increase animation cycle frequency (max = ebitengine ticks per second, and this project is configured to the default 60) | +| ArrowDown | Decrease animation cycle frequency (min = 1) | + + +![ebiten](/resources/images/ebiten.png?raw=true "ebiten") diff --git a/asb.go b/asb.go index d0839f0..b9e893b 100644 --- a/asb.go +++ b/asb.go @@ -6,7 +6,6 @@ import ( "fmt" "image" "image/color" - "log" "math" "github.com/hajimehoshi/ebiten/v2" @@ -20,24 +19,30 @@ const ( ASB_COLOR = 0x5d2f6a ASB_MARGIN = 10 - ASB_GRAD_TOP = 0x9933cc - ASB_GRAD_BOTTOM = 0x5d2f6a - - ASB_FLAME_SCALE = 1.56 - ASB_FLAME_OFFSET = 15 - - ASB_MASK_WIDTH = 10 - ASB_MASK_HEIGHT = ASB_HEIGHT - + //scales and offsets + ASB_GRAD_TOP = 0x457cf1 //0xff887c //0x9933cc + ASB_GRAD_BOTTOM = 0x2051bf //0xb6325f //0x5d2f6a + ASB_FLAME_SCALE = 1.56 + ASB_FLAME_OFFSET = 15 + ASB_MASK_WIDTH = 10 + ASB_MASK_HEIGHT = ASB_HEIGHT ASB_STRFADE_XOFFSET = 80 ASB_STRFADE_YOFFSET = 50 ASB_STRFADE_VALUE = 0x50 + + //scoreboard ticker related + ASB_TICK_PIXEL_SCALE = 2 //how many pixels per cycle for ticker animation + ASB_TICK_MAX_ADJUST = 2 * ASB_HEIGHT ) var ( //go:embed resources/images/barmask.png barmaskAsset_png []byte assetsBarmask *ebiten.Image + + //go:embed resources/images/crown.png + crownAsset_png []byte + assetsCrown *ebiten.Image ) type AnimatedScoreBar struct { @@ -45,12 +50,16 @@ type AnimatedScoreBar struct { name string target float64 value float64 - gradient *image.RGBA + gradient *Gradient + barcolors []color.RGBA character *Character flames *Flame barbuffer *ebiten.Image order int //ranking sf float64 //scaling factor + crown *ebiten.Image + leader bool + cycle int } func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar { @@ -61,17 +70,22 @@ func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar { sf: 1, ScoreImage: ebiten.NewImage(ASB_WIDTH, ASB_HEIGHT), barbuffer: ebiten.NewImage(ASB_WIDTH, ASB_HEIGHT), + gradient: NewGradient(ASB_WIDTH, ASB_HEIGHT), character: NewCharacter(t), flames: NewFlame(), + crown: ebiten.NewImageFromImage(assetsCrown), order: 0, + leader: false, + barcolors: []color.RGBA{HexToRGBA(ASB_GRAD_TOP), HexToRGBA(ASB_GRAD_BOTTOM)}, } a.character.RandomizeCycleStart() + a.SetBarColors(a.barcolors[GRAD_IDX_TOP], a.barcolors[GRAD_IDX_BOTTOM]) + return a +} - //fill bar gradient - a.gradient = image.NewRGBA(image.Rect(0, 0, ASB_WIDTH, ASB_HEIGHT)) - FillGradient(a.gradient, a.barbuffer.Bounds().Dx(), a.barbuffer.Bounds().Dy(), HexToRGBA(ASB_GRAD_TOP), HexToRGBA(ASB_GRAD_BOTTOM)) - a.barbuffer.WritePixels(a.gradient.Pix) +func (a *AnimatedScoreBar) RefreshBar() { + a.barbuffer.DrawImage(a.gradient.Scene, nil) //mask start of the bar for smooth edges op := &ebiten.DrawImageOptions{} @@ -80,7 +94,6 @@ func NewAnimatedScoreBar(t CharacterType, s string) *AnimatedScoreBar { //add name to the fill bar, which will gradually display as the value increases text.Draw(a.barbuffer, a.name, DashFont.TeamBarName, ASB_MARGIN, ASB_HEIGHT-ASB_MARGIN, color.White) - return a } func (a *AnimatedScoreBar) GetCharacterType() CharacterType { @@ -103,22 +116,43 @@ func (a *AnimatedScoreBar) GetValue() float64 { return a.value } +func (a *AnimatedScoreBar) ResetLead() { + a.leader = false +} + +func (a *AnimatedScoreBar) SetLead() { + a.leader = true +} + func (a *AnimatedScoreBar) Animate() { a.ScoreImage.Clear() - a.AddFadedTeamName() + //a.AddFadedTeamName() //peeps complained, so we cut this a.AddScoreBar() a.AddCharacter() a.AddTextScore() a.AdjustValue() + a.AddLeadIndicator() + + a.CycleUpdate() +} + +func (a *AnimatedScoreBar) CycleUpdate() { + a.cycle++ +} + +func (a *AnimatedScoreBar) AddLeadIndicator() { + if a.leader { + a.ScoreImage.DrawImage(a.crown, nil) + } } func (a *AnimatedScoreBar) AddFadedTeamName() { //alpha blended black for the team name c := HexToRGBA(0x000000) c.A = ASB_STRFADE_VALUE - text.Draw(a.ScoreImage, a.name, DashFont.TeamBackgroundName, ASB_STRFADE_XOFFSET, ASB_STRFADE_YOFFSET, c) + text.Draw(a.ScoreImage, a.name, DashFont.TeamBackgroundName, 1000, ASB_STRFADE_YOFFSET, c) } func (a *AnimatedScoreBar) SetOrder(order int) { @@ -171,10 +205,34 @@ func (a *AnimatedScoreBar) AddCharacter() { } func (a *AnimatedScoreBar) AddTextScore() { + var ypos int + M := ASB_HEIGHT tx := int(a.value*a.sf) + ASB_BUFFER + a.character.GetWidth() - ty := ASB_HEIGHT - text.Draw(a.ScoreImage, fmt.Sprintf("%0.f", a.value), DashFont.Title, tx, ty, color.White) - text.Draw(a.ScoreImage, "POINTS", DashFont.Title, tx+ASB_BUFFER*7, ty, color.White) + + //animate ticker movement for the team name + adjust := ASB_TICK_MAX_ADJUST //max adjustment + if a.cycle%adjust < M/ASB_TICK_PIXEL_SCALE { + ypos = 2*M - ASB_TICK_PIXEL_SCALE*a.cycle%adjust + } else if a.cycle%adjust > 2*M/ASB_TICK_PIXEL_SCALE { + ypos = M - (ASB_TICK_PIXEL_SCALE*(a.cycle-2*M/ASB_TICK_PIXEL_SCALE))%adjust + } else { + ypos = M + } + text.Draw(a.ScoreImage, a.name, DashFont.Title, tx, ypos, color.White) + + //now do the same for the points, offset by the height of the ticker + bcycle := a.cycle - M/ASB_TICK_PIXEL_SCALE + adjust = 2 * M + if bcycle%adjust < 2*M/ASB_TICK_PIXEL_SCALE { + ypos = 3*M - ASB_TICK_PIXEL_SCALE*bcycle%adjust + } else if bcycle%adjust > 3*M/ASB_TICK_PIXEL_SCALE { + ypos = M - (ASB_TICK_PIXEL_SCALE*(bcycle-3*M/ASB_TICK_PIXEL_SCALE))%adjust //M - (pixel_scale*(bcycle-2*M/pixel_scale))%ma + } else { + ypos = M + } + text.Draw(a.ScoreImage, fmt.Sprintf("%0.f", a.value), DashFont.Title, tx, ypos, color.White) + text.Draw(a.ScoreImage, "POINTS", DashFont.Title, tx+ASB_BUFFER*7, ypos, color.White) + } func (a *AnimatedScoreBar) AdjustValue() { @@ -183,6 +241,7 @@ func (a *AnimatedScoreBar) AdjustValue() { } } +// set our point scale factor (scalefactor = pixels per score point) func (a *AnimatedScoreBar) SetScaleFactor(sf float64) { a.sf = sf } @@ -194,7 +253,19 @@ func (a *AnimatedScoreBar) Reset() { func init() { img, _, err := image.Decode(bytes.NewReader(barmaskAsset_png)) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } assetsBarmask = ebiten.NewImageFromImage(img) + + img, _, err = image.Decode(bytes.NewReader(crownAsset_png)) + if err != nil { + DashLogger.Fatal(err) + } + assetsCrown = ebiten.NewImageFromImage(img) +} + +func (a *AnimatedScoreBar) SetBarColors(top, bottom color.RGBA) { + //fill bar gradient + a.gradient.SetColors(top, bottom) + a.RefreshBar() } diff --git a/characters.go b/characters.go index 4492cca..72fa570 100644 --- a/characters.go +++ b/characters.go @@ -5,7 +5,6 @@ import ( _ "embed" "image" _ "image/png" - "log" "math/rand" "github.com/hajimehoshi/ebiten/v2" @@ -136,13 +135,13 @@ func (c *Character) GetType() CharacterType { func init() { img, _, err := image.Decode(bytes.NewReader(assets_png)) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } assetsImage = ebiten.NewImageFromImage(img) img, _, err = image.Decode(bytes.NewReader(winnerAssets_png)) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } assetsWinner = ebiten.NewImageFromImage(img) } diff --git a/constants.go b/constants.go index 36f41bc..a4813c4 100644 --- a/constants.go +++ b/constants.go @@ -1,31 +1,11 @@ package main -const ( - CUSTOM_FIELD_TEAM = "customfield_14580" -) - -func GetDeadlineResourcePath() string { - return "resources/settings/deadline.txt" +func GetConfigPath() string { + return "resources/settings/config.json" } -func GetDashboardTitle() string { - return "PI-8 DASHBOARD" -} - -func GetTeamNames() []string { - return []string{"devops", "gaia", "igaluk", "orion", "pulsar", "solaris", "supernova"} -} - -func GetPOList() []string { - return []string{"dbenavid", "el076320", "em031838", "ktsui", "ma031420"} -} - -func GetBountyJQL() string { - return `project = GERSSW AND ("Epic Link" =GERSSW-4971 OR "Epic Link" =GERSSW-3759) AND Status = Accepted` -} - -func GetCompletionText() string { - return "PHASE B COMPLETE" +func GetLogPath() string { + return "dashboard.log" } func GetTimerFormatString() string { diff --git a/dashboard.go b/dashboard.go index 8350225..341e0de 100644 --- a/dashboard.go +++ b/dashboard.go @@ -4,10 +4,9 @@ import ( "bufio" _ "embed" "fmt" - "image" "image/color" - "log" "math" + "math/rand" "os" "syscall" "time" @@ -19,32 +18,35 @@ import ( ) const ( - DIMWIDTH = 1920 - DIMHEIGHT = 1080 + DASH_WIDTH = 1920 + DASH_HEIGHT = 1080 - COLOR_GRAD_TOP = 0xff887c - COLOR_GRAD_BOTTOM = 0xb6325f - COLOR_GRAD_ETOP = 0xffcc33 - COLOR_GRAD_EBOTTOM = 0xcc9933 + //color codes + DASH_COLOR_GRAD_TOP = 0x9601ce //0xff887c + DASH_COLOR_GRAD_BOTTOM = 0x4b007a //0xb6325f + DASH_COLOR_GRAD_ETOP = 0xffcc33 + DASH_COLOR_GRAD_EBOTTOM = 0xcc9933 + DASH_COLOR_GRAD_24TOP = 0xd42518 + DASH_COLOR_GRAD_24BOT = 0x911416 - SCORE_UPDATE_INTERVAL = 60 * 60 * 5 //in seconds, 60 updates per second, 60 seconds per minute, 5 minutes - SCORE_VERTICAL_OFFSET = 40 //pixels - REWARD_SCALE = 5 - TIMER_UPDATE_RATE = 3 //updates per frame - WINNER_CHARACTER_SPACING = 150 - TIMER_XOFFSET_DIST = 500 - POOMOJI_SIN_SCALER = 10 - DASH_SCALE_FACTOR = 3 //pixels per team-points on results bar - DASH_ANIMATIONS_PER_CYCLE = 12 //our effective animation rate + //intervals and rates + DASH_SCORE_UPDATE_INTERVAL = 60 * 60 * 5 //in seconds, 60 updates per second, 60 seconds per minute, 5 minutes + DASH_TIMER_UPDATE_RATE = 3 //updates per frame + DASH_ANIMATIONS_PER_CYCLE = 12 //our effective animation rate - //various textual offset - DASH_TIMESTR_XOFFSET = 50 - DASH_TIMESTR_YOFFSET = 40 - DASH_DEBUG_OFFSET = 50 - DASH_WINSTR_XOFFSET = 80 - DASH_WINSTR_YOFFSET = 55 - - MAX_DEBUG_TARGET = 200 + //offsets and scalers + DASH_TIMESTR_XOFFSET = 50 + DASH_TIMESTR_YOFFSET = 40 + DASH_WINSTR_XOFFSET = 80 + DASH_WINSTR_YOFFSET = 55 + DASH_SCORE_VERTICAL_OFFSET = 40 //pixels + DASH_REWARD_SCALE = 5 + DASH_WINNER_CHARACTER_SPACING = 150 + DASH_TIMER_XOFFSET_DIST = 500 + DASH_POOMOJI_SIN_SCALER = 10 + DASH_SCALE_FACTOR = 3 //pixels per team-points on results bar + DASH_DEBUG_OFFSET = 50 + DASH_MAX_DEBUG_TARGET = 200 ) type TeamData struct { @@ -60,6 +62,7 @@ type Dashboard struct { WindowTitle string Width int Height int + settings *DashSettings //timer and animation related tick int @@ -68,9 +71,14 @@ type Dashboard struct { expired bool aps int //animation-cycles per second endcondition bool + final24 bool + finalready bool //dashboard elements and backgrounds - gradientImage *image.RGBA + gradient *Gradient + + //gradientImage *image.RGBA + gradColorBottom color.RGBA gradColorTop color.RGBA reward *Reward @@ -91,13 +99,35 @@ func (d *Dashboard) Update() error { //perform animation rate update check d.UpdateAnimationRate() + d.UpdateTimer() + d.UpdateScoreboardAsync() + d.HandleInputs() + + d.tick++ + return nil +} + +// instantaneous inputs, bypassing timers +func (d *Dashboard) HandleInputs() { + if d.expired { + return + } - /********** instantaneous score updates, bypassing timer bypass timer *****************/ //END IMMEDIATELY if inpututil.IsKeyJustPressed(ebiten.KeyE) { d.deadline = time.Now() } + //24 HOURS REMAINING MODE + if inpututil.IsKeyJustPressed(ebiten.KeyR) { + //find the time.Time just under 24 hours from now, then use this to compute delta + //between original deadline and new pre-24hour time, finally subtracting this duration + //from the original deadline to arrive at our new, just shy of 24hour deadline + timePlus24 := time.Now().Add(24*time.Hour + 5*time.Second) + newoffset := d.deadline.Sub(timePlus24) + d.deadline = d.deadline.Add(-newoffset) + } + //GENERATE RANDOM SCORES if inpututil.IsKeyJustPressed(ebiten.KeyP) { d.leaderboard.RandomizeTeamTargets() @@ -106,49 +136,71 @@ func (d *Dashboard) Update() error { //SET ALL TEAMS TO PREDETERMINED VALUE if inpututil.IsKeyJustPressed(ebiten.KeyL) { - d.leaderboard.SetAllTargets(MAX_DEBUG_TARGET) + d.leaderboard.SetAllTargets(DASH_MAX_DEBUG_TARGET) d.leaders = d.leaderboard.GetLeaders() } - /**************************************************************************************/ - //perform timer calculation - if d.tick%TIMER_UPDATE_RATE == 0 { - d.timeleft = time.Until(d.deadline) + //CHOOSE AND SET NEW RANDOM GRADIENT COLORS FOR BG + if inpututil.IsKeyJustPressed(ebiten.KeyPeriod) { + d.gradient.SetColors(HexToRGBA(int(rand.Uint32())), HexToRGBA(int(rand.Uint32()))) } - //reload scores in new thread - if d.tick%SCORE_UPDATE_INTERVAL == 0 { + //TRY AND RECONNECT TO JIRA + if inpututil.IsKeyJustPressed(ebiten.KeyJ) { + d.issuereader.Reconnect() + if d.issuereader.IsAvailable() { + go d.UpdateScoreboardAsync() + } + } + + //FORCE DROP JIRA CONNECTION + if inpututil.IsKeyJustPressed(ebiten.KeyD) { + d.issuereader.DropClient() + } + +} + +func (d *Dashboard) UpdateTimer() { + //perform timer calculation if we're at the correct update interval + if d.tick%DASH_TIMER_UPDATE_RATE == 0 { + //except don't do it on weekends (since we're using working days weekends don't count) + isweekend := (time.Now().Weekday() == time.Sunday || time.Now().Weekday() == time.Saturday) + if !isweekend { + d.timeleft = time.Until(d.deadline) + d.expired = d.timeleft.Seconds() <= 0 //set expiry + } + } +} + +// use thread to reload scores +func (d *Dashboard) UpdateScoreboardAsync() { + if d.tick%DASH_SCORE_UPDATE_INTERVAL == 0 && !d.endcondition { go d.UpdateScoreboard() } - - //have we expired? - d.expired = d.timeleft.Seconds() <= 0 - d.tick++ - - return nil } // update score from jira actuals func (d *Dashboard) UpdateScoreboard() { - if d.issuereader == nil { + if !d.issuereader.IsAvailable() { return } - if !d.expired { + if !d.endcondition { scores := d.issuereader.RefreshScores() - for k, v := range scores { - d.leaderboard.UpdateScoreForTeam(k, v) + //refresh could take a while, we may have actually ended in between + if !d.endcondition { + for k, v := range scores { + d.leaderboard.UpdateScoreForTeam(k, v) + } + d.leaders = d.leaderboard.GetLeaders() } - - d.leaders = d.leaderboard.GetLeaders() } } func (d *Dashboard) Draw(screen *ebiten.Image) { - screen.WritePixels(d.gradientImage.Pix) + screen.DrawImage(d.gradient.Scene, nil) d.DrawUpdate(screen) - //d.DrawDebug(screen) } @@ -160,6 +212,22 @@ func (d *Dashboard) DrawUpdate(screen *ebiten.Image) { d.RenderTimer(screen) d.DrawLeaderboard(screen) } + + d.DrawWarnings(screen) +} + +func (d *Dashboard) DrawWarnings(screen *ebiten.Image) { + if d.tick%90 < 45 { + var str string + + if !d.issuereader.IsAvailable() { + str = "OFFLINE MODE" + } + + if str != "" { + text.Draw(screen, str, DashFont.Debug, DASH_TIMESTR_YOFFSET, DASH_TIMESTR_YOFFSET, color.White) + } + } } func (d *Dashboard) DrawLeaderboard(screen *ebiten.Image) { @@ -183,50 +251,55 @@ func (d *Dashboard) DrawReward(screen *ebiten.Image) { var ypos float64 //compute relative position and offset for reward rpos := CharacterPosition{X: float64(d.Width) / 2, Y: float64(d.Height) / 2} - xpos := rpos.X - float64(d.reward.Width())/2*REWARD_SCALE + xpos := rpos.X - float64(d.reward.Width())/2*DASH_REWARD_SCALE //animate the y position incr := d.tick * 2 if incr < int(rpos.Y) { ypos = float64(incr) } else { - ypos = rpos.Y + math.Sin((float64(d.tick)-rpos.Y)/(math.Pi*4))*POOMOJI_SIN_SCALER + ypos = rpos.Y + math.Sin((float64(d.tick)-rpos.Y)/(math.Pi*4))*DASH_POOMOJI_SIN_SCALER } //adjust y offset to center - ypos = ypos - float64(d.reward.Width())/2*REWARD_SCALE + ypos = ypos - float64(d.reward.Width())/2*DASH_REWARD_SCALE //apply transformation op := &ebiten.DrawImageOptions{} - op.GeoM.Scale(REWARD_SCALE, REWARD_SCALE) + op.GeoM.Scale(DASH_REWARD_SCALE, DASH_REWARD_SCALE) op.GeoM.Translate(xpos, ypos) screen.DrawImage(d.reward.Sprite, op) } // draw winning characteres + scores func (d *Dashboard) DrawWinners(screen *ebiten.Image) { - winnertext := GetWinnerText(len(d.leaders)) - text.Draw(screen, winnertext, DashFont.Debug, d.Width/2-DASH_WINSTR_XOFFSET, d.Height*2/3-DASH_WINSTR_YOFFSET, color.White) - var i int = 0 - for _, team := range d.leaders { - name := team.Name() + numleaders := len(d.leaders) - mascot := d.winnerCharacters[i] - op := &ebiten.DrawImageOptions{} + if numleaders > 0 { + text.Draw(screen, GetWinnerText(numleaders), DashFont.Debug, d.Width/2-DASH_WINSTR_XOFFSET, d.Height*2/3-DASH_WINSTR_YOFFSET, color.White) + var i int = 0 + for _, team := range d.leaders { + name := team.Name() - totallen := len(d.leaders) * WINNER_CHARACTER_SPACING + mascot := d.winnerCharacters[i] + op := &ebiten.DrawImageOptions{} - xpos := d.Width/2 - totallen/2 + i*WINNER_CHARACTER_SPACING - ypos := d.Height * 2 / 3 + totallen := numleaders * DASH_WINNER_CHARACTER_SPACING - op.GeoM.Scale(2, 2) - op.GeoM.Translate(float64(xpos), float64(ypos)) - screen.DrawImage(mascot.Sprite, op) - text.Draw(screen, name, DashFont.Normal, xpos, ypos+150, color.Black) + xpos := d.Width/2 - totallen/2 + i*DASH_WINNER_CHARACTER_SPACING + ypos := d.Height * 2 / 3 - str := fmt.Sprintf("%0.fPTS", team.GetTarget()) - text.Draw(screen, str, DashFont.Points, xpos, ypos+150+20, color.Black) - i++ + op.GeoM.Scale(2, 2) + op.GeoM.Translate(float64(xpos), float64(ypos)) + screen.DrawImage(mascot.Sprite, op) + text.Draw(screen, name, DashFont.Normal, xpos, ypos+150, color.Black) + + str := fmt.Sprintf("%0.fPTS", team.GetTarget()) + text.Draw(screen, str, DashFont.Points, xpos, ypos+150+20, color.Black) + i++ + } + } else { + text.Draw(screen, "EVERYONE LOSES", DashFont.Debug, d.Width/2-DASH_WINSTR_XOFFSET*3/2, d.Height*2/3-DASH_WINSTR_YOFFSET, color.White) } } @@ -243,7 +316,7 @@ func (d *Dashboard) AnimateRewards() { // performs end condition settings exactly once per dashboard reset func (d *Dashboard) IniitializeEndCondition() { if !d.endcondition { - FillGradient(d.gradientImage, d.Width, d.Height, HexToRGBA(COLOR_GRAD_ETOP), HexToRGBA(COLOR_GRAD_EBOTTOM)) + d.gradient.SetColors(HexToRGBA(DASH_COLOR_GRAD_ETOP), HexToRGBA(DASH_COLOR_GRAD_EBOTTOM)) d.endcondition = true d.tick = 0 @@ -268,22 +341,36 @@ func (d *Dashboard) DrawDebug(screen *ebiten.Image) { func (d *Dashboard) RenderTimer(screen *ebiten.Image) { hoursRaw := d.timeleft.Hours() - days := math.Floor(float64(hoursRaw) / 24) + + //ok this bit is messy due to our lame workweek tracking, if we've got less than 5 days left, use the raw day count + //otherwise take into account business day tracking + days := math.Floor(float64(hoursRaw) / 24) //total number of days, including weekends + if days > 5 { + days = float64(GetWeekDaysUntil(d.deadline)) //weekdays only (not including holidays) + } + hours := int(hoursRaw) % 24 minutes := int(d.timeleft.Minutes()) % 60 //minutes per hour seconds := int(d.timeleft.Seconds()) % 60 //seconds per minute - formattedTime := fmt.Sprintf("%3.f:%02d:%02d:%02d", days, hours, minutes, seconds) - ypos := d.Height / 3 - xpos := d.Width/2 - TIMER_XOFFSET_DIST + d.final24 = hoursRaw < 24 + if d.final24 && !d.finalready { + d.leaderboard.Pinkify() + d.gradient.SetColors(HexToRGBA(DASH_COLOR_GRAD_24TOP), HexToRGBA(DASH_COLOR_GRAD_24BOT)) + d.finalready = true + } - text.Draw(screen, formattedTime, DashFont.PrincipleTimer, xpos, ypos, color.Black) - text.Draw(screen, GetTimerFormatString(), DashFont.Normal, d.Width/2-DASH_TIMESTR_XOFFSET, ypos+DASH_TIMESTR_YOFFSET, color.Black) + formattedTime := fmt.Sprintf("%.f:%02d:%02d:%02d", days, hours, minutes, seconds) + ypos := d.Height / 3 + xpos := d.Width/2 - DASH_TIMER_XOFFSET_DIST + + text.Draw(screen, formattedTime, DashFont.PrincipleTimer, xpos, ypos, color.White) + text.Draw(screen, GetTimerFormatString(), DashFont.Normal, d.Width/2-DASH_TIMESTR_XOFFSET, ypos+DASH_TIMESTR_YOFFSET, color.White) } func (d *Dashboard) DrawExpiration(screen *ebiten.Image) { if d.tick%90 < 45 { - text.Draw(screen, GetCompletionText(), DashFont.Celebration, d.Width/2-600, d.Height/3, color.Black) + text.Draw(screen, d.settings.Labels.Completion, DashFont.Celebration, d.Width/2-600, d.Height/3, color.Black) } } @@ -298,13 +385,12 @@ func (d *Dashboard) SetDimensions(w, h int) { if h > 0 { d.Height = h } - - d.gradientImage = image.NewRGBA(image.Rect(0, 0, w, h)) - FillGradient(d.gradientImage, d.Width, d.Height, d.gradColorTop, d.gradColorBottom) + d.gradient.SetColors(d.gradColorTop, d.gradColorBottom) } func (d *Dashboard) SetTime(t time.Time) { d.deadline = t + d.timeleft = time.Until(d.deadline) } // adjust animation rate based on keyboard input (up/down arrow keys) @@ -327,45 +413,55 @@ func (d *Dashboard) UpdateAnimationRate() { // adds a jira issue reader to the dashboard - prompts for credentials func (d *Dashboard) AddIssueReader() { + r := bufio.NewReader(os.Stdin) fmt.Print("Jira Username: ") username, _ := r.ReadString('\n') + fmt.Print("Jira Password: ") pass, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(pass) - d.issuereader = NewIssueReader(username, password) + d.issuereader = NewIssueReader(username, password, d.settings) } -func NewDashboard() *Dashboard { +func NewDashboard(s *DashSettings) *Dashboard { d := &Dashboard{ tick: 0, - WindowTitle: GetDashboardTitle(), - gradColorTop: HexToRGBA(COLOR_GRAD_TOP), - gradColorBottom: HexToRGBA(COLOR_GRAD_BOTTOM), + WindowTitle: s.Labels.Title, + gradColorTop: HexToRGBA(DASH_COLOR_GRAD_TOP), + gradColorBottom: HexToRGBA(DASH_COLOR_GRAD_BOTTOM), aps: DASH_ANIMATIONS_PER_CYCLE, reward: NewReward(), endcondition: false, winnerCharacters: []*Character{}, + gradient: NewGradient(DASH_WIDTH, DASH_HEIGHT), + final24: false, + finalready: false, + settings: s, } - //d.AddIssueReader() + d.AddIssueReader() - teamnames := GetTeamNames() - d.leaderboard = NewLeaderboard(ASB_WIDTH, (len(teamnames)+1)*ASB_HEIGHT+ASB_BUFFER, teamnames) + d.SetTime(d.settings.Expiry) + d.leaderboard = NewLeaderboard(ASB_WIDTH, (len(d.settings.Teams)+1)*ASB_HEIGHT+ASB_BUFFER, d.settings.Teams) d.leaderboard.SetScaleFactor(DASH_SCALE_FACTOR) - return d } -func RunDashboard(t time.Time) { - dash := NewDashboard() - ebiten.SetWindowSize(ebiten.ScreenSizeInFullscreen()) +func RunDashboard(s *DashSettings) { + if s == nil { + return + } + + dash := NewDashboard(s) + dash.SetDimensions(DASH_WIDTH, DASH_HEIGHT) + + ebiten.SetWindowSize(ebiten.ScreenSizeInFullscreen()) + ebiten.SetWindowTitle(s.Labels.Title) - ebiten.SetWindowTitle(dash.WindowTitle) - dash.SetDimensions(DIMWIDTH, DIMHEIGHT) ebiten.SetFullscreen(true) - dash.SetTime(t) if err := ebiten.RunGame(dash); err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } } diff --git a/flame.go b/flame.go index 150e5d6..a611542 100644 --- a/flame.go +++ b/flame.go @@ -5,7 +5,6 @@ import ( _ "embed" "image" _ "image/png" - "log" "github.com/hajimehoshi/ebiten/v2" ) @@ -63,7 +62,7 @@ func (f *Flame) GetHeight() int { func init() { img, _, err := image.Decode(bytes.NewReader(flameAssets_png)) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } assetsFlame = ebiten.NewImageFromImage(img) } diff --git a/fonts.go b/fonts.go index f5778c5..58d57b9 100644 --- a/fonts.go +++ b/fonts.go @@ -2,7 +2,6 @@ package main import ( _ "embed" - "log" "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" "golang.org/x/image/font" @@ -34,7 +33,7 @@ func init() { DashFont = &FontBase{} tt, err := opentype.ParseCollection(banschrift_ttf) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } //fmt.Printf("%d\n", tt.NumFonts()) @@ -46,7 +45,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.Expiry, err = opentype.NewFace(fnt, &opentype.FaceOptions{ @@ -55,7 +54,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.Title, err = opentype.NewFace(fnt, &opentype.FaceOptions{ @@ -64,7 +63,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.Normal, err = opentype.NewFace(fnt, &opentype.FaceOptions{ @@ -73,7 +72,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.Points, err = opentype.NewFace(fnt, &opentype.FaceOptions{ @@ -82,12 +81,12 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } p2tt, err := opentype.Parse(fonts.PressStart2P_ttf) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.Celebration, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ @@ -96,7 +95,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.TeamBackgroundName, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ @@ -105,7 +104,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.TeamBarName, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ @@ -114,7 +113,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } DashFont.Debug, err = opentype.NewFace(p2tt, &opentype.FaceOptions{ @@ -123,7 +122,7 @@ func init() { Hinting: font.HintingVertical, }) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } } diff --git a/gradient.go b/gradient.go index 409f6fa..78f0d39 100644 --- a/gradient.go +++ b/gradient.go @@ -3,6 +3,13 @@ package main import ( "image" "image/color" + + "github.com/hajimehoshi/ebiten/v2" +) + +const ( + GRAD_IDX_TOP = 0 + GRAD_IDX_BOTTOM = 1 ) func HexToRGBA(hexcolor int) color.RGBA { @@ -14,13 +21,36 @@ func HexToRGBA(hexcolor int) color.RGBA { } } -// performs linear gradient fill on target image for the background from top to bottom colors -func FillGradient(target *image.RGBA, width int, height int, top, bottom color.RGBA) { +type Gradient struct { + Scene *ebiten.Image //our resultant image + target *image.RGBA //raw pixel array + width int + height int + colors []color.RGBA //gradient boundary colors +} - for y := 0; y < height; y++ { +func NewGradient(width, height int) *Gradient { + return &Gradient{ + Scene: ebiten.NewImage(width, height), + target: image.NewRGBA(image.Rect(0, 0, width, height)), + width: width, + height: height, + colors: []color.RGBA{HexToRGBA(0xFFFFFF), HexToRGBA(0x000000)}, + } +} + +func (g *Gradient) SetColors(top, bottom color.RGBA) { + g.colors[GRAD_IDX_TOP] = top + g.colors[GRAD_IDX_BOTTOM] = bottom + g.fillCurrent() +} + +// performs linear fill on the currently set gradient values, top to bottom (0 to 1) +func (g *Gradient) fill(top, bottom color.RGBA) { + for y := 0; y < g.height; y++ { //the percent of our transition informs our blending - percentIn := float64(y) / float64(height) + percentIn := float64(y) / float64(g.height) //compute top and bottom blend values as factor of percentage pTopR := float64(top.R) * (1 - percentIn) @@ -32,12 +62,18 @@ func FillGradient(target *image.RGBA, width int, height int, top, bottom color.R pBottomB := float64(bottom.B) * (percentIn) //perform blending on per pixel row basis - for x := 0; x < width; x++ { - pixelIndex := 4 * (width*y + x) - target.Pix[pixelIndex] = uint8(pTopR + pBottomR) - target.Pix[pixelIndex+1] = uint8(pTopG + pBottomG) - target.Pix[pixelIndex+2] = uint8(pTopB + pBottomB) - target.Pix[pixelIndex+3] = 0xff + for x := 0; x < g.width; x++ { + pixelIndex := 4 * (g.width*y + x) + g.target.Pix[pixelIndex] = uint8(pTopR + pBottomR) + g.target.Pix[pixelIndex+1] = uint8(pTopG + pBottomG) + g.target.Pix[pixelIndex+2] = uint8(pTopB + pBottomB) + g.target.Pix[pixelIndex+3] = 0xff } } + g.Scene.WritePixels(g.target.Pix) +} + +// gradient on currently set colors +func (g *Gradient) fillCurrent() { + g.fill(g.colors[GRAD_IDX_TOP], g.colors[GRAD_IDX_BOTTOM]) } diff --git a/issuereader.go b/issuereader.go index fcb6acf..5763b03 100644 --- a/issuereader.go +++ b/issuereader.go @@ -1,7 +1,7 @@ package main import ( - "log" + "fmt" "regexp" "strconv" "strings" @@ -9,34 +9,53 @@ import ( "github.com/andygrunwald/go-jira" ) -const ( - JIRA_URL = "https://jirawms.mda.ca" -) - type IssueReader struct { - auth jira.BasicAuthTransport //auth data - client *jira.Client //jira client - score map[string]int //score information + auth jira.BasicAuthTransport //auth data + client *jira.Client //jira client + score map[string]int //score information + settings *DashSettings + available bool //flag letting us know if the client is currentl available } -func NewIssueReader(user, pass string) *IssueReader { +func NewIssueReader(user, pass string, s *DashSettings) *IssueReader { ir := &IssueReader{ auth: jira.BasicAuthTransport{ Username: strings.TrimSpace(user), Password: strings.TrimSpace(pass), }, - score: make(map[string]int), - } - - var err error - ir.client, err = jira.NewClient(ir.auth.Client(), strings.TrimSpace(JIRA_URL)) - if err != nil { - log.Fatal(err) + score: make(map[string]int), + settings: s, + available: false, } + ir.Reconnect() return ir } +func (i *IssueReader) Reconnect() { + i.client = nil + if i.settings.Jira.Url != "" { + var err error + i.client, err = jira.NewClient(i.auth.Client(), strings.TrimSpace(i.settings.Jira.Url)) + if err != nil { + DashLogger.Log(fmt.Sprintf("%v", err)) + i.available = false + } else { + i.available = true + } + } +} + +// forcibly drops the client (recoverable only through manual reconnection) +func (i *IssueReader) DropClient() { + i.client = nil + i.available = false +} + +func (i *IssueReader) IsAvailable() bool { + return i.available +} + func (i *IssueReader) GetScores() map[string]int { return i.score } @@ -48,19 +67,34 @@ func (i *IssueReader) ZeroScores() { } +func (i *IssueReader) BuildBountyJQL() string { + var epiclink string + + epiclink = "(" + for k, epicid := range i.settings.Jira.Epics { + epiclink = epiclink + `"Epic Link" = ` + epicid + if k != len(i.settings.Jira.Epics)-1 { + epiclink = epiclink + " OR " + } + } + epiclink = epiclink + ")" + + query := fmt.Sprintf(`project=%s AND %s AND Status = Accepted`, i.settings.Jira.Project, epiclink) + return query +} + func (i *IssueReader) RefreshScores() map[string]int { - i.ZeroScores() - - poIssues, er := i.GetAllIssues(i.client, GetBountyJQL()) + poIssues, er := i.GetAllIssues(i.client, i.BuildBountyJQL()) if er != nil { - log.Fatal(er) - } - - //run through each to check if we've got bounty points to award - for _, issue := range poIssues { - teamId := i.ParseCustomTeamField(issue.Fields.Unknowns[CUSTOM_FIELD_TEAM]) - i.ExtractScoreFromComments(teamId, issue.Key) + DashLogger.Log(fmt.Sprintf("%v\n", er)) + } else { + i.ZeroScores() + //run through each to check if we've got bounty points to award + for _, issue := range poIssues { + teamId := i.ParseCustomTeamField(issue.Fields.Unknowns[i.settings.Jira.Teamfield]) + i.ExtractScoreFromComments(teamId, issue.Key) + } } return i.GetScores() @@ -80,7 +114,7 @@ func (i *IssueReader) ExtractScoreFromComments(teamId string, key string) { if i.IsValidTeam(teamId) { is, _, err := i.client.Issue.Get(key, nil) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } comments := is.Fields.Comments.Comments @@ -95,7 +129,7 @@ func (i *IssueReader) ExtractScoreFromComments(teamId string, key string) { // check if s exists in Scrum Teams list func (i *IssueReader) IsValidTeam(s string) bool { - for _, t := range GetTeamNames() { + for _, t := range i.settings.Teams { if strings.Compare(strings.ToLower(s), strings.ToLower(t)) == 0 { return true } @@ -106,14 +140,14 @@ func (i *IssueReader) IsValidTeam(s string) bool { // given a jira.Comment, find and extract the associated PO Bounty points func (i *IssueReader) ExtractBountyFromComment(c *jira.Comment) int { result := 0 - re := regexp.MustCompile(`POB: (\d+)`) + re := regexp.MustCompile(i.settings.Criteria.Pattern) if c != nil { matches := re.FindStringSubmatch(c.Body) - //we're expecting exactly one principle match and one submatch (2 entries total) - if len(matches) == 2 { + //our returned matches must be larger than the specified match index + if len(matches) > i.settings.Criteria.MatchId { //submatch must be an integer, or else we simply don't update the result - a, err := strconv.Atoi(matches[1]) + a, err := strconv.Atoi(matches[i.settings.Criteria.MatchId]) if err == nil { result = a } @@ -126,7 +160,7 @@ func (i *IssueReader) ExtractBountyFromComment(c *jira.Comment) int { // given a specific jira.Comment, find if it was authored by someone int he POList func (i *IssueReader) CommentAuthorIsPO(c *jira.Comment) bool { if c != nil { - for _, p := range GetPOList() { + for _, p := range i.settings.ProductOwners { if c.Author.Name == p { return true } @@ -137,6 +171,12 @@ func (i *IssueReader) CommentAuthorIsPO(c *jira.Comment) bool { // retrieve all issues as per searchString JQL func (i *IssueReader) GetAllIssues(client *jira.Client, searchString string) ([]jira.Issue, error) { + + if client == nil { + i.available = false + return nil, nil + } + last := 0 var issues []jira.Issue for { diff --git a/leaderboard.go b/leaderboard.go index 6fbe34f..be8ddab 100644 --- a/leaderboard.go +++ b/leaderboard.go @@ -8,7 +8,9 @@ import ( ) const ( - MAX_RANDOM_INCREMENT = 50 + LDRBRD_MAX_RANDOM_INCREMENT = 50 + LDRBRD_BAR_CLR_GRD_24TOP = 0xeb40d4 + LDRBRD_BAR_CLR_GRD_24BOT = 0xc625af ) type Leaderboard struct { @@ -43,12 +45,14 @@ func (l *Leaderboard) UpdateScoreForTeam(name string, score int) { } } +// return current list of leaders, and while we're at it, update our current lead indicators func (l *Leaderboard) GetLeaders() []*AnimatedScoreBar { leaders := []*AnimatedScoreBar{} highScore := 0 for _, team := range l.teams { + team.ResetLead() teamscore := int(team.GetTarget()) if teamscore > highScore { leaders = leaders[:0] @@ -59,6 +63,11 @@ func (l *Leaderboard) GetLeaders() []*AnimatedScoreBar { } } + //for new set of leaders, set appropriate lead indicators + for _, bar := range leaders { + bar.SetLead() + } + return leaders } @@ -82,6 +91,12 @@ func (l *Leaderboard) SetAllTargets(t float64) { func (l *Leaderboard) RandomizeTeamTargets() { for _, board := range l.teams { - board.SetTarget(board.GetTarget() + float64(rand.Int()%MAX_RANDOM_INCREMENT)) + board.SetTarget(board.GetTarget() + float64(rand.Int()%LDRBRD_MAX_RANDOM_INCREMENT)) + } +} + +func (l *Leaderboard) Pinkify() { + for _, board := range l.teams { + board.SetBarColors(HexToRGBA(LDRBRD_BAR_CLR_GRD_24TOP), HexToRGBA(LDRBRD_BAR_CLR_GRD_24BOT)) } } diff --git a/main.go b/main.go index c209b84..0b07c07 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,14 @@ package main +var DashLogger *Logger + func main() { - RunDashboard(LoadTime()) + DashLogger = NewLogger(GetLogPath()) + + settings, err := LoadSettings(GetConfigPath()) + if err != nil { + DashLogger.Fatal(err) + } + + RunDashboard(settings) } diff --git a/reward.go b/reward.go index 1bdfbca..576b061 100644 --- a/reward.go +++ b/reward.go @@ -5,7 +5,6 @@ import ( _ "embed" "image" _ "image/png" - "log" "github.com/hajimehoshi/ebiten/v2" ) @@ -14,9 +13,9 @@ const ( REWARD_HEIGHT = 32 REWARD_WIDTH = 32 - SHINY_ANIMATION_CYCLES = 9 - SHINY_ROW_INDEX = 11 - SPARKLE_ROW_INDEX = 7 + REWARD_FX_CYCLES = 9 //number of animation cycles for effects + REWARD_FX_SHEEN_IDX = 11 //row offset from png, sheen effect + REWARD_FX_SPRKL_IDX = 7 //row offset from png, sparkle effect ) var ( @@ -51,21 +50,22 @@ func (r *Reward) Animate() { r.scratch.DrawImage(rewardsImage.SubImage(image.Rect(0, 0, REWARD_WIDTH, REWARD_HEIGHT)).(*ebiten.Image), nil) - if r.cycle < SHINY_ANIMATION_CYCLES { + //animate the effects + if r.cycle < REWARD_FX_CYCLES { //add sheen op := &ebiten.DrawImageOptions{} op.Blend = ebiten.BlendSourceAtop sx := r.cycle * REWARD_WIDTH - sy := REWARD_HEIGHT * SHINY_ROW_INDEX + sy := REWARD_HEIGHT * REWARD_FX_SHEEN_IDX ex := sx + REWARD_WIDTH ey := sy + REWARD_HEIGHT r.scratch.DrawImage(shinyImage.SubImage(image.Rect(sx, sy, ex, ey)).(*ebiten.Image), op) - //add sparkle - spsy := REWARD_HEIGHT * SPARKLE_ROW_INDEX + //add sparkle: x postions are identical to sheen (same png sheet), just need to compute new y offsets + spsy := REWARD_HEIGHT * REWARD_FX_SPRKL_IDX spey := spsy + REWARD_HEIGHT op.GeoM.Translate(REWARD_WIDTH/2, REWARD_HEIGHT/2) r.scratch.DrawImage(shinyImage.SubImage(image.Rect(sx, spsy, ex, spey)).(*ebiten.Image), nil) @@ -76,7 +76,7 @@ func (r *Reward) Animate() { } func (r *Reward) CycleUpdate() { - r.cycle = (r.cycle + 1) % (SHINY_ANIMATION_CYCLES * 8) + r.cycle = (r.cycle + 1) % (REWARD_FX_CYCLES * 8) } func (r *Reward) Width() int { @@ -91,13 +91,13 @@ func init() { img, _, err := image.Decode(bytes.NewReader(assetsReward_png)) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } rewardsImage = ebiten.NewImageFromImage(img) img, _, err = image.Decode(bytes.NewReader(assetsShiny_png)) if err != nil { - log.Fatal(err) + DashLogger.Fatal(err) } shinyImage = ebiten.NewImageFromImage(img) diff --git a/timer.go b/timer.go index 533a495..b2cad9c 100644 --- a/timer.go +++ b/timer.go @@ -1,23 +1,25 @@ package main import ( - "log" - "os" + "math" "time" ) -func LoadTime() time.Time { +/* +totally punked from stackoverflow +https://stackoverflow.com/questions/31327124/how-to-calculate-number-of-business-days-in-golang/51607001#51607001 +*/ +func GetWeekDaysUntil(target time.Time) int { + start := time.Now() + offset := -int(start.Weekday()) + start.AddDate(0, 0, offset) - data, err := os.ReadFile(GetDeadlineResourcePath()) - if err != nil { - data = make([]byte, 1) - data[0] = 0 + offset += int(target.Weekday()) + if target.Weekday() == time.Sunday { + offset++ } - deadline, err := time.Parse(time.RFC3339, string(data)) - if err != nil { - log.Fatal(err) - } - - return deadline + diff := target.Sub(start).Truncate(time.Hour * 24) + weeks := float64(diff.Hours()/24) / 7 + return int(math.Round(weeks)*5) + offset - 1 }