Compare commits

..

2 Commits

Author SHA1 Message Date
8f7b16a9ae Updates to match new primary repo. 2023-10-02 14:38:57 -04:00
db5da9bb48 Updates before synching with new host-repo. 2023-10-02 14:38:57 -04:00
18 changed files with 659 additions and 229 deletions

70
README.md Normal file
View File

@@ -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 <targetname>
```
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")

107
asb.go
View File

@@ -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
//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()
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
//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
//offsets and scalers
DASH_TIMESTR_XOFFSET = 50
DASH_TIMESTR_YOFFSET = 40
DASH_DEBUG_OFFSET = 50
DASH_WINSTR_XOFFSET = 80
DASH_WINSTR_YOFFSET = 55
MAX_DEBUG_TARGET = 200
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()
//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()
}
}
}
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,30 +251,32 @@ 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)
numleaders := len(d.leaders)
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()
@@ -214,9 +284,9 @@ func (d *Dashboard) DrawWinners(screen *ebiten.Image) {
mascot := d.winnerCharacters[i]
op := &ebiten.DrawImageOptions{}
totallen := len(d.leaders) * WINNER_CHARACTER_SPACING
totallen := numleaders * DASH_WINNER_CHARACTER_SPACING
xpos := d.Width/2 - totallen/2 + i*WINNER_CHARACTER_SPACING
xpos := d.Width/2 - totallen/2 + i*DASH_WINNER_CHARACTER_SPACING
ypos := d.Height * 2 / 3
op.GeoM.Scale(2, 2)
@@ -228,6 +298,9 @@ func (d *Dashboard) DrawWinners(screen *ebiten.Image) {
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)
}
}
// update reward animation cycles
@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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])
}

View File

@@ -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
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),
settings: s,
available: false,
}
var err error
ir.client, err = jira.NewClient(ir.auth.Client(), strings.TrimSpace(JIRA_URL))
if err != nil {
log.Fatal(err)
}
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,20 +67,35 @@ 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)
}
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[CUSTOM_FIELD_TEAM])
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 {

View File

@@ -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))
}
}

46
logger.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"fmt"
"log"
"os"
)
type Logger struct {
logpath string
logfile []byte
}
// write to our file, if it exists
func (l *Logger) Log(str string) {
CheckLogPath(l.logpath)
var msg string
msg = str
os.WriteFile(l.logpath, []byte(msg), os.ModeAppend)
}
// dump the err into a string, write to our log file, then pass off the error to the log package
func (l *Logger) Fatal(err error) {
l.Log(fmt.Sprintf("%v\n", err))
log.Fatal(err)
}
func NewLogger(logpath string) *Logger {
CheckLogPath(logpath)
return &Logger{
logpath: logpath,
}
}
// checks for existence of given logpath file, creates if it doesn't
func CheckLogPath(logpath string) {
_, err := os.Stat(logpath)
if os.IsNotExist(err) {
os.Create(logpath)
} else if err != nil {
log.Fatal(err)
}
}

11
main.go
View File

@@ -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)
}

BIN
resources/images/crown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
resources/images/ebiten.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,19 @@
{
"labels": {
"title": "PI-8 Dashboard",
"completion": "PHASE B COMPLETE"
},
"expiry": "2023-11-24T04:00:00.000Z",
"jira": {
"url": "https://jirawms.mda.ca",
"teamfield": "customfield_14580",
"project": "GERSSW",
"epics": ["GERSSW-4971", "GERSSW-3759"]
},
"teams": ["devops","gaia", "igaluk", "orion", "pulsar", "solaris", "supernova"],
"productowners": ["dbenavid", "el076320", "em031838", "ktsui", "ma031420"],
"criteria": {
"pattern": "POB: (\\d+)",
"matchid": 1
}
}

View File

@@ -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)

49
settings.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"encoding/json"
"os"
"time"
)
type DashSettingsJira struct {
Url string `json:"url"`
Teamfield string `json:"teamfield"`
Project string `json:"project"`
Epics []string `json:"epics"`
}
type DashSettingsSearch struct {
Pattern string `json:"pattern"`
MatchId int `json:"matchid"`
}
type DashSettingsLabels struct {
Title string `json:"title"`
Completion string `json:"completion"`
}
type DashSettings struct {
Labels DashSettingsLabels `json:"labels"`
Expiry time.Time `json:"expiry"`
Jira DashSettingsJira `json:"jira"`
Teams []string `json:"teams"`
ProductOwners []string `json:"productowners"`
Criteria DashSettingsSearch `json:"criteria"`
}
func LoadSettings(path string) (*DashSettings, error) {
var err error
var settings *DashSettings = &DashSettings{}
//check file exists, load, deserialize
_, err = os.Stat(path)
if err == nil {
rawbytes, err := os.ReadFile(path)
if err == nil {
err = json.Unmarshal(rawbytes, settings)
}
}
return settings, err
}

View File

@@ -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
}