Skip to content

Commit ddc12f0

Browse files
committed
Add country detail page with clickable map
1 parent b69c6de commit ddc12f0

File tree

5 files changed

+148
-6
lines changed

5 files changed

+148
-6
lines changed

internal/countries/model.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ type Repository interface {
2626
FindSeriesByCode(ctx context.Context, code string, startMonth, endMonth, limit, offset int) (*CountryPopularityList, error)
2727
}
2828

29+
func (p CountryPopularity) GetName() string { return p.Code }
30+
func (p CountryPopularity) GetStartMonth() int { return p.StartMonth }
31+
func (p CountryPopularity) GetPopularity() float64 { return p.Popularity }
32+
2933
func newItem(identifier string, samples, count int, popularity float64, startMonth, endMonth int) CountryPopularity {
3034
return CountryPopularity{
3135
Code: identifier, Samples: samples, Count: count,

internal/ui/country/handler.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package country
22

33
import (
44
"net/http"
5+
"strings"
56

7+
"pkgstatsd/internal/chartdata"
68
"pkgstatsd/internal/countries"
79
"pkgstatsd/internal/ui/layout"
810
"pkgstatsd/internal/web"
@@ -34,6 +36,33 @@ func (h *Handler) HandleCountries(w http.ResponseWriter, r *http.Request) {
3436
)
3537
}
3638

39+
func (h *Handler) HandleCountryDetail(w http.ResponseWriter, r *http.Request) {
40+
code := strings.ToUpper(r.PathValue("code"))
41+
if code == "" {
42+
http.NotFound(w, r)
43+
return
44+
}
45+
46+
list, err := h.repo.FindSeriesByCode(r.Context(), code, 0, web.GetLastCompleteMonth(), layout.SeriesLimit, 0)
47+
if err != nil {
48+
layout.ServerError(w, "failed to fetch country series", err)
49+
return
50+
}
51+
52+
if list.Total == 0 {
53+
http.NotFound(w, r)
54+
return
55+
}
56+
57+
data := chartdata.Build(list.CountryPopularities)
58+
59+
layout.Render(w, r,
60+
layout.Page{Title: code + " - Country statistics", Description: "Popularity of Arch Linux in " + code + " over time.", Path: "/countries", Manifest: h.manifest, CanonicalPath: "/countries/" + strings.ToLower(code)},
61+
CountryDetailContent(code, data),
62+
)
63+
}
64+
3765
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
3866
mux.HandleFunc("GET /countries", h.HandleCountries)
67+
mux.HandleFunc("GET /countries/{code}", h.HandleCountryDetail)
3968
}

internal/ui/country/handler_test.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package country
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
67
"net/http/httptest"
78
"strings"
@@ -12,7 +13,8 @@ import (
1213
)
1314

1415
type mockRepo struct {
15-
findAllFunc func(ctx context.Context, query string, startMonth, endMonth, limit, offset int) (*countries.CountryPopularityList, error)
16+
findAllFunc func(ctx context.Context, query string, startMonth, endMonth, limit, offset int) (*countries.CountryPopularityList, error)
17+
findSeriesByCodeFunc func(ctx context.Context, code string, startMonth, endMonth, limit, offset int) (*countries.CountryPopularityList, error)
1618
}
1719

1820
func (m *mockRepo) FindByCode(ctx context.Context, code string, startMonth, endMonth int) (*countries.CountryPopularity, error) {
@@ -24,6 +26,9 @@ func (m *mockRepo) FindAll(ctx context.Context, query string, startMonth, endMon
2426
}
2527

2628
func (m *mockRepo) FindSeriesByCode(ctx context.Context, code string, startMonth, endMonth, limit, offset int) (*countries.CountryPopularityList, error) {
29+
if m.findSeriesByCodeFunc != nil {
30+
return m.findSeriesByCodeFunc(ctx, code, startMonth, endMonth, limit, offset)
31+
}
2732
return nil, nil
2833
}
2934

@@ -58,3 +63,79 @@ func TestHandleCountries(t *testing.T) {
5863
t.Error("expected body to contain country code")
5964
}
6065
}
66+
67+
func TestHandleCountryDetail(t *testing.T) {
68+
manifest, _ := layout.NewManifest([]byte(`{}`))
69+
var queriedCode string
70+
repo := &mockRepo{
71+
findSeriesByCodeFunc: func(ctx context.Context, code string, _, _, _, _ int) (*countries.CountryPopularityList, error) {
72+
queriedCode = code
73+
return &countries.CountryPopularityList{
74+
Total: 1,
75+
CountryPopularities: []countries.CountryPopularity{
76+
{Code: code, StartMonth: 202501, EndMonth: 202501, Popularity: 10.5},
77+
},
78+
}, nil
79+
},
80+
}
81+
handler := NewHandler(repo, manifest)
82+
83+
req := httptest.NewRequest(http.MethodGet, "/countries/de", nil)
84+
req.SetPathValue("code", "de")
85+
rr := httptest.NewRecorder()
86+
87+
handler.HandleCountryDetail(rr, req)
88+
89+
if rr.Code != http.StatusOK {
90+
t.Errorf("expected status 200, got %d", rr.Code)
91+
}
92+
93+
if queriedCode != "DE" {
94+
t.Errorf("expected uppercase query DE, got %q", queriedCode)
95+
}
96+
97+
body := rr.Body.String()
98+
if !strings.Contains(body, "DE") {
99+
t.Error("expected body to contain country code")
100+
}
101+
}
102+
103+
func TestHandleCountryDetail_NotFound(t *testing.T) {
104+
manifest, _ := layout.NewManifest([]byte(`{}`))
105+
repo := &mockRepo{
106+
findSeriesByCodeFunc: func(ctx context.Context, code string, _, _, _, _ int) (*countries.CountryPopularityList, error) {
107+
return &countries.CountryPopularityList{Total: 0}, nil
108+
},
109+
}
110+
handler := NewHandler(repo, manifest)
111+
112+
req := httptest.NewRequest(http.MethodGet, "/countries/xx", nil)
113+
req.SetPathValue("code", "xx")
114+
rr := httptest.NewRecorder()
115+
116+
handler.HandleCountryDetail(rr, req)
117+
118+
if rr.Code != http.StatusNotFound {
119+
t.Errorf("expected status 404, got %d", rr.Code)
120+
}
121+
}
122+
123+
func TestHandleCountryDetail_Error(t *testing.T) {
124+
manifest, _ := layout.NewManifest([]byte(`{}`))
125+
repo := &mockRepo{
126+
findSeriesByCodeFunc: func(_ context.Context, _ string, _, _, _, _ int) (*countries.CountryPopularityList, error) {
127+
return nil, errors.New("db error")
128+
},
129+
}
130+
handler := NewHandler(repo, manifest)
131+
132+
req := httptest.NewRequest(http.MethodGet, "/countries/de", nil)
133+
req.SetPathValue("code", "de")
134+
rr := httptest.NewRecorder()
135+
136+
handler.HandleCountryDetail(rr, req)
137+
138+
if rr.Code != http.StatusInternalServerError {
139+
t.Errorf("expected status 500, got %d", rr.Code)
140+
}
141+
}

internal/ui/country/templates.templ

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package country
22

3-
import "pkgstatsd/internal/countries"
3+
import (
4+
"pkgstatsd/internal/chartdata"
5+
"pkgstatsd/internal/countries"
6+
"strings"
7+
)
48

59
templ CountriesContent(countryPopularities []countries.CountryPopularity) {
610
<h1 class="mb-4">Countries</h1>
@@ -12,12 +16,24 @@ templ CountriesContent(countryPopularities []countries.CountryPopularity) {
1216

1317
type popularityValue struct {
1418
Popularity float64 `json:"popularity"`
19+
Link string `json:"link"`
1520
}
1621

1722
func toSvgMapValues(pops []countries.CountryPopularity) map[string]popularityValue {
1823
m := make(map[string]popularityValue, len(pops))
1924
for _, p := range pops {
20-
m[p.Code] = popularityValue{Popularity: p.Popularity}
25+
m[p.Code] = popularityValue{Popularity: p.Popularity, Link: "/countries/" + strings.ToLower(p.Code)}
2126
}
2227
return m
2328
}
29+
30+
templ CountryDetailContent(code string, data chartdata.Data) {
31+
<h1 class="mb-3">{ code }</h1>
32+
<p class="mb-3">Relative usage from <strong>{ code }</strong></p>
33+
<popularity-chart role="img" aria-label={ "Chart showing popularity of Arch Linux in " + code + " over time" }>
34+
@templ.JSONScript("", data)
35+
</popularity-chart>
36+
<div class="mt-3">
37+
<a class="btn btn-outline-primary" href="/countries">All countries</a>
38+
</div>
39+
}

src/components/country-map.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class CountryMap extends HTMLElement {
88
return;
99
}
1010

11-
let values: Record<string, { popularity: number }>;
11+
let values: Record<string, { popularity: number; link?: string }>;
1212
try {
1313
values = JSON.parse(script.textContent);
1414
} catch (error) {
@@ -34,7 +34,7 @@ class CountryMap extends HTMLElement {
3434
}
3535

3636
private async renderBarChart(
37-
values: Record<string, { popularity: number }>,
37+
values: Record<string, { popularity: number; link?: string }>,
3838
) {
3939
const sorted = Object.entries(values).sort(
4040
([, a], [, b]) => b.popularity - a.popularity,
@@ -43,6 +43,7 @@ class CountryMap extends HTMLElement {
4343
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
4444
const labels = sorted.map(([code]) => regionNames.of(code) ?? code);
4545
const data = sorted.map(([, v]) => v.popularity);
46+
const links = sorted.map(([, v]) => v.link);
4647

4748
const { Chart, BarElement, BarController, CategoryScale, LinearScale } =
4849
await import("chart.js");
@@ -73,7 +74,7 @@ class CountryMap extends HTMLElement {
7374
indexAxis: "y",
7475
animation: false,
7576
maintainAspectRatio: false,
76-
events: [],
77+
events: ["click", "mousemove"],
7778
plugins: {
7879
legend: { display: false },
7980
},
@@ -90,6 +91,17 @@ class CountryMap extends HTMLElement {
9091
ticks: { color: textColor },
9192
},
9293
},
94+
onHover: (_event, elements) => {
95+
canvas.style.cursor = elements.length > 0 ? "pointer" : "";
96+
},
97+
onClick: (_event, elements) => {
98+
if (elements.length > 0) {
99+
const link = links[elements[0].index];
100+
if (link) {
101+
window.location.href = link;
102+
}
103+
}
104+
},
93105
},
94106
});
95107
}

0 commit comments

Comments
 (0)