diff --git a/db/queries/queries.sql b/db/queries/queries.sql index aeff188..f339fa5 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -62,10 +62,40 @@ FROM WHERE uuid IN (sqlc.slice('uuids')); +-- name: GetGamesInfo :one +SELECT + COUNT(*) AS total_games, + COUNT(*) FILTER (WHERE game_won = TRUE) AS won_games, + COUNT(*) FILTER (WHERE game_failed = TRUE AND game_won = FALSE) AS lost_games, + COUNT(*) FILTER (WHERE game_failed = FALSE AND game_won = FALSE) AS not_finished_games +FROM + games; + -- name: GetMovesByGameId :many SELECT * FROM moves WHERE - game_id = ? \ No newline at end of file + game_id = ?; + +-- name: GetGamesByMonthYearGroupedByDay :many +SELECT + strftime('%d', created_at) AS day, + COUNT(*) AS games_played +FROM games +WHERE created_at >= ? AND created_at < ? +GROUP BY day +ORDER BY day; + +-- name: GetGamesPlayedPerGridSize :many +SELECT grid_size, COUNT(*) AS games_played +FROM games +GROUP BY grid_size +ORDER BY grid_size; + +-- name: GetMinesPopularity :many +SELECT mines_amount, COUNT(*) AS mines_count +FROM games +GROUP BY mines_amount +ORDER BY mines_amount; \ No newline at end of file diff --git a/db/sqlc.yaml b/db/sqlc.yaml index 74b3327..ea8e024 100644 --- a/db/sqlc.yaml +++ b/db/sqlc.yaml @@ -5,5 +5,6 @@ sql: schema: "./migrations" gen: go: + initialisms: [] package: "db" out: "../internal/db" \ No newline at end of file diff --git a/dist/icon.png b/dist/icon.png new file mode 100644 index 0000000..89d2d41 Binary files /dev/null and b/dist/icon.png differ diff --git a/dist/load-spinner.svg b/dist/load-spinner.svg new file mode 100644 index 0000000..4ac5351 --- /dev/null +++ b/dist/load-spinner.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dist/main.css b/dist/main.css index e9f634d..c05d367 100644 --- a/dist/main.css +++ b/dist/main.css @@ -2,6 +2,7 @@ background-color: rgba(0, 0, 0, 0.088); transition: background-color 0.2s ease; cursor: pointer; + font-weight: bold; } .cell-revealed { diff --git a/go.mod b/go.mod index 337d2ff..aea327d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.1 require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-echarts/go-echarts/v2 v2.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.3.0 // indirect diff --git a/go.sum b/go.sum index 055817f..90a6084 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-echarts/go-echarts/v2 v2.4.2 h1:1FC3tGzsLSgdeO4Ltc3OAtcIiRomfEKxKX9oocIL68g= +github.com/go-echarts/go-echarts/v2 v2.4.2/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= diff --git a/internal/api_handlers.go b/internal/api_handlers.go new file mode 100644 index 0000000..5749adc --- /dev/null +++ b/internal/api_handlers.go @@ -0,0 +1,296 @@ +package internal + +import ( + "bytes" + "database/sql" + "fmt" + "html/template" + "minesweeper/internal/db" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-echarts/go-echarts/v2/charts" + "github.com/go-echarts/go-echarts/v2/opts" + "github.com/gorilla/sessions" + + chartrender "github.com/go-echarts/go-echarts/v2/render" +) + +type ApiHandler struct { + Templates *template.Template + Store *sessions.CookieStore + Queries *db.Queries +} + +func NewApiHandler(templates *template.Template, store *sessions.CookieStore, queries *db.Queries) *ApiHandler { + return &ApiHandler{templates, store, queries} +} + +// renderToHtml renders a chart as a template.HTML value. +// +// The argument should be a go-echarts chart that implements the Renderer interface. +// The rendered chart is returned as a template.HTML value. +// +// If the chart fails to render, an error is returned. +func renderToHtml(c interface{}) (template.HTML, error) { + r, ok := c.(chartrender.Renderer) + if !ok { + return "", fmt.Errorf("provided chart does not implement the Renderer interface") + } + + var buf bytes.Buffer + + err := r.Render(&buf) + if err != nil { + return "", fmt.Errorf("failed to render chart: %v", err) + + } + + htmlContent := buf.String() + + // Remove the , , <script> tags from the rendered chart + // TODO use net/html package with RemoveChild to remove the tags + htmlContent = strings.ReplaceAll(htmlContent, "<head>", "") + htmlContent = strings.ReplaceAll(htmlContent, "</head>", "") + htmlContent = strings.ReplaceAll(htmlContent, "<title>Awesome go-echarts", "") + htmlContent = strings.ReplaceAll(htmlContent, "", "") + + return template.HTML(htmlContent), nil +} + +func (h *ApiHandler) PieWinsLossesIncompleteChart(w http.ResponseWriter, r *http.Request) { + rawData, err := h.Queries.GetGamesInfo(r.Context()) + + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching DB information: %v", err), http.StatusInternalServerError) + return + } + + parsedData := make([]opts.PieData, 0) + var colors = []string{"#28a745", "#dc3545", "#ffc107"} + + parsedData = append(parsedData, opts.PieData{Name: "Wins", Value: rawData.WonGames, ItemStyle: &opts.ItemStyle{Color: colors[0]}}) + parsedData = append(parsedData, opts.PieData{Name: "Losses", Value: rawData.LostGames, ItemStyle: &opts.ItemStyle{Color: colors[1]}}) + parsedData = append(parsedData, opts.PieData{Name: "Incomplete", Value: rawData.NotFinishedGames, ItemStyle: &opts.ItemStyle{Color: colors[2]}}) + + pie := charts.NewPie() + + pie.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ + Title: "Wins vs Losses vs Incomplete", + Subtitle: fmt.Sprintf("Total games: %v", rawData.TotalGames), + })) + + pie.AddSeries("Game Status", parsedData). + SetSeriesOptions( + charts.WithLabelOpts(opts.Label{ + Formatter: "{b}: {d}%", // Label formatter to show percentage + }), + ) + + htmlPieSnipper, err := renderToHtml(pie) + + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering chart: %v", err), http.StatusInternalServerError) + return + } + + w.Write([]byte(htmlPieSnipper)) +} + +func (h *ApiHandler) GridSizeBar(w http.ResponseWriter, r *http.Request) { + + rawData, err := h.Queries.GetGamesPlayedPerGridSize(r.Context()) + + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching grid size data: %v", err), http.StatusInternalServerError) + return + } + + gridSizes := make([]string, len(rawData)) + parsedBarData := make([]opts.BarData, len(rawData)) + + // find the most popular grid size + maxGamesPlayed := int64(0) + for _, dbData := range rawData { + if dbData.GamesPlayed > maxGamesPlayed { + maxGamesPlayed = dbData.GamesPlayed + } + } + + for i, dbData := range rawData { + gridSizes[i] = fmt.Sprintf("%vx%v", dbData.GridSize, dbData.GridSize) + + if dbData.GamesPlayed == maxGamesPlayed { + parsedBarData[i] = opts.BarData{Value: dbData.GamesPlayed, ItemStyle: &opts.ItemStyle{Color: "#ffa500"}} + } else { + parsedBarData[i] = opts.BarData{Value: dbData.GamesPlayed} + } + } + + bar := charts.NewBar() + bar.SetGlobalOptions( + charts.WithTitleOpts(opts.Title{ + Title: "Grid Size Popularity", + Subtitle: "Games played per grid size", + }), charts.WithDataZoomOpts(opts.DataZoom{ + Type: "slider", + Start: 0, + End: 100, + }), + charts.WithLegendOpts(opts.Legend{ + Show: opts.Bool(false), + }), + ) + + bar.SetXAxis(gridSizes). + AddSeries("Games Played", parsedBarData). + SetSeriesOptions( + charts.WithLabelOpts(opts.Label{Show: opts.Bool(true)}), + ) + + htmlBarSnippet, err := renderToHtml(bar) + + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering chart: %v", err), http.StatusInternalServerError) + return + } + + w.Write([]byte(htmlBarSnippet)) +} + +func (h *ApiHandler) MinesAmountBarChart(w http.ResponseWriter, r *http.Request) { + rawDbData, err := h.Queries.GetMinesPopularity(r.Context()) + + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching mines popularity data: %v", err), http.StatusInternalServerError) + return + } + + // find the most popular amount of mines + maxAmount := int64(0) + for _, dbData := range rawDbData { + if dbData.MinesCount > maxAmount { + maxAmount = dbData.MinesCount + } + } + + minesPopularity := make([]string, len(rawDbData)) + parsedBarData := make([]opts.BarData, len(rawDbData)) + + for i, dbData := range rawDbData { + minesPopularity[i] = strconv.FormatInt(dbData.MinesAmount, 10) + + if dbData.MinesCount == maxAmount { + parsedBarData[i] = opts.BarData{Value: dbData.MinesCount, ItemStyle: &opts.ItemStyle{Color: "#ffa500"}} + } else { + parsedBarData[i] = opts.BarData{Value: dbData.MinesCount} + } + } + + bar := charts.NewBar() + bar.SetGlobalOptions( + charts.WithTitleOpts(opts.Title{ + Title: "Amount of Mines Popularity", + Subtitle: "Games with particular amount of mines", + }), + charts.WithDataZoomOpts(opts.DataZoom{ + Type: "slider", + Start: 10, + End: 75, + }), + charts.WithLegendOpts(opts.Legend{ + Show: opts.Bool(false), + }), + ) + + bar.SetXAxis(minesPopularity). + AddSeries("Mines Amount", parsedBarData). + SetSeriesOptions( + charts.WithLabelOpts(opts.Label{Show: opts.Bool(true)}), + ) + + htmlBarSnippet, err := renderToHtml(bar) + + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering chart: %v", err), http.StatusInternalServerError) + return + } + + w.Write([]byte(htmlBarSnippet)) +} + +func (h *ApiHandler) PlayedGamesInMonthBarChart(w http.ResponseWriter, r *http.Request) { + pickedDate := r.URL.Query().Get("picked-date-range") + + if pickedDate == "" { + now := time.Now() + pickedDate = now.Format("2006-01") + } + + parsedDate, err := time.Parse("2006-01", pickedDate) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid date format: %v", err), http.StatusBadRequest) + return + } + + startOfMonth := parsedDate + endOfMonth := startOfMonth.AddDate(0, 1, 0) + + // Create sql.NullTime for start and end of the month + startTime := sql.NullTime{ + Time: startOfMonth, + Valid: true, + } + + endTime := sql.NullTime{ + Time: endOfMonth, + Valid: true, + } + + gamesPerDay, err := h.Queries.GetGamesByMonthYearGroupedByDay(r.Context(), db.GetGamesByMonthYearGroupedByDayParams{ + CreatedAt: startTime, + CreatedAt_2: endTime, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error fetching games: %v", err), http.StatusInternalServerError) + return + } + + days := []string{} + gamesPlayed := []opts.BarData{} + + for _, gameDay := range gamesPerDay { + days = append(days, fmt.Sprintf("%v", gameDay.Day)) + gamesPlayed = append(gamesPlayed, opts.BarData{Value: gameDay.GamesPlayed}) + } + + bar := charts.NewBar() + bar.SetGlobalOptions( + charts.WithTitleOpts(opts.Title{ + Title: fmt.Sprintf("Games Per Day in %v", parsedDate.Format("January 2006")), + Subtitle: "Amount of games played per day", + }), + charts.WithDataZoomOpts(opts.DataZoom{ + Type: "slider", + Start: 0, + End: 100, + }), + charts.WithLegendOpts(opts.Legend{ + Show: opts.Bool(false), + }), + ) + + bar.SetXAxis(days). + AddSeries("Games Played", gamesPlayed). + SetSeriesOptions(charts.WithLabelOpts(opts.Label{Show: opts.Bool(true)})) + + htmlBarSnippet, err := renderToHtml(bar) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering chart: %v", err), http.StatusInternalServerError) + return + } + + w.Write([]byte(htmlBarSnippet)) +} diff --git a/internal/handlers.go b/internal/handlers.go index ed31601..dc7307c 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -225,7 +225,7 @@ func (h *Handler) HandleGridAction(w http.ResponseWriter, r *http.Request) { GameFailed: game.GameFailed, GameWon: game.GameWon, GridState: encodedGridState, - ID: game.ID, + Id: game.Id, }) if err != nil { log.Printf("Failed to update game state in database: %v", err) @@ -360,3 +360,12 @@ func (h *Handler) SessionGamesInfo(w http.ResponseWriter, r *http.Request) { } } + +func (h *Handler) Charts(w http.ResponseWriter, r *http.Request) { + err := h.Templates.ExecuteTemplate(w, "charts_page", nil) + + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) + return + } +} diff --git a/internal/models/models.go b/internal/models/models.go index fda1a87..f8b41fa 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -15,7 +15,7 @@ type Cell struct { } type Game struct { - ID int64 + Id int64 Uuid string GridSize int MinesAmount int @@ -276,7 +276,7 @@ func FromDbGame(dbGame *db.Game) (*Game, error) { decodedGameGrid := DecodeGameGrid(dbGame.GridState, int(dbGame.GridSize)) return &Game{ - ID: dbGame.ID, + Id: dbGame.Id, Uuid: dbGame.Uuid, GridSize: int(dbGame.GridSize), MinesAmount: int(dbGame.MinesAmount), diff --git a/main.go b/main.go index 325816e..a46dd80 100644 --- a/main.go +++ b/main.go @@ -109,6 +109,7 @@ func main() { queries := db.New(dbConn) handler := internal.NewHandler(templates, globalStore, queries) + apiHandler := internal.NewApiHandler(templates, globalStore, queries) mux.HandleFunc("/", handler.Index) mux.HandleFunc("/load-game", handler.LoadGame) @@ -116,6 +117,12 @@ func main() { mux.HandleFunc("/handle-grid-action", handler.HandleGridAction) mux.HandleFunc("/games", handler.IndexGames) mux.HandleFunc("/session-games-info", handler.SessionGamesInfo) + mux.HandleFunc("/charts", handler.Charts) + + mux.HandleFunc("/api/charts/pie/wins-losses-incomplete", apiHandler.PieWinsLossesIncompleteChart) + mux.HandleFunc("/api/charts/bar/grid-size", apiHandler.GridSizeBar) + mux.HandleFunc("/api/charts/bar/mines-amount", apiHandler.MinesAmountBarChart) + mux.HandleFunc("/api/charts/bar/games-played", apiHandler.PlayedGamesInMonthBarChart) port := cmp.Or(os.Getenv("APP_PORT"), "8080") diff --git a/templates/admin/base_layout.html b/templates/admin/base_layout.html index c8ba16e..8a27db2 100644 --- a/templates/admin/base_layout.html +++ b/templates/admin/base_layout.html @@ -7,7 +7,8 @@ name="viewport" content="width=device-width, initial-scale=1.0" /> - Minesweeper! + Minesweeper in Numbers + - + {{ template "navbar" }} {{ block "content" . }}{{ end }} diff --git a/templates/admin/charts_page.html b/templates/admin/charts_page.html new file mode 100644 index 0000000..4b913e2 --- /dev/null +++ b/templates/admin/charts_page.html @@ -0,0 +1,132 @@ +{{ define "charts_page" }} + + {{ template "base_layout" . }} +
+

Game Statistics

+ +
+
+
+
+ Loading... +
+

+ Loading chart... +

+
+
+ +
+
+ + +
+
+
+ Loading... +
+ +

+ Loading chart... +

+
+
+
+ +
+
+
+
+ Loading... +
+ +

+ Loading chart... +

+
+
+ +
+
+
+ Loading... +
+

+ Loading chart... +

+
+
+
+
+{{ end }} diff --git a/templates/admin/index_games_page.html b/templates/admin/index_games_page.html index a72cb5d..e7048a7 100644 --- a/templates/admin/index_games_page.html +++ b/templates/admin/index_games_page.html @@ -62,7 +62,7 @@

{{ range .Games }} - {{ .ID }} + {{ .Id }} {{ .Uuid }} diff --git a/templates/admin/navbar.html b/templates/admin/navbar.html index f092ded..469fef0 100644 --- a/templates/admin/navbar.html +++ b/templates/admin/navbar.html @@ -1,7 +1,7 @@ {{ define "navbar" }}