diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 21a1f26..efd8d10 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/roots/wp-packages/internal/deploy" "github.com/roots/wp-packages/internal/og" "github.com/roots/wp-packages/internal/packages" + "github.com/roots/wp-packages/internal/telemetry" "github.com/roots/wp-packages/internal/version" ) @@ -57,6 +58,7 @@ func setETag(w http.ResponseWriter, r *http.Request, parts ...string) bool { } type packageRow struct { + ID int64 Type string Name string DisplayName string @@ -356,6 +358,8 @@ func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc { versions := parseVersions(pkg) + monthlyInstalls, _ := telemetry.GetMonthlyInstalls(r.Context(), a.DB, pkg.ID) + untagged := pkg.Type == "plugin" && pkg.WporgVersion != "" && !versionIsTagged(versions, pkg.WporgVersion) trunkOnly := untagged && !hasTaggedVersion(versions) @@ -426,14 +430,15 @@ func handleDetail(a *app.App, tmpl *templateSet) http.HandlerFunc { } render(w, r, tmpl.detail, "layout", map[string]any{ - "Package": pkg, - "Versions": versions, - "Untagged": untagged, - "TrunkOnly": trunkOnly, - "AppURL": a.Config.AppURL, - "CDNURL": a.Config.R2.CDNPublicURL, - "OGImage": ogImage, - "JSONLD": []any{softwareApp, breadcrumbs}, + "Package": pkg, + "Versions": versions, + "MonthlyInstalls": monthlyInstalls, + "Untagged": untagged, + "TrunkOnly": trunkOnly, + "AppURL": a.Config.AppURL, + "CDNURL": a.Config.R2.CDNPublicURL, + "OGImage": ogImage, + "JSONLD": []any{softwareApp, breadcrumbs}, }) } } @@ -727,12 +732,12 @@ func packageExistsInactive(ctx context.Context, db *sql.DB, pkgType, name string func queryPackageDetail(ctx context.Context, db *sql.DB, pkgType, name string) (*packageDetail, error) { var p packageDetail - err := db.QueryRowContext(ctx, `SELECT type, name, COALESCE(display_name,''), COALESCE(description,''), + err := db.QueryRowContext(ctx, `SELECT id, type, name, COALESCE(display_name,''), COALESCE(description,''), COALESCE(author,''), COALESCE(homepage,''), COALESCE(current_version,''), downloads, active_installs, wp_packages_installs_total, versions_json, COALESCE(wporg_version,''), COALESCE(updated_at,''), og_image_generated_at FROM packages WHERE type = ? AND name = ? AND is_active = 1`, pkgType, name, - ).Scan(&p.Type, &p.Name, &p.DisplayName, &p.Description, &p.Author, &p.Homepage, + ).Scan(&p.ID, &p.Type, &p.Name, &p.DisplayName, &p.Description, &p.Author, &p.Homepage, &p.CurrentVersion, &p.Downloads, &p.ActiveInstalls, &p.WpPackagesInstallsTotal, &p.VersionsJSON, &p.WporgVersion, &p.UpdatedAt, &p.OGImageGeneratedAt) if err != nil { diff --git a/internal/http/templates.go b/internal/http/templates.go index dc731ed..db6b88f 100644 --- a/internal/http/templates.go +++ b/internal/http/templates.go @@ -13,6 +13,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/roots/wp-packages/internal/telemetry" ) //go:embed templates/*.html @@ -76,6 +78,7 @@ var funcMap = template.FuncMap{ } return fmt.Sprintf("%.1f", float64(n)*100/float64(total)) }, + "installChart": installChart, "wporgURL": func(composerName string) string { // "wp-plugin/slug" → "https://wordpress.org/plugins/slug/" // "wp-theme/slug" → "https://wordpress.org/themes/slug/" @@ -384,3 +387,127 @@ func pageRange(current, total int) []int { } return result } + +// installChart renders a server-side SVG bar chart for monthly install data. +func installChart(data []telemetry.MonthlyInstall) template.HTML { + if len(data) == 0 { + return "" + } + + // Use last 12 months max + if len(data) > 12 { + data = data[len(data)-12:] + } + + // Find max value for scaling + max := 0 + for _, m := range data { + if m.Installs > max { + max = m.Installs + } + } + if max == 0 { + return "" + } + + // Compute nice Y-axis tick values + ticks := yAxisTicks(max) + + n := len(data) + padLeft := 44.0 + padRight := 4.0 + padTop := 20.0 + padBottom := 28.0 + chartW := 600.0 + chartH := 160.0 + totalW := padLeft + chartW + padRight + barGap := 6.0 + maxBarW := 48.0 + barW := (chartW - float64(n-1)*barGap) / float64(n) + if barW > maxBarW { + barW = maxBarW + } + totalH := padTop + chartH + padBottom + + var b strings.Builder + fmt.Fprintf(&b, ``) + return template.HTML(b.String()) +} + +// yAxisTicks returns 3-5 nice round tick values from 0 to max. +func yAxisTicks(max int) []int { + if max <= 0 { + return nil + } + // Find a nice step: 1, 2, 5, 10, 20, 50, 100, ... + target := max / 4 + if target < 1 { + target = 1 + } + mag := 1 + for mag*10 <= target { + mag *= 10 + } + var step int + if mag*2 >= target { + step = mag * 2 + } else if mag*5 >= target { + step = mag * 5 + } else { + step = mag * 10 + } + + var ticks []int + for v := step; v <= max; v += step { + ticks = append(ticks, v) + } + return ticks +} diff --git a/internal/http/templates/detail.html b/internal/http/templates/detail.html index c29d281..a54c616 100644 --- a/internal/http/templates/detail.html +++ b/internal/http/templates/detail.html @@ -101,6 +101,12 @@
No versions available.