Playtime: average over the days recorded, full bars for leaders under an hour
Busiest hours and the heatmap averaged over 30 days (4 weeks) even when recording began yesterday; they now use only the days since it began, and say so. Bars capped their scale at 1, so a leader with 0.5 h got half a bar. The hours tile keeps a decimal under 10, and K/D shows - with no kills or deaths yet.
This commit is contained in:
parent
f659859d66
commit
c7fbff0e43
4 changed files with 57 additions and 14 deletions
|
|
@ -295,7 +295,7 @@ when `SAW_TEST_PT_DSN` is set. It drops and recreates PlaytimeHistory's tables f
|
||||||
give it a scratch database:
|
give it a scratch database:
|
||||||
|
|
||||||
```
|
```
|
||||||
podman run -d --rm --name pt-test -e MARIADB_ROOT_PASSWORD=test -e MARIADB_DATABASE=playtime_history -p 127.0.0.1:33071:3306 mariadb:11
|
podman run -d --rm --name pt-test -e MARIADB_ROOT_PASSWORD=test -e MARIADB_DATABASE=playtime_history -p 127.0.0.1:33071:3306 mariadb:10.6
|
||||||
SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB
|
SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,8 +233,12 @@ type Overview struct {
|
||||||
Summary Summary `json:"summary"`
|
Summary Summary `json:"summary"`
|
||||||
ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day
|
ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day
|
||||||
Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first
|
Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first
|
||||||
Days []Day `json:"days"`
|
// The days ByHour and Heat average over: the last 30 (28 for Heat), or fewer while the
|
||||||
Maps []MapTime `json:"maps"`
|
// recording is younger than that, so a new server doesn't look emptier than it is.
|
||||||
|
ByHourDays int `json:"byHourDays"`
|
||||||
|
HeatDays int `json:"heatDays"`
|
||||||
|
Days []Day `json:"days"`
|
||||||
|
Maps []MapTime `json:"maps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) (Overview, error) {
|
func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) (Overview, error) {
|
||||||
|
|
@ -246,12 +250,38 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location)
|
||||||
}
|
}
|
||||||
week, fortnight := now.Add(-7*24*time.Hour), now.Add(-14*24*time.Hour)
|
week, fortnight := now.Add(-7*24*time.Hour), now.Add(-14*24*time.Hour)
|
||||||
heatFrom := dayStart(now, loc).AddDate(0, 0, -27)
|
heatFrom := dayStart(now, loc).AddDate(0, 0, -27)
|
||||||
|
|
||||||
|
// Average over the days there's been recording for, not days from before it began.
|
||||||
|
where, args := s.and("server_id", nil)
|
||||||
|
var first sql.NullTime
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT MIN(started_at) FROM pt_sessions WHERE 1=1`+where, args...).Scan(&first); err != nil {
|
||||||
|
return o, err
|
||||||
|
}
|
||||||
|
byHourFrom, heatStart := start, heatFrom
|
||||||
|
if first.Valid {
|
||||||
|
if f := dayStart(first.Time, loc); f.After(byHourFrom) {
|
||||||
|
byHourFrom = f
|
||||||
|
}
|
||||||
|
if f := dayStart(first.Time, loc); f.After(heatStart) {
|
||||||
|
heatStart = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
today := dayStart(now, loc)
|
||||||
|
var weekdays [7]int // how many of each weekday the heatmap covers
|
||||||
|
for d := heatStart; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||||
|
weekdays[(int(d.Weekday())+6)%7]++
|
||||||
|
o.HeatDays++
|
||||||
|
}
|
||||||
|
for d := byHourFrom; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||||
|
o.ByHourDays++
|
||||||
|
}
|
||||||
|
|
||||||
for _, b := range buckets {
|
for _, b := range buckets {
|
||||||
h := float64(b.Seconds) / 3600
|
h := float64(b.Seconds) / 3600
|
||||||
local := b.Start.In(loc)
|
local := b.Start.In(loc)
|
||||||
o.ByHour[local.Hour()] += h / 30
|
o.ByHour[local.Hour()] += h / float64(max(o.ByHourDays, 1))
|
||||||
if !local.Before(heatFrom) {
|
if wd := (int(local.Weekday()) + 6) % 7; !local.Before(heatFrom) && weekdays[wd] > 0 {
|
||||||
o.Heat[(int(local.Weekday())+6)%7][local.Hour()] += h / 4
|
o.Heat[wd][local.Hour()] += h / float64(weekdays[wd])
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case !b.Start.Before(week):
|
case !b.Start.Before(week):
|
||||||
|
|
@ -271,7 +301,7 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location)
|
||||||
}
|
}
|
||||||
o.Summary.Hours, o.Summary.HoursPrev = round1(o.Summary.Hours), round1(o.Summary.HoursPrev)
|
o.Summary.Hours, o.Summary.HoursPrev = round1(o.Summary.Hours), round1(o.Summary.HoursPrev)
|
||||||
|
|
||||||
where, args := s.and("server_id", []any{week, fortnight, week, fortnight})
|
where, args = s.and("server_id", []any{week, fortnight, week, fortnight})
|
||||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
COUNT(DISTINCT IF(started_at >= ?, steamid64, NULL)),
|
COUNT(DISTINCT IF(started_at >= ?, steamid64, NULL)),
|
||||||
COUNT(DISTINCT IF(started_at >= ? AND started_at < ?, steamid64, NULL))
|
COUNT(DISTINCT IF(started_at >= ? AND started_at < ?, steamid64, NULL))
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,12 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
for _, h := range o.ByHour {
|
for _, h := range o.ByHour {
|
||||||
byHour += h
|
byHour += h
|
||||||
}
|
}
|
||||||
if byHour < 3.3/30 || byHour > 3.7/30 { // 3.5 h spread over 30 days, rounded per hour
|
// Recording began with Bravo's session 10 days ago, so 3.5 h spread over those 11 days.
|
||||||
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, 3.5/30)
|
if o.ByHourDays != 11 || o.HeatDays != 11 {
|
||||||
|
t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays)
|
||||||
|
}
|
||||||
|
if want := 3.5 / 11; byHour < want-0.05 || byHour > want+0.05 {
|
||||||
|
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want)
|
||||||
}
|
}
|
||||||
// Alpha on one day, Bravo on two days ten days apart.
|
// Alpha on one day, Bravo on two days ten days apart.
|
||||||
players := 0
|
players := 0
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,10 @@ const PT_RECORDS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const ptH = (h) => (h >= 100 ? Math.round(h).toLocaleString() : (Math.round(h * 10) / 10).toFixed(1)) + " h";
|
const ptH = (h) => (h >= 100 ? Math.round(h).toLocaleString() : (Math.round(h * 10) / 10).toFixed(1)) + " h";
|
||||||
const ptPct = (a, b) => Math.round((a / Math.max(b, 1)) * 100);
|
// a as a percentage of b. Works for hours under 1 too; nothing of nothing is 0.
|
||||||
const ptKD = (k, d) => (k / Math.max(d, 1)).toFixed(2);
|
const ptPct = (a, b) => (b > 0 ? Math.round((a / b) * 100) : 0);
|
||||||
|
// "–" until there's a kill or death to go on (session stats are saved when a session ends).
|
||||||
|
const ptKD = (k, d) => (k + d > 0 ? (k / Math.max(d, 1)).toFixed(2) : "–");
|
||||||
const ptPad = (h) => String(h).padStart(2, "0");
|
const ptPad = (h) => String(h).padStart(2, "0");
|
||||||
const ptDur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${ptPad(Math.floor((s % 3600) / 60))}m` : `${Math.max(1, Math.round(s / 60))}m`);
|
const ptDur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${ptPad(Math.floor((s % 3600) / 60))}m` : `${Math.max(1, Math.round(s / 60))}m`);
|
||||||
const ptDay = (iso, opts = { weekday: "short", day: "numeric", month: "short" }) => new Date(`${iso}T12:00:00`).toLocaleDateString(undefined, opts);
|
const ptDay = (iso, opts = { weekday: "short", day: "numeric", month: "short" }) => new Date(`${iso}T12:00:00`).toLocaleDateString(undefined, opts);
|
||||||
|
|
@ -84,7 +86,7 @@ function ptTiles(s) {
|
||||||
const delta = (a, b) => (b ? `<span class="delta ${a >= b ? "up" : ""}">${a >= b ? "+" : ""}${ptPct(a - b, b)}% on the 7 days before</span>` : '<span class="delta">Nothing the 7 days before</span>');
|
const delta = (a, b) => (b ? `<span class="delta ${a >= b ? "up" : ""}">${a >= b ? "+" : ""}${ptPct(a - b, b)}% on the 7 days before</span>` : '<span class="delta">Nothing the 7 days before</span>');
|
||||||
return `<div class="pt-tiles">
|
return `<div class="pt-tiles">
|
||||||
<div class="pt-tile"><small>Players, last 7 days</small><b>${s.players}</b>${delta(s.players, s.playersPrev)}</div>
|
<div class="pt-tile"><small>Players, last 7 days</small><b>${s.players}</b>${delta(s.players, s.playersPrev)}</div>
|
||||||
<div class="pt-tile"><small>Hours played, last 7 days</small><b>${Math.round(s.hours).toLocaleString()}</b>${delta(s.hours, s.hoursPrev)}</div>
|
<div class="pt-tile"><small>Hours played, last 7 days</small><b>${s.hours < 10 ? s.hours.toFixed(1) : Math.round(s.hours).toLocaleString()}</b>${delta(s.hours, s.hoursPrev)}</div>
|
||||||
<div class="pt-tile"><small>Most online at once</small><b>${s.peak}</b><span class="delta">${s.peakAt ? fmtDate(s.peakAt) : "No count yet"}</span></div>
|
<div class="pt-tile"><small>Most online at once</small><b>${s.peak}</b><span class="delta">${s.peakAt ? fmtDate(s.peakAt) : "No count yet"}</span></div>
|
||||||
<div class="pt-tile"><small>New players, last 7 days</small><b>${s.newPlayers}</b><span class="delta">First time on the server</span></div>
|
<div class="pt-tile"><small>New players, last 7 days</small><b>${s.newPlayers}</b><span class="delta">First time on the server</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -110,11 +112,18 @@ function ptLeaders(d) {
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "the last 30 days", or how long the recording's been going when that's shorter.
|
||||||
|
function ptDaysText(n) {
|
||||||
|
if (n >= 30) return "the last 30 days";
|
||||||
|
if (n >= 28) return "the last 4 weeks";
|
||||||
|
return n > 1 ? `the ${n} days since recording began` : "today, the first day recorded";
|
||||||
|
}
|
||||||
|
|
||||||
function ptBusiest(o) {
|
function ptBusiest(o) {
|
||||||
const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${v} players on average` }));
|
const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${v} players on average` }));
|
||||||
const best = o.byHour.indexOf(Math.max(...o.byHour));
|
const best = o.byHour.indexOf(Math.max(...o.byHour));
|
||||||
return `<section class="panel">
|
return `<section class="panel">
|
||||||
<div class="panel-head"><div><h2>Busiest hours</h2><p>Players on T or CT, averaged over the last 30 days, in your time zone.</p></div></div>
|
<div class="panel-head"><div><h2>Busiest hours</h2><p>Players on T or CT (bots don't count), averaged over ${ptDaysText(o.byHourDays)}, in your time zone.</p></div></div>
|
||||||
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at <b>${ptPad(best)}:00</b>, with ${o.byHour[best]} players on average.` : "" })}
|
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at <b>${ptPad(best)}:00</b>, with ${o.byHour[best]} players on average.` : "" })}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +140,7 @@ function ptHeatmap(o) {
|
||||||
const max = Math.max(0.1, ...o.heat.flat());
|
const max = Math.max(0.1, ...o.heat.flat());
|
||||||
const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5)));
|
const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5)));
|
||||||
return `<section class="panel">
|
return `<section class="panel">
|
||||||
<div class="panel-head"><div><h2>When people play</h2><p>Average players on T or CT by day and hour, last 4 weeks, in your time zone.</p></div></div>
|
<div class="panel-head"><div><h2>When people play</h2><p>Average players on T or CT by day and hour, over ${ptDaysText(o.heatDays)}, in your time zone.</p></div></div>
|
||||||
<div class="pt-heat"><table>
|
<div class="pt-heat"><table>
|
||||||
<thead><tr><th></th>${o.heat[0].map((_, h) => `<th>${h % 3 === 0 ? ptPad(h) : ""}</th>`).join("")}</tr></thead>
|
<thead><tr><th></th>${o.heat[0].map((_, h) => `<th>${h % 3 === 0 ? ptPad(h) : ""}</th>`).join("")}</tr></thead>
|
||||||
<tbody>${o.heat.map((row, wd) => `<tr><th scope="row">${PT_WEEKDAYS[wd]}</th>${row.map((v, h) => `<td><i style="background:var(--heat-${step(v)})" data-tip="${PT_WEEKDAYS[wd]} ${ptPad(h)}:00 · ${v} players"></i></td>`).join("")}</tr>`).join("")}</tbody>
|
<tbody>${o.heat.map((row, wd) => `<tr><th scope="row">${PT_WEEKDAYS[wd]}</th>${row.map((v, h) => `<td><i style="background:var(--heat-${step(v)})" data-tip="${PT_WEEKDAYS[wd]} ${ptPad(h)}:00 · ${v} players"></i></td>`).join("")}</tr>`).join("")}</tbody>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue