Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions internal/http/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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},
})
}
}
Expand Down Expand Up @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions internal/http/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"path/filepath"
"strings"
"time"

"github.com/roots/wp-packages/internal/telemetry"
)

//go:embed templates/*.html
Expand Down Expand Up @@ -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/"
Expand Down Expand Up @@ -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, `<svg viewBox="0 0 %.0f %.0f" width="100%%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Monthly Composer installs chart">`, totalW, totalH)
b.WriteString(`<style>g.bar .tip{opacity:0;transition:opacity .1s}g.bar:hover .tip{opacity:1}g.bar:hover rect{opacity:.9!important}</style>`)

// Y-axis tick lines and labels
for _, tick := range ticks {
y := padTop + chartH - (float64(tick)/float64(max))*chartH
// Grid line
fmt.Fprintf(&b, `<line x1="%.0f" y1="%.1f" x2="%.0f" y2="%.1f" stroke="#e5e7eb" stroke-width="1"/>`,
padLeft, y, totalW-padRight, y)
// Label
fmt.Fprintf(&b, `<text class="label" x="%.0f" y="%.1f" text-anchor="end" fill="#9ca3af" style="font-size:10px;font-family:sans-serif">%s</text>`,
padLeft-6, y+3.5, formatNumber(int64(tick)))
}

for i, m := range data {
x := padLeft + float64(i)*(barW+barGap)
barH := (float64(m.Installs) / float64(max)) * chartH
y := padTop + chartH - barH

label := formatNumberComma(int64(m.Installs))

// Bar with rounded top corners
radius := 3.0
if barH < radius*2 {
radius = barH / 2
}

// Wrap bar + hover label in a group
b.WriteString(`<g class="bar">`)
ariaLabel := fmt.Sprintf("%s: %s installs", m.Month, label)
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%.1f" fill="#525ddc" style="opacity:.6;transition:opacity .15s" role="graphics-symbol" aria-label="%s"/>`,
x, y, barW, barH, radius, template.HTMLEscapeString(ariaLabel))
// Hover label above bar
tipY := y - 6
if tipY < 8 {
tipY = 8
}
fmt.Fprintf(&b, `<text class="tip tip-text" x="%.1f" y="%.1f" text-anchor="middle" fill="#525ddc" style="font-size:10px;font-weight:600;font-family:sans-serif">%s</text>`,
x+barW/2, tipY, label)
b.WriteString(`</g>`)

// X-axis label
labelX := x + barW/2
labelY := padTop + chartH + 16
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" text-anchor="middle" fill="#9ca3af" style="font-size:10px;font-family:sans-serif">%s</text>`,
labelX, labelY, m.Month)
}

b.WriteString(`</svg>`)
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
}
6 changes: 6 additions & 0 deletions internal/http/templates/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ <h1 class="text-2xl font-bold text-gray-900">{{if .Package.DisplayName}}{{.Packa
{{end}}
{{else}}<div class="text-center py-16"><p class="text-gray-500">No versions available.</p></div>{{end}}
</div>
{{if .MonthlyInstalls}}
<div class="mt-10">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Composer Installs</h2>
{{installChart .MonthlyInstalls}}
</div>
{{end}}
</div>
<aside class="space-y-4">
<div class="rounded-xl border border-gray-200/60 bg-white overflow-hidden">
Expand Down
Loading