JavaScript Architecture
Complete documentation of the client-side JavaScript in /Users/weixiangzhang/Local_Dev/projects/bythewei/src/pages/index.astro.
1. Script Architecture
The page contains two <script> blocks, both IIFEs (Immediately Invoked Function Expressions), positioned after all HTML markup and before the <style> block.
Script Block 1 — Main Application (lines 288-1111)
<script>
(function () {
// ~820 lines: QOTD, Catalog, Journal, Reading Year, modals, event handling
})();
</script>
- Type: Standard Astro client-side
<script>(no special directives). - Pattern: Single top-level IIFE wrapping all application logic.
- Scope: All variables and functions are private to the closure. Nothing leaks to
window. - Subsystems: QOTD fetch, Bookshelf Catalog, Reading Journal, Reading Year Modal, global Escape handler.
Script Block 2 — Kaomoji Rotation Engine (lines 1117-1146)
<script define:vars={{ kaomojis }}>
(function () {
// ~28 lines: date-seeded rotation of kaomoji faces
})();
</script>
- Type: Astro
<script>withdefine:varsdirective. define:vars={{ kaomojis }}: Injects the server-sidekaomojisarray (imported from../data/kaomojis.tsin the frontmatter) as a serialized JS variable available inside the script at runtime.- Pattern: Separate IIFE, completely independent from Script Block 1.
- Why separate: It needs
define:varsto receive the kaomoji dataset from the Astro build step. The main script block does not usedefine:vars.
Summary Table
| Block | Lines | Directive | Pattern | Purpose |
|---|---|---|---|---|
| 1 | 288-1111 | (none) | IIFE | All interactive features |
| 2 | 1117-1146 | define:vars | IIFE | Kaomoji daily rotation |
2. Date-Seeded Randomization (hashPick / hash)
Both script blocks implement the same deterministic pseudo-random system. Every visitor on the same calendar day sees the same selections worldwide. Selections rotate automatically at midnight with no deploy required.
Seed Calculation
const d = new Date();
const seed = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
This produces a unique integer per calendar day (e.g., 20260219 for Feb 19, 2026). The formula encodes YYYYMMDD as a plain integer.
Hash Function (Knuth multiplicative hash variant)
Both blocks use the same algorithm, named hashPick in Block 1 and hash in Block 2:
function hashPick(arr, offset) {
let x = (seed * 2654435761 + offset * 2246822519) >>> 0; // Knuth golden ratio constants
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0; // Mix bits (MurmurHash3 finalizer)
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0; // Second round
return arr[(x ^ (x >>> 16)) % arr.length]; // Index into array
}
2654435761: Knuth’s multiplicative hash constant (closest prime to2^32 / phi).2246822519: Secondary prime for offset mixing.0x45d9f3b: MurmurHash3 finalizer constant — two rounds of xor-shift-multiply for avalanche.>>> 0: Forces unsigned 32-bit integer (prevents negative indices).
QOTD Selection (Block 1)
const pool = entries.filter(e => e.highlights && e.highlights.length > 30);
const entry = hashPick(pool, 9999);
- Filters for entries with highlight text longer than 30 characters.
- Uses offset
9999(arbitrary fixed constant) to select one entry per day. - The same quote shows all day for all visitors.
Kaomoji Selection (Block 2)
Uses a deduplication layer on top of the hash:
const used = new Set();
function pick(slotIndex) {
let attempt = 0, idx;
do {
idx = hash(seed, slotIndex * 97 + attempt) % total;
attempt++;
} while (used.has(idx) && attempt < total);
used.add(idx);
return kaomojis[idx];
}
- Each
[data-kaomoji]element gets a unique kaomoji (no repeats within a page load). slotIndex * 97spreads the hash inputs to avoid clustering.- The
attemptcounter resolves collisions by probing the next hash value. - Falls through after
totalattempts (safety valve — practically never hit with 200 kaomojis and ~12 slots).
3. Data Loading
bookmarks.clean.json (eager fetch)
fetch('/data/bookmarks.clean.json')
.then(r => r.json())
.then(entries => {
allEntries = entries;
// ... populate QOTD ...
})
.catch(() => { /* fallback message */ });
- Timing: Fetched immediately on page load (inside the IIFE, not gated by any user action).
- Storage: Cached in the closure variable
allEntries(module-level array). - Dual purpose: Powers both QOTD (immediate) and Catalog/Journal (on demand).
- Error handling: Sets loading text to a fallback kaomoji message.
File location: /Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/bookmarks.clean.json
reading-log.json (lazy fetch)
function openReading() {
readingModal.hidden = false;
document.body.style.overflow = 'hidden';
if (!readingBuilt) {
fetch('/data/reading-log.json')
.then(r => r.json())
.then(books => buildReadingYear(books))
.catch(() => { readingLoading.textContent = 'failed to load reading data (CVV_CV)'; });
}
}
- Timing: Fetched only on first open of the Reading Year modal.
- Guard:
readingBuiltflag prevents re-fetching on subsequent opens. - No caching variable: The data flows directly into
buildReadingYear()which builds the DOM; the raw array is not stored.
File location: /Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/reading-log.json
4. Modal System
Three modals exist, each following the same pattern but with slight variations.
Modal Inventory
| Modal | ID | Trigger | Z-order |
|---|---|---|---|
| Catalog | catalog-modal | #shelf-trigger (hidden pin button) | Base |
| Reading Journal | journal-modal | Spine click in Catalog / QOTD book | Above Catalog |
| Reading Year | reading-modal | #reading-trigger (sticky button) | Independent |
Open Pattern
Every modal open function follows:
function openX() {
xModal.hidden = false;
document.body.style.overflow = 'hidden'; // scroll lock
// optional: lazy-build content
}
Close Pattern
function closeX() {
xModal.hidden = true;
document.body.style.overflow = ''; // restore scroll
// optional: save state (Journal saves scroll position)
}
Shared Behaviors
- Backdrop click: Each modal listens for clicks on the
.modal-backdropitself (not children) to close.catalogModal.addEventListener('click', e => { if (e.target === catalogModal) closeCatalog(); }); - Close button: Each has a
.modal-closebutton with a dedicated listener. - Body scroll lock:
document.body.style.overflow = 'hidden'on open, restored to''on close.
Escape Key Handling (Global)
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (!journalModal.hidden) closeJournal();
else if (!readingModal.hidden) { reviewTip.hidden = true; closeReading(); }
else if (!catalogModal.hidden) closeCatalog();
});
Priority order: Journal > Reading Year > Catalog. This handles the stacking case where Journal opens on top of Catalog — pressing Escape closes Journal first, then a second press closes Catalog.
The Reading Year close also explicitly hides the reviewTip tooltip.
Modal Stacking: Catalog to Journal
The Catalog-to-Journal transition is the only stacking case:
// Spine click in catalog:
spine.addEventListener('click', () => {
closeCatalog(false); // false = don't restore scroll yet
setTimeout(() => openJournal(...), 200); // 200ms delay for visual transition
});
// Journal back button:
journalBack.addEventListener('click', () => { closeJournal(); openCatalog(true); });
The closeCatalog(restoreScroll) parameter controls whether body.overflow is restored:
falsewhen transitioning to Journal (keeps scroll locked).true(default) when closing to the main page.
5. Catalog (Bookshelf)
groupBooks() — Title Normalization and Grouping
normTitle()
function normTitle(t) {
return (t ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 40);
}
- Lowercases the title.
- Strips all non-alphanumeric characters (handles punctuation, spaces, unicode).
- Truncates to 40 characters (prevents long-title key bloat).
- Purpose: Deduplicates entries that have the same book with slightly different title formatting or author names.
groupBooks()
function groupBooks() {
const books = {};
for (const e of allEntries) {
if (!e.highlights || e.highlights.length < 10) continue; // skip trivial entries
const key = normTitle(e.book_title);
if (!books[key]) {
books[key] = { title: e.book_title, author: e.author, entries: [] };
} else {
// Keep the longer/more complete author name
if ((e.author || '').length > (books[key].author || '').length) {
books[key].author = e.author;
}
}
books[key].entries.push(e);
}
return Object.values(books).sort((a, b) => b.entries.length - a.entries.length);
}
- Groups by normalized title (title-only key, ignoring author variants).
- Filters out entries with highlights shorter than 10 characters.
- When merging, keeps the longest author name (handles cases like “Nghi Vo” vs “Nghi Vo (Author)”).
- Returns sorted by annotation count descending (most-highlighted books first).
spineWidth(count) — Width Encoding
function spineWidth(count) {
if (count <= 2) return 40;
if (count <= 10) return 48;
if (count <= 25) return 56;
if (count <= 60) return 64;
return 72;
}
Maps annotation count to pixel width in 5 tiers. More highlights = wider spine = more visual prominence on the shelf.
spineColors(count) — Color Encoding
function spineColors(count) {
if (count === 0) return ['#f0ece0', '#bbb6a8']; // ghost: cream
if (count <= 2) return ['#e8d5b5', '#8a7a62']; // ivory
if (count <= 10) return ['#c4956a', '#fff8ef']; // warm tan
if (count <= 25) return ['#a0522d', '#ffe8d0']; // sienna
if (count <= 60) return ['#7a3020', '#ffd4b8']; // deep rust
return ['#4a1a1a', '#f0c090']; // near-black mahogany
}
Returns [background, text-color] tuple. The progression goes from light cream (barely annotated) to dark mahogany (heavily read). Six tiers total.
spineGold(count) — Gold Shimmer
function spineGold(count) { return count >= 26; }
Books with 26+ annotations get a spine-gold CSS class (presumably a shimmer/glow effect defined in CSS).
Shelf Row Packing Algorithm
const SHELF_WIDTH = bookcase.clientWidth || 1060;
const SPINE_GAP = 2;
const rows = [];
let currentRow = [];
let rowWidth = 0;
for (const book of books) {
const w = spineWidth(book.entries.length) + SPINE_GAP;
if (rowWidth + w > SHELF_WIDTH && currentRow.length > 0) {
rows.push(currentRow);
currentRow = [];
rowWidth = 0;
}
currentRow.push(book);
rowWidth += w;
}
if (currentRow.length) rows.push(currentRow);
- Algorithm: Greedy first-fit bin packing, left to right.
- SHELF_WIDTH: Measured from the actual bookcase element width (fallback 1060px).
- SPINE_GAP: 2px between spines.
- Books are already sorted by annotation count (descending), so the most-highlighted books appear on the top shelf.
- Each row is rendered as a
.shelf-rowcontaining.shelf-spinesand a.shelf-plankdivider.
Tooltip System
A single tooltip element (#spine-tooltip) is shared across all spines and repositioned on hover.
showTooltip(spine, event)
function showTooltip(spine, e) {
document.getElementById('st-title').textContent = spine.dataset.title;
document.getElementById('st-author').textContent = spine.dataset.author;
const count = parseInt(spine.dataset.count, 10);
const max = parseInt(spine.dataset.maxCount, 10);
document.getElementById('st-count').textContent = `${count} mark${count !== 1 ? 's' : ''}`;
document.getElementById('st-bar').style.width = `${Math.round((count / max) * 100)}%`;
document.getElementById('st-teaser').textContent = spine.dataset.teaser;
spineTooltip.hidden = false;
positionTooltip(e);
}
Populates five fields from data-* attributes: title, author, count (with pluralization), a proportional bar (scaled to maxCount = 91), and a teaser quote.
hideTooltip()
Simply sets spineTooltip.hidden = true.
positionTooltip(event)
function positionTooltip(e) {
if (spineTooltip.hidden) return;
const tw = spineTooltip.offsetWidth || 240;
const th = spineTooltip.offsetHeight || 120;
let x = e.clientX - tw / 2;
let y = e.clientY - th - 16;
x = Math.max(8, Math.min(window.innerWidth - tw - 8, x));
y = Math.max(8, Math.min(window.innerHeight - th - 8, y));
spineTooltip.style.left = `${x}px`;
spineTooltip.style.top = `${y}px`;
}
- Centers horizontally on cursor.
- Places above cursor with 16px gap.
- Clamps to viewport edges with 8px margin.
- Follows mouse via
mousemovelistener on each spine.
Spine Event Bindings
spine.addEventListener('mouseenter', (e) => showTooltip(spine, e));
spine.addEventListener('mouseleave', hideTooltip);
spine.addEventListener('mousemove', (e) => positionTooltip(e));
spine.addEventListener('click', () => {
closeCatalog(false);
setTimeout(() => openJournal(book.title, book.author), 200);
});
6. Reading Journal
openJournal(bookTitle, author)
function openJournal(bookTitle, author) {
buildJournal(bookTitle, author);
journalModal.hidden = false;
journalPages.scrollTop = 0;
document.body.style.overflow = 'hidden';
}
Called from: spine click (Catalog), QOTD book title button.
buildJournal(bookTitle, author) — Page Rendering
- Filters
allEntriesby normalized title match (samenormTitle()dedup). - Sets header (title + author).
- For each entry, renders:
- Entry number: Zero-padded two-digit index.
- Date: From the entry’s
datefield. - Text: Split into a bold “first sentence” (via
firstSentence()) and the remainder. - Dog-ear button: Toggle for bookmarking individual passages.
firstSentence(text)
function firstSentence(text) {
const m = text.match(/^.{20,}?[.!?]["'\u201D]?\s/);
return m ? m[0].trim() : text.slice(0, 120);
}
Matches the first sentence (minimum 20 chars, ending at a sentence-terminal punctuation mark optionally followed by a closing quote). Falls back to first 120 characters.
Dog-ear localStorage Persistence
function dogKey(title, i) {
return `de-${title.replace(/\W/g,'').slice(0,20)}-${i}`;
}
- Key format:
de-{sanitized-title-20chars}-{index}. - Toggle behavior: clicking the dog-ear button adds/removes a
localStorageentry. - Visual: adds/removes
dog-earedclass on the entry element. - Side effect: calls
buildTOC()to refresh the TOC panel (dogeared items get a pin icon).
TOC Panel
function buildTOC(entries, bookTitle) {
tocList.innerHTML = '';
entries.forEach((e, i) => {
const dogeared = localStorage.getItem(dogKey(bookTitle, i));
const tocItem = document.createElement('button');
tocItem.className = `toc-item${dogeared ? ' toc-dogeared' : ''}`;
tocItem.textContent = `${dogeared ? '(pin) ' : ''}${i + 1}. ${(e.date ?? '').slice(0, 10) || '---'}`;
tocItem.addEventListener('click', () => {
document.getElementById(`je-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
tocPanel.hidden = true;
});
tocList.appendChild(tocItem);
});
}
- Toggleable via
#toc-togglebutton (tocPanel.hidden = !tocPanel.hidden). - Each item scrolls to the corresponding entry with smooth animation.
- Dogeared entries are visually distinguished with a pin prefix and
toc-dogearedclass. - Clicking a TOC item auto-closes the TOC panel.
Ribbon Scroll Indicator
journalPages.addEventListener('scroll', () => {
const pct = journalPages.scrollTop / (journalPages.scrollHeight - journalPages.clientHeight);
journalRibbon.style.height = `${Math.min(100, pct * 100)}%`;
});
A vertical ribbon that grows as you scroll through the journal, acting as a progress indicator along the spine.
Scroll Position Persistence
- Save: On
closeJournal(), savesjournalPages.scrollToptolocalStoragekeyjs-scroll-{title-20chars}. - Restore: On
buildJournal(), reads and restores the saved scroll position.
7. Reading Year Modal
buildReadingYear(books) — Full Breakdown
Called once (guarded by readingBuilt flag) when the modal first opens. Receives the parsed reading-log.json array.
Stats Strip
readingStats.innerHTML = [
[`${books.length}`, 'books'],
[`${totalPages.toLocaleString()}`, 'pages'],
[`${avgRating}`, 'avg rating'],
[`${fiveStars}`, 'five-stars'],
[`${genres}`, 'genres'],
].map(([v, l]) => `<div class="rs-card"><strong>${v}</strong><span>${l}</span></div>`).join('');
Five stat cards: total books, total pages, average rating, five-star count, genre count.
Monthly Wall
- Groups books by
YYYY-MMkey fromdate_finished. - Sorts month keys chronologically.
- Each month column (
.wall-month) contains:- A stack (
.wall-stack) of colored mini-blocks (.wall-mini), one per book. - Each mini is colored by genre (
genreColor(b.genre)). - Each mini has a small random rotation (
Math.random() * 6 - 3degrees) for a hand-placed aesthetic. - Title attribute:
"Book Title --- (star rating)"for native tooltip on hover. - A month label (e.g., “Feb ‘19”) and count.
- A stack (
Genre Bars
- Counts books per genre, sorts descending.
- Renders horizontal bar chart rows.
- Bar width:
(count / maxGenre * 100)%. - Bar color: from
genreColor()mapping.
Diversity Bars
Two stacked bar sections:
- Gender: Proportional flexbox segments for F/M/N.
- Authors of Color: Proportional flexbox segments for yes/no.
Each segment shows the label and count.
Emotion Receipt
- Aggregates
emotionsarrays from all books (normalizes “Angry” to “Anger”). - Renders as a receipt-style monospaced list.
- Includes:
- Sorted emotion/count rows.
- NET EMOTION: POSITIVE or NEGATIVE based on majority of
emotional_outputfield. - TOTAL PAGES and TOTAL BOOKS summary.
- A
.receipt-teardiv at the bottom for visual effect.
Top Books (“The Fives”)
- Filters for 5-star books, sorted by page count descending.
- Shows up to 20 entries.
- Books with reviews get
top-clickableclass and a click handler for the review tooltip.
Book List with Search Filtering
blSearch.addEventListener('input', () => {
const q = blSearch.value.toLowerCase().trim();
booklistEl.querySelectorAll('.bl-row').forEach(row => {
const b = titleToRow[row.dataset.title]?.book;
const haystack = [
b.title, b.author, b.genre, b.fiction,
b.review || '', b.gender === 'F' ? 'female woman' : b.gender === 'M' ? 'male man' : 'nonbinary',
b.poc ? 'poc' : '',
].join(' ').toLowerCase();
row.hidden = q.length > 0 && !haystack.includes(q);
});
});
- Full-text search across title, author, genre, fiction type, review text, expanded gender terms, and POC flag.
- Simple
String.includes()matching (case-insensitive). - Real-time filtering on every keystroke.
Bidirectional Hover Highlighting
highlightViz(book) — From Book List Row or Wall Mini
function highlightViz(b) {
// Dim all wall-minis except matching; highlight matching
readingWall.querySelectorAll('.wall-mini').forEach(m => {
m.classList.toggle('wall-dim', !m.title.startsWith(b.title));
if (m.title.startsWith(b.title)) m.classList.add('wall-highlight');
});
// Highlight matching genre bar
genreBars.querySelectorAll('.bar-row').forEach(r => {
const label = r.querySelector('.bar-label');
r.classList.toggle('bar-active', label && label.textContent === b.genre);
});
// Highlight matching gender segment
// Highlight matching POC segment
}
Cross-highlights across four visualizations simultaneously: monthly wall, genre bars, gender bars, and POC bars.
clearHighlights()
Removes all wall-dim, wall-highlight, bar-active, div-bar-active, and bl-active classes across all visualizations.
Direction 1: Book List Row -> Visualizations
row.addEventListener('mouseenter', () => {
row.classList.add('bl-active');
highlightViz(b);
});
row.addEventListener('mouseleave', () => {
row.classList.remove('bl-active');
clearHighlights();
});
Direction 2: Wall Mini -> Book List
mini.addEventListener('mouseenter', () => {
if (wallLocked) return;
const bookTitle = mini.title.split(' --- ')[0];
const match = titleToRow[bookTitle];
if (match) {
match.row.classList.add('bl-active');
highlightViz(match.book);
}
// Dim other minis, highlight this one
});
Uses titleToRow lookup map (title string -> { row, book }) for O(1) reverse lookup.
Wall-Mini Click: Lock + Scroll
mini.addEventListener('click', () => {
const bookTitle = mini.title.split(' --- ')[0];
const match = titleToRow[bookTitle];
if (!match) return;
clearHighlights();
match.row.classList.add('bl-active');
highlightViz(match.book);
// ... dim/highlight wall minis ...
// Scroll book list panel to the matching row
const listContainer = document.getElementById('reading-booklist');
const rowTop = match.row.offsetTop - listContainer.offsetTop;
listContainer.scrollTo({ top: rowTop - 40, behavior: 'smooth' });
// Lock hover for 4 seconds
wallLocked = true;
setTimeout(() => {
wallLocked = false;
clearHighlights();
}, 4000);
});
- Clicking a wall mini “locks” the highlight state.
- Scrolls the book list panel (not the page) to show the matching row.
wallLocked = truesuppresses hover interactions for 4 seconds.- After timeout, clears all highlights and re-enables hover.
Review Tooltip System
Two sources can trigger the review tooltip:
- Top Books panel (
top-clickableitems). - Book List panel (
bl-clickablerows).
Both share the same dynamically-created tooltip element:
const reviewTip = document.createElement('div');
reviewTip.className = 'review-tooltip';
reviewTip.hidden = true;
reviewTip.innerHTML = '<div class="rt-title"></div><div class="rt-meta"></div><div class="rt-body"></div>';
document.body.appendChild(reviewTip);
Toggle behavior: Clicking the same book again hides the tooltip. Clicking a different book repositions it.
Positioning: Near the clicked element, clamped to viewport edges. Attempts below the element first, flips above if insufficient space.
Dismissal: Global click listener closes the tooltip when clicking outside both the tooltip itself and any clickable book row.
Genre Color Mapping
const GENRE_COLORS = {
'Fantasy': '#4ecdc4', 'History': '#5dade2',
'Contemporary': '#f0b847', 'Autobiography': '#bb8fce',
'Technology Studies': '#5499c7','Productivity': '#f5b041',
'Race Studies': '#ec7063', 'Science Fiction': '#48c9b0',
'Philosophy': '#af7ac5', 'Sociology': '#f1948a',
'Psychology': '#f7dc6f', 'Mythology': '#45b39d',
'Historical Fiction': '#e59866','Medical': '#76d7c4',
'Graphic Novel': '#aed6f1', 'Poetry': '#d2b4de',
'Horror': '#cd6155',
};
function genreColor(g) { return GENRE_COLORS[g] || '#8B6914'; }
17 named genre colors with a brown/gold fallback for unmapped genres.
8. Kaomoji Rotation Engine (Script Block 2)
How Daily Rotation Works
- At build time, Astro imports
kaomojisfrom../data/kaomojis.ts(200 entries, categorized). define:vars={{ kaomojis }}serializes the array into the client script.- On page load, the IIFE calculates today’s seed and replaces every
[data-kaomoji]element’s text content. - A
Settracks used indices to guarantee no duplicate kaomojis on the same page.
Kaomoji Dataset Structure
File: /Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/kaomojis.ts
export const kaomojis: string[] = [
// 200 entries across 12 categories:
// HAPPY / WHOLESOME (20)
// EXCITED / CELEBRATING (20)
// SURPRISED / SHOCKED (20)
// ANGRY / TABLE FLIP (20)
// SAD / CRYING (20)
// COOL / SMUG / UNBOTHERED (20)
// FIGHTING / DETERMINED (20)
// LOVE / AFFECTIONATE (20)
// SHRUG / WHATEVER (10)
// ANIMALS (15)
// MAGIC / SPARKLE (10)
// TIRED / DONE (10)
// RUNNING / CHAOS (10)
];
Approximately 200 kaomojis covering the full emotional spectrum. Categories are comments only — the array is flat at runtime.
Slot Assignment
document.querySelectorAll('[data-kaomoji]').forEach(function (el, i) {
el.textContent = pick(i);
});
Each [data-kaomoji] element in the HTML has a default kaomoji in the Astro template (e.g., (|(*^_^*)|)). These are server-rendered defaults that get replaced client-side. The i parameter (DOM order index) determines which kaomoji each slot receives for that day.
9. Helper Functions
escHtml(s)
function escHtml(s) {
return (s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
Basic HTML entity escaping. Null-safe via ??. Used everywhere user data is interpolated into innerHTML.
stars(n)
function stars(n) { return '(filled star)'.repeat(n) + '(empty star)'.repeat(5 - n); }
Generates a 5-star rating string from an integer (e.g., stars(3) returns 3 filled + 2 empty stars).
genderBadge(g)
function genderBadge(g) {
const map = { F: ['F','bl-badge-f'], M: ['M','bl-badge-m'], N: ['N','bl-badge-n'] };
const [label, cls] = map[g] || ['?',''];
return `<span class="bl-badge ${cls}">${label}</span>`;
}
Returns an HTML badge span with gender-specific styling. Maps F/M/N to labeled, classed badges.
pocBadge(poc)
function pocBadge(poc) {
return poc ? `<span class="bl-badge bl-badge-poc">POC</span>` : '';
}
Returns a “POC” badge or empty string.
firstSentence(text)
function firstSentence(text) {
const m = text.match(/^.{20,}?[.!?]["'\u201D]?\s/);
return m ? m[0].trim() : text.slice(0, 120);
}
Extracts the first sentence for the bold lead in journal entries.
getTeaser(entries)
function getTeaser(entries) {
const e = [...entries]
.sort((a, b) => a.highlights.length - b.highlights.length)
.find(e => e.highlights.length > 40);
const t = e?.highlights ?? '';
return t.length > 100 ? t.slice(0, 97).trimEnd() + '...' : t;
}
Finds the shortest meaningful highlight (>40 chars) for spine tooltip teasers. Truncates to 100 chars with ellipsis.
genreColor(g)
function genreColor(g) { return GENRE_COLORS[g] || '#8B6914'; }
Lookup with brown/gold fallback.
normTitle(t)
function normTitle(t) {
return (t ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 40);
}
Title normalization for dedup key generation.
dogKey(title, i)
function dogKey(title, i) {
return `de-${title.replace(/\W/g,'').slice(0,20)}-${i}`;
}
localStorage key for dog-ear state.
10. Event Handling
Direct Binding (element-specific listeners)
| Element | Event | Handler |
|---|---|---|
#shelf-trigger | click | openCatalog() |
#catalog-close | click | closeCatalog() |
#catalog-modal | click | Close if target is backdrop itself |
#journal-back | click | closeJournal(); openCatalog(true) |
#journal-close | click | closeJournal() |
#journal-modal | click | Close if target is backdrop itself |
#journal-pages | scroll | Update ribbon progress |
#toc-toggle | click | Toggle TOC panel visibility |
#reading-trigger | click | openReading() |
#reading-close | click | closeReading() |
#reading-modal | click | Close if target is backdrop itself |
#qotd-book | click | openJournal() with book data |
#bl-search | input | Filter book list rows |
document | keydown | Global Escape handler (priority: J > R > C) |
document | click | Close review tooltip on outside click |
Per-Element Binding (generated elements)
| Element Class | Event | Handler |
|---|---|---|
.book-spine | mouseenter | showTooltip() |
.book-spine | mouseleave | hideTooltip() |
.book-spine | mousemove | positionTooltip() |
.book-spine | click | Close catalog, open journal after 200ms delay |
.je-dogear | click | Toggle dog-ear state + rebuild TOC |
.toc-item | click | Scroll to entry + close TOC panel |
.bl-row | mouseenter | Highlight row + highlightViz() |
.bl-row | mouseleave | Clear all highlights |
.bl-clickable | click | Toggle review tooltip |
.wall-mini | mouseenter | Highlight matching book row + viz (if not locked) |
.wall-mini | mouseleave | Clear highlights (if not locked) |
.wall-mini | click | Lock highlights + scroll book list to match |
.top-clickable | click | Toggle review tooltip |
Delegation vs Direct
The codebase uses exclusively direct binding — no event delegation. Every dynamically created element gets its own addEventListener call at creation time. This is viable because:
- The element count is bounded (150 books, ~200 bookmarks).
- Elements are created once and never destroyed/recreated.
11. State Management
All state lives in closure variables within the main IIFE. Nothing is exposed globally.
Closure Variables
| Variable | Type | Purpose |
|---|---|---|
allEntries | Array | All bookmark entries from bookmarks.clean.json |
catalogBuilt | Boolean | Guard: prevents rebuilding the bookshelf on re-open |
readingBuilt | Boolean | Guard: prevents re-fetching/rebuilding Reading Year |
wallLocked | Boolean | Suppresses wall-mini hover during 4-second lock period |
titleToRow | Object | Map of book title -> { row: HTMLElement, book: Object } |
reviewTip | Element | The shared review tooltip DOM element |
reviewTimeout | Variable | Declared but unused (likely leftover from earlier iteration) |
ROTATIONS | Array | CSS rotation classes ['r1'..'r7'] (declared, not used in current code) |
GENRE_COLORS | Object | Genre name -> hex color mapping |
MONTHS | Array | Short month names for wall labels |
localStorage Keys
| Pattern | Purpose |
|---|---|
de-{title20}-{index} | Dog-ear state for a journal passage |
js-scroll-{title20} | Journal scroll position per book |
Build Guards
Both catalogBuilt and readingBuilt follow the same pattern:
- Start as
false. - Checked at the top of the build function.
- Set to
truebefore any DOM rendering. - Never reset — the built content persists for the page session.
Data Flow
Page Load
|
v
fetch bookmarks.clean.json --> allEntries (closure)
| |
v v
QOTD (hashPick) Catalog (on demand)
|
v
Journal (on spine click)
User clicks "150 books" sticky
|
v
fetch reading-log.json --> buildReadingYear()
|
v
Stats + Wall + Genres + Diversity + Receipt + Top Books + Book List
Appendix: File Map
| File | Purpose |
|---|---|
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/pages/index.astro | Main page: HTML + 2 script blocks + styles |
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/kaomojis.ts | 200 kaomojis in 12 categories |
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/bookmarks.clean.json | Kindle/reading highlights (title, author, highlights, date) |
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/reading-log.json | 150 books with rating, genre, gender, poc, emotions, review, pages |
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/sprint.json | Sprint board content (meta, stats, columns, stickies) |
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/layouts/Layout.astro | Base layout wrapper |