');
}
const btn = document.getElementById('hlDownloadBtn');
if (btn) { btn.disabled = true; btn.classList.add('loading'); }
try {
const [jsPDF, logoSrc, thaiFont] = await Promise.all([_loadJsPDF(), _preloadLogoDataUri(), _fetchRlThaiFont().catch(e => { console.error('[Thai font]', e); return null; })]);
const qs = Array.isArray(passage.questions) ? passage.questions : [];
const words = passage.wordCount || wordCount(passage.content);
const qCount = passage.questionCount || qs.length;
const estMins = Math.ceil(words / 150) + qCount;
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
if (thaiFont) _registerRlThaiFont(doc, thaiFont);
const FONT = thaiFont ? 'Sarabun' : 'helvetica'; // fall back to helvetica (boxes for Thai) only if the font fetch failed
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 44;
const contentW = pageW - margin * 2;
let y = margin;
function newPageIfNeeded(h) {
if (y + h > pageH - margin) { doc.addPage(); y = margin; }
}
function writeLines(lines, x, lineH, opts) {
lines.forEach(line => {
newPageIfNeeded(lineH);
doc.text(line, x, y, opts);
y += lineH;
});
}
// Header — logo left, title + meta right.
if (logoSrc) {
try { doc.addImage(logoSrc, 'PNG', margin, y - 6, 92, 22); } catch (e) { /* non-fatal */ }
}
doc.setFont(FONT, 'bold');
doc.setFontSize(13);
doc.text(passage.title || 'Reading Passage', pageW - margin, y + 6, { align: 'right' });
doc.setFont(FONT, 'normal');
doc.setFontSize(9);
doc.setTextColor(120);
doc.text(`${words} words · ${estMins} min · ${qCount} questions`, pageW - margin, y + 20, { align: 'right' });
doc.setTextColor(20);
y += 40;
doc.setDrawColor(210);
doc.line(margin, y, pageW - margin, y);
y += 22;
// Passage
doc.setFont(FONT, 'normal');
doc.setFontSize(11);
(passage.content || '').split(/\n\s*\n/).map(p => p.trim()).filter(Boolean).forEach(para => {
writeLines(doc.splitTextToSize(para, contentW), margin, 16);
y += 8;
});
y += 12;
// Questions — stem + options, no correct answer or explanation
qs.forEach((q, qi) => {
newPageIfNeeded(20);
doc.setFont(FONT, 'bold');
doc.setFontSize(11);
writeLines(doc.splitTextToSize(`${qi + 1}. ${q.q}`, contentW), margin, 15);
doc.setFont(FONT, 'normal');
doc.setFontSize(10.5);
(q.options || []).forEach((opt, oi) => {
writeLines(doc.splitTextToSize(`${LETTERS[oi]}. ${opt}`, contentW - 14), margin + 14, 14);
});
y += 12;
});
const url = URL.createObjectURL(doc.output('blob'));
if (pdfWin) pdfWin.location.href = url;
else toast('เบราว์เซอร์บล็อกป๊อปอัป กรุณาอนุญาตแล้วลองใหม่', 'err');
} catch (e) {
console.error('[downloadPassagePDF]', e);
if (pdfWin) pdfWin.close();
toast('สร้าง PDF ไม่สำเร็จ กรุณาลองใหม่', 'err');
} finally {
if (btn) { btn.disabled = false; btn.classList.remove('loading'); }
}
};
function attachHlListeners() {
if (_hlAttached) return;
_hlAttached = true;
const body = document.getElementById('passageBody');
/* Desktop */
body.addEventListener('mouseup', onBodyMouseRelease);
/* Mobile: native long-press → drag selection → lift finger.
getSelection() is fully populated at touchend, so we reuse the
same selection-based path as desktop. */
body.addEventListener('touchend', e => {
if (!hlTool) return;
const r = selectionToRange();
if (!r || r.end <= r.start) return;
const color = hlTool === 'erase' ? 0 : hlTool === 'y' ? 1 : hlTool === 'u' ? 3 : 2;
applyHighlight(r.start, r.end, color);
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
e.preventDefault();
});
}
function onBodyMouseRelease() {
if (!hlTool) return;
const r = selectionToRange();
if (!r || r.end <= r.start) return;
const color = hlTool === 'erase' ? 0 : hlTool === 'y' ? 1 : hlTool === 'u' ? 3 : 2;
applyHighlight(r.start, r.end, color);
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
}
function applyHighlight(start, end, c) {
start = Math.max(0, start); end = Math.min(RE_canonLen, end);
if (end <= start) return;
for (let i = start; i < end; i++) RE_marks[i] = c;
renderPassageBody();
saveHighlights();
}
function selectionToRange() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const body = document.getElementById('passageBody');
const range = sel.getRangeAt(0);
if (!body.contains(range.commonAncestorContainer)) return null;
const startP = paragraphElFromNode(range.startContainer);
const endP = paragraphElFromNode(range.endContainer);
if (!startP || !endP) return null;
const gs = Number(startP.dataset.ps) + measureOffset(startP, range.startContainer, range.startOffset);
const ge = Number(endP.dataset.ps) + measureOffset(endP, range.endContainer, range.endOffset);
return { start: Math.min(gs, ge), end: Math.max(gs, ge) };
}
function measureOffset(pEl, node, offset) {
const r = document.createRange();
r.setStart(pEl, 0);
r.setEnd(node, offset);
return r.toString().length;
}
function paragraphElFromNode(node) {
let n = node.nodeType === 3 ? node.parentNode : node;
while (n && n.id !== 'passageBody') {
if (n.dataset && n.dataset.ps !== undefined) return n;
n = n.parentNode;
}
return null;
}
function marksToRuns() {
const runs = []; let k = 0;
while (k < RE_marks.length) {
const c = RE_marks[k];
if (c) { let j = k + 1; while (j < RE_marks.length && RE_marks[j] === c) j++; runs.push({ s: k, e: j, c }); k = j; }
else k++;
}
return runs;
}
function runsToMarks(runs) {
RE_marks = new Uint8Array(RE_canonLen);
(runs || []).forEach(r => { for (let i = r.s; i < r.e && i < RE_canonLen; i++) RE_marks[i] = r.c; });
}
const HL_LS = pid => 'csg_reading_hl_' + pid;
function saveHighlights() {
clearTimeout(hlSaveTimer);
hlSaveTimer = setTimeout(() => {
const runs = marksToRuns();
const u = getLoggedInUser();
if (u) {
db.collection('users').doc(u.uid).collection('readingHighlights').doc(passage.id)
.set({ runs, updatedAt: firebase.firestore.FieldValue.serverTimestamp() })
.catch(e => console.error('highlight save', e));
} else {
try { localStorage.setItem(HL_LS(passage.id), JSON.stringify(runs)); } catch {}
}
}, 700);
}
async function loadHighlights() {
let runs = null;
const u = getLoggedInUser();
if (u) {
try {
const s = await db.collection('users').doc(u.uid).collection('readingHighlights').doc(passage.id).get();
if (s.exists) runs = s.data().runs;
} catch {}
}
if (!runs) { try { runs = JSON.parse(localStorage.getItem(HL_LS(passage.id))); } catch {} }
if (runs && runs.length) { runsToMarks(runs); renderPassageBody(); }
}
/* ════════════════════════════ Resume after login redirect ════════════════════════════ */
async function tryResumePending() {
let pending = null;
try { pending = JSON.parse(sessionStorage.getItem(PENDING_KEY)); } catch {}
if (!pending || !getLoggedInUser()) return;
if (!passage || pending.passageId !== passage.id) { sessionStorage.removeItem(PENDING_KEY); return; }
sessionStorage.removeItem(PENDING_KEY);
// Dashboard is showing — transition to reading mode before restoring answers
if (document.getElementById('splitGrid').classList.contains('hide')) enterReading();
(pending.answers || []).forEach((oi, qi) => { if (oi >= 0) selectOpt(qi, oi); });
const elapsedSec = Math.max(1, Math.round((pending.elapsedMs || 0) / 1000));
const btn = document.getElementById('gradeBtn');
if (btn) { btn.disabled = true; btn.textContent = 'กำลังตรวจ…'; }
try {
const res = await _gradeReadingFn({ passageId: passage.id, answers: pending.answers || answers, elapsedSec });
gradeNow(pending.answers || answers, res.data, elapsedSec);
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = 'ตรวจข้อสอบ'; }
if (e.code === 'functions/already-exists') { markAlreadySubmitted(); return; }
if (e.code === 'functions/unauthenticated') { _handleSessionExpired(); return; }
toast('ไม่สามารถบันทึกผลได้ กรุณาลองใหม่', 'err');
}
}
const _origRender = render;
render = function() { _origRender(); if (!graded) tryResumePending(); tryResumeGroupJoin(); };
// ── Stats view ────────────────────────────────────────────────────────────
const _statsSessionData = {}; // idx → { answers, passageId }
const _statsPassageCache = {}; // passageId → passage doc data
let _statsCachedSessions = null, _statsCachedAt = 0;
const STATS_CACHE_TTL = 60_000; // 1 minute
let _statsViewMode = 'alevel'; // 'alevel' | 'tgat1'
window.switchStatsView = function(mode) {
_statsViewMode = mode;
const alBtn = document.getElementById('statsViewTabAlevel');
const t1Btn = document.getElementById('statsViewTabTgat1');
if (alBtn) alBtn.classList.toggle('active', mode === 'alevel');
if (t1Btn) t1Btn.classList.toggle('active', mode === 'tgat1');
if (_statsCachedSessions) renderStatsView(_statsCachedSessions);
};
let _voidedPassageIds = new Set();
let _calActiveDates = {}, _calBkkDate = null;
let _noticeData = null;
const MODE_LABELS_STATS = {
tgat1: 'TGAT 1',
alevel: 'A-Level อังกฤษ',
'alevel-review': 'A-Level: Review',
'alevel-visual': 'A-Level: Visuals',
'alevel-ads': 'A-Level: Ads',
'alevel-news': 'A-Level: News',
'alevel-general': 'A-Level: General',
cloze: 'A-Level: เติมคำ',
general: 'ทั่วไป'
};
// A-Level-family stats view includes reading (alevel*) AND cloze.
const _isAlevelStatsMode = m => !!m && (m.startsWith('alevel') || m === 'cloze');
window.openInfo = function() {
document.getElementById('infoView').classList.remove('hide');
_initPwaTab();
};
window.closeInfo = function() {
document.getElementById('infoView').classList.add('hide');
};
let _pwaModalInited = false;
window.openPwaModal = function() {
document.getElementById('pwaInstallModal').classList.remove('hide');
if (_pwaModalInited) return;
_pwaModalInited = true;
const isStandalone = window.navigator.standalone === true ||
window.matchMedia('(display-mode: standalone)').matches;
if (isStandalone) document.getElementById('pmAlready').classList.remove('hide');
const ua = navigator.userAgent;
const isIos = /iPhone|iPad|iPod/.test(ua);
const isAndroid = /Android/.test(ua);
if (isIos) {
document.getElementById('pmCardIos').classList.add('highlighted');
document.getElementById('pmHdrIos').classList.add('highlighted');
document.getElementById('pmYouIos').classList.remove('hide');
} else if (isAndroid) {
document.getElementById('pmCardAndroid').classList.add('highlighted');
document.getElementById('pmHdrAndroid').classList.add('highlighted');
document.getElementById('pmYouAndroid').classList.remove('hide');
} else {
document.getElementById('pmCardDesktop').classList.add('highlighted');
document.getElementById('pmHdrDesktop').classList.add('highlighted');
document.getElementById('pmYouDesktop').classList.remove('hide');
}
};
window.closePwaModal = function() {
document.getElementById('pwaInstallModal').classList.add('hide');
};
window.switchInfoTab = function(tab) {
document.getElementById('infoTabExam').classList.toggle('hide', tab !== 'exam');
document.getElementById('infoTabInstall').classList.toggle('hide', tab !== 'install');
document.getElementById('infoTabBtnExam').classList.toggle('active', tab === 'exam');
document.getElementById('infoTabBtnInstall').classList.toggle('active', tab === 'install');
};
let _pwaTabInited = false;
function _initPwaTab() {
if (_pwaTabInited) return;
_pwaTabInited = true;
/* Already installed as standalone app? */
const isStandalone = window.navigator.standalone === true ||
window.matchMedia('(display-mode: standalone)').matches;
if (isStandalone) {
document.getElementById('pwaAlreadyInstalled').classList.remove('hide');
}
/* Detect platform and highlight the relevant card */
const ua = navigator.userAgent;
const isIos = /iPhone|iPad|iPod/.test(ua);
const isAndroid = /Android/.test(ua);
const isDesktop = !isIos && !isAndroid;
if (isIos) {
document.getElementById('pwaCardIos').classList.add('highlighted');
document.getElementById('pwaHdrIos').classList.add('highlighted');
document.getElementById('pwaYouIos').classList.remove('hide');
} else if (isAndroid) {
document.getElementById('pwaCardAndroid').classList.add('highlighted');
document.getElementById('pwaHdrAndroid').classList.add('highlighted');
document.getElementById('pwaYouAndroid').classList.remove('hide');
} else {
document.getElementById('pwaCardDesktop').classList.add('highlighted');
document.getElementById('pwaHdrDesktop').classList.add('highlighted');
document.getElementById('pwaYouDesktop').classList.remove('hide');
}
}
async function _fetchNotice() {
try {
const snap = await db.collection('readingMeta').doc('notice').get();
_noticeData = snap.exists ? snap.data() : null;
} catch (_) { _noticeData = null; }
_renderNoticeBanner();
}
function _renderNoticeBanner() {
const el = document.getElementById('noticeBanner');
if (!el) return;
if (!_noticeData || !_noticeData.active) { el.classList.add('hide'); return; }
const dismissed = sessionStorage.getItem('readinglab_notice_dismissed');
if (dismissed === _noticeData.message) { el.classList.add('hide'); return; }
document.getElementById('noticeBannerMsg').textContent = _noticeData.message || '';
el.className = 'notice-banner ' + (_noticeData.type === 'warning' ? 'warning' : 'info');
el.classList.remove('hide');
}
window.dismissNoticeBanner = function() {
if (_noticeData) sessionStorage.setItem('readinglab_notice_dismissed', _noticeData.message || '');
const el = document.getElementById('noticeBanner');
if (el) el.classList.add('hide');
};
function buildCalSvg(days) {
if (!_calBkkDate) return '';
const todayStr = _calBkkDate(new Date());
const todayUTC = new Date(todayStr + 'T00:00:00Z');
const firstTarget = new Date(todayUTC);
firstTarget.setUTCDate(todayUTC.getUTCDate() - (days - 1));
const dow0 = (firstTarget.getUTCDay() + 6) % 7;
firstTarget.setUTCDate(firstTarget.getUTCDate() - dow0);
const lastTarget = new Date(todayUTC);
const dowLast = (todayUTC.getUTCDay() + 6) % 7;
lastTarget.setUTCDate(todayUTC.getUTCDate() + (6 - dowLast));
const cells = [];
const cur = new Date(firstTarget);
while (cur <= lastTarget) {
cells.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
const numWeeks = cells.length / 7;
const CELL = 22, GAP = 4, COL = CELL + GAP;
const DOW_W = 18, DOW_GAP = 5;
const MONTH_H = 14, MONTH_GAP = 4;
const gridX = DOW_W + DOW_GAP;
const gridY = MONTH_H + MONTH_GAP;
const VW = gridX + numWeeks * COL - GAP;
const VH = gridY + 7 * COL - GAP + 2;
const MONTH_TH = ['ม.ค.','ก.พ.','มี.ค.','เม.ย.','พ.ค.','มิ.ย.','ก.ค.','ส.ค.','ก.ย.','ต.ค.','พ.ย.','ธ.ค.'];
const DOW_LABELS = ['จ','','พ','','ศ','','อา'];
let s = '';
DOW_LABELS.forEach((lbl, i) => {
if (!lbl) return;
const y = gridY + i * COL + CELL / 2 + 3.5;
s += `
'; try { const [sessionSnap, tgat1Snap, voidedSnap] = await Promise.all([ db.collection('readingSessions') .where('userId', '==', u.uid) .orderBy('submittedAt', 'desc') .limit(150) .get(), db.collection('users').doc(u.uid).collection('tgat1Sessions').get(), db.collection('readingPassages').where('voided', '==', true).get().catch(() => ({ docs: [] })), ]); const aLevelSessions = sessionSnap.docs.map(d => ({ id: d.id, ...d.data() })); // Merge TGAT 1 sessions (stored in a subcollection) into the combined list const tgat1Sessions = tgat1Snap.docs.map(d => ({ id: d.id, ...d.data(), mode: 'tgat1' })); // Fetch passage titles for TGAT 1 sessions (needed by history view) const t1PassageIds = [...new Set(tgat1Sessions.map(s => s.passageId).filter(Boolean))]; if (t1PassageIds.length) { const t1Docs = {}; await Promise.all(t1PassageIds.map(async pid => { try { const s = _statsPassageCache[pid] || (await db.collection('readingPassages').doc(pid).get().then(snap => snap.exists ? { id: snap.id, ...snap.data() } : null)); if (s) { t1Docs[pid] = s; _statsPassageCache[pid] = s; } } catch {} })); tgat1Sessions.forEach(s => { if (s.passageId && t1Docs[s.passageId]) s.passageTitle = t1Docs[s.passageId].title || ''; }); } _statsCachedSessions = [...aLevelSessions, ...tgat1Sessions].sort((a, b) => { const ta = a.submittedAt && a.submittedAt.toDate ? a.submittedAt.toDate() : new Date(0); const tb = b.submittedAt && b.submittedAt.toDate ? b.submittedAt.toDate() : new Date(0); return tb - ta; }); _statsCachedAt = Date.now(); _voidedPassageIds = new Set(voidedSnap.docs.map(d => d.id)); const _sc = document.getElementById('dashSessCount'); if (_sc) _sc.textContent = aLevelSessions.filter(s => !_voidedPassageIds.has(s.passageId)).length; renderStatsView(_statsCachedSessions); } catch (e) { console.error(e); document.getElementById('statsInner').innerHTML = '
';
}
};
window.closeStats = function() {
document.getElementById('statsView').classList.add('hide');
};
function _barSvg(items, { colorFn, unit, maxVal } = {}) {
const n = items.length;
if (!n) return '';
const AXIS_W = 36, VW = 560, BAR_AREA = 80, LABEL_H = 18, PAD_TOP = 8;
const VH = BAR_AREA + LABEL_H + PAD_TOP;
const plotW = VW - AXIS_W;
const gap = 4, maxBw = 36;
const bw = Math.min(maxBw, Math.max(4, Math.floor((plotW - gap * (n + 1)) / n)));
const totalW = n * bw + (n + 1) * gap;
const startX = AXIS_W + Math.max(0, (plotW - totalW) / 2);
const mx = maxVal || Math.max(...items.map(x => x.v), 1);
const baseY = PAD_TOP + BAR_AREA;
const labelEvery = Math.ceil(n / 7);
const topVal = mx, midVal = Math.round(mx / 2);
const unitStr = unit || '';
let s = '';
// grid lines + Y-axis labels
s += `
`; const pbHtml = `
`; // Charts — last 14 sessions, reversed to oldest→newest (L→R) let chartHtml = ''; if (total > 0) { const chartSess = [...validSessions].slice(0, 14).reverse(); const chartItems = chartSess.map(s => { const d = s.submittedAt && s.submittedAt.toDate ? s.submittedAt.toDate() : null; const label = d ? (d.getDate() + '/' + (d.getMonth() + 1)) : '?'; return { label, score: s.score || 0, wpm: s.wpm || 0 }; }); const scoreScFn = v => v >= 70 ? '#22c55e' : v >= 50 ? '#f59e0b' : '#ef4444'; const scoreSvg = _barSvg(chartItems.map(c => ({ label: c.label, v: c.score })), { colorFn: scoreScFn, unit: '%', maxVal: 100 }); const wpmSvg = _barSvg(chartItems.map(c => ({ label: c.label, v: c.wpm })), { colorFn: () => 'var(--accent)', unit: ' wpm' }); const n = chartSess.length; const subLabel = n + ' บทล่าสุด'; chartHtml = `
`; } // Accuracy by passage type — A-Level only (multiple subtypes worth showing; TGAT 1 is one mode) let modeHtml = ''; if (!isTgat1 && total > 0) { const modeMap = {}; validSessions.forEach(s => { const m = s.mode || 'general'; if (!modeMap[m]) modeMap[m] = { sum: 0, count: 0 }; modeMap[m].sum += (s.score || 0); modeMap[m].count++; }); const modeEntries = Object.entries(modeMap).sort((a, b) => b[1].count - a[1].count); let rowsHtml = ''; modeEntries.forEach(([mode, d]) => { const avg = Math.round(d.sum / d.count); const fill = 'var(--accent)'; const label = MODE_LABELS_STATS[mode] || mode; rowsHtml += `
`; }); modeHtml = `
`; } // Skill stats — A-Level only (server-aggregated on userDoc; not tracked for TGAT 1) let skillHtml = ''; if (!isTgat1) { const skillStats = (uDoc && uDoc.readingSkillStats) || {}; const skillEntries = Object.entries(skillStats).filter(([, v]) => v.total > 0); if (skillEntries.length > 0) { skillEntries.sort((a, b) => (a[1].correct / a[1].total) - (b[1].correct / b[1].total)); const SKILL_LABELS_TH = { 'main-idea': 'ใจความสำคัญ', detail: 'รายละเอียด', inference: 'การอนุมาน', vocabulary: 'คำศัพท์', tone: 'น้ำเสียง', negative: 'ข้อยกเว้น', purpose: 'จุดประสงค์', other: 'พิเศษ' }; let rowsHtml = ''; skillEntries.forEach(([skill, v]) => { const correct = v.correct || 0; const pct = Math.round((correct / v.total) * 100); const fill = pct >= 70 ? 'var(--green)' : pct >= 50 ? '#f59e0b' : 'var(--red)'; const label = SKILL_LABELS_TH[skill] || skill; rowsHtml += `
`; }); skillHtml = `
`; } } // Consistency calendar — filtered to current view's sessions let calHtml = ''; { const bkkDate = d => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Bangkok', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d && d.toDate ? d.toDate() : (d instanceof Date ? d : new Date(d))); const activeDates = {}; validSessions.forEach(s => { if (!s.submittedAt) return; const ds = bkkDate(s.submittedAt); if (!activeDates[ds] || s.score > activeDates[ds]) activeDates[ds] = s.score || 0; }); _calBkkDate = bkkDate; _calActiveDates = activeDates; calHtml = `
`; } // History — stash all sessions by original index (needed by openHistDetail), // but only render entries that match the current view Object.keys(_statsSessionData).forEach(k => delete _statsSessionData[k]); let histItems = ''; let histCount = 0; sessions.forEach((s, i) => { _statsSessionData[i] = { answers: s.answers || [], passageId: s.passageId || '' }; const inView = isTgat1 ? s.mode === 'tgat1' : _isAlevelStatsMode(s.mode); if (!inView) return; histCount++; const d = s.submittedAt && s.submittedAt.toDate ? s.submittedAt.toDate() : null; const when = d ? d.toLocaleDateString('th-TH', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'; const modeLabel = MODE_LABELS_STATS[s.mode] || s.mode || ''; const sc = s.score || 0; const isVoided = _voidedPassageIds.has(s.passageId); const voidBadge = isVoided ? ' ยกเลิก' : ''; // TGAT 1 has no "on-time vs archive" concept (always freely accessible), so the // check-mark differentiation only applies to A-Level sessions. const doneCls = s.practice ? 'hist-check-yellow' : 'hist-check-green'; const doneTitle = s.practice ? 'ทำแบบฝึกหัดย้อนหลังแล้ว' : 'ทำตรงเวลาแล้ว'; const doneBadge = isTgat1 ? '' : ``; histItems += `
`; }); const histHtml = `
`; document.getElementById('statsInner').innerHTML = pbHtml + chartHtml + modeHtml + skillHtml + calHtml + histHtml; } window.openHistDetail = async function(idx) { const s = (_statsCachedSessions || [])[idx]; const { answers: userAnswers, passageId } = _statsSessionData[idx] || {}; document.getElementById('statsView').classList.add('hide'); const detailView = document.getElementById('histDetailView'); detailView.classList.remove('hide'); detailView.scrollTop = 0; document.getElementById('histDetailTitle').textContent = s ? (s.passageTitle || 'ไม่มีชื่อ') : ''; document.getElementById('histDetailInner').innerHTML = '
'; let p = _statsPassageCache[passageId]; if (!p && passageId) { try { const snap = await db.collection('readingPassages').doc(passageId).get(); p = snap.exists ? { id: snap.id, ...snap.data() } : null; if (p) _statsPassageCache[passageId] = p; } catch(e) { console.error(e); } } if (!p) { document.getElementById('histDetailInner').innerHTML = '
'; return; } const sc = s ? (typeof s.score === 'number' ? s.score : (s.total ? Math.round((s.correct / s.total) * 100) : 0)) : 0; const scoreCol = sc >= 70 ? 'var(--green)' : sc >= 50 ? '#f59e0b' : 'var(--red)'; const qs = Array.isArray(p.questions) ? p.questions : []; // Passage card const isImageOnlyHist = p.mode === 'alevel-visual' || p.mode === 'alevel-ads'; const imgHtml = p.imageUrl ? `
` : ''; const paraHtml = isImageOnlyHist ? '' : (p.content || '').split(/\n\s*\n/).map(para => `
${esc(para.trim())}
`).join(''); const srcHtml = p.source ? `
` : ''; const passageCardHtml = `
`; if (!qs.length) { document.getElementById('histDetailInner').innerHTML = passageCardHtml; return; } // Skill feedback from stored answers let skillsHtml = ''; if (userAnswers && userAnswers.length) { const _skW = {}, _skR = {}; qs.forEach((q, qi) => { if (!q.skill) return; if (userAnswers[qi] === q.correct) _skR[q.skill] = true; else _skW[q.skill] = true; }); const rightLabels = Object.keys(_skR).filter(k => !_skW[k]).map(k => SKILL_LABELS[k] || k); const wrongLabels = Object.keys(_skW).map(k => SKILL_LABELS[k] || k); if (rightLabels.length || wrongLabels.length) { skillsHtml = '
'; } } // Stats (ดูผล) tab content const timeStr = s && s.elapsedSec > 0 ? fmtClock(s.elapsedSec * 1000) : '—'; const wpm = s ? Math.min(s.wpm || 0, 600) : 0; const words = p.wordCount || wordCount(p.content); const resultHtml = `
`; // Questions (ดูเฉลย) tab content const qsHtml = qs.map((q, qi) => { const chosen = (userAnswers && userAnswers[qi] !== undefined) ? userAnswers[qi] : -1; const optsHtml = (q.options || []).map((opt, oi) => { let cls = 'opt'; if (oi === q.correct) cls += ' correct'; else if (oi === chosen && oi !== q.correct) cls += ' wrong'; return `
`;
}).join('');
const skillHtml = q.skill ? `
${esc(SKILL_LABELS[q.skill] || q.skill)}` : '';
const exBody = q.explain ? `เฉลย: ${esc(q.explain)}${skillHtml}` : (q.skill ? skillHtml.slice(4) : '');
return `
`; }).join(''); const omrCardHtml = `
`; document.getElementById('histDetailInner').innerHTML = passageCardHtml + omrCardHtml; }; window.hdTab = function(tab) { const rv = document.getElementById('hdResultView'); const qv = document.getElementById('hdReviewView'); const btns = document.querySelectorAll('#hdTabs .ogt-btn'); if (tab === 'result') { if (rv) rv.style.display = ''; if (qv) qv.style.display = 'none'; btns[0].classList.add('active'); btns[1].classList.remove('active'); } else { if (rv) rv.style.display = 'none'; if (qv) qv.style.display = ''; btns[0].classList.remove('active'); btns[1].classList.add('active'); } }; window.closeHistDetail = function() { document.getElementById('histDetailView').classList.add('hide'); document.getElementById('statsView').classList.remove('hide'); }; // ── End stats view ──────────────────────────────────────────────────────── // ── Vocab tap / word gloss ──────────────────────────────────────────────── // Click-to-translate: single-word click (unchanged trigger) plus drag-select for short // phrases (idioms/phrasal verbs a bare word can't cover). Both paths funnel into // lookupAndShowGloss(), which sends the word/phrase AND its sentence to the // Claude-Haiku-backed wordGloss function — see functions/index.js for why (context-aware // gloss instead of the old bare-word Longdo scrape) and the per-passage caching that // keeps this cheap even at 100-200 users/day reading the same daily passage. const _glossCache = {}; let _activeWEl = null; // Touch capability, not screen width — an iPad is >639px in both orientations but has // exactly the same fiddly-drag-selection problem a phone does, so the extend chip needs // to key off "is this a touchscreen" rather than "is this a narrow window." const IS_COARSE_POINTER = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); // ── Mobile phrase builder state (tap-to-extend, see CSS comment above .wg-extend-btn) ── let _phraseMode = false; let _phraseAnchorEl = null; let _phraseStartEl = null; let _phraseCurrentEls = []; let _phraseRange = null; function activateVocabMode() { const body = document.getElementById('passageBody'); if (!body || body.dataset.vocabReady) return; body.dataset.vocabReady = '1'; // Walk all text nodes; wrap English word tokens in const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); for (const node of nodes) { const text = node.textContent; if (!/[A-Za-z]/.test(text)) continue; const parts = text.split(/([A-Za-z][A-Za-z'-]*)/); if (parts.length < 2) continue; const frag = document.createDocumentFragment(); for (const part of parts) { if (/^[A-Za-z]/.test(part)) { const sp = document.createElement('span'); sp.className = 'w'; sp.textContent = part; frag.appendChild(sp); } else { frag.appendChild(document.createTextNode(part)); } } node.parentNode.replaceChild(frag, node); } body.classList.add('vocab-mode'); // Insert hint strip with lookup counter above passage body if (!document.getElementById('vocabHintStrip')) { const strip = document.createElement('div'); strip.id = 'vocabHintStrip'; strip.className = 'vocab-hint-strip'; strip.innerHTML = `กดคำ หรือลากคลุมวลี เพื่อดูคำแปลภาษาไทย0/${GLOSS_LIMIT} คำ`; body.parentNode.insertBefore(strip, body); } updateGlossCounter(); } // Delegated click on passage body — single-word path document.getElementById('passageBody').addEventListener('click', async function(e) { const wEl = e.target.closest('.w'); if (_phraseMode) { // Swallow every tap in the passage while building a phrase — a tap on a word // extends the range, a tap elsewhere does nothing (use the ยกเลิก button to bail). e.stopPropagation(); if (wEl) _extendPhraseTo(wEl); return; } if (!wEl) { closeWordGloss(); return; } if (!this.classList.contains('vocab-mode')) return; e.stopPropagation(); const raw = wEl.textContent.trim(); const word = raw.replace(/[^A-Za-z'-]/g, '').replace(/^['-]+|['-]+$/g, ''); if (!word || word.length < 2) return; if (_activeWEl) _activeWEl.classList.remove('wg-active'); _activeWEl = wEl; wEl.classList.add('wg-active'); wEl.classList.remove('wg-tap-pulse'); void wEl.offsetWidth; // restart the animation even on a fast repeat tap of the same word wEl.classList.add('wg-tap-pulse'); wEl.addEventListener('animationend', () => wEl.classList.remove('wg-tap-pulse'), { once: true }); await lookupAndShowGloss(word, _extractSentence(wEl), wEl.getBoundingClientRect()); _maybeShowExtendChip(wEl); }); // Only offered on touchscreens (phone or tablet) — a mouse-driven window keeps // drag-selection (see translateSelection below), since dragging with a pointer is fine; // it's dragging with a finger that's fiddly, regardless of how wide the screen is. function _maybeShowExtendChip(wEl) { const chip = document.getElementById('wgExtendBtn'); if (!chip) return; const glossVisible = !document.getElementById('wordGloss').classList.contains('hide'); if (glossVisible && IS_COARSE_POINTER) chip.classList.remove('hide'); else chip.classList.add('hide'); } function _paraOf(el) { const body = document.getElementById('passageBody'); let p = el; while (p && p.parentElement !== body && p !== body) p = p.parentElement; return p; } window.enterPhraseExtendMode = function() { if (!_activeWEl) return; _phraseMode = true; _phraseAnchorEl = _activeWEl; _phraseStartEl = _activeWEl; _phraseCurrentEls = [_activeWEl]; _phraseRange = null; document.getElementById('passageBody').classList.add('phrase-extend-active'); document.getElementById('wgExtendBtn').classList.add('hide'); document.getElementById('wgMeta').classList.add('hide'); document.getElementById('wgThai').classList.add('hide'); document.getElementById('wgPhraseBar').classList.remove('hide'); document.getElementById('wgPhrasePreview').textContent = _phraseAnchorEl.textContent.trim(); const strip = document.getElementById('vocabHintStrip'); if (strip && strip.dataset.savedHtml === undefined) { strip.dataset.savedHtml = strip.innerHTML; strip.innerHTML = `แตะคำถัดไปเพื่อขยายวลี`; } }; function _extendPhraseTo(tappedEl) { if (tappedEl === _phraseAnchorEl && _phraseCurrentEls.length === 1) return; const para = _paraOf(_phraseAnchorEl); if (!para || _paraOf(tappedEl) !== para) { toast('ขยายวลีได้เฉพาะในย่อหน้าเดียวกัน', ''); return; } const allW = Array.from(para.querySelectorAll('.w')); const ai = allW.indexOf(_phraseAnchorEl); const ti = allW.indexOf(tappedEl); if (ai === -1 || ti === -1) return; const lo = Math.min(ai, ti), hi = Math.max(ai, ti); if (hi - lo + 1 > 6) { toast('วลียาวเกินไป (สูงสุด 6 คำ)', ''); return; } _phraseCurrentEls.forEach(el => el.classList.remove('wg-active')); _phraseCurrentEls = allW.slice(lo, hi + 1); _phraseCurrentEls.forEach(el => el.classList.add('wg-active')); _phraseStartEl = allW[lo]; const range = document.createRange(); range.setStartBefore(allW[lo]); range.setEndAfter(allW[hi]); _phraseRange = range; document.getElementById('wgPhrasePreview').textContent = range.toString().trim().replace(/\s+/g, ' '); } function _exitPhraseMode() { _phraseMode = false; document.getElementById('passageBody').classList.remove('phrase-extend-active'); _phraseCurrentEls.forEach(el => el.classList.remove('wg-active')); _phraseCurrentEls = []; _phraseAnchorEl = null; _phraseStartEl = null; _phraseRange = null; document.getElementById('wgMeta').classList.remove('hide'); document.getElementById('wgThai').classList.remove('hide'); document.getElementById('wgPhraseBar').classList.add('hide'); const strip = document.getElementById('vocabHintStrip'); if (strip && strip.dataset.savedHtml !== undefined) { strip.innerHTML = strip.dataset.savedHtml; delete strip.dataset.savedHtml; } } window.cancelPhraseExtendMode = function() { _exitPhraseMode(); closeWordGloss(); }; window.confirmPhraseExtend = async function() { const text = (_phraseRange ? _phraseRange.toString() : (_phraseAnchorEl && _phraseAnchorEl.textContent) || '') .trim().replace(/\s+/g, ' '); const startEl = _phraseStartEl || _phraseAnchorEl; const rect = _phraseRange ? _phraseRange.getBoundingClientRect() : (startEl && startEl.getBoundingClientRect()); _exitPhraseMode(); if (_activeWEl) { _activeWEl.classList.remove('wg-active'); _activeWEl = null; } if (!text || text.length < 2 || !startEl) return; await lookupAndShowGloss(text, _extractSentence(startEl), rect); }; // ── Phrase selection → translate (extends the click path to short multi-word // selections). A confirm popover, not an instant lookup, so idle text-selecting // (e.g. while reading) never spends a quota slot. ── let _selDebounce = null; let _selPopoverText = null; let _selPopoverRange = null; document.addEventListener('selectionchange', () => { clearTimeout(_selDebounce); _selDebounce = setTimeout(_handleSelectionChange, 150); }); function _handleSelectionChange() { const body = document.getElementById('passageBody'); const popover = document.getElementById('selTranslateBtn'); if (!body || !popover || !body.classList.contains('vocab-mode')) return; const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) { popover.classList.add('hide'); return; } const range = sel.getRangeAt(0); if (!body.contains(range.commonAncestorContainer)) { popover.classList.add('hide'); return; } const text = sel.toString().trim().replace(/\s+/g, ' '); // Only offer for a short, plausible phrase — not an accidental whole-paragraph drag. if (!text || text.length < 2 || text.length > 60 || text.split(' ').length > 6 || !/[A-Za-z]/.test(text)) { popover.classList.add('hide'); return; } _selPopoverText = text; _selPopoverRange = range.cloneRange(); const rect = range.getBoundingClientRect(); const pW = 60, pH = 34; let left = rect.left + rect.width / 2 - pW / 2; left = Math.max(8, Math.min(left, window.innerWidth - pW - 8)); let top = rect.top - pH - 8; if (top < 8) top = rect.bottom + 8; popover.style.left = left + 'px'; popover.style.top = top + 'px'; popover.classList.remove('hide'); } window.translateSelection = async function() { const text = _selPopoverText; const range = _selPopoverRange; document.getElementById('selTranslateBtn').classList.add('hide'); const sel = window.getSelection(); if (sel) sel.removeAllRanges(); if (!text || !range) return; if (_activeWEl) { _activeWEl.classList.remove('wg-active'); _activeWEl = null; } await lookupAndShowGloss(text, _extractSentence(range.startContainer), range.getBoundingClientRect()); }; // Walk outward from a node through its paragraph's siblings, collecting text until a // sentence-ending punctuation mark (or the paragraph boundary) on each side — a plain-DOM // approximation of "the sentence this word/selection sits in," sent to wordGloss as context. function _extractSentence(node) { const body = document.getElementById('passageBody'); let para = node.nodeType === 3 ? node.parentElement : node; while (para && para.parentElement !== body && para !== body) para = para.parentElement; if (!para || !para.parentElement) return node.textContent || ''; const nodes = Array.from(para.childNodes); const anchor = node.nodeType === 3 ? node : node; let idx = nodes.indexOf(anchor); if (idx === -1) { // Selection may start inside a text node that isn't a direct child (rare with the // .w-span structure, but be defensive) — fall back to the whole paragraph text. return (para.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 500); } let before = ''; for (let i = idx - 1; i >= 0; i--) { const t = nodes[i].textContent || ''; const m = t.match(/[.!?][^.!?]*$/); if (m) { before = t.slice(t.lastIndexOf(m[0]) + 1) + before; break; } before = t + before; } let after = ''; for (let i = idx + 1; i < nodes.length; i++) { const t = nodes[i].textContent || ''; const m = t.match(/[.!?]/); if (m) { after += t.slice(0, m.index + 1); break; } after += t; } return (before + (anchor.textContent || '') + after).replace(/\s+/g, ' ').trim().slice(0, 500); } async function lookupAndShowGloss(text, sentence, rect) { // Only count quota for new (non-cached) lookups const cacheKey = text.toLowerCase(); if (!(cacheKey in _glossCache)) { if (_glossUsed >= GLOSS_LIMIT) { toast(`ใช้การค้นหาคำครบ ${GLOSS_LIMIT} คำแล้วสำหรับบทอ่านนี้`, ''); return; } _glossUsed++; updateGlossCounter(); } const gloss = document.getElementById('wordGloss'); document.getElementById('wgWord').textContent = text; document.getElementById('wgMeta').textContent = ''; document.getElementById('wgMeta').classList.add('hide'); // no part-of-speech yet — an empty chip would just be a blank blob document.getElementById('wgThai').classList.remove('hide'); document.getElementById('wgThai').innerHTML = 'กำลังค้นหา…'; document.getElementById('wgExtendBtn').classList.add('hide'); document.getElementById('wgPhraseBar').classList.add('hide'); positionWordGloss(rect); gloss.classList.remove('hide'); document.getElementById('wgBackdrop').classList.remove('hide'); const g = await fetchWordGloss(text, sentence); const metaEl = document.getElementById('wgMeta'); metaEl.textContent = g.type || ''; metaEl.classList.toggle('hide', !g.type); const thaiEl = document.getElementById('wgThai'); if (g.thai) { thaiEl.textContent = g.thai; } else { thaiEl.innerHTML = 'ไม่พบคำแปล'; } } function positionWordGloss(rect) { const gloss = document.getElementById('wordGloss'); if (window.innerWidth <= 639) return; // bottom sheet on mobile — no positioning needed gloss.style.visibility = 'hidden'; gloss.classList.remove('hide'); const gW = gloss.offsetWidth || 180; const gH = gloss.offsetHeight || 70; let left = rect.left + rect.width / 2 - gW / 2; left = Math.max(8, Math.min(left, window.innerWidth - gW - 8)); let top = rect.top - gH - 10; const placedAbove = top >= 8; if (!placedAbove) top = rect.bottom + 8; gloss.style.left = left + 'px'; gloss.style.top = top + 'px'; gloss.classList.toggle('wg-tail-down', placedAbove); // card sits above the word — tail points down at it gloss.classList.toggle('wg-tail-up', !placedAbove); // card sits below the word — tail points up at it // Keep the tail lined up with the word's actual center even when the card itself // got clamped away from the viewport edge (so left !== rect center - gW/2). const tailLeft = Math.max(14, Math.min(rect.left + rect.width / 2 - left, gW - 14)); gloss.style.setProperty('--wg-tail-left', tailLeft + 'px'); gloss.style.visibility = ''; } window.closeWordGloss = function() { if (_phraseMode) _exitPhraseMode(); document.getElementById('wordGloss').classList.add('hide'); document.getElementById('wgBackdrop').classList.add('hide'); if (_activeWEl) { _activeWEl.classList.remove('wg-active'); _activeWEl = null; } }; document.addEventListener('click', function(e) { if (document.getElementById('wordGloss').classList.contains('hide')) return; if (!e.target.closest('#wordGloss') && !e.target.closest('#selTranslateBtn') && !e.target.classList.contains('w')) closeWordGloss(); }); const _gradeReadingFn = firebase.functions().httpsCallable('gradeReading'); const _wordGlossFn = firebase.functions().httpsCallable('wordGloss'); function updateGlossCounter() { const strip = document.getElementById('vocabHintStrip'); if (!strip) return; const remaining = GLOSS_LIMIT - _glossUsed; if (remaining <= 0) { strip.classList.add('exhausted'); strip.innerHTML = `ใช้ครบ ${GLOSS_LIMIT} คำแล้ว — ฝึกศัพท์ไม่จำกัดที่ PudVocab →`; return; } strip.classList.remove('exhausted'); const counter = strip.querySelector('.gloss-counter'); if (!counter) return; counter.textContent = `${_glossUsed}/${GLOSS_LIMIT} คำ`; counter.style.background = remaining <= 5 ? 'rgba(212,134,11,0.14)' : ''; counter.style.color = remaining <= 5 ? 'var(--amber)' : ''; } async function fetchWordGloss(text, sentence) { const cacheKey = text.toLowerCase(); if (cacheKey in _glossCache) return _glossCache[cacheKey]; try { const res = await _wordGlossFn({ text, sentence, passageId: passage && passage.id }); const d = res.data || {}; _glossCache[cacheKey] = d; return d; } catch (e) { console.warn('[wordGloss]', e.message); if (e.code === 'functions/resource-exhausted') { toast('ใช้การค้นหาคำครบสำหรับวันนี้แล้ว ลองใหม่พรุ่งนี้นะ', 'err'); } // Deliberately not cached — a transient failure shouldn't stick as a permanent miss. return {}; } } // ── End vocab tap ────────────────────────────────────────────────────────── // ── Share result ───────────────────────────────────────────────────────── function _tomorrowText() { // Compute ms remaining until midnight in Bangkok (UTC+7) const ms = bangkokDayEnd(bangkokToday()) - new Date(); const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); if (h > 0) return `มาในอีก ${h} ชั่วโมง ${m} นาที`; return `มาในอีก ${m} นาที`; } async function _loadTomorrowPassageTease() { try { const bkkNow = new Date(Date.now() + 7 * 3600 * 1000); const tmrwStr = new Date(Date.UTC(bkkNow.getUTCFullYear(), bkkNow.getUTCMonth(), bkkNow.getUTCDate() + 1)).toISOString().slice(0, 10); const snap = await db.collection('readingPassages').where('activeDate', '==', tmrwStr).limit(1).get(); const topicEl = document.getElementById('tmrwTopic'); if (!topicEl) return; if (snap.empty) { topicEl.textContent = 'บทอ่านใหม่'; return; } const p = snap.docs[0].data(); const full = MODE_LABELS[p.mode] || ''; const typeLabel = full.replace('A-Level อังกฤษ: ', '').replace('A-Level อังกฤษ', 'A-Level'); topicEl.textContent = typeLabel || 'บทอ่านใหม่'; } catch(e) { /* silent */ } } function _startTmrwCountdown(el) { if (!el) return; function tick() { const now = new Date(); const ms = bangkokDayEnd(bangkokToday()) - now; if (ms <= 0) { el.textContent = 'ถึงเวลาแล้ว!'; return; } const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); const s = Math.floor((ms % 60000) / 1000); el.textContent = `${h} ชั่วโมง ${m} นาที ${s} วินาที`; setTimeout(tick, 1000); } tick(); } window.shareResult = function() { if (!_shareData) return; const { score, correct, total, wpm, elapsedSec, dailyStreak } = _shareData; const timeStr = fmtClock(elapsedSec * 1000); const streakLine = dailyStreak ? `🔥 ติดต่อกัน ${dailyStreak} วัน ` : ''; const text = `📖 ReadingLab วันนี้ ✅ ${score}% — ${correct}/${total} ข้อถูก ⏱️ ${timeStr} · ${wpm} คำ/นาที ${streakLine}chestudygroup.com/readinglab`; if (navigator.share) { navigator.share({ title: 'ReadingLab', text, url: 'https://www.chestudygroup.com/readinglab' }).catch(() => {}); } else { const fallback = () => { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); toast('คัดลอกแล้ว!'); }; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => toast('คัดลอกแล้ว!')).catch(fallback); } else { fallback(); } } }; // ── Review cancel confirm ──────────────────────────────────────────────── window._cancelReview = function() { document.getElementById('reviewMainContent').classList.add('hide'); document.getElementById('reviewCancelConfirm').classList.remove('hide'); }; window._confirmCancelNo = function() { document.getElementById('reviewCancelConfirm').classList.add('hide'); document.getElementById('reviewMainContent').classList.remove('hide'); }; window._confirmCancelYes = function() { _closeReviewModal(null); }; // ── End share / cancel confirm ────────────────────────────────────────────