Files

606 lines
27 KiB
HTML
Raw Permalink 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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>DSP 包名收入波动报告</title>
<style>
:root {
--ink: #14223a;
--muted: #64748b;
--line: #dce4ef;
--canvas: #f5f8fc;
--surface: #ffffff;
--navy: #142b55;
--blue: #2563eb;
--blue-soft: #e8f0ff;
--green: #16825d;
--green-soft: #e7f7ef;
--red: #c43f45;
--red-soft: #fff0f1;
--amber: #a25d00;
--shadow: 0 12px 32px rgba(34, 58, 93, .08);
}
* { box-sizing: border-box; }
html { background: var(--canvas); }
body {
margin: 0;
min-width: 320px;
color: var(--ink);
background:
radial-gradient(circle at 84% -10%, rgba(59, 130, 246, .15), transparent 32rem),
var(--canvas);
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select { font: inherit; }
button { cursor: pointer; }
.shell { width: min(1440px, calc(100% - 40px)); margin: 0 auto; }
.hero {
padding: 48px 0 32px;
color: #fff;
background: linear-gradient(120deg, #132a53 0%, #1e427e 54%, #2b6ed3 100%);
box-shadow: inset 0 -1px rgba(255,255,255,.14);
}
.hero .shell { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border: 1px solid rgba(255,255,255,.26);
border-radius: 999px;
color: #dcecff;
font-size: 12px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.pulse { width: 7px; height: 7px; border-radius: 50%; background: #6ee7b7; box-shadow: 0 0 0 4px rgba(110,231,183,.18); }
h1 { margin: 13px 0 5px; font-size: clamp(28px, 4vw, 40px); letter-spacing: -.035em; line-height: 1.12; }
.hero p { max-width: 720px; margin: 0; color: #c8ddff; font-size: 15px; }
.status-card {
flex: 0 0 auto;
min-width: 255px;
padding: 14px 16px;
border: 1px solid rgba(255,255,255,.18);
border-radius: 12px;
background: rgba(7, 21, 47, .22);
}
.status-label { display: block; color: #b7cff5; font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
.status-value { display: block; margin-top: 4px; color: #fff; font-weight: 700; }
main { padding: 28px 0 56px; }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 22px; }
.metric, .panel {
border: 1px solid rgba(205, 218, 236, .92);
border-radius: 16px;
background: var(--surface);
box-shadow: var(--shadow);
}
.metric { min-height: 122px; padding: 18px 20px; }
.metric-label { display: block; color: var(--muted); font-size: 12px; font-weight: 700; }
.metric-value { display: block; margin-top: 10px; font-size: clamp(22px, 2.4vw, 30px); font-weight: 800; letter-spacing: -.035em; }
.metric-note { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; }
.panel { overflow: hidden; margin-bottom: 22px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 22px 24px 14px; }
.panel-heading h2 { margin: 0; font-size: 18px; letter-spacing: -.015em; }
.panel-heading p { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
.legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 12px; color: var(--muted); font-size: 12px; }
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
.legend-line { width: 22px; height: 3px; border-radius: 2px; background: var(--blue); }
.chart-wrap { min-height: 286px; padding: 0 14px 17px; }
#trendChart { display: block; width: 100%; min-height: 270px; overflow: visible; }
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 0 24px 18px; }
.field { display: grid; gap: 5px; min-width: 180px; color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; }
.field.search { flex: 1 1 260px; }
select, input {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid #c8d4e4;
border-radius: 9px;
outline: none;
color: var(--ink);
background: #fff;
font-size: 13px;
letter-spacing: normal;
text-transform: none;
}
select:focus, input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
.button-secondary {
min-height: 38px;
padding: 0 13px;
border: 1px solid #c8d4e4;
border-radius: 9px;
color: #31547f;
background: #fff;
font-size: 13px;
font-weight: 700;
}
.button-secondary:hover { border-color: #8eafd8; background: #f5f9ff; }
.data-note { padding: 0 24px 14px; color: var(--muted); font-size: 12px; }
.table-scroll { width: 100%; overflow: auto; border-top: 1px solid var(--line); }
table { width: 100%; min-width: 820px; border-collapse: collapse; }
th, td { padding: 12px 16px; border-bottom: 1px solid #e8eef6; text-align: right; white-space: nowrap; }
th {
position: sticky;
top: 0;
z-index: 1;
color: #57708f;
background: #f8fbff;
font-size: 11px;
font-weight: 800;
letter-spacing: .03em;
text-transform: uppercase;
}
th:first-child, td:first-child { position: sticky; left: 0; text-align: left; }
th:first-child { z-index: 2; background: #f8fbff; }
td:first-child { z-index: 1; min-width: 260px; color: #203858; background: #fff; font-weight: 700; }
tbody tr:hover td { background: #f6faff; }
tbody tr:hover td:first-child { background: #f6faff; }
.diff { border-radius: 7px; font-weight: 800; }
.diff.positive { color: var(--green); background: var(--green-soft); }
.diff.negative { color: var(--red); background: var(--red-soft); }
.diff.neutral { color: var(--amber); background: #fff8e9; }
.empty { padding: 30px !important; color: var(--muted); text-align: center !important; }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 11px; padding: 14px 20px; }
.pagination span { margin-right: auto; color: var(--muted); font-size: 12px; }
.pagination button {
min-width: 68px;
min-height: 32px;
padding: 0 10px;
border: 1px solid #c8d4e4;
border-radius: 7px;
color: #31547f;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.pagination button:disabled { cursor: not-allowed; opacity: .42; }
.footnote { margin: 18px 2px 0; color: var(--muted); font-size: 12px; }
.footnote strong { color: #415a7a; }
.error { color: var(--red); }
@media (max-width: 900px) {
.hero .shell { display: block; }
.status-card { display: inline-block; margin-top: 20px; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 580px) {
.shell { width: min(100% - 24px, 1440px); }
.hero { padding-top: 34px; }
main { padding-top: 18px; }
.metrics { gap: 10px; }
.metric { min-height: 105px; padding: 15px; }
.panel-heading, .filters { padding-left: 16px; padding-right: 16px; }
.panel-heading { display: block; }
.legend { justify-content: flex-start; margin-top: 10px; }
.data-note { padding-left: 16px; padding-right: 16px; }
}
</style>
</head>
<body>
<header class="hero">
<div class="shell">
<div>
<span class="eyebrow"><i class="pulse"></i> AgentStage · DSP BI</span>
<h1>DSP 包名收入波动报告</h1>
<p>按归因平台筛选,以包名和日期透视收入;右侧即时显示最新日(T-1)相对上一日(T-2)的收入变化。</p>
</div>
<div class="status-card" aria-live="polite">
<span class="status-label">数据状态</span>
<span class="status-value" id="dataStatus">正在加载数据…</span>
</div>
</div>
</header>
<main class="shell">
<section class="metrics" aria-label="汇总指标">
<article class="metric"><span class="metric-label">最新日收入</span><strong class="metric-value" id="latestRevenue">—</strong><span class="metric-note" id="latestRevenueNote">等待数据</span></article>
<article class="metric"><span class="metric-label">已返回明细</span><strong class="metric-value" id="rowCount">—</strong><span class="metric-note">单次查询最多 5,000 行</span></article>
<article class="metric"><span class="metric-label">归因平台</span><strong class="metric-value" id="platformCount">—</strong><span class="metric-note">可通过筛选器切换</span></article>
<article class="metric"><span class="metric-label">包名数</span><strong class="metric-value" id="packageCount">—</strong><span class="metric-note">当前筛选结果</span></article>
</section>
<section class="panel" aria-labelledby="trendTitle">
<div class="panel-heading">
<div><h2 id="trendTitle">收入趋势</h2><p>当前筛选条件下,按数据日期汇总的 revenue。</p></div>
<div class="legend"><span class="legend-item"><i class="legend-line"></i>收入</span></div>
</div>
<div class="chart-wrap"><svg id="trendChart" viewBox="0 0 920 300" role="img" aria-label="收入趋势图"></svg></div>
</section>
<section class="panel" aria-labelledby="pivotTitle">
<div class="panel-heading">
<div><h2 id="pivotTitle">包名 × 日期收入透视表</h2><p><code>attr_platform</code> 作为筛选列,<code>Pkg name</code> 作为行,<code>revenue</code> 作为值。</p></div>
</div>
<div class="filters">
<label class="field">归因平台<select id="platformFilter" aria-label="按归因平台筛选"><option value="__all__">全部平台</option></select></label>
<label class="field search">搜索包名<input id="packageSearch" type="search" placeholder="输入包名,例如 com.example.app" autocomplete="off"></label>
<button class="button-secondary" id="clearFilters" type="button">清除筛选</button>
</div>
<div class="data-note" id="dataNote">正在读取查询元数据…</div>
<div class="table-scroll">
<table aria-describedby="dataNote">
<thead id="pivotHead"></thead>
<tbody id="pivotBody"><tr><td class="empty">正在加载…</td></tr></tbody>
</table>
</div>
<nav class="pagination" aria-label="表格分页">
<span id="pageIndicator">—</span>
<button id="previousPage" type="button">上一页</button>
<button id="nextPage" type="button">下一页</button>
</nav>
</section>
<p class="footnote"><strong>差额规则:</strong>T-1 − T-2 和 T-1 − T-4:≥ +100 显示绿色;≤ −100 显示红色;其余差额显示黑色。页面数据由自动任务每日 10:30(Asia/Shanghai)更新。</p>
</main>
<script>
(() => {
'use strict';
const state = {
data: null,
platformQuery: '',
packageQuery: '',
sortKey: 'total',
sortDirection: 'desc',
page: 1,
pageSize: 100,
};
const numberFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
const compactFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
const dateFormatter = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' });
const $ = (id) => document.getElementById(id);
const text = (value) => (value === undefined || value === null ? '' : String(value));
const money = (value) => numberFormatter.format(Math.round(Number(value || 0)));
const shortDate = (date) => date && date.length >= 10 ? date.slice(5).replace('-', '/') : text(date);
const svgNode = (name, attributes = {}) => {
const node = document.createElementNS('http://www.w3.org/2000/svg', name);
Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, value));
return node;
};
function dataDates() {
const fromFile = Array.isArray(state.data?.dates) ? state.data.dates : [];
const fallback = [...new Set((state.data?.rows || []).map((row) => row.date))];
return [...(fromFile.length ? fromFile : fallback)].sort();
}
function selectedRows() {
const platformQuery = state.platformQuery.trim().toLowerCase();
const packageQuery = state.packageQuery.trim().toLowerCase();
return (state.data?.rows || []).filter((row) => {
const platformMatches = !platformQuery || text(row.attr_platform).toLowerCase().includes(platformQuery);
const packageMatches = !packageQuery || text(row.pkg_name).toLowerCase().includes(packageQuery);
return platformMatches && packageMatches;
});
}
function buildPivot(rows, dates) {
const packages = new Map();
rows.forEach((row) => {
const name = text(row.pkg_name) || '(未标记)';
if (!packages.has(name)) packages.set(name, { name, values: new Map() });
const entry = packages.get(name);
entry.values.set(row.date, (entry.values.get(row.date) || 0) + Number(row.revenue || 0));
});
return [...packages.values()].map((entry) => {
const values = dates.map((date) => entry.values.get(date) || 0);
const latest = values.length ? values[values.length - 1] : 0;
const previous = values.length > 1 ? values[values.length - 2] : null;
const fourthPrevious = values.length >= 4 ? values[values.length - 4] : null;
const total = values.reduce((sum, value) => sum + value, 0);
return {
...entry,
values,
total,
latest,
difference: previous === null ? null : latest - previous,
differenceT4: fourthPrevious === null ? null : latest - fourthPrevious,
};
});
}
function sortPivot(pivot, dates) {
const dateIndex = dates.indexOf(state.sortKey);
const useTotal = state.sortKey === 'total' || dateIndex < 0;
const direction = state.sortDirection === 'asc' ? 1 : -1;
return [...pivot].sort((left, right) => {
const leftValue = useTotal ? left.total : left.values[dateIndex];
const rightValue = useTotal ? right.total : right.values[dateIndex];
return direction * (leftValue - rightValue) || left.name.localeCompare(right.name);
});
}
function totalsForDates(rows, dates) {
const totals = new Map(dates.map((date) => [date, 0]));
rows.forEach((row) => totals.set(row.date, (totals.get(row.date) || 0) + Number(row.revenue || 0)));
return dates.map((date) => ({ date, value: totals.get(date) || 0 }));
}
function renderSummary(rows, dates, pivot) {
const totals = totalsForDates(rows, dates);
const latest = totals.length ? totals[totals.length - 1] : null;
$('latestRevenue').textContent = latest ? money(latest.value) : '—';
$('latestRevenueNote').textContent = latest ? `T-1 · ${latest.date}` : '当前筛选无数据';
$('rowCount').textContent = compactFormatter.format(rows.length);
$('platformCount').textContent = compactFormatter.format(new Set(rows.map((row) => row.attr_platform)).size);
$('packageCount').textContent = compactFormatter.format(pivot.length);
}
function renderChart(rows, dates) {
const chart = $('trendChart');
chart.replaceChildren();
const points = totalsForDates(rows, dates);
if (!points.length) {
const message = svgNode('text', { x: 460, y: 150, 'text-anchor': 'middle', fill: '#64748b', 'font-size': 14 });
message.textContent = '当前筛选没有可绘制的收入数据';
chart.append(message);
return;
}
const width = 920, height = 300, left = 70, right = 26, top = 25, bottom = 60;
const plotWidth = width - left - right, plotHeight = height - top - bottom;
const max = Math.max(...points.map((point) => point.value), 1);
const min = 0;
const y = (value) => top + plotHeight - ((value - min) / (max - min || 1)) * plotHeight;
const x = (index) => points.length === 1 ? left + plotWidth / 2 : left + index * (plotWidth / (points.length - 1));
for (let tick = 0; tick <= 4; tick += 1) {
const value = max * (tick / 4);
const yPosition = y(value);
chart.append(svgNode('line', { x1: left, y1: yPosition, x2: width - right, y2: yPosition, stroke: '#e3ebf5', 'stroke-width': 1 }));
const label = svgNode('text', { x: left - 10, y: yPosition + 4, 'text-anchor': 'end', fill: '#7589a3', 'font-size': 11 });
label.textContent = compactFormatter.format(value);
chart.append(label);
}
const linePoints = points.map((point, index) => `${x(index)},${y(point.value)}`).join(' ');
const areaPoints = `${left},${top + plotHeight} ${linePoints} ${width - right},${top + plotHeight}`;
chart.append(svgNode('polygon', { points: areaPoints, fill: 'rgba(37, 99, 235, .10)' }));
chart.append(svgNode('polyline', { points: linePoints, fill: 'none', stroke: '#2563eb', 'stroke-width': 3.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }));
points.forEach((point, index) => {
const cx = x(index), cy = y(point.value);
chart.append(svgNode('circle', { cx, cy, r: 4.5, fill: '#fff', stroke: '#2563eb', 'stroke-width': 3 }));
const label = svgNode('text', { x: cx, y: height - 26, 'text-anchor': 'middle', fill: '#5c718d', 'font-size': 11, 'font-weight': 700 });
label.textContent = shortDate(point.date);
chart.append(label);
const title = svgNode('title');
title.textContent = `${point.date}: ${money(point.value)}`;
chart.lastChild.append(title);
});
}
function createSortButton(date, direction) {
const isActive = state.sortKey === date && state.sortDirection === direction;
const button = document.createElement('button');
button.type = 'button';
button.textContent = direction === 'asc' ? '↑' : '↓';
button.title = `按 ${date} 收入${direction === 'asc' ? '升序' : '降序'}排列`;
button.setAttribute('aria-label', button.title);
button.setAttribute('aria-pressed', String(isActive));
Object.assign(button.style, {
width: '18px',
minWidth: '18px',
height: '18px',
padding: '0',
border: `1px solid ${isActive ? '#2563eb' : '#c8d4e4'}`,
borderRadius: '4px',
color: isActive ? '#fff' : '#58708e',
background: isActive ? '#2563eb' : '#fff',
fontSize: '11px',
fontWeight: '800',
lineHeight: '16px',
});
button.addEventListener('click', () => {
state.sortKey = date;
state.sortDirection = direction;
state.page = 1;
render();
});
return button;
}
function createDateHeader(date) {
const cell = document.createElement('th');
cell.style.minWidth = '138px';
const content = document.createElement('span');
Object.assign(content.style, { display: 'inline-flex', alignItems: 'center', gap: '4px' });
const label = document.createElement('span');
label.textContent = date;
content.append(label, createSortButton(date, 'asc'), createSortButton(date, 'desc'));
cell.append(content);
return cell;
}
function createDifferenceCell(value) {
const cell = document.createElement('td');
if (value === null) {
cell.textContent = '—';
} else {
cell.textContent = `${value > 0 ? '+' : ''}${money(value)}`;
if (value >= 100) {
cell.className = 'diff positive';
} else if (value <= -100) {
cell.className = 'diff negative';
} else {
cell.className = 'diff';
cell.style.color = '#000';
cell.style.background = 'transparent';
}
}
return cell;
}
function renderTable(rows, dates, pivot) {
const head = $('pivotHead');
const body = $('pivotBody');
head.replaceChildren();
body.replaceChildren();
const headerRow = document.createElement('tr');
const packageHeader = document.createElement('th');
packageHeader.textContent = 'Pkg name';
headerRow.append(packageHeader);
dates.forEach((date) => headerRow.append(createDateHeader(date)));
const differenceHeader = document.createElement('th');
differenceHeader.textContent = 'T-1 − T-2';
headerRow.append(differenceHeader);
const differenceT4Header = document.createElement('th');
differenceT4Header.textContent = 'T-1 − T-4';
headerRow.append(differenceT4Header);
head.append(headerRow);
const dailyTotals = totalsForDates(rows, dates);
const totalRow = document.createElement('tr');
const totalLabel = document.createElement('td');
totalLabel.textContent = '每日总收入';
totalRow.append(totalLabel);
dailyTotals.forEach((total) => {
const cell = document.createElement('td');
cell.textContent = money(total.value);
totalRow.append(cell);
});
const latestTotal = dailyTotals.length ? dailyTotals[dailyTotals.length - 1].value : null;
const previousTotal = dailyTotals.length > 1 ? dailyTotals[dailyTotals.length - 2].value : null;
const fourthPreviousTotal = dailyTotals.length >= 4 ? dailyTotals[dailyTotals.length - 4].value : null;
totalRow.append(
createDifferenceCell(previousTotal === null ? null : latestTotal - previousTotal),
createDifferenceCell(fourthPreviousTotal === null ? null : latestTotal - fourthPreviousTotal),
);
body.append(totalRow);
const pageCount = Math.max(1, Math.ceil(pivot.length / state.pageSize));
if (state.page > pageCount) state.page = pageCount;
const start = (state.page - 1) * state.pageSize;
const pageRows = pivot.slice(start, start + state.pageSize);
if (!pageRows.length) {
const empty = document.createElement('tr');
const cell = document.createElement('td');
cell.className = 'empty';
cell.colSpan = dates.length + 3;
cell.textContent = '没有匹配当前筛选条件的包名';
empty.append(cell);
body.append(empty);
}
pageRows.forEach((item) => {
const row = document.createElement('tr');
const name = document.createElement('td');
name.textContent = item.name;
row.append(name);
item.values.forEach((value) => {
const cell = document.createElement('td');
cell.textContent = money(value);
row.append(cell);
});
row.append(createDifferenceCell(item.difference), createDifferenceCell(item.differenceT4));
body.append(row);
});
$('pageIndicator').textContent = `第 ${state.page} / ${pageCount} 页 · ${pivot.length.toLocaleString('en-US')} 个包名`;
$('previousPage').disabled = state.page <= 1;
$('nextPage').disabled = state.page >= pageCount;
}
function render() {
const dates = dataDates();
const rows = selectedRows();
const pivot = sortPivot(buildPivot(rows, dates), dates);
renderSummary(rows, dates, pivot);
renderChart(rows, dates);
renderTable(rows, dates, pivot);
}
function populatePlatforms() {
const select = $('platformFilter');
if (!select || select.tagName === 'INPUT') return;
const field = select.parentElement;
const platforms = Array.isArray(state.data?.platforms) ? state.data.platforms : [...new Set((state.data?.rows || []).map((row) => row.attr_platform))].sort();
const input = document.createElement('input');
input.id = 'platformFilter';
input.type = 'search';
input.setAttribute('list', 'platformOptions');
input.setAttribute('aria-label', '模糊搜索归因平台');
input.placeholder = '输入归因平台,例如 appnextads';
input.autocomplete = 'off';
const options = document.createElement('datalist');
options.id = 'platformOptions';
platforms.forEach((platform) => {
const option = document.createElement('option');
option.value = platform;
options.append(option);
});
select.replaceWith(input);
field.append(options);
input.addEventListener('input', (event) => {
state.platformQuery = event.target.value;
state.page = 1;
render();
});
}
function renderMetadata() {
const generated = state.data?.generatedAt ? dateFormatter.format(new Date(state.data.generatedAt)) : '未知';
const source = state.data?.source || {};
const dates = dataDates();
const dateLabel = dates.length ? dates.join(' ~ ') : '无可用日期';
$('dataStatus').textContent = `已更新 · ${generated}`;
$('dataNote').textContent = `查询范围:${source.timeRange || '—'} | 数据日期:${dateLabel} | 已返回 ${Number(state.data?.rawRowCount || 0).toLocaleString('en-US')} 行 | 行数上限:${source.rowLimit || 5000} | 默认按包名总收入降序`;
}
async function load() {
try {
const response = await fetch('data.json', { cache: 'no-store' });
if (!response.ok) throw new Error(`data.json 返回 HTTP ${response.status}`);
const data = await response.json();
if (!Array.isArray(data.rows)) throw new Error('data.json 缺少 rows 数组');
state.data = data;
populatePlatforms();
renderMetadata();
render();
} catch (error) {
$('dataStatus').textContent = '数据加载失败';
$('dataStatus').classList.add('error');
$('dataNote').textContent = `无法读取数据:${error.message}`;
$('pivotBody').replaceChildren();
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.className = 'empty error';
cell.textContent = '请等待下一次自动更新,或检查 update.sh 执行日志。';
row.append(cell);
$('pivotBody').append(row);
}
}
$('packageSearch').addEventListener('input', (event) => {
state.packageQuery = event.target.value;
state.page = 1;
render();
});
$('clearFilters').addEventListener('click', () => {
state.platformQuery = '';
state.packageQuery = '';
state.sortKey = 'total';
state.sortDirection = 'desc';
state.page = 1;
$('platformFilter').value = '';
$('packageSearch').value = '';
render();
});
$('previousPage').addEventListener('click', () => { state.page -= 1; render(); });
$('nextPage').addEventListener('click', () => { state.page += 1; render(); });
load();
})();
</script>
</body>
</html>