// ── THEME SYSTEM ──────────────────────────────────────────────
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('sh_theme', theme);
['dark','light','rose','ocean','lavender'].forEach(t => {
const btn = document.getElementById('theme-' + t);
if (btn) btn.style.borderColor = t === theme ? 'var(--accent)' : 'transparent';
});
}
function loadTheme() {
const saved = localStorage.getItem('sh_theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
setTimeout(() => setTheme(saved), 50);
}
// ── SKELETON LOADER ────────────────────────────────────────────
function skeletonRows(n=3, h='48px') {
return Array(n).fill('').map(() =>
`
`
).join('');
}
// ── CHAT CHIPS ────────────────────────────────────────────────
function chatChip(prompt) {
const input = document.getElementById('chat-input');
if (input) { input.value = prompt; sendChat(); }
}
// ── STATE ───────────────────────────────────────────────────
const S = {
claudeKey: localStorage.getItem('sh_claude') || '',
canvasUrl: localStorage.getItem('sh_canvas_url') || '',
canvasToken: localStorage.getItem('sh_canvas_token') || '',
n8nUrl: localStorage.getItem('sh_n8n_url') || '',
useProxy: localStorage.getItem('sh_use_proxy') === 'true',
googleToken: sessionStorage.getItem('sh_gtoken') || '',
msToken: sessionStorage.getItem('sh_mstoken') || '',
googleClient: localStorage.getItem('sh_gclient') || '',
msClient: localStorage.getItem('sh_msclient') || '',
appUrl: localStorage.getItem('sh_appurl') || window.location.origin + window.location.pathname,
docs: JSON.parse(localStorage.getItem('sh_docs') || '[]'),
activeDoc: null,
chatHistory: [],
notes: localStorage.getItem('sh_notes') || '',
lastResult: '',
currentView: 'sources',
};
const CLAUDE_MODEL = 'claude-opus-4-5';
// ── PROXY HELPERS ───────────────────────────────────────────
async function claudeFetch(body) {
if (S.n8nUrl) {
const res = await fetch(`${S.n8nUrl}/webhook/claude`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (!res.ok) throw new Error(`Proxy error ${res.status}`);
return res.json();
}
if (!S.claudeKey) throw new Error('No Claude API key — add it in Settings');
const data = await claudeFetch(body);
}
// ── INIT ────────────────────────────────────────────────────
// ── THEME SYSTEM ─────────────────────────────────────────────
function init() {
try { loadTheme(); } catch(e) {}
loadSettings();
document.getElementById('notes-textarea').value = S.notes;
checkOAuthCallback();
renderDocsList();
setView('sources');
buildCanvasBookmarklet();
trackerRender();
timerUpdate();
const ci = document.getElementById('chat-input');
ci.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
});
ci.addEventListener('input', () => {
ci.style.height = 'auto';
ci.style.height = Math.min(ci.scrollHeight, 120) + 'px';
});
// Keyboard shortcut: Enter in modal title field
document.getElementById('ta-title').addEventListener('keydown', e => {
if (e.key === 'Enter') trackerSaveAdd();
});
}
// ── VIEW ROUTING ────────────────────────────────────────────
function setView(v) {
S.currentView = v;
document.querySelectorAll('.view').forEach(el => el.style.display = 'none');
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const el = document.getElementById('view-' + v);
if (el) el.style.display = 'flex';
if (v === 'tracker') { setTimeout(trackerRender, 0); }
if (v === 'grades') { setTimeout(gradesInitView, 0); }
if (v === 'citations') { setTimeout(citationRender, 0); }
const nav = document.getElementById('nav-' + v);
if (nav) nav.classList.add('active');
if (v === 'analyze') renderAnalyzeView();
if (v === 'docs') renderDocsList();
if (v === 'chat') updateChatSub();
}
// ── SETTINGS ────────────────────────────────────────────────
function loadSettings() {
document.getElementById('s-claude-key').value = S.claudeKey;
document.getElementById('s-canvas-url').value = S.canvasUrl;
document.getElementById('s-canvas-token').value = S.canvasToken;
if(document.getElementById('s-n8n-url')) document.getElementById('s-n8n-url').value = S.n8nUrl;
document.getElementById('s-google-client').value = S.googleClient;
document.getElementById('s-ms-client').value = S.msClient;
document.getElementById('s-app-url').value = S.appUrl;
}
function saveSettings() {
S.claudeKey = document.getElementById('s-claude-key').value.trim();
S.canvasUrl = document.getElementById('s-canvas-url').value.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
S.canvasToken = document.getElementById('s-canvas-token').value.trim();
S.n8nUrl = (document.getElementById('s-n8n-url')?.value||'').trim().replace(/\/$/, '');
S.useProxy = !!S.n8nUrl;
localStorage.setItem('sh_n8n_url', S.n8nUrl);
localStorage.setItem('sh_use_proxy', S.useProxy);
S.googleClient = document.getElementById('s-google-client').value.trim();
S.msClient = document.getElementById('s-ms-client').value.trim();
S.appUrl = document.getElementById('s-app-url').value.trim() || S.appUrl;
localStorage.setItem('sh_claude', S.claudeKey);
localStorage.setItem('sh_canvas_url', S.canvasUrl);
localStorage.setItem('sh_canvas_token', S.canvasToken);
localStorage.setItem('sh_gclient', S.googleClient);
localStorage.setItem('sh_msclient', S.msClient);
localStorage.setItem('sh_appurl', S.appUrl);
toast('✓ Settings saved');
buildCanvasBookmarklet();
}
// ── GOOGLE OAUTH ─────────────────────────────────────────────
function connectGoogle() {
if (!S.googleClient) { toast('⚠ Enter Google Client ID in Settings first'); setView('settings'); return; }
const params = new URLSearchParams({
client_id: S.googleClient,
redirect_uri: S.appUrl,
response_type: 'token',
scope: 'https://www.googleapis.com/auth/documents.readonly https://www.googleapis.com/auth/drive.readonly',
state: 'google',
});
window.location.href = 'https://accounts.google.com/o/oauth2/v2/auth?' + params;
}
async function fetchGoogleDocs() {
if (!S.googleToken) return [];
try {
const res = await fetch(
"https://www.googleapis.com/drive/v3/files?q=mimeType='application/vnd.google-apps.document'&fields=files(id,name,modifiedTime)&pageSize=30",
{ headers: { Authorization: 'Bearer ' + S.googleToken } }
);
const data = await res.json();
if (data.error) { S.googleToken = ''; sessionStorage.removeItem('sh_gtoken'); return []; }
return (data.files || []).map(f => ({
id: 'g_' + f.id, source: 'google', name: f.name, rawId: f.id,
meta: 'Google Doc', icon: '📄', modified: f.modifiedTime,
}));
} catch { return []; }
}
async function fetchGoogleDocContent(rawId) {
const res = await fetch(
`https://docs.googleapis.com/v1/documents/${rawId}`,
{ headers: { Authorization: 'Bearer ' + S.googleToken } }
);
const data = await res.json();
if (data.error) return null;
let text = '';
const body = data.body?.content || [];
for (const el of body) {
if (el.paragraph) {
for (const pe of (el.paragraph.elements || [])) {
text += (pe.textRun?.content || '');
}
}
}
return { title: data.title, content: text.trim() };
}
// ── MICROSOFT OAUTH ──────────────────────────────────────────
function connectMicrosoft() {
if (!S.msClient) { toast('⚠ Enter Microsoft Client ID in Settings first'); setView('settings'); return; }
const params = new URLSearchParams({
client_id: S.msClient,
redirect_uri: S.appUrl,
response_type: 'token',
scope: 'Files.Read Notes.Read User.Read',
state: 'microsoft',
});
window.location.href = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` + params;
}
async function fetchMsDocs() {
if (!S.msToken) return [];
try {
const [filesRes, notesRes] = await Promise.allSettled([
fetch('https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=file ne null&$select=id,name,lastModifiedDateTime,file&$top=30',
{ headers: { Authorization: 'Bearer ' + S.msToken } }),
fetch('https://graph.microsoft.com/v1.0/me/onenote/pages?$select=id,title,lastModifiedDateTime&$top=30',
{ headers: { Authorization: 'Bearer ' + S.msToken } }),
]);
let docs = [];
if (filesRes.status === 'fulfilled') {
const data = await filesRes.value.json();
const wordFiles = (data.value || []).filter(f => f.file?.mimeType?.includes('word') || f.name?.endsWith('.docx'));
docs.push(...wordFiles.map(f => ({
id: 'ms_' + f.id, source: 'word', name: f.name, rawId: f.id,
meta: 'Word Doc', icon: '📘', modified: f.lastModifiedDateTime,
})));
}
if (notesRes.status === 'fulfilled') {
const data = await notesRes.value.json();
docs.push(...(data.value || []).map(f => ({
id: 'on_' + f.id, source: 'onenote', name: f.title || 'Untitled', rawId: f.id,
meta: 'OneNote', icon: '🗒', modified: f.lastModifiedDateTime,
})));
}
return docs;
} catch { return []; }
}
async function fetchMsDocContent(doc) {
if (doc.source === 'onenote') {
const res = await fetch(`https://graph.microsoft.com/v1.0/me/onenote/pages/${doc.rawId}/content`,
{ headers: { Authorization: 'Bearer ' + S.msToken } });
const html = await res.text();
const tmp = document.createElement('div');
tmp.innerHTML = html;
return { title: doc.name, content: tmp.innerText?.trim() || '' };
} else {
// Word: get download URL
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/items/${doc.rawId}?$select=@microsoft.graph.downloadUrl,name`,
{ headers: { Authorization: 'Bearer ' + S.msToken } });
const data = await res.json();
return { title: data.name, content: '[Word document detected — text extraction requires server-side processing. Use the content preview from OneDrive or paste the text manually.]' };
}
}
// ── OAUTH CALLBACK HANDLER ───────────────────────────────────
function checkOAuthCallback() {
const hash = window.location.hash;
if (!hash.includes('access_token')) return;
const params = new URLSearchParams(hash.substring(1));
const token = params.get('access_token');
const state = params.get('state');
if (!token) return;
history.replaceState(null, '', window.location.pathname);
if (state === 'google') {
S.googleToken = token;
sessionStorage.setItem('sh_gtoken', token);
updateConnectionStatus();
toast('✓ Google connected! Loading docs…');
refreshDocs();
setView('docs');
} else if (state === 'microsoft') {
S.msToken = token;
sessionStorage.setItem('sh_mstoken', token);
updateConnectionStatus();
toast('✓ Microsoft connected! Loading docs…');
refreshDocs();
setView('docs');
}
}
function updateConnectionStatus() {
const gs = document.getElementById('google-status');
const ms = document.getElementById('ms-status');
if (S.googleToken) { gs.textContent = 'Connected'; gs.className = 'source-status status-connected'; document.getElementById('google-card').classList.add('connected'); }
else { gs.textContent = 'Disconnected'; gs.className = 'source-status status-disconnected'; }
if (S.msToken) { ms.textContent = 'Connected'; ms.className = 'source-status status-connected'; document.getElementById('ms-card').classList.add('connected'); }
else { ms.textContent = 'Disconnected'; ms.className = 'source-status status-disconnected'; }
}
// ── MANUAL URL PASTING ───────────────────────────────────────
function manualGooglePaste() {
const s = document.getElementById('google-url-strip');
s.style.display = s.style.display === 'none' ? 'block' : 'none';
}
function manualMsPaste() {
const s = document.getElementById('ms-url-strip');
s.style.display = s.style.display === 'none' ? 'block' : 'none';
}
async function loadGoogleDocByUrl() {
const url = document.getElementById('google-url-input').value.trim();
const match = url.match(/\/d\/([a-zA-Z0-9_-]+)/);
if (!match) { toast('⚠ Invalid Google Docs URL'); return; }
const id = match[1];
const docMeta = { id: 'g_' + id, source: 'google', name: 'Google Doc (from URL)', rawId: id, meta: 'Google Doc', icon: '📄', needsToken: true };
if (S.googleToken) {
toast('Loading doc…');
const content = await fetchGoogleDocContent(id);
if (content) { docMeta.name = content.title; docMeta.content = content.content; }
} else {
docMeta.content = '[Connect Google account to read full content, or paste the document text manually in the Chat tab]';
}
addDoc(docMeta);
toast('✓ Document added');
setView('docs');
}
async function loadMsDocByUrl() {
const url = document.getElementById('ms-url-input').value.trim();
if (!url) return;
const doc = { id: 'ms_manual_' + Date.now(), source: 'word', name: 'Microsoft Document', rawId: null, meta: 'Word/OneNote', icon: '🪟', content: '[Paste document text in Chat → Context tab, or connect Microsoft account for automatic reading]' };
addDoc(doc);
toast('✓ Document added');
setView('docs');
}
// ── DOC MANAGEMENT ───────────────────────────────────────────
function addDoc(doc) {
const existing = S.docs.findIndex(d => d.id === doc.id);
if (existing >= 0) S.docs[existing] = doc;
else S.docs.unshift(doc);
localStorage.setItem('sh_docs', JSON.stringify(S.docs));
}
async function refreshDocs() {
toast('Refreshing documents…');
const [gDocs, msDocs] = await Promise.all([
S.googleToken ? fetchGoogleDocs() : [],
S.msToken ? fetchMsDocs() : [],
]);
const existing = S.docs.filter(d => d.source === 'manual' || d.source === 'canvas');
S.docs = [...gDocs, ...msDocs, ...existing];
localStorage.setItem('sh_docs', JSON.stringify(S.docs));
renderDocsList();
toast(`✓ ${S.docs.length} documents loaded`);
updateConnectionStatus();
}
let docFilter = 'all';
function filterDocs(f, btn) {
docFilter = f;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderDocsList();
}
function renderDocsList() {
const container = document.getElementById('docs-list-container');
let docs = S.docs;
if (docFilter !== 'all') docs = docs.filter(d => d.source === docFilter);
if (!docs.length) {
container.innerHTML = `
📂
No documents
${docFilter === 'all' ? 'Connect a source or paste a document URL in Connections.' : 'No ' + docFilter + ' documents found.'}
`;
return;
}
const list = document.createElement('div');
list.className = 'doc-list';
docs.forEach(doc => {
const item = document.createElement('div');
item.className = 'doc-item' + (S.activeDoc?.id === doc.id ? ' selected' : '');
item.innerHTML = `
${doc.icon}
${doc.name}
${doc.meta}
`;
item.onclick = () => selectDoc(doc);
list.appendChild(item);
});
container.innerHTML = '';
container.appendChild(list);
}
async function selectDoc(doc) {
S.activeDoc = doc;
// Load content if not loaded
if (!doc.content && S.googleToken && doc.source === 'google') {
toast('Loading document content…');
const c = await fetchGoogleDocContent(doc.rawId);
if (c) { doc.content = c.content; doc.name = c.title; addDoc(doc); }
} else if (!doc.content && S.msToken && (doc.source === 'onenote' || doc.source === 'word')) {
toast('Loading document content…');
const c = await fetchMsDocContent(doc);
if (c) { doc.content = c.content; addDoc(doc); }
}
document.getElementById('analyze-selected-btn').disabled = false;
document.getElementById('active-doc-pill').style.display = 'block';
document.getElementById('active-doc-name').textContent = doc.name;
renderDocsList();
toast('✓ ' + doc.name + ' selected');
}
function analyzeSelected() {
if (!S.activeDoc) return;
setView('analyze');
}
// ── ANALYZE VIEW ─────────────────────────────────────────────
const TOOLS = [
{ id: 'summarize', icon: '📋', label: 'Summarize', desc: 'TL;DR of key points',
prompt: 'Summarize this document concisely. Extract the main goal, requirements, and key points.' },
{ id: 'breakdown', icon: '🪜', label: 'Step-by-Step', desc: 'Numbered action items',
prompt: 'Break this assignment into clear, numbered actionable steps. Make each step specific and achievable.' },
{ id: 'simplify', icon: '💡', label: 'Simplify', desc: 'Plain English rewrite',
prompt: 'Rewrite these instructions in simple, plain English. Remove all jargon and academic language.' },
{ id: 'rubric', icon: '✅', label: 'Rubric Guide', desc: 'How to get full marks',
prompt: 'Based on the requirements and rubric in this document, tell me exactly what to do to score full marks. List each criterion.' },
{ id: 'outline', icon: '📝', label: 'Outline', desc: 'Writing structure',
prompt: 'Create a detailed essay/assignment outline with sections and sub-bullets for what to cover in each.' },
{ id: 'schedule', icon: '⏱️', label: 'Time Plan', desc: 'Study schedule',
prompt: 'Create a realistic Pomodoro-style study schedule to complete this assignment. Include time estimates for each section.' },
{ id: 'keyterms', icon: '🔑', label: 'Key Terms', desc: 'Important vocab/concepts',
prompt: 'Extract and define all key terms, concepts, formulas, or important vocabulary from this document.' },
{ id: 'questions', icon: '❓', label: 'Practice Qs', desc: 'Study questions',
prompt: 'Generate 5 practice questions based on this document that would test understanding of the material.' },
];
// ── ANALYZE TAB SWITCHING ────────────────────────────────────
function switchAnalyzeTab(tab) {
['tools','qb'].forEach(t => {
document.getElementById('apanel-' + t).style.display = t === tab ? 'flex' : 'none';
document.getElementById('atab-' + t).classList.toggle('active', t === tab);
});
const isTool = tab === 'tools';
document.getElementById('btn-copy-result').style.display = isTool && S.lastResult ? 'inline-flex' : 'none';
document.getElementById('btn-save-notes').style.display = isTool && S.lastResult ? 'inline-flex' : 'none';
document.getElementById('qb-save-all-btn').style.display = !isTool && QB.questions.length ? 'inline-flex' : 'none';
}
function renderAnalyzeView() {
const main = document.getElementById('analyze-main');
const noDoc = document.getElementById('no-doc-state');
const title = document.getElementById('analyze-doc-title');
if (!S.activeDoc) {
main.style.display = 'none';
noDoc.style.display = 'flex';
return;
}
main.style.display = 'flex';
noDoc.style.display = 'none';
title.textContent = S.activeDoc.name;
const grid = document.getElementById('tool-grid');
grid.innerHTML = '';
TOOLS.forEach(t => {
const chip = document.createElement('div');
chip.className = 'tool-chip';
chip.id = 'chip-' + t.id;
chip.innerHTML = `${t.icon}
${t.label}
${t.desc}
`;
chip.onclick = () => runTool(t);
grid.appendChild(chip);
});
const preview = document.getElementById('doc-preview');
preview.textContent = (S.activeDoc.content || 'No content loaded.').slice(0, 2000) + (S.activeDoc.content?.length > 2000 ? '…' : '');
resetResultBox();
// keep whichever tab was last open
switchAnalyzeTab(QB._activeTab || 'tools');
}
function resetResultBox() {
const rb = document.getElementById('result-box');
rb.className = 'result-box empty';
rb.innerHTML = 'Click a tool to analyze your document';
document.getElementById('result-actions').style.display = 'none';
const rl = document.getElementById('result-label');
rl.querySelector('.dot').classList.remove('pulse');
}
// ── QUESTION BREAKDOWN ENGINE ────────────────────────────────
const QB = {
questions: [], // [{id,type,text,points,subItems}]
analyses: {}, // {id: {what,how,tips,points,done}}
activeId: null,
parsing: false,
_activeTab:'tools',
};
const QB_PARSE_SYSTEM = `You are an academic document parser. Extract every distinct question, task, or deliverable from the assignment.
Output ONLY valid JSON — no markdown, no explanation. Format:
[
{
"id": 1,
"type": "question|task|instruction",
"text": "exact question text",
"points": null or number,
"subItems": ["sub-part a text", "sub-part b text"]
}
]
Rules:
- Capture EVERY numbered or lettered item
- subItems: only if the question has lettered sub-parts (a, b, c)
- type "instruction" = general directions, not a specific answerable question
- Preserve exact wording`;
async function qbParseDoc() {
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); return; }
if (!S.activeDoc?.content) { toast('⚠ No document content to parse'); return; }
if (QB.parsing) return;
QB.parsing = true;
QB.questions = [];
QB.analyses = {};
QB.activeId = null;
QB._activeTab = 'qb';
const btn = document.getElementById('qb-parse-btn');
btn.disabled = true;
btn.textContent = 'Parsing…';
document.getElementById('qb-status').textContent = 'Extracting questions…';
document.getElementById('qb-progress-wrap').style.display = 'flex';
document.getElementById('qb-save-all-btn').style.display = 'none';
qbRenderList([]);
qbShowEmpty();
try {
const data = await claudeFetch({
model: CLAUDE_MODEL, max_tokens: 2000,
system: QB_PARSE_SYSTEM,
messages: [{ role:'user', content:`Title: ${S.activeDoc.name}\n\n${S.activeDoc.content.slice(0,10000)}` }],
});
const raw = data?.content?.[0]?.text || '[]';
const clean = raw.replace(/```json|```/g,'').trim();
QB.questions = JSON.parse(clean);
} catch(e) {
toast('Error parsing: ' + e.message);
QB.parsing = false;
btn.disabled = false;
btn.textContent = '✦ Parse Questions';
document.getElementById('qb-status').textContent = 'Parse failed';
return;
}
if (!QB.questions.length) {
toast('No questions found in document');
QB.parsing = false;
btn.disabled = false;
btn.textContent = '✦ Parse Questions';
document.getElementById('qb-status').textContent = 'No questions detected';
return;
}
btn.textContent = '↻ Re-Parse';
btn.disabled = false;
document.getElementById('qb-status').textContent = `${QB.questions.length} question${QB.questions.length!==1?'s':''} found`;
qbRenderList(QB.questions);
// Now analyze each question one by one
QB.parsing = true;
for (let i = 0; i < QB.questions.length; i++) {
const q = QB.questions[i];
const pct = Math.round((i / QB.questions.length) * 100);
document.getElementById('qb-progress-bar').style.width = pct + '%';
document.getElementById('qb-progress-label').textContent = `${i} / ${QB.questions.length}`;
document.getElementById('qb-status').textContent = `Analyzing Q${q.id}…`;
qbUpdateListItem(q.id, 'analyzing');
await qbAnalyzeQuestion(q);
qbUpdateListItem(q.id, 'done');
// Auto-select first one when ready
if (i === 0) { QB.activeId = q.id; qbRenderDetail(q.id); }
}
document.getElementById('qb-progress-bar').style.width = '100%';
document.getElementById('qb-progress-label').textContent = `${QB.questions.length} / ${QB.questions.length}`;
document.getElementById('qb-status').textContent = `All ${QB.questions.length} questions analyzed ✓`;
document.getElementById('qb-save-all-btn').style.display = 'inline-flex';
QB.parsing = false;
}
async function qbAnalyzeQuestion(q) {
const subCtx = q.subItems?.length
? `\nSub-parts:\n${q.subItems.map((s,i)=>`${String.fromCharCode(97+i)}) ${s}`).join('\n')}`
: '';
const prompt = `Analyze this assignment question and give a structured breakdown for the student.
Question ${q.id}${q.points?` (${q.points} pts)`:''}: ${q.text}${subCtx}
Full assignment context:
${(S.activeDoc?.content||'').slice(0,5000)}
Respond in this EXACT format (use the exact headers):
## What is being asked
[1-2 sentences in plain English explaining what the question wants]
## How to answer it
[Numbered steps — the actual approach/method to tackle this question]
## Key things to include
[Bullet points of specific content, evidence, or concepts needed for a strong answer]
## Watch out for
[1-3 common mistakes or tricky aspects of this question]
${q.points ? `## Points breakdown\n[How marks are likely distributed across this question]` : ''}`;
try {
const data = await claudeFetch({
model: CLAUDE_MODEL, max_tokens: 900,
system: 'You are a sharp academic tutor. Be concise, specific, and genuinely useful. Format with markdown.',
messages: [{ role:'user', content: prompt }],
});
const reply = data?.content?.[0]?.text || 'No response.';
QB.analyses[q.id] = { text: reply, done: true };
} catch(e) {
QB.analyses[q.id] = { text: 'Error analyzing this question: ' + e.message, done: true };
}
}
// ── QB UI HELPERS ────────────────────────────────────────────
function qbRenderList(questions) {
const list = document.getElementById('qb-list');
if (!questions.length) {
list.innerHTML = `
🔍
Click "Parse Questions"
Claude will extract and analyze every question.
`;
return;
}
list.innerHTML = '';
questions.forEach(q => {
const item = document.createElement('div');
item.id = 'qb-item-' + q.id;
item.style.cssText = 'padding:10px 12px; border-radius:10px; border:1px solid var(--border); background:rgba(255,255,255,0.02); margin-bottom:6px; cursor:pointer; transition:all 0.15s;';
item.innerHTML = qbListItemHTML(q, 'pending');
item.onclick = () => { QB.activeId = q.id; qbRenderDetail(q.id); qbHighlightItem(q.id); };
list.appendChild(item);
});
}
function qbListItemHTML(q, state) {
const typeCol = {question:'var(--blue)',task:'#C084FC',instruction:'var(--amber)'}[q.type] || 'var(--muted)';
const dot = state === 'analyzing'
? `
`
: state === 'done'
? `
`
: `
`;
return `
${dot}
Q${q.id}
${q.text}
${q.points ? `
${q.points} pts
` : ''}
`;
}
function qbUpdateListItem(id, state) {
const el = document.getElementById('qb-item-' + id);
const q = QB.questions.find(q => q.id === id);
if (!el || !q) return;
el.innerHTML = qbListItemHTML(q, state);
if (state === 'done' && QB.activeId === id) qbRenderDetail(id);
}
function qbHighlightItem(id) {
document.querySelectorAll('#qb-list > div').forEach(el => {
el.style.background = 'rgba(255,255,255,0.02)';
el.style.borderColor = 'var(--border)';
});
const el = document.getElementById('qb-item-' + id);
if (el) { el.style.background = 'rgba(94,234,212,0.07)'; el.style.borderColor = 'rgba(94,234,212,0.25)'; }
}
function qbShowEmpty() {
document.getElementById('qb-detail-empty').style.display = 'flex';
document.getElementById('qb-detail-content').style.display = 'none';
}
function qbRenderDetail(id) {
const q = QB.questions.find(q => q.id === id);
const ana = QB.analyses[id];
const empty = document.getElementById('qb-detail-empty');
const content = document.getElementById('qb-detail-content');
qbHighlightItem(id);
empty.style.display = 'none';
content.style.display = 'flex';
const typeCol = {question:'var(--blue)',task:'#C084FC',instruction:'var(--amber)'}[q.type] || 'var(--muted)';
content.innerHTML = `
${q.type}
${q.points ? `${q.points} pts ` : ''}
Q${q.id} of ${QB.questions.length}
${q.text}
${q.subItems?.length ? `
${q.subItems.map((s,i)=>`
${String.fromCharCode(97+i)}) ${s}
`).join('')}
` : ''}
${ana?.done
? `
${formatMd(ana.text)}
`
: `
Analyzing question ${id}…
`
}
${id > 1 ? `← Q${id-1} ` : ''}
${id < QB.questions.length ? `Q${id+1} → ` : ''}
`;
}
function qbNav(id) {
QB.activeId = id;
qbRenderDetail(id);
qbHighlightItem(id);
}
function qbCopyAnalysis(id) {
const ana = QB.analyses[id];
if (ana?.text) { navigator.clipboard.writeText(ana.text); toast('✓ Copied'); }
}
function qbSaveOne(id) {
const q = QB.questions.find(q => q.id === id);
const ana = QB.analyses[id];
if (!q || !ana?.text) return;
const ta = document.getElementById('notes-textarea');
ta.value += `\n\n── Q${q.id}: ${q.text.slice(0,60)}… ──\n${ana.text}`;
S.notes = ta.value;
localStorage.setItem('sh_notes', S.notes);
toast('✓ Saved to Notes');
}
function qbSaveAll() {
const ta = document.getElementById('notes-textarea');
let block = `\n\n════ ${S.activeDoc?.name || 'Assignment'} — Question Breakdowns ════\n`;
QB.questions.forEach(q => {
const ana = QB.analyses[q.id];
if (ana?.text) block += `\n── Q${q.id}: ${q.text.slice(0,80)}${q.text.length>80?'…':''} ──\n${ana.text}\n`;
});
ta.value += block;
S.notes = ta.value;
localStorage.setItem('sh_notes', S.notes);
toast(`✓ All ${QB.questions.length} breakdowns saved to Notes`);
}
async function runTool(tool) {
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); return; }
if (!S.activeDoc?.content) { toast('⚠ Document has no content to analyze'); return; }
document.querySelectorAll('.tool-chip').forEach(c => c.classList.remove('active', 'running'));
const chip = document.getElementById('chip-' + tool.id);
chip.classList.add('active', 'running');
const rb = document.getElementById('result-box');
rb.className = 'result-box';
rb.innerHTML = ``;
document.getElementById('result-actions').style.display = 'none';
document.getElementById('result-label').querySelector('.dot').classList.add('pulse');
const docContext = `Document: "${S.activeDoc.name}" (Source: ${S.activeDoc.source})\n\n${S.activeDoc.content.slice(0, 8000)}`;
const systemPrompt = `You are a sharp academic AI assistant. The student has provided a document from their course. Be concise, structured, and student-focused. Use **bold**, numbered lists, and bullet points to format your response clearly.`;
try {
const data = await claudeFetch({
model: CLAUDE_MODEL,
max_tokens: 1500,
system: systemPrompt,
messages: [{ role: 'user', content: `${tool.prompt}\n\n---\n${docContext}` }],
});
const reply = data?.content?.[0]?.text || 'No response.';
S.lastResult = reply;
rb.className = 'result-box';
rb.innerHTML = formatMd(reply);
document.getElementById('result-actions').style.display = 'flex';
document.getElementById('btn-copy-result').style.display = 'inline-flex';
document.getElementById('btn-save-notes').style.display = 'inline-flex';
} catch(e) {
rb.innerHTML = `Error: ${e.message} `;
}
chip.classList.remove('running');
document.getElementById('result-label').querySelector('.dot').classList.remove('pulse');
}
function copyResult() {
if (!S.lastResult) return;
navigator.clipboard.writeText(S.lastResult);
toast('✓ Copied to clipboard');
}
function saveToNotes() {
if (!S.lastResult) return;
const existing = document.getElementById('notes-textarea').value;
const header = `\n\n── ${S.activeDoc?.name || 'Document'} ──\n`;
document.getElementById('notes-textarea').value = existing + header + S.lastResult;
S.notes = document.getElementById('notes-textarea').value;
localStorage.setItem('sh_notes', S.notes);
toast('✓ Saved to Notes');
}
function sendToChat() {
if (!S.lastResult) return;
S.chatHistory.push({ role: 'assistant', content: '**From Analyze:**\n\n' + S.lastResult });
setView('chat');
renderChat();
}
// ── CHAT ────────────────────────────────────────────────────
function updateChatSub() {
const sub = document.getElementById('chat-doc-sub');
sub.textContent = S.activeDoc ? S.activeDoc.name + ' — context loaded' : 'General — no document loaded';
}
function clearChat() { S.chatHistory = []; renderChat(); }
function renderChat() {
const msgs = document.getElementById('chat-msgs');
const empty = document.getElementById('chat-empty');
msgs.innerHTML = '';
if (!S.chatHistory.length) { msgs.appendChild(empty); return; }
S.chatHistory.forEach(m => {
const row = document.createElement('div');
row.className = 'msg-row ' + m.role;
const av = document.createElement('div');
av.className = 'msg-avatar ' + (m.role === 'assistant' ? 'ai' : 'user');
av.textContent = m.role === 'assistant' ? 'S' : '👤';
const col = document.createElement('div');
col.style.cssText = 'display:flex;flex-direction:column;max-width:78%;';
const bub = document.createElement('div');
bub.className = 'bubble ' + (m.role === 'assistant' ? 'ai' : 'user');
bub.innerHTML = m.role === 'assistant' ? formatMd(m.content) : m.content;
col.appendChild(bub);
if (m.role === 'assistant') {
const cb = document.createElement('button');
cb.className = 'msg-copy-btn';
cb.textContent = '⎘ Copy';
cb.onclick = () => { navigator.clipboard.writeText(m.content); cb.textContent = '✓ Copied'; setTimeout(() => cb.textContent = '⎘ Copy', 1500); };
col.appendChild(cb);
}
row.appendChild(av); row.appendChild(col);
msgs.appendChild(row);
});
msgs.scrollTop = msgs.scrollHeight;
}
async function sendChat() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text || !S.claudeKey) {
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); }
return;
}
input.value = ''; input.style.height = 'auto';
S.chatHistory.push({ role: 'user', content: text });
renderChat();
document.getElementById('chat-send-btn').disabled = true;
const docCtx = S.activeDoc?.content
? `\n\nThe student has this document loaded: "${S.activeDoc.name}"\n---\n${S.activeDoc.content.slice(0, 6000)}`
: '';
// Show typing indicator
const msgs = document.getElementById('chat-msgs');
const typing = document.createElement('div');
typing.id = 'chat-typing';
typing.className = 'msg-row assistant';
typing.innerHTML = 'S
';
msgs.appendChild(typing);
msgs.scrollTop = msgs.scrollHeight;
try {
const data = await claudeFetch({
model: CLAUDE_MODEL,
max_tokens: 1500,
system: 'You are Skibidi Study, a helpful AI tutor for students. Be clear, concise, and encouraging. Use markdown for formatting.' + docCtx,
messages: S.chatHistory.map(m => ({ role: m.role, content: m.content })),
});
const reply = data?.content?.[0]?.text || 'No response.';
document.getElementById('chat-typing')?.remove();
S.chatHistory.push({ role: 'assistant', content: reply });
} catch(e) {
document.getElementById('chat-typing')?.remove();
S.chatHistory.push({ role: 'assistant', content: 'Error: ' + e.message });
}
document.getElementById('chat-send-btn').disabled = false;
renderChat();
}
// ── NOTES ────────────────────────────────────────────────────
function saveNotes() {
S.notes = document.getElementById('notes-textarea').value;
localStorage.setItem('sh_notes', S.notes);
toast('✓ Notes saved');
}
async function aiSummarizeNotes() {
const content = document.getElementById('notes-textarea').value;
if (!content.trim()) { toast('⚠ Notes are empty'); return; }
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); return; }
const box = document.getElementById('notes-ai-box');
const result = document.getElementById('notes-ai-result');
result.style.display = 'block';
document.getElementById('notes-dot').classList.add('pulse');
box.innerHTML = '';
try {
const data = await claudeFetch({
model: CLAUDE_MODEL, max_tokens: 600,
messages: [{ role: 'user', content: 'Summarize these student notes concisely, pulling out key ideas and action items:\n\n' + content }],
});
box.innerHTML = formatMd(data?.content?.[0]?.text || '');
} catch(e) { box.innerHTML = `${e.message} `; }
document.getElementById('notes-dot').classList.remove('pulse');
}
// ── CANVAS BOOKMARKLET ────────────────────────────────────────
function buildCanvasBookmarklet() {
const appUrl = S.appUrl || window.location.href;
const code = `(function(){
var e=document.createElement('script');
e.src='${appUrl.replace(/\/[^/]*$/, '')}/bookmarklet.js?t='+Date.now();
document.head.appendChild(e);
})()`;
const href = 'javascript:' + encodeURIComponent(code);
document.getElementById('canvas-bookmarklet-link').href = href;
}
function copyCanvasBookmarklet() {
const link = document.getElementById('canvas-bookmarklet-link').href;
navigator.clipboard.writeText(link);
toast('✓ Bookmarklet code copied');
}
// ── MARKDOWN FORMATTER ────────────────────────────────────────
function formatMd(text) {
return text
.replace(/\*\*(.*?)\*\*/g, '$1 ')
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^- (.+)$/gm, '• $1
')
.replace(/^(\d+)\. (.+)$/gm, '$1. $2
')
.replace(/`([^`]+)`/g, '$1')
.replace(/\n\n/g, ' ').replace(/\n/g, ' ');
}
// ── TOAST ────────────────────────────────────────────────────
function toast(msg, type) {
document.querySelectorAll('.toast').forEach(t => t.remove());
const t = document.createElement('div');
t.className = 'toast';
if (type === 'success') t.style.borderColor = 'rgba(93,187,107,0.5)';
if (type === 'error') t.style.borderColor = 'rgba(248,113,113,0.5)';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => {
t.style.transition = 'opacity 0.3s, transform 0.3s';
t.style.opacity = '0';
t.style.transform = 'translateX(-50%) translateY(8px)';
setTimeout(() => t.remove(), 300);
}, 2500);
}
// ═══════════════════════════════════════════════════════════
// ── ASSIGNMENT TRACKER ──────────────────────────────────────
// ═══════════════════════════════════════════════════════════
const TRACKER_KEY = 'sh_tracker';
let TR = { assignments: JSON.parse(localStorage.getItem(TRACKER_KEY) || '[]') };
const SUBJECT_MAP = {
math: { label:'Math', icon:'📐', cls:'subj-math' },
science: { label:'Science', icon:'🔬', cls:'subj-science' },
biology: { label:'Biology', icon:'🧬', cls:'subj-science' },
chemistry:{ label:'Chemistry', icon:'⚗️', cls:'subj-science' },
physics: { label:'Physics', icon:'⚡', cls:'subj-science' },
history: { label:'History', icon:'📜', cls:'subj-history' },
english: { label:'English', icon:'📖', cls:'subj-english' },
writing: { label:'Writing', icon:'✍️', cls:'subj-english' },
cs: { label:'Comp Sci', icon:'💻', cls:'subj-cs' },
business: { label:'Business', icon:'📊', cls:'subj-business' },
economics:{ label:'Economics', icon:'📈', cls:'subj-business' },
art: { label:'Art', icon:'🎨', cls:'subj-art' },
other: { label:'General', icon:'📚', cls:'subj-other' },
};
// ── CANVAS ASSIGNMENT SYNC ───────────────────────────────────
async function trackerSyncCanvas() {
if (!S.canvasToken || !S.canvasUrl) {
toast('⚠ Connect Canvas in Settings first');
return;
}
const btn = document.getElementById('tracker-sync-btn');
if (btn) { btn.textContent = 'Syncing…'; btn.disabled = true; }
try {
// Get active courses
const allCourses = await canvasFetch('/api/v1/courses?per_page=100&include[]=term&enrollment_type=student');
const now = new Date();
const activeCourses = (Array.isArray(allCourses) ? allCourses : []).filter(c => {
if (!c.name || c.access_restricted_by_date || c.workflow_state !== 'available') return false;
if (c.term?.name?.toLowerCase().includes('archive')) return false;
if (c.term?.end_at && new Date(c.term.end_at) < new Date(now.getFullYear() - 1, 0, 1)) return false;
return true;
});
// Get assignments for each active course
let added = 0;
for (const course of activeCourses.slice(0, 8)) {
try {
const assignments = await canvasFetch(`/api/v1/courses/${course.id}/assignments?per_page=50&include[]=submission&order_by=due_at`);
if (!Array.isArray(assignments)) continue;
for (const a of assignments) {
if (!a.name || !a.points_possible) continue;
const existing = TR.assignments.find(t => t.canvasId === a.id);
if (existing) continue; // already in tracker
const submission = a.submission;
const isLate = submission?.late || false;
const isDone = submission?.workflow_state === 'graded' || submission?.workflow_state === 'submitted';
const isMissing = submission?.missing || (!submission?.submitted_at && a.due_at && new Date(a.due_at) < now);
const assignment = {
id: Date.now() + Math.random(),
canvasId: a.id,
title: a.name,
due: a.due_at,
priority: isMissing ? 'high' : 'medium',
text: a.description ? a.description.replace(/<[^>]*>/g, '').slice(0, 500) : '',
notes: '',
subject: detectSubjectFromCourse(course.name),
status: isDone ? 'done' : isLate || isMissing ? 'late' : 'todo',
points: a.points_possible,
courseId: course.id,
courseName: course.name,
created: new Date().toISOString(),
};
TR.assignments.push(assignment);
added++;
}
} catch(e) { console.log('Error fetching assignments for', course.name, e); }
}
trackerSave();
if (btn) { btn.textContent = '↻ Sync Canvas'; btn.disabled = false; }
toast(`✓ Synced ${added} new assignments from Canvas`);
} catch(e) {
if (btn) { btn.textContent = '↻ Sync Canvas'; btn.disabled = false; }
toast('Error syncing: ' + e.message);
}
}
function detectSubjectFromCourse(name) {
const n = name.toLowerCase();
if (n.includes('math') || n.includes('algebra') || n.includes('calculus') || n.includes('geometry') || n.includes('amdm') || n.includes('statistics')) return 'math';
if (n.includes('physics') || n.includes('chemistry') || n.includes('biology') || n.includes('science') || n.includes('research')) return 'science';
if (n.includes('history') || n.includes('social') || n.includes('us ')) return 'history';
if (n.includes('english') || n.includes('lit') || n.includes('writing') || n.includes('comp') || n.includes('journalism') || n.includes('newspaper')) return 'english';
if (n.includes('computer') || n.includes('coding') || n.includes('program') || n.includes('cs') || n.includes('csp') || n.includes('apps') || n.includes('game')) return 'cs';
if (n.includes('art') || n.includes('film') || n.includes('video') || n.includes('media') || n.includes('design') || n.includes('animation')) return 'art';
if (n.includes('business') || n.includes('economics')) return 'business';
return 'other';
}
function trackerSave() {
localStorage.setItem(TRACKER_KEY, JSON.stringify(TR.assignments));
trackerRender();
trackerUpdateBadge();
}
function trackerUpdateBadge() {
const badge = document.getElementById('tracker-badge');
const active = TR.assignments.filter(a => a.status !== 'done').length;
if (active > 0) { badge.textContent = active; badge.style.display = 'inline'; }
else badge.style.display = 'none';
}
function trackerOpenAdd() {
document.getElementById('tracker-modal').style.display = 'flex';
document.getElementById('ta-title').focus();
}
function trackerCloseAdd() {
document.getElementById('tracker-modal').style.display = 'none';
['ta-title','ta-text','ta-notes','ta-due'].forEach(id => document.getElementById(id).value = '');
document.getElementById('ta-priority').value = 'medium';
}
async function trackerSaveAdd() {
const title = document.getElementById('ta-title').value.trim();
const due = document.getElementById('ta-due').value;
const priority = document.getElementById('ta-priority').value;
const text = document.getElementById('ta-text').value.trim();
const notes = document.getElementById('ta-notes').value.trim();
if (!title) { toast('⚠ Enter an assignment title'); return; }
const btn = document.querySelector('#tracker-modal .btn.primary');
btn.textContent = '✦ Detecting…'; btn.disabled = true;
let subject = 'other';
if (text && S.claudeKey) {
try {
const data = await claudeFetch({
model: CLAUDE_MODEL, max_tokens: 20,
system: 'Classify the academic subject of this assignment. Reply with ONLY one word from: math, science, biology, chemistry, physics, history, english, writing, cs, business, economics, art, other',
messages: [{ role:'user', content: text.slice(0,1000) }],
});
const raw = (data?.content?.[0]?.text || 'other').trim().toLowerCase().replace(/[^a-z]/g,'');
subject = SUBJECT_MAP[raw] ? raw : 'other';
} catch(e) { subject = 'other'; }
}
const assignment = {
id: Date.now(),
title, due, priority, text, notes, subject,
status: 'todo',
created: new Date().toISOString(),
};
TR.assignments.push(assignment);
trackerSave();
trackerCloseAdd();
btn.textContent = '✦ Add & Detect Subject'; btn.disabled = false;
toast(`✓ Added "${title}" · Subject: ${SUBJECT_MAP[subject].label}`);
}
function trackerDelete(id) {
TR.assignments = TR.assignments.filter(a => a.id !== id);
trackerSave();
}
function trackerMove(id, status) {
const a = TR.assignments.find(a => a.id === id);
if (a) { a.status = status; trackerSave(); }
}
function trackerOpenInAnalyze(id) {
const a = TR.assignments.find(a => a.id === id);
if (!a || !a.text) { toast('⚠ No assignment text — add text when creating'); return; }
// Load as a virtual doc
S.activeDoc = {
id: 'tracker_' + a.id,
source: 'tracker',
name: a.title,
content: a.text + (a.notes ? '\n\n--- CLASS NOTES ---\n' + a.notes : ''),
icon: SUBJECT_MAP[a.subject]?.icon || '📚',
meta: SUBJECT_MAP[a.subject]?.label || 'Assignment',
};
document.getElementById('active-doc-pill').style.display = 'block';
document.getElementById('active-doc-name').textContent = a.title;
setView('analyze');
toast(`✓ Opened "${a.title}" in Analyze`);
// Show notes linker if notes present
if (a.notes) {
document.getElementById('notes-linker-banner').style.display = 'block';
document.getElementById('notes-linker-sub').textContent = `Class notes attached · ${SUBJECT_MAP[a.subject]?.label || 'General'} subject detected`;
S._trackerNotesActive = a.notes;
}
}
function trackerUnlinkNotes() {
document.getElementById('notes-linker-banner').style.display = 'none';
S._trackerNotesActive = null;
if (S.activeDoc) S.activeDoc.content = S.activeDoc.content.split('\n\n--- CLASS NOTES ---\n')[0];
toast('Notes unlinked');
}
function trackerUseNotes() {
setView('analyze');
setTimeout(() => switchAnalyzeTab('qb'), 100);
}
// Drag-and-drop
let _dragId = null;
function trackerDragStart(e, id) { _dragId = id; e.target.classList.add('dragging'); }
function trackerDragEnd(e) { e.target.classList.remove('dragging'); }
function trackerDrop(e, status) {
e.preventDefault();
if (_dragId) { trackerMove(_dragId, status); _dragId = null; }
}
function trackerRender() {
const now2 = new Date();
const cols = { todo:[], late:[], done:[] };
TR.assignments.forEach(a => {
// Auto-move to late if past due and not done
if (a.status !== 'done' && a.due && new Date(a.due) < now2) {
cols.late.push(a);
} else {
(cols[a.status] || cols.todo).push(a);
}
});
['todo','late','done'].forEach(col => {
const el = document.getElementById('col-' + col);
document.getElementById('count-' + col).textContent = cols[col].length;
el.innerHTML = '';
if (!cols[col].length) {
el.innerHTML = `Drop here
`;
return;
}
// Sort todo column
if (col === 'todo') {
const sortBy = document.getElementById('todo-sort')?.value || 'due';
if (sortBy === 'due') {
cols[col].sort((a,b) => {
if (!a.due && !b.due) return 0;
if (!a.due) return 1;
if (!b.due) return -1;
return new Date(a.due) - new Date(b.due);
});
} else if (sortBy === 'impact') {
cols[col].sort((a,b) => (b.gradeImpact||0) - (a.gradeImpact||0));
}
}
cols[col].forEach(a => {
const subj = SUBJECT_MAP[a.subject] || SUBJECT_MAP.other;
const dueTxt = a.due ? trackerDueLabel(a.due) : null;
const dueCls = a.due ? trackerDueCls(a.due) : 'ok';
const priIcon = { high:'🔴', medium:'🟡', low:'🟢' }[a.priority] || '🟡';
const card = document.createElement('div');
card.className = 'kanban-card';
card.draggable = true;
card.addEventListener('dragstart', e => trackerDragStart(e, a.id));
card.addEventListener('dragend', e => trackerDragEnd(e));
card.innerHTML = `
${subj.icon} ${subj.label}
${a.notes ? `📎 Notes linked
` : ''}
${a.title}
${dueTxt ? `📅 ${dueTxt} ` : ''}
${priIcon}
${col !== 'todo' ? `← To Do ` : ''}
${col === 'late' ? `Move to To Do ` : ''}
${col !== 'done' ? `✓ Done ` : ''}
${a.text ? `🔬 Analyze ` : ''}
✕
`;
el.appendChild(card);
});
});
// Deadline strip
const upcoming = TR.assignments
.filter(a => a.due && a.status !== 'done')
.sort((a,b) => new Date(a.due) - new Date(b.due))
.slice(0, 8);
const pills = document.getElementById('deadline-pills');
const empty = document.getElementById('deadline-empty');
pills.innerHTML = '';
if (!upcoming.length) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
upcoming.forEach(a => {
const cls = trackerDueCls(a.due);
const pill = document.createElement('div');
pill.className = `deadline-pill ${cls}`;
pill.innerHTML = `${SUBJECT_MAP[a.subject]?.icon || '📚'} ${a.title.slice(0,22)}${a.title.length>22?'…':''} · ${trackerDueLabel(a.due)}`;
pill.onclick = () => { setView('tracker'); };
pills.appendChild(pill);
});
trackerUpdateBadge();
}
function trackerDueLabel(due) {
const diff = new Date(due) - new Date();
const h = diff / 3600000;
if (h < 0) return 'Overdue';
if (h < 24) return `${Math.round(h)}h left`;
const d = Math.floor(h / 24);
if (d === 1) return 'Tomorrow';
if (d < 7) return `${d} days`;
return new Date(due).toLocaleDateString(undefined,{month:'short',day:'numeric'});
}
function trackerDueCls(due) {
const h = (new Date(due) - new Date()) / 3600000;
if (h < 0) return 'urgent';
if (h < 48) return 'urgent';
if (h < 168) return 'soon';
return 'ok';
}
// ═══════════════════════════════════════════════════════════
// ── POMODORO TIMER ──────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
const TIMER = {
modes: { focus: 25*60, shortBreak: 5*60, longBreak: 15*60 },
mode: 'focus',
remaining: 25*60,
total: 25*60,
running: false,
sessions: parseInt(localStorage.getItem('sh_timer_sessions') || '0'),
_interval: null,
};
function timerToggle() {
if (TIMER.running) {
clearInterval(TIMER._interval);
TIMER.running = false;
document.getElementById('timer-start-btn').textContent = 'Resume';
} else {
TIMER.running = true;
document.getElementById('timer-start-btn').textContent = 'Pause';
TIMER._interval = setInterval(() => {
TIMER.remaining--;
timerUpdate();
if (TIMER.remaining <= 0) {
clearInterval(TIMER._interval);
TIMER.running = false;
if (TIMER.mode === 'focus') {
TIMER.sessions++;
localStorage.setItem('sh_timer_sessions', TIMER.sessions);
toast('🍅 Focus session complete! Take a break.');
} else {
toast('⏱ Break over! Ready to focus?');
}
timerUpdate();
document.getElementById('timer-start-btn').textContent = 'Start';
// try notification
if (Notification.permission === 'granted') {
new Notification('Study Hub', { body: TIMER.mode === 'focus' ? '🍅 Focus session done! Take a break.' : '⏱ Break over — back to work!' });
}
}
}, 1000);
}
document.getElementById('timer-start-btn').textContent = TIMER.running ? 'Pause' : 'Resume';
}
function timerReset() {
clearInterval(TIMER._interval);
TIMER.running = false;
TIMER.remaining = TIMER.total;
document.getElementById('timer-start-btn').textContent = 'Start';
timerUpdate();
}
function timerSwitch() {
clearInterval(TIMER._interval);
TIMER.running = false;
const modes = ['focus','shortBreak','longBreak'];
const next = modes[(modes.indexOf(TIMER.mode) + 1) % modes.length];
TIMER.mode = next;
TIMER.total = TIMER.modes[next];
TIMER.remaining = TIMER.total;
const labels = { focus:'FOCUS', shortBreak:'5 MIN', longBreak:'15 MIN' };
document.getElementById('timer-mode-pill').textContent = labels[next];
document.getElementById('timer-switch-btn').textContent = next === 'focus' ? 'Break' : next === 'shortBreak' ? 'Long' : 'Focus';
document.getElementById('timer-start-btn').textContent = 'Start';
timerUpdate();
// Request notification permission
if (Notification.permission === 'default') Notification.requestPermission();
}
function timerUpdate() {
const m = Math.floor(TIMER.remaining / 60).toString().padStart(2,'0');
const s = (TIMER.remaining % 60).toString().padStart(2,'0');
document.getElementById('timer-display').textContent = `${m}:${s}`;
const pct = (TIMER.remaining / TIMER.total) * 100;
document.getElementById('timer-bar').style.width = pct + '%';
const col = TIMER.mode === 'focus' ? 'var(--green)' : 'var(--blue)';
document.getElementById('timer-bar').style.background = `linear-gradient(90deg,${col},var(--blue))`;
document.getElementById('timer-sessions').textContent = `🍅 ${TIMER.sessions} session${TIMER.sessions!==1?'s':''} today`;
// change display color when low
const display = document.getElementById('timer-display');
display.style.color = TIMER.remaining < 60 && TIMER.mode === 'focus' ? 'var(--red)' : 'var(--text)';
}
// ═══════════════════════════════════════════════════════════
// ── GRADE ANALYZER — Canvas API direct ──────────────────────
// ═══════════════════════════════════════════════════════════
let GR = { courseId: null, courseName: '' };
function gradesInitView() {
if (S.canvasToken && S.canvasUrl) {
document.getElementById('grades-no-token').style.display = 'none';
document.getElementById('grades-connected').style.display = 'flex';
document.getElementById('grades-canvas-domain').textContent = S.canvasUrl;
gradesLoadCourses();
} else {
document.getElementById('grades-no-token').style.display = 'block';
document.getElementById('grades-connected').style.display = 'none';
}
}
function quickSaveCanvasToken() {
const url = document.getElementById('quick-canvas-url').value.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
const token = document.getElementById('quick-canvas-token').value.trim();
if (!url || !token) { toast('⚠ Enter both your Canvas URL and token'); return; }
S.canvasUrl = url;
S.canvasToken = token;
localStorage.setItem('sh_canvas_url', url);
localStorage.setItem('sh_canvas_token', token);
// sync to settings inputs
if (document.getElementById('s-canvas-url')) document.getElementById('s-canvas-url').value = url;
if (document.getElementById('s-canvas-token')) document.getElementById('s-canvas-token').value = token;
toast('✓ Canvas connected!');
gradesInitView();
}
function gradesDisconnect() {
S.canvasToken = ''; S.canvasUrl = '';
localStorage.removeItem('sh_canvas_token'); localStorage.removeItem('sh_canvas_url');
gradesInitView();
}
async function canvasFetch(path) {
if (!S.n8nUrl) throw new Error('Add your n8n URL in Settings');
const res = await fetch(`${S.n8nUrl}/webhook/canvas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canvasUrl: S.canvasUrl, token: S.canvasToken, path }),
});
if (!res.ok) throw new Error(`Proxy error ${res.status}`);
const text = await res.text();
if (!text || text.length < 2) throw new Error('Empty response from proxy');
const json = JSON.parse(text);
// Handle all possible response shapes
if (Array.isArray(json)) return json;
if (json && Array.isArray(json.courses)) return json.courses;
if (json && Array.isArray(json.result)) return json.result;
if (json && Array.isArray(json.data)) return json.data;
if (json && typeof json === 'object') return [json];
return json;
}
async function gradesLoadCourses() {
const list = document.getElementById('grades-course-list');
list.innerHTML = ``;
try {
// Fetch active enrollments
let allCourses = await canvasFetch('/api/v1/courses?per_page=100&include[]=term&enrollment_type=student');
if (!Array.isArray(allCourses)) allCourses = [];
const now = new Date();
let courses = allCourses.filter(c => {
if (!c.name || c.access_restricted_by_date) return false;
if (c.workflow_state !== 'available') return false;
if (c.term) {
if (c.term.name && c.term.name.toLowerCase().includes('archive')) return false;
const termEnd = c.term.end_at ? new Date(c.term.end_at) : null;
if (termEnd && termEnd < new Date(now.getFullYear() - 1, 0, 1)) return false;
}
return true;
});
list.innerHTML = '';
if (!courses || !courses.length) {
list.innerHTML = `No active courses found.
`;
return;
}
courses
.filter(c => c.name)
.forEach(c => {
const card = document.createElement('div');
card.style.cssText = 'padding:12px 16px;border-radius:11px;border:1px solid var(--border);background:rgba(255,255,255,0.02);cursor:pointer;transition:all 0.15s;display:flex;align-items:center;gap:12px;';
card.innerHTML = `
🎓
${c.name}
${c.course_code ? `
${c.course_code}
` : ''}
→
`;
card.onmouseenter = () => { card.style.background='rgba(255,255,255,0.06)'; card.style.borderColor='var(--border-hi)'; };
card.onmouseleave = () => { card.style.background='rgba(255,255,255,0.02)'; card.style.borderColor='var(--border)'; };
card.onclick = () => gradesLoadCourse(c.id, c.name);
list.appendChild(card);
});
} catch(e) {
list.innerHTML = `Error: ${e.message}Check your Canvas URL and token in Settings.
`;
}
}
async function gradesLoadCourse(courseId, courseName) {
GR.courseId = courseId;
GR.courseName = courseName;
document.getElementById('grades-active-course-name').textContent = courseName;
document.getElementById('grades-paste-panel').style.display = 'none';
document.getElementById('grades-results-panel').style.display = 'flex';
document.getElementById('grades-clear-btn').style.display = 'inline-flex';
document.getElementById('grades-analyze-btn').style.display = 'none';
// Show loading state
const sumBar = document.getElementById('grades-summary-bar');
const scenarios = document.getElementById('grades-scenarios');
const priList = document.getElementById('grades-priority-list');
const allList = document.getElementById('grades-all-list');
sumBar.innerHTML = ` Fetching grades from Canvas…
`;
scenarios.innerHTML = ''; priList.innerHTML = ''; allList.innerHTML = '';
try {
// Fetch assignment groups (gives us weights) + assignments in parallel
const [groups, submissions] = await Promise.all([
canvasFetch(`/api/v1/courses/${courseId}/assignment_groups?include[]=assignments&include[]=submission`),
canvasFetch(`/api/v1/courses/${courseId}/students/submissions?student_ids[]=self&per_page=100&include[]=assignment`),
]);
// Build a submission map: assignment_id → submission
const subMap = {};
submissions.forEach(s => { subMap[s.assignment_id] = s; });
// Build normalised assignment list
const assignments = [];
groups.forEach(group => {
(group.assignments || []).forEach(a => {
if (!a.published) return;
if (a.submission_types?.includes('not_graded')) return;
const sub = subMap[a.id];
const earned = sub?.score ?? null; // null = not submitted / missing
const missing = sub?.missing || sub?.workflow_state === 'unsubmitted' || earned === null;
const possible = a.points_possible || 0;
if (!possible) return; // skip 0-point assignments
assignments.push({
name: a.name,
earned: missing ? null : earned,
possible: possible,
weight: group.group_weight || null,
group: group.name,
due: a.due_at,
});
});
});
if (!assignments.length) {
sumBar.innerHTML = `No graded assignments found for this course yet.
`;
return;
}
gradesRender(assignments);
} catch(e) {
sumBar.innerHTML = `Error loading grades: ${e.message}
`;
}
}
async function gradesRefresh() {
if (GR.courseId) gradesLoadCourse(GR.courseId, GR.courseName);
}
function gradesBackToCourses() {
GR.courseId = null;
document.getElementById('grades-paste-panel').style.display = 'flex';
document.getElementById('grades-results-panel').style.display = 'none';
document.getElementById('grades-clear-btn').style.display = 'none';
}
function gradesClear() {
gradesBackToCourses();
}
// ── GRADE MATH & RENDERING ───────────────────────────────────
function letterGrade(pct) {
if (pct >= 93) return 'A'; if (pct >= 90) return 'A-';
if (pct >= 87) return 'B+'; if (pct >= 83) return 'B'; if (pct >= 80) return 'B-';
if (pct >= 77) return 'C+'; if (pct >= 73) return 'C'; if (pct >= 70) return 'C-';
if (pct >= 67) return 'D+'; if (pct >= 60) return 'D'; return 'F';
}
function gradeColor(pct) {
if (pct >= 90) return 'var(--green)';
if (pct >= 80) return 'var(--blue)';
if (pct >= 70) return 'var(--amber)';
return 'var(--red)';
}
function calcGrade(items, hasWeights) {
if (!items.length) return 0;
if (hasWeights) {
let totalWeight = 0, earned = 0;
items.forEach(a => {
const w = a.weight || 0;
const pct = a.earned !== null ? (a.earned / a.possible) * 100 : 0;
earned += pct * w;
totalWeight += w;
});
return totalWeight ? earned / totalWeight : 0;
} else {
const e = items.reduce((s, a) => s + (a.earned !== null ? a.earned : 0), 0);
const p = items.reduce((s, a) => s + a.possible, 0);
return p ? (e / p) * 100 : 0;
}
}
function gradesRender(assignments) {
const hasWeights = assignments.some(a => a.weight !== null && a.weight > 0);
const zeros = assignments.filter(a => a.earned === null || a.earned === 0);
const scored = assignments.filter(a => a.earned !== null && a.earned > 0);
const allWithZeros = [...scored, ...zeros.map(a => ({ ...a, earned: 0 }))];
const gradeNow = calcGrade(allWithZeros, hasWeights);
const grade70 = calcGrade([...scored, ...zeros.map(a => ({ ...a, earned: a.possible * 0.70 }))], hasWeights);
const grade100 = calcGrade([...scored, ...zeros.map(a => ({ ...a, earned: a.possible }))], hasWeights);
// Per-zero impact: what does grade become if just this one becomes 100?
const zeroImpacts = zeros.map(a => {
const with100 = calcGrade([
...scored,
...zeros.map(z => z === a ? { ...z, earned: z.possible } : { ...z, earned: 0 }),
], hasWeights);
const with70 = calcGrade([
...scored,
...zeros.map(z => z === a ? { ...z, earned: z.possible * 0.7 } : { ...z, earned: 0 }),
], hasWeights);
return { ...a, impact100: +(with100 - gradeNow).toFixed(2), impact70: +(with70 - gradeNow).toFixed(2) };
}).sort((a, b) => b.impact100 - a.impact100);
// ── Summary bar ────────────────────────────────────────────
document.getElementById('grades-summary-bar').innerHTML = `
${gradeNow.toFixed(1)}% ${letterGrade(gradeNow)}
Current Grade
${zeros.length}
Missing / Zeros
${assignments.length}
Total
${hasWeights ? `Weighted grading
` : ''}
`;
// ── Scenario cards ─────────────────────────────────────────
function scenarioCard(label, grade, note) {
const delta = grade - gradeNow;
const sign = delta >= 0 ? '+' : '';
const col = gradeColor(grade);
return `
${label}
${grade.toFixed(1)}%
${letterGrade(grade)}
${sign}${delta.toFixed(1)}% vs now
${note}
`;
}
document.getElementById('grades-scenarios').innerHTML =
scenarioCard('Current Grade', gradeNow, 'As it stands right now') +
scenarioCard('If All Zeros → 70%', grade70, `${zeros.length} missing at minimum pass`) +
scenarioCard('If All Zeros → 100%', grade100, `${zeros.length} missing at perfect score`);
// ── Priority list ─────────────────────────────────────────
const priList = document.getElementById('grades-priority-list');
priList.innerHTML = '';
if (!zeroImpacts.length) {
priList.innerHTML = `
🎉 No missing assignments! All zeros cleared.
`;
} else {
zeroImpacts.forEach((a, i) => {
const rankCls = i === 0 ? 'p1' : i === 1 ? 'p2' : i === 2 ? 'p3' : 'pn';
const urgency = i === 0 ? '🔴 Highest impact' : i === 1 ? '🟡 High impact' : i < 5 ? '🟢 Medium impact' : '⚪ Lower impact';
const dueStr = a.due ? `· Due ${new Date(a.due).toLocaleDateString(undefined,{month:'short',day:'numeric'})}` : '';
const row = document.createElement('div');
row.className = 'priority-row';
row.innerHTML = `
${i + 1}
${a.name}
${urgency} · ${a.possible} pts${a.weight ? ` · ${a.weight}% weight` : ''} ${dueStr}${a.group ? ` · ${a.group}` : ''}
+${a.impact100.toFixed(1)}%
if 100%
+${a.impact70.toFixed(1)}% if 70%
`;
priList.appendChild(row);
});
}
// ── All assignments ────────────────────────────────────────
const allList = document.getElementById('grades-all-list');
allList.innerHTML = '';
assignments.forEach(a => {
const pct = a.earned !== null ? (a.earned / a.possible) * 100 : 0;
const missing = a.earned === null;
const zero = a.earned === 0;
const col = (missing || zero) ? 'var(--red)' : pct >= 90 ? 'var(--green)' : pct >= 70 ? 'var(--amber)' : 'var(--red)';
const scoreText = missing
? `Missing `
: zero
? `0 / ${a.possible} `
: `${a.earned} / ${a.possible} (${pct.toFixed(0)}%)`;
const dueStr = a.due ? `${new Date(a.due).toLocaleDateString(undefined,{month:'short',day:'numeric'})} ` : '';
allList.innerHTML += `
${(missing||zero)?'⚠ ':''}${a.name}
${dueStr}
${a.group ? `
${a.group} ` : ''}
${scoreText}
${a.weight ? `
${a.weight}%
` : ''}
`;
});
}
// ═══════════════════════════════════════════════════════════
// ── QUIZ PREP ───────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
const QZ = {
questions: [], // parsed question objects
current: 0,
score: 0,
answered: 0,
taskHistory: [], // for procrastination breaker context
};
async function quizGenerate() {
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); return; }
const topic = document.getElementById('quiz-topic').value.trim();
const notes = document.getElementById('quiz-notes').value.trim();
const type = document.getElementById('quiz-type').value;
const diff = document.getElementById('quiz-diff').value;
const count = document.getElementById('quiz-count').value;
if (!topic && !notes) { toast('⚠ Enter a topic or paste some notes'); return; }
const btn = document.getElementById('quiz-gen-btn2');
btn.textContent = 'Generating…'; btn.disabled = true;
const typeDesc = { mixed: 'a mix of multiple-choice and short-answer', mcq: 'multiple-choice only (4 options each)', short: 'short-answer only', truefalse: 'true/false only', fillblank: 'fill-in-the-blank only' }[type];
const diffDesc = { easy: 'basic recall and definitions', medium: 'conceptual understanding', hard: 'application and analysis', exam: 'mixed difficulty mimicking a real exam' }[diff];
const prompt = `Generate exactly ${count} quiz questions about: ${topic || 'the material below'}.
Format: ${typeDesc}. Difficulty: ${diffDesc}.
${notes ? `\nBase questions on this material:\n${notes.slice(0, 6000)}` : ''}
Return ONLY valid JSON — no markdown fences, no explanation:
[
{
"type": "mcq" | "short" | "truefalse" | "fillblank",
"question": "question text",
"options": ["A) ...", "B) ...", "C) ...", "D) ..."], // only for mcq
"correct": "A" | "B" | "C" | "D" | "True" | "False" | "exact answer string",
"explanation": "brief explanation of the correct answer"
}
]
For short/fillblank, omit options. correct = the ideal answer string.`;
try {
const data = await claudeFetch({ model: CLAUDE_MODEL, max_tokens: 3000, messages: [{ role:'user', content: prompt }] });
const raw = (data?.content?.[0]?.text || '[]').replace(/```json|```/g,'').trim();
QZ.questions = JSON.parse(raw);
QZ.current = 0; QZ.score = 0; QZ.answered = 0;
} catch(e) {
toast('Error generating quiz: ' + e.message);
btn.textContent = '✦ Generate Quiz'; btn.disabled = false;
return;
}
btn.textContent = '✦ Generate Quiz'; btn.disabled = false;
if (!QZ.questions.length) { toast('⚠ No questions generated — try being more specific'); return; }
document.getElementById('quiz-setup').style.display = 'none';
document.getElementById('quiz-active').style.display = 'flex';
document.getElementById('quiz-reset-btn').style.display = 'inline-flex';
document.getElementById('quiz-gen-btn').style.display = 'none';
quizShowQuestion();
}
function quizShowQuestion() {
const q = QZ.questions[QZ.current];
const tot = QZ.questions.length;
const pct = ((QZ.current) / tot) * 100;
document.getElementById('quiz-progress-label').textContent = `Q${QZ.current + 1} of ${tot}`;
document.getElementById('quiz-progress-bar').style.width = pct + '%';
document.getElementById('quiz-score-display').textContent = `Score: ${QZ.score}/${QZ.answered}`;
document.getElementById('quiz-results').style.display = 'none';
const area = document.getElementById('quiz-question-area');
area.style.display = 'flex';
area.innerHTML = '';
const card = document.createElement('div');
card.className = 'quiz-question-card';
if (q.type === 'mcq') {
const letters = ['A','B','C','D'];
card.innerHTML = `
Question ${QZ.current + 1} · Multiple Choice
${q.question}
${(q.options || []).map((opt, i) => `
${letters[i]}
${opt.replace(/^[A-D]\)\s*/,'')}
`).join('')}
Next Question →
`;
} else if (q.type === 'truefalse') {
card.innerHTML = `
Question ${QZ.current + 1} · True / False
${q.question}
Next Question →
`;
} else {
// short answer / fill-in-blank
card.innerHTML = `
Question ${QZ.current + 1} · ${q.type === 'fillblank' ? 'Fill in the Blank' : 'Short Answer'}
${q.question}
Submit Answer
Reveal Answer
Next Question →
`;
}
area.appendChild(card);
area.scrollTop = 0;
}
function quizAnswerMCQ(selected, idx) {
const q = QZ.questions[QZ.current];
const correct = (selected === q.correct) || (selected.replace(/^[A-D]\)\s*/,'').trim() === q.correct.trim());
document.querySelectorAll('[id^="qopt-"]').forEach(el => el.onclick = null);
const letters = ['A','B','C','D','True','False'];
const correctIdx = letters.indexOf(q.correct);
const opts = document.querySelectorAll('[id^="qopt-"]');
opts.forEach((el, i) => {
if (i === correctIdx || letters[i] === q.correct) el.classList.add('correct');
else if (i === idx && !correct) el.classList.add('wrong');
});
if (correct) QZ.score++;
QZ.answered++;
quizShowFeedback(correct, q.explanation);
}
async function quizAnswerShort() {
const q = QZ.questions[QZ.current];
const ans = document.getElementById('quiz-short-ans').value.trim();
if (!ans) return;
document.getElementById('quiz-short-ans').disabled = true;
const fb = document.getElementById('quiz-feedback');
fb.style.display = 'block';
fb.className = 'quiz-feedback';
fb.innerHTML = ``;
try {
const data = await claudeFetch({
model: CLAUDE_MODEL, max_tokens: 200,
messages:[{ role:'user', content:`Question: ${q.question}\nIdeal answer: ${q.correct}\nStudent's answer: ${ans}\n\nIs the student's answer correct or substantially correct? Reply with ONLY: CORRECT or INCORRECT, then one sentence of feedback.` }],
});
const result = data?.content?.[0]?.text || 'INCORRECT';
const isCorrect = result.trim().toUpperCase().startsWith('CORRECT');
const feedback = result.replace(/^(CORRECT|INCORRECT)[.:]?\s*/i,'');
if (isCorrect) QZ.score++;
QZ.answered++;
fb.className = 'quiz-feedback ' + (isCorrect ? 'correct' : 'wrong');
fb.innerHTML = `${isCorrect ? '✓ Correct!' : '✗ Not quite'} ${feedback}Model answer: ${q.correct} `;
} catch(e) {
fb.innerHTML = `Correct answer: ${q.correct} ${q.explanation || ''}`;
}
document.getElementById('quiz-next-btn').style.display = 'inline-block';
document.getElementById('quiz-score-display').textContent = `Score: ${QZ.score}/${QZ.answered}`;
}
function quizRevealShort() {
const q = QZ.questions[QZ.current];
const fb = document.getElementById('quiz-feedback');
fb.style.display = 'block';
fb.className = 'quiz-feedback wrong';
fb.innerHTML = `Answer: ${q.correct}${q.explanation || ''} `;
QZ.answered++;
document.getElementById('quiz-next-btn').style.display = 'inline-block';
document.getElementById('quiz-score-display').textContent = `Score: ${QZ.score}/${QZ.answered}`;
}
function quizShowFeedback(correct, explanation) {
const fb = document.getElementById('quiz-feedback');
fb.style.display = 'block';
fb.className = 'quiz-feedback ' + (correct ? 'correct' : 'wrong');
fb.innerHTML = `${correct ? '✓ Correct!' : '✗ Incorrect'} ${explanation || ''}`;
document.getElementById('quiz-next-btn').style.display = 'inline-block';
document.getElementById('quiz-score-display').textContent = `Score: ${QZ.score}/${QZ.answered}`;
}
function quizNext() {
QZ.current++;
if (QZ.current >= QZ.questions.length) { quizShowResults(); return; }
quizShowQuestion();
}
function quizShowResults() {
const pct = Math.round((QZ.score / QZ.questions.length) * 100);
const col = pct >= 80 ? 'var(--green)' : pct >= 60 ? 'var(--amber)' : 'var(--red)';
const msg = pct >= 90 ? '🎉 Excellent work!' : pct >= 70 ? '👍 Good effort — review what you missed.' : '📖 Keep studying — you\'ve got this.';
document.getElementById('quiz-progress-bar').style.width = '100%';
document.getElementById('quiz-question-area').style.display = 'none';
const results = document.getElementById('quiz-results');
results.style.display = 'flex';
results.innerHTML = `
${pct}%
${QZ.score} / ${QZ.questions.length} correct
${msg}
↺ New Quiz
↻ Retry Same Quiz
`;
}
function quizReset() {
QZ.questions = []; QZ.current = 0; QZ.score = 0; QZ.answered = 0;
document.getElementById('quiz-setup').style.display = 'flex';
document.getElementById('quiz-active').style.display = 'none';
document.getElementById('quiz-reset-btn').style.display = 'none';
document.getElementById('quiz-gen-btn').style.display = 'none';
}
function quizRetry() {
QZ.current = 0; QZ.score = 0; QZ.answered = 0;
document.getElementById('quiz-results').style.display = 'none';
document.getElementById('quiz-question-area').style.display = 'flex';
quizShowQuestion();
}
// ═══════════════════════════════════════════════════════════
// ── CITATION MANAGER ────────────────────────────────────────
// ═══════════════════════════════════════════════════════════
const CIT = { sources: JSON.parse(localStorage.getItem('sh_citations') || '[]') };
function citationSave() { localStorage.setItem('sh_citations', JSON.stringify(CIT.sources)); }
function citationRender() {
const list = document.getElementById('cit-list');
const empty = document.getElementById('cit-empty');
const copyAll = document.getElementById('cit-copy-all-btn');
if (!CIT.sources.length) {
empty.style.display = 'flex'; copyAll.style.display = 'none';
list.innerHTML = '';
list.appendChild(empty);
return;
}
empty.style.display = 'none';
copyAll.style.display = 'inline-flex';
list.innerHTML = '';
CIT.sources.forEach((src, i) => {
const card = document.createElement('div');
card.className = 'citation-card';
card.innerHTML = `
${src.format}
Source ${i + 1}
✕
${src.formatted}
${src.quote ? `"${src.quote}"
` : ''}
${src.summary ? `${src.summary}
` : ''}
📋 Copy citation
${src.quote ? `💬 Copy quote ` : ''}
📓 Send to Notes
`;
list.appendChild(card);
});
}
async function citationAdd() {
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); return; }
const input = document.getElementById('cit-input').value.trim();
const format = document.getElementById('cit-format').value;
if (!input) return;
const btn = document.getElementById('cit-add-btn');
btn.textContent = 'Formatting…'; btn.disabled = true;
const prompt = `You are a citation and source assistant. The user has provided this source reference: "${input}"
Do the following:
1. Format a complete ${format} citation for this source. If it's a URL, infer what you can from the URL and use "[Retrieved from URL]" for what you can't know. If it's a DOI, format accordingly.
2. Extract or suggest one relevant short quote (1-2 sentences) that might be useful for academic writing. If you can't access the source, suggest a placeholder.
3. Write a 1-sentence summary of what this source is likely about.
Respond ONLY with valid JSON, no markdown:
{
"formatted": "full formatted citation in ${format}",
"quote": "a short useful quote or empty string",
"summary": "one sentence about this source"
}`;
try {
const data = await claudeFetch({ model: CLAUDE_MODEL, max_tokens: 500, messages:[{ role:'user', content: prompt }] });
const raw = (data?.content?.[0]?.text || '{}').replace(/```json|```/g,'').trim();
const parsed = JSON.parse(raw);
CIT.sources.unshift({ ...parsed, format, input, added: new Date().toISOString() });
citationSave();
document.getElementById('cit-input').value = '';
citationRender();
toast('✓ Citation added');
} catch(e) {
toast('Error: ' + e.message);
}
btn.textContent = 'Format →'; btn.disabled = false;
}
function citationDelete(i) {
CIT.sources.splice(i, 1);
citationSave();
citationRender();
}
function citationCopyAll() {
const all = CIT.sources.map((s, i) => `${i + 1}. ${s.formatted}`).join('\n\n');
navigator.clipboard.writeText(all);
toast(`✓ ${CIT.sources.length} citations copied`);
}
function citationSendToNotes(i) {
const s = CIT.sources[i];
const ta = document.getElementById('notes-textarea');
ta.value += `\n\n── Source ──\n${s.formatted}${s.quote ? '\n"' + s.quote + '"' : ''}`;
S.notes = ta.value;
localStorage.setItem('sh_notes', S.notes);
toast('✓ Saved to Notes');
}
// ═══════════════════════════════════════════════════════════
// ── PROCRASTINATION BREAKER / FOCUS MODE ────────────────────
// ═══════════════════════════════════════════════════════════
const FC = {
steps: [],
current: 0,
task: '',
timerSecs: 120,
timerRunning: false,
_interval: null,
};
async function focusBreak() {
if (!S.claudeKey) { toast('⚠ Enter Claude API key in Settings'); setView('settings'); return; }
const task = document.getElementById('focus-task-input').value.trim();
if (!task) { toast('⚠ Describe the task you\'re avoiding'); return; }
FC.task = task;
FC.steps = [];
FC.current = 0;
const btn = document.querySelector('#focus-setup button:last-child');
btn.textContent = 'Breaking it down…'; btn.disabled = true;
const step = await focusFetchStep(task, []);
btn.textContent = '🎯 Give me my first step'; btn.disabled = false;
if (!step) return;
FC.steps.push(step);
document.getElementById('focus-setup').style.display = 'none';
document.getElementById('focus-session').style.display = 'flex';
focusRenderStep();
}
async function focusFetchStep(task, doneSteps) {
const doneCtx = doneSteps.length ? `\nCompleted steps so far:\n${doneSteps.map((s,i) => `${i+1}. ${s.action}`).join('\n')}` : '';
const prompt = `A student is procrastinating on this task: "${task}"${doneCtx}
Give them their ${doneSteps.length === 0 ? 'first' : 'next'} micro-step — something so small and specific it takes under 2 minutes to start.
Respond ONLY with JSON:
{
"action": "The single specific micro-step (one sentence, starts with a verb)",
"why": "One sentence on why this tiny step helps break the block",
"minutes": 2
}`;
try {
const data = await claudeFetch({ model: CLAUDE_MODEL, max_tokens: 200, messages:[{ role:'user', content: prompt }] });
const raw = (data?.content?.[0]?.text || '{}').replace(/```json|```/g,'').trim();
return JSON.parse(raw);
} catch(e) {
toast('Error: ' + e.message); return null;
}
}
function focusRenderStep() {
const step = FC.steps[FC.current];
document.getElementById('focus-step-text').textContent = step.action;
document.getElementById('focus-step-why').textContent = step.why || '';
document.getElementById('focus-time-est').textContent = `⏱ ~${step.minutes || 2} minutes`;
// Reset timer
clearInterval(FC._interval);
FC.timerRunning = false;
FC.timerSecs = (step.minutes || 2) * 60;
focusTimerUpdate();
document.getElementById('focus-timer-btn').textContent = `Start ${step.minutes || 2}-min timer`;
// Render history
const hist = document.getElementById('focus-step-history');
hist.innerHTML = '';
FC.steps.slice(0, FC.current).forEach((s, i) => {
const el = document.createElement('div');
el.className = 'focus-done-step';
el.innerHTML = `✓ ${s.action}`;
hist.appendChild(el);
});
}
async function focusNextStep() {
clearInterval(FC._interval);
FC.timerRunning = false;
const btn = document.querySelector('#focus-session button[onclick="focusNextStep()"]');
btn.textContent = 'Getting next step…'; btn.disabled = true;
const step = await focusFetchStep(FC.task, FC.steps);
btn.textContent = '✓ Done — give me next step'; btn.disabled = false;
if (!step) return;
FC.current++;
FC.steps.push(step);
focusRenderStep();
toast('✓ Step complete! Keep going.');
}
function focusTimerToggle() {
if (FC.timerRunning) {
clearInterval(FC._interval);
FC.timerRunning = false;
document.getElementById('focus-timer-btn').textContent = 'Resume';
} else {
FC.timerRunning = true;
document.getElementById('focus-timer-btn').textContent = 'Pause';
FC._interval = setInterval(() => {
FC.timerSecs--;
focusTimerUpdate();
if (FC.timerSecs <= 0) {
clearInterval(FC._interval);
FC.timerRunning = false;
document.getElementById('focus-timer-btn').textContent = 'Restart';
toast('⏱ Time\'s up — did you complete the step?');
if (Notification.permission === 'granted') new Notification('Study Hub', { body: '⏱ Focus step time\'s up!' });
}
}, 1000);
}
}
function focusTimerUpdate() {
const m = Math.floor(FC.timerSecs / 60).toString().padStart(2,'0');
const s = (FC.timerSecs % 60).toString().padStart(2,'0');
document.getElementById('focus-timer-display').textContent = `${m}:${s}`;
document.getElementById('focus-timer-display').style.color = FC.timerSecs < 30 ? 'var(--red)' : 'var(--text)';
}
function focusReset() {
clearInterval(FC._interval);
FC.steps = []; FC.current = 0; FC.timerRunning = false;
document.getElementById('focus-session').style.display = 'none';
document.getElementById('focus-setup').style.display = 'flex';
document.getElementById('focus-task-input').value = '';
}
init();