Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2024-05-01 - [Pre-calculate derived properties for render loops]
**Learning:** Instantiating `Date` objects repeatedly inside a render or filter loop for large lists causes measurable UI slowdowns. Derived string concatenations and normalizations (like `.toLowerCase()`) in hot loops also have a performance penalty.
**Action:** Always pre-calculate derived search strings, formatted dates, and conditional booleans (like `isNew`) during initial data load instead of per-render recalculations. To prevent bloating `localStorage`, add these runtime properties after the core data has been saved to the cache.
69 changes: 52 additions & 17 deletions script.js
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,37 @@ async function syncClassSwitcher() {
renderSemesterTabs();
}

function prepareSearchIndex(pdfs) {
const now = new Date();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;

pdfs.forEach(pdf => {
// Pre-calculate search string for efficient filtering
const title = pdf.title || '';
const desc = pdf.description || '';
const cat = pdf.category || '';
const author = pdf.author || '';
pdf._searchStr = `${title} ${desc} ${cat} ${author}`.toLowerCase();

// Handle Firestore Timestamp or standard Date string
let uploadDateObj;
if (pdf.uploadDate && typeof pdf.uploadDate.toDate === 'function') {
uploadDateObj = pdf.uploadDate.toDate();
} else {
uploadDateObj = new Date(pdf.uploadDate);
}

// Pre-calculate isNew flag
const timeDiff = now - uploadDateObj;
pdf._isNew = timeDiff < sevenDaysMs;

// Pre-calculate formatted date string
pdf._formattedDate = uploadDateObj.toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
});
}

