/* =============================================================================
* SGLV PTW · Tab: 入库月历 (Acquisition Calendar)
* -----------------------------------------------------------------------------
* 迁移自主脚本 mountPlaytimeCalendar(v2.20.0 :16090-16502,414 行)
* 迁移方式:脚本化提取 + 依赖替换(唯一改动 = 外部依赖改为 ctx.host 注入)
*
* 保留的三处关键性能设计(勿简化):
* ① gridCache 按年缓存 12 个月卡节点(FIFO 16 年)——切年命中直接复用,img/文本零重建
* ② tlBodyCache 按年月缓存时间线 body(FIFO 48 月)——切月命中直接挂回,游戏图标不重建(消闪烁核心)
* ③ syncSelection 纯 className 字符串操作(不用 classList,兼容测试桩),不触碰 innerHTML
*
* 视图态差异:原 opts.filter → 此处 view.layerFilter(支持从总览 Tab 钻取)
* ========================================================================== */
(function (root) {
'use strict';
function mount(container, ctx) {
const H = ctx.host;
const T = H.T;
const ICONS = H.ICONS;
const isZh = H.isZh;
const view = ctx.view;
const pane = document.createElement('div');
pane.className = 'sglv-ptcal-pane';
container.appendChild(pane);
// v2.20.2: baseSet 为固定基集合; gameSet 随「仅个人游戏」开关注入
// (排除家庭共享后重建索引, 月历/时间线/chips 全部只统计个人游戏)
const sortedGames = ctx.getGames();
const baseSet = view.layerFilter ? sortedGames.filter(view.layerFilter) : sortedGames.slice();
let gameSet = baseSet;
let personalOnly = false;
// 已解析的入库时间映射(个人过滤空态预检用, 免二次读取)
let resolvedMap = null;
// v2.20.1: opts.acquiredMap 直通(浮窗吃灰榜/活跃度分层已抓过入库时间)时同步渲染——
// 旧逻辑即使有数也走 Promise 一拍, loading 占位先入 DOM 再被替换, 产生闪烁闪帧。
if (view.acquiredMap) {
resolvedMap = view.acquiredMap || {};
try { renderPane(resolvedMap); }
catch (e) { console.warn('[SGLV] 入库月历渲染失败:', e); renderEmpty(); }
} else {
pane.innerHTML = `
${H.escHtml(T.ptCalLoading)}
`;
Promise.resolve(H.getAcquiredMap())
.then(map => { resolvedMap = map || {}; renderPane(resolvedMap); })
.catch(() => renderEmpty());
}
function renderEmpty() {
pane.innerHTML = `${H.escHtml(T.ptCalNoData)}
${H.escHtml(T.ptCalNoDataHint)}
`;
}
function renderPane(map) {
const now = new Date();
const today = new Date(); today.setHours(0, 0, 0, 0);
const realYear = now.getFullYear();
// v2.20.2: 索引构建抽为 buildIndex()——「仅个人游戏」切换时重算索引(而非整页重挂),
// curYear/curMonth/selDay 等视图状态得以保留。单次遍历构建 年 → 'YYYY-MM-DD' →
// [game, ...] 三级映射(仅统计当前游戏集内条目)。
let byYear = {};
let years = [];
let minYear = realYear;
let maxYear = realYear;
let chipYears = [];
let hasYearData = () => false;
function buildIndex() {
byYear = {};
for (const g of gameSet) {
const ts = map[Number(g.appid)];
if (!ts || ts <= 0) continue;
const d = new Date(ts * 1000);
const y = d.getFullYear();
const key = y + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const ym = byYear[y] || (byYear[y] = {});
(ym[key] || (ym[key] = [])).push(g);
}
years = Object.keys(byYear).map(Number).sort((a, b) => a - b);
minYear = years[0];
maxYear = Math.max(realYear, years[years.length - 1]);
// 年份胶囊 = 数据年份 ∪ 今年(今年无数据也占位;按年升序)
chipYears = years.indexOf(realYear) >= 0 ? years : years.concat(realYear).sort((a, b) => a - b);
hasYearData = y => years.indexOf(y) >= 0;
}
buildIndex();
if (years.length === 0) { renderEmpty(); return; }
// v2.20.1: 默认恒落在「今天」——年份/月份都取今天所在值。年份域在数据年份之外
// 始终补齐当前自然年:吃灰榜(入库超 1 年)等筛选集内今年必然无数据,旧逻辑退到
// 「最末数据年」导致 2026 年打开月历却停在 2025、chips/导航都无法到达今年(数据错位
// 观感)。今年无数据时 12 月卡全空、统计为 0、时间线走空态提示,一键即可翻回有数据年。
let curYear = realYear >= minYear ? realYear : minYear;
let curMonth = now.getMonth(); // 0-11,默认「当月」
let selDay = null;
pane.innerHTML = '';
const toolbar = document.createElement('div');
toolbar.className = 'sglv-ptcal-toolbar';
pane.appendChild(toolbar);
const grid = document.createElement('div');
grid.className = 'sglv-ptcal-grid';
pane.appendChild(grid);
const timeline = document.createElement('div');
timeline.className = 'sglv-ptcal-timeline';
pane.appendChild(timeline);
// v2.20.1: 性能重构——旧实现每次交互(点色块/切年/切月)都 renderAll() 全量重建
// toolbar + 12 月卡(~500 span) + 时间线全部 group/img, img 节点反复创建导致
// 滚动闪烁与布局跳动。改为:
// ① 月卡按年缓存(gridCache, 12 卡为整体, 上限 16 年 FIFO)——切年命中直接
// appendChild 复用节点, img/文本零重建;
// ② 时间线 body 按 ${curYear}-${curMonth} 缓存(tlBodyCache, 上限 48 FIFO)——
// 切月命中直接挂回, 游戏图标 img 不重建(消闪烁核心);
// ③ 同月点选/取消只做 syncSelection() 纯 class 切换(className 字符串操作,
// 兼容无 classList 的测试桩), 不触碰任何 innerHTML;
// ④ 仅跨月/跨年才重建对应区块, 末尾统一 syncSelection() 幂等校正选中态。
const gridCache = new Map(); // year → [12 个月卡元素]
const tlBodyCache = new Map(); // 'YYYY-M' → 时间线 body 元素
let selCellEl = null; // 月历中当前选中格(网格侧选中态维护)
let tlBodyEl = null; // 当前挂载的时间线 body(时间线侧选中态维护)
const hasCls = (el, c) => el && (' ' + el.className + ' ').indexOf(' ' + c + ' ') >= 0;
const addCls = (el, c) => { if (el && !hasCls(el, c)) el.className = (el.className + ' ' + c).trim(); };
const delCls = (el, c) => { if (el) el.className = (' ' + el.className + ' ').replace(' ' + c + ' ', ' ').replace(/\s+/g, ' ').trim(); };
// 5 级色阶与 renderAcquiredHeatmap 的 heatColor 阈值完全一致(1/2/3-4/5+)
const CAL_COLORS = ['#0e4429', '#006832', '#26a641', '#39d353'];
function heatLevel(cnt) { return cnt <= 1 ? 1 : cnt <= 2 ? 2 : cnt <= 4 ? 3 : 4; }
const pad2 = (n) => String(n).padStart(2, '0');
const dayKey = (y, m, d) => `${y}-${pad2(m + 1)}-${pad2(d)}`;
const MONTH_NAMES = isZh
? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const WD_NAMES = isZh ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
// v2.20.0: 时间线分组头用的完整星期名(周一开头,与 WD_NAMES 索引对齐)
const WD_FULL = isZh
? ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function yearStats(y) {
const ym = byYear[y] || {};
let count = 0, peakDay = 0;
const monthCount = new Array(12).fill(0);
for (const k in ym) {
const list = ym[k];
count += list.length;
monthCount[Number(k.slice(5, 7)) - 1] += list.length;
if (list.length > peakDay) peakDay = list.length;
}
const peakMonth = monthCount.indexOf(Math.max.apply(null, monthCount));
return { count, peakDay, peakMonth, covered: monthCount.filter(c => c > 0).length, monthCount };
}
function renderToolbar() {
const st = yearStats(curYear);
// 今年无数据(筛选集)时最猛月份不展示「1月(0)」,直接 '—'
const peakLabel = st.count > 0 ? `${MONTH_NAMES[st.peakMonth]}(${st.monthCount[st.peakMonth]})` : '—';
const chips = chipYears.length > 1
? chipYears.map(y => ``).join('')
: '';
toolbar.innerHTML = `
${curYear}
${chips ? `${chips}
` : ''}
${H.escHtml(T.ptCalYearTotal)} ${st.count} ${H.escHtml(T.ptCalUnit)}
${H.escHtml(T.ptCalPeakMonth)} ${peakLabel}
${H.escHtml(T.ptCalPeakDay)} ${st.peakDay}
${H.escHtml(T.ptCalMonthsCovered)} ${st.covered}/12
${H.escHtml(T.ptHeatmapLess)}${CAL_COLORS.map(c => ``).join('')}${H.escHtml(T.ptHeatmapMore)}
`;
toolbar.querySelectorAll('.sglv-ptcal-yearnav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const next = curYear + Number(btn.dataset.nav);
if (next < minYear || next > maxYear) return;
switchYear(next);
});
});
toolbar.querySelectorAll('.sglv-ptcal-year-chip').forEach(btn => {
btn.addEventListener('click', () => {
switchYear(Number(btn.dataset.year));
});
});
// v2.20.2: 「仅个人游戏」开关——排除家庭共享后重建索引(视图状态保留);
// 个人集内无任何入库记录时提示并不切换(避免 renderEmpty 后开关消失无法恢复)。
// 用 querySelectorAll 绑定(innerHTML 重建后节点替换, 每次重绑到新节点, 不叠加)。
toolbar.querySelectorAll('#sglv-ptcal-personal').forEach(btn => {
btn.addEventListener('click', () => {
const next = !personalOnly;
if (next) {
const m = resolvedMap || {};
const hasPersonal = baseSet.some(g => H.isGameOwnedByMe(g) && (m[Number(g.appid)] || 0) > 0);
if (!hasPersonal) { H.showToast.info(T.ptCalPersonalEmpty); return; }
}
setPersonalOnly(next);
});
});
}
// v2.20.1: 年份切换统一入口——切到「今年」时月份锚定当月(而非沿用旧年所选月份),
// 其他年份沿用当前月;该年当月无记录时再兜底跳到最晚有记录的月份。
function switchYear(y) {
curYear = y;
selDay = null;
if (curYear === realYear) curMonth = now.getMonth();
ensureMonthHasData();
renderAll();
}
// v2.20.2: 仅个人游戏——gameSet 切换为本人拥有子集后重建索引; 数据域已变,
// 月卡/时间线缓存全部失效清空; curYear 钳制到新年份域, 切到今年时月份重锚当月;
// 选中态清空(被过滤掉的日期分组不再存在)。
function setPersonalOnly(on) {
personalOnly = on;
gameSet = on ? baseSet.filter(g => H.isGameOwnedByMe(g)) : baseSet;
buildIndex();
gridCache.clear();
tlBodyCache.clear();
selDay = null;
selCellEl = null;
curYear = Math.min(Math.max(curYear, minYear), maxYear);
if (curYear === realYear) curMonth = now.getMonth();
ensureMonthHasData();
renderAll();
}
// v2.20.0: 切年份后若「当月」在该年无任何记录(如翻到只有上半年数据的年份),
// 兜底跳到该年最晚有记录的月份,避免时间线整块空掉(仅年份导航触发;
// 初次进入仍严格按「当月」展示)。点击日历色块同样会切到目标月份。
function ensureMonthHasData() {
if (monthHasData(curYear, curMonth)) return;
for (let m = 11; m >= 0; m--) {
if (monthHasData(curYear, m)) { curMonth = m; return; }
}
}
function monthHasData(y, m) {
const ym = byYear[y] || {};
const daysInMonth = new Date(y, m + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const list = ym[dayKey(y, m, d)];
if (list && list.length > 0) return true;
}
return false;
}
function monthCard(m) {
const ym = byYear[curYear] || {};
const first = new Date(curYear, m, 1);
const daysInMonth = new Date(curYear, m + 1, 0).getDate();
const offset = (first.getDay() + 6) % 7; // 周一开头(与入库热力图 dayNames 一致)
let monthTotal = 0;
const cells = [];
for (let i = 0; i < offset; i++) cells.push('');
for (let d = 1; d <= daysInMonth; d++) {
const key = dayKey(curYear, m, d);
const list = ym[key];
const cellDate = new Date(curYear, m, d);
if (list && list.length > 0) {
monthTotal += list.length;
cells.push(`${d}`);
} else if (cellDate > today) {
cells.push(`${d}`);
} else {
cells.push(`${d}`);
}
}
const tail = (7 - (cells.length % 7)) % 7;
for (let i = 0; i < tail; i++) cells.push('');
const card = document.createElement('div');
card.className = 'sglv-ptcal-month';
card.innerHTML = `
${MONTH_NAMES[m]}
${monthTotal > 0 ? `${monthTotal} ${H.escHtml(T.ptCalUnit)}` : '—'}
${WD_NAMES.map(w => `${w}`).join('')}
${cells.join('')}
`;
return card;
}
// 事件委托:整页网格仅 1 个 click 监听(对齐 v2.11.52c 列表项做法)
// v2.20.1: 同月点选/取消只切 class(零重建); 跨月才重建时间线并滚入视野
grid.addEventListener('click', e => {
const cell = e.target.closest('.sglv-ptcal-day.has');
if (!cell) return;
const key = cell.dataset.day;
if (selDay === key) { selDay = null; syncSelection(); return; } // 再次点击取消高亮
const prevMonth = curMonth;
selDay = key;
// 点到别的月份的色块时,时间线跟着切到那个月(否则高亮目标不在视区内)
curMonth = Number(key.slice(5, 7)) - 1;
if (curMonth !== prevMonth) renderTimeline(true);
syncSelection();
});
// 时间线反向同步:点分组头 = 高亮/取消该日(月历格子上的选中态同步回写)
// v2.20.1: 同样只切 class——时间线只展示当月分组, 点选不改变年月, 无需重建
timeline.addEventListener('click', e => {
const group = e.target.closest('.sglv-ptcal-group');
if (!group) return;
const key = group.dataset.day;
selDay = (selDay === key) ? null : key;
syncSelection();
});
// v2.20.1: 选中态同步(网格 + 时间线两侧)——纯 className 字符串操作,
// 不触碰 innerHTML, 复用缓存节点时也不会丢/残留选中类(幂等)。
function syncGridSel() {
if (selCellEl) { delCls(selCellEl, 'sel'); selCellEl = null; }
if (!selDay) return;
const cell = grid.querySelector('.sglv-ptcal-day.has[data-day="' + selDay + '"]');
if (cell) { addCls(cell, 'sel'); selCellEl = cell; }
}
function syncTimelineSel() {
if (!tlBodyEl) return;
for (const grp of tlBodyEl.children) {
if (!grp || !grp.dataset || !grp.dataset.day) continue;
if (grp.dataset.day === selDay) addCls(grp, 'sel');
else delCls(grp, 'sel');
}
}
function syncSelection() { syncGridSel(); syncTimelineSel(); }
// v2.20.0: 当月入库时间线——常驻展示(不再依赖先点某天才出现),
// 按日倒序:最后一天 → 第一天;点击月历色块即高亮对应分组,
// 高亮底色 = 该日入库数量的色阶色(lv1~lv4,与日历格子同色)。
// v2.20.1: body 按年月缓存复用(img 不重建→滚动不闪烁); 头部(月份/总数)
// 仍每次 innerHTML 重建, 保证与当前月一致。
function renderTimeline(scrollToSel) {
const ym = byYear[curYear] || {};
const daysInMonth = new Date(curYear, curMonth + 1, 0).getDate();
// 倒序遍历当月日期,只收集有入库记录的日
const groups = [];
let monthTotal = 0;
for (let d = daysInMonth; d >= 1; d--) {
const list = ym[dayKey(curYear, curMonth, d)];
if (!list || list.length === 0) continue;
monthTotal += list.length;
groups.push({ key: dayKey(curYear, curMonth, d), day: d, list });
}
const ymLabel = `${curYear}-${pad2(curMonth + 1)}`;
timeline.innerHTML = `
${ICONS.calendar}${H.escHtml(T.ptCalTimeline)}${ymLabel}
${monthTotal > 0 ? `· ${monthTotal} ${H.escHtml(T.ptCalUnit)}` : ''}
${H.escHtml(T.ptCalTimelineHint)}
`;
const cacheKey = curYear + '-' + curMonth;
let body = tlBodyCache.get(cacheKey);
if (!body) {
body = document.createElement('div');
body.className = 'sglv-ptcal-tl-body';
if (groups.length === 0) {
// 整年无数据(如今今年的吃灰榜)与「该月无记录、同年其他月有」区分提示
const mainHint = hasYearData(curYear) ? T.ptCalTimelineEmpty : T.ptCalNoDataThisYear;
body.innerHTML = `${H.escHtml(mainHint)}
${H.escHtml(T.ptCalTimelineEmptyHint)}
`;
} else {
const frag = document.createDocumentFragment();
groups.forEach(grp => {
const level = heatLevel(grp.list.length);
const selected = selDay === grp.key;
const wdIdx = (new Date(curYear, curMonth, grp.day).getDay() + 6) % 7; // 周一开头
const group = document.createElement('div');
group.className = `sglv-ptcal-group lv${level}${selected ? ' sel' : ''}`;
group.dataset.day = grp.key;
group.innerHTML = `
${pad2(curMonth + 1)}-${pad2(grp.day)}
${WD_FULL[wdIdx]}
${grp.list.length} ${H.escHtml(T.ptCalUnit)}
`;
const games = document.createElement('div');
games.className = 'sglv-ptcal-group-games';
const gfrag = document.createDocumentFragment();
grp.list.slice().sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), isZh ? 'zh-Hans-CN' : 'en', { numeric: true, sensitivity: 'base' })).forEach(g => {
const item = document.createElement('div');
item.className = 'sglv-ptcal-detail-item';
item.title = T.ptCalOpenStore;
const hours = g.playtime > 0 ? (g.playtime / 60).toFixed(1) + 'h' : T.ptRange0;
item.innerHTML = `
${H.escHtml(g.name || ('App ' + g.appid))}${H.escHtml(hours)}`;
// 阻止冒泡:游戏条目点击 = 开商店,分组点击 = 切换高亮
item.addEventListener('click', e => {
e.stopPropagation();
root.open(`https://store.steampowered.com/app/${g.appid}`, '_blank');
});
gfrag.appendChild(item);
});
games.appendChild(gfrag);
group.appendChild(games);
frag.appendChild(group);
});
body.appendChild(frag);
}
tlBodyCache.set(cacheKey, body);
// FIFO 上限 48 个月(4 年), 防长时间翻页无限增长
if (tlBodyCache.size > 48) tlBodyCache.delete(tlBodyCache.keys().next().value);
H.applyGameAliases(body);
}
timeline.appendChild(body);
tlBodyEl = body;
syncTimelineSel();
// 选中后把对应分组滚入视野(月历网格常在首屏,选中项可能落在折叠区外)。
// v2.20.1: behavior 改 'auto'——smooth 与缓存挂载后的布局竞争会造成抖动闪烁
if (scrollToSel && selDay) {
requestAnimationFrame(() => {
const el = body.querySelector('.sglv-ptcal-group.sel');
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
});
}
}
// v2.20.1: 月卡按年缓存渲染——命中直接复用 12 个卡片节点(切年零重建)
function renderGrid() {
let cards = gridCache.get(curYear);
if (!cards) {
cards = [];
for (let m = 0; m < 12; m++) cards.push(monthCard(m));
gridCache.set(curYear, cards);
if (gridCache.size > 16) gridCache.delete(gridCache.keys().next().value); // FIFO 上限 16 年
}
grid.innerHTML = '';
const frag = document.createDocumentFragment();
for (const c of cards) frag.appendChild(c);
grid.appendChild(frag);
syncGridSel();
}
function renderAll(scrollToSel) {
renderToolbar();
renderGrid();
renderTimeline(scrollToSel);
}
renderAll();
}
}
// ---- 注册 ----
if (!root.SGLVPTW) {
console.warn('[PTW-TAB-CALENDAR] 框架未就绪,无法注册');
} else {
root.SGLVPTW.registerTab({
key: 'calendar',
label: '入库月历',
order: 60,
requires: [
'getOwnedGames', 'isGameOwnedByMe', 'getGameIconUrl', 'applyGameAliases',
'getAcquiredMap', 'escHtml', 'T', 'ICONS', 'isZh',
],
softRequires: ['showToast'],
mount,
});
console.log('[PTW-TAB-CALENDAR] 已注册');
}
})(typeof window !== 'undefined' ? window : globalThis);