Illustrate monthly reports and add viewing pattern breakdowns
This commit is contained in:
@@ -142,6 +142,34 @@ def completed_month(month: str | None) -> str:
|
|||||||
return period["month"]
|
return period["month"]
|
||||||
|
|
||||||
|
|
||||||
|
async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
|
||||||
|
"""Embed only signed artwork from this account's report; missing art is optional."""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from .insights_artwork import get_artwork
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
images = []
|
||||||
|
report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
|
||||||
|
|
||||||
|
async def picture(index, row):
|
||||||
|
match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data, mime = await get_artwork(account, runtime, *match.groups())
|
||||||
|
cid = f"recap-title-{index}@magent"
|
||||||
|
row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
|
||||||
|
images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
|
||||||
|
except Exception:
|
||||||
|
pass # An unavailable poster must never prevent a personal report.
|
||||||
|
|
||||||
|
await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
|
||||||
|
rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
|
||||||
|
if not preview:
|
||||||
|
rendered["inline_images"] = images
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
async def preview(user: dict, month: str | None) -> dict:
|
async def preview(user: dict, month: str | None) -> dict:
|
||||||
account = current_account(user)
|
account = current_account(user)
|
||||||
selected = completed_month(month)
|
selected = completed_month(month)
|
||||||
@@ -156,8 +184,8 @@ async def preview(user: dict, month: str | None) -> dict:
|
|||||||
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
|
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
|
||||||
if report["state"] != "ready":
|
if report["state"] != "ready":
|
||||||
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
|
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
|
||||||
return {"month": selected, "email": account.get("email"), **mail.render_recap(
|
return {"month": selected, "email": account.get("email"), **await illustrated_recap(
|
||||||
report, account["username"], config["public_url"], config["public_url"] + "/profile#monthly-recaps")}
|
report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
|
||||||
|
|
||||||
|
|
||||||
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
||||||
@@ -200,7 +228,7 @@ async def process_delivery(delivery: dict) -> None:
|
|||||||
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
|
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
|
||||||
raise mail.DeliveryError("failed", "A complete personal report is not available.")
|
raise mail.DeliveryError("failed", "A complete personal report is not available.")
|
||||||
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
|
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
|
||||||
rendered = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
|
rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
|
||||||
|
|
||||||
def before_data():
|
def before_data():
|
||||||
eligible_delivery(delivery)
|
eligible_delivery(delivery)
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive
|
|||||||
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||||
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
||||||
daily_seconds = defaultdict(float)
|
daily_seconds = defaultdict(float)
|
||||||
|
weekdays = [0.0] * 7
|
||||||
|
media_minutes = defaultdict(float)
|
||||||
|
longest_play = 0.0
|
||||||
clients = defaultdict(float)
|
clients = defaultdict(float)
|
||||||
methods = defaultdict(float)
|
methods = defaultdict(float)
|
||||||
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
|
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
|
||||||
@@ -157,6 +160,9 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
|
|||||||
episode_ids.add(str(episode_id))
|
episode_ids.add(str(episode_id))
|
||||||
elif media_type == "movie":
|
elif media_type == "movie":
|
||||||
movie_ids.add(item_id)
|
movie_ids.add(item_id)
|
||||||
|
weekdays[date.weekday()] += duration / 60
|
||||||
|
media_minutes[media_type] += duration / 60
|
||||||
|
longest_play = max(longest_play, duration / 60)
|
||||||
seconds += duration
|
seconds += duration
|
||||||
daily_seconds[date.date().isoformat()] += duration
|
daily_seconds[date.date().isoformat()] += duration
|
||||||
client = str(row.get("Client") or "Unknown player")[:200]
|
client = str(row.get("Client") or "Unknown player")[:200]
|
||||||
@@ -166,7 +172,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
|
|||||||
methods[method] += duration
|
methods[method] += duration
|
||||||
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
||||||
series = str(row.get("SeriesName") or "")[:500]
|
series = str(row.get("SeriesName") or "")[:500]
|
||||||
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0})
|
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
|
||||||
title["minutes"] += duration / 60
|
title["minutes"] += duration / 60
|
||||||
title["plays"] += 1
|
title["plays"] += 1
|
||||||
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
|
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
|
||||||
@@ -193,6 +199,11 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
|
|||||||
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
||||||
"episodes": len(episode_ids), "active_days": len(active_days),
|
"episodes": len(episode_ids), "active_days": len(active_days),
|
||||||
"current_streak": current, "longest_streak": longest},
|
"current_streak": current, "longest_streak": longest},
|
||||||
|
"patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0,
|
||||||
|
"longest_play_minutes": round(longest_play, 1),
|
||||||
|
"weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0,
|
||||||
|
"weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))],
|
||||||
|
"media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]},
|
||||||
"daily": daily, "top_titles": top,
|
"daily": daily, "top_titles": top,
|
||||||
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
|
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
|
||||||
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
|
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ def signature(user, runtime, media_id, expires):
|
|||||||
|
|
||||||
def with_artwork(data, user, runtime):
|
def with_artwork(data, user, runtime):
|
||||||
expires = int(time.time()) + TOKEN_SECONDS
|
expires = int(time.time()) + TOKEN_SECONDS
|
||||||
recent = []
|
result = {**data}
|
||||||
for play in data.get("recent", []):
|
for field in ("recent", "top_titles"):
|
||||||
row = {**play}
|
rows = []
|
||||||
media_id = row.pop("artwork_item_id", None)
|
for play in data.get(field, []):
|
||||||
row["artwork_url"] = None
|
row = {**play}
|
||||||
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
|
media_id = row.pop("artwork_item_id", None)
|
||||||
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
|
row["artwork_url"] = None
|
||||||
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
|
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
|
||||||
recent.append(row)
|
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
|
||||||
return {**data, "recent": recent}
|
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
|
||||||
|
rows.append(row)
|
||||||
|
result[field] = rows
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def verify_artwork_token(user, runtime, media_id, token):
|
def verify_artwork_token(user, runtime, media_id, token):
|
||||||
|
|||||||
@@ -88,10 +88,26 @@ def render_recap(report: dict, username: str, public_url: str, unsubscribe_url:
|
|||||||
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
|
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
|
||||||
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
|
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
|
||||||
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
|
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
|
||||||
|
patterns = report.get("patterns", {})
|
||||||
|
if patterns:
|
||||||
|
detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%."
|
||||||
|
lines.append(detail)
|
||||||
|
content += f'<p style="padding:18px;background:#242334;border-radius:12px;color:#d8cfff;line-height:1.8">{esc(detail)}</p>'
|
||||||
|
for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])):
|
||||||
|
peak = max(1, *(row["minutes"] for row in rows))
|
||||||
|
content += f'<h2 style="font-size:18px;color:#e5e1e4">{heading}</h2><table role="presentation" width="100%" cellspacing="0" cellpadding="0">'
|
||||||
|
for row in rows:
|
||||||
|
width = round(row["minutes"] / peak * 100)
|
||||||
|
content += f'<tr><td style="padding:8px 0;color:#bdb6c3;font-size:12px;width:100px">{esc(row["name"])}</td><td style="padding:8px"><table role="presentation" width="{width}%" cellspacing="0" cellpadding="0"><tr><td height="8" style="background:{"#8cdbdd" if width else "transparent"};border-radius:4px;font-size:0"> </td></tr></table></td><td style="width:65px;color:#e0d8ff;font-size:12px;text-align:right">{number(row["minutes"])} min</td></tr>'
|
||||||
|
lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
|
||||||
|
content += '</table>'
|
||||||
top = report.get("top_titles", [])[:3]
|
top = report.get("top_titles", [])[:3]
|
||||||
if top:
|
if top:
|
||||||
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
|
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
|
||||||
for item in top:
|
for item in top:
|
||||||
|
artwork = item.get("email_artwork", "")
|
||||||
|
if artwork.startswith(("cid:", "data:image/")):
|
||||||
|
content += f'<img src="{esc(artwork, quote=True)}" alt="{esc(item["title"], quote=True)}" width="80" style="display:block;border-radius:10px;margin-top:20px" />'
|
||||||
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
|
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
|
||||||
else:
|
else:
|
||||||
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
|
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
|
||||||
@@ -130,8 +146,8 @@ def send_email(recipient: str, rendered: dict, message_id: str, before_data=lamb
|
|||||||
html_part = message.get_payload()[-1]
|
html_part = message.get_payload()[-1]
|
||||||
for attachment in rendered.get('inline_images', []):
|
for attachment in rendered.get('inline_images', []):
|
||||||
html_part.add_related(
|
html_part.add_related(
|
||||||
attachment['data'], maintype='image', subtype='jpeg', cid=f"<{attachment['cid']}>",
|
attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
|
||||||
filename=attachment['cid'].split('@')[0] + '.jpg', disposition='inline')
|
filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
|
||||||
payload = message.as_bytes()
|
payload = message.as_bytes()
|
||||||
smtp, stage = None, "connect"
|
smtp, stage = None, "connect"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from backend.tests.test_insights import play, NOW, LIBRARIES
|
||||||
|
from backend.tests.test_email_recaps import fixture_report
|
||||||
|
from backend.app.services.insights import summarize
|
||||||
|
from backend.app.services.email_recaps import illustrated_recap
|
||||||
|
|
||||||
|
class ReportGraphicsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_patterns_deduplicate_and_handle_empty_history(self):
|
||||||
|
first = play()
|
||||||
|
second = play("second", PlaybackDuration=1800, EpisodeId="episode", ActivityDateInserted=(NOW-timedelta(days=1)).isoformat())
|
||||||
|
report = summarize([first, first, second], LIBRARIES, NOW-timedelta(days=7), NOW)
|
||||||
|
self.assertEqual(report["patterns"]["average_play_minutes"], 45)
|
||||||
|
self.assertEqual(report["patterns"]["longest_play_minutes"], 60)
|
||||||
|
self.assertEqual(report["patterns"]["weekend_percent"], 33.3)
|
||||||
|
self.assertEqual(sum(r["minutes"] for r in report["patterns"]["media"]), 90)
|
||||||
|
empty = summarize([], [], NOW-timedelta(days=7), NOW)
|
||||||
|
self.assertEqual(empty["patterns"]["average_play_minutes"], 0)
|
||||||
|
|
||||||
|
async def test_artwork_embedded_without_private_links_and_optional_on_failure(self):
|
||||||
|
report = fixture_report()
|
||||||
|
report["top_titles"][0]["artwork_url"] = "/insights/artwork/" + "a"*32 + "?token=123." + "b"*64
|
||||||
|
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(return_value=(b"picture", "image/webp"))):
|
||||||
|
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
|
||||||
|
self.assertIn("cid:recap-title-0@magent", rendered["body_html"])
|
||||||
|
self.assertNotIn("?token=", rendered["body_html"])
|
||||||
|
self.assertEqual(rendered["inline_images"][0]["subtype"], "webp")
|
||||||
|
preview = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe", preview=True)
|
||||||
|
self.assertIn("data:image/webp;base64,", preview["body_html"])
|
||||||
|
self.assertNotIn("inline_images", preview)
|
||||||
|
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(side_effect=RuntimeError())):
|
||||||
|
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
|
||||||
|
self.assertEqual(rendered["inline_images"], [])
|
||||||
@@ -17,7 +17,8 @@ export type Stats = {
|
|||||||
updated_at?: string
|
updated_at?: string
|
||||||
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
|
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
|
||||||
daily?: Day[]
|
daily?: Day[]
|
||||||
top_titles?: { title: string; type: string; minutes: number; plays: number }[]
|
patterns?: { average_play_minutes: number; longest_play_minutes: number; weekend_percent: number; weekdays: Breakdown[]; media: Breakdown[] }
|
||||||
|
top_titles?: { artwork_url?: string | null; title: string; type: string; minutes: number; plays: number }[]
|
||||||
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string; artwork_url?: string | null }[]
|
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string; artwork_url?: string | null }[]
|
||||||
clients?: Breakdown[]
|
clients?: Breakdown[]
|
||||||
methods?: Breakdown[]
|
methods?: Breakdown[]
|
||||||
|
|||||||
@@ -151,8 +151,19 @@ export default function MonthlyReportsPage() {
|
|||||||
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div>
|
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{data.patterns && <>
|
||||||
|
<section className="report-pattern-summary" aria-label="Viewing insights">
|
||||||
|
<article><span>Average play</span><strong>{decimal(data.patterns.average_play_minutes)} <small>min</small></strong><p>Time per recorded playback session.</p></article>
|
||||||
|
<article><span>Longest play</span><strong>{decimal(data.patterns.longest_play_minutes)} <small>min</small></strong><p>Your longest recorded session this month.</p></article>
|
||||||
|
<article><span>Weekend viewing</span><strong>{decimal(data.patterns.weekend_percent)}<small>%</small></strong><p>Share of viewing on Saturday and Sunday (UTC).</p></article>
|
||||||
|
</section>
|
||||||
|
<div className="stats-main-grid">
|
||||||
|
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} />
|
||||||
|
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} />
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
<div className="stats-three-grid">
|
<div className="stats-three-grid">
|
||||||
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section>
|
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><RecentArtwork url={title.artwork_url} type={title.type} /><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section>
|
||||||
<BreakdownCard title="Your players" rows={data.clients ?? []} />
|
<BreakdownCard title="Your players" rows={data.clients ?? []} />
|
||||||
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
|
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,3 +28,14 @@
|
|||||||
.report-email-panel { display: grid; gap: 12px; }
|
.report-email-panel { display: grid; gap: 12px; }
|
||||||
.report-email-panel > button { justify-self: start; }
|
.report-email-panel > button { justify-self: start; }
|
||||||
.report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; }
|
.report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.report-pattern-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin:20px 0; }
|
||||||
|
.report-pattern-summary article { padding:24px; border:1px solid #45404e; border-radius:16px; background:linear-gradient(135deg,#262137,#14292d); }
|
||||||
|
.report-pattern-summary span { color:#d0c6e7; font-size:13px; }
|
||||||
|
.report-pattern-summary strong { display:block; font-size:36px; margin:12px 0; color:#c5baff; }
|
||||||
|
.report-pattern-summary small { font-size:16px; }
|
||||||
|
.report-pattern-summary p { color:#b6b6c0; font-size:13px; margin:0; }
|
||||||
|
.report-top-titles li { grid-template-columns:44px minmax(0,1fr) auto; gap:12px; }
|
||||||
|
.report-top-titles li > div { flex:1; min-width:0; }
|
||||||
|
.report-top-titles li > .stats-media-icon { width:44px; }
|
||||||
|
@media(max-width:640px) { .report-pattern-summary { grid-template-columns:1fr; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user