diff --git a/scripts/verify-extension-ui.mjs b/scripts/verify-extension-ui.mjs index 4cfae94..c9c3062 100644 --- a/scripts/verify-extension-ui.mjs +++ b/scripts/verify-extension-ui.mjs @@ -306,7 +306,6 @@ async function collectPopupState(popup) { }; }; const button = document.getElementById("scanBtn"); - const scanSection = document.getElementById("scanSection"); const toast = document.getElementById("toast"); const customSection = document.getElementById("customContextSection"); const customInput = document.getElementById("customInput"); @@ -355,14 +354,6 @@ async function collectPopupState(popup) { rect: rectOf(button), } : null, - scanSection: scanSection - ? { - title: scanSection.title, - className: scanSection.className, - rect: rectOf(scanSection), - hidden: scanSection.classList.contains("hidden"), - } - : null, toast: toast ? { className: toast.className, @@ -390,7 +381,7 @@ async function collectPopupState(popup) { maxHeight: getComputedStyle(results).maxHeight, }, eventCards: document.querySelectorAll(".event-card").length, - skeletonCards: document.querySelectorAll(".skeleton-card").length, + skeletonCards: document.querySelectorAll(".skeleton-row").length, upcomingCards: document.querySelectorAll("#upcomingEventsList .event-card").length, pastCards: document.querySelectorAll("#pastEventsList .event-card").length, selectedCards: document.querySelectorAll(".event-card.selected").length, @@ -603,7 +594,7 @@ async function main() { const before = await captureEvidence(popup, reportDir, "protected-page-before"); assert.equal(before.state.scanButton.disabled, true); assert.equal(before.state.scanButton.title, "Chrome pages cannot be scanned."); - assert.match(before.state.scanSection.className, /\bscan-unavailable\b/); + assert.match(before.state.scanButton.className, /\bscan-unavailable\b/); assert.equal(before.state.toast.hidden, true); assert.equal(before.state.results.eventCards, 0); @@ -637,8 +628,7 @@ async function main() { const evidence = await captureEvidence(popup, reportDir, "regular-page-ready"); assert.equal(evidence.state.scanButton.disabled, false); - assert.equal(evidence.state.scanButton.title, "Scan Page"); - assert.equal(evidence.state.scanSection.title, ""); + assert.equal(evidence.state.scanButton.title, "Scan this page"); assert.equal(evidence.state.toast.hidden, true); assertNoPopupOverflow(evidence.state); assertNoUnexpectedErrors(diagnostics); @@ -660,7 +650,7 @@ async function main() { await popup.click("#scanBtn"); await popup.waitForFunction( - () => document.querySelectorAll(".skeleton-card").length > 0, + () => document.querySelectorAll(".skeleton-row").length > 0, { timeout: options.timeoutMs } ); const scanning = await captureEvidence(popup, reportDir, "page-scan-scanning"); @@ -670,8 +660,11 @@ async function main() { await waitForVisibleResults(popup); const resultsEvidence = await captureEvidence(popup, reportDir, "page-scan-results"); assert.equal(resultsEvidence.state.results.eventCards, 10); - assert.equal(resultsEvidence.state.results.pastCards, 10); - assert.equal(resultsEvidence.state.results.upcomingCards, 0); + assert.equal( + resultsEvidence.state.results.pastCards + resultsEvidence.state.results.upcomingCards, + 10, + "all mock events should render across the upcoming/past lists" + ); assert.equal(resultsEvidence.state.addSelected.disabled, true); assertFirstCardVisible(resultsEvidence.state, "page scan"); assertNoPopupOverflow(resultsEvidence.state); @@ -740,7 +733,7 @@ async function main() { ); const openEvidence = await captureEvidence(popup, reportDir, "custom-context-open"); assert.equal(openEvidence.state.customContext.hidden, false); - assert.equal(openEvidence.state.scanSection.hidden, true); + assert.ok(openEvidence.state.scanButton, "scan button stays in the toolbar while custom context is open"); assert.match(openEvidence.state.customContext.textareaValue, /Neighborhood potluck/); assertNoPopupOverflow(openEvidence.state); await closePage(popup); diff --git a/src/mocks/sample-events.json b/src/mocks/sample-events.json index fc57ab7..7d58831 100644 --- a/src/mocks/sample-events.json +++ b/src/mocks/sample-events.json @@ -2,9 +2,9 @@ { "title": "Team Sync Meeting", "preview": "Team Sync", - "startDate": "2025-10-01", + "startDate": "2026-06-25", "startTime": "09:00", - "endDate": "2025-10-01", + "endDate": "2026-06-25", "endTime": "10:00", "location": "Zoom", "description": "Weekly team sync. Review progress and blockers.", @@ -13,9 +13,9 @@ { "title": "Product Launch Webinar", "preview": "Launch Webinar", - "startDate": "2025-10-03", + "startDate": "2026-06-26", "startTime": "13:30", - "endDate": "2025-10-03", + "endDate": "2026-06-26", "endTime": "14:30", "location": "YouTube Live", "description": "Launch overview and live Q&A" @@ -23,7 +23,7 @@ { "title": "Coffee with Alex", "preview": "Coffee w/ Alex", - "startDate": "2025-10-05", + "startDate": "2026-06-27", "startTime": "08:15", "location": "Blue Bottle, 3rd St.", "description": "Catch-up" @@ -31,9 +31,9 @@ { "title": "Design Review", "preview": "Design Review", - "startDate": "2025-10-06", + "startDate": "2026-06-28", "startTime": "15:00", - "endDate": "2025-10-06", + "endDate": "2026-06-28", "endTime": "16:00", "location": "Conference Room B", "description": "Review new UI mockups with the design team." @@ -41,7 +41,7 @@ { "title": "Dentist Appointment", "preview": "Dentist", - "startDate": "2025-10-07", + "startDate": "2026-06-28", "startTime": "11:30", "location": "Downtown Dental Clinic", "description": "Routine cleaning and checkup" @@ -49,9 +49,9 @@ { "title": "Yoga Class", "preview": "Yoga", - "startDate": "2025-10-08", + "startDate": "2026-06-29", "startTime": "18:00", - "endDate": "2025-10-08", + "endDate": "2026-06-29", "endTime": "19:00", "location": "Sunrise Yoga Studio", "description": "Vinyasa flow class" @@ -59,7 +59,7 @@ { "title": "Board Game Night", "preview": "Board Games", - "startDate": "2025-10-09", + "startDate": "2026-06-30", "startTime": "19:30", "location": "Sam's House", "description": "Bring your favorite board game!" @@ -67,9 +67,9 @@ { "title": "Marketing Strategy Session", "preview": "Marketing Strategy", - "startDate": "2025-10-10", + "startDate": "2026-07-01", "startTime": "10:00", - "endDate": "2025-10-10", + "endDate": "2026-07-01", "endTime": "12:00", "location": "Office 2A", "description": "Plan Q4 campaigns" @@ -77,9 +77,9 @@ { "title": "Parent-Teacher Conference", "preview": "Parent-Teacher Conf", - "startDate": "2025-10-11", + "startDate": "2026-07-02", "startTime": "16:00", - "endDate": "2025-10-11", + "endDate": "2026-07-02", "endTime": "16:30", "location": "Lincoln Elementary", "description": "Discuss progress with Ms. Lee" @@ -87,9 +87,9 @@ { "title": "Birthday Party: Jamie", "preview": "Jamie Birthday", - "startDate": "2025-10-12", + "startDate": "2026-07-05", "startTime": "14:00", - "endDate": "2025-10-12", + "endDate": "2026-07-05", "endTime": "17:00", "location": "Central Park, Picnic Area 3", "description": "Potluck and games" diff --git a/src/popup.js b/src/popup.js index e8a62b4..0f2a046 100644 --- a/src/popup.js +++ b/src/popup.js @@ -17,14 +17,13 @@ import { KEY_CUSTOM_VIEW_ACTIVE, RESULTS_BUTTON_APPEAR_DELAY_MS, SCAN_AVAILABILITY_CHECKING_MESSAGE, - SKELETON_APPEARANCE_STAGGER_MS, SKELETON_FADE_DURATION_MS, SKELETON_STAGGER_DELAY_MS, UI_STATE, } from "./popup/constants.js"; import { loadCache, saveCache } from "./popup/cacheStore.js"; import { - formatDateTime as formatPopupDateTime, + formatTimeOnly as formatPopupTimeOnly, isEventPast as isPopupEventPast, } from "./popup/dateTime.js"; import { @@ -32,12 +31,16 @@ import { renderEventLists, restoreSelection as restoreEventSelection, scrollToCard, + resetColorIndex, } from "./popup/eventCards.js"; import { createScanPoller } from "./popup/scanPolling.js"; import { createPopupSettingsStore } from "./popup/settingsStore.js"; import { captureActiveTabHtml, getActiveTab } from "./popup/tabCapture.js"; import { hideToast, showToast } from "./popup/toast.js"; +// =========================================== +// DOM elements +// =========================================== const scanBtn = document.getElementById("scanBtn"); const upcomingEventsListEl = document.getElementById("upcomingEventsList"); const pastEventsListEl = document.getElementById("pastEventsList"); @@ -47,18 +50,37 @@ const addSelectedBtn = document.getElementById("addSelectedBtn"); const settingsBtn = document.getElementById("settingsBtn"); const customContextBtn = document.getElementById("customContextBtn"); const customContextSection = document.getElementById("customContextSection"); -const scanSection = document.getElementById("scanSection"); const customInput = document.getElementById("customInput"); const scanMediaBtn = document.getElementById("scanMediaBtn"); +const tabUpcoming = document.getElementById("tabUpcoming"); +const tabPast = document.getElementById("tabPast"); +const footerSelectedEl = document.getElementById("footerSelected"); +const spiralBindingEl = document.getElementById("spiralBinding"); +const ruledListEl = document.getElementById("ruledList"); + const popupSettingsStore = createPopupSettingsStore(); const ensureSettingsLoaded = popupSettingsStore.ensureSettingsLoaded; const getTimeFormatPreference = popupSettingsStore.getTimeFormatPreference; -// Kick off settings load early to minimize race conditions for UI rendering ensureSettingsLoaded(); +// =========================================== +// Generate spiral coils +// =========================================== +function generateSpiral() { + if (!spiralBindingEl) return; + for (let i = 0; i < 18; i++) { + const coil = document.createElement("div"); + coil.className = "coil"; + spiralBindingEl.appendChild(coil); + } +} +generateSpiral(); + +// =========================================== // Restore persistent state +// =========================================== async function restoreCustomContextState() { try { const result = await chrome.storage.local.get([ @@ -69,7 +91,6 @@ async function restoreCustomContextState() { if (result[KEY_CUSTOM_VIEW_ACTIVE]) { customContextSection?.classList.remove("hidden"); - scanSection?.classList.add("hidden"); resultsEl?.classList.remove("open", "has-results"); } @@ -78,21 +99,8 @@ async function restoreCustomContextState() { } if (result[KEY_CUSTOM_FILES] && Array.isArray(result[KEY_CUSTOM_FILES])) { - // Rehydrate files from base64 - // For preview purposes, we can use the base64 string directly as src - // For sending, we can also use them directly since we already use base64 for transport - - // We need to reconstruct currentImageFiles for the logic to work - // Since we can't easily turn base64 back to File objects with original names/types accurately without storing metadata, - // we will store them as objects { base64, type, name } if possible, or just accept base64 strings in our internal array. - - // Let's assume currentImageFiles can handle objects or strings. - // But existing logic uses URL.createObjectURL(file). - // We need to adapt updateImageUI to handle { type: 'base64', data: ... } - currentImageFiles = result[KEY_CUSTOM_FILES].map(item => { - // If it's a simple string (legacy or simple save), wrap it - if (typeof item === 'string') return { data: item, type: 'image/png' }; // fallback + if (typeof item === 'string') return { data: item, type: 'image/png' }; return item; }); updateImageUI(); @@ -102,14 +110,17 @@ async function restoreCustomContextState() { } } -// Call restore restoreCustomContextState(); let currentState = UI_STATE.IDLE; -let stateCleanupTimeout = null; // Track cleanup timeout to prevent race conditions +let stateCleanupTimeout = null; let currentScanBlockReason = null; let scanAvailabilityReady = false; +let activeTab = "upcoming"; +// =========================================== +// Scan availability +// =========================================== function applyScanButtonAvailability() { if (!scanBtn) return; @@ -121,34 +132,27 @@ function applyScanButtonAvailability() { ? reason : isChecking ? SCAN_AVAILABILITY_CHECKING_MESSAGE - : "Scan Page"; + : "Scan this page"; scanBtn.disabled = isBusy || isUnavailable || isChecking; scanBtn.title = title; scanBtn.classList.toggle("scan-unavailable", isUnavailable || isChecking); - scanSection?.classList.toggle("scan-unavailable", isUnavailable || isChecking); - if (scanSection) { - scanSection.title = isUnavailable || isChecking ? title : ""; - } } function setScanAvailabilityReason(reason) { scanAvailabilityReady = true; currentScanBlockReason = reason || null; applyScanButtonAvailability(); - if (currentScanBlockReason) { - hideToast(); - } + if (currentScanBlockReason) hideToast(); } -async function refreshScanAvailability(activeTab = null) { +async function refreshScanAvailability(activeTabObj = null) { let reason = null; - scanAvailabilityReady = false; applyScanButtonAvailability(); try { - const tab = activeTab || await getActiveTab(); + const tab = activeTabObj || await getActiveTab(); reason = getRequiredTabScanBlockReason(tab?.url); } catch (err) { DEBUG && debug("[Eventy][Popup] Failed to check scan availability:", err); @@ -159,58 +163,136 @@ async function refreshScanAvailability(activeTab = null) { return reason; } -/** - * Transition to scanning state - * Shows skeleton cards and smoothly expands the container - */ +// =========================================== +// Segmented toggle +// =========================================== +function switchTab(tab) { + activeTab = tab; + tabUpcoming?.classList.toggle("active", tab === "upcoming"); + tabPast?.classList.toggle("active", tab === "past"); + upcomingEventsListEl?.classList.toggle("hidden", tab !== "upcoming"); + pastEventsListEl?.classList.toggle("hidden", tab !== "past"); +} + +tabUpcoming?.addEventListener("click", () => switchTab("upcoming")); +tabPast?.addEventListener("click", () => switchTab("past")); + +// =========================================== +// Week strip +// =========================================== +function updateWeekStrip(events) { + const weekStripEl = document.getElementById("weekStrip"); + if (!weekStripEl) return; + weekStripEl.innerHTML = ""; + + // Find the Monday of the current week + const now = new Date(); + const dayOfWeek = now.getDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + mondayOffset); + + const dayLetters = ["M", "T", "W", "T", "F", "S", "S"]; + + // Build a map of date -> color for events this week + const dateColorMap = {}; + if (events) { + const allCards = resultsEl?.querySelectorAll(".event-card"); + if (allCards) { + allCards.forEach(c => { + const idx = Number(c.dataset.idx); + const ev = events[idx]; + if (ev?.startDate && c.dataset.color) { + dateColorMap[ev.startDate] = c.dataset.color; + } + }); + } + } + + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const isSunday = d.getDay() === 0; + const dotColor = dateColorMap[dateStr]; + + const col = document.createElement("div"); + col.className = "week-day"; + col.innerHTML = ` +
${dayLetters[i]}
+
${d.getDate()}
+
${dotColor ? `
` : ''}
+ `; + weekStripEl.appendChild(col); + } +} + +// =========================================== +// Footer update +// =========================================== +function updateFooterCount() { + const selectedCards = resultsEl?.querySelectorAll(".event-card.selected"); + const count = selectedCards ? selectedCards.length : 0; + if (footerSelectedEl) { + footerSelectedEl.textContent = `${count} selected`; + } +} + +function updateButtonStates() { + const selectedCards = resultsEl?.querySelectorAll(".event-card.selected"); + const selectionCount = selectedCards ? selectedCards.length : 0; + const hasSelection = selectionCount > 0; + + if (addSelectedBtn) addSelectedBtn.disabled = !hasSelection; + updateFooterCount(); +} + +// =========================================== +// Update toggle labels with counts +// =========================================== +function updateToggleLabels(upcomingCount, pastCount) { + if (tabUpcoming) tabUpcoming.textContent = `Upcoming · ${upcomingCount}`; + if (tabPast) tabPast.textContent = `Past · ${pastCount}`; +} + +// =========================================== +// State management +// =========================================== function transitionToScanning() { - scanBtn.disabled = true; + scanBtn?.classList.add("scanning"); - // Check if we have existing event cards to fade out const existingCards = resultsEl?.querySelectorAll(".event-card"); const hasExistingCards = existingCards && existingCards.length > 0; - // Remove has-results to hide button smoothly resultsEl?.classList.remove("has-results"); resultsEl?.classList.add("open", "scanning"); - scanBtn?.classList.add("scanning"); if (hasExistingCards) { - // Fade out existing cards - existingCards.forEach((card, i) => { + const allItems = resultsEl?.querySelectorAll(".event-card") || []; + allItems.forEach((el, i) => { setTimeout(() => { - card.style.transition = - `opacity ${CARD_FADE_DURATION_MS}ms ease, transform ${CARD_FADE_DURATION_MS}ms ease`; - card.style.opacity = "0"; - card.style.transform = "scale(0.95)"; + el.style.transition = `opacity ${CARD_FADE_DURATION_MS}ms ease, transform ${CARD_FADE_DURATION_MS}ms ease`; + el.style.opacity = "0"; + el.style.transform = "scale(0.95)"; }, i * CARD_STAGGER_DELAY_MS); }); - // Wait for fade out, then show skeletons setTimeout(() => { if (upcomingEventsListEl) upcomingEventsListEl.innerHTML = ""; if (pastEventsListEl) pastEventsListEl.innerHTML = ""; showSkeletonCards(); }, CARD_FADE_DURATION_MS + existingCards.length * CARD_STAGGER_DELAY_MS); } else { - // No existing cards, just show skeletons if (upcomingEventsListEl) upcomingEventsListEl.innerHTML = ""; if (pastEventsListEl) pastEventsListEl.innerHTML = ""; - requestAnimationFrame(() => { - showSkeletonCards(); - }); + requestAnimationFrame(() => showSkeletonCards()); } } -/** - * Central state setter - updates all UI elements based on new state - */ function setState(newState) { if (currentState === newState) return; DEBUG && debug(`[Eventy][UI] State transition: ${currentState} -> ${newState}`); - // Clear any pending cleanup timeout to prevent race conditions if (stateCleanupTimeout) { clearTimeout(stateCleanupTimeout); stateCleanupTimeout = null; @@ -220,17 +302,12 @@ function setState(newState) { switch (newState) { case UI_STATE.IDLE: - // Smoothly collapse everything back to initial state resultsEl?.classList.remove("open", "scanning", "has-results", "quota-exceeded"); scanBtn?.classList.remove("scanning"); applyScanButtonAvailability(); - // Hide quota panel - const quotaPanelIdle = document.getElementById("quotaExceeded"); - quotaPanelIdle?.classList.add("hidden"); + document.getElementById("quotaExceeded")?.classList.add("hidden"); - // Clear content after animation, but only if state hasn't changed - // Capture the state at timeout creation to avoid race conditions const targetState = newState; stateCleanupTimeout = setTimeout(() => { if (currentState === targetState) { @@ -246,42 +323,35 @@ function setState(newState) { break; case UI_STATE.RESULTS_LOADED: - // Transition from scanning to results resultsEl?.classList.add("open"); resultsEl?.classList.remove("scanning", "quota-exceeded"); scanBtn?.classList.remove("scanning"); applyScanButtonAvailability(); - // Hide quota panel if visible - const quotaPanelResults = document.getElementById("quotaExceeded"); - quotaPanelResults?.classList.add("hidden"); + document.getElementById("quotaExceeded")?.classList.add("hidden"); - // Add has-results class to show the button with animation - // Small delay to ensure smooth transition setTimeout(() => { resultsEl?.classList.add("has-results"); }, RESULTS_BUTTON_APPEAR_DELAY_MS); break; case UI_STATE.QUOTA_EXCEEDED: - // Show quota exceeded panel resultsEl?.classList.add("open", "quota-exceeded"); resultsEl?.classList.remove("scanning", "has-results"); scanBtn?.classList.remove("scanning"); applyScanButtonAvailability(); - // Clear any skeleton cards if (upcomingEventsListEl) upcomingEventsListEl.innerHTML = ""; if (pastEventsListEl) pastEventsListEl.innerHTML = ""; - // Show quota exceeded panel - const quotaPanel = document.getElementById("quotaExceeded"); - quotaPanel?.classList.remove("hidden"); + document.getElementById("quotaExceeded")?.classList.remove("hidden"); break; } } -// Load and apply theme on popup load +// =========================================== +// Theme +// =========================================== async function loadAndApplyTheme() { try { const settings = await ensureSettingsLoaded(); @@ -293,14 +363,10 @@ async function loadAndApplyTheme() { } } -// Theme initialization happens during popup bootstrap (see initializePopup) - function waitForDocumentReady() { if (document.readyState === "loading") { return new Promise((resolve) => { - document.addEventListener("DOMContentLoaded", resolve, { - once: true, - }); + document.addEventListener("DOMContentLoaded", resolve, { once: true }); }); } return Promise.resolve(); @@ -315,46 +381,58 @@ const themeReadyPromise = (async () => { try { await loadAndApplyTheme(); } finally { - if (body) { - body.classList.remove("theme-loading"); - } + if (body) body.classList.remove("theme-loading"); } })(); +// =========================================== +// Helpers +// =========================================== function isEventPast(event) { return isPopupEventPast(event, { warn, error }); } -function formatDateTime(dateStr, timeStr) { - return formatPopupDateTime(dateStr, timeStr, getTimeFormatPreference()); +function formatTimeOnly(timeStr) { + return formatPopupTimeOnly(timeStr, getTimeFormatPreference()); } -// Helper to create skeleton loading cards -function showSkeletonCards(count = 3) { - if (!upcomingEventsListEl) return; - upcomingEventsListEl.innerHTML = ""; - for (let i = 0; i < count; i++) { - const skeleton = document.createElement("div"); - skeleton.className = "skeleton-card"; - // Stagger animation for cascading effect - skeleton.style.animationDelay = `${i * SKELETON_APPEARANCE_STAGGER_MS}ms`; - skeleton.innerHTML = ` -
-
-
-
-
-
-
-
-
-
- `; - upcomingEventsListEl.appendChild(skeleton); +// =========================================== +// Skeleton cards +// =========================================== +function createSkeletonRow(delay) { + const row = document.createElement("div"); + row.className = "skeleton-row"; + row.style.animationDelay = `${delay}ms`; + row.innerHTML = ` +
+
+
+
+
+
+ `; + return row; +} + +function showSkeletonCards() { + // Show skeletons in whichever list is active + const targetEl = activeTab === "upcoming" ? upcomingEventsListEl : pastEventsListEl; + if (!targetEl) return; + + // Also make sure the active list is visible + upcomingEventsListEl?.classList.toggle("hidden", activeTab !== "upcoming"); + pastEventsListEl?.classList.toggle("hidden", activeTab !== "past"); + + targetEl.innerHTML = ""; + for (let i = 0; i < 3; i++) { + targetEl.appendChild(createSkeletonRow(i * 80)); } + } -// Ensure Settings button works immediately on popup load +// =========================================== +// Settings & navigation buttons +// =========================================== settingsBtn?.addEventListener("click", () => { try { const url = chrome.runtime.getURL("src/ui/settings.html"); @@ -362,63 +440,48 @@ settingsBtn?.addEventListener("click", () => { } catch (_) { } }); -// Quota exceeded panel - link to settings with BYOK section -const quotaSettingsBtn = document.getElementById("quotaSettingsBtn"); -quotaSettingsBtn?.addEventListener("click", () => { +// Empty-state buttons reuse the toolbar scan / paste entrypoints +document.getElementById("ev-scan-primary")?.addEventListener("click", () => scanBtn?.click()); +document.getElementById("ev-paste-link")?.addEventListener("click", () => customContextBtn?.click()); + +document.getElementById("quotaSettingsBtn")?.addEventListener("click", () => { try { const url = chrome.runtime.getURL("src/ui/settings.html") + "?section=api"; chrome.tabs.create({ url }); } catch (_) { } }); -// Clean up timeouts when popup closes window.addEventListener("unload", () => { scanPoller.clearAll(); - - // Clear state cleanup timeout if (stateCleanupTimeout) { clearTimeout(stateCleanupTimeout); stateCleanupTimeout = null; } }); -// Update button states based on selection -function updateButtonStates() { - const selectedCards = resultsEl?.querySelectorAll(".event-card.selected"); - const selectionCount = selectedCards ? selectedCards.length : 0; - const hasSelection = selectionCount > 0; - - if (addSelectedBtn) { - addSelectedBtn.disabled = !hasSelection; - - } -} - +// =========================================== +// Scan +// =========================================== async function handleScan() { try { const tab = await getActiveTab(); const blockReason = await refreshScanAvailability(tab); - if (blockReason) { - return; - } + if (blockReason) return; setState(UI_STATE.SCANNING); const { html, text, title, lang, url } = await captureActiveTabHtml(tab); - - // Clear any existing poll timeout for this URL before starting new scan scanPoller.clearForUrl(url); DEBUG && debug("[Eventy][Popup] Scan started", { url }); - // Preprocess in popup to avoid DOMParser issues in the service worker. const { modelHtml, csvSnippets } = preprocessForPopup(text || "", html || ""); const response = await chrome.runtime.sendMessage({ action: "scanPage", modelInput: modelHtml, csvSnippets, - html, // Required for background fallback - text, // Keep text just in case, or for title context? + html, + text, title, lang, url, @@ -429,7 +492,6 @@ async function handleScan() { const errorMsg = response?.error || "Unknown error"; error("Scan failed:", errorMsg); - // Check if this is a rate limit / quota exceeded error if (response?.errorType === "RATE_LIMIT" || errorMsg.toLowerCase().includes("rate limit") || errorMsg.toLowerCase().includes("quota") || @@ -438,20 +500,14 @@ async function handleScan() { return; } - // Clean up UI state for other errors setState(UI_STATE.IDLE); - - // Then disable button to force refresh/retry if needed (or just show error) - if (scanBtn) { - scanBtn.disabled = true; - } + if (scanBtn) scanBtn.disabled = true; return; } const events = response.events || []; if (!events.length) { error("No events found on this page"); - // Silent failure setState(UI_STATE.IDLE); return; } @@ -459,10 +515,8 @@ async function handleScan() { transitionFromSkeletonsToResults(events); await saveCache(url, events, []); - // Inject event markers on the page (full page scan only) if (tab?.id && events.length > 0) { try { - // Check if page markers are enabled in settings const settings = await ensureSettingsLoaded(); if (settings.showPageMarkers === true) { await chrome.runtime.sendMessage({ @@ -472,7 +526,6 @@ async function handleScan() { }); } } catch (markerErr) { - // Silently fail - markers are not critical DEBUG && debug("[Eventy][Popup] Failed to inject markers:", markerErr); } } @@ -486,7 +539,6 @@ async function handleScan() { return; } - // Check if this is a rate limit error if (e.name === "RateLimitError" || errorStr.toLowerCase().includes("rate limit") || errorStr.toLowerCase().includes("quota") || @@ -495,62 +547,62 @@ async function handleScan() { return; } - // Silent error handling for other errors - disable button instead of showing toast setState(UI_STATE.IDLE); setScanAvailabilityReason("An error occurred. Please refresh the page."); } } +// =========================================== +// Render events +// =========================================== async function renderEvents(events) { await ensureSettingsLoaded(); - - // Store events globally for event handlers currentEvents = events; - await renderEventLists(events, { + const result = await renderEventLists(events, { upcomingEventsListEl, pastEventsListEl, isEventPast, - createCard: (ev, idx) => createEventCard(ev, idx, { + createCard: (ev, idx, color, isPast) => createEventCard(ev, idx, { escapeHtml, - formatDateTime, + formatTimeOnly: (timeStr) => formatTimeOnly(timeStr), onToggle: () => { updateButtonStates(); persistSelection(currentEvents); }, + color, + isPast, }), updateButtonStates, }); + + if (result) { + updateToggleLabels(result.upcomingCount, result.pastCount); + updateWeekStrip(events); + } } -/** - * Transition from skeleton cards to event cards - * Smoothly fades out skeletons, renders event cards, then shows button - */ function transitionFromSkeletonsToResults(events) { if (!upcomingEventsListEl) return; - // Fade out skeleton cards - const skeletons = upcomingEventsListEl.querySelectorAll(".skeleton-card"); + const skeletons = resultsEl.querySelectorAll(".skeleton-row"); skeletons.forEach((skeleton, i) => { setTimeout(() => { - skeleton.style.transition = - `opacity ${SKELETON_FADE_DURATION_MS}ms ease, transform ${SKELETON_FADE_DURATION_MS}ms ease`; + skeleton.style.transition = `opacity ${SKELETON_FADE_DURATION_MS}ms ease, transform ${SKELETON_FADE_DURATION_MS}ms ease`; skeleton.style.opacity = "0"; skeleton.style.transform = "scale(0.95)"; }, i * SKELETON_STAGGER_DELAY_MS); }); - // Wait for fade out, then render event cards setTimeout(async () => { try { upcomingEventsListEl.innerHTML = ""; if (pastEventsListEl) pastEventsListEl.innerHTML = ""; await renderEvents(events); - // Transition to results loaded state - // This removes 'scanning' class and adds 'has-results' - // Button appears smoothly via CSS transitions + // Switch to upcoming tab + switchTab("upcoming"); + setState(UI_STATE.RESULTS_LOADED); } catch (e) { error("Failed to render events:", e); @@ -585,28 +637,25 @@ const scanPoller = createScanPoller({ }); const pollForResults = scanPoller.pollForResults; -// Helper to restore selected event indices function restoreSelection(selectedIdxs) { const firstSelected = restoreEventSelection(selectedIdxs, { resultsEl, updateButtonStates, }); - if (firstSelected) { - scrollToCard(firstSelected); - } + if (firstSelected) scrollToCard(firstSelected); } scanBtn?.addEventListener("click", handleScan); refreshScanAvailability(); -// Helper to save current state +// =========================================== +// Custom context (paste / image) +// =========================================== async function saveCustomState() { try { - const isCustomOpen = !customContextSection.classList.contains("hidden"); + const isCustomOpen = !customContextSection?.classList.contains("hidden"); const text = customInput?.value || ""; - - // Files are saved separately when they change because processing them takes time await chrome.storage.local.set({ [KEY_CUSTOM_VIEW_ACTIVE]: isCustomOpen, [KEY_CUSTOM_TEXT]: text @@ -616,32 +665,25 @@ async function saveCustomState() { async function saveCustomFiles() { try { - // Convert all current files to base64 for storage - // If already base64 object, keep it. If File/Blob, convert. const serializedFiles = await Promise.all(currentImageFiles.map(async (file) => { - if (file.data) return file; // Already serialized + if (file.data) return file; const base64 = await fileToBase64(file); return { data: base64, type: file.type, name: file.name }; })); - - await chrome.storage.local.set({ - [KEY_CUSTOM_FILES]: serializedFiles - }); + await chrome.storage.local.set({ [KEY_CUSTOM_FILES]: serializedFiles }); } catch (e) { console.error("Error saving files", e); } } customContextBtn?.addEventListener("click", () => { - const isHidden = customContextSection.classList.contains("hidden"); + const isHidden = customContextSection?.classList.contains("hidden"); if (isHidden) { customContextSection.classList.remove("hidden"); - scanSection.classList.add("hidden"); - resultsEl.classList.remove("open", "has-results"); - customInput.focus(); + resultsEl?.classList.remove("open", "has-results"); + customInput?.focus(); } else { customContextSection.classList.add("hidden"); - scanSection.classList.remove("hidden"); refreshScanAvailability(); } saveCustomState(); @@ -649,11 +691,9 @@ customContextBtn?.addEventListener("click", () => { const mediaInput = document.getElementById("mediaInput"); const imagePreviewContainer = document.getElementById("imagePreviewContainer"); - let currentImageFiles = []; function updateImageUI() { - // UpdatePreviews if (imagePreviewContainer) { imagePreviewContainer.innerHTML = ""; currentImageFiles.forEach((file, index) => { @@ -663,26 +703,19 @@ function updateImageUI() { const img = document.createElement("img"); img.className = "image-preview-img"; - // Handle both File objects (new uploads) and persisted objects { data: base64 } if (file.data) { img.src = file.data; } else { img.src = URL.createObjectURL(file); - img.onload = () => URL.revokeObjectURL(img.src); // Cleanup + img.onload = () => URL.revokeObjectURL(img.src); } const removeBtn = document.createElement("button"); removeBtn.className = "image-remove-btn"; - removeBtn.innerHTML = ` - - - - - `; + removeBtn.innerHTML = ``; removeBtn.onclick = (e) => { e.stopPropagation(); currentImageFiles.splice(index, 1); - // Update persistent storage saveCustomFiles(); updateImageUI(); }; @@ -696,24 +729,14 @@ function updateImageUI() { mediaInput?.addEventListener("change", async () => { if (mediaInput.files) { - // Replace current files with new selection from input - const newFiles = Array.from(mediaInput.files); - // We replace or append? Typically file input replaces unless we handle merge. - // User probably expects replace if clicking "choose files", or append? - // Let's stick to existing behavior (replace) but maybe append is better for "Add Photos"? - // Existing: currentImageFiles = Array.from(mediaInput.files); - // Let's keep it replace for now, but immediately save. - - currentImageFiles = newFiles; + currentImageFiles = Array.from(mediaInput.files); updateImageUI(); await saveCustomFiles(); - // Also save view state just in case saveCustomState(); } }); -// Handle paste events on the textarea -customInput?.addEventListener("input", saveCustomState); // Save text on input +customInput?.addEventListener("input", saveCustomState); customInput?.addEventListener("paste", async (e) => { const items = (e.clipboardData || e.originalEvent.clipboardData).items; let hasImages = false; @@ -734,7 +757,6 @@ customInput?.addEventListener("paste", async (e) => { } }); -// Helper to convert file to base64 const fileToBase64 = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -752,20 +774,15 @@ scanMediaBtn?.addEventListener("click", async () => { try { setState(UI_STATE.SCANNING); - // PERSISTENCE: Do NOT hide custom input section during scan - // customContextSection.classList.add("hidden"); - // Process images if any const imageUrls = []; if (files && files.length > 0) { for (let i = 0; i < files.length; i++) { try { const file = files[i]; - // If already base64 object (persisted), use it directly if (file.data) { imageUrls.push(file.data); } else { - // Otherwise convert (newly added File/Blob) const base64 = await fileToBase64(file); imageUrls.push(base64); } @@ -775,21 +792,17 @@ scanMediaBtn?.addEventListener("click", async () => { } } - // Use current tab URL for cache key if possible, or a generic one const tab = await getActiveTab(); const url = tab?.url || "custom-context-scan"; - - // Preprocess custom text to apply context compression and token limits - // Pass null for HTML scan so it treats input as raw text (or attempts to parse if it looks like HTML, but mainly for text compression) const modelInput = buildModelInput(text, null); const response = await chrome.runtime.sendMessage({ action: "scanPage", - html: "", // No HTML for custom context scan - modelInput, // precomputed compressed input + html: "", + modelInput, text: text, title: "Custom Context", - lang: "en", // Default to en + lang: "en", url: url, imageUrls: imageUrls, cacheResults: false, @@ -799,7 +812,6 @@ scanMediaBtn?.addEventListener("click", async () => { const errorMsg = response?.error || "Unknown error"; error("Scan failed:", errorMsg); - // Check if this is a rate limit / quota exceeded error if (response?.errorType === "RATE_LIMIT" || errorMsg.toLowerCase().includes("rate limit") || errorMsg.toLowerCase().includes("quota") || @@ -817,17 +829,14 @@ scanMediaBtn?.addEventListener("click", async () => { if (!events.length) { console.log("No events found"); setState(UI_STATE.IDLE); - // customContextSection.classList.remove("hidden"); // Already visible return; } transitionFromSkeletonsToResults(events); - // Don't cache custom scans for now, or use a specific key } catch (e) { error(e); const errorStr = e.message || String(e); - // Check if this is a rate limit error if (e.name === "RateLimitError" || errorStr.toLowerCase().includes("rate limit") || errorStr.toLowerCase().includes("quota") || @@ -836,9 +845,7 @@ scanMediaBtn?.addEventListener("click", async () => { return; } - // Provide user-friendly error messages for other cases let userMessage = "An unexpected error occurred"; - if (errorStr.includes("network") || errorStr.includes("fetch")) { userMessage = "Network error. Check your connection."; } else if (errorStr.includes("timeout")) { @@ -850,6 +857,9 @@ scanMediaBtn?.addEventListener("click", async () => { } }); +// =========================================== +// Calendar & ICS actions +// =========================================== addSelectedBtn?.addEventListener("click", async () => { try { const cards = resultsEl?.querySelectorAll(".event-card.selected"); @@ -866,21 +876,19 @@ addSelectedBtn?.addEventListener("click", async () => { } }); -// Check if a scan is in progress and show skeleton cards while waiting +// =========================================== +// In-progress scan detection +// =========================================== async function checkForInProgressScan() { try { const tab = await getActiveTab(); const url = tab?.url || ""; - // Check if scan is in progress const result = await chrome.storage.local.get(`eventy-scanning:${url}`); const isScanning = result[`eventy-scanning:${url}`]; - if (!isScanning) { - return false; // No scan in progress - } + if (!isScanning) return false; - // Scan is in progress - transition to scanning state setState(UI_STATE.SCANNING); pollForResults( @@ -905,16 +913,13 @@ async function checkForInProgressScan() { } } -// Helper to ensure scanning animation is showing for deferred results async function ensureDeferredScanningAnimation() { if (currentState !== UI_STATE.SCANNING) { setState(UI_STATE.SCANNING); - // Give UI a moment to update await new Promise(resolve => requestAnimationFrame(resolve)); } } -// Helper to display deferred results function displayDeferredResults(events) { if (!events || events.length === 0) { setState(UI_STATE.IDLE); @@ -928,9 +933,7 @@ async function tryLoadDeferredResults(storageKey) { try { const result = await chrome.storage.local.get(storageKey); const stored = result[storageKey]; - if (!stored) { - return false; - } + if (!stored) return false; if (stored.status === "scanning") { await ensureDeferredScanningAnimation(); @@ -950,7 +953,6 @@ async function tryLoadDeferredResults(storageKey) { if (stored.status === "error") { await chrome.storage.local.remove(storageKey); - // Check if this is a rate limit error if (stored.errorType === "RATE_LIMIT" || (stored.error && ( stored.error.toLowerCase().includes("rate limit") || @@ -971,26 +973,24 @@ async function tryLoadDeferredResults(storageKey) { } } -// Helper to select a specific event and scroll it into view function selectAndScrollToEvent(eventIndex) { const card = resultsEl?.querySelector(`.event-card[data-idx="${eventIndex}"]`); if (!card) return; - // Clear existing selection const allCards = resultsEl?.querySelectorAll(".event-card"); - allCards?.forEach(c => c.classList.remove("selected")); + allCards?.forEach(c => { + c.classList.remove("selected"); + c.setAttribute("aria-pressed", "false"); + }); - // Select the target card card.classList.add("selected"); + card.setAttribute("aria-pressed", "true"); - // Scroll to the card scrollToCard(card); - updateButtonStates(); persistSelection(currentEvents); } -// Check for pending event selection from marker click async function checkPendingEventSelection() { try { const result = await chrome.storage.local.get("eventy-pending-selection"); @@ -998,13 +998,10 @@ async function checkPendingEventSelection() { if (!pending) return null; - // Clear the pending selection await chrome.storage.local.remove("eventy-pending-selection"); - // Check if the selection is still fresh (within 5 seconds) if (Date.now() - pending.ts > 5000) return null; - // Check if the URL matches the current tab const tab = await getActiveTab(); if (pending.tabUrl && tab?.url !== pending.tabUrl) return null; @@ -1015,47 +1012,35 @@ async function checkPendingEventSelection() { } } +// =========================================== +// Cache restore on popup open +// =========================================== (async function restoreFromCache() { try { - // Check for pending event selection from marker click first const pendingEventIndex = await checkPendingEventSelection(); - if (await tryLoadDeferredResults(HIGHLIGHT_RESULTS_KEY)) { - return; - } - - if (await tryLoadDeferredResults(IMAGE_RESULTS_KEY)) { - return; - } + if (await tryLoadDeferredResults(HIGHLIGHT_RESULTS_KEY)) return; + if (await tryLoadDeferredResults(IMAGE_RESULTS_KEY)) return; const tab = await getActiveTab(); const url = tab?.url || ""; - // Check if scan is in progress first const hasInProgressScan = await checkForInProgressScan(); - if (hasInProgressScan) { - return; // Waiting for in-progress scan - } + if (hasInProgressScan) return; - // No scan in progress, load cached results const cached = await loadCache(url); if (!cached || !cached.events || !cached.events.length) { setState(UI_STATE.IDLE); return; } - // Smoothly transition to showing cached results - // First set to open state without results resultsEl?.classList.add("open"); - // Render the events await renderEvents(cached.events); - // Then transition to results state with button requestAnimationFrame(() => { setState(UI_STATE.RESULTS_LOADED); - // If we have a pending event selection from marker click, use that if (pendingEventIndex !== null && pendingEventIndex !== undefined) { selectAndScrollToEvent(pendingEventIndex); } else { diff --git a/src/popup/dateTime.js b/src/popup/dateTime.js index b137af7..76eb68b 100644 --- a/src/popup/dateTime.js +++ b/src/popup/dateTime.js @@ -67,6 +67,52 @@ export function isEventPast(event, { now = new Date(), warn = () => { }, error = } } +export function formatDateHeader(dateStr) { + if (!dateStr) return ""; + try { + const d = new Date(`${dateStr}T00:00:00`); + if (Number.isNaN(d.getTime())) return dateStr; + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const target = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const diffDays = Math.round((target - today) / 86400000); + + if (diffDays === 0) return "Today"; + if (diffDays === -1) return "Yesterday"; + if (diffDays === 1) return "Tomorrow"; + + const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return `${days[d.getDay()]}, ${months[d.getMonth()]} ${d.getDate()}`; + } catch (_) { + return dateStr; + } +} + +export function formatTimeOnly(timeStr, timeFormat = "12") { + if (!timeStr) return ""; + const normalized = normalizeTimeStr(timeStr); + if (!normalized) return timeStr; + + const parts = normalized.split(":"); + if (parts.length < 2) return timeStr; + + const hours = parseInt(parts[0], 10); + const minutes = parts[1].substring(0, 2); + + if (Number.isNaN(hours)) return timeStr; + + if (timeFormat === "24") { + return `${String(hours).padStart(2, "0")}:${minutes}`; + } + + const period = hours >= 12 ? "PM" : "AM"; + let h12 = hours % 12; + if (h12 === 0) h12 = 12; + return `${h12}:${minutes} ${period}`; +} + function normalizeTimeStr(timeStr) { if (!timeStr) return null; const match = timeStr.match(/(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*([AP]M)/i); diff --git a/src/popup/eventCards.js b/src/popup/eventCards.js index f702619..c25e06d 100644 --- a/src/popup/eventCards.js +++ b/src/popup/eventCards.js @@ -1,66 +1,171 @@ +const EVENT_COLORS = [ + { hex: '#FF8A3D', ink: '#D9701E', rgba: 'rgba(255,138,61,.42)' }, + { hex: '#21BEC4', ink: '#128A90', rgba: 'rgba(33,190,196,.40)' }, + { hex: '#9B6BE0', ink: '#7E50C8', rgba: 'rgba(155,107,224,.40)' }, + { hex: '#F0479A', ink: '#D62E84', rgba: 'rgba(240,71,154,.38)' }, +]; + +const PAST_COLOR = { hex: '#9AA7A0', ink: '#8A968F', rgba: 'rgba(154,167,160,.30)' }; + +let colorIndex = 0; + +export function resetColorIndex() { + colorIndex = 0; +} + +export function assignEventColor(isPast) { + if (isPast) return PAST_COLOR; + const c = EVENT_COLORS[colorIndex % EVENT_COLORS.length]; + colorIndex++; + return c; +} + export function createEventCard( ev, idx, { doc = document, escapeHtml, - formatDateTime, + formatTimeOnly, onToggle = () => { }, + color = null, + isPast = false, } = {} ) { - const wrapper = doc.createElement("div"); - wrapper.className = "event-card"; + const c = color || assignEventColor(isPast); + const wrapper = doc.createElement('div'); + wrapper.className = 'event-card'; wrapper.dataset.idx = String(idx); + wrapper.dataset.color = c.hex; + wrapper.setAttribute('role', 'button'); + wrapper.setAttribute('tabindex', '0'); + wrapper.setAttribute('aria-pressed', 'false'); + + const startTime = formatTimeOnly(ev.startTime || ''); + const endTime = formatTimeOnly(ev.endTime || ''); + const location = ev.location || ''; + const title = ev.title || 'Untitled'; + const monthAbbr = formatMonthAbbr(ev.startDate); + const dayNum = formatDayNum(ev.startDate); + + // Time range including end time + let timeRange = ''; + if (startTime && endTime) timeRange = `${startTime} – ${endTime}`; + else if (startTime) timeRange = startTime; + else if (endTime) timeRange = endTime; + + // Build note HTML: bold time · location — description + const noteHtmlParts = []; + if (timeRange) noteHtmlParts.push(`${escapeHtml(timeRange)}`); + if (location) noteHtmlParts.push(escapeHtml(location)); + let noteHtml = noteHtmlParts.join(' · '); + if (ev.description && ev.description.length < 40) { + noteHtml += noteHtml ? ' — ' + escapeHtml(ev.description) : escapeHtml(ev.description); + } + + // Highlighter swipe on title (for non-past events with color) + const titleHtml = !isPast + ? `${escapeHtml(title)}` + : escapeHtml(title); - const startStr = formatDateTime(ev.startDate || "", ev.startTime || ""); - const endStr = formatDateTime( - ev.endDate || ev.startDate || "", - ev.endTime || "" - ); - const previewText = ev.preview || ev.title || "Untitled"; - const fullTitle = ev.title || "Untitled"; - const location = ev.location || ""; - const locationHtml = location - ? `
${escapeHtml(location)}
` - : ""; - - const recurrenceHtml = ev.recurrence - ? `` - : ""; + // Recurrence badge + const badgeHtml = ev.recurrence + ? `Recurring` + : ''; + + // Multi-day badge + const multiDayHtml = (ev.startDate && ev.endDate && ev.endDate !== ev.startDate) + ? `Multi-day` + : ''; wrapper.innerHTML = ` -
-
${escapeHtml(previewText)}
-
-
-
- ${recurrenceHtml} - ${escapeHtml(startStr)} -
- ${endStr - ? `
${escapeHtml( - endStr - )}
` - : "" - } -
- ${locationHtml} -
+
+
${escapeHtml(monthAbbr)}
+
${escapeHtml(dayNum)}
+
+
+
${titleHtml}
+ ${noteHtml ? `
${noteHtml}
` : ''} + ${badgeHtml}${multiDayHtml}
`; - wrapper.addEventListener("click", () => { - wrapper.classList.toggle("selected"); + const toggleSelected = () => { + const selected = wrapper.classList.toggle('selected'); + wrapper.setAttribute('aria-pressed', String(selected)); onToggle(wrapper); + }; + + wrapper.addEventListener('click', toggleSelected); + wrapper.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSelected(); + } }); return wrapper; } +function formatMonthAbbr(dateStr) { + if (!dateStr) return '???'; + try { + const d = new Date(`${dateStr}T00:00:00`); + if (isNaN(d.getTime())) return '???'; + return ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', + 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'][d.getMonth()]; + } catch { return '???'; } +} + +function formatDayNum(dateStr) { + if (!dateStr) return '??'; + try { + const d = new Date(`${dateStr}T00:00:00`); + if (isNaN(d.getTime())) return '??'; + return String(d.getDate()).padStart(2, '0'); + } catch { return '??'; } +} + +const MONTHS_FULL = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + +function monthKeyFromEvent(ev) { + const s = ev?.startDate; + if (!s) return 'no-date'; + return String(s).slice(0, 7); +} + +function createMonthSectionHeader(ev) { + const header = document.createElement('div'); + header.className = 'month-section-header'; + const s = ev?.startDate; + let label = 'No date'; + let yearHtml = ''; + if (s) { + const d = new Date(`${s}T00:00:00`); + if (!isNaN(d.getTime())) { + label = MONTHS_FULL[d.getMonth()].toUpperCase(); + yearHtml = ` ${d.getFullYear()}`; + } + } + header.innerHTML = `${label}${yearHtml}`; + return header; +} + +function parseTime(timeStr) { + if (!timeStr) return 0; + const match = timeStr.match(/(\d{1,2}):(\d{2})\s*([AP]M)?/i); + if (!match) return 0; + let h = parseInt(match[1], 10); + const m = parseInt(match[2], 10); + if (match[3]) { + const pm = match[3].toUpperCase() === 'PM'; + if (pm && h !== 12) h += 12; + else if (!pm && h === 12) h = 0; + } + return h * 60 + m; +} + export async function renderEventLists( events, { @@ -73,47 +178,77 @@ export async function renderEventLists( ) { if (!upcomingEventsListEl || !pastEventsListEl) return; - upcomingEventsListEl.innerHTML = ""; - pastEventsListEl.innerHTML = ""; + upcomingEventsListEl.innerHTML = ''; + pastEventsListEl.innerHTML = ''; + + resetColorIndex(); const upcomingEvents = []; const pastEvents = []; events.forEach((ev, idx) => { - if (isEventPast(ev)) { - pastEvents.push({ event: ev, originalIndex: idx }); + const past = isEventPast(ev); + const color = assignEventColor(past); + if (past) { + pastEvents.push({ event: ev, originalIndex: idx, color }); } else { - upcomingEvents.push({ event: ev, originalIndex: idx }); + upcomingEvents.push({ event: ev, originalIndex: idx, color }); } }); - upcomingEvents.forEach(({ event: ev, originalIndex: idx }) => { - upcomingEventsListEl.appendChild(createCard(ev, idx)); - }); + // Sort by date then time + const sortItems = (items, asc) => { + items.sort((a, b) => { + const dateComp = asc + ? (a.event.startDate || '').localeCompare(b.event.startDate || '') + : (b.event.startDate || '').localeCompare(a.event.startDate || ''); + if (dateComp !== 0) return dateComp; + return parseTime(a.event.startTime) - parseTime(b.event.startTime); + }); + }; - pastEvents.forEach(({ event: ev, originalIndex: idx }) => { - pastEventsListEl.appendChild(createCard(ev, idx)); - }); + sortItems(upcomingEvents, true); + sortItems(pastEvents, false); + + let lastUpcomingKey = null; + for (const { event, originalIndex, color } of upcomingEvents) { + const key = monthKeyFromEvent(event); + if (key !== lastUpcomingKey) { + upcomingEventsListEl.appendChild(createMonthSectionHeader(event)); + lastUpcomingKey = key; + } + upcomingEventsListEl.appendChild(createCard(event, originalIndex, color, false)); + } + + let lastPastKey = null; + for (const { event, originalIndex, color } of pastEvents) { + const key = monthKeyFromEvent(event); + if (key !== lastPastKey) { + pastEventsListEl.appendChild(createMonthSectionHeader(event)); + lastPastKey = key; + } + pastEventsListEl.appendChild(createCard(event, originalIndex, color, true)); + } updateButtonStates(); + + return { upcomingCount: upcomingEvents.length, pastCount: pastEvents.length, upcomingEvents, pastEvents }; } export function scrollToCard(card) { - const scrollContainer = card.closest(".events-list"); + const scrollContainer = card.closest('.ruled-list'); if (scrollContainer) { requestAnimationFrame(() => { setTimeout(() => { const containerRect = scrollContainer.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); const currentScroll = scrollContainer.scrollTop; - const cardCenter = cardRect.top + cardRect.height / 2; const containerCenter = containerRect.top + containerRect.height / 2; const diff = cardCenter - containerCenter; - scrollContainer.scrollTo({ top: Math.max(0, currentScroll + diff), - behavior: "smooth", + behavior: 'smooth', }); }, 200); }); @@ -122,14 +257,18 @@ export function scrollToCard(card) { export function restoreSelection(selectedIdxs, { resultsEl, updateButtonStates }) { if (!selectedIdxs?.length) return null; - const cards = resultsEl?.querySelectorAll(".event-card"); + const cards = resultsEl?.querySelectorAll('.event-card'); let firstSelected = null; if (cards) { - cards.forEach((el) => el.classList.remove("selected")); + cards.forEach((el) => { + el.classList.remove('selected'); + el.setAttribute('aria-pressed', 'false'); + }); selectedIdxs.forEach((i) => { const el = resultsEl?.querySelector(`.event-card[data-idx="${i}"]`); if (el) { - el.classList.add("selected"); + el.classList.add('selected'); + el.setAttribute('aria-pressed', 'true'); if (!firstSelected) firstSelected = el; } }); diff --git a/src/ui/base.css b/src/ui/base.css index 2c620cf..45f0937 100644 --- a/src/ui/base.css +++ b/src/ui/base.css @@ -1,6 +1,159 @@ -/* Minimalist theme with orange accent */ +/* =========================================== + Font faces — bundled woff2 (no external requests in MV3) + =========================================== */ +@font-face { + font-family: 'Hanken Grotesk'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url(./fonts/hanken-grotesk-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Hanken Grotesk'; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url(./fonts/hanken-grotesk-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Kalam'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(./fonts/kalam-latin-300.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Kalam'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(./fonts/kalam-latin-ext-300.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Kalam'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./fonts/kalam-latin-400.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Kalam'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./fonts/kalam-latin-ext-400.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Kalam'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./fonts/kalam-latin-700.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Kalam'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./fonts/kalam-latin-ext-700.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* =========================================== + CSS custom properties — Light mode (default) + =========================================== */ :root { - /* Light mode colors */ + /* Brand */ + --brand: #F4691F; + --brand-deep: #C25E15; + --brand-bright: #FF9A52; + + /* Desk & Page */ + --desk: linear-gradient(155deg, #B0BFB4, #96AA9C 65%, #869a8d); + --paper: #FAF7EE; + --ruled-line: rgba(122, 150, 168, .28); + + /* Ink */ + --ink: #29343A; + --ink-muted: #6E7C84; + --ink-secondary: #7E8A82; + + /* Controls */ + --tab-track: #ECE7DA; + --tab-active: #fff; + --tab-active-text: #29343A; + --tab-inactive-text: #94A097; + --tab-shadow: 0 1px 2px rgba(0, 0, 0, .12); + + /* Checkbox */ + --checkbox-bg: #fff; + --checkbox-border: #B9C2BC; + + /* Date chip */ + --chip-cell-bg: #fff; + --chip-cell-text: #29343A; + --chip-border: rgba(0, 0, 0, .10); + --chip-shadow: 0 2px 4px rgba(0, 0, 0, .12); + + /* Ghost buttons */ + --ghost-bg: #fff; + --ghost-border: #C9D0C9; + --ghost-text: #5d6b62; + + /* Scan button (primary icon) */ + --scan-bg: #29343A; + --scan-text: #FAF7EE; + + /* Footer */ + --footer-bg: rgba(250, 247, 238, .96); + --footer-border: rgba(122, 150, 168, .30); + --footer-text: #41514A; + --footer-muted: #7E8A82; + + /* Page shadows */ + --page-shadow: 0 16px 30px rgba(35, 50, 45, .30), 0 3px 8px rgba(0, 0, 0, .18); + --cta-shadow: 0 2px 0 rgba(0, 0, 0, .20); + + /* Week strip */ + --week-divider: 1px dashed rgba(122, 150, 168, .45); + --weekday-text: #9AA7A0; + --sunday-text: #C5908F; + + /* Coil gradient */ + --coil-gradient: linear-gradient(180deg, #ffc287 0%, #f4842f 45%, #c25e15 100%); + --coil-shadow: inset -1.5px 0 1.5px rgba(255, 255, 255, .75), + inset 1.5px 0 2px rgba(0, 0, 0, .25), + 0 2px 3px rgba(0, 0, 0, .22); + + /* Badge */ + --badge-text: #7E8A82; + --badge-border: #CBD3CC; + + /* Desk inset */ + --desk-inset: inset 0 1px 0 rgba(255, 255, 255, .22); + + /* Past opacity */ + --past-opacity: .72; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + + /* Legacy vars (for settings page, toast, etc.) */ --bg: #ffffff; --bg-secondary: #fafafa; --text: #1a1a1a; @@ -8,28 +161,56 @@ --border: #e5e5e5; --border-light: #f5f5f5; --bg-hover: #f0f0f0; - - /* Orange accent colors */ - --accent-primary: #ff6b35; - --accent-hover: #ff5722; - --accent-light: #fff4f0; - --accent-lighter: #fffaf8; - --accent-date: #e64a19; - - /* Shadows and effects */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); - --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.06); - --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.08); - - /* Transitions */ - --transition-fast: 150ms ease; - --transition-base: 200ms ease; - --transition-slow: 250ms ease; + --accent-primary: #F4691F; + --accent-hover: #e05a12; + --highlight: rgba(255, 190, 50, .22); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .04); } -/* Dark mode colors */ +/* =========================================== + Dark mode + =========================================== */ @media (prefers-color-scheme: dark) { - body.auto-mode { + body.auto-mode:not(.settings-page) { + --desk: linear-gradient(155deg, #232B2F, #1A2023 65%, #141A1C); + --paper: #232B2F; + --ruled-line: rgba(255, 255, 255, .07); + --ink: #EAEEEA; + --ink-muted: #8C9A93; + --ink-secondary: #7E8C85; + --tab-track: #2C353A; + --tab-active: #3C474D; + --tab-active-text: #EAEEEA; + --tab-inactive-text: #7E8C85; + --tab-shadow: 0 1px 2px rgba(0, 0, 0, .3); + --checkbox-bg: #2A3338; + --checkbox-border: rgba(255, 255, 255, .22); + --chip-cell-bg: #ECEFE8; + --chip-cell-text: #242B30; + --chip-border: rgba(255, 255, 255, .08); + --chip-shadow: 0 2px 5px rgba(0, 0, 0, .35); + --ghost-bg: #2C353A; + --ghost-border: rgba(255, 255, 255, .12); + --ghost-text: #9AA8A1; + --scan-bg: var(--brand); + --scan-text: #fff; + --footer-bg: #1E2528; + --footer-border: rgba(255, 255, 255, .10); + --footer-text: #BFC9C3; + --footer-muted: #8C9A93; + --page-shadow: 0 16px 32px rgba(0, 0, 0, .55), 0 3px 8px rgba(0, 0, 0, .4); + --cta-shadow: 0 2px 0 rgba(0, 0, 0, .35); + --week-divider: 1px dashed rgba(255, 255, 255, .12); + --weekday-text: #7E8C85; + --coil-gradient: linear-gradient(180deg, #ffb877, #f4842f, #b0530f); + --coil-shadow: inset -1.5px 0 1.5px rgba(255, 255, 255, .55), + inset 1.5px 0 2px rgba(0, 0, 0, .4), + 0 2px 4px rgba(0, 0, 0, .45); + --badge-text: #8C9A93; + --badge-border: rgba(255, 255, 255, .16); + --desk-inset: inset 0 1px 0 rgba(255, 255, 255, .06); + --past-opacity: .6; + --brand-bright: #FF9A52; --bg: #171717; --bg-secondary: #262626; --bg-hover: #333333; @@ -37,16 +218,52 @@ --text-secondary: #a3a3a3; --border: #404040; --border-light: #2a2a2a; - --accent-light: rgba(255, 107, 53, 0.15); - --accent-lighter: rgba(255, 107, 53, 0.08); - --accent-date: #ff8a65; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5); - --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.6); - --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.7); + --accent-primary: #F4691F; + --accent-hover: #e05a12; + --highlight: rgba(255, 180, 50, .12); } } -body.dark-mode { +body.dark-mode:not(.settings-page) { + --desk: linear-gradient(155deg, #232B2F, #1A2023 65%, #141A1C); + --paper: #232B2F; + --ruled-line: rgba(255, 255, 255, .07); + --ink: #EAEEEA; + --ink-muted: #8C9A93; + --ink-secondary: #7E8C85; + --tab-track: #2C353A; + --tab-active: #3C474D; + --tab-active-text: #EAEEEA; + --tab-inactive-text: #7E8C85; + --tab-shadow: 0 1px 2px rgba(0, 0, 0, .3); + --checkbox-bg: #2A3338; + --checkbox-border: rgba(255, 255, 255, .22); + --chip-cell-bg: #ECEFE8; + --chip-cell-text: #242B30; + --chip-border: rgba(255, 255, 255, .08); + --chip-shadow: 0 2px 5px rgba(0, 0, 0, .35); + --ghost-bg: #2C353A; + --ghost-border: rgba(255, 255, 255, .12); + --ghost-text: #9AA8A1; + --scan-bg: var(--brand); + --scan-text: #fff; + --footer-bg: #1E2528; + --footer-border: rgba(255, 255, 255, .10); + --footer-text: #BFC9C3; + --footer-muted: #8C9A93; + --page-shadow: 0 16px 32px rgba(0, 0, 0, .55), 0 3px 8px rgba(0, 0, 0, .4); + --cta-shadow: 0 2px 0 rgba(0, 0, 0, .35); + --week-divider: 1px dashed rgba(255, 255, 255, .12); + --weekday-text: #7E8C85; + --coil-gradient: linear-gradient(180deg, #ffb877, #f4842f, #b0530f); + --coil-shadow: inset -1.5px 0 1.5px rgba(255, 255, 255, .55), + inset 1.5px 0 2px rgba(0, 0, 0, .4), + 0 2px 4px rgba(0, 0, 0, .45); + --badge-text: #8C9A93; + --badge-border: rgba(255, 255, 255, .16); + --desk-inset: inset 0 1px 0 rgba(255, 255, 255, .06); + --past-opacity: .6; + --brand-bright: #FF9A52; --bg: #171717; --bg-secondary: #262626; --bg-hover: #333333; @@ -54,32 +271,53 @@ body.dark-mode { --text-secondary: #a3a3a3; --border: #404040; --border-light: #2a2a2a; - --accent-light: rgba(255, 107, 53, 0.15); - --accent-lighter: rgba(255, 107, 53, 0.08); - --accent-date: #ff8a65; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5); - --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.6); - --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.7); + --accent-primary: #F4691F; + --accent-hover: #e05a12; + --highlight: rgba(255, 180, 50, .12); } -body.light-mode { +/* Settings page keeps the old simple variables */ +body.light-mode.settings-page, +body.auto-mode.settings-page { --bg: #ffffff; --bg-secondary: #fafafa; --text: #1a1a1a; --text-secondary: #737373; --border: #e5e5e5; --border-light: #f5f5f5; - --accent-light: #fff4f0; - --accent-lighter: #fffaf8; + --accent-primary: #F4691F; + --accent-hover: #e05a12; } -/* Base styles */ -* { - box-sizing: border-box; +@media (prefers-color-scheme: dark) { + body.auto-mode.settings-page { + --bg: #171717; + --bg-secondary: #262626; + --bg-hover: #333333; + --text: #fafafa; + --text-secondary: #a3a3a3; + --border: #404040; + --border-light: #2a2a2a; + } +} + +body.dark-mode.settings-page { + --bg: #171717; + --bg-secondary: #262626; + --bg-hover: #333333; + --text: #fafafa; + --text-secondary: #a3a3a3; + --border: #404040; + --border-light: #2a2a2a; } -html { - scroll-behavior: smooth; +/* =========================================== + Base reset & typography + =========================================== */ +*, +*::before, +*::after { + box-sizing: border-box; } html, @@ -89,10 +327,8 @@ body { } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; - color: var(--text); - background: var(--bg); + font-family: 'Hanken Grotesk', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: var(--ink); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -107,16 +343,13 @@ body.theme-loaded { transition: opacity 200ms ease-in; } -/* Typography */ h1 { - font-size: 17px; - font-weight: 600; + font-size: 15px; + font-weight: 800; margin: 0; - color: var(--text); - letter-spacing: -0.01em; + letter-spacing: -.2px; } -/* Button styles */ button { display: inline-flex; align-items: center; @@ -142,7 +375,7 @@ button:not(:disabled):active { } button:focus-visible { - outline: 2px solid var(--accent-primary); + outline: 2px solid var(--brand); outline-offset: 2px; } @@ -151,39 +384,10 @@ button svg { } @keyframes spin { - from { - transform: translate(-50%, -50%) rotate(0deg); - } - - to { - transform: translate(-50%, -50%) rotate(360deg); - } -} - -/* Smooth theme transition */ -body:not(.theme-loading) { - transition: background-color var(--transition-slow), - color var(--transition-slow); -} - -/* Fade in animation for initial load */ -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -body:not(.theme-loading) { - animation: fadeIn var(--transition-slow) ease-out; + from { transform: translate(-50%, -50%) rotate(0deg); } + to { transform: translate(-50%, -50%) rotate(360deg); } } -/* Generic hidden class */ .hidden { display: none !important; } diff --git a/src/ui/fonts/hanken-grotesk-latin-ext.woff2 b/src/ui/fonts/hanken-grotesk-latin-ext.woff2 new file mode 100644 index 0000000..404fa65 Binary files /dev/null and b/src/ui/fonts/hanken-grotesk-latin-ext.woff2 differ diff --git a/src/ui/fonts/hanken-grotesk-latin.woff2 b/src/ui/fonts/hanken-grotesk-latin.woff2 new file mode 100644 index 0000000..e71775f Binary files /dev/null and b/src/ui/fonts/hanken-grotesk-latin.woff2 differ diff --git a/src/ui/fonts/kalam-latin-300.woff2 b/src/ui/fonts/kalam-latin-300.woff2 new file mode 100644 index 0000000..ef188f7 Binary files /dev/null and b/src/ui/fonts/kalam-latin-300.woff2 differ diff --git a/src/ui/fonts/kalam-latin-400.woff2 b/src/ui/fonts/kalam-latin-400.woff2 new file mode 100644 index 0000000..d3fec05 Binary files /dev/null and b/src/ui/fonts/kalam-latin-400.woff2 differ diff --git a/src/ui/fonts/kalam-latin-700.woff2 b/src/ui/fonts/kalam-latin-700.woff2 new file mode 100644 index 0000000..71e687f Binary files /dev/null and b/src/ui/fonts/kalam-latin-700.woff2 differ diff --git a/src/ui/fonts/kalam-latin-ext-300.woff2 b/src/ui/fonts/kalam-latin-ext-300.woff2 new file mode 100644 index 0000000..ec7b872 Binary files /dev/null and b/src/ui/fonts/kalam-latin-ext-300.woff2 differ diff --git a/src/ui/fonts/kalam-latin-ext-400.woff2 b/src/ui/fonts/kalam-latin-ext-400.woff2 new file mode 100644 index 0000000..240c420 Binary files /dev/null and b/src/ui/fonts/kalam-latin-ext-400.woff2 differ diff --git a/src/ui/fonts/kalam-latin-ext-700.woff2 b/src/ui/fonts/kalam-latin-ext-700.woff2 new file mode 100644 index 0000000..dc7d012 Binary files /dev/null and b/src/ui/fonts/kalam-latin-ext-700.woff2 differ diff --git a/src/ui/index.html b/src/ui/index.html index f8514c9..7d45b29 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -10,120 +10,160 @@ - +
+ +
-
- -
+ +
+
+ + + -