From ec7623a51a92fead65230c49bb0edc4a0efb8ce4 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Sat, 21 May 2022 19:18:17 +0200 Subject: [PATCH] Implement timestamp pagination for motion list --- cmd/boardvoting/handlers.go | 110 +++++++- cmd/boardvoting/jobs.go | 10 +- cmd/boardvoting/main.go | 4 +- cmd/boardvoting/notifications.go | 6 +- cmd/boardvoting/routes.go | 2 +- internal/models/models.go | 50 ++++ internal/models/{decisions.go => motions.go} | 238 ++++++++++++++++-- .../{decisions_test.go => motions_test.go} | 4 +- ui/html/pages/motions.html | 39 ++- ui/html/partials/motion_actions.html | 3 + ui/html/partials/motion_display.html | 50 ++++ ui/html/partials/motion_status_class.html | 3 + ui/html/partials/nav.html | 7 +- ui/html/partials/pagination.html | 12 + 14 files changed, 501 insertions(+), 37 deletions(-) create mode 100644 internal/models/models.go rename internal/models/{decisions.go => motions.go} (55%) rename internal/models/{decisions_test.go => motions_test.go} (95%) create mode 100644 ui/html/partials/motion_actions.html create mode 100644 ui/html/partials/motion_display.html create mode 100644 ui/html/partials/motion_status_class.html create mode 100644 ui/html/partials/pagination.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 65d00b4..4ad02dd 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -20,29 +20,133 @@ package main import ( "html/template" "net/http" + "strings" + "time" + + "git.cacert.org/cacert-boardvoting/internal/models" + "github.com/Masterminds/sprig/v3" + "github.com/gorilla/csrf" ) -func (app *application) motions(w http.ResponseWriter, r *http.Request) { +const motionsPerPage = 10 + +func (app *application) motionList(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/motions/" { app.notFound(w) return } + var err error + + ctx := r.Context() + + listOptions := &models.MotionListOptions{Limit: motionsPerPage} + + const queryParamBefore = "before" + const queryParamAfter = "after" + + if r.URL.Query().Has(queryParamAfter) { + var after time.Time + + err := after.UnmarshalText([]byte(r.URL.Query().Get(queryParamAfter))) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + listOptions.After = &after + } else if r.URL.Query().Has(queryParamBefore) { + var before time.Time + + err := before.UnmarshalText([]byte(r.URL.Query().Get(queryParamBefore))) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + listOptions.Before = &before + } + + motions, err := app.motions.GetMotions(ctx, listOptions) + if err != nil { + app.serverError(w, err) + + return + } + + first, last, err := app.motions.TimestampRange(ctx) + if err != nil { + app.serverError(w, err) + + return + } + files := []string{ "./ui/html/base.html", "./ui/html/partials/nav.html", + "./ui/html/partials/pagination.html", + "./ui/html/partials/motion_actions.html", + "./ui/html/partials/motion_display.html", + "./ui/html/partials/motion_status_class.html", "./ui/html/pages/motions.html", } - ts, err := template.ParseFiles(files...) + funcMaps := sprig.FuncMap() + funcMaps["nl2br"] = func(text string) template.HTML { + // #nosec G203 input is sanitized + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "
")) + } + funcMaps["canMangageUsers"] = func(*models.Voter) bool { + return false + } + funcMaps[csrf.TemplateTag] = func() template.HTML { + return csrf.TemplateField(r) + } + + ts, err := template.New("").Funcs(funcMaps).ParseFiles(files...) if err != nil { app.serverError(w, err) return } - err = ts.ExecuteTemplate(w, "base", nil) + templateCtx := struct { + Voter *models.Voter + Flashes []string + Params struct { + Flags struct { + Unvoted bool + } + } + PrevPage, NextPage string + Motions []*models.MotionForDisplay + }{Motions: motions} + + if len(motions) > 0 && first.Before(motions[len(motions)-1].Proposed) { + marshalled, err := motions[len(motions)-1].Proposed.MarshalText() + if err != nil { + app.serverError(w, err) + + return + } + + templateCtx.NextPage = string(marshalled) + } + if len(motions) > 0 && last.After(motions[0].Proposed) { + marshalled, err := motions[0].Proposed.MarshalText() + if err != nil { + app.serverError(w, err) + + return + } + + templateCtx.PrevPage = string(marshalled) + } + + err = ts.ExecuteTemplate(w, "base", templateCtx) if err != nil { app.serverError(w, err) } diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go index a732db7..90b1666 100644 --- a/cmd/boardvoting/jobs.go +++ b/cmd/boardvoting/jobs.go @@ -35,7 +35,7 @@ type RemindVotersJob struct { infoLog, errorLog *log.Logger timer *time.Timer voters *models.VoterModel - decisions *models.DecisionModel + decisions *models.MotionModel notify chan NotificationMail reschedule chan Job } @@ -69,7 +69,7 @@ func (r *RemindVotersJob) Run() { var ( voters []models.Voter - decisions []models.Decision + decisions []models.Motion err error ) @@ -113,7 +113,7 @@ func (app *application) NewRemindVotersJob( infoLog: app.infoLog, errorLog: app.errorLog, voters: app.voters, - decisions: app.decisions, + decisions: app.motions, reschedule: rescheduleChannel, notify: app.mailNotifier.notifyChannel, } @@ -123,7 +123,7 @@ type CloseDecisionsJob struct { timer *time.Timer infoLog *log.Logger errorLog *log.Logger - decisions *models.DecisionModel + decisions *models.MotionModel reschedule chan Job notify chan NotificationMail } @@ -192,7 +192,7 @@ func (app *application) NewCloseDecisionsJob( return &CloseDecisionsJob{ infoLog: app.infoLog, errorLog: app.errorLog, - decisions: app.decisions, + decisions: app.motions, reschedule: rescheduleChannel, notify: app.mailNotifier.notifyChannel, } diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index 497218d..9ed5114 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -43,7 +43,7 @@ var ( type application struct { errorLog, infoLog *log.Logger voters *models.VoterModel - decisions *models.DecisionModel + motions *models.MotionModel jobScheduler *JobScheduler mailNotifier *MailNotifier mailConfig *mailConfig @@ -85,7 +85,7 @@ func main() { app := &application{ errorLog: errorLog, infoLog: infoLog, - decisions: &models.DecisionModel{DB: db, InfoLog: infoLog}, + motions: &models.MotionModel{DB: db, InfoLog: infoLog}, voters: &models.VoterModel{DB: db}, mailConfig: config.MailConfig, baseURL: config.BaseURL, diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index b73a7ff..f0295fd 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -132,14 +132,14 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) { type RemindVoterNotification struct { voter models.Voter - decisions []models.Decision + decisions []models.Motion } func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent { return &NotificationContent{ template: "remind_voter_mail.txt", data: struct { - Decisions []models.Decision + Decisions []models.Motion Name string }{Decisions: r.decisions, Name: r.voter.Name}, subject: "Outstanding CAcert board votes", @@ -148,7 +148,7 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati } type ClosedDecisionNotification struct { - decision *models.ClosedDecision + decision *models.ClosedMotion } func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index e6f8857..da11703 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -42,7 +42,7 @@ func (app *application) routes() *http.ServeMux { mux.Handle("/static/", http.StripPrefix("/static", fileServer)) mux.HandleFunc("/", app.home) - mux.HandleFunc("/motions/", app.motions) + mux.HandleFunc("/motions/", app.motionList) return mux } diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..5e16392 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,50 @@ +/* +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package models defines data models and database access types. +package models + +import ( + "fmt" + "time" +) + +func parseSqlite3TimeStamp(timeStamp string) (*time.Time, error) { + const ( + sqlite3TsStringFormat = "2006-01-02 15:04:05.999999999-07:00" + sqlite3ShortTsStringFormat = "2006-01-02 15:04:05" + ) + + var ( + result time.Time + err error + ) + + if result, err = time.Parse(sqlite3TsStringFormat, timeStamp); err == nil { + result = result.UTC() + + return &result, nil + } + + if result, err = time.ParseInLocation(sqlite3ShortTsStringFormat, timeStamp, time.UTC); err == nil { + result = result.UTC() + + return &result, nil + } + + return nil, fmt.Errorf("could not parse timestamp: %w", err) +} diff --git a/internal/models/decisions.go b/internal/models/motions.go similarity index 55% rename from internal/models/decisions.go rename to internal/models/motions.go index 5f5e447..a026bed 100644 --- a/internal/models/decisions.go +++ b/internal/models/motions.go @@ -135,7 +135,7 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, st return voteStatusApproved, "Quorum and majority have been reached" } -type Decision struct { +type Motion struct { ID int64 `db:"id"` Proposed time.Time Proponent int64 `db:"proponent"` @@ -150,26 +150,26 @@ type Decision struct { VoteType VoteType } -type ClosedDecision struct { - Decision *Decision +type ClosedMotion struct { + Decision *Motion VoteSums *VoteSums Reasoning string } -type DecisionModel struct { +type MotionModel struct { DB *sqlx.DB InfoLog *log.Logger } // Create a new decision. -func (m *DecisionModel) Create( +func (m *MotionModel) Create( ctx context.Context, proponent *Voter, voteType VoteType, title, content string, proposed, due time.Time, ) (int64, error) { - d := &Decision{ + d := &Motion{ Proposed: proposed.UTC(), Proponent: proponent.ID, Title: title, @@ -183,10 +183,10 @@ func (m *DecisionModel) Create( `INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified, tag) VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :proposed, - 'm' || strftime('%Y%m%d', :proposed) || '.' || ( + 'm' || STRFTIME('%Y%m%d', :proposed) || '.' || ( SELECT COUNT(*)+1 AS num FROM decisions - WHERE proposed BETWEEN date(:proposed) AND date(:proposed, '1 day') + WHERE proposed BETWEEN DATE(:proposed) AND DATE(:proposed, '1 day') ))`, d, ) @@ -202,7 +202,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop return id, nil } -func (m *DecisionModel) CloseDecisions(ctx context.Context) ([]*ClosedDecision, error) { +func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*ClosedMotion, error) { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("could not start transaction: %w", err) @@ -225,10 +225,10 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now _ = rows.Close() }(rows) - decisions := make([]*Decision, 0) + decisions := make([]*Motion, 0) for rows.Next() { - decision := &Decision{} + decision := &Motion{} if err = rows.StructScan(decision); err != nil { return nil, fmt.Errorf("scanning row failed: %w", err) } @@ -240,9 +240,9 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now decisions = append(decisions, decision) } - results := make([]*ClosedDecision, 0, len(decisions)) + results := make([]*ClosedMotion, 0, len(decisions)) - var decisionResult *ClosedDecision + var decisionResult *ClosedMotion for _, decision := range decisions { m.InfoLog.Printf("found closable decision %s", decision.Tag) @@ -261,7 +261,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now return results, nil } -func (m *DecisionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Decision) (*ClosedDecision, error) { +func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*ClosedMotion, error) { quorum, majority := d.VoteType.QuorumAndMajority() var ( @@ -297,14 +297,14 @@ func (m *DecisionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Decis m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning) - return &ClosedDecision{d, voteSums, reasoning}, nil + return &ClosedMotion{d, voteSums, reasoning}, nil } -func (m *DecisionModel) UnVotedDecisionsForVoter(_ context.Context, _ *Voter) ([]Decision, error) { +func (m *MotionModel) UnVotedDecisionsForVoter(_ context.Context, _ *Voter) ([]Motion, error) { panic("not implemented") } -func (m *DecisionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Decision) (*VoteSums, error) { +func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) { voteRows, err := tx.QueryxContext( ctx, `SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`, @@ -347,7 +347,7 @@ func (m *DecisionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Dec return sums, nil } -func (m *DecisionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) { +func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) { row := m.DB.QueryRowContext( ctx, `SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`, @@ -376,3 +376,205 @@ func (m *DecisionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, return &due, nil } + +type MotionForDisplay struct { + ID int64 `db:"id"` + Tag string + Proponent int64 + Proposer string + Proposed time.Time + Title string + Content string + Type VoteType `db:"votetype"` + Status VoteStatus + Due time.Time + Modified time.Time + Sums VoteSums + Votes []VoteForDisplay +} + +type VoteForDisplay struct { + Name string + Vote VoteChoice +} + +type MotionListOptions struct { + Limit int + Before, After *time.Time +} + +func (m *MotionModel) TimestampRange(ctx context.Context) (*time.Time, *time.Time, error) { + row := m.DB.QueryRowxContext(ctx, `SELECT MIN(proposed), MAX(proposed) FROM decisions`) + + if err := row.Err(); err != nil { + return nil, nil, fmt.Errorf("could not query for motion timestamps: %w", err) + } + + var ( + first, last sql.NullString + firstTs, lastTs *time.Time + err error + ) + + if err := row.Scan(&first, &last); err != nil { + return nil, nil, fmt.Errorf("could not scan timestamps: %w", err) + } + + if !first.Valid || !last.Valid { + return nil, nil, nil + } + + if firstTs, err = parseSqlite3TimeStamp(first.String); err != nil { + return nil, nil, err + } + + if lastTs, err = parseSqlite3TimeStamp(last.String); err != nil { + return nil, nil, err + } + + return firstTs, lastTs, nil +} + +func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*MotionForDisplay, error) { + var rows *sqlx.Rows + var err error + + if options.Before != nil { + rows, err = m.DB.QueryxContext( + ctx, + `SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, + decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified +FROM decisions +JOIN voters ON decisions.proponent=voters.id +WHERE decisions.proposed < $1 +ORDER BY proposed DESC +LIMIT $2`, + options.Before, + options.Limit, + ) + } else if options.After != nil { + rows, err = m.DB.QueryxContext( + ctx, + `WITH display_decision AS (SELECT decisions.id, + decisions.tag, + decisions.proponent, + voters.name AS proposer, + decisions.proposed, + decisions.title, + decisions.content, + decisions.votetype, + decisions.status, + decisions.due, + decisions.modified + FROM decisions + JOIN voters ON decisions.proponent = voters.id + WHERE decisions.proposed > $1 + ORDER BY proposed + LIMIT $2) +SELECT * +FROM display_decision +ORDER BY proposed DESC`, + options.After, + options.Limit, + ) + } else { + rows, err = m.DB.QueryxContext( + ctx, + `SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, + decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified +FROM decisions +JOIN voters ON decisions.proponent=voters.id +ORDER BY proposed DESC +LIMIT $1`, + options.Limit, + ) + } + + if err != nil { + return nil, fmt.Errorf("could not execute query: %w", err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + motions := make([]*MotionForDisplay, 0, options.Limit) + + for rows.Next() { + var decision MotionForDisplay + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("could not fetch row: %w", err) + } + + if err = rows.StructScan(&decision); err != nil { + return nil, fmt.Errorf("could not scan result: %w", err) + } + + motions = append(motions, &decision) + } + + if len(motions) > 0 { + err = m.FillVoteSums(ctx, motions) + if err != nil { + return nil, err + } + } + + return motions, nil +} + +func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*MotionForDisplay) error { + decisionIds := make([]int64, len(decisions)) + decisionMap := make(map[int64]*MotionForDisplay, len(decisions)) + + for idx, decision := range decisions { + decisionIds[idx] = decision.ID + decisionMap[decision.ID] = decision + } + + query, args, err := sqlx.In( + `SELECT v.decision, v.vote, COUNT(*) FROM votes v WHERE v.decision IN (?) GROUP BY v.decision, v.vote`, + decisionIds, + ) + if err != nil { + return fmt.Errorf("could not create IN query: %w", err) + } + + rows, err := m.DB.QueryContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("could not execute query: %w", err) + } + + defer func(rows *sql.Rows) { + _ = rows.Close() + }(rows) + + for rows.Next() { + if err = rows.Err(); err != nil { + return fmt.Errorf("could not fetch row: %w", err) + } + + var ( + decisionID int64 + vote VoteChoice + count int + ) + + err = rows.Scan(&decisionID, &vote, &count) + if err != nil { + return fmt.Errorf("could not scan row: %w", err) + } + + switch vote { + case voteAye: + decisionMap[decisionID].Sums.Ayes = count + case voteNaye: + decisionMap[decisionID].Sums.Nayes = count + case voteAbstain: + decisionMap[decisionID].Sums.Abstains = count + } + } + + return nil +} diff --git a/internal/models/decisions_test.go b/internal/models/motions_test.go similarity index 95% rename from internal/models/decisions_test.go rename to internal/models/motions_test.go index 3b8b623..4be4cbd 100644 --- a/internal/models/decisions_test.go +++ b/internal/models/motions_test.go @@ -55,7 +55,7 @@ func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) { func TestDecisionModel_Create(t *testing.T) { dbx, logger := prepareTestDb(t) - dm := models.DecisionModel{DB: dbx, InfoLog: logger} + dm := models.MotionModel{DB: dbx, InfoLog: logger} v := &models.Voter{ ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index. @@ -79,7 +79,7 @@ func TestDecisionModel_Create(t *testing.T) { func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) { dbx, logger := prepareTestDb(t) - dm := models.DecisionModel{DB: dbx, InfoLog: logger} + dm := models.MotionModel{DB: dbx, InfoLog: logger} var ( nextDue *time.Time diff --git a/ui/html/pages/motions.html b/ui/html/pages/motions.html index 5759ce8..209f9b3 100644 --- a/ui/html/pages/motions.html +++ b/ui/html/pages/motions.html @@ -1,6 +1,43 @@ {{ define "title" }}Motions{{ end }} {{ define "main" }} -

All the motions

+{{ $voter := .Voter }} +{{ $page := . }} +{{ if $voter }} +
+ +
+{{ end }} +{{ if .Motions }} + +{{ range .Motions }} +
+ {{ template "motion_display" . }} + {{ if $voter }}{{ template "motion_actions" . }}{{ end }} +
+{{ end }} + +{{ else }} +
+
+ +
+
No motions available
+
+
+

Nothing to see yet.

{{ end }} +{{ end }} diff --git a/ui/html/partials/motion_actions.html b/ui/html/partials/motion_actions.html new file mode 100644 index 0000000..dc46365 --- /dev/null +++ b/ui/html/partials/motion_actions.html @@ -0,0 +1,3 @@ +{{ define "motion_actions" }} +

TODO: MOTION_ACTIONS PLACEHOLDER

+{{ end }} \ No newline at end of file diff --git a/ui/html/partials/motion_display.html b/ui/html/partials/motion_display.html new file mode 100644 index 0000000..1c38a52 --- /dev/null +++ b/ui/html/partials/motion_display.html @@ -0,0 +1,50 @@ +{{ define "motion_display" }} +{{ .Status|toString|title }} +
{{ dateInZone "2006-01-02 15:04:05 UTC" .Modified "UTC" }}
+

{{ .Tag }}: {{ .Title }}

+

{{ wrap 76 .Content | nl2br }}

+ + + + + + + + + + + + + + + + + + + +
Due{{ dateInZone "2006-01-02 15:04:05 UTC" .Due "UTC" }}
Proposed{{ dateInZone "2006-01-02 15:04:05 UTC" .Proposed "UTC" }}
Vote type:{{ .Type|toString|title }}
Votes: +
+
Aye +
{{.Sums.Ayes}}
+
+
Naye +
{{.Sums.Nayes}}
+
+
Abstain +
{{.Sums.Abstains}}
+
+
+ {{ if .Votes }} +
+ {{ range .Votes }} +
{{ .Name }}: {{ .Vote.Vote }}
+ {{ end }} +
+ Hide Votes + {{ else if or (ne 0 .Sums.Ayes) (ne 0 .Sums.Nayes) (ne 0 .Sums.Abstains) }} + Show Votes + {{ end }} +
+{{ end }} diff --git a/ui/html/partials/motion_status_class.html b/ui/html/partials/motion_status_class.html new file mode 100644 index 0000000..48a0fb5 --- /dev/null +++ b/ui/html/partials/motion_status_class.html @@ -0,0 +1,3 @@ +{{ define "motion_status_class" -}} +{{ if eq . 0 }}blue{{ else if eq . 1 }}green{{ else if eq . -1 }}red{{ else if eq . -2 }}grey{{ end }} +{{- end }} diff --git a/ui/html/partials/nav.html b/ui/html/partials/nav.html index 6e7dabe..d8e732b 100644 --- a/ui/html/partials/nav.html +++ b/ui/html/partials/nav.html @@ -1,5 +1,8 @@ {{ define "nav" }} -