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
, , ", "")
+
+ 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 chart...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading chart...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading chart...
+
+
+
+
+
+
+
+
+
+
+ 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" }}
|