async function loadPDFDatabase() {
if (isMaintenanceActive) return;

Expand Down Expand Up @@ -454,6 +485,7 @@ async function loadPDFDatabase() {

if (shouldUseCache) {
pdfDatabase = cachedData;
prepareSearchIndex(pdfDatabase);
// --- FIX: CALL THIS TO POPULATE UI ---
syncClassSwitcher();
renderSemesterTabs();
Expand All @@ -477,6 +509,8 @@ async function loadPDFDatabase() {
data: pdfDatabase
}));

prepareSearchIndex(pdfDatabase);

// --- FIX: CALL THIS TO POPULATE UI ---
syncClassSwitcher();
renderPDFs();
Expand Down Expand Up @@ -905,26 +939,23 @@ function renderPDFs() {

// Locate renderPDFs() in script.js and update the filter section
const filteredPdfs = pdfDatabase.filter(pdf => {
const matchesSemester = pdf.semester === currentSemester;
// Early returns for cheap equality checks
if (pdf.semester !== currentSemester) return false;

// NEW: Check if the PDF class matches the UI's current class selection
// Note: If old documents don't have this field, they will be hidden.
const matchesClass = pdf.class === currentClass;
if (pdf.class !== currentClass) return false;

let matchesCategory = false;
if (currentCategory === 'favorites') {
matchesCategory = favorites.includes(pdf.id);
} else {
matchesCategory = currentCategory === 'all' || pdf.category === currentCategory;
if (!favorites.includes(pdf.id)) return false;
} else if (currentCategory !== 'all') {
if (pdf.category !== currentCategory) return false;
}

const matchesSearch = pdf.title.toLowerCase().includes(searchTerm) ||
pdf.description.toLowerCase().includes(searchTerm) ||
pdf.category.toLowerCase().includes(searchTerm) ||
pdf.author.toLowerCase().includes(searchTerm);
// Check pre-calculated search string (guarding against missing index)
if (!pdf._searchStr) return false;

// Update return statement to include matchesClass
return matchesSemester && matchesClass && matchesCategory && matchesSearch;
return pdf._searchStr.includes(searchTerm);
});

updatePDFCount(filteredPdfs.length);
Expand Down Expand Up @@ -994,9 +1025,13 @@ function createPDFCard(pdf, favoritesList, index = 0, highlightRegex = null) {
const heartIconClass = isFav ? 'fas' : 'far';
const btnActiveClass = isFav ? 'active' : '';

const uploadDateObj = new Date(pdf.uploadDate);
const timeDiff = new Date() - uploadDateObj;
const isNew = timeDiff < (7 * 24 * 60 * 60 * 1000); // 7 days
// Use pre-calculated isNew flag if available, fallback to dynamic calculation
let isNew = pdf._isNew;
if (isNew === undefined) {
const uploadDateObj = new Date(pdf.uploadDate);
const timeDiff = new Date() - uploadDateObj;
isNew = timeDiff < (7 * 24 * 60 * 60 * 1000); // 7 days
}

const newBadgeHTML = isNew
? `<span style="background:var(--error-color); color:white; font-size:0.6rem; padding:2px 6px; border-radius:4px; margin-left:8px; vertical-align:middle;">NEW</span>`
Expand All @@ -1010,8 +1045,8 @@ function createPDFCard(pdf, favoritesList, index = 0, highlightRegex = null) {
};
const categoryIcon = categoryIcons[pdf.category] || 'fa-file-pdf';

// Formatting Date
const formattedDate = new Date(pdf.uploadDate).toLocaleDateString('en-US', {
// Use pre-calculated formatted date if available, fallback to dynamic formatting
const formattedDate = pdf._formattedDate || new Date(pdf.uploadDate).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});

Expand Down
66 changes: 66 additions & 0 deletions tests/test_render_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Ah, 'inorganic' contains 'organic'. My test search term was ambiguous!

import json

def run_tests():
global_state = {
'currentSemester': 1,
'currentClass': 'MSc Chemistry',
'currentCategory': 'all',
'searchTerm': 'alcohols', # Changed search term
'favorites': []
}

pdfs = [
{
"id": "pdf1",
"class": "MSc Chemistry",
"semester": 1,
"category": "Organic",
"title": "Alcohols and Phenols",
"description": "Notes on Alcohols and Phenols",
"author": "Dr. Smith",
"uploadDate": "2024-01-01T00:00:00.000Z"
},
{
"id": "pdf2",
"class": "MSc Chemistry",
"semester": 1,
"category": "Inorganic",
"title": "Coordination Compounds",
"description": "Notes on Coordination Compounds",
"author": "Dr. Jones",
"uploadDate": "2024-02-01T00:00:00.000Z"
}
]

# Simulate prepareSearchIndex
for pdf in pdfs:
title = pdf.get('title', '')
desc = pdf.get('description', '')
cat = pdf.get('category', '')
author = pdf.get('author', '')
pdf['_searchStr'] = f"{title} {desc} {cat} {author}".lower()
pdf['_isNew'] = False
pdf['_formattedDate'] = 'Jan 1, 2024'

# Simulate renderPDFs filter logic
filtered = []
for pdf in pdfs:
if pdf['semester'] != global_state['currentSemester']: continue
if pdf['class'] != global_state['currentClass']: continue
if global_state['currentCategory'] == 'favorites':
if pdf['id'] not in global_state['favorites']: continue
elif global_state['currentCategory'] != 'all':
if pdf['category'] != global_state['currentCategory']: continue
if not pdf.get('_searchStr'): continue
if global_state['searchTerm'] not in pdf['_searchStr']: continue
filtered.append(pdf)

assert len(filtered) == 1, f"Expected 1 item, got {len(filtered)}"
assert filtered[0]['id'] == 'pdf1', "Wrong item filtered"

print("Logic verification PASSED")

if __name__ == '__main__':
run_tests()
93 changes: 93 additions & 0 deletions tests/verify_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import asyncio
from playwright.async_api import async_playwright

async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()

# Seed local storage with test data
await page.goto('http://localhost:8000')

mock_data = """
{"timestamp": 9999999999999, "data": [
{
"id": "pdf1",
"class": "MSc Chemistry",
"semester": 1,
"category": "Organic",
"title": "Alcohols and Phenols",
"description": "Notes on Alcohols and Phenols",
"author": "Dr. Smith",
"uploadDate": "2024-01-01T00:00:00.000Z"
},
{
"id": "pdf2",
"class": "MSc Chemistry",
"semester": 1,
"category": "Inorganic",
"title": "Coordination Compounds",
"description": "Notes on Coordination Compounds",
"author": "Dr. Jones",
"uploadDate": "2024-02-01T00:00:00.000Z"
}
]}
"""

await page.evaluate(f"localStorage.setItem('classnotes_db_cache', `{mock_data}`);")
await page.evaluate(f"localStorage.setItem('currentClass', 'MSc Chemistry');")
await page.evaluate(f"localStorage.setItem('currentSemester', '1');")

# Mock Firebase network requests to avoid overwriting cache
await page.route("**/firestore.googleapis.com/**", lambda route: route.abort())

# Mock Firebase app script to avoid hanging
await page.route("**/firebase-app.js", lambda route: route.fulfill(body="window.firebase = { apps: [], initializeApp: () => {}, firestore: () => ({ collection: () => ({ doc: () => ({ onSnapshot: () => {} }) }) }) };", status=200))
await page.route("https://www.gstatic.com/firebasejs/10.8.0/firebase-app-compat.js", lambda route: route.fulfill(body="window.firebase = { apps: [], initializeApp: () => {} };", status=200))
await page.route("https://www.gstatic.com/firebasejs/10.8.0/firebase-firestore-compat.js", lambda route: route.fulfill(body="window.firebase.firestore = () => ({ collection: () => ({ doc: () => ({ onSnapshot: () => {} }), orderBy: () => ({ get: async () => ({ empty: true }) }) }) });", status=200))
await page.route("https://www.gstatic.com/firebasejs/10.8.0/firebase-auth-compat.js", lambda route: route.fulfill(body="window.firebase.auth = () => ({ signInAnonymously: async () => ({ user: { uid: 'mock' } }), onAuthStateChanged: (cb) => { cb(null); } });", status=200))

# Reload to use the seeded mock data
await page.goto('http://localhost:8000', wait_until="domcontentloaded")

# Force load the db to trigger render directly
await page.evaluate("""
window.db = {
collection: () => ({
doc: () => ({ onSnapshot: () => {} }),
orderBy: () => ({
get: async () => ({
empty: true
}),
limit: () => ({ get: async () => ({ empty: true }) })
})
})
};
if(window.loadPDFDatabase) {
window.loadPDFDatabase().catch(e => console.error(e));
}
""")

# Wait for the database to load and render
await page.wait_for_selector('.pdf-card', state='attached', timeout=5000)

# Check initial render count
cards = await page.locator('.pdf-card').all()
assert len(cards) == 2, f"Expected 2 cards initially, got {len(cards)}"
print("Initial render: PASSED (2 cards)")

# Test searching
await page.fill('#searchInput', 'Organic')
await page.wait_for_timeout(1000) # wait for debounce/render

cards_after_search = await page.locator('.pdf-card').all()
assert len(cards_after_search) == 1, f"Expected 1 card after search, got {len(cards_after_search)}"

text = await cards_after_search[0].inner_text()
assert 'Alcohols' in text, "The wrong card was filtered"
print("Search filtering: PASSED")

await browser.close()

if __name__ == '__main__':
asyncio.run(main())