NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name itch.io stats
// @namespace https://itch.io/
// @version 4.3
// @description Ищет свои игры в списках itch.io, сохраняет позиции, показывает статистику и пассивно подсвечивает найденные игры
// @match https://itch.io/*
// @match https://*.itch.io/*
// @author Nnican
// @license MIT
// @grant none
// ==/UserScript==
(function () {
'use strict';
const SCROLL_INTERVAL = 170;
const SCROLL_STEP = 1180;
const SCROLL_BURST_STEP_FACTOR = 1.18;
const SCROLL_BURST_COUNT = 4;
const SCROLL_BURST_PAUSE = 85;
const DEFAULT_PAGE_SIZE = 36;
const MAX_SEARCH_PAGE = 30;
const SEARCH_SERIES = [
{ key: 'popular', label: 'Popular', pathPart: '' },
{ key: 'new-and-popular', label: 'New & Popular', pathPart: 'new-and-popular' },
{ key: 'newest', label: 'Newest', pathPart: 'newest' },
{ key: 'top-sellers', label: 'Top Sellers', pathPart: 'top-sellers' },
{ key: 'top-rated', label: 'Top Rated', pathPart: 'top-rated' }
];
const ANALYTICS_SERIES = SEARCH_SERIES.filter(item => item.key !== 'newest');
const STORAGE_KEY_GAMES = 'tm_itch_dashboard_published_games_v4';
const STORAGE_KEY_COLLAPSED = 'tm_itch_finder_collapsed_v4';
const STORAGE_KEY_POSITIONS = 'tm_itch_game_positions_v4';
const STORAGE_KEY_GAME_META = 'tm_itch_game_meta_v4';
const STORAGE_KEY_SUMMARY_SECTIONS = 'tm_itch_summary_sections_v1';
const STORAGE_KEY_SUMMARY_COLLAPSED = 'tm_itch_summary_collapsed_v1';
const STORAGE_KEY_SUMMARY_SERIES = 'tm_itch_summary_series_v1';
const STORAGE_KEY_SUMMARY_CHART_PREFS = 'tm_itch_summary_chart_prefs_v1';
const STORAGE_KEY_INTERSECTIONS = 'tm_itch_summary_intersections_v1';
const STORAGE_KEY_REFRESH_STATE = 'tm_itch_refresh_state_v1';
const STORAGE_KEY_REFRESH_REDIRECT_GUARD = 'tm_itch_refresh_redirect_guard_v1';
const REFRESH_STATE_MAX_AGE = 15 * 60 * 1000;
const WINDOW_NAME_TRANSFER_PREFIX = 'tm_itch_transfer_v1:';
let searching = false;
let pausedByHiddenTab = false;
let targetGame = null;
let targetText = '';
let refreshAutostarted = false;
let transferredPayload = null;
let summaryReminderShown = false;
let lastLoadedPage = null;
let lastNumItems = DEFAULT_PAGE_SIZE;
let dashboardGames = [];
const passiveHighlighted = new WeakSet();
const confettiPlayed = new WeakSet();
const tiltInstalled = new WeakSet();
const foundInfoByCard = new WeakMap();
const path = location.pathname;
const isGamesPage = path === '/games' || path.startsWith('/games/');
const isDashboardPage = path === '/dashboard';
const isSummaryPage = path.startsWith('/game/summary/');
const isPublicGamePage = path !== '/dashboard' && !isGamesPage && !!document.querySelector('[id^="view_html_game_"]');
function getSeriesConfig(seriesKey) {
return SEARCH_SERIES.find(item => item.key === seriesKey) || SEARCH_SERIES[0];
}
function getSeriesLabel(seriesKey) {
return getSeriesConfig(seriesKey).label;
}
function getSeriesPathPart(seriesKey) {
return getSeriesConfig(seriesKey).pathPart;
}
function isKnownSeriesKey(seriesKey) {
return SEARCH_SERIES.some(item => item.key === seriesKey);
}
function isKnownSeriesPathPart(part) {
const wanted = normalize(part);
return SEARCH_SERIES.some(item => normalize(item.pathPart) === wanted);
}
function getSeriesOrder(seriesKey) {
const index = ANALYTICS_SERIES.findIndex(item => item.key === seriesKey);
return index >= 0 ? index : 999;
}
function getDefaultSummarySeriesState() {
return ANALYTICS_SERIES.reduce((acc, item) => {
acc[item.key] = true;
return acc;
}, {});
}
const style = document.createElement('style');
style.textContent = `
:root {
--tm-accent: #D36D6D;
--tm-accent-strong: #bc5b5b;
--tm-accent-soft: rgba(211,109,109,.28);
}
.tm-rainbow-found {
animation: none !important;
box-shadow: 0 0 0 2px #fff, 0 0 12px rgba(255,255,255,.65) !important;
position: relative !important;
z-index: 9998 !important;
padding: 10px !important;
border-radius: 12px !important;
transform-style: preserve-3d !important;
will-change: transform !important;
overflow: visible !important;
}
.tm-rainbow-found img,
.tm-rainbow-found .game_thumb,
.tm-rainbow-found .thumb_link,
.tm-rainbow-found .game_cover,
.tm-rainbow-found .cover,
.tm-rainbow-found .screenshot {
border-radius: 12px !important;
overflow: hidden !important;
}
.tm-tilt-card {
transform-style: preserve-3d !important;
perspective: 900px !important;
transition: transform .12s ease-out, filter .16s ease-out !important;
will-change: transform !important;
border-radius: 12px !important;
position: relative !important;
}
.tm-tilt-card:hover {
z-index: 9997 !important;
filter: brightness(1.08) saturate(1.08);
}
.tm-tilt-sensor {
position: absolute;
inset: 0;
z-index: 99999;
pointer-events: auto;
background: transparent;
border-radius: inherit;
}
.tm-tilt-sensor-floating {
position: fixed;
z-index: 999999;
pointer-events: auto;
background: transparent;
cursor: pointer;
}
.tm-stat-section-title {
margin: 10px -12px 8px;
padding: 9px 12px;
background: var(--tm-accent);
color: #fff;
font-size: 13px;
font-weight: 900;
text-align: left;
border-top: 1px solid rgba(255,255,255,.18);
border-bottom: 1px solid rgba(255,255,255,.55);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
user-select: none;
}
.tm-stat-section {
margin-bottom: 8px;
}
.tm-stat-section-body {
overflow: hidden;
opacity: 1;
max-height: 2000px;
transition: max-height .28s ease, opacity .22s ease, margin-top .28s ease;
will-change: max-height, opacity;
}
.tm-stat-section-body.tm-hidden {
opacity: 0;
max-height: 0;
margin-top: -2px;
}
.tm-stat-section-toggle {
opacity: .78;
font-size: 12px;
font-weight: 900;
}
.tm-summary-root-body.tm-hidden {
display: none;
}
#tm-itch-finder,
#tm-itch-summary-stats {
position: fixed;
top: 64px;
right: 16px;
z-index: 999999;
background: #111;
color: white;
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,.35);
font-family: system-ui, sans-serif;
width: 320px;
max-height: calc(100vh - 84px);
overflow: hidden;
display: flex;
flex-direction: column;
}
#tm-itch-summary-stats {
width: 980px;
max-width: calc(100vw - 32px);
}
#tm-itch-summary-stats.tm-embedded {
position: relative;
top: auto;
right: auto;
z-index: auto;
width: 100%;
max-width: none;
max-height: none;
margin-top: 18px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.18);
}
#tm-itch-finder.tm-collapsed {
display: none;
}
#tm-itch-open-button {
position: fixed;
top: 64px;
right: 16px;
z-index: 999999;
width: 46px;
height: 46px;
border: 0;
border-radius: 12px;
background: var(--tm-accent);
color: #fff;
font: 22px/1 system-ui, sans-serif;
cursor: pointer;
box-shadow: 0 6px 24px rgba(0,0,0,.35);
display: none;
}
#tm-itch-open-button.tm-visible {
display: block;
}
.tm-widget-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: 0 0 auto;
padding: 12px;
border-bottom: 1px solid rgba(255,255,255,.08);
background:
linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)),
#111;
cursor: grab;
user-select: none;
}
#tm-itch-summary-stats.tm-embedded .tm-widget-head {
cursor: default;
border-bottom: 1px solid rgba(255,255,255,.12);
}
.tm-widget-head:active {
cursor: grabbing;
}
.tm-widget-title {
font-size: 13px;
font-weight: 800;
opacity: .95;
}
.tm-widget-scroll-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
}
#tm-itch-collapse,
.tm-widget-collapse {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: var(--tm-accent-strong);
color: #fff;
cursor: pointer;
font-weight: 900;
}
#tm-itch-finder input {
width: 100%;
box-sizing: border-box;
padding: 8px;
border-radius: 8px;
border: 1px solid #555;
margin-bottom: 8px;
background: #2b2b2b;
color: #fff;
}
#tm-itch-search,
.tm-small-button {
width: 100%;
padding: 8px;
border: 0;
border-radius: 8px;
cursor: pointer;
font-weight: 700;
margin-bottom: 8px;
}
#tm-itch-status {
font-size: 12px;
opacity: .85;
margin-bottom: 10px;
line-height: 1.35;
white-space: pre-line;
}
.tm-games-title {
font-size: 12px;
font-weight: 700;
margin: 10px 0 6px;
opacity: .9;
}
.tm-game-item {
font-size: 12px;
padding: 6px 8px;
border-radius: 8px;
background: #222;
margin-bottom: 5px;
cursor: pointer;
line-height: 1.25;
}
.tm-game-item:hover {
background: #333;
}
.tm-found-info {
--tm-found-bg: linear-gradient(135deg, #242424, #171717);
display: block;
width: 360px;
max-width: min(420px, calc(100vw - 40px));
margin: 0 !important;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.12);
color: #f3f3f3;
font: 14px/1.35 system-ui, sans-serif;
font-weight: 800;
box-shadow: 0 14px 32px rgba(0,0,0,.55);
position: absolute;
left: 50%;
top: calc(100% + 2rem);
transform: translateX(-50%);
z-index: 10010;
box-sizing: border-box;
clear: both;
pointer-events: auto;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
.tm-found-info.tm-clickable {
cursor: pointer;
}
.tm-found-info.tm-clickable:hover {
filter: brightness(1.04);
}
.tm-found-info::before,
.tm-found-info::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
clip-path: polygon(50% 0, 0 100%, 100% 100%);
}
.tm-found-info::before {
top: -14px;
width: 28px;
height: 14px;
background: rgba(255,255,255,.12);
}
.tm-found-info::after {
top: -12px;
width: 24px;
height: 12px;
background: var(--tm-found-bg);
}
.tm-found-info .tm-big-place {
display: flex;
align-items: center;
gap: 10px;
font-size: 30px;
line-height: 1.05;
margin-bottom: 4px;
letter-spacing: -.03em;
}
.tm-rank-icon {
font-size: 28px;
line-height: 1;
image-rendering: pixelated;
filter:
drop-shadow(0 2px 0 rgba(0,0,0,.35))
drop-shadow(0 0 6px rgba(255,255,255,.25));
}
.tm-found-rank-diamond {
--tm-found-bg: linear-gradient(135deg, #1d2b30, #123945, #0f222a);
background: linear-gradient(135deg, #1d2b30, #123945, #0f222a) !important;
box-shadow: 0 0 0 2px rgba(125,249,255,.24), 0 0 24px rgba(125,249,255,.35) !important;
}
.tm-found-rank-gold {
--tm-found-bg: linear-gradient(135deg, #312713, #5b4310, #261e0d);
background: linear-gradient(135deg, #312713, #5b4310, #261e0d) !important;
box-shadow: 0 0 0 2px rgba(255,215,0,.2), 0 0 22px rgba(255,215,0,.28) !important;
}
.tm-found-rank-silver {
--tm-found-bg: linear-gradient(135deg, #30333a, #4a5059, #22262d);
background: linear-gradient(135deg, #30333a, #4a5059, #22262d) !important;
box-shadow: 0 0 0 2px rgba(255,255,255,.14), 0 0 20px rgba(220,220,220,.2) !important;
}
.tm-found-rank-bronze {
--tm-found-bg: linear-gradient(135deg, #3a2618, #6a3d1f, #2a1910);
background: linear-gradient(135deg, #3a2618, #6a3d1f, #2a1910) !important;
box-shadow: 0 0 0 2px rgba(205,127,50,.18), 0 0 18px rgba(205,127,50,.22) !important;
}
.tm-found-rank-default {
--tm-found-bg: linear-gradient(135deg, #242424, #171717);
background: linear-gradient(135deg, #242424, #171717) !important;
}
.tm-found-info .tm-small-line {
display: block;
font-size: 12px;
opacity: .92;
font-weight: 800;
word-break: break-word;
}
.tm-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tm-main-chip-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 10px;
width: 100%;
}
.tm-main-chip {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
background: rgba(0,0,0,.28);
color: rgba(255,255,255,.74);
font-size: 12px;
line-height: 1.2;
font-weight: 900;
box-sizing: border-box;
}
.tm-main-chip.tm-active {
background: var(--tm-accent);
color: #fff;
box-shadow: 0 0 0 2px rgba(255,255,255,.28) inset;
}
.tm-sub-chip-row {
display: flex;
flex-wrap: nowrap;
gap: 6px;
margin-top: 6px;
width: 100%;
}
.tm-sub-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
flex: 1 1 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(0,0,0,.52);
color: rgba(255,255,255,.74);
font-size: 10px;
line-height: 1.2;
font-weight: 800;
text-align: center;
white-space: nowrap;
box-sizing: border-box;
}
.tm-sub-chip.tm-active {
background: rgba(211,109,109,.9);
color: #fff;
box-shadow: 0 0 0 1px rgba(255,255,255,.24) inset;
}
.tm-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
background: rgba(0,0,0,.75);
color: #fff;
font-size: 11px;
line-height: 1.2;
font-weight: 800;
text-transform: capitalize;
}
.tm-confetti-layer {
pointer-events: none;
position: fixed;
left: 0;
top: 0;
width: 0;
height: 0;
z-index: 999998;
perspective: 800px;
overflow: visible;
}
.tm-confetti-piece {
position: absolute;
width: 12px;
height: 18px;
border-radius: 3px;
transform-style: preserve-3d;
animation: tmConfettiFly 9200ms cubic-bezier(.12,.74,.22,1) forwards;
box-shadow:
0 2px 10px rgba(0,0,0,.28),
0 0 18px rgba(255,255,255,.55);
filter: saturate(1.3) brightness(1.2);
}
@keyframes tmConfettiFly {
0% {
transform:
translate3d(0, 0, 0)
rotateX(0deg)
rotateY(0deg)
rotateZ(0deg)
scale(1.35);
opacity: 1;
}
100% {
transform:
translate3d(var(--dx), var(--dy), var(--dz))
rotateX(var(--rx))
rotateY(var(--ry))
rotateZ(var(--rz))
scale(.85);
opacity: 0;
}
}
.tm-stat-muted {
opacity: .75;
font-size: 12px;
line-height: 1.35;
margin-top: 10px;
margin-bottom: 10px;
}
.tm-stat-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
font-size: 12px;
min-width: 640px;
}
.tm-stat-table-wrap {
overflow-x: auto;
overflow-y: hidden;
}
.tm-stat-table th,
.tm-stat-table td {
text-align: left;
padding: 7px 5px;
border-bottom: 1px solid #3a4248;
vertical-align: top;
}
.tm-stat-table th:nth-child(3),
.tm-stat-table th:nth-child(4),
.tm-stat-table th:nth-child(5),
.tm-stat-table th:nth-child(6),
.tm-stat-table td:nth-child(3),
.tm-stat-table td:nth-child(4),
.tm-stat-table td:nth-child(5),
.tm-stat-table td:nth-child(6) {
text-align: center;
white-space: nowrap;
}
.tm-stat-series-cell {
text-align: center !important;
white-space: nowrap;
min-width: 88px;
}
.tm-stat-select-col,
.tm-stat-action-col {
width: 26px;
text-align: center !important;
}
.tm-stat-placeholder-col,
.tm-stat-placeholder-cell {
opacity: 0;
pointer-events: none;
}
.tm-stat-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.tm-stat-link {
border: 0;
padding: 0;
background: transparent;
color: #fff;
cursor: pointer;
text-align: left;
font: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.tm-stat-current {
font-weight: 800;
}
.tm-stat-checkbox {
accent-color: var(--tm-accent);
width: 14px;
height: 14px;
margin: 0;
cursor: pointer;
flex: 0 0 auto;
}
.tm-remove-intersection {
width: 22px;
height: 22px;
border: 0;
border-radius: 999px;
background: rgba(211,109,109,.18);
color: #fff;
cursor: pointer;
font: 700 14px/1 system-ui, sans-serif;
}
.tm-stat-table th {
opacity: .75;
font-weight: 700;
}
.tm-stat-focus-row td {
background: rgba(211,109,109,.16);
transition: background-color .24s ease, box-shadow .24s ease;
}
.tm-stat-focus-row.tm-stat-focus-live td {
background: rgba(211,109,109,.28);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.1);
}
.tm-stat-focus-cell {
position: relative;
z-index: 1;
background: rgba(211,109,109,.22) !important;
box-shadow: inset 0 0 0 2px rgba(255,255,255,.28);
}
.tm-series-toolbar {
margin-bottom: 12px;
padding: 10px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
background: rgba(255,255,255,.03);
}
.tm-series-toolbar-title {
font-size: 12px;
font-weight: 800;
margin-bottom: 8px;
}
.tm-series-toggle-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tm-series-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
cursor: pointer;
user-select: none;
}
.tm-series-toggle-input {
margin: 0;
accent-color: var(--tm-accent);
}
.tm-series-toggle-label {
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.tm-summary-reminder {
position: fixed;
top: 14px;
left: 50%;
z-index: 1000000;
display: flex;
align-items: flex-start;
gap: 12px;
width: min(520px, calc(100vw - 24px));
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(255, 164, 91, .22);
background:
radial-gradient(circle at top left, rgba(255, 164, 91, .18), transparent 42%),
linear-gradient(180deg, rgba(26, 26, 29, .98), rgba(15, 15, 17, .98));
color: #fff7ef;
box-shadow: 0 18px 40px rgba(0,0,0,.42), 0 6px 18px rgba(255, 164, 91, .14);
backdrop-filter: blur(14px);
transform: translate(-50%, -12px);
opacity: 0;
transition: opacity .22s ease, transform .22s ease;
}
.tm-summary-reminder.tm-visible {
opacity: 1;
transform: translate(-50%, 0);
}
.tm-summary-reminder-badge {
flex: 0 0 auto;
min-width: 72px;
padding: 10px 12px;
border-radius: 12px;
background: linear-gradient(180deg, rgba(255, 164, 91, .24), rgba(255, 103, 82, .14));
border: 1px solid rgba(255,255,255,.08);
text-align: center;
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
}
.tm-summary-reminder-badge-value {
display: block;
font-size: 24px;
line-height: 1;
font-weight: 800;
letter-spacing: -.03em;
color: #ffd3a6;
}
.tm-summary-reminder-badge-label {
display: block;
margin-top: 4px;
font-size: 10px;
line-height: 1.2;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: rgba(255, 236, 219, .76);
}
.tm-summary-reminder-content {
flex: 1 1 auto;
min-width: 0;
}
.tm-summary-reminder-title {
font-size: 13px;
line-height: 1.35;
font-weight: 800;
color: #fffaf5;
}
.tm-summary-reminder-meta {
margin-top: 4px;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 236, 219, .72);
}
.tm-summary-reminder-close {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 0;
border-radius: 999px;
background: rgba(255,255,255,.08);
color: rgba(255,255,255,.9);
cursor: pointer;
font: 700 15px/1 system-ui, sans-serif;
transition: background .18s ease, transform .18s ease;
}
.tm-summary-reminder-close:hover {
background: rgba(255,255,255,.14);
transform: scale(1.04);
}
.tm-stat-chart {
margin-top: 12px;
padding: 10px 10px 8px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
background: rgba(255,255,255,.03);
position: relative;
}
.tm-stat-chart-head {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
margin-bottom: 8px;
}
.tm-stat-chart-head-left,
.tm-stat-chart-head-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tm-stat-chart-head-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.tm-stat-chart-title {
margin: 4px 0 0;
color: rgba(255,255,255,.76);
font-size: 20px;
font-weight: 800;
line-height: 1.15;
}
.tm-stat-chart-toggle {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.tm-stat-chart-toggle-button {
border: 1px solid rgba(255,255,255,.14);
border-radius: 999px;
background: rgba(255,255,255,.04);
color: rgba(255,255,255,.82);
padding: 4px 10px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.tm-stat-chart-toggle-button.tm-active {
background: rgba(211,109,109,.18);
border-color: rgba(211,109,109,.42);
color: #fff;
}
.tm-stat-chart-svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.tm-stat-chart-grid {
stroke: rgba(255,255,255,.08);
stroke-width: 1;
}
.tm-stat-chart-axis {
stroke: rgba(255,255,255,.18);
stroke-width: 1;
}
.tm-stat-chart-tick {
fill: rgba(255,255,255,.52);
font-size: 10px;
font-weight: 700;
}
.tm-stat-chart-day {
fill: rgba(255,255,255,.62);
font-size: 10px;
font-weight: 700;
}
.tm-stat-chart-line {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.tm-stat-chart-point {
stroke: #111;
stroke-width: 1.5;
}
.tm-stat-chart-hover-line {
stroke: rgba(255,255,255,.18);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.tm-stat-chart-hover-zone {
fill: transparent;
cursor: crosshair;
}
.tm-stat-chart-tooltip {
position: absolute;
min-width: 148px;
max-width: 220px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(14,16,20,.96);
border: 1px solid rgba(255,255,255,.08);
box-shadow: 0 10px 30px rgba(0,0,0,.35);
pointer-events: none;
opacity: 0;
transform: translateY(-4px);
transition: opacity .12s ease, transform .12s ease;
z-index: 2;
}
.tm-stat-chart-tooltip.tm-visible {
opacity: 1;
transform: translateY(0);
}
.tm-stat-chart-tooltip-day {
font-size: 12px;
font-weight: 800;
margin-bottom: 8px;
color: #fff;
}
.tm-stat-chart-tooltip-row {
display: grid;
grid-template-columns: 10px 1fr auto;
gap: 8px;
align-items: center;
font-size: 11px;
color: rgba(255,255,255,.9);
margin-top: 4px;
}
.tm-stat-chart-tooltip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.tm-stat-chart-tooltip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tm-stat-chart-tooltip-value {
font-weight: 800;
color: #fff;
}
.tm-stat-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tm-stat-chart-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 120px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255,255,255,.05);
font-size: 11px;
color: rgba(255,255,255,.86);
}
.tm-stat-chart-legend-swatch {
width: 8px;
height: 8px;
border-radius: 999px;
flex: 0 0 auto;
}
.tm-stat-chart-legend-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tm-action-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 0;
}
.tm-action-row .tm-small-button {
margin-bottom: 0;
padding: 7px 8px;
font-size: 12px;
}
.tm-action-stack {
display: grid;
gap: 8px;
margin-top: 14px;
}
.tm-intersections-action {
margin-top: 10px;
}
.tm-primary-button {
background: linear-gradient(135deg, var(--tm-accent), var(--tm-accent-strong));
color: #fff;
box-shadow: 0 10px 24px var(--tm-accent-soft);
margin-top: 8px;
}
.tm-secondary-button {
background: rgba(211,109,109,.14);
color: rgba(255,255,255,.88);
border: 1px solid rgba(211,109,109,.38);
}
.tm-clear-button {
background: rgba(211,109,109,.22);
color: white;
border: 1px solid rgba(211,109,109,.34);
}
`;
document.head.appendChild(style);
function normalize(text) {
return String(text || '').trim().toLowerCase();
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function safeJsonParse(value, fallback) {
try {
return JSON.parse(value);
} catch (_) {
return fallback;
}
}
function loadDashboardGamesFromCache() {
const data = safeJsonParse(localStorage.getItem(STORAGE_KEY_GAMES), null);
dashboardGames = Array.isArray(data?.games) ? data.games : [];
}
function saveDashboardGames(games) {
localStorage.setItem(STORAGE_KEY_GAMES, JSON.stringify({
savedAt: Date.now(),
games
}));
}
function loadPositions() {
const raw = localStorage.getItem(STORAGE_KEY_POSITIONS);
const parsed = safeJsonParse(raw, {});
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
let mutated = false;
Object.values(parsed).forEach(entry => {
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.records)) return;
entry.records.forEach(record => {
if (!record || typeof record !== 'object') return;
const foundAt = Number(record.foundAt || 0);
if (!Number.isFinite(foundAt) || foundAt <= 0) return;
if (!record.localDayKey) {
record.localDayKey = getLocalDayKey(foundAt);
mutated = true;
}
if (!record.localHourKey) {
record.localHourKey = getLocalHourKey(foundAt);
mutated = true;
}
});
});
if (mutated) savePositions(parsed);
return parsed;
}
function savePositions(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
data = {};
}
localStorage.setItem(STORAGE_KEY_POSITIONS, JSON.stringify(data));
}
function loadGameMeta() {
const raw = localStorage.getItem(STORAGE_KEY_GAME_META);
const parsed = safeJsonParse(raw, {});
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed;
}
function saveGameMeta(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
data = {};
}
localStorage.setItem(STORAGE_KEY_GAME_META, JSON.stringify(data));
}
function storeMetaOnCurrentOrigin(meta) {
if (!meta || typeof meta !== 'object') return;
const all = loadGameMeta();
if (meta.id) {
all['id:' + meta.id] = meta;
}
if (meta.name) {
all['name:' + normalize(meta.name)] = meta;
}
saveGameMeta(all);
}
function readTransferredPayload() {
const raw = String(window.name || '');
if (!raw.startsWith(WINDOW_NAME_TRANSFER_PREFIX)) return null;
try {
const payload = JSON.parse(raw.slice(WINDOW_NAME_TRANSFER_PREFIX.length));
return payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : null;
} catch (_) {
return null;
}
}
function writeTransferredPayload(patch) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return;
try {
const current = readTransferredPayload() || {};
window.name = WINDOW_NAME_TRANSFER_PREFIX + JSON.stringify({
...current,
...patch
});
} catch (_) {}
}
function setTransferredMeta(meta) {
if (!meta || typeof meta !== 'object') return;
writeTransferredPayload({ meta });
}
function setTransferredRefreshState(refreshState) {
if (!refreshState || typeof refreshState !== 'object') return;
writeTransferredPayload({ refreshState });
}
function setTransferredPendingSearch(game) {
if (!game || typeof game !== 'object') return;
writeTransferredPayload({
pendingSearch: {
id: game.id || null,
name: game.name || ''
}
});
}
function setTransferredPendingSummaryFocus(target) {
if (!target || typeof target !== 'object') return;
writeTransferredPayload({
pendingSummaryFocus: {
section: String(target.section || ''),
label: String(target.label || ''),
series: String(target.series || '')
}
});
}
function setTransferredPendingSummaryWidget() {
writeTransferredPayload({
pendingSummaryWidget: true
});
}
function consumeTransferredMeta() {
const payload = readTransferredPayload();
if (!payload) return null;
window.name = '';
if (payload.meta) {
storeMetaOnCurrentOrigin(payload.meta);
}
if (payload.refreshState) {
saveRefreshState(payload.refreshState);
}
return payload;
}
function loadSummarySectionState() {
const raw = localStorage.getItem(STORAGE_KEY_SUMMARY_SECTIONS);
const parsed = safeJsonParse(raw, {});
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
}
function saveSummarySectionState(data) {
localStorage.setItem(STORAGE_KEY_SUMMARY_SECTIONS, JSON.stringify(data || {}));
}
function loadSummarySeriesState() {
const defaults = getDefaultSummarySeriesState();
const raw = localStorage.getItem(STORAGE_KEY_SUMMARY_SERIES);
const parsed = safeJsonParse(raw, {});
const result = { ...defaults };
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return result;
}
ANALYTICS_SERIES.forEach(item => {
if (typeof parsed[item.key] === 'boolean') {
result[item.key] = parsed[item.key];
}
});
return result;
}
function saveSummarySeriesState(data) {
const defaults = getDefaultSummarySeriesState();
const next = { ...defaults };
if (data && typeof data === 'object' && !Array.isArray(data)) {
ANALYTICS_SERIES.forEach(item => {
if (typeof data[item.key] === 'boolean') {
next[item.key] = data[item.key];
}
});
}
localStorage.setItem(STORAGE_KEY_SUMMARY_SERIES, JSON.stringify(next));
}
function loadSummaryChartPrefs() {
const raw = localStorage.getItem(STORAGE_KEY_SUMMARY_CHART_PREFS);
const parsed = safeJsonParse(raw, {});
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
}
function saveSummaryChartPrefs(data) {
localStorage.setItem(STORAGE_KEY_SUMMARY_CHART_PREFS, JSON.stringify(data || {}));
}
function getSummaryChartPref(chartKey, visibleModes = []) {
const allPrefs = loadSummaryChartPrefs();
const pref = allPrefs?.[chartKey];
const mode = visibleModes.includes(pref?.mode) ? pref.mode : (visibleModes[0] || '');
const duration = pref?.duration === 7 ? 7 : 1;
return { mode, duration };
}
function setSummaryChartPref(chartKey, pref) {
if (!chartKey) return;
const allPrefs = loadSummaryChartPrefs();
allPrefs[chartKey] = {
mode: String(pref?.mode || ''),
duration: Number(pref?.duration) === 7 ? 7 : 1
};
saveSummaryChartPrefs(allPrefs);
}
function getEnabledSummarySeries() {
const state = loadSummarySeriesState();
return ANALYTICS_SERIES
.map(item => item.key)
.filter(key => state[key] !== false);
}
function loadIntersectionsState() {
const raw = localStorage.getItem(STORAGE_KEY_INTERSECTIONS);
const parsed = safeJsonParse(raw, {});
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
}
function saveIntersectionsState(data) {
localStorage.setItem(STORAGE_KEY_INTERSECTIONS, JSON.stringify(data || {}));
}
function getIntersectionStorageKey(game) {
return game?.id ? `id:${game.id}` : `name:${normalize(game?.name)}`;
}
function getGameIntersections(game) {
const all = loadIntersectionsState();
const key = getIntersectionStorageKey(game);
const items = Array.isArray(all[key]) ? all[key] : [];
const normalizedItems = items
.filter(item => item && typeof item === 'object' && item.id && item.label)
.map(item => {
if (!Array.isArray(item.parts) || !item.parts.length) return item;
const parts = normalizeIntersectionParts(item.parts);
const urls = buildIntersectionUrls(parts);
if (!urls.popularUrl || !urls.newPopularUrl) return item;
return {
...item,
id: buildIntersectionId(parts),
label: parts.map(part => part.label).join(' + '),
parts,
popularUrl: urls.popularUrl,
newPopularUrl: urls.newPopularUrl
};
});
if (key && JSON.stringify(normalizedItems) !== JSON.stringify(items)) {
all[key] = normalizedItems;
saveIntersectionsState(all);
}
return normalizedItems;
}
function saveGameIntersections(game, items) {
const all = loadIntersectionsState();
const key = getIntersectionStorageKey(game);
if (!key) return;
all[key] = Array.isArray(items) ? items : [];
saveIntersectionsState(all);
}
function loadRefreshState() {
const raw = localStorage.getItem(STORAGE_KEY_REFRESH_STATE);
const parsed = safeJsonParse(raw, null);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const startedAt = Number(parsed.startedAt || 0);
if (!startedAt || Date.now() - startedAt > REFRESH_STATE_MAX_AGE) {
clearRefreshState();
return null;
}
return parsed;
}
function saveRefreshState(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) return;
localStorage.setItem(STORAGE_KEY_REFRESH_STATE, JSON.stringify(data));
}
function clearRefreshState() {
localStorage.removeItem(STORAGE_KEY_REFRESH_STATE);
sessionStorage.removeItem(STORAGE_KEY_REFRESH_REDIRECT_GUARD);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function installWidgetDrag(element, handle) {
if (!element || !handle) return;
handle.addEventListener('mousedown', event => {
if (event.button !== 0) return;
if (event.target.closest('button, input, textarea, select, a, label')) return;
const rect = element.getBoundingClientRect();
const startOffsetX = event.clientX - rect.left;
const startOffsetY = event.clientY - rect.top;
element.style.left = `${rect.left}px`;
element.style.top = `${rect.top}px`;
element.style.right = 'auto';
element.style.bottom = 'auto';
function moveAt(clientX, clientY) {
const maxLeft = Math.max(0, window.innerWidth - rect.width);
const maxTop = Math.max(0, window.innerHeight - rect.height);
const nextLeft = clamp(clientX - startOffsetX, 0, maxLeft);
const nextTop = clamp(clientY - startOffsetY, 0, maxTop);
element.style.left = `${nextLeft}px`;
element.style.top = `${nextTop}px`;
}
function onMouseMove(moveEvent) {
moveAt(moveEvent.clientX, moveEvent.clientY);
}
function stopDragging() {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', stopDragging);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', stopDragging);
event.preventDefault();
});
}
function abortRefreshFlow(reason = '') {
if (reason) {
console.warn('[itch.io stats] refresh aborted:', reason);
}
clearRefreshState();
}
function isSameGame(a, b) {
if (!a || !b) return false;
if (a.id && b.id && String(a.id) === String(b.id)) return true;
const aName = normalize(a.name);
const bName = normalize(b.name);
return !!aName && aName === bName;
}
function slugifyLabel(label) {
return normalize(label)
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function normalizePlatformLabel(label) {
const text = String(label || '').trim();
return getKnownPlatformLabel(text) || text;
}
function getKnownPlatformLabel(value) {
const key = normalize(value).replace(/^platform-/, '');
if (!key) return '';
if (key === 'html5' || key === 'web') return 'Web';
if (key === 'mobile-web' || key === 'mobileweb') return 'Mobile Web';
if (key === 'osx' || key === 'macos' || key === 'mac-os' || key === 'mac') return 'macOS';
if (key === 'windows' || key === 'win') return 'Windows';
if (key === 'linux') return 'Linux';
if (key === 'android') return 'Android';
if (key === 'ios' || key === 'i-os') return 'iOS';
return '';
}
function getSearchPathParts(pathname = location.pathname) {
return String(pathname || '').split('/').filter(Boolean);
}
function getSearchSeriesFromPath(pathname = location.pathname) {
const parts = getSearchPathParts(pathname);
if (parts[0] !== 'games') return '';
const seriesPart = parts[1] || '';
if (!isKnownSeriesPathPart(seriesPart)) return 'popular';
const match = SEARCH_SERIES.find(item => normalize(item.pathPart) === normalize(seriesPart));
return match?.key || 'popular';
}
function getSearchCategoryFromPath(pathname = location.pathname) {
const series = getSearchSeriesFromPath(pathname);
return series ? getSeriesLabel(series) : '';
}
function isIgnoredSearchSegment(value) {
const text = normalize(value);
return !!text && [
'games',
'tools',
'assets',
'jams',
'browse',
'new and popular',
'top sellers',
'top rated',
'newest',
'most recent'
].includes(text);
}
function getRecordCategory(record) {
const pathCategory = getSearchCategoryFromPath(record?.path || '');
if (pathCategory) return pathCategory;
const metaCategory = normalize(record?.meta?.category);
if (metaCategory === normalize('Most Recent')) return getSeriesLabel('newest');
const seriesMatch = SEARCH_SERIES.find(item => normalize(item.label) === metaCategory);
if (seriesMatch) return seriesMatch.label;
return '';
}
function isIgnoredMetaLabel(value) {
const text = normalize(value);
return !text || SEARCH_SERIES.some(item => normalize(item.label) === text) || text === 'most recent' || text === 'default' || text.includes('generative ai');
}
function normalizeLinkEntries(values) {
const seen = new Set();
const result = [];
for (const item of values.flat()) {
const label = String(item?.label || '').trim();
const href = String(item?.href || '').trim();
if (!label || !href || isIgnoredMetaLabel(label)) continue;
const key = `${normalize(label)}|${href}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ label, href });
}
return result;
}
function backfillMissingLinkEntries(type, labels, links) {
const existing = new Map(
normalizeLinkEntries(links).map(item => [normalize(item.label), item.href])
);
return normalizeLabelList(labels)
.map(label => ({
label,
href: existing.get(normalize(label)) || buildSearchUrlForLabel(type, label)
}))
.filter(item => item.href);
}
function findLinkEntryByToken(type, token, links) {
const wanted = normalize(token);
if (!wanted) return null;
return normalizeLinkEntries(links).find(item => {
try {
const pathname = new URL(item.href, 'https://itch.io').pathname;
const parts = pathname.split('/').filter(Boolean).map(part => normalize(part));
if (type === 'platform' && wanted === 'html5') {
return parts.includes('html5') || parts.includes('platform-web');
}
if (type === 'platform' && wanted === 'platform-web') {
return parts.includes('platform-web') || parts.includes('html5');
}
return parts.includes(wanted);
} catch (_) {
return false;
}
}) || null;
}
function canonicalizeLabelWithLinks(type, label, links) {
const text = String(label || '').trim();
if (!text) return '';
if (type === 'platform') {
const normalizedPlatform = normalizePlatformLabel(text);
const normalizedLinks = normalizeLinkEntries(links);
const exactPlatformMatch = normalizedLinks.find(item => normalize(item.label) === normalize(normalizedPlatform));
if (exactPlatformMatch) return normalizePlatformLabel(exactPlatformMatch.label);
if (normalize(text) === 'html5' || normalize(text) === 'web') return 'Web';
}
const normalizedText = normalize(text);
const normalizedLinks = normalizeLinkEntries(links);
const exactMatch = normalizedLinks.find(item => normalize(item.label) === normalizedText);
if (exactMatch) return type === 'platform' ? normalizePlatformLabel(exactMatch.label) : exactMatch.label;
const slugToken = type === 'platform' && normalizedText === 'html5'
? 'html5'
: `${type}-${slugifyLabel(text)}`;
const tokenMatch = findLinkEntryByToken(type, slugToken, normalizedLinks);
return tokenMatch?.label
? (type === 'platform' ? normalizePlatformLabel(tokenMatch.label) : tokenMatch.label)
: (type === 'platform' ? normalizePlatformLabel(text) : text);
}
function toAbsoluteItchUrl(href) {
try {
return new URL(href, 'https://itch.io').href;
} catch (_) {
return '';
}
}
function buildSeriesUrl(seriesKey, href) {
const absolute = toAbsoluteItchUrl(href || 'https://itch.io/games');
if (!absolute) return '';
try {
const url = new URL(absolute);
if (!url.pathname.startsWith('/games')) return absolute;
const parts = url.pathname.split('/').filter(Boolean);
const gamesIndex = parts.indexOf('games');
const filters = gamesIndex >= 0
? parts.slice(gamesIndex + 1).filter(part => part && !isKnownSeriesPathPart(part))
: [];
const seriesPathPart = getSeriesPathPart(seriesKey);
const nextParts = ['games'];
if (seriesPathPart) nextParts.push(seriesPathPart);
nextParts.push(...filters);
url.pathname = '/' + nextParts.join('/');
return url.href;
} catch (_) {
return absolute;
}
}
function toNewAndPopularUrl(href) {
return buildSeriesUrl('new-and-popular', href);
}
function parseSearchInfoFromHref(href) {
const absolute = toAbsoluteItchUrl(href);
if (!absolute) return { segments: [], searchPairs: [] };
try {
const url = new URL(absolute);
const parts = url.pathname.split('/').filter(Boolean);
const gameIndex = parts.indexOf('games');
if (gameIndex < 0) return { segments: [], searchPairs: [] };
return {
segments: parts
.slice(gameIndex + 1)
.filter(part => part && !isKnownSeriesPathPart(part)),
searchPairs: [...url.searchParams.entries()]
};
} catch (_) {
return { segments: [], searchPairs: [] };
}
}
function sortIntersectionParts(parts) {
const order = { genre: 0, platform: 1, tag: 2 };
return [...parts].sort((a, b) => {
const typeDelta = (order[a.type] ?? 99) - (order[b.type] ?? 99);
if (typeDelta) return typeDelta;
const labelDelta = normalize(a.label).localeCompare(normalize(b.label));
if (labelDelta) return labelDelta;
return String(a.href || '').localeCompare(String(b.href || ''));
});
}
function normalizeIntersectionParts(parts) {
const seen = new Set();
const result = [];
for (const part of sortIntersectionParts(Array.isArray(parts) ? parts : [])) {
const labelKey = normalize(part?.label);
const href = String(part?.href || '').trim();
if (!part?.type || !labelKey || !href || seen.has(labelKey)) continue;
seen.add(labelKey);
result.push(part);
}
return result;
}
function buildIntersectionId(parts) {
return normalizeIntersectionParts(parts)
.map(part => `${part.type}|${normalize(part.label)}|${String(part.href || '').trim()}`)
.join('||');
}
function buildIntersectionUrls(parts) {
const sorted = normalizeIntersectionParts(parts);
const segmentsByType = {
genre: [],
platform: [],
tag: []
};
const seenByType = {
genre: new Set(),
platform: new Set(),
tag: new Set()
};
const searchPairs = [];
const seenSearchPairs = new Set();
for (const part of sorted) {
const bucket = segmentsByType[part.type];
const seen = seenByType[part.type];
if (!bucket || !seen) continue;
const searchInfo = parseSearchInfoFromHref(part.href);
for (const segment of searchInfo.segments) {
const rawSegment = String(segment || '').trim();
const cleanedSegment = part.type === 'platform' && normalize(rawSegment) === 'html5'
? 'platform-web'
: rawSegment;
const key = normalize(cleanedSegment);
if (!key || seen.has(key)) continue;
seen.add(key);
bucket.push(cleanedSegment);
}
searchInfo.searchPairs.forEach(([key, value]) => {
const pairKey = `${String(key)}=${String(value)}`;
if (seenSearchPairs.has(pairKey)) return;
seenSearchPairs.add(pairKey);
searchPairs.push([key, value]);
});
}
const segments = [
...segmentsByType.genre,
...segmentsByType.platform,
...segmentsByType.tag
];
const path = segments.join('/');
if (!path) return { popularUrl: '', newPopularUrl: '' };
const search = new URLSearchParams();
searchPairs.forEach(([key, value]) => search.append(key, value));
const suffix = search.toString() ? `?${search.toString()}` : '';
return {
popularUrl: `https://itch.io/games/${path}${suffix}`,
newPopularUrl: `https://itch.io/games/new-and-popular/${path}${suffix}`
};
}
function buildSearchUrlForLabel(type, label) {
const slug = slugifyLabel(label);
if (type === 'main') {
const seriesMatch = SEARCH_SERIES.find(item => normalize(item.label) === normalize(label));
return buildSeriesUrl(seriesMatch?.key || 'popular', 'https://itch.io/games');
}
if (type === 'platform' && (normalize(label) === 'html5' || normalize(label) === 'web')) {
return 'https://itch.io/games/platform-web';
}
if (type === 'platform' && normalize(label) === 'mobile web') {
return 'https://itch.io/games/platform-mobile-web';
}
if (!slug) return '';
return `https://itch.io/games/${type}-${slug}`;
}
function removeRecordForContext(game, contextKey) {
if (!game || !contextKey) return;
const gameKey = getGameKey(game);
if (!gameKey) return;
const all = loadPositions();
const entry = all[gameKey];
if (!entry || !Array.isArray(entry.records)) return;
const currentHourKey = getLocalHourKey(Date.now());
entry.records = entry.records.filter(record => {
if (!record || record.contextKey !== contextKey) return true;
const recordHourKey = String(record.localHourKey || getLocalHourKey(record.foundAt) || '');
return recordHourKey !== currentHourKey;
});
all[gameKey] = entry;
savePositions(all);
}
function upsertGameRecord(game, record) {
if (!record) return record;
const safeGame = game || record.game || {
id: null,
name: targetText || 'Unknown game'
};
const gameKey = getGameKey(safeGame);
let all = loadPositions();
if (!all || typeof all !== 'object' || Array.isArray(all)) {
all = {};
}
if (!gameKey) return record;
if (!all[gameKey] || typeof all[gameKey] !== 'object') {
all[gameKey] = {
game: {
id: safeGame?.id || null,
name: safeGame?.name || targetText || 'Unknown game'
},
records: []
};
}
if (!Array.isArray(all[gameKey].records)) {
all[gameKey].records = [];
}
const records = all[gameKey].records;
const recordHourKey = String(record.localHourKey || getLocalHourKey(record.foundAt) || '');
const existingIndex = records.findIndex(x => {
if (!x) return false;
if (x.contextKey !== record.contextKey) return false;
const existingHourKey = String(x.localHourKey || getLocalHourKey(x.foundAt) || '');
return existingHourKey && existingHourKey === recordHourKey;
});
if (existingIndex >= 0) {
records[existingIndex] = record;
} else {
records.push(record);
}
records.sort((a, b) => {
const aRank = Number(a?.globalPosition || Number.MAX_SAFE_INTEGER);
const bRank = Number(b?.globalPosition || Number.MAX_SAFE_INTEGER);
if (aRank !== bRank) return aRank - bRank;
return Number(b?.foundAt || 0) - Number(a?.foundAt || 0);
});
all[gameKey].records = records;
savePositions(all);
return record;
}
function formatOverflowRank(loadedGamesCount) {
const loaded = Number(loadedGamesCount || 0);
if (loaded >= 1000) return '>1000';
if (loaded > 0) return '>' + loaded;
return '—';
}
function saveLimitReachedPosition(game) {
const safeGame = game || {
id: null,
name: targetText || 'Unknown game'
};
const foundAt = Date.now();
const record = {
url: location.href,
path: location.pathname,
contextKey: getSearchContextKey(),
tags: getSearchTags(),
page: getEstimatedCurrentPage(),
pageSize: lastNumItems || DEFAULT_PAGE_SIZE,
positionOnPage: 0,
globalPosition: Number.MAX_SAFE_INTEGER,
loadedGamesCount: getLoadedGamesCount(),
meta: getSearchMeta(),
foundAt,
localDayKey: getLocalDayKey(foundAt),
localHourKey: getLocalHourKey(foundAt),
limitReached: true,
displayRank: formatOverflowRank(getLoadedGamesCount()),
game: {
id: safeGame?.id || null,
name: safeGame?.name || targetText || 'Unknown game'
}
};
return upsertGameRecord(safeGame, record);
}
function getCurrentSearchUrlKey() {
return location.origin + location.pathname + location.search;
}
function getComparableSearchUrlKey(url = location.href) {
try {
const parsed = new URL(url, location.origin);
[...parsed.searchParams.keys()].forEach(key => {
if (key.startsWith('__cf_') || key.startsWith('cf_')) {
parsed.searchParams.delete(key);
}
});
const pathParts = parsed.pathname.split('/').filter(Boolean);
const gamesIndex = pathParts.indexOf('games');
const seriesKey = getSearchSeriesFromPath(parsed.pathname) || 'popular';
const filterParts = gamesIndex >= 0
? pathParts
.slice(gamesIndex + 1)
.filter(part => !isKnownSeriesPathPart(part))
.map(part => normalize(part))
.sort()
: pathParts.map(part => normalize(part));
const normalizedSearch = [...parsed.searchParams.entries()]
.map(([key, value]) => [String(key), String(value)])
.sort((a, b) => `${a[0]}=${a[1]}`.localeCompare(`${b[0]}=${b[1]}`));
const search = new URLSearchParams(normalizedSearch).toString();
const seriesPathPart = getSeriesPathPart(seriesKey);
const pathKey = gamesIndex >= 0
? `/games/${seriesPathPart ? `${seriesPathPart}/` : ''}${filterParts.join('/')}`
: `/${filterParts.join('/')}`;
return parsed.origin + pathKey.replace(/\/$/, '') + (search ? `?${search}` : '');
} catch (_) {
return String(url || '').replace(/([?&])__cf_[^&]+=[^&]*/g, '$1').replace(/[?&]$/, '');
}
}
function isSameSearchUrl(left, right = location.href) {
return getComparableSearchUrlKey(left) === getComparableSearchUrlKey(right);
}
function isCloudflareChallengePage() {
const text = normalize(document.body?.innerText || document.title || '');
return !!(
document.querySelector('[name="cf-turnstile-response"], .cf-turnstile, #challenge-running, #challenge-stage') ||
location.search.includes('__cf_chl') ||
text.includes('checking your browser') ||
text.includes('security check') ||
text.includes('проверки безопасности') ||
text.includes('один момент')
);
}
function isSearchPageReady() {
if (!isGamesPage) return true;
if (document.readyState !== 'complete') return false;
const hasGamesContent = !!(
document.querySelector('.game_cell, .browse_game_grid .game_cell, .game_grid_widget .game_cell') ||
document.querySelector('.browse_game_grid, .game_grid_widget, .game_grid')
);
if (hasGamesContent) return true;
if (isCloudflareChallengePage()) return false;
return false;
}
function registerRefreshRedirectAttempt(targetKey) {
const now = Date.now();
const guard = safeJsonParse(sessionStorage.getItem(STORAGE_KEY_REFRESH_REDIRECT_GUARD), {});
const sameTarget = guard?.targetKey === targetKey && now - Number(guard?.at || 0) < 12000;
const count = sameTarget ? Number(guard.count || 0) + 1 : 1;
sessionStorage.setItem(STORAGE_KEY_REFRESH_REDIRECT_GUARD, JSON.stringify({
targetKey,
count,
at: now
}));
return count;
}
function clearRefreshRedirectGuard() {
sessionStorage.removeItem(STORAGE_KEY_REFRESH_REDIRECT_GUARD);
}
function finishRefreshFlow() {
const state = loadRefreshState();
if (!state) return;
clearRefreshState();
if (state.summaryUrl && location.href !== state.summaryUrl) {
setTransferredPendingSummaryWidget();
location.href = state.summaryUrl;
}
}
function advanceRefreshFlow(result) {
const state = loadRefreshState();
if (!state || state.phase !== 'search') return;
const queue = Array.isArray(state.queue) ? state.queue : [];
const nextIndex = Number(state.index || 0) + 1;
state.index = nextIndex;
state.lastResult = result || null;
if (nextIndex >= queue.length) {
finishRefreshFlow();
return;
}
saveRefreshState(state);
location.href = queue[nextIndex].url;
}
function getRefreshQueueItemLabel(item) {
if (!item) return '';
const sectionLabels = {
default: 'Общее',
platforms: 'Платформа',
genres: 'Жанр',
tags: 'Тег',
intersections: 'Пересечение'
};
const section = sectionLabels[item.section] || item.section || '';
const series = getSeriesLabel(item.series);
const label = item.label && item.label !== 'Default' ? `: ${item.label}` : '';
return [section + label, series].filter(Boolean).join(' / ');
}
function getRefreshProgressText(extraLines = []) {
const state = loadRefreshState();
if (!state || state.phase !== 'search') return '';
const queue = Array.isArray(state.queue) ? state.queue : [];
const total = queue.length;
const index = Math.min(Math.max(Number(state.index || 0), 0), Math.max(total - 1, 0));
const item = queue[index] || null;
const completed = Math.min(index, total);
const percent = total ? Math.round((completed / total) * 100) : 0;
const gameName = state.game?.name || targetText || 'Unknown game';
const itemLabel = getRefreshQueueItemLabel(item);
return [
`Обновление аналитики: ${gameName}`,
`Готово: ${completed}/${total} (${percent}%)`,
itemLabel ? `Сейчас: ${itemLabel}` : '',
...extraLines
].filter(Boolean).join('\n');
}
function setSearchStatus(status, lines = [], fallbackText = '') {
if (!status) return;
const extraLines = Array.isArray(lines)
? lines.filter(Boolean)
: [lines].filter(Boolean);
const refreshText = getRefreshProgressText(extraLines);
status.textContent = refreshText || extraLines.join('\n') || fallbackText;
}
function getGameKey(game) {
if (game?.id) return `id:${game.id}`;
const name = normalize(game?.name || targetText);
if (name) return `name:${name}`;
return null;
}
function getCurrentGameIdFromSummaryUrl() {
const match = location.pathname.match(/^\/game\/summary\/([^/?#]+)/);
return match ? match[1] : null;
}
function getSearchTags() {
const pathParts = location.pathname
.split('/')
.filter(Boolean)
.filter(x => !isIgnoredSearchSegment(x));
const params = new URLSearchParams(location.search);
const tags = [];
pathParts.forEach(x => tags.push(x));
for (const [key, value] of params.entries()) {
if (value) tags.push(`${key}:${value}`);
else tags.push(key);
}
return tags.length ? tags.join('/') : 'без фильтров';
}
function getSearchMeta() {
const parts = location.pathname.split('/').filter(Boolean);
const params = new URLSearchParams(location.search);
const refreshState = loadRefreshState();
const queueItem = refreshState && refreshState.phase === 'search'
? (Array.isArray(refreshState.queue) ? refreshState.queue[Number(refreshState.index || 0)] : null)
: null;
const queueItemMatchesPage = !!(queueItem?.url && isSameSearchUrl(queueItem.url, location.href));
const gameMetaCache = loadGameMeta();
const gameMeta = (() => {
const game = refreshState?.game || targetGame;
if (!game) return null;
if (game.id && gameMetaCache['id:' + game.id]) return gameMetaCache['id:' + game.id];
const nameKey = normalize(game.name);
if (nameKey && gameMetaCache['name:' + nameKey]) return gameMetaCache['name:' + nameKey];
return null;
})();
const gameGenreLinks = Array.isArray(gameMeta?.genreLinks) ? gameMeta.genreLinks : [];
const gamePlatformLinks = Array.isArray(gameMeta?.platformLinks) ? gameMeta.platformLinks : [];
const gameTagLinks = Array.isArray(gameMeta?.tagLinks) ? gameMeta.tagLinks : [];
const category = getSearchCategoryFromPath(location.pathname);
const tags = [];
const genres = [];
const platforms = [];
function addBySlug(value) {
if (!value) return;
if (isIgnoredSearchSegment(value)) return;
const platformLabel = getKnownPlatformLabel(value);
if (platformLabel) {
platforms.push(platformLabel);
return;
}
if (value.startsWith('tag-')) {
tags.push(canonicalizeLabelWithLinks('tag', value.replace(/^tag-/, '').replaceAll('-', ' '), gameTagLinks));
return;
}
if (value.startsWith('genre-')) {
genres.push(canonicalizeLabelWithLinks('genre', value.replace(/^genre-/, '').replaceAll('-', ' '), gameGenreLinks));
return;
}
if (value.startsWith('platform-')) {
const tokenMatch = findLinkEntryByToken('platform', value, gamePlatformLinks);
platforms.push(normalizePlatformLabel(tokenMatch?.label || value.replace(/^platform-/, '').replaceAll('-', ' ')));
return;
}
const tokenMatch = findLinkEntryByToken('platform', value, gamePlatformLinks);
if (tokenMatch?.label) platforms.push(normalizePlatformLabel(tokenMatch.label));
}
parts.forEach(addBySlug);
for (const [key, value] of params.entries()) {
addBySlug(key);
addBySlug(value);
}
if (queueItemMatchesPage) {
if (queueItem.section === 'platforms') platforms.push(queueItem.label);
if (queueItem.section === 'genres') genres.push(queueItem.label);
if (queueItem.section === 'tags') tags.push(queueItem.label);
if (queueItem.section === 'intersections' && Array.isArray(queueItem.parts)) {
queueItem.parts.forEach(part => {
if (!part?.label) return;
if (part.type === 'platform') platforms.push(normalizePlatformLabel(part.label));
if (part.type === 'genre') genres.push(part.label);
if (part.type === 'tag') tags.push(part.label);
});
}
}
return {
category,
tags: [...new Set(tags)],
genres: [...new Set(genres)],
platforms: [...new Set(platforms)],
summaryLabel: queueItemMatchesPage ? queueItem.label : ''
};
}
function getSearchLabelsFromRecord(record) {
if (!record) return [];
const labels = [];
const recordCategory = getRecordCategory(record);
if (recordCategory) labels.push(recordCategory);
if (record.meta?.summaryLabel) labels.push(record.meta.summaryLabel);
if (Array.isArray(record.meta?.tags)) {
record.meta.tags
.filter(tag => !isIgnoredMetaLabel(tag))
.forEach(tag => labels.push(tag));
}
if (Array.isArray(record.meta?.genres)) {
record.meta.genres.forEach(genre => labels.push(genre));
}
if (Array.isArray(record.meta?.platforms)) {
record.meta.platforms.forEach(platform => labels.push(platform));
}
const rawTags = String(record.tags || '');
const parts = String(record.path || '')
.split('/')
.filter(Boolean);
if (!labels.length && recordCategory) {
labels.push(recordCategory);
}
parts
.filter(part => part.startsWith('tag-') || part.startsWith('genre-') || part.startsWith('platform-') || getKnownPlatformLabel(part))
.map(part => getKnownPlatformLabel(part) || part.replace(/^(tag|genre|platform)-/, '').replaceAll('-', ' '))
.filter(label => !isIgnoredMetaLabel(label))
.forEach(label => labels.push(label));
rawTags
.split(/[\/|]/)
.map(x => x.trim())
.filter(Boolean)
.filter(x => x !== 'без фильтров')
.filter(x => !x.startsWith('sort:'))
.map(x => getKnownPlatformLabel(x) || x.replace(/^(tag|genre|platform)-/, '').replaceAll('-', ' '))
.filter(tag => !isIgnoredMetaLabel(tag))
.forEach(tag => labels.push(tag));
return [...new Set(labels.map(x => String(x).trim()).filter(Boolean))];
}
function getPlatformLabelsFromRecordContext(record) {
if (!record) return [];
const labels = [];
if (Array.isArray(record.meta?.platforms)) {
record.meta.platforms.forEach(platform => labels.push(normalizePlatformLabel(platform)));
}
String(record.path || '')
.split('/')
.filter(Boolean)
.map(part => getKnownPlatformLabel(part))
.filter(Boolean)
.forEach(label => labels.push(label));
String(record.tags || '')
.split(/[\/|]/)
.map(x => getKnownPlatformLabel(String(x || '').trim()))
.filter(Boolean)
.forEach(label => labels.push(label));
return [...new Set(labels.map(x => String(x).trim()).filter(Boolean))];
}
function getSearchContextKey() {
const tags = getSearchTags();
const params = new URLSearchParams(location.search);
const sort = params.get('sort') || '';
return [
location.pathname,
tags,
sort ? `sort:${sort}` : ''
].filter(Boolean).join(' | ');
}
function getGameCards() {
return [
...document.querySelectorAll(`
.game_cell,
.browse_game_grid .game_cell,
.game_grid_widget .game_cell
`)
].filter(Boolean);
}
function getCardTitle(card) {
const titleNode =
card.querySelector?.('.title') ||
card.querySelector?.('.game_title') ||
card.querySelector?.('a.title') ||
card.querySelector?.('a.game_link') ||
card.querySelector?.('a[href]');
return normalize(
titleNode?.innerText ||
titleNode?.textContent ||
card.getAttribute?.('title') ||
card.getAttribute?.('aria-label') ||
card.innerText ||
card.textContent
);
}
function getCoverElement(card) {
return (
card.querySelector('.game_thumb') ||
card.querySelector('.thumb_link') ||
card.querySelector('.cover_link') ||
card.querySelector('.game_cover') ||
card.querySelector('a[href] img')?.closest('a') ||
card.querySelector('img')
);
}
function findGameByName(name) {
const query = normalize(name);
if (!query) return null;
for (const card of getGameCards()) {
const title = getCardTitle(card);
if (title.includes(query)) return card;
}
return null;
}
function getVisibleIndex(card) {
const cards = getGameCards();
return cards.indexOf(card) + 1;
}
function getLoadedGamesCount() {
return getGameCards().length;
}
function getEstimatedCurrentPage() {
if (lastLoadedPage) return Number(lastLoadedPage);
return Math.ceil(Math.max(1, getLoadedGamesCount()) / (lastNumItems || DEFAULT_PAGE_SIZE));
}
function buildRecord(card, game) {
const safeGame = game || {
id: null,
name: targetText || getCardTitle(card) || 'Unknown game'
};
const foundAt = Date.now();
const index = getVisibleIndex(card);
const pageSize = lastNumItems || DEFAULT_PAGE_SIZE;
const page = Math.ceil(Math.max(1, index) / pageSize);
const positionOnPage = ((index - 1) % pageSize) + 1;
const globalPosition = index;
const tags = getSearchTags();
const contextKey = getSearchContextKey();
return {
url: location.href,
path: location.pathname,
contextKey,
tags,
page,
pageSize,
positionOnPage,
globalPosition,
loadedGamesCount: getLoadedGamesCount(),
meta: getSearchMeta(),
foundAt,
localDayKey: getLocalDayKey(foundAt),
localHourKey: getLocalHourKey(foundAt),
game: {
id: safeGame?.id || null,
name: safeGame?.name || targetText || 'Unknown game'
}
};
}
function saveFoundPosition(card, game) {
if (!card) return null;
const safeGame = game || {
id: null,
name: targetText || getCardTitle(card) || 'Unknown game'
};
const record = buildRecord(card, safeGame);
return upsertGameRecord(safeGame, record);
}
function getFoundInfoElement(card) {
return foundInfoByCard.get(card) || card?.querySelector?.('.tm-found-info') || null;
}
function positionFoundInfoElement(card, info) {
if (!card || !info) return;
const rect = card.getBoundingClientRect();
const left = window.scrollX + rect.left + (rect.width / 2);
const top = window.scrollY + rect.bottom + 32;
info.style.left = `${left}px`;
info.style.top = `${top}px`;
}
function removeFoundInfoElement(card) {
const info = getFoundInfoElement(card);
if (!info) return;
if (typeof info._tmCleanupPosition === 'function') {
info._tmCleanupPosition();
}
info.remove();
foundInfoByCard.delete(card);
}
function findDashboardGameMatch(game) {
if (!game) return null;
loadDashboardGamesFromCache();
if (game.id) {
const byId = dashboardGames.find(item => String(item?.id) === String(game.id));
if (byId) return byId;
}
const wantedName = normalize(game.name);
if (!wantedName) return null;
return dashboardGames.find(item => normalize(item?.name) === wantedName) || null;
}
function getSummaryUrlForGame(game) {
const cachedGame = findDashboardGameMatch(game);
const gameId = game?.id || cachedGame?.id || null;
if (!gameId) return '';
return `https://itch.io/game/summary/${encodeURIComponent(gameId)}`;
}
function buildSummaryFocusTarget(record) {
if (!record || typeof record !== 'object') return null;
const meta = record.meta && typeof record.meta === 'object' ? record.meta : {};
const series = getRecordSeries(record) || getSearchSeriesFromPath(record.path || '') || 'popular';
const summaryLabel = String(meta.summaryLabel || '').trim();
const tags = Array.isArray(meta.tags) ? meta.tags.filter(Boolean) : [];
const genres = Array.isArray(meta.genres) ? meta.genres.filter(Boolean) : [];
const platforms = Array.isArray(meta.platforms) ? meta.platforms.filter(Boolean) : [];
if (isDefaultSummaryRecord(record)) {
return {
section: 'default',
label: 'Default',
series
};
}
if (summaryLabel) {
if (summaryLabel.includes(' + ')) {
return {
section: 'intersections',
label: summaryLabel,
series
};
}
if (platforms.some(item => normalize(item) === normalize(summaryLabel))) {
return { section: 'platforms', label: summaryLabel, series };
}
if (genres.some(item => normalize(item) === normalize(summaryLabel))) {
return { section: 'genres', label: summaryLabel, series };
}
if (tags.some(item => normalize(item) === normalize(summaryLabel))) {
return { section: 'tags', label: summaryLabel, series };
}
}
const pathParts = getSearchPathParts(record.path || '');
const gameIndex = pathParts.indexOf('games');
const filterParts = gameIndex >= 0
? pathParts.slice(gameIndex + 1).filter(part => part && !isKnownSeriesPathPart(part))
: [];
for (const part of filterParts) {
if (part.startsWith('genre-')) {
return {
section: 'genres',
label: genres[0] || part.replace(/^genre-/, '').replaceAll('-', ' '),
series
};
}
if (part.startsWith('tag-')) {
return {
section: 'tags',
label: tags[0] || part.replace(/^tag-/, '').replaceAll('-', ' '),
series
};
}
const knownPlatform = getKnownPlatformLabel(part);
if (part.startsWith('platform-') || knownPlatform) {
return {
section: 'platforms',
label: platforms[0] || knownPlatform || normalizePlatformLabel(part.replace(/^platform-/, '').replaceAll('-', ' ')),
series
};
}
}
if (genres[0]) return { section: 'genres', label: genres[0], series };
if (platforms[0]) return { section: 'platforms', label: platforms[0], series };
if (tags[0]) return { section: 'tags', label: tags[0], series };
return {
section: 'default',
label: 'Default',
series
};
}
function placeFoundInfoUnderCover(card, record, game) {
if (!record || !card) return null;
removeFoundInfoElement(card);
const safeGame = game || record.game || {
id: null,
name: targetText || 'Unknown game'
};
const meta = getSearchMeta();
const mainChips = `
<div class="tm-main-chip-row">
<span class="tm-main-chip ${meta.category === 'Popular' ? 'tm-active' : ''}">Popular🔥</span>
<span class="tm-main-chip ${meta.category === 'New & Popular' ? 'tm-active' : ''}">New & Popular🌟</span>
</div>
`;
const secondaryChips = `
<div class="tm-sub-chip-row">
<span class="tm-sub-chip ${meta.category === 'Top Rated' ? 'tm-active' : ''}">Top Rated</span>
<span class="tm-sub-chip ${meta.category === 'Top Sellers' ? 'tm-active' : ''}">Top Sellers</span>
</div>
`;
const chips = [
...meta.tags.map(tag => `<span class="tm-chip">${escapeHtml(tag)}</span>`),
...meta.genres.map(genre => `<span class="tm-chip">${escapeHtml(genre)}</span>`),
...meta.platforms.map(platform => `<span class="tm-chip">${escapeHtml(platform)}</span>`)
].filter(Boolean).join('');
const info = document.createElement('div');
info.className = 'tm-found-info';
const pos = Number(record.globalPosition || 999999);
let rankClass = 'tm-found-rank-default';
let rankIcon = '';
if (pos === 1) {
rankClass = 'tm-found-rank-diamond';
rankIcon = '💎';
} else if (pos >= 2 && pos <= 10) {
rankClass = 'tm-found-rank-gold';
rankIcon = '🥇';
} else if (pos >= 11 && pos <= 20) {
rankClass = 'tm-found-rank-silver';
rankIcon = '🥈';
} else if (pos >= 21 && pos <= 30) {
rankClass = 'tm-found-rank-bronze';
rankIcon = '🥉';
}
info.classList.add(rankClass);
info.innerHTML = `
<span class="tm-big-place">
${rankIcon ? `<span class="tm-rank-icon">${rankIcon}</span>` : ''}
<span>#${record.globalPosition}</span>
</span>
<span class="tm-small-line">${escapeHtml(safeGame?.name || targetText)}</span>
<span class="tm-small-line">Page: ${record.page}</span>
${mainChips}
${secondaryChips}
${chips ? `<div class="tm-chip-row">${chips}</div>` : ''}
`;
const summaryUrl = getSummaryUrlForGame(safeGame);
const focusTarget = buildSummaryFocusTarget(record);
document.body.appendChild(info);
positionFoundInfoElement(card, info);
const updatePosition = () => positionFoundInfoElement(card, info);
window.addEventListener('scroll', updatePosition, { passive: true });
window.addEventListener('resize', updatePosition);
info._tmCleanupPosition = () => {
window.removeEventListener('scroll', updatePosition);
window.removeEventListener('resize', updatePosition);
};
foundInfoByCard.set(card, info);
if (summaryUrl && focusTarget) {
info.classList.add('tm-clickable');
info.title = 'Открыть аналитику и перейти к записи';
info.addEventListener('click', () => {
setTransferredPendingSummaryWidget();
setTransferredPendingSummaryFocus(focusTarget);
location.href = summaryUrl;
});
}
return info;
}
function playConfettiFromElement(element) {
if (!element || confettiPlayed.has(element)) return;
confettiPlayed.add(element);
const rect = element.getBoundingClientRect();
const originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height * 0.5;
const layer = document.createElement('div');
layer.className = 'tm-confetti-layer';
layer.style.left = `${originX}px`;
layer.style.top = `${originY}px`;
const colors = [
'#ff004c',
'#ff9900',
'#ffe600',
'#00d26a',
'#00b7ff',
'#9b5cff',
'#ffffff',
'#ff7de9',
'#7df9ff'
];
for (let i = 0; i < 68; i++) {
const piece = document.createElement('div');
piece.className = 'tm-confetti-piece';
const angle = Math.random() * Math.PI * 2;
const distance = 340 + Math.random() * 780;
const dx = Math.cos(angle) * distance;
const dy = -220 - Math.random() * 620 + Math.sin(angle) * 210;
const dz = `${-700 + Math.random() * 1400}px`;
piece.style.background = colors[Math.floor(Math.random() * colors.length)];
piece.style.left = '0px';
piece.style.top = '0px';
piece.style.setProperty('--dx', `${dx}px`);
piece.style.setProperty('--dy', `${dy}px`);
piece.style.setProperty('--dz', dz);
piece.style.setProperty('--rx', `${360 + Math.random() * 900}deg`);
piece.style.setProperty('--ry', `${360 + Math.random() * 900}deg`);
piece.style.setProperty('--rz', `${360 + Math.random() * 900}deg`);
piece.style.animationDelay = `${Math.random() * 90}ms`;
piece.style.opacity = `${0.8 + Math.random() * 0.2}`;
layer.appendChild(piece);
}
document.body.appendChild(layer);
setTimeout(() => {
layer.remove();
}, 9800);
}
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForScrollToSettle(stableMs = 220, timeoutMs = 2200) {
const startedAt = Date.now();
let lastY = window.scrollY;
let stableSince = Date.now();
while (Date.now() - startedAt < timeoutMs) {
await wait(60);
const currentY = window.scrollY;
if (Math.abs(currentY - lastY) <= 1) {
if (Date.now() - stableSince >= stableMs) return;
} else {
lastY = currentY;
stableSince = Date.now();
}
}
}
async function scrollToCardAndPlayConfetti(card, options = {}) {
if (!card) return;
const fast = options.fast === true;
card.scrollIntoView({
behavior: fast ? 'auto' : 'smooth',
block: fast ? 'nearest' : 'center'
});
if (fast) {
await wait(40);
} else {
await waitForScrollToSettle();
}
const info = getFoundInfoElement(card);
playConfettiFromElement(info || card);
}
function getStoredRecordsForGame(game) {
if (!game) return [];
const positions = loadPositions();
const gameKey = getGameKey(game);
const directRecords = Array.isArray(positions?.[gameKey]?.records)
? positions[gameKey].records
: [];
if (directRecords.length) return directRecords;
const wantedName = normalize(game?.name);
if (!wantedName) return [];
return Object.values(positions || {})
.flatMap(entry => Array.isArray(entry?.records) ? entry.records : [])
.filter(record => {
const recordName = normalize(record?.game?.name);
return recordName && (recordName === wantedName || recordName.includes(wantedName) || wantedName.includes(recordName));
});
}
function getBestStoredRecordForCurrentSearch(game) {
const records = getStoredRecordsForGame(game);
if (!records.length) return null;
const currentContextKey = getSearchContextKey();
const currentUrlKey = getComparableSearchUrlKey();
const scored = records.map(record => {
let score = 0;
if (record?.contextKey === currentContextKey) score += 6;
if (getComparableSearchUrlKey(record?.url || '') === currentUrlKey) score += 8;
if (record?.path === location.pathname) score += 2;
if (Number(record?.loadedGamesCount || 0) > 0) score += 1;
return { record, score };
});
scored.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
const aLoaded = Number(a.record?.loadedGamesCount || 0);
const bLoaded = Number(b.record?.loadedGamesCount || 0);
if (aLoaded !== bLoaded) return bLoaded - aLoaded;
const aRank = Number(a.record?.globalPosition || 0);
const bRank = Number(b.record?.globalPosition || 0);
if (aRank !== bRank) return bRank - aRank;
return Number(b.record?.foundAt || 0) - Number(a.record?.foundAt || 0);
});
return scored[0]?.record || null;
}
async function jumpToLastLoadedGame(game, status = null) {
const record = getBestStoredRecordForCurrentSearch(game);
if (!record) return false;
const cards = getGameCards();
if (!cards.length) return false;
const rememberedIndex = Math.max(
Number(record?.loadedGamesCount || 0),
Number(record?.globalPosition || 0),
1
);
const anchorIndex = Math.min(rememberedIndex, cards.length) - 1;
const anchorCard = cards[anchorIndex] || cards[cards.length - 1];
if (!anchorCard) return false;
if (status) {
setSearchStatus(status, [
'Перехожу к последней загруженной игре...',
`Пролистано игр: ${Math.min(rememberedIndex, cards.length)}`
]);
}
anchorCard.scrollIntoView({
behavior: 'auto',
block: 'end'
});
await wait(50);
window.scrollBy({
top: Math.max(SCROLL_STEP, Math.round(window.innerHeight * 0.9)),
behavior: 'auto'
});
await wait(120);
return true;
}
async function jumpToCurrentListTail(status = null) {
const cards = getGameCards();
const lastCard = cards[cards.length - 1];
if (!lastCard) return false;
if (status) {
setSearchStatus(status, [
'Быстро перехожу к концу загруженного списка...',
`Пролистано игр: ${cards.length}`
]);
}
lastCard.scrollIntoView({
behavior: 'auto',
block: 'end'
});
await wait(20);
window.scrollBy({
top: Math.max(window.innerHeight, SCROLL_STEP),
behavior: 'auto'
});
await wait(80);
return true;
}
async function waitForSearchPageReady(status = null, timeoutMs = 90000) {
if (!isGamesPage || isSearchPageReady()) return true;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (status) {
setSearchStatus(status, isCloudflareChallengePage()
? 'Жду завершения проверки Cloudflare...'
: 'Жду полной загрузки списка игр...');
}
await sleep(500);
if (isSearchPageReady()) return true;
}
return false;
}
async function waitForCloudflareChallengeToClear(status = null, timeoutMs = 180000) {
if (!isCloudflareChallengePage()) return true;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (status) {
setSearchStatus(status, 'Cloudflare просит проверку. Не скроллю страницу и жду завершения challenge...');
}
await sleep(500);
if (!isCloudflareChallengePage()) {
await sleep(1200);
return true;
}
}
return false;
}
function installTilt(card) {
if (!card || tiltInstalled.has(card)) return;
tiltInstalled.add(card);
card.classList.add('tm-tilt-card');
function resetTilt() {
if (card.classList.contains('tm-rainbow-found')) {
card.style.transform = `
perspective(900px)
translateY(-6px)
scale(1.045)
`;
} else {
card.style.transform = '';
}
}
card.addEventListener('mousemove', event => {
const baseRect = card.getBoundingClientRect();
if (!baseRect.width || !baseRect.height) return;
const x = Math.max(0, Math.min(1, (event.clientX - baseRect.left) / baseRect.width));
const y = Math.max(0, Math.min(1, (event.clientY - baseRect.top) / baseRect.height));
const rotateY = (x - 0.5) * 14;
const rotateX = -(y - 0.5) * 14;
const scale = card.classList.contains('tm-rainbow-found') ? 1.045 : 1.02;
card.style.transform = `
perspective(900px)
rotateX(${rotateX}deg)
rotateY(${rotateY}deg)
translateY(-6px)
scale(${scale})
`;
});
card.addEventListener('mouseleave', resetTilt);
}
function highlightCard(card, game, passive = false) {
if (!card) return null;
const safeGame = game || {
id: null,
name: targetText || getCardTitle(card) || 'Unknown game'
};
installTilt(card);
card.classList.add('tm-rainbow-found');
if (!card.style.transform) {
card.style.transform = `
perspective(900px)
translateY(-6px)
scale(1.045)
`;
}
const record = saveFoundPosition(card, safeGame);
if (record) {
placeFoundInfoUnderCover(card, record, safeGame, passive);
}
const status = document.querySelector('#tm-itch-status');
if (status && !passive && record) {
status.textContent =
`Найдено. Позиция сохранена\n` +
`Page: ${record.page}, место: ${record.globalPosition}`;
}
return record;
}
function updateStatusScrolling() {
const status = document.querySelector('#tm-itch-status');
if (!status) return;
const page = getEstimatedCurrentPage();
const loaded = getLoadedGamesCount();
setSearchStatus(status, [
`Page: ${page}/${MAX_SEARCH_PAGE}`,
`Пролистано игр: ${loaded}`
], (
`Листаю страницу...\n` +
`Page: ${page}/${MAX_SEARCH_PAGE}\n` +
`Пролистано игр: ${loaded}`
));
}
function passiveScanOwnGames() {
if (!isGamesPage) return;
loadDashboardGamesFromCache();
if (!dashboardGames.length) return;
for (const game of dashboardGames) {
if (!game?.name) continue;
const card = findGameByName(game.name);
if (!card) continue;
if (passiveHighlighted.has(card)) continue;
passiveHighlighted.add(card);
highlightCard(card, game, true);
scrollToCardAndPlayConfetti(card);
}
}
async function searchLoop() {
const button = document.querySelector('#tm-itch-search');
const status = document.querySelector('#tm-itch-status');
const refreshState = loadRefreshState();
const refreshActive = !!(refreshState && refreshState.phase === 'search' && isSameGame(refreshState.game, targetGame));
searching = true;
pausedByHiddenTab = false;
button.textContent = 'Остановить';
updateStatusScrolling();
const pageReady = await waitForSearchPageReady(status);
if (!pageReady) {
searching = false;
button.textContent = 'Найти и листать';
status.textContent = 'Поиск остановлен: список игр не загрузился полностью.';
return;
}
const initiallyFound = findGameByName(targetText);
if (!initiallyFound) {
await jumpToLastLoadedGame(targetGame, status);
}
let lastScrollY = -1;
let lastScrollHeight = -1;
let lastLoadedCount = -1;
let lastKnownPage = Number(lastLoadedPage || 0);
let stuckCount = 0;
while (searching) {
if (isCloudflareChallengePage()) {
const challengeCleared = await waitForCloudflareChallengeToClear(status);
if (!challengeCleared) {
searching = false;
button.textContent = 'Найти и листать';
status.textContent = 'Поиск остановлен: Cloudflare слишком долго держит проверку.';
return;
}
const pageReadyAfterChallenge = await waitForSearchPageReady(status);
if (!pageReadyAfterChallenge) {
searching = false;
button.textContent = 'Найти и листать';
status.textContent = 'Поиск остановлен: после Cloudflare список игр не загрузился.';
return;
}
updateStatusScrolling();
continue;
}
if (document.hidden) {
pausedByHiddenTab = true;
setSearchStatus(status, [
'Пауза: вкладка скрыта.',
'Поиск продолжится, когда ты вернёшься.'
]);
await waitUntilVisible();
if (!searching) break;
pausedByHiddenTab = false;
updateStatusScrolling();
}
passiveScanOwnGames();
const found = findGameByName(targetText);
if (found) {
const safeTargetGame = targetGame || {
id: null,
name: targetText || getCardTitle(found) || 'Unknown game'
};
const record = highlightCard(found, safeTargetGame, false);
scrollToCardAndPlayConfetti(found);
searching = false;
button.textContent = 'Найти и листать';
if (refreshActive) {
setTimeout(() => {
advanceRefreshFlow({
status: 'found',
contextKey: record?.contextKey || getSearchContextKey(),
url: getCurrentSearchUrlKey()
});
}, 250);
}
return;
}
const currentPage = getEstimatedCurrentPage();
if (currentPage >= MAX_SEARCH_PAGE) {
const safeTargetGame = targetGame || {
id: null,
name: targetText || 'Unknown game'
};
saveLimitReachedPosition(safeTargetGame);
searching = false;
button.textContent = 'Найти и листать';
status.textContent =
`Остановлено: достигнут лимит ${MAX_SEARCH_PAGE} page.\n` +
`Пролистано игр: ${getLoadedGamesCount()}`;
if (refreshActive && targetGame) {
removeRecordForContext(targetGame, getSearchContextKey());
saveLimitReachedPosition(targetGame);
setTimeout(() => {
advanceRefreshFlow({
status: 'not-found',
contextKey: getSearchContextKey(),
url: getCurrentSearchUrlKey()
});
}, 250);
}
return;
}
const beforeY = window.scrollY;
const beforeHeight = document.body.scrollHeight;
const beforeLoaded = getLoadedGamesCount();
const beforePage = Number(lastLoadedPage || 0);
await jumpToCurrentListTail(status);
const afterY = window.scrollY;
const afterHeight = document.body.scrollHeight;
const afterLoaded = getLoadedGamesCount();
const afterPage = Number(lastLoadedPage || 0);
const changedDuringBurst =
afterY !== beforeY ||
afterHeight !== beforeHeight ||
afterLoaded !== beforeLoaded ||
afterPage !== beforePage;
await sleep(SCROLL_INTERVAL);
if (!searching) break;
if (isCloudflareChallengePage()) continue;
passiveScanOwnGames();
updateStatusScrolling();
const currentY = window.scrollY;
const currentHeight = document.body.scrollHeight;
const currentLoaded = getLoadedGamesCount();
const currentKnownPage = Number(lastLoadedPage || 0);
if (
!changedDuringBurst &&
currentY === lastScrollY &&
currentHeight === lastScrollHeight &&
currentLoaded === lastLoadedCount &&
currentKnownPage === lastKnownPage
) stuckCount++;
else stuckCount = 0;
lastScrollY = currentY;
lastScrollHeight = currentHeight;
lastLoadedCount = currentLoaded;
lastKnownPage = currentKnownPage;
if (stuckCount >= 6) {
searching = false;
button.textContent = 'Найти и листать';
status.textContent =
`Не найдено до конца страницы.\n` +
`Пролистано игр: ${getLoadedGamesCount()}`;
if (refreshActive) {
removeRecordForContext(targetGame, getSearchContextKey());
setTimeout(() => {
advanceRefreshFlow({
status: 'not-found',
contextKey: getSearchContextKey(),
url: getCurrentSearchUrlKey()
});
}, 250);
}
return;
}
}
if (button) button.textContent = 'Найти и листать';
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function waitUntilVisible() {
return new Promise(resolve => {
if (!document.hidden) {
resolve();
return;
}
const handler = () => {
if (!document.hidden) {
document.removeEventListener('visibilitychange', handler);
resolve();
}
};
document.addEventListener('visibilitychange', handler);
});
}
function stopSearch(reason = 'Остановлено') {
searching = false;
pausedByHiddenTab = false;
clearRefreshState();
const button = document.querySelector('#tm-itch-search');
const status = document.querySelector('#tm-itch-status');
if (button) button.textContent = 'Найти и листать';
if (status) status.textContent = reason;
}
function startSearch(gameOrName) {
if (typeof gameOrName === 'string') {
targetText = gameOrName;
targetGame = {
id: null,
name: gameOrName
};
} else {
targetGame = gameOrName || null;
targetText = gameOrName?.name || '';
}
const input = document.querySelector('#tm-itch-query');
const button = document.querySelector('#tm-itch-search');
const status = document.querySelector('#tm-itch-status');
const refreshState = loadRefreshState();
const refreshActive = !!(refreshState && refreshState.phase === 'search' && isSameGame(refreshState.game, targetGame));
input.value = targetText;
if (!targetText.trim()) {
status.textContent = 'Введите название игры';
return;
}
if (searching) {
stopSearch('Остановлено');
return;
}
searchLoop().catch(error => {
console.error('[itch.io stats] searchLoop failed:', error);
if (refreshActive) abortRefreshFlow('search loop failed');
searching = false;
pausedByHiddenTab = false;
button.textContent = 'Найти и листать';
status.textContent =
`Поиск остановлен из-за ошибки.\n` +
`Подробности смотри в Console.`;
});
}
async function maybeResumeRefreshFlow() {
const state = loadRefreshState();
if (!state || !state.game) return;
if (isSummaryPage) {
abortRefreshFlow('summary page should not auto-resume refresh');
return;
}
if (isPublicGamePage && state.phase === 'fetch-meta') {
cachePublicGameMetaIfAvailable();
return;
}
if (!isGamesPage || state.phase !== 'search' || refreshAutostarted) return;
const queue = Array.isArray(state.queue) ? state.queue : [];
const item = queue[Number(state.index || 0)];
if (!item) {
abortRefreshFlow('missing queue item');
return;
}
const currentKey = getComparableSearchUrlKey();
const targetKey = getComparableSearchUrlKey(item.url);
if (currentKey !== targetKey) {
const redirectCount = registerRefreshRedirectAttempt(targetKey);
if (redirectCount >= 3) {
abortRefreshFlow('refresh redirect loop detected');
const status = document.querySelector('#tm-itch-status');
if (status) {
status.textContent = 'Обновление остановлено: страница несколько раз открывалась заново до старта поиска.';
}
return;
}
location.href = item.url;
return;
}
clearRefreshRedirectGuard();
await waitForSearchPageReady(document.querySelector('#tm-itch-status'));
if (!isSearchPageReady()) return;
refreshAutostarted = true;
setTimeout(() => {
startSearch(state.game);
}, 250);
}
function parseAjaxResponse(data) {
if (!data || typeof data !== 'object') return;
if ('page' in data && 'num_items' in data && 'content' in data) {
lastLoadedPage = Number(data.page);
lastNumItems = Number(data.num_items) || DEFAULT_PAGE_SIZE;
const status = document.querySelector('#tm-itch-status');
if (status && searching && !pausedByHiddenTab) {
setSearchStatus(status, [
`Загружена page ${lastLoadedPage}/${MAX_SEARCH_PAGE}`,
`Пролистано игр: ${getLoadedGamesCount()}`
]);
}
setTimeout(() => {
installTiltForVisibleCards();
passiveScanOwnGames();
}, 80);
}
}
function installAjaxObserver() {
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const clone = response.clone();
const contentType = clone.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
clone.json().then(parseAjaxResponse).catch(() => {});
}
} catch (_) {}
return response;
};
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__tmUrl = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
this.addEventListener('load', function () {
try {
const contentType = this.getResponseHeader('content-type') || '';
if (contentType.includes('application/json')) {
parseAjaxResponse(JSON.parse(this.responseText));
}
} catch (_) {}
});
return originalSend.apply(this, arguments);
};
}
function extractGameIdFromDashboardRow(row) {
const links = [...row.querySelectorAll('a[href]')];
for (const link of links) {
const text = normalize(link.textContent);
const href = link.href || link.getAttribute('href') || '';
if (text === 'analytics' || href.includes('/game/analytics/')) {
const match = href.match(/\/game\/analytics\/([^/?#]+)/);
if (match) return match[1];
}
}
for (const link of links) {
const href = link.href || link.getAttribute('href') || '';
let match = href.match(/\/game\/summary\/([^/?#]+)/);
if (match) return match[1];
match = href.match(/\/game\/edit\/([^/?#]+)/);
if (match) return match[1];
match = href.match(/[?&]game_id=([^&#]+)/);
if (match) return match[1];
}
return null;
}
function extractPublishedGamesFromDashboardPage() {
const rows = [...document.querySelectorAll('.game_list .game_row, .game_row')];
const games = [];
for (const row of rows) {
const rowText = normalize(row.textContent);
const isPublished = rowText.includes('published');
if (!isPublished) continue;
const nameNode =
row.querySelector('.game_title .game_link') ||
row.querySelector('.game_title a.game_link') ||
row.querySelector('a.game_link') ||
row.querySelector('.game_title a') ||
row.querySelector('.game_title') ||
row.querySelector('.title');
const name = String(nameNode?.textContent || '').trim();
if (!name) continue;
const id = extractGameIdFromDashboardRow(row);
const gameUrlNode =
row.querySelector('.game_title a.game_link') ||
row.querySelector('a.game_link') ||
nameNode;
const href = gameUrlNode?.href || gameUrlNode?.getAttribute('href') || '';
if (!games.some(g => normalize(g.name) === normalize(name))) {
games.push({
id,
name,
href,
cachedAt: Date.now()
});
}
}
return games;
}
function cacheDashboardGamesIfOnDashboard() {
if (!isDashboardPage) return;
const games = extractPublishedGamesFromDashboardPage();
if (games.length) {
dashboardGames = games;
saveDashboardGames(games);
console.log('[itch.io stats] cached published games:', games);
}
}
function createSearchWidget() {
loadDashboardGamesFromCache();
const openButton = document.createElement('button');
openButton.id = 'tm-itch-open-button';
openButton.textContent = '🔎';
openButton.title = 'Открыть itch.io stats';
document.body.appendChild(openButton);
const widget = document.createElement('div');
widget.id = 'tm-itch-finder';
widget.innerHTML = `
<div class="tm-widget-head">
<div class="tm-widget-title">itch.io stats</div>
<button id="tm-itch-collapse" title="Свернуть">×</button>
</div>
<div class="tm-widget-scroll-body">
<input id="tm-itch-query" placeholder="Название игры..." />
<button id="tm-itch-search">Найти и листать</button>
<div id="tm-itch-status">Введите название игры</div>
<div class="tm-games-title">Мои публичные игры</div>
<div id="tm-dashboard-games">Кэша нет. Открой Dashboard.</div>
</div>
`;
document.body.appendChild(widget);
installWidgetDrag(widget, widget.querySelector('.tm-widget-head'));
const input = document.querySelector('#tm-itch-query');
const button = document.querySelector('#tm-itch-search');
const status = document.querySelector('#tm-itch-status');
const dashboardList = document.querySelector('#tm-dashboard-games');
const collapseButton = document.querySelector('#tm-itch-collapse');
const pendingSearch = transferredPayload?.pendingSearch;
const refreshProgress = getRefreshProgressText();
if (status && refreshProgress) {
status.textContent = refreshProgress;
}
function setCollapsed(collapsed, options = {}) {
const shouldStopSearch = options.stopSearch !== false;
const shouldPersist = options.persist !== false;
if (collapsed && shouldStopSearch && searching) {
stopSearch('Остановлено: виджет свёрнут');
}
widget.classList.toggle('tm-collapsed', collapsed);
openButton.classList.toggle('tm-visible', collapsed);
if (shouldPersist) {
localStorage.setItem(STORAGE_KEY_COLLAPSED, collapsed ? '1' : '0');
}
}
collapseButton.addEventListener('click', () => setCollapsed(true));
openButton.addEventListener('click', () => setCollapsed(false));
const collapsedState = localStorage.getItem(STORAGE_KEY_COLLAPSED);
if (refreshProgress) {
setCollapsed(false, { stopSearch: false, persist: false });
} else if (collapsedState === null) {
setCollapsed(true, { stopSearch: false });
} else {
setCollapsed(collapsedState === '1', { stopSearch: false });
}
button.addEventListener('click', () => startSearch(input.value));
if (pendingSearch?.name) {
transferredPayload.pendingSearch = null;
setTimeout(() => startSearch(pendingSearch), 0);
}
input.addEventListener('keydown', event => {
if (event.key === 'Enter') button.click();
});
if (!dashboardGames.length) {
dashboardList.textContent = 'Кэша нет. Открой Dashboard.';
} else {
dashboardList.innerHTML = '';
dashboardGames.forEach(game => {
const item = document.createElement('div');
item.className = 'tm-game-item';
item.textContent = game.name;
item.title = 'Клик — искать эту игру';
item.addEventListener('click', () => startSearch(game));
dashboardList.appendChild(item);
});
}
setTimeout(() => {
installTiltForVisibleCards();
passiveScanOwnGames();
}, 300);
}
function installTiltForVisibleCards() {
if (!isGamesPage) return;
for (const card of getGameCards()) {
installTilt(card);
}
}
function getPublicGameIdFromPage() {
const root = document.querySelector('[id^="view_html_game_"]');
const match = root?.id?.match(new RegExp('view_html_game_([0-9]+)'));
return match ? match[1] : null;
}
function extractInfoPanelMetaFromPublicGamePage() {
const gameId = getPublicGameIdFromPage();
if (!gameId) return null;
const root = document.querySelector('#view_html_game_' + CSS.escape(gameId));
const panel =
root?.querySelector('div.columns div.left_col.column div.more_information_toggle.open div.info_panel_wrapper') ||
root?.querySelector('.info_panel_wrapper') ||
document.querySelector('.info_panel_wrapper');
if (!panel) return null;
const result = {
id: gameId,
url: location.href,
name: document.querySelector('.game_title')?.textContent?.trim() || document.querySelector('h1')?.textContent?.trim() || document.title.trim(),
genres: [],
platforms: [],
tags: [],
genreLinks: [],
platformLinks: [],
tagLinks: [],
cachedAt: Date.now()
};
const rows = [...panel.querySelectorAll('tr, .info_panel_row, .info_row, .metadata_row, .info_panel_widget')];
const typedLinkSections = new Map();
for (const row of rows) {
const type = detectMetaType(row);
if (!type) continue;
[...row.querySelectorAll('a[href]')].forEach(link => {
typedLinkSections.set(link, type);
});
}
const links = [...panel.querySelectorAll('a[href]')];
for (const link of links) {
const href = link.getAttribute('href') || '';
const value = String(link.textContent || '').trim();
if (!value) continue;
const forcedType = typedLinkSections.get(link) || '';
if (forcedType === 'genres' || (!forcedType && (href.includes('/games/genre-') || href.includes('genre-')))) {
result.genres.push(value);
result.genreLinks.push({ label: value, href: toAbsoluteItchUrl(href) });
}
if (forcedType === 'platforms' || (!forcedType && (href.includes('/games/platform-') || href.includes('platform-') || href.includes('/games/html5')))) {
result.platforms.push(normalizePlatformLabel(value));
result.platformLinks.push({ label: normalizePlatformLabel(value), href: toAbsoluteItchUrl(href) });
}
if (forcedType === 'tags' || (!forcedType && (href.includes('/games/tag-') || href.includes('tag-')))) {
result.tags.push(value);
result.tagLinks.push({ label: value, href: toAbsoluteItchUrl(href) });
}
}
function detectMetaType(row) {
const cells = [...row.children].filter(Boolean);
const labelCandidates = [
row.querySelector('th'),
row.querySelector('.label'),
row.querySelector('.info_label'),
row.querySelector('.meta_label'),
cells[0]
].filter(Boolean);
const labelText = normalize(labelCandidates.map(node => node.textContent || '').join(' ').trim());
if (labelText === 'content' || labelText.startsWith('content ')) return '';
if (labelText.includes('genre')) return 'genres';
if (labelText.includes('platform')) return 'platforms';
if (labelText.includes('tag')) return 'tags';
const fullText = normalize(row.textContent);
if (fullText === 'content' || fullText.startsWith('content ')) return '';
if (fullText.includes('genre')) return 'genres';
if (fullText.includes('platform')) return 'platforms';
if (fullText.includes('tag')) return 'tags';
return '';
}
function extractRowValues(row, type) {
const clone = row.cloneNode(true);
const cells = [...clone.children].filter(Boolean);
const firstCell = cells[0] || null;
const lastCell = cells.length > 1 ? cells[cells.length - 1] : clone;
if (firstCell && firstCell !== lastCell) firstCell.remove();
const container = lastCell || clone;
const linkValues = [...container.querySelectorAll('a[href]')]
.map(link => String(link.textContent || '').trim())
.filter(Boolean);
if (linkValues.length) return linkValues;
const rawText = String(container.innerText || container.textContent || '')
.replace(/^(genres?|platforms?|tags?)\s*:?\s*/i, '')
.replace(/\s+/g, ' ')
.trim();
if (!rawText) return [];
return rawText
.split(/\s*,\s*/)
.map(x => x.trim())
.filter(Boolean)
.filter(value => normalize(value) !== normalize(type.replace(/s$/, '')));
}
for (const row of rows) {
const type = detectMetaType(row);
if (!type) continue;
result[type].push(...extractRowValues(row, type));
}
result.genres = normalizeLabelList(result.genres.filter(value => !isIgnoredMetaLabel(value)));
result.platforms = normalizeLabelList(result.platforms.filter(value => !isIgnoredMetaLabel(value)));
const sideMetaLabels = new Set([
...result.genres,
...result.platforms
].map(value => normalize(value)));
result.tags = normalizeLabelList(result.tags
.filter(value => !isIgnoredMetaLabel(value))
.filter(value => !sideMetaLabels.has(normalize(value))));
result.genreLinks = backfillMissingLinkEntries('genre', result.genres, result.genreLinks);
result.platformLinks = backfillMissingLinkEntries('platform', result.platforms, result.platformLinks);
result.tagLinks = backfillMissingLinkEntries('tag', result.tags, result.tagLinks);
return result;
}
function cachePublicGameMetaIfAvailable() {
const meta = extractInfoPanelMetaFromPublicGamePage();
if (!meta) return;
storeMetaOnCurrentOrigin(meta);
console.log('[itch.io stats] cached game meta:', meta);
const params = new URLSearchParams(location.search);
const returnUrl = params.get('tm_return');
const refreshMode = params.get('tm_refresh') === '1';
const refreshState = loadRefreshState();
if (refreshMode) {
if (!refreshState || refreshState.phase !== 'fetch-meta') {
abortRefreshFlow('missing fetch-meta state on public page');
return;
}
if (!isSameGame(refreshState.game, meta)) {
abortRefreshFlow('public game does not match refresh target');
return;
}
refreshState.game = {
id: meta.id || refreshState.game?.id || null,
name: meta.name || refreshState.game?.name || 'Unknown game'
};
refreshState.phase = 'search';
refreshState.index = 0;
refreshState.queue = buildRefreshQueue(refreshState.game);
saveRefreshState(refreshState);
if (refreshState.queue.length) {
setTransferredMeta(meta);
setTransferredRefreshState(refreshState);
location.href = refreshState.queue[0].url;
return;
}
abortRefreshFlow('refresh queue is empty');
return;
}
if (returnUrl && (meta.genres.length || meta.platforms.length || meta.tags.length)) {
setTransferredMeta(meta);
location.href = returnUrl;
}
}
function formatStatCell(record) {
if (!record) return '—';
return '#' + record.globalPosition + ' Page ' + record.page;
}
function formatStatCell(record) {
if (!record) return '—';
return '#' + record.globalPosition;
}
function findRecordsForLabel(records, label) {
const wanted = normalize(label);
return records.filter(record => {
return getSearchLabelsFromRecord(record).some(value => normalize(value) === wanted);
});
}
function normalizeLabelList(values) {
return [...new Set(
values
.flat()
.map(x => String(x || '').trim())
.filter(Boolean)
)];
}
function formatStatCell(record) {
if (!record) return '—';
if (record.displayRank) return record.displayRank;
return '#' + record.globalPosition;
}
function normalizeLabelList(values) {
const result = [];
const seen = new Set();
for (const rawValue of values.flat()) {
const value = String(rawValue || '').trim();
const key = normalize(value);
if (!value || !key || seen.has(key)) continue;
seen.add(key);
result.push(value);
}
return result;
}
function formatOverflowRank(loadedGamesCount) {
const loaded = Number(loadedGamesCount || 0);
if (loaded >= 1000) return '>1000';
if (loaded > 0) return '>' + loaded;
return '-';
}
function formatStatCell(record) {
if (!record) return '-';
if (record.displayRank) return record.displayRank;
return '#' + record.globalPosition;
}
function getCurrentDeltaMarker(currentRecord, bestRecord) {
if (!currentRecord || !bestRecord) return '';
const currentRank = Number(currentRecord.globalPosition || Number.MAX_SAFE_INTEGER);
const bestRank = Number(bestRecord.globalPosition || Number.MAX_SAFE_INTEGER);
if (!Number.isFinite(currentRank) || !Number.isFinite(bestRank)) return '';
if (currentRank > bestRank) return ' 📉';
if (currentRank < bestRank) return ' 📈';
return '';
}
function renderStatCell(record, options = {}) {
const text = record ? formatStatCell(record) : '🔎';
const className = options.current ? 'tm-stat-current' : '';
const marker = options.current ? getCurrentDeltaMarker(record, options.bestRecord) : '';
const content = `${escapeHtml(text)}${escapeHtml(marker)}`;
if (options.href && options.current) {
const buttonClass = ['tm-stat-link', className].filter(Boolean).join(' ');
return `<button class="${buttonClass}" data-open-search-url="${escapeHtml(options.href)}">${content}</button>`;
}
if (className) {
return `<span class="${className}">${content}</span>`;
}
return content;
}
function renderBestStatCell(record) {
return record ? renderStatCell(record) : '—';
}
function getRecordSeries(record) {
const category = normalize(getRecordCategory(record));
const match = SEARCH_SERIES.find(item => normalize(item.label) === category);
return match?.key || '';
}
function hasRecordModifiers(record) {
const tags = Array.isArray(record?.meta?.tags) ? record.meta.tags : [];
const genres = Array.isArray(record?.meta?.genres) ? record.meta.genres : [];
const platforms = Array.isArray(record?.meta?.platforms) ? record.meta.platforms : [];
return !!(tags.length || genres.length || platforms.length);
}
function isDefaultSummaryRecord(record) {
if (!record) return false;
const summaryLabel = normalize(record?.meta?.summaryLabel);
if (summaryLabel === normalize('Default')) return true;
if (summaryLabel) return false;
return !!getRecordSeries(record) && !hasRecordModifiers(record);
}
function getQueueItemRecords(records, item) {
const seriesRecords = records.filter(record => getRecordSeries(record) === item.series);
if (item.section === 'default') {
return seriesRecords.filter(record => isDefaultSummaryRecord(record));
}
if (item.section === 'intersections') {
const wanted = normalize(item.label);
return seriesRecords.filter(record => normalize(record?.meta?.summaryLabel) === wanted);
}
return findRecordsForLabel(seriesRecords, item.label);
}
function dedupeRefreshItems(items) {
const seen = new Set();
return items
.filter(item => item.url)
.filter(item => {
const key = `${item.section}:${item.series}:${normalize(item.label)}:${item.url}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function buildFilterLinkEntries(type, labels, links) {
const byLabel = new Map(
normalizeLinkEntries(links).map(item => [normalize(item.label), item.href])
);
return normalizeLabelList(labels)
.map(label => ({
label,
href: byLabel.get(normalize(label)) || buildSearchUrlForLabel(type, label)
}))
.filter(item => item.href);
}
function isFiniteRankRecord(record) {
const rank = Number(record?.globalPosition);
return Number.isFinite(rank) && rank > 0 && rank < Number.MAX_SAFE_INTEGER;
}
function formatChartDayKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getLocalDayKey(value = Date.now()) {
const date = value instanceof Date ? value : new Date(Number(value));
if (Number.isNaN(date.getTime())) return '';
return formatChartDayKey(date);
}
function getLocalHourKey(value = Date.now()) {
const date = value instanceof Date ? value : new Date(Number(value));
if (Number.isNaN(date.getTime())) return '';
const hour = String(date.getHours()).padStart(2, '0');
return `${formatChartDayKey(date)} ${hour}`;
}
function getRecentChartDays(count = 7) {
const formatter = new Intl.DateTimeFormat('en-US', {
weekday: 'short',
day: '2-digit'
});
const days = [];
const today = new Date();
for (let offset = count - 1; offset >= 0; offset -= 1) {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset);
days.push({
key: formatChartDayKey(date),
label: formatter.format(date)
});
}
return days;
}
function getRecentChartHours() {
const hours = [];
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dayKey = formatChartDayKey(now);
for (let hour = 0; hour < 24; hour += 1) {
const date = new Date(year, month, day, hour);
const hourLabel = `${hour}h`;
hours.push({
key: `${dayKey} ${String(hour).padStart(2, '0')}`,
label: hour % 2 === 0 || hour === 23 ? hourLabel : '',
fullLabel: hourLabel,
dayKey,
hour
});
}
return hours;
}
function collectBestRankByDay(records, days, dayKeySet) {
const bestByDay = new Map();
records.forEach(record => {
if (!isFiniteRankRecord(record)) return;
const foundAt = Number(record?.foundAt || 0);
const date = new Date(foundAt);
if (Number.isNaN(date.getTime())) return;
const dayKey = String(record?.localDayKey || '') || formatChartDayKey(date);
if (!dayKeySet.has(dayKey)) return;
const rank = Number(record.globalPosition);
const previous = bestByDay.get(dayKey);
if (!previous || rank < previous.value || (rank === previous.value && foundAt >= previous.foundAt)) {
bestByDay.set(dayKey, {
value: rank,
foundAt
});
}
});
return days.map(day => {
const point = bestByDay.get(day.key);
return point ? {
dayKey: day.key,
dayLabel: day.label,
value: point.value,
foundAt: point.foundAt
} : null;
});
}
function collectLatestRankByHour(records, hours, hourKeySet) {
const latestByHour = new Map();
records.forEach(record => {
if (!isFiniteRankRecord(record)) return;
const foundAt = Number(record?.foundAt || 0);
const date = new Date(foundAt);
if (Number.isNaN(date.getTime())) return;
const hourKey = getLocalHourKey(date);
if (!hourKeySet.has(hourKey)) return;
const rank = Number(record.globalPosition);
const previous = latestByHour.get(hourKey);
if (!previous || foundAt >= previous.foundAt) {
latestByHour.set(hourKey, {
value: rank,
foundAt
});
}
});
return hours.map(hour => {
const point = latestByHour.get(hour.key);
return point ? {
dayKey: hour.dayKey,
dayLabel: hour.fullLabel || hour.label,
value: point.value,
foundAt: point.foundAt
} : null;
});
}
function getSectionLineChartData(records, section, labels) {
const days = getRecentChartDays(7);
const dayKeySet = new Set(days.map(day => day.key));
const palette = ['#4A8CFF', '#FFC53D', '#D86BFF', '#FF7F50', '#8A5CFF', '#31D0AA', '#4DD8FF', '#FF5D8F', '#9BE564', '#FFD166'];
function buildModeSeries(seriesType) {
return labels.map((label, index) => ({
key: `${seriesType}:${normalize(label)}`,
label,
color: palette[index % palette.length],
points: collectBestRankByDay(getQueueItemRecords(records, {
section,
label,
series: seriesType
}), days, dayKeySet)
})).filter(item => item.points.some(Boolean));
}
return {
days,
modes: ANALYTICS_SERIES.reduce((acc, item) => {
acc[item.key] = buildModeSeries(item.key);
return acc;
}, {})
};
}
function buildChartPath(points, getX, getY) {
let path = '';
points.forEach((point, index) => {
if (!point) return;
const command = (!path || !points[index - 1]) ? 'M' : 'L';
path += `${command}${getX(index)} ${getY(point.value)} `;
});
return path.trim();
}
function renderSectionChart(chartData, options = {}) {
const days = Array.isArray(chartData?.days) ? chartData.days : [];
const series = Array.isArray(chartData?.series) ? chartData.series : [];
const palette = Array.isArray(options.palette) && options.palette.length
? options.palette
: ['#4A8CFF', '#FFC53D', '#D86BFF', '#FF7F50', '#8A5CFF', '#31D0AA', '#4DD8FF', '#FF5D8F', '#9BE564', '#FFD166'];
const showLegend = options.showLegend !== false;
if (!days.length || !series.length) {
return `<div class="tm-stat-muted tm-stat-chart">No data for the last 7 days.</div>`;
}
const width = 580;
const height = 180;
const margin = { top: 8, right: 12, bottom: 28, left: 44 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const values = series.flatMap(item => item.points.filter(Boolean).map(point => point.value));
if (!values.length) {
return `<div class="tm-stat-muted tm-stat-chart">No exact rank data for the last 7 days.</div>`;
}
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (minValue === maxValue) {
minValue = Math.max(1, minValue - 1);
maxValue += 1;
}
const getX = index => margin.left + (days.length === 1 ? plotWidth / 2 : (plotWidth / (days.length - 1)) * index);
const getY = value => {
const ratio = (value - minValue) / (maxValue - minValue);
return margin.top + ratio * plotHeight;
};
const tickValues = [0, 1, 2, 3].map(step => {
const ratio = step / 3;
return Math.round(minValue + (maxValue - minValue) * ratio);
});
const gridLines = tickValues.map(value => {
const y = getY(value);
return `
<line class="tm-stat-chart-grid" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line>
<text class="tm-stat-chart-tick" x="${margin.left - 8}" y="${y + 4}" text-anchor="end">#${value}</text>
`;
}).join('');
const dayLabels = days.map((day, index) => `
<text class="tm-stat-chart-day" x="${getX(index)}" y="${height - 8}" text-anchor="middle">${escapeHtml(day.label)}</text>
`).join('');
const lineMarkup = series.map((item, index) => {
const color = palette[index % palette.length];
const path = buildChartPath(item.points, getX, getY);
const circles = item.points.map((point, pointIndex) => {
if (!point) return '';
const title = `${item.label} • ${point.dayLabel} • #${point.value} • ${getSeriesLabel(point.series || 'popular')}`;
return `
<circle class="tm-stat-chart-point" cx="${getX(pointIndex)}" cy="${getY(point.value)}" r="3.5" fill="${color}">
<title>${escapeHtml(title)}</title>
</circle>
`;
}).join('');
return `
${path ? `<path class="tm-stat-chart-line" d="${path}" stroke="${color}"></path>` : ''}
${circles}
`;
}).join('');
const legendMarkup = series.map((item, index) => {
const color = palette[index % palette.length];
return `
<div class="tm-stat-chart-legend-item" title="${escapeHtml(item.label)}">
<span class="tm-stat-chart-legend-swatch" style="background:${color}"></span>
<span class="tm-stat-chart-legend-label">${escapeHtml(item.label)}</span>
</div>
`;
}).join('');
return `
<div class="tm-stat-chart">
<svg class="tm-stat-chart-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="Last 7 days current ranks chart">
${gridLines}
<line class="tm-stat-chart-axis" x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}"></line>
${lineMarkup}
${dayLabels}
</svg>
${showLegend ? `
<div class="tm-stat-chart-legend">
${legendMarkup}
</div>
` : ''}
</div>
`;
}
function renderSectionFieldCharts(chartData) {
const days = Array.isArray(chartData?.days) ? chartData.days : [];
const fields = Array.isArray(chartData?.fields) ? chartData.fields : [];
if (!days.length || !fields.length) {
return `<div class="tm-stat-muted tm-stat-chart">No data for the last 7 days.</div>`;
}
const cards = fields.map(field => {
const hasPoints = Array.isArray(field.points) && field.points.some(Boolean);
const cardChart = hasPoints
? renderSectionChart({
days,
series: [{
label: field.label,
points: field.points
}]
}, {
palette: [field.color || '#4A8CFF'],
showLegend: false
})
: `<div class="tm-stat-muted tm-stat-chart">No exact rank data for the last 7 days.</div>`;
return `
<div class="tm-stat-chart-card">
<div class="tm-stat-chart-card-title">${escapeHtml(field.label)}</div>
${cardChart}
</div>
`;
}).join('');
return `<div class="tm-stat-chart-grid-4">${cards}</div>`;
}
function getSectionToggleChartData(records, section, labels) {
const palette = ['#4A8CFF', '#FFC53D', '#D86BFF', '#FF7F50', '#8A5CFF', '#31D0AA', '#4DD8FF', '#FF5D8F', '#9BE564', '#FFD166'];
function buildMode(mode, days, dayKeySet) {
return labels.map((label, index) => ({
key: `${mode}:${normalize(label)}`,
label,
color: palette[index % palette.length],
points: collectBestRankByDay(getQueueItemRecords(records, {
section,
label,
series: mode
}), days, dayKeySet)
})).filter(item => item.points.some(Boolean));
}
function buildDuration(durationDays) {
const days = durationDays === 1 ? getRecentChartHours() : getRecentChartDays(durationDays);
const dayKeySet = new Set(days.map(day => day.key));
return {
days,
modes: ANALYTICS_SERIES.reduce((acc, item) => {
acc[item.key] = labels.map((label, index) => ({
key: `${item.key}:${normalize(label)}`,
label,
color: palette[index % palette.length],
points: durationDays === 1
? collectLatestRankByHour(getQueueItemRecords(records, {
section,
label,
series: item.key
}), days, dayKeySet)
: collectBestRankByDay(getQueueItemRecords(records, {
section,
label,
series: item.key
}), days, dayKeySet)
})).filter(seriesItem => seriesItem.points.some(Boolean));
return acc;
}, {})
};
}
return {
durations: {
1: buildDuration(1),
7: buildDuration(7)
}
};
}
function renderSectionChartSkeleton(chartKey, seriesKeys = ANALYTICS_SERIES.map(item => item.key)) {
return `
<div class="tm-stat-chart" data-chart-root="${escapeHtml(chartKey)}">
<div class="tm-stat-chart-head">
<div class="tm-stat-chart-head-top">
<div class="tm-stat-chart-head-left">
<div class="tm-stat-chart-toggle">
${seriesKeys.map((seriesKey, index) => `
<button class="tm-stat-chart-toggle-button ${index === 0 ? 'tm-active' : ''}" type="button" data-chart-mode="${escapeHtml(seriesKey)}">${escapeHtml(getSeriesLabel(seriesKey))}</button>
`).join('')}
</div>
</div>
<div class="tm-stat-chart-head-right">
<div class="tm-stat-chart-toggle">
${[1, 7].map((duration, index) => `
<button class="tm-stat-chart-toggle-button ${index === 0 ? 'tm-active' : ''}" type="button" data-chart-duration="${duration}">${duration}d</button>
`).join('')}
</div>
</div>
</div>
<h1 class="tm-stat-chart-title"></h1>
</div>
<div class="tm-stat-chart-body"></div>
<div class="tm-stat-chart-tooltip"></div>
</div>
`;
}
function renderSectionToggleChartInto(root, chartData, mode = 'popular', durationDays = 1) {
if (!root) return;
const body = root.querySelector('.tm-stat-chart-body');
const title = root.querySelector('.tm-stat-chart-title');
const tooltip = root.querySelector('.tm-stat-chart-tooltip');
const durationLabel = `${durationDays}d`;
const modeLabel = isKnownSeriesKey(mode) ? getSeriesLabel(mode) : 'Section not selected';
const durationData = chartData?.durations?.[durationDays] || chartData?.durations?.[1] || null;
const days = Array.isArray(durationData?.days) ? durationData.days : [];
const series = Array.isArray(durationData?.modes?.[mode]) ? durationData.modes[mode] : [];
if (!body) return;
if (title) title.textContent = `${modeLabel} / ${durationLabel}`;
if (!days.length || !series.length) {
body.innerHTML = `<div class="tm-stat-muted">No data for the last ${durationLabel}.</div>`;
if (tooltip) {
tooltip.classList.remove('tm-visible');
tooltip.innerHTML = '';
}
return;
}
const width = 580;
const height = 180;
const margin = { top: 8, right: 12, bottom: 28, left: 44 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const values = series.flatMap(item => item.points.filter(Boolean).map(point => point.value));
if (!values.length) {
body.innerHTML = `<div class="tm-stat-muted">No exact rank data for the last ${durationLabel}.</div>`;
return;
}
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (minValue === maxValue) {
minValue = Math.max(1, minValue - 1);
maxValue += 1;
}
const getX = index => margin.left + (days.length === 1 ? plotWidth / 2 : (plotWidth / (days.length - 1)) * index);
const getY = value => margin.top + ((value - minValue) / (maxValue - minValue)) * plotHeight;
const tickValues = [0, 1, 2, 3].map(step => Math.round(minValue + (maxValue - minValue) * (step / 3)));
const gridLines = tickValues.map(value => {
const y = getY(value);
return `
<line class="tm-stat-chart-grid" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line>
<text class="tm-stat-chart-tick" x="${margin.left - 8}" y="${y + 4}" text-anchor="end">#${value}</text>
`;
}).join('');
const dayLabels = days.map((day, index) => `
<text class="tm-stat-chart-day" x="${getX(index)}" y="${height - 8}" text-anchor="middle">${escapeHtml(day.label)}</text>
`).join('');
const lineMarkup = series.map(item => `
${buildChartPath(item.points, getX, getY) ? `<path class="tm-stat-chart-line" d="${buildChartPath(item.points, getX, getY)}" stroke="${item.color}"></path>` : ''}
${item.points.map((point, pointIndex) => {
if (!point) return '';
return `<circle class="tm-stat-chart-point" cx="${getX(pointIndex)}" cy="${getY(point.value)}" r="3.5" fill="${item.color}"></circle>`;
}).join('')}
`).join('');
const hoverZones = days.map((day, index) => {
const prevX = index === 0 ? margin.left : (getX(index - 1) + getX(index)) / 2;
const nextX = index === days.length - 1 ? width - margin.right : (getX(index) + getX(index + 1)) / 2;
return `<rect class="tm-stat-chart-hover-zone" data-chart-day-index="${index}" x="${prevX}" y="${margin.top}" width="${Math.max(1, nextX - prevX)}" height="${plotHeight}"></rect>`;
}).join('');
const legendMarkup = series.map(item => `
<div class="tm-stat-chart-legend-item" title="${escapeHtml(item.label)}">
<span class="tm-stat-chart-legend-swatch" style="background:${item.color}"></span>
<span class="tm-stat-chart-legend-label">${escapeHtml(item.label)}</span>
</div>
`).join('');
body.innerHTML = `
<svg class="tm-stat-chart-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(`${modeLabel} ${durationLabel} current ranks chart`)}">
${gridLines}
<line class="tm-stat-chart-axis" x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}"></line>
${lineMarkup}
<line class="tm-stat-chart-hover-line" x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${height - margin.bottom}" visibility="hidden"></line>
${hoverZones}
${dayLabels}
</svg>
<div class="tm-stat-chart-legend">${legendMarkup}</div>
`;
const svg = body.querySelector('.tm-stat-chart-svg');
const hoverLine = body.querySelector('.tm-stat-chart-hover-line');
function hideTooltip() {
if (tooltip) {
tooltip.classList.remove('tm-visible');
tooltip.innerHTML = '';
}
if (hoverLine) hoverLine.setAttribute('visibility', 'hidden');
}
body.querySelectorAll('[data-chart-day-index]').forEach(zone => {
zone.addEventListener('mouseenter', () => {
const index = Number(zone.getAttribute('data-chart-day-index'));
const x = getX(index);
const day = days[index];
const rows = series.map(item => ({
label: item.label,
color: item.color,
point: item.points[index]
})).sort((a, b) => {
const aValue = Number(a.point?.value);
const bValue = Number(b.point?.value);
const aHasValue = Number.isFinite(aValue);
const bHasValue = Number.isFinite(bValue);
if (aHasValue && bHasValue) return aValue - bValue;
if (aHasValue) return -1;
if (bHasValue) return 1;
return a.label.localeCompare(b.label);
});
if (hoverLine) {
hoverLine.setAttribute('x1', String(x));
hoverLine.setAttribute('x2', String(x));
hoverLine.setAttribute('visibility', 'visible');
}
if (!tooltip || !svg) return;
tooltip.innerHTML = `
<div class="tm-stat-chart-tooltip-day">${escapeHtml(day.fullLabel || day.label)}</div>
${rows.map(row => `
<div class="tm-stat-chart-tooltip-row">
<span class="tm-stat-chart-tooltip-dot" style="background:${row.color}"></span>
<span class="tm-stat-chart-tooltip-label" title="${escapeHtml(row.label)}">${escapeHtml(row.label)}</span>
<span class="tm-stat-chart-tooltip-value">${row.point ? `#${row.point.value}` : '--'}</span>
</div>
`).join('')}
`;
const rect = root.getBoundingClientRect();
const plotX = (x / width) * rect.width;
tooltip.style.left = `${Math.min(Math.max(plotX + 12, 8), Math.max(8, rect.width - 228))}px`;
tooltip.style.top = `44px`;
tooltip.classList.add('tm-visible');
});
zone.addEventListener('mouseleave', hideTooltip);
});
body.addEventListener('mouseleave', hideTooltip);
}
function sortRefreshItems(items, records) {
function getPriority(item) {
const hasRecords = getQueueItemRecords(records, item).length > 0;
if (item.section === 'default' && hasRecords) return 100;
if (item.section === 'default') return 10;
if (item.section === 'intersections') return 0;
if (item.section === 'platforms') return 1;
if (item.section === 'genres') return 2;
if (item.section === 'tags') return 3;
return 50;
}
return [...items].sort((a, b) => {
const priorityDelta = getPriority(a) - getPriority(b);
if (priorityDelta) return priorityDelta;
const seriesDelta = getSeriesOrder(a.series) - getSeriesOrder(b.series);
if (seriesDelta) return seriesDelta;
const sectionDelta = String(a.section || '').localeCompare(String(b.section || ''));
if (sectionDelta) return sectionDelta;
const labelDelta = normalize(a.label).localeCompare(normalize(b.label));
if (labelDelta) return labelDelta;
return String(a.series || '').localeCompare(String(b.series || ''));
});
}
function getSummaryData(game) {
const positions = loadPositions();
const metaCache = loadGameMeta();
const possibleKeys = [
game?.id ? `id:${game.id}` : null,
game?.name ? `name:${normalize(game.name)}` : null
].filter(Boolean);
let entry = null;
let meta = null;
for (const key of possibleKeys) {
if (!entry && positions[key]) entry = positions[key];
if (!meta && metaCache[key]) meta = metaCache[key];
}
const records = Array.isArray(entry?.records) ? entry.records : [];
if (!meta) {
const namesToMatch = [
game?.name,
entry?.game?.name,
...records.map(record => record?.game?.name)
]
.map(name => normalize(name))
.filter(Boolean);
const cachedItems = Object.values(metaCache)
.filter(item => item && typeof item === 'object');
meta = cachedItems.find(item => {
const itemName = normalize(item?.name);
return itemName && namesToMatch.some(name => {
return itemName === name || itemName.includes(name) || name.includes(itemName);
});
}) || null;
if (!meta && cachedItems.length === 1) {
meta = cachedItems[0];
}
}
const mainLabels = SEARCH_SERIES.map(item => item.label);
const metaGenres = Array.isArray(meta?.genres) ? meta.genres : [];
const metaPlatforms = Array.isArray(meta?.platforms) ? meta.platforms : [];
const metaTags = Array.isArray(meta?.tags) ? meta.tags : [];
const metaGenreLinks = Array.isArray(meta?.genreLinks) ? meta.genreLinks : [];
const metaPlatformLinks = Array.isArray(meta?.platformLinks) ? meta.platformLinks : [];
const metaTagLinks = Array.isArray(meta?.tagLinks) ? meta.tagLinks : [];
const recordGenres = records.flatMap(record => {
return Array.isArray(record.meta?.genres)
? record.meta.genres.map(label => canonicalizeLabelWithLinks('genre', label, metaGenreLinks))
: [];
});
const recordPlatforms = records.flatMap(record => {
return Array.isArray(record.meta?.platforms)
? record.meta.platforms.map(label => canonicalizeLabelWithLinks('platform', label, metaPlatformLinks))
: [];
});
const recordContextPlatforms = records.flatMap(record => {
return getPlatformLabelsFromRecordContext(record)
.map(label => canonicalizeLabelWithLinks('platform', label, metaPlatformLinks));
});
const recordTags = records.flatMap(record => {
return Array.isArray(record.meta?.tags)
? record.meta.tags.map(label => canonicalizeLabelWithLinks('tag', label, metaTagLinks))
: [];
});
const allRecordLabels = records.flatMap(record => getSearchLabelsFromRecord(record));
const genreLabels = normalizeLabelList([
metaGenres.map(label => canonicalizeLabelWithLinks('genre', label, metaGenreLinks)),
recordGenres
]);
const platformLabels = normalizeLabelList([
metaPlatforms.map(label => canonicalizeLabelWithLinks('platform', label, metaPlatformLinks)),
recordPlatforms,
recordContextPlatforms
]);
if (platformLabels.some(label => normalize(label) === 'web') && !platformLabels.some(label => normalize(label) === 'mobile web')) {
platformLabels.push('Mobile Web');
}
const knownSideLabels = new Set([
...mainLabels,
...genreLabels,
...platformLabels
].map(x => normalize(x)));
const canonicalMetaTags = normalizeLabelList(
metaTags.map(label => canonicalizeLabelWithLinks('tag', label, metaTagLinks))
);
const canonicalMetaTagSet = new Set(canonicalMetaTags.map(label => normalize(label)));
const searchTagLabels = allRecordLabels
.filter(label => !knownSideLabels.has(normalize(label)))
.map(label => canonicalizeLabelWithLinks('tag', label, metaTagLinks));
const tagSources = canonicalMetaTags.length
? [
canonicalMetaTags,
recordTags.filter(label => canonicalMetaTagSet.has(normalize(label))),
searchTagLabels.filter(label => canonicalMetaTagSet.has(normalize(label)))
]
: [
canonicalMetaTags,
recordTags,
searchTagLabels
];
const tagLabels = normalizeLabelList(tagSources)
.filter(label => !isIgnoredMetaLabel(label))
.filter(label => !knownSideLabels.has(normalize(label)));
const metaUrl = meta?.url || '';
const publicBaseUrl = game?.href || metaUrl;
const genreLinks = buildFilterLinkEntries('genre', genreLabels, metaGenreLinks);
const platformLinks = buildFilterLinkEntries('platform', platformLabels, metaPlatformLinks);
const tagLinks = buildFilterLinkEntries('tag', tagLabels, metaTagLinks);
return {
possibleKeys,
entry,
meta,
records,
mainLabels,
genreLabels,
platformLabels,
tagLabels,
publicBaseUrl,
genreLinks,
platformLinks,
tagLinks,
intersections: getGameIntersections(game)
};
}
function buildRefreshQueue(game) {
const data = getSummaryData(game);
const enabledSeries = getEnabledSummarySeries();
const items = dedupeRefreshItems([
...enabledSeries.map(series => ({
section: 'default',
label: 'Default',
series,
url: buildSeriesUrl(series, 'https://itch.io/games')
})),
...data.platformLinks.flatMap(item => enabledSeries.map(series => ({
section: 'platforms',
label: item.label,
series,
url: buildSeriesUrl(series, item.href)
}))),
...data.genreLinks.flatMap(item => enabledSeries.map(series => ({
section: 'genres',
label: item.label,
series,
url: buildSeriesUrl(series, item.href)
}))),
...data.tagLinks.flatMap(item => enabledSeries.map(series => ({
section: 'tags',
label: item.label,
series,
url: buildSeriesUrl(series, item.href)
}))),
...data.intersections.flatMap(item => enabledSeries.map(series => ({
section: 'intersections',
label: item.label,
series,
url: buildSeriesUrl(series, item.popularUrl),
parts: item.parts
})))
]);
const missingItems = items.filter(item => !getQueueItemRecords(data.records, item).length);
if (missingItems.length) {
return sortRefreshItems(missingItems, data.records);
}
return sortRefreshItems(items, data.records);
}
function buildLegacyStatLabelCell(label) {
return escapeHtml(label);
}
function buildStatRows(records, labels) {
return labels
.map(label => {
const matching = findRecordsForLabel(records, label);
const current = matching.reduce((best, item) => {
if (!best) return item;
return Number(item.foundAt || 0) >= Number(best.foundAt || 0) ? item : best;
}, null);
const best = matching.reduce((bestItem, item) => {
if (!bestItem) return item;
return Number(item.globalPosition || 999999) < Number(bestItem.globalPosition || 999999) ? item : bestItem;
}, null);
return `
<tr>
<td>${buildLegacyStatLabelCell(label)}</td>
<td>${renderStatCell(current, { current: true, bestRecord: best })}</td>
<td>${renderBestStatCell(best)}</td>
</tr>
`;
})
.join('');
}
function findGameForCurrentSummaryPage() {
const gameId = getCurrentGameIdFromSummaryUrl();
loadDashboardGamesFromCache();
if (gameId) {
const byId = dashboardGames.find(g => String(g.id) === String(gameId));
if (byId) return byId;
}
const title =
document.querySelector('.game_title')?.textContent ||
document.querySelector('h1')?.textContent ||
document.title?.replace(/analytics|summary|itch\.io/gi, '').trim();
if (title) {
const byName = dashboardGames.find(g => normalize(title).includes(normalize(g.name)));
if (byName) return byName;
return {
id: gameId,
name: title.trim()
};
}
return {
id: gameId,
name: gameId ? `Game ${gameId}` : 'Unknown game'
};
}
function getSummaryStatsMountPoint() {
const referrersBlock = document.querySelector('.padded .referrers');
if (referrersBlock?.parentElement) {
return {
parent: referrersBlock.parentElement,
after: referrersBlock
};
}
const paddedBlocks = [...document.querySelectorAll('.padded')];
if (paddedBlocks.length) {
return {
parent: paddedBlocks[paddedBlocks.length - 1],
after: null
};
}
return {
parent:
document.querySelector('.inner_column') ||
document.querySelector('.main') ||
document.body,
after: null
};
}
function createSummaryStatsWidget() {
const game = findGameForCurrentSummaryPage();
const {
possibleKeys,
records,
genreLabels,
platformLabels,
tagLabels,
publicBaseUrl,
genreLinks,
platformLinks,
tagLinks,
intersections
} = getSummaryData(game);
const publicGameUrl = publicBaseUrl
? publicBaseUrl + (publicBaseUrl.includes('?') ? '&' : '?') + 'tm_return=' + encodeURIComponent(location.href)
: '';
const existingWidget = document.querySelector('#tm-itch-summary-stats');
if (existingWidget) existingWidget.remove();
const mountPoint = getSummaryStatsMountPoint();
const widget = document.createElement('div');
const sectionState = loadSummarySectionState();
const seriesState = loadSummarySeriesState();
const visibleSeries = ANALYTICS_SERIES
.map(item => item.key)
.filter(key => seriesState[key]);
const selected = new Set();
widget.id = 'tm-itch-summary-stats';
widget.classList.add('tm-embedded');
function getLatestRecord(recordsToCheck) {
return recordsToCheck.reduce((best, item) => {
if (!best) return item;
return Number(item.foundAt || 0) >= Number(best.foundAt || 0) ? item : best;
}, null);
}
function getBestRecord(recordsToCheck) {
return recordsToCheck.reduce((best, item) => {
if (!best) return item;
return Number(item.globalPosition || 999999) < Number(best.globalPosition || 999999) ? item : best;
}, null);
}
function getLatestAnalyticsTimestamp() {
return records.reduce((best, record) => Math.max(best, Number(record?.foundAt || 0)), 0);
}
function showSummaryReminder() {
if (summaryReminderShown) return;
summaryReminderShown = true;
const previous = document.querySelector('#tm-summary-reminder');
if (previous) previous.remove();
const reminder = document.createElement('div');
reminder.id = 'tm-summary-reminder';
reminder.className = 'tm-summary-reminder';
const latestFoundAt = getLatestAnalyticsTimestamp();
if (!latestFoundAt) return;
const now = new Date();
const then = new Date(latestFoundAt);
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startThen = new Date(then.getFullYear(), then.getMonth(), then.getDate());
const days = Math.max(0, Math.floor((startNow - startThen) / (24 * 60 * 60 * 1000)));
if (!days) return;
const dateText = then.toLocaleDateString('ru-RU');
const timeText = then.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const dayLabel = days === 1 ? 'день' : (days >= 2 && days <= 4 ? 'дня' : 'дней');
const reminderTitle = `Аналитика не обновлялась ${days} ${dayLabel}`;
const reminderMeta = `Последнее обновление: ${dateText} в ${timeText}`;
reminder.innerHTML = `
<div class="tm-summary-reminder-badge">
<span class="tm-summary-reminder-badge-value">${days}</span>
<span class="tm-summary-reminder-badge-label">${escapeHtml(dayLabel)}</span>
</div>
<div class="tm-summary-reminder-content">
<div class="tm-summary-reminder-title">${escapeHtml(reminderTitle)}</div>
<div class="tm-summary-reminder-meta">${escapeHtml(reminderMeta)}</div>
</div>
<button class="tm-summary-reminder-close" type="button" title="Закрыть">×</button>
`;
document.body.appendChild(reminder);
let removed = false;
const removeReminder = () => {
if (removed) return;
removed = true;
reminder.classList.remove('tm-visible');
setTimeout(() => reminder.remove(), 180);
};
reminder.querySelector('.tm-summary-reminder-close')?.addEventListener('click', removeReminder);
setTimeout(() => reminder.classList.add('tm-visible'), 10);
setTimeout(removeReminder, 5000);
}
function createFilterOptions(type, labels, links) {
const linkByLabel = new Map(links.map(item => [normalize(item.label), item.href]));
return labels
.map(label => ({
type,
label,
href: linkByLabel.get(normalize(label)) || buildSearchUrlForLabel(type, label)
}))
.filter(item => item.href);
}
const selectableOptions = [
...createFilterOptions('platform', platformLabels, platformLinks),
...createFilterOptions('genre', genreLabels, genreLinks),
...createFilterOptions('tag', tagLabels, tagLinks)
];
const platformHrefByLabel = new Map(platformLinks.map(item => [normalize(item.label), item.href]));
const genreHrefByLabel = new Map(genreLinks.map(item => [normalize(item.label), item.href]));
const tagHrefByLabel = new Map(tagLinks.map(item => [normalize(item.label), item.href]));
const intersectionHrefByLabel = new Map(intersections.map(item => [normalize(item.label), item.popularUrl]));
function getSummaryBaseHref(section, label) {
const key = normalize(label);
if (section === 'default') return 'https://itch.io/games';
if (section === 'platforms') return platformHrefByLabel.get(key) || buildSearchUrlForLabel('platform', label);
if (section === 'genres') return genreHrefByLabel.get(key) || buildSearchUrlForLabel('genre', label);
if (section === 'tags') return tagHrefByLabel.get(key) || buildSearchUrlForLabel('tag', label);
if (section === 'intersections') return intersectionHrefByLabel.get(key) || '';
return '';
}
function buildRow(label, section = '', extra = {}) {
const baseHref = getSummaryBaseHref(section, label);
const seriesStats = ANALYTICS_SERIES.reduce((acc, item) => {
const matchingRecords = getQueueItemRecords(records, {
section,
label,
series: item.key
});
acc[item.key] = {
href: baseHref ? buildSeriesUrl(item.key, baseHref) : '',
current: getLatestRecord(matchingRecords),
best: getBestRecord(matchingRecords)
};
return acc;
}, {});
return {
label,
section,
seriesStats,
...extra
};
}
function buildRows(labels, section = '') {
return labels.map(label => buildRow(label, section));
}
function buildSeriesHeaderCells() {
return visibleSeries.map(seriesKey => `
<th class="tm-stat-series-cell">${escapeHtml(getSeriesLabel(seriesKey))} now</th>
<th class="tm-stat-series-cell">${escapeHtml(getSeriesLabel(seriesKey))} best</th>
`).join('');
}
function buildSeriesValueCells(row) {
return visibleSeries.map(seriesKey => {
const stats = row.seriesStats?.[seriesKey] || {};
return `
<td class="tm-stat-series-cell" data-summary-series-current="${escapeHtml(seriesKey)}">${renderStatCell(stats.current, { current: true, bestRecord: stats.best, href: stats.href })}</td>
<td class="tm-stat-series-cell" data-summary-series-best="${escapeHtml(seriesKey)}">${renderBestStatCell(stats.best)}</td>
`;
}).join('');
}
function buildTableRows(rows, options = {}) {
if (!rows.length) {
return `
<tr>
${options.selectable ? '<td class="tm-stat-select-col"></td>' : '<td class="tm-stat-select-col tm-stat-placeholder-cell"></td>'}
<td>—</td>
${visibleSeries.map(() => '<td class="tm-stat-series-cell">—</td><td class="tm-stat-series-cell">—</td>').join('')}
${options.allowDelete ? '<td class="tm-stat-action-col"></td>' : '<td class="tm-stat-action-col tm-stat-placeholder-cell"></td>'}
</tr>
`;
}
return rows.map(row => {
const selectKey = options.getSelectKey ? options.getSelectKey(row) : '';
const selectType = selectKey.split('|')[0] || '';
const inputType = options.singleSelect ? 'radio' : 'checkbox';
const inputName = options.singleSelect ? `tm-select-${selectType}` : '';
const selectCell = options.selectable ? `
<td class="tm-stat-select-col">
<input class="tm-stat-checkbox" type="${inputType}" ${inputName ? `name="${escapeHtml(inputName)}"` : ''} data-select-key="${escapeHtml(selectKey)}" data-select-type="${escapeHtml(selectType)}">
</td>
` : '<td class="tm-stat-select-col tm-stat-placeholder-cell"></td>';
const actionCell = options.allowDelete ? `
<td class="tm-stat-action-col">
<button class="tm-remove-intersection" data-remove-intersection="${escapeHtml(row.id)}" title="Удалить">×</button>
</td>
` : '<td class="tm-stat-action-col tm-stat-placeholder-cell"></td>';
return `
<tr data-summary-row-section="${escapeHtml(row.section || options.sectionKey || '')}" data-summary-row-label="${escapeHtml(normalize(row.label))}">
${selectCell}
<td><div class="tm-stat-name-cell"><span>${escapeHtml(row.label)}</span></div></td>
${buildSeriesValueCells(row)}
${actionCell}
</tr>
`;
}).join('');
}
function sectionHtml(key, title, rows, options = {}) {
const collapsed = !!sectionState[key];
const chartHtml = renderSectionChartSkeleton(options.chartKey || key, visibleSeries);
const emptySeriesNote = visibleSeries.length
? ''
: `<div class="tm-stat-muted">Включите хотя бы один раздел выше, чтобы видеть аналитику и запускать обновление.</div>`;
return `
<section class="tm-stat-section" data-summary-section="${escapeHtml(key)}">
<div class="tm-stat-section-title" data-section-toggle="${escapeHtml(key)}">
<span>${escapeHtml(title)}</span>
<span class="tm-stat-section-toggle">${collapsed ? '+' : '-'}</span>
</div>
<div class="tm-stat-section-body ${collapsed ? 'tm-hidden' : ''}">
<div class="tm-stat-table-wrap">
<table class="tm-stat-table">
<thead>
<tr>
${options.selectable ? '<th class="tm-stat-select-col"></th>' : '<th class="tm-stat-select-col tm-stat-placeholder-col"></th>'}
<th>Раздел</th>
${buildSeriesHeaderCells()}
${options.allowDelete ? '<th class="tm-stat-action-col"></th>' : '<th class="tm-stat-action-col tm-stat-placeholder-col"></th>'}
</tr>
</thead>
<tbody>
${buildTableRows(rows, options)}
</tbody>
</table>
</div>
${emptySeriesNote}
${chartHtml}
</div>
</section>
`;
}
const defaultRows = [buildRow('Top', 'default')];
const platformRows = buildRows(platformLabels, 'platforms');
const genreRows = buildRows(genreLabels, 'genres');
const tagRows = buildRows(tagLabels, 'tags');
const intersectionRows = intersections.map(item => buildRow(item.label, 'intersections', {
id: item.id || ''
}));
const chartDataByKey = {
default: getSectionToggleChartData(records, 'default', defaultRows.map(row => row.label)),
platforms: getSectionToggleChartData(records, 'platforms', platformRows.map(row => row.label)),
genres: getSectionToggleChartData(records, 'genres', genreRows.map(row => row.label)),
tags: getSectionToggleChartData(records, 'tags', tagRows.map(row => row.label)),
intersections: getSectionToggleChartData(records, 'intersections', intersectionRows.map(row => row.label))
};
const seriesToggleHtml = ANALYTICS_SERIES.map(item => `
<label class="tm-series-toggle">
<input class="tm-series-toggle-input" type="checkbox" data-summary-series="${escapeHtml(item.key)}" ${seriesState[item.key] ? 'checked' : ''}>
<span class="tm-series-toggle-label">${escapeHtml(item.label)}</span>
</label>
`).join('');
widget.innerHTML = `
<div class="tm-widget-head">
<div class="tm-widget-title">Summary Stats</div>
<button class="tm-widget-collapse" id="tm-summary-collapse" type="button" title="Свернуть">-</button>
</div>
<div class="tm-summary-root-body tm-widget-scroll-body">
<div class="tm-series-toolbar">
<div class="tm-series-toolbar-title">Разделы аналитики и обновления</div>
<div class="tm-series-toggle-row">
${seriesToggleHtml}
</div>
<div class="tm-stat-muted">Обновление проходит только по включённым разделам.</div>
</div>
${sectionHtml('default', 'Общее', defaultRows)}
${sectionHtml('platforms', 'Платформы', platformRows, {
selectable: true,
singleSelect: true,
getSelectKey: row => `platform|${normalize(row.label)}`
})}
${sectionHtml('genres', 'Жанры', genreRows, {
selectable: true,
singleSelect: true,
getSelectKey: row => `genre|${normalize(row.label)}`
})}
${sectionHtml('tags', 'Теги', tagRows, {
selectable: true,
getSelectKey: row => `tag|${normalize(row.label)}`
})}
${sectionHtml('intersections', 'Пересечения', intersectionRows, {
allowDelete: true
})}
<button class="tm-small-button tm-secondary-button tm-intersections-action" id="tm-build-intersection">
Собрать пересечение
</button>
${!records.length ? `
<div class="tm-stat-muted">
Для этой игры пока нет сохранённых позиций.
</div>
` : ''}
<div class="tm-action-row">
${publicGameUrl ? `
<button class="tm-small-button tm-secondary-button" id="tm-fetch-game-tags">
Получить теги
</button>
` : `
<button class="tm-small-button tm-secondary-button" disabled>
Нет ссылки
</button>
`}
<button class="tm-small-button tm-clear-button" id="tm-clear-this-game-stats">
Очистить
</button>
</div>
${game ? `
<button class="tm-small-button tm-primary-button" id="tm-refresh-game-stats">
↻ Обновить
</button>
<div class="tm-stat-muted" id="tm-summary-refresh-status"></div>
` : `
<button class="tm-small-button tm-primary-button" disabled>
↻ Обновить
</button>
`}
</div>
`;
if (mountPoint.after?.nextSibling) {
mountPoint.parent.insertBefore(widget, mountPoint.after.nextSibling);
} else if (mountPoint.after) {
mountPoint.parent.appendChild(widget);
} else {
mountPoint.parent.appendChild(widget);
}
showSummaryReminder();
const rootBody = widget.querySelector('.tm-summary-root-body');
const collapseButton = widget.querySelector('#tm-summary-collapse');
function setSummaryCollapsed(collapsed, persist = true) {
if (!rootBody || !collapseButton) return;
rootBody.classList.toggle('tm-hidden', collapsed);
collapseButton.textContent = collapsed ? '+' : '-';
collapseButton.title = collapsed ? 'Развернуть' : 'Свернуть';
collapseButton.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (persist) {
localStorage.setItem(STORAGE_KEY_SUMMARY_COLLAPSED, collapsed ? '1' : '0');
}
}
collapseButton?.addEventListener('click', () => {
const isCollapsed = rootBody?.classList.contains('tm-hidden');
setSummaryCollapsed(!isCollapsed);
});
setSummaryCollapsed(localStorage.getItem(STORAGE_KEY_SUMMARY_COLLAPSED) === '1', false);
widget.querySelectorAll('[data-chart-root]').forEach(root => {
const chartKey = root.getAttribute('data-chart-root');
const chartData = chartDataByKey[chartKey];
if (!chartData) return;
const initialPref = getSummaryChartPref(chartKey, visibleSeries);
let currentMode = initialPref.mode;
let currentDuration = initialPref.duration;
root.querySelectorAll('[data-chart-mode]').forEach(button => {
const buttonMode = button.getAttribute('data-chart-mode') || '';
button.classList.toggle('tm-active', buttonMode === currentMode);
});
root.querySelectorAll('[data-chart-duration]').forEach(button => {
const buttonDuration = Number(button.getAttribute('data-chart-duration')) || 1;
button.classList.toggle('tm-active', buttonDuration === currentDuration);
});
renderSectionToggleChartInto(root, chartData, currentMode, currentDuration);
root.querySelectorAll('[data-chart-mode]').forEach(button => {
button.addEventListener('click', () => {
const nextMode = button.getAttribute('data-chart-mode') || currentMode;
currentMode = nextMode;
setSummaryChartPref(chartKey, {
mode: currentMode,
duration: currentDuration
});
root.querySelectorAll('[data-chart-mode]').forEach(other => {
other.classList.toggle('tm-active', other === button);
});
renderSectionToggleChartInto(root, chartData, currentMode, currentDuration);
});
});
root.querySelectorAll('[data-chart-duration]').forEach(button => {
button.addEventListener('click', () => {
const nextDuration = Number(button.getAttribute('data-chart-duration')) || currentDuration;
currentDuration = nextDuration;
setSummaryChartPref(chartKey, {
mode: currentMode,
duration: currentDuration
});
root.querySelectorAll('[data-chart-duration]').forEach(other => {
other.classList.toggle('tm-active', other === button);
});
renderSectionToggleChartInto(root, chartData, currentMode, currentDuration);
});
});
});
function setSectionCollapsed(body, collapsed) {
if (!body) return;
if (collapsed) {
body.style.maxHeight = `${body.scrollHeight}px`;
body.offsetHeight;
body.classList.add('tm-hidden');
body.style.maxHeight = '0px';
return;
}
body.classList.remove('tm-hidden');
body.style.maxHeight = '0px';
body.offsetHeight;
body.style.maxHeight = `${body.scrollHeight}px`;
}
function scrollSummaryWidgetIntoView() {
widget.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
function focusSummaryTarget(target) {
if (!target || typeof target !== 'object') return;
const sectionKey = String(target.section || '').trim();
const labelKey = normalize(target.label);
const seriesKey = String(target.series || '').trim();
if (!sectionKey || !labelKey) return;
const section = widget.querySelector(`[data-summary-section="${CSS.escape(sectionKey)}"]`);
const sectionBody = section?.querySelector('.tm-stat-section-body');
const sectionToggle = section?.querySelector('[data-section-toggle]');
const sectionIcon = sectionToggle?.querySelector('.tm-stat-section-toggle');
if (sectionBody?.classList.contains('tm-hidden')) {
const state = loadSummarySectionState();
state[sectionKey] = false;
saveSummarySectionState(state);
setSectionCollapsed(sectionBody, false);
if (sectionIcon) sectionIcon.textContent = '-';
}
const rowSelector = `[data-summary-row-section="${CSS.escape(sectionKey)}"][data-summary-row-label="${CSS.escape(labelKey)}"]`;
const row = widget.querySelector(rowSelector) || widget.querySelector(`[data-summary-row-label="${CSS.escape(labelKey)}"]`);
if (!row) return;
const scrollBody = widget.querySelector('.tm-widget-scroll-body') || widget;
const bodyRect = scrollBody.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const nextTop = scrollBody.scrollTop + (rowRect.top - bodyRect.top) - (scrollBody.clientHeight / 2) + (rowRect.height / 2);
scrollBody.scrollTo({
top: Math.max(0, nextTop),
behavior: 'smooth'
});
setTimeout(() => {
row.classList.add('tm-stat-focus-row', 'tm-stat-focus-live');
const focusCell = seriesKey
? row.querySelector(`[data-summary-series-current="${CSS.escape(seriesKey)}"]`)
: null;
if (focusCell) focusCell.classList.add('tm-stat-focus-cell');
setTimeout(() => {
row.classList.remove('tm-stat-focus-live');
row.classList.remove('tm-stat-focus-row');
if (focusCell) focusCell.classList.remove('tm-stat-focus-cell');
}, 2600);
}, 420);
}
widget.querySelectorAll('.tm-stat-section-body').forEach(body => {
body.style.maxHeight = body.classList.contains('tm-hidden')
? '0px'
: `${body.scrollHeight}px`;
body.addEventListener('transitionend', event => {
if (event.propertyName !== 'max-height') return;
body.style.maxHeight = body.classList.contains('tm-hidden')
? '0px'
: 'none';
});
});
widget.querySelectorAll('[data-section-toggle]').forEach(toggle => {
toggle.addEventListener('click', () => {
const key = toggle.getAttribute('data-section-toggle');
const body = toggle.nextElementSibling;
const icon = toggle.querySelector('.tm-stat-section-toggle');
const state = loadSummarySectionState();
const isCollapsed = body ? body.classList.contains('tm-hidden') : !!state[key];
const nextCollapsed = !isCollapsed;
state[key] = nextCollapsed;
saveSummarySectionState(state);
if (body) setSectionCollapsed(body, nextCollapsed);
if (icon) icon.textContent = nextCollapsed ? '+' : '-';
});
});
widget.querySelectorAll('[data-summary-series]').forEach(input => {
input.addEventListener('change', () => {
const seriesKey = input.getAttribute('data-summary-series');
if (!seriesKey || !isKnownSeriesKey(seriesKey)) return;
const nextState = loadSummarySeriesState();
nextState[seriesKey] = input.checked;
saveSummarySeriesState(nextState);
createSummaryStatsWidget();
});
});
widget.querySelectorAll('.tm-stat-checkbox').forEach(input => {
input.addEventListener('change', () => {
const key = input.getAttribute('data-select-key');
const type = input.getAttribute('data-select-type');
if (!key) return;
if (input.type === 'radio' && input.checked) {
widget.querySelectorAll(`.tm-stat-checkbox[data-select-type="${type}"]`).forEach(other => {
const otherKey = other.getAttribute('data-select-key');
if (other === input || !otherKey) return;
selected.delete(otherKey);
});
}
if (input.checked) selected.add(key);
else selected.delete(key);
});
});
widget.querySelectorAll('[data-remove-intersection]').forEach(button => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-remove-intersection');
saveGameIntersections(game, intersections.filter(item => item.id !== id));
createSummaryStatsWidget();
});
});
widget.querySelectorAll('[data-open-search-url]').forEach(button => {
button.addEventListener('click', () => {
const href = button.getAttribute('data-open-search-url');
if (!href) return;
setTransferredPendingSearch({
id: game?.id || null,
name: game?.name || 'Unknown game'
});
location.href = href;
});
});
const buildIntersectionButton = widget.querySelector('#tm-build-intersection');
if (buildIntersectionButton) {
buildIntersectionButton.addEventListener('click', () => {
const parts = normalizeIntersectionParts(selectableOptions.filter(item => selected.has(`${item.type}|${normalize(item.label)}`)));
if (!parts.length) return;
const id = buildIntersectionId(parts);
const urls = buildIntersectionUrls(parts);
if (!urls.popularUrl || !urls.newPopularUrl) return;
const item = {
id,
label: parts.map(part => part.label).join(' + '),
parts,
popularUrl: urls.popularUrl,
newPopularUrl: urls.newPopularUrl
};
const nextItems = [...intersections];
const existingIndex = nextItems.findIndex(entry => entry.id === id);
if (existingIndex >= 0) nextItems[existingIndex] = item;
else nextItems.push(item);
saveGameIntersections(game, nextItems);
createSummaryStatsWidget();
});
}
const fetchTagsButton = widget.querySelector('#tm-fetch-game-tags');
if (fetchTagsButton && publicGameUrl) {
fetchTagsButton.addEventListener('click', () => {
location.href = publicGameUrl;
});
}
const refreshButton = widget.querySelector('#tm-refresh-game-stats');
if (refreshButton && game) {
refreshButton.addEventListener('click', () => {
const refreshState = {
phase: 'search',
game: {
id: game?.id || null,
name: game?.name || 'Unknown game'
},
summaryUrl: location.href,
queue: buildRefreshQueue(game),
index: 0,
startedAt: Date.now()
};
if (!refreshState.queue.length) {
const status = widget.querySelector('#tm-summary-refresh-status');
if (status) status.textContent = 'Нет включённых разделов для обновления.';
return;
}
saveRefreshState(refreshState);
location.href = refreshState.queue[0].url;
});
}
const clearButton = widget.querySelector('#tm-clear-this-game-stats');
clearButton.addEventListener('click', () => {
if (!confirm('Очистить сохранённую статистику для этой игры?')) return;
const all = loadPositions();
for (const key of possibleKeys) {
delete all[key];
}
savePositions(all);
createSummaryStatsWidget();
});
const pendingSummaryFocus = transferredPayload?.pendingSummaryFocus;
const pendingSummaryWidget = !!transferredPayload?.pendingSummaryWidget;
if (pendingSummaryWidget) {
transferredPayload.pendingSummaryWidget = false;
setTimeout(() => scrollSummaryWidgetIntoView(), 40);
}
if (pendingSummaryFocus) {
transferredPayload.pendingSummaryFocus = null;
setTimeout(() => focusSummaryTarget(pendingSummaryFocus), 40);
}
}
function installGamesPageObserver() {
if (!isGamesPage) return;
let timer = null;
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
installTiltForVisibleCards();
passiveScanOwnGames();
}, 160);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
window.addEventListener('scroll', () => {
clearTimeout(timer);
timer = setTimeout(() => {
installTiltForVisibleCards();
passiveScanOwnGames();
}, 160);
}, { passive: true });
}
function safeInit(label, fn) {
try {
fn();
} catch (error) {
console.error('[itch.io stats] init failed:', label, error);
}
}
transferredPayload = consumeTransferredMeta();
installAjaxObserver();
if (isDashboardPage) {
cacheDashboardGamesIfOnDashboard();
const dashboardObserver = new MutationObserver(() => {
cacheDashboardGamesIfOnDashboard();
});
dashboardObserver.observe(document.body, {
childList: true,
subtree: true
});
}
if (isGamesPage) {
safeInit('games widget', () => {
createSearchWidget();
installGamesPageObserver();
});
}
if (isPublicGamePage) {
safeInit('public game meta', () => {
cachePublicGameMetaIfAvailable();
const publicGameObserver = new MutationObserver(() => {
cachePublicGameMetaIfAvailable();
});
publicGameObserver.observe(document.body, {
childList: true,
subtree: true
});
});
}
if (isSummaryPage) {
safeInit('summary stats', createSummaryStatsWidget);
}
maybeResumeRefreshFlow().catch(error => {
console.error('[itch.io stats] refresh resume failed:', error);
});
})();