Files
Magent/scripts/review_monthly_reports_ui.cjs
T
Assclaw 333a799e21
Magent CI/CD / verify (push) Successful in 10m58s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m42s
Add personal monthly viewing reports and CSV exports
2026-09-09 16:25:35 +12:00

142 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Fixture-only browser review. Every API request is intercepted.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://localhost:3114'
const output = process.env.REVIEW_DIR
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext({ acceptDownloads: true })
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
const calls = []
let mode = 'ready'
let role = 'admin'
let failDownload = false
const months = Array.from({ length: 24 }, (_, index) => new Date(Date.UTC(2026, 8 - index, 1)).toISOString().slice(0, 7))
const delta = (current, previous) => ({ current, previous, difference: current - previous, percent: previous ? (current - previous) / previous * 100 : current ? null : 0 })
const fixture = (month = '2026-08') => {
const index = months.indexOf(month)
const next = new Date(`${month}-01T00:00:00Z`)
next.setUTCMonth(next.getUTCMonth() + 1)
const days = month === '2026-09' ? 7 : new Date(next.getTime() - 1).getUTCDate()
const summary = { minutes: 1500, movies: 8, episodes: 23, plays: 35, active_days: 20, longest_streak: 6, current_streak: 0 }
return {
state: 'ready', month, available_months: months, timezone: 'UTC', is_admin: role === 'admin',
is_partial: month === '2026-09', comparison_capped: false,
period_start: `${month}-01T00:00:00Z`, period_end: month === '2026-09' ? '2026-09-07T12:00:00Z' : next.toISOString(),
comparison_month: months[index + 1] || '2024-09', comparison_start: `${months[index + 1] || '2024-09'}-01T00:00:00Z`, comparison_end: `${month}-01T00:00:00Z`,
updated_at: '2026-09-07T12:00:00Z', summary,
previous_summary: { ...summary, minutes: 1000 },
changes: { minutes: delta(1500, 1000), movies: delta(8, 10), episodes: delta(23, 0), requests: delta(3, 3), plays: delta(35, 25), active_days: delta(20, 15), longest_streak: delta(6, 4) },
daily: Array.from({ length: days }, (_, day) => ({ date: `${month}-${String(day + 1).padStart(2, '0')}`, minutes: day % 4 ? 40 + day * 2 : 0 })),
top_titles: [{ title: 'Severance', type: 'series', minutes: 460, plays: 10 }, { title: 'Arrival', type: 'movie', minutes: 116, plays: 1 }],
clients: [{ name: 'Jellyfin Web', minutes: 1200 }, { name: 'Jellyfin for Android TV', minutes: 300 }],
methods: [{ name: 'Direct play', minutes: 1300 }, { name: 'Transcode', minutes: 200 }],
transcoding: { video_minutes: 100, audio_minutes: 61, hardware_video_minutes: 80, software_video_minutes: 20, unknown_hardware_minutes: 0, unknown_video_minutes: 0, unknown_audio_minutes: 0, hardware: [{ name: 'NVIDIA NVENC', minutes: 80 }], audio_codecs: [{ name: 'AAC', minutes: 61 }], gpu_busy_minutes: null },
recent: [{ id: 'play', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: `${month}-05T10:00:00Z`, client: 'Jellyfin Web', method: 'Direct play', artwork_url: '/insights/artwork/fixture?token=fixture' }],
requests: { total: 3, movies: 2, tv: 1, pending: 1, approved: 2, declined: 0, recent: [{ request_id: 12, title: 'Dune: Part Two', media_type: 'movie', status: 2 }] },
}
}
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
calls.push({ method: request.method(), path: url.pathname, month: url.searchParams.get('month') })
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role } })
if (url.pathname.startsWith('/api/insights/artwork/')) return route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><rect width="120" height="180" fill="#243946"/><path d="M0 150L65 35L120 150" fill="#c8bdaa"/></svg>' })
if (url.pathname === '/api/insights/reports/monthly.csv') {
if (failDownload) return route.fulfill({ status: 502, json: { detail: 'Unavailable' } })
return route.fulfill({ contentType: 'text/csv; charset=utf-8', body: `\ufeffMonth,Minutes\r\n${url.searchParams.get('month')},1500\r\n` })
}
if (url.pathname === '/api/insights/reports/monthly' || url.pathname === '/api/insights') {
if (mode === 'unauthorized') return route.fulfill({ status: 401, json: { detail: 'Sign in' } })
if (mode === 'unavailable') return route.fulfill({ status: 502, json: { detail: 'Your monthly report is temporarily unavailable. Please try again shortly.' } })
if (mode === 'limit') return route.fulfill({ status: 422, json: { detail: "This report exceeds Jellystat's history limit. No partial report has been generated." } })
const data = { ...fixture(url.searchParams.get('month') || '2026-08'), days: 30 }
if (mode === 'empty') {
for (const key of Object.keys(data.summary)) data.summary[key] = 0
for (const key of Object.keys(data.changes)) data.changes[key] = delta(0, 0)
data.requests = { total: 0, movies: 0, tv: 0, pending: 0, approved: 0, declined: 0, recent: [] }
data.daily = data.daily.map((day) => ({ ...day, minutes: 0 }))
data.top_titles = data.recent = data.methods = data.clients = []
} else if (mode !== 'ready') { data.state = mode; data.summary = null }
return route.fulfill({ json: data })
}
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return route.fulfill({ json: {} })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 980, 390, 320]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/insights/reports')
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
assert.equal(await page.getByLabel('Report month').inputValue(), '2026-08')
assert.equal(await page.getByLabel('Report month').locator('option').count(), 24)
assert.equal(await page.locator('.stats-view-tabs a[aria-current=page]').innerText(), 'Monthly reports')
assert.equal(await page.locator('.header-actions a.is-active').innerText(), 'My Stats')
assert.match(await page.locator('.stats-metrics').innerText(), /\+500 min \(\+50%\)/)
assert.match(await page.locator('.stats-metrics').innerText(), /2 \(20%\)/)
assert.match(await page.locator('.stats-metrics').innerText(), /No activity recorded in the comparison period/)
assert.equal(await page.getByRole('link', { name: 'Dune: Part Two' }).getAttribute('href'), '/requests/12')
await page.locator('.stats-history').scrollIntoViewIfNeeded()
await page.waitForFunction(() => document.querySelector('.stats-media-icon img')?.naturalWidth > 0)
await page.evaluate(() => window.scrollTo(0, 0))
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
if (width <= 980) assert.equal(await page.locator('.workspace-mobile-nav a.is-active').innerText(), 'Stats')
if (output) {
fs.mkdirSync(output, { recursive: true })
await page.screenshot({ path: path.join(output, `monthly-report-${width}.png`), fullPage: true })
}
}
await page.getByRole('button', { name: 'Previous month', exact: true }).click()
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
await page.getByRole('button', { name: 'Next month', exact: true }).click()
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
await page.getByLabel('Report month').selectOption('2026-09')
await page.getByRole('heading', { name: 'September 2026', exact: true }).waitFor()
await page.getByText('Compared with the same elapsed time in August 2026.', { exact: true }).waitFor()
assert(await page.getByRole('button', { name: 'Next month', exact: true }).isDisabled())
const downloaded = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download CSV', exact: true }).click()
const download = await downloaded
assert.equal(download.suggestedFilename(), 'magent-monthly-report-2026-09.csv')
assert.match(fs.readFileSync(await download.path(), 'utf8'), /2026-09,1500/)
failDownload = true
await page.getByRole('button', { name: 'Download CSV', exact: true }).click()
await page.getByRole('alert').filter({ hasText: 'could not be downloaded' }).waitFor()
failDownload = false
await page.getByLabel('Report month').selectOption(months.at(-1))
await page.getByRole('heading', { name: 'October 2024', exact: true }).waitFor()
assert(await page.getByRole('button', { name: 'Previous month', exact: true }).isDisabled())
for (const [state, text] of [['empty', 'No viewing history was recorded for this month.'], ['unlinked', 'Link your viewing account'], ['not_configured', 'Your monthly story starts here'], ['unavailable', 'Report couldnt load'], ['limit', 'No partial report has been generated.']]) {
mode = state
await page.reload()
await page.getByText(text, { exact: false }).waitFor()
if (state !== 'empty') assert(await page.getByRole('button', { name: 'Download CSV', exact: true }).isDisabled())
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
}
mode = 'not_configured'
role = 'user'
await page.reload()
await page.getByText('Monthly reports will appear once your administrator connects Jellystat.').waitFor()
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
mode = 'unauthorized'
await page.reload()
await page.waitForURL('**/login?next=%2Finsights%2Freports')
mode = 'ready'
await page.goto(base + '/insights/reports')
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
await page.getByRole('navigation', { name: 'My Stats views' }).getByRole('link', { name: 'Overview' }).click()
await page.waitForURL('**/insights')
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
assert.equal(await page.locator('.stats-view-tabs a[aria-current=page]').innerText(), 'Overview')
assert(calls.filter((call) => call.path.startsWith('/api/insights/reports')).every((call) => call.method === 'GET'))
assert.deepEqual(errors, [])
console.log('Monthly report browser checks passed: desktop/mobile, comparison labels, periods, navigation, artwork, CSV download/error, empty/setup/link/error/auth states.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exit(1) })