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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ teen comedies (Superbad, Clueless), serial-killer thrillers (Zodiac) and german

Tests: `tests/test_nanogenres.py`.

**The story's last three views: rhythm clock, taste river, you and the crowd.** Visualizations 2,
7 and 8 of the Wrap-Up blueprint, drawn as SVG with classes only:
- **Rhythm clock:** 24 hour sectors with a ring per weekday, shaded by the quartiles of the
played hours, with the music's leading mood per part of the day underneath. The facts carry a
weekday-by-hour grid (`tempo.clock_h`).
- **Taste river:** the five leading nanogenres stacked month by month around a centre line, each
a shade of the accent and named where it is widest (labels kept inside the chart, none over
another), with the nanogenres new to you. The facts carry the hours by month
(`nanogenres.by_month`).
- **You and the crowd:** your hours against the community's score for up to 40 of the period's
video titles. The judge's last verdict (`judge_verdicts`) is the marker: a cross for a title it
wanted gone, a triangle for one it kept. The top left is named: what you loved and the crowd did
not.
- The pool is the period's own video titles, because a music-heavy rollup left only 11.
- AniList scores, which carry no vote count, count; a known small vote count does not.
- `FACTS_VERSION` 11.

Tests: `tests/test_wrapup_views.py`.

**Wrap-Up v2, step 2: an editor picks the story, a writer tells it.** v1 asked for the whole
story as one JSON form and checked it with rules that were strict on style and loose on truth:
- an honest rounding, a title with an "!" or a band called Journey sent everything back;
Expand Down
12 changes: 12 additions & 0 deletions frontend/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,18 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.wus-ct-bg { fill: var(--bg3); }
.wus-ct-you { fill: var(--amber); }
.wus-ct-cur { fill: var(--text3); }
/* rhythm clock, taste river, consensus (the story's stage-3 views) */
.wus-clock path { stroke: none; }
.wus-rv { stroke: var(--bg2); stroke-width: .6; }
.wus-rv.s0 { fill: var(--amber); }
.wus-rv.s1 { fill: rgba(229,160,13,.72); }
.wus-rv.s2 { fill: rgba(229,160,13,.5); }
.wus-rv.s3 { fill: rgba(229,160,13,.32); }
.wus-rv.s4 { fill: rgba(229,160,13,.18); }
.wus-rv-lbl { font-size: 8px; fill: var(--text); paint-order: stroke; stroke: var(--bg2); stroke-width: 2px; }
.wus-cs-pt { fill: var(--text3); }
.wus-cs-kept { fill: var(--amber); }
.wus-cs-cut { stroke: var(--danger-fg); stroke-width: 1.6; fill: none; }
.wus-ct-meta { font-size: 12px; color: var(--text3); }
.wu-explore { font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--text3); margin: 26px 0 10px; font-weight: 600; }
@media (max-width: 640px) {
Expand Down
121 changes: 121 additions & 0 deletions frontend/js/wrapup_story.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,102 @@ export function court(c) {
<div class="wus-ct-meta">${_s(c.decided)} decided · ${_s(c.kept)} kept · ${_s(c.deleted)} deleted · ${_s(c.overrides)} over its objection</div></div>`;
}

const _DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const _DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];

/** The rhythm clock: 24 hour sectors, a ring per weekday (Monday inside),
* four levels by the quartiles of the active cells. */
export function clock(rows) {
const cells = (rows || []).flat().filter(v => v > 0).sort((a, b) => a - b);
if (cells.length < 10) return '';
const q = p => cells[Math.min(cells.length - 1, Math.floor(p * cells.length))];
const cuts = [q(0.25), q(0.5), q(0.75)];
const lvl = v => !v ? 0 : v <= cuts[0] ? 1 : v <= cuts[1] ? 2 : v <= cuts[2] ? 3 : 4;
const c = 120, r0 = 30, ring = 10; // the hour marks sit inside the 240 box
const pt = (r, a) => [c + r * Math.sin(a), c - r * Math.cos(a)].map(x => x.toFixed(1));
const arcs = [];
rows.forEach((row, d) => row.forEach((v, h) => {
const a0 = h * Math.PI / 12 + 0.012, a1 = (h + 1) * Math.PI / 12 - 0.012;
const ri = r0 + d * ring + 1, ro = r0 + (d + 1) * ring - 1;
const [x1, y1] = pt(ro, a0), [x2, y2] = pt(ro, a1), [x3, y3] = pt(ri, a1), [x4, y4] = pt(ri, a0);
arcs.push(`<path d="M${x1} ${y1}A${ro} ${ro} 0 0 1 ${x2} ${y2}L${x3} ${y3}A${ri} ${ri} 0 0 0 ${x4} ${y4}Z" class="wus-hm l${lvl(v)}"><title>${_DAYS[d]} ${h}:00${v ? ` · ${_fmt(v)} h` : ''}</title></path>`);
}));
const ticks = [0, 6, 12, 18].map(h => {
const [x, y] = pt(r0 + 7 * ring + 10, h * Math.PI / 12);
return `<text x="${x}" y="${y}" class="wus-rd-lbl" text-anchor="middle" dominant-baseline="middle">${h}</text>`;
}).join('');
return `<svg class="wus-chart wus-clock" width="300" viewBox="0 0 240 240" role="img" aria-label="Hours by weekday and hour">${arcs.join('')}${ticks}</svg>`;
}

/** The taste river: the leading nanogenres' hours stacked month by month
* around a centre line, each a shade of the accent, named at its widest. */
export function river(byMonth) {
const months = Object.keys(byMonth || {}).sort();
const names = [...new Set(months.flatMap(m => Object.keys(byMonth[m])))];
if (months.length < 3 || names.length < 2) return '';
const totals = names.map(n => months.reduce((s, m) => s + (byMonth[m][n] || 0), 0));
const order = names.map((n, i) => [n, totals[i]]).sort((a, b) => b[1] - a[1]).map(([n]) => n);
const W = 300, H = 140, x = i => (i / (months.length - 1) * W).toFixed(1);
const col = months.map(m => order.reduce((s, n) => s + (byMonth[m][n] || 0), 0));
const scale = (H - 10) / Math.max(1, ...col);
const layers = [], labels = [], placed = [];
const base = col.map(t => H / 2 - t * scale / 2);
const CHAR_W = 4.2; // the 8px label's width per character
order.forEach((n, k) => {
const top = base.slice(), bot = base.map((b, i) => b + (byMonth[months[i]][n] || 0) * scale);
const up = top.map((y, i) => `${x(i)},${y.toFixed(1)}`).join(' L');
const down = bot.map((y, i) => `${x(i)},${y.toFixed(1)}`).reverse().join(' L');
layers.push(`<path d="M${up} L${down}Z" class="wus-rv s${Math.min(k, 4)}"><title>${_s(n)}</title></path>`);
const widest = bot.map((y, i) => y - top[i]).reduce((b, w, i, a) => (w > a[b] ? i : b), 0);
if (bot[widest] - top[widest] >= 12) {
// inside the box, and never over a label already placed
const w = n.length * CHAR_W, y = (top[widest] + bot[widest]) / 2;
const left = Math.min(Math.max(+x(widest) - w / 2, 2), W - 2 - w);
if (!placed.some(p => Math.abs(p.y - y) < 10 && left < p.right && left + w > p.left)) {
placed.push({left, right: left + w, y});
labels.push(`<text x="${left.toFixed(1)}" y="${y.toFixed(1)}" class="wus-rv-lbl" dominant-baseline="middle">${_s(n)}</text>`);
}
}
bot.forEach((y, i) => { base[i] = y; });
});
const ends = `<text x="0" y="${H + 12}" class="wus-rd-lbl">${_s(months[0])}</text><text x="${W}" y="${H + 12}" class="wus-rd-lbl" text-anchor="end">${_s(months[months.length - 1])}</text>`;
return `<svg class="wus-chart wus-river" width="460" viewBox="0 0 ${W} ${H + 16}" role="img" aria-label="Your nanogenres month by month: ${order.map(_s).join(', ')}">${layers.join('')}${labels.join('')}${ends}</svg>`;
}

/** Consensus: your hours (up) against the community's score (right); the
* judge's verdict is the marker. Top-left is what you loved alone. */
export function consensus(c) {
const pts = c?.points || [];
if (pts.length < 6) return '';
const W = 280, H = 170, pad = 22;
const cs = pts.map(p => p.community);
const lo = Math.min(...cs, c.median_community) - 0.3, hi = Math.max(...cs, c.median_community) + 0.3;
const hmax = Math.max(...pts.map(p => p.hours), 1);
const px = v => pad + (v - lo) / (hi - lo) * (W - pad - 6);
const py = v => H - pad - Math.sqrt(Math.max(0, v) / hmax) * (H - pad - 8);
const f1 = n => n.toFixed(1);
const mark = p => {
const X = px(p.community), Y = py(p.hours);
const tip = `<title>${_s(p.title)} · ${_fmt(p.hours)} h · ${_fmt(p.community)}</title>`;
if (p.verdict === 'cut') {
return `<path d="M${f1(X - 4)} ${f1(Y - 4)}L${f1(X + 4)} ${f1(Y + 4)}M${f1(X + 4)} ${f1(Y - 4)}L${f1(X - 4)} ${f1(Y + 4)}" class="wus-cs-cut">${tip}</path>`;
}
if (p.verdict === 'kept') {
return `<path d="M${f1(X)} ${f1(Y - 5)}L${f1(X + 5)} ${f1(Y + 4)}L${f1(X - 5)} ${f1(Y + 4)}Z" class="wus-cs-kept">${tip}</path>`;
}
return `<circle cx="${f1(X)}" cy="${f1(Y)}" r="4" class="wus-cs-pt">${tip}</circle>`;
};
return `<svg class="wus-chart wus-cons" width="420" viewBox="0 0 ${W} ${H}" role="img" aria-label="${pts.length} titles: your hours against the community score">
<line x1="${f1(px(c.median_community))}" x2="${f1(px(c.median_community))}" y1="4" y2="${H - pad}" class="wus-axis"/>
<line x1="${pad}" x2="${W - 6}" y1="${f1(py(c.median_hours))}" y2="${f1(py(c.median_hours))}" class="wus-axis"/>
<text x="${pad + 2}" y="12" class="wus-rd-lbl">yours alone</text>
<text x="4" y="${H / 2}" class="wus-rd-lbl" transform="rotate(-90 8 ${H / 2})" text-anchor="middle">your hours</text>
<text x="${pad}" y="${H - 6}" class="wus-rd-lbl">${_fmt(Math.min(...cs))}</text>
<text x="${f1(px(Math.max(...cs)))}" y="${H - 6}" class="wus-rd-lbl" text-anchor="end">${_fmt(Math.max(...cs))}</text>
<text x="${f1((pad + W) / 2)}" y="${H - 6}" class="wus-rd-lbl" text-anchor="middle">community score</text>
${pts.map(mark).join('')}</svg>`;
}

// ── the slides ──────────────────────────────────────────────────────────────
function _posters(f, refs, story) {
// no artwork, no frame: an empty "?" tile says nothing on a story slide
Expand Down Expand Up @@ -158,6 +254,16 @@ function build(d) {
${heatmap(t.by_day_h, f.period.start, f.period.end)}
<p class="wus-text">Longest run: ${_s(t.longest_streak_days)} days in a row.${bd.date ? ` Biggest day: ${_s(bd.date)}, ${_fmt(bd.hours)} hours.` : ''}</p>`});
}
const clockSvg = clock(t.clock_h);
if (clockSvg) {
const moods = f.music?.moods?.by_daypart || {};
const moodLine = ['morning', 'afternoon', 'evening', 'night'].filter(p => moods[p])
.map(p => `${p}: ${_s(moods[p].mood)}`).join(' · ');
const day = _DAY_NAMES[((t.peak_weekday ?? 1) + 6) % 7];
out.push({cls: 'clock', share: `Most at ${t.peak_hour}:00, busiest on ${day}s`,
html: `<div class="wus-eyebrow">Round the clock</div><h2 class="wus-h">Most at ${_s(t.peak_hour)}:00, busiest on ${day}s</h2>
${clockSvg}${moodLine ? `<p class="wus-text">The music's mood — ${moodLine}.</p>` : ''}`});
}
const slides = s?.slides?.length ? s.slides
: (d.insights || []).slice(0, 5).map(c => ({id: c.id, beat: c.slide, headline: '', text: c.claim, item_refs: []}));
for (const sl of slides) {
Expand All @@ -168,6 +274,21 @@ function build(d) {
html: `${sl.headline ? `<h2 class="wus-h">${_s(sl.headline)}</h2>` : ''}${_posters(f, sl.item_refs, s)}${visual}
<p class="wus-text">${_s(sl.text)}</p>`});
}
const ng = f.nanogenres || {};
const riverSvg = river(ng.by_month);
if (riverSvg) {
const lead = (ng.top || [])[0];
out.push({cls: 'river', share: lead ? `${lead.name}: ${_fmt(lead.hours)} hours` : 'How the taste moved',
html: `<div class="wus-eyebrow">How the taste moved</div>${lead ? `<h2 class="wus-h">${_s(lead.name)} led</h2>` : ''}
${riverSvg}${(ng.new_to_you || []).length ? `<p class="wus-text">New to you: ${ng.new_to_you.map(_s).join(', ')}.</p>` : ''}`});
}
const consSvg = consensus(f.consensus);
if (consSvg) {
const alone = f.consensus.yours_alone || [];
out.push({cls: 'consensus', share: alone.length ? `Mine, not theirs: ${alone.join(', ')}` : 'You and the crowd',
html: `<div class="wus-eyebrow">You and the crowd</div><h2 class="wus-h">${alone.length ? `Yours alone: ${_s(alone[0])}` : 'Mostly in step with the crowd'}</h2>
${consSvg}<p class="wus-text">Up is your hours, right the community's score. A cross is a title the judge wanted gone, a triangle one it kept.</p>`});
}
const rt = s?.roast_and_toast;
if (rt?.roast && rt?.toast) {
out.push({cls: 'pair', share: rt.toast,
Expand Down
20 changes: 16 additions & 4 deletions src/services/wrapup_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
# 4: the skip rate from Spotify's skips (music_skips), none without them.
# 5: court record, GB in vs out, time-to-watch, auteurs (services/wrapup_sections).
# 6: hours per day for the story mode's heatmap.
FACTS_VERSION = 10
FACTS_VERSION = 11


@dataclass(frozen=True)
Expand Down Expand Up @@ -287,6 +287,14 @@ def build_wrapup_facts(user_id: int, scope: str, period_key: str,
out["dismissal"] = _dismissal(extra, enriched)
vnames = {t["title"] for t in vtitles}
out["auteurs"] = _sections.auteurs(db, [w for w in want if w["title"] in vnames])
# Consensus wants more titles than the top of a music-heavy
# rollup: the period's own video titles, read from the cache.
vbase = base[:3] + (W.media_type.in_(video_types),) + base[4:]
vpool = _title_rollup(db, W, ms, vbase)[:_sections.CONSENSUS_POOL]
more = [t for t in vpool if t["title"] not in enriched]
if more:
enriched = {**enriched, **_verified_lookup(_with_ids(db, more))}
out["consensus"] = _sections.consensus(db, user_id, vpool, enriched)
if "music" in types:
out["music"] = _music(db, W, ms, user_id, base, period)
moods = _moods(db, W, ms, base, local)
Expand Down Expand Up @@ -339,9 +347,12 @@ def _tempo(db, W, ms, base, local, total_ms, est_ms, plays) -> dict:
by_hour[int(h)] = int(v or 0)
dw = func.strftime("%w", local) # 0 = Sunday
by_dow = [0] * 7
for d, v in db.query(dw, func.sum(ms)).filter(*base).group_by(dw).all():
if d is not None:
by_dow[int(d)] = int(v or 0)
# weekday x hour for the rhythm clock, Monday first
clock = [[0] * 24 for _ in range(7)]
for d, h, v in db.query(dw, hr, func.sum(ms)).filter(*base).group_by(dw, hr).all():
if d is not None and h is not None:
by_dow[int(d)] += int(v or 0)
clock[(int(d) + 6) % 7][int(h)] += int(v or 0)
day = func.date(local)
days = [r for r in db.query(day, func.sum(ms), func.count()).filter(*base)
.group_by(day).all() if r[0]]
Expand All @@ -362,6 +373,7 @@ def _tempo(db, W, ms, base, local, total_ms, est_ms, plays) -> dict:
"peak_hour": max(range(24), key=lambda h: by_hour[h]),
"peak_weekday": max(range(7), key=lambda d: by_dow[d]),
"by_hour_h": [_h(v) for v in by_hour],
"clock_h": [[round((v or 0) / 3_600_000, 2) for v in row] for row in clock],
"dayparts": {k: round(v / total, 2) for k, v in parts.items()},
"weekend_share": round((by_dow[0] + by_dow[6]) / max(1, sum(by_dow)), 2),
"weekend_daytime_share": round(weekend_day / total, 2),
Expand Down
62 changes: 59 additions & 3 deletions src/services/wrapup_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def best(table) -> Optional[dict]:
# ── nanogenres ───────────────────────────────────────────────────────────────

_NANO_TOP = 6
_NANO_RIVER = 5 # nanogenres drawn in the river
_NANO_MIN_SHARE = 0.25 # under this share of the hours mapped, say nothing


Expand All @@ -214,9 +215,15 @@ def nanogenres(db, W, user_id: int, period, types: list) -> dict:
from src.services.wrapup_facts import a_play, played_ms_sql
ms = played_ms_sql(W)
key = func.coalesce(W.series_title, W.title)
played = [(k, mt, float(v or 0)) for k, mt, v in db.query(key, W.media_type, func.sum(ms)).filter(
W.user_id == user_id, W.media_type.in_(types), a_play(W), *_in(W.viewed_at, period))
.group_by(key, W.media_type) if k and v]
month = func.strftime("%Y-%m", W.viewed_at)
per_month = [(k, mt, m, float(v or 0)) for k, mt, m, v in
db.query(key, W.media_type, month, func.sum(ms)).filter(
W.user_id == user_id, W.media_type.in_(types), a_play(W), *_in(W.viewed_at, period))
.group_by(key, W.media_type, month) if k and v]
rolled: dict = defaultdict(float)
for k, mt, _, v in per_month:
rolled[(k, mt)] += v
played = [(k, mt, v) for (k, mt), v in rolled.items()]
total = sum(v for _, _, v in played)
if not played or not total:
return {}
Expand Down Expand Up @@ -246,11 +253,60 @@ def nanogenres(db, W, user_id: int, period, types: list) -> dict:
before.add(k)
new = [g for g in hours if not any(t in before for t in titles[g])]
top = sorted(hours, key=hours.get, reverse=True)
# the taste river: the leading nanogenres' hours month by month
lead = {g[1] for g in top[:_NANO_RIVER]}
river: dict = defaultdict(lambda: defaultdict(float))
for k, mt, m, v in per_month:
g = genre_of.get((k, group_of(mt)))
if g and g[1] in lead:
river[m][g[1]] += v / 3_600_000
return {
"top": [{"name": g[1], "hours": round(hours[g] / 3_600_000, 1),
"share": round(hours[g] / mapped, 3),
"titles": [t for t, _ in titles[g].most_common(3)]} for g in top[:_NANO_TOP]],
"new_to_you": [g[1] for g in sorted(new, key=hours.get, reverse=True)[:3]] if period.key != "all" else [],
"count": len(hours),
"coverage": round(mapped / total, 3),
"by_month": {m: {n: round(h, 1) for n, h in sorted(row.items())}
for m, row in sorted(river.items())},
}


# ── consensus: your hours against the community's score ─────────────────────

_CONSENSUS_MIN = 6 # points for a scatter worth drawing
_CONSENSUS_MAX = 40
CONSENSUS_POOL = _CONSENSUS_MAX
_CONSENSUS_VOTES = 200 # a community score below this mass is noise


def consensus(db, user_id: int, titles: list, enriched: dict) -> dict:
"""Every played title with a community score: its score, your hours on
it, and the judge's last verdict on it (judge_verdicts) — the scatter's
top-left is what you loved and the crowd did not."""
from src.database.models import JudgeVerdict as J
pts = []
for t in titles[:_CONSENSUS_MAX]:
e = enriched.get(t["title"]) or {}
com, votes = e.get("rating"), e.get("votes") or 0
# AniList scores carry no vote count (0): a known small count is noise
if com is None or 0 < votes < _CONSENSUS_VOTES:
continue
pts.append({"title": t["title"], "media_type": t["media_type"], "hours": t["hours"],
"community": round(float(com), 1), "votes": int(votes)})
if len(pts) < _CONSENSUS_MIN:
return {}
verdict: dict = {}
names = [p["title"] for p in pts]
for title, v, at in (db.query(J.title, J.verdict, J.last_at)
.filter(J.user_id == user_id, J.title.in_(names))
.order_by(J.last_at)):
verdict[title] = v # the latest wins
for p in pts:
v = (verdict.get(p["title"]) or "").upper()
p["verdict"] = "cut" if v in ("CUT", "STAGNANT") else ("kept" if v else None)
hrs = sorted(p["hours"] for p in pts)
mid_h, mid_c = hrs[len(hrs) // 2], sorted(p["community"] for p in pts)[len(pts) // 2]
yours = [p["title"] for p in pts if p["hours"] > mid_h and p["community"] < mid_c]
return {"points": pts, "median_hours": mid_h, "median_community": mid_c,
"yours_alone": yours[:3]}
2 changes: 1 addition & 1 deletion tests/test_music_moods.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ def facts(types):
check("the fact sheet carries the moods, the parts of the day and the players",
"Moods (Plex's tags" in sheet and "In the night: mostly Melancholy" in sheet
and "Played on: iPhone" in sheet and "After 23:00 mostly on iPhone" in sheet)
check("the facts' shape changed: FACTS_VERSION moved", wf.FACTS_VERSION == 10)
check("the facts' shape changed: FACTS_VERSION moved", wf.FACTS_VERSION >= 10)

print(f"\n{PASS} passed, {FAIL} failed")
sys.exit(1 if FAIL else 0)
Loading
Loading