From eb20192ed8da0cd2370805a33cb028f2c93e8e1e Mon Sep 17 00:00:00 2001 From: ParkerES Date: Sat, 7 Feb 2026 10:16:19 -0500 Subject: [PATCH 01/13] Style-fix-and-clean-up-code-structure-maintainability --- .claude/settings.local.json | 11 + DATA_SCRAPING_FLOW.md | 360 +++++++ DATA_VIEW_FEATURE.md | 81 ++ EQUIPMENT_AND_PEOPLE_FIGHTING_EXTRACTION.md | 370 +++++++ QUICK_REFERENCE.md | 385 +++++++ .../components/error-display/ErrorDisplay.tsx | 13 - .../components/error-display/ErrorHeader.tsx | 21 - .../error-display/ErrorResetButton.tsx | 11 - .../error-display/ErrorStackTraceList.tsx | 20 - .../src/components/Settings/index.tsx | 61 ++ .../side-panel/src/components/Stats/index.tsx | 580 ----------- .../src/components/TrackedHistory/index.tsx | 362 +------ pages/side-panel/src/constants/Tabs/index.js | 2 + .../side-panel/src/hooks/useGlobalDataSync.ts | 72 ++ pnpm-lock.yaml | 938 +++++++++++++++--- 15 files changed, 2168 insertions(+), 1119 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 DATA_SCRAPING_FLOW.md create mode 100644 DATA_VIEW_FEATURE.md create mode 100644 EQUIPMENT_AND_PEOPLE_FIGHTING_EXTRACTION.md create mode 100644 QUICK_REFERENCE.md delete mode 100644 packages/ui/lib/components/error-display/ErrorDisplay.tsx delete mode 100644 packages/ui/lib/components/error-display/ErrorHeader.tsx delete mode 100644 packages/ui/lib/components/error-display/ErrorResetButton.tsx delete mode 100644 packages/ui/lib/components/error-display/ErrorStackTraceList.tsx create mode 100644 pages/side-panel/src/components/Settings/index.tsx delete mode 100644 pages/side-panel/src/components/Stats/index.tsx create mode 100644 pages/side-panel/src/hooks/useGlobalDataSync.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a838d64 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(timeout:*)", + "Bash(npm run dev:*)", + "Bash(node --version:*)", + "Bash(npm --version)", + "Bash(npx prettier:*)" + ] + } +} diff --git a/DATA_SCRAPING_FLOW.md b/DATA_SCRAPING_FLOW.md new file mode 100644 index 0000000..ade995e --- /dev/null +++ b/DATA_SCRAPING_FLOW.md @@ -0,0 +1,360 @@ +# Data Scraping Flow - Complete Documentation + +## Overview +The extension scrapes game data when **fight ends are detected**, processes it through deduplication logic, saves to storage, and updates the UI cache in real-time. + +--- + +## 1. When Scrapes Fire + +### Primary Trigger: Experience Increase Detection +A scrape fires when **total exp increases for any skill**, detected by: + +#### Detection Method: Skill Level Info Parsing +``` +Example text after fight ends: +"The Rusalka died. You got 213 defence experience. +You also gained 22 defence experience from your dragon armour! +Defence level: 133 (28343195 exp, 573592 for next level)" + +Parser extracts: +- Skill: "Defence" +- Total Exp: 28343195 +- Level: 133 +- Exp for Next: 573592 +``` + +#### Watchers: +1. **MutationObserver** (Primary - watches `#centerContent`) + - Monitors text changes in centerContent + - Detects when skill level info appears/updates + - Pattern: `/\w+\s+level:\s+\d+\s+\(\d+\s+exp,\s+\d+\s+for\s+next\s+level\)/i` + +2. **Periodic Check** (Fallback - every 2 seconds) + - Calls `parseSkillLevels()` to check all visible skills + - Compares total exp to cached values + - Catches cases where MutationObserver might miss + +### Exp Comparison Logic: +```typescript +lastSeenExpBySkill: Map + +For each skill found: + 1. If skill not in map → Initialize (don't scrape) + 2. If totalExp > lastSeenExpBySkill[skill] → SCRAPE! (fight ended) + 3. If totalExp === lastSeenExpBySkill[skill] → Skip (no change) +``` + +### Anti-Duplication Guards: +```typescript +// Natural deduplication via exp comparison: +1. Only scrapes when total exp INCREASES +2. Each skill tracked independently +3. isProcessingFight flag (prevents concurrent scrapes) +4. No duplicates possible - exp only increases once per fight +``` + +--- + +## 2. What Data is Scraped + +### From `scrapeScreenData()`: + +```typescript +ScreenData { + // Main skill being trained + actionText: { + currentActionText: string, // Skill name (e.g., "Mining") + exp: string, // Total exp for this skill + skillLevel: string, // Current skill level + expForNextLevel: string, // Exp needed for next level + speedText: string, // Activity speed + addExp: string, // Base exp gain per action + + inventory: { + hp: string, // Current HP display + farmingExp: string, // Farming exp if applicable + }, + + // Combat-specific data + combatExp: CombatExpGain[], // Array of {skill, exp} for combat skills + drops: string[], // Item drops from monsters + }, + + // Combat metadata + monster: string, // Monster name being fought + location: string, // Location name + damageDealt: string[], // Array of damage values dealt + damageReceived: string[], // Array of damage values received + peopleFighting: number | null, // Number of people at location + + // Fight tracking + totalFights: number, // Set to 1 when fight ends + totalInventoryHP: string, // Current HP from inventory + hpUsed: number, // Sum of damageReceived array + + // Equipment snapshot at fight end + equipment: { + helm, shield, body, weapon, legs, gloves, boots, horse, trophy: { + name: string, + stats: string, + enchant: string, + imageUrl: string, + }, + totals: { + armour: number, + aim: number, + power: number, + travelTime: number, + } + }, + + // Metadata + timestamp: string, // ISO timestamp + uuid: string, // Unique identifier (v4 UUID) +} +``` + +--- + +## 3. Data Flow Path + +### Step 1: Content Script → Background +``` +Content Script (sendData.ts) + ├─ Detects fight end + ├─ Calls scrapeScreenData() + ├─ Sets totalFights = 1 + └─ Sends chrome.runtime.sendMessage({ + type: UPDATE_SCREEN_DATA, + data: ScreenData + }) +``` + +### Step 2: Background Processing +``` +Background Script (background/index.ts) + ├─ Receives UPDATE_SCREEN_DATA message + ├─ Calls processScreenData(data) + │ ├─ Converts to CSV rows (screenDataToCSVRows) + │ ├─ Calculates gainedExp (exp delta since last scrape) + │ ├─ Deduplication logic (by UUID + skill) + │ ├─ Filters incomplete rows + │ └─ Returns dataSaved: boolean + │ + ├─ If dataSaved === true: + │ ├─ appendTrackedData(rows) → saves to 'tracked_data_csv' + │ ├─ updateWeeklyStats() + │ └─ Sends message to side panel: + │ chrome.runtime.sendMessage({ + │ type: UPDATE_SCREEN_DATA, + │ data: ScreenData + │ }) + │ + └─ If dataSaved === false: + └─ No message sent (duplicate/incomplete data) +``` + +### Step 3: Storage Service +``` +storage-service.ts (appendTrackedData) + ├─ Gets existing CSV from 'tracked_data_csv' + ├─ Converts new rows to CSV strings + ├─ Appends to existing CSV + └─ Saves back to chrome.storage.local +``` + +### Step 4: Side Panel Cache Update +``` +Side Panel (useGlobalDataSync hook) + ├─ Receives UPDATE_SCREEN_DATA message + ├─ Calls getTrackedData() to fetch fresh data + ├─ Updates QueryClient cache directly: + │ queryClient.setQueryData( + │ ['trackedData'], + │ freshData + │ ) + │ + └─ All components using useTrackedDataQuery() + automatically see new data +``` + +### Step 5: UI Components Update +``` +Components using cached data automatically re-render: + ├─ Dashboard + ├─ Performance (Stats page) + ├─ LootMap + ├─ TrackedHistory + └─ DataView ← Your new component! +``` + +--- + +## 4. Deduplication Logic + +### Level 1: Content Script +```typescript +// Prevent same fight from being scraped multiple times +- Compare fight log text content (unique identifier) +- Check if total exp changed (same exp = same fight) +- Track processed fights in Set +``` + +### Level 2: Background Script +```typescript +// Deduplicate before saving to storage +Map keyed by: + - Primary: UUID + skill (reliable for new format) + - Fallback: timestamp + monster + skill + gainedExp + +Rules: + - Keep row with most complete data + - Merge drops from duplicate rows + - Only count totalFights once per unique fight +``` + +### Result: +- Each unique fight is saved exactly once +- No duplicate exp counting +- No inflated stats + +--- + +## 5. Cache Update Strategy + +### Optimized Approach (Current): +```typescript +// Direct cache update (efficient) +const freshData = await getTrackedData(); +queryClient.setQueryData(TRACKED_DATA_QUERY_KEY, freshData); +``` + +**Benefits:** +- Single storage read +- Instant UI update +- No refetch delay +- More efficient than invalidate + refetch + +### Backup Mechanism: +```typescript +// Storage change listener +chrome.storage.onChanged.addListener((changes) => { + if (changes.tracked_data_csv) { + // Also updates cache directly + const freshData = await getTrackedData(); + queryClient.setQueryData(TRACKED_DATA_QUERY_KEY, freshData); + } +}); +``` + +--- + +## 6. Why Exp-Based Detection is Better + +### Old Approach (Fight Log Detection): +❌ Fight log always visible during combat +❌ Multiple timer checks and complex text comparisons +❌ Needed Set tracking for processed fights +❌ Could miss fights or duplicate scrapes +❌ Only worked for combat with `#fightLogTop` + +### New Approach (Exp Increase Detection): +✅ Exp only increases ONCE per fight completion +✅ Natural deduplication (no Set tracking needed) +✅ Works for ANY skill (not just combat) +✅ Simpler logic = less code = fewer bugs +✅ More reliable - exp is source of truth +✅ Easier to extend for tracking other activities + +### Example Flow: +``` +User fights Rusalka + ↓ +Fight ends → skill level info appears + ↓ +"Defence level: 133 (28343195 exp, 573592 for next level)" + ↓ +Parser detects: Defence exp = 28343195 + ↓ +Compare to cached: lastSeenExpBySkill["Defence"] = 28343000 + ↓ +28343195 > 28343000 → EXP INCREASED! + ↓ +Trigger scrape → Save all fight data + ↓ +Update cache: lastSeenExpBySkill["Defence"] = 28343195 +``` + +--- + +## 7. Performance Optimizations + +### Content Script: +1. **Targeted MutationObserver**: Only watches `#centerContent` +2. **Simple Exp Comparison**: Just Map lookup and number comparison +3. **Early Exit**: If exp unchanged (most of the time) +4. **No Complex Tracking**: Removed Set, fight log text comparison, etc. +5. **Smaller Bundle**: Reduced from 17.03 kB to 15.84 kB + +### Background Script: +1. **Early Validation**: Filters incomplete rows before saving +2. **Efficient Deduplication**: Map-based O(n) deduplication +3. **Batch Operations**: Saves all rows in single storage write +4. **No Message if No Save**: Only sends UPDATE message when data actually saved + +### Side Panel: +1. **Global Listeners**: Always active (no mount/unmount overhead) +2. **Direct Cache Update**: No invalidation cascade +3. **Single Storage Read**: Per update instead of invalidate + refetch +4. **Shared Cache**: All components use same data, no duplication + +--- + +## 8. Data Integrity + +### Ensures Accuracy By: +1. **UUID per scrape**: Each screen scrape gets unique identifier +2. **Exp Delta Calculation**: Tracks lastExpBySkill to calculate accurate gains +3. **First Scrape Handling**: Doesn't count total exp as gained exp on first scrape +4. **Negative Delta Protection**: Ignores negative exp changes (stat page refreshes) +5. **Zero Delta Skip**: Doesn't save rows with no exp gain (unless fight data exists) + +--- + +## 9. File Structure + +``` +Content Scripts (pages/content/src/): + ├─ sendData.ts - Fight detection & scrape triggering + └─ scrapeScreenData.ts - Data extraction from DOM + +Background (chrome-extension/src/background/): + └─ index.ts - Message handling, processing, storage + +Shared Utilities (packages/shared/lib/utils/): + ├─ csv-tracker.ts - CSV row conversion & parsing + ├─ storage-service.ts - Storage operations + └─ types.ts - TypeScript interfaces + +Side Panel (pages/side-panel/src/): + ├─ hooks/useGlobalDataSync.ts - Global cache sync + └─ components/DataView/ - Your new data viewer + +Query Hooks (packages/shared/lib/hooks/): + └─ useTrackedDataQuery.ts - React-Query hook for tracked data +``` + +--- + +## 10. Key Takeaways + +✅ **Scrapes fire**: When total exp increases (natural fight end detection) +✅ **Trigger source**: `#centerContent` skill level info parsing +✅ **No false triggers**: Exp only increases once per fight completion +✅ **Data saved**: To `'tracked_data_csv'` in chrome.storage.local +✅ **Cache updated**: Via direct `setQueryData` (efficient) +✅ **UI updates**: Instantly across all components +✅ **Deduplication**: Natural via exp comparison (no complex tracking needed) +✅ **Performance**: Optimized for minimal overhead and I/O +✅ **Extensible**: Works for ANY skill that shows level info (not just combat) diff --git a/DATA_VIEW_FEATURE.md b/DATA_VIEW_FEATURE.md new file mode 100644 index 0000000..6175107 --- /dev/null +++ b/DATA_VIEW_FEATURE.md @@ -0,0 +1,81 @@ +# Data View Feature + +## Overview +The Data View feature provides a comprehensive interface to view all tracked data in your Chrome extension. It includes filtering capabilities and multiple view modes. + +## Location +Access the Data View through the **Settings dropdown** in the header navigation. + +### Desktop (>700px width) +- Click the "Settings" badge in the main header +- Select "Data View" from the dropdown menu + +### Mobile (<700px width) +- Click the gear icon (⚙️) on the right side of the header +- Select "Data View" from the menu + +## Features + +### 1. Data Display Modes +- **Table View** (default): Mobile-responsive table showing key data fields +- **JSON View**: Raw JSON format for all data records + +Toggle between modes using the switch in the Data View settings card. + +### 2. Filters +Three filter options are available: + +- **All Data**: Shows all tracked records +- **Loot Only**: Shows only records with item drops from monsters +- **Exp Gains Only**: Shows only records with experience gains (gainedExp > 0) + +### 3. Table Columns +The table view displays: +- Timestamp (formatted for readability) +- Skill name +- Skill level +- Gained experience +- Drops (truncated for mobile, hover for full text) +- Monster name +- Location +- HP (current/total) + +### 4. Data Sorting +All data is automatically sorted by timestamp in descending order (most recent first). + +### 5. Real-time Updates +The data refreshes every 5 seconds automatically to show the latest tracked information. + +## Technical Details + +### Files Added +1. `pages/side-panel/src/components/DataView/index.tsx` - Main component +2. `pages/side-panel/src/components/DataView/useDataView.ts` - Data fetching and filtering logic +3. `pages/side-panel/src/constants/Tabs/index.js` - Added DATA_VIEW constant + +### Files Modified +1. `pages/side-panel/src/components/Header/index.tsx` - Added Data View to settings dropdown +2. `pages/side-panel/src/SidePanel.tsx` - Added Data View rendering + +### Data Source +The component uses the CSV storage system (`getCSVRows` from `@extension/shared`) to fetch all tracked data, which includes: +- Screen scraping data +- Combat experience gains +- Loot drops +- Monster encounters +- Equipment data +- Fight statistics + +### Mobile Responsiveness +- Table is horizontally scrollable on small screens +- Minimum column widths prevent text overlap +- Truncated text with hover tooltips for long content +- Responsive filter badges that wrap on small screens + +## Usage Tips + +1. **Viewing Recent Activity**: The default view shows all data sorted by most recent first +2. **Finding Specific Loot**: Use the "Loot Only" filter to see what items have been dropped +3. **Tracking Exp Progress**: Use the "Exp Gains Only" filter to see only skill improvements +4. **Exporting Data**: Switch to JSON view and copy the data for external analysis +5. **Performance**: The table is optimized for mobile devices and handles large datasets efficiently diff --git a/EQUIPMENT_AND_PEOPLE_FIGHTING_EXTRACTION.md b/EQUIPMENT_AND_PEOPLE_FIGHTING_EXTRACTION.md new file mode 100644 index 0000000..dce00b2 --- /dev/null +++ b/EQUIPMENT_AND_PEOPLE_FIGHTING_EXTRACTION.md @@ -0,0 +1,370 @@ +# Equipment Data and People Fighting Extraction Guide + +This document describes the correct way to extract equipment data and `peopleFighting` information from the game page. + +## Table of Contents +- [Equipment Data Extraction](#equipment-data-extraction) +- [People Fighting Extraction](#people-fighting-extraction) +- [Data Structures](#data-structures) +- [Important Notes](#important-notes) + +--- + +## Equipment Data Extraction + +### Overview +Equipment data is extracted from the `#wearDisplayTD` element, which contains all equipped items. Each equipment slot has a unique ID that maps to a slot name. + +### Implementation + +#### 1. Find the Equipment Container +```typescript +const wearDisplayTD = document.querySelector('#wearDisplayTD') as HTMLElement | null; +if (!wearDisplayTD) { + return undefined; // Equipment data not available +} +``` + +#### 2. Slot Mapping +The following slot IDs map to equipment slot names: + +| Element ID | Slot Name | Description | +|------------|-----------|-------------| +| `displayHelm` | `helm` | Helmet slot | +| `displayShield` | `shield` | Shield slot | +| `displayBody` | `body` | Body/chest armor slot | +| `displayHand` | `weapon` | Weapon slot | +| `displayLegs` | `legs` | Leg armor slot | +| `displayGloves` | `gloves` | Gloves slot | +| `displayShoes` | `boots` | Boots slot | +| `displayHorse` | `horse` | Horse slot | +| `displayTrophy` | `trophy` | Trophy slot | + +#### 3. Extract Equipment Item Data + +For each slot, extract the following information: + +```typescript +const slotMap: Record = { + displayHelm: 'helm', + displayShield: 'shield', + displayBody: 'body', + displayHand: 'weapon', + displayLegs: 'legs', + displayGloves: 'gloves', + displayShoes: 'boots', + displayHorse: 'horse', + displayTrophy: 'trophy', +}; + +Object.entries(slotMap).forEach(([id, slot]) => { + const element = wearDisplayTD.querySelector(`#${id}`) as HTMLElement | null; + if (!element) return; // Slot is empty or element not found +``` + +##### a. Extract Image URL +The image URL is stored in the element's `style` attribute as a CSS `url()` value: + +```typescript +const style = element.getAttribute('style') || ''; +const urlMatch = style.match(/url\(["']?([^"')]+)["']?\)/); +const imageUrl = urlMatch && urlMatch[1] ? urlMatch[1] : undefined; +``` + +##### b. Extract Title (Item Name and Enchant/Stats) +The `title` attribute contains the item name and optional enchant/stats information: + +```typescript +const title = element.getAttribute('title') || ''; +// Format: "Dragon helm [4 Aim]" or "Novariet scimitar [0 Durability]" +``` + +**Title Format:** `"Item Name [Enchant/Stats]"` + +Parse the title: +```typescript +const titleMatch = title.match(/^(.+?)(?:\s+\[(.+?)\])?$/); +let name = title; +let enchant: string | undefined; +let stats: string | undefined; + +if (titleMatch) { + name = titleMatch[1].trim(); + if (titleMatch[2]) { + const bracketContent = titleMatch[2]; + // Check if it's an enchant (contains "Aim", "Power", "Armour", "Travel Time") + if (/\d+\s+(?:Aim|Power|Armour|Travel\s+Time)/i.test(bracketContent)) { + enchant = bracketContent; // e.g., "4 Aim", "2 Power", "10 Armour" + } else { + stats = bracketContent; // e.g., "0 Durability" + } + } +} +``` + +##### c. Extract Text Content (Stats Numbers) +The `textContent` property contains numeric stats displayed in the cell: + +```typescript +const textContent = element.textContent?.trim() || ''; +// Format: "40" or "167/160" (numbers before image) + +if (textContent) { + // Extract numbers and slashes (for durability like "167/160") + const statsMatch = textContent.match(/^([\d/]+)/); + if (statsMatch && statsMatch[1]) { + stats = statsMatch[1].trim(); + } else { + // Fallback: extract all numbers and slashes + const cleanStats = textContent.replace(/[^\d/]/g, '').trim(); + if (cleanStats) { + stats = cleanStats; + } + } +} +``` + +##### d. Build Equipment Item Object +```typescript +const item: EquipmentItem = { + slot: slot as string, + name, + title, + imageUrl, +}; + +if (stats) item.stats = stats; +if (enchant) item.enchant = enchant; + +equipment[slot] = item; +``` + +#### 4. Calculate Equipment Totals + +Equipment totals (Armour, Aim, Power, Travel Time) can be found in two ways: + +##### Method 1: Extract from Page Body Text (Preferred) +Search the entire page body text for total values: + +```typescript +const bodyText = document.body.textContent || ''; + +// Look for patterns like "Total Armour: 123" or "Armour: 123" or "Armour 123" +const armourMatch = bodyText.match(/(?:total\s+)?armour[:\s]+(\d+)/i); +if (armourMatch) { + equipment.totals.armour = parseInt(armourMatch[1], 10); +} + +const aimMatch = bodyText.match(/(?:total\s+)?aim[:\s]+(\d+)/i); +if (aimMatch) { + equipment.totals.aim = parseInt(aimMatch[1], 10); +} + +const powerMatch = bodyText.match(/(?:total\s+)?power[:\s]+(\d+)/i); +if (powerMatch) { + equipment.totals.power = parseInt(powerMatch[1], 10); +} + +const travelTimeMatch = bodyText.match(/(?:total\s+)?travel\s+time[:\s]+(\d+)/i); +if (travelTimeMatch) { + equipment.totals.travelTime = parseInt(travelTimeMatch[1], 10); +} +``` + +##### Method 2: Calculate from Equipment Enchants (Fallback) +If totals are not found in page text, sum up values from individual equipment enchants: + +```typescript +let totalAim = 0; +let totalPower = 0; +let totalArmour = 0; +let totalTravelTime = 0; + +Object.values(equipment).forEach(item => { + if (item && typeof item === 'object' && 'enchant' in item && item.enchant) { + const enchant = item.enchant; + const aimMatch = enchant.match(/(\d+)\s+Aim/i); + const powerMatch = enchant.match(/(\d+)\s+Power/i); + const armourMatch = enchant.match(/(\d+)\s+Armour/i); + const travelTimeMatch = enchant.match(/(\d+)\s+Travel\s+Time/i); + + if (aimMatch) totalAim += parseInt(aimMatch[1], 10); + if (powerMatch) totalPower += parseInt(powerMatch[1], 10); + if (armourMatch) totalArmour += parseInt(armourMatch[1], 10); + if (travelTimeMatch) totalTravelTime += parseInt(travelTimeMatch[1], 10); + } +}); + +// Use calculated totals if not found in page text +if (!equipment.totals.aim && totalAim > 0) equipment.totals.aim = totalAim; +if (!equipment.totals.power && totalPower > 0) equipment.totals.power = totalPower; +if (!equipment.totals.armour && totalArmour > 0) equipment.totals.armour = totalArmour; +if (!equipment.totals.travelTime && totalTravelTime > 0) equipment.totals.travelTime = totalTravelTime; +``` + +### When to Extract Equipment +**Important:** Equipment data should only be extracted when a fight has just finished (when skill level info is present in the fight log). This prevents unnecessary processing and ensures equipment data is captured at the right moment. + +--- + +## People Fighting Extraction + +### Overview +The `peopleFighting` value represents the number of people currently fighting at the location. It is extracted from text that follows the pattern: "There are X people fighting here". + +### Implementation + +#### 1. Find Source Elements +Check two potential locations for the people fighting text: + +```typescript +const locationElement = document.body.querySelector('#LocationContent') as HTMLElement | null; +const fightLogElement = document.body.querySelector('#fightLogTop')?.nextElementSibling as HTMLElement | null; +``` + +#### 2. Extract from LocationContent Element (Primary) +```typescript +if (locationElement) { + const locationText = locationElement.textContent || ''; + const peopleMatch = locationText.match(/there\s+are\s+(\d+)\s+people\s+fighting\s+here/i); + if (peopleMatch && peopleMatch[1]) { + const count = parseInt(peopleMatch[1], 10); + if (!isNaN(count)) { + return count; + } + } +} +``` + +#### 3. Extract from Fight Log Element (Fallback) +```typescript +if (fightLogElement) { + const fightText = fightLogElement.textContent || ''; + const peopleMatch = fightText.match(/there\s+are\s+(\d+)\s+people\s+fighting\s+here/i); + if (peopleMatch && peopleMatch[1]) { + const count = parseInt(peopleMatch[1], 10); + if (!isNaN(count)) { + return count; + } + } +} +``` + +#### 4. Return Result +```typescript +return null; // Return null if not found +``` + +### Regex Pattern +The regex pattern used is case-insensitive and matches: +- "There are 5 people fighting here" +- "there are 10 people fighting here" +- "THERE ARE 3 PEOPLE FIGHTING HERE" + +Pattern: `/there\s+are\s+(\d+)\s+people\s+fighting\s+here/i` + +### When to Extract People Fighting +People fighting can be extracted at any time (not just at fight end), as it represents the current state of the location. + +--- + +## Data Structures + +### EquipmentData Interface +```typescript +interface EquipmentData { + helm?: EquipmentItem; + shield?: EquipmentItem; + body?: EquipmentItem; + weapon?: EquipmentItem; // displayHand + legs?: EquipmentItem; + gloves?: EquipmentItem; + boots?: EquipmentItem; + horse?: EquipmentItem; + trophy?: EquipmentItem; + totals: { + armour?: number; + aim?: number; + power?: number; + travelTime?: number; + }; +} +``` + +### EquipmentItem Interface +```typescript +interface EquipmentItem { + slot: string; + name: string; + title: string; + imageUrl?: string; + stats?: string; // e.g., "167/160" for durability + enchant?: string; // e.g., "4 Aim", "2 Power", "10 Armour" +} +``` + +### ScreenData Interface (Relevant Fields) +```typescript +interface ScreenData { + // ... other fields + peopleFighting?: number | null; // Number of people fighting at the location + equipment?: EquipmentData; // Equipment worn at fight end +} +``` + +--- + +## Important Notes + +### Equipment Extraction +1. **Timing:** Equipment should only be extracted when a fight has just finished (when skill level info is present in the fight log). +2. **Empty Slots:** If an equipment slot is empty, the element may not exist. Always check for element existence before extracting. +3. **Image URLs:** Image URLs are extracted from CSS `url()` values in the `style` attribute. +4. **Title Parsing:** The title attribute contains both the item name and optional enchant/stats in brackets. +5. **Stats vs Enchants:** + - Enchants contain "Aim", "Power", "Armour", or "Travel Time" (e.g., "4 Aim") + - Stats are other values like durability (e.g., "0 Durability") +6. **Totals Calculation:** Always try to extract totals from page text first, then fall back to calculating from individual equipment enchants. + +### People Fighting Extraction +1. **Multiple Sources:** Check both `#LocationContent` and the fight log element for maximum reliability. +2. **Case Insensitive:** The regex pattern is case-insensitive to handle variations in text casing. +3. **Null Handling:** Return `null` if the text is not found (not `0` or `undefined`). +4. **Real-time Value:** This value can change at any time and represents the current state of the location. + +### Error Handling +- Always check for element existence before accessing properties +- Use optional chaining and nullish coalescing where appropriate +- Return `undefined` or `null` when data is not available (don't throw errors) +- Validate parsed numbers with `isNaN()` checks + +### Performance Considerations +- Equipment extraction is only performed at fight end to minimize processing +- People fighting extraction is lightweight and can be done more frequently +- Both operations use efficient DOM queries and regex matching + +--- + +## Example Usage + +```typescript +// Extract equipment (only at fight end) +if (hasSkillLevelInfo) { + const equipment = parseEquipment(); + // equipment will be EquipmentData | undefined +} + +// Extract people fighting (anytime) +const locationElement = document.body.querySelector('#LocationContent') as HTMLElement | null; +const fightLogElement = document.body.querySelector('#fightLogTop')?.nextElementSibling as HTMLElement | null; +const peopleFighting = parsePeopleFighting(locationElement, fightLogElement); +// peopleFighting will be number | null +``` + +--- + +## References + +- Source file: `Chrome-Ext/pages/content/src/scrapeScreenData.ts` +- Type definitions: `Chrome-Ext/packages/shared/lib/utils/types.ts` +- CSV tracking: `Chrome-Ext/packages/shared/lib/utils/csv-tracker.ts` diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..c975650 --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,385 @@ +# Quick Reference Guide + +## For Users + +### How to Use the Extension + +#### 1. Getting Started +1. Install the extension in your browser +2. Navigate to the game (Syrnia) +3. Open the side panel (right-click extension icon, select "Open side panel") + +#### 2. Viewing Your Stats +**Option A: Visit Stats Page** +- Go to `https://www.syrnia.com/theGame/includes2/stats.php` +- Extension automatically scrapes your stats +- Close the tab when done (data is saved) + +**Option B: Use Side Panel** +- Open side panel +- Click "Open Player Stats" button in Profile Card +- Stats will be displayed after page loads + +#### 3. Viewing Dashboard +- Open side panel → Dashboard tab +- See current hour and previous hour exp gains +- View drops, HP used, and average hit +- See all tracked skills with levels and exp + +#### 4. Exporting Your Data +**Export All Data:** +- Dashboard tab → Click "Export All Data" button +- Choose save location +- Three CSV files will be downloaded + +**Export Specific Data:** +- History tab → Click download icon +- Exports tracked data only + +#### 5. Viewing History +- Open side panel → History tab +- Select time period (Hour, Day, Week, Month) +- Click on any row to expand details +- See exp by skill, drops, HP used + +#### 6. Clearing Data +**Clear All Data:** +- History tab → Click trash icon +- Confirm deletion + +**Clear Current Hour:** +- Stats tab → Click "Clear Hour" button +- Confirms deletion + +--- + +## For Developers + +### Using the Storage Service + +```typescript +import { + getTrackedData, + appendTrackedData, + getUserStats, + saveUserStats, + getWeeklyStats, + saveWeeklyStats, + downloadTrackedDataCSV, + downloadUserStatsCSV, + downloadWeeklyStatsCSV, + downloadAllDataCSV, +} from '@extension/shared'; + +// Get tracked data +const rows = await getTrackedData(); + +// Append tracked data +await appendTrackedData([newRow]); + +// Get user stats +const stats = await getUserStats(); + +// Save user stats +await saveUserStats(statsData); + +// Get weekly stats +const weeklyStats = await getWeeklyStats(); + +// Export data +await downloadAllDataCSV(true); // true = show file picker +``` + +### Using TanStack Query Hooks + +```typescript +import { + useTrackedDataQuery, + useUserStatsQuery, + useWeeklyStatsQuery, + useDataExport, +} from '@extension/shared'; + +// Tracked data hook +const { + allData, // All CSV rows + dataByPeriod, // Filter by time period + dataByHour, // Filter by hour + dataByDay, // Filter by day + stats, // Aggregated stats + statsByPeriod, // Stats for period + refresh, // Manual refresh + download, // Download CSV + clear, // Clear all data + clearByHour, // Clear by hour + loading, // Initial loading state + isFetching, // Background fetching + error, // Error state +} = useTrackedDataQuery(); + +// User stats hook +const { + userStats, // User stats object + loading, // Loading state + isFetching, // Background fetching + error, // Error state + refresh, // Manual refresh +} = useUserStatsQuery(); + +// Weekly stats hook +const { + weeklyStats, // All weekly stats + currentWeekStats, // Current week only + loading, // Loading state + isFetching, // Background fetching + error, // Error state + refresh, // Manual refresh +} = useWeeklyStatsQuery(); + +// Data export hook +const { + exportData, // Export function + isExporting, // Loading state + error, // Error state +} = useDataExport(); + +// Export examples +await exportData('tracked', true); // Tracked data +await exportData('userStats', true); // User stats +await exportData('weeklyStats', true);// Weekly stats +await exportData('all', true); // All data +``` + +### Creating New Components + +```typescript +import { useTrackedDataQuery, useFormatting } from '@extension/shared'; +import { Card, CardContent, CardHeader, CardTitle } from '@extension/ui'; +import { memo, useMemo } from 'react'; + +const MyComponent = memo(() => { + const { allData, loading } = useTrackedDataQuery(); + const { formatExp } = useFormatting(); + + // Memoize expensive calculations + const totalExp = useMemo(() => { + return allData.reduce((sum, row) => { + return sum + (parseInt(row.gainedExp || '0', 10) || 0); + }, 0); + }, [allData]); + + if (loading) { + return
Loading...
; + } + + return ( + + + Total Exp + + +

{formatExp(totalExp)}

+
+
+ ); +}); + +MyComponent.displayName = 'MyComponent'; + +export default MyComponent; +``` + +### Adding New Storage Operations + +```typescript +// In storage-service.ts + +/** + * Get custom data from storage + */ +export async function getCustomData(): Promise { + const csvContent = await getFromStorage('custom_data_csv', getCustomDataHeader()); + return parseCustomDataCSV(csvContent); +} + +/** + * Save custom data to storage + */ +export async function saveCustomData(data: CustomData[]): Promise { + const header = getCustomDataHeader(); + const lines = data.map(customDataToString); + const csvContent = `${header}\n${lines.join('\n')}`; + await setInStorage('custom_data_csv', csvContent); +} + +/** + * Download custom data as CSV + */ +export async function downloadCustomDataCSV(saveAs: boolean = true): Promise { + const csvContent = await getFromStorage('custom_data_csv', getCustomDataHeader()); + const date = new Date().toISOString().split('T')[0]; + await downloadCSV(csvContent, `custom_data_${date}.csv`, saveAs); +} +``` + +### Creating New Hooks + +```typescript +// useCustomDataQuery.ts +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { getCustomData } from '../utils/storage-service.js'; + +export const CUSTOM_DATA_QUERY_KEY = ['customData'] as const; + +export const useCustomDataQuery = () => { + const queryClient = useQueryClient(); + + const { + data: customData = [], + isLoading, + isFetching, + error, + } = useQuery({ + queryKey: CUSTOM_DATA_QUERY_KEY, + queryFn: async () => { + return await getCustomData(); + }, + staleTime: 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }); + + // Listen for storage changes + useEffect(() => { + const storageListener = ( + changes: { [key: string]: chrome.storage.StorageChange }, + areaName: string + ) => { + if (areaName === 'local' && changes.custom_data_csv) { + queryClient.invalidateQueries({ queryKey: CUSTOM_DATA_QUERY_KEY }); + } + }; + + chrome.storage.onChanged.addListener(storageListener); + + return () => { + chrome.storage.onChanged.removeListener(storageListener); + }; + }, [queryClient]); + + const refresh = async () => { + await queryClient.invalidateQueries({ queryKey: CUSTOM_DATA_QUERY_KEY }); + await queryClient.refetchQueries({ queryKey: CUSTOM_DATA_QUERY_KEY }); + }; + + return { + customData, + loading: isLoading, + isFetching, + error: error as Error | null, + refresh, + }; +}; +``` + +--- + +## Common Tasks + +### Task: Add a new stat to track +1. Update `types.ts` with new field +2. Update scraping logic in content script +3. Update CSV format in `csv-tracker.ts` +4. Update storage service if needed +5. Update UI components to display new stat + +### Task: Add a new time period filter +1. Update `TimePeriod` type in `csv-tracker.ts` +2. Add filter logic to `filterByTimePeriod()` +3. Update UI components to show new option + +### Task: Add a new export format +1. Create new export function in `storage-service.ts` +2. Update `useDataExport` hook to support new format +3. Update UI to show new export option + +### Task: Add a new chart type +1. Create new chart component in `ExpChart/charts/` +2. Add to chart type selector in `ExpChart/index.tsx` +3. Update chart data processing if needed + +--- + +## Troubleshooting + +### Data not updating in side panel +**Solution:** +1. Check if stats page was visited recently +2. Refresh side panel manually +3. Check browser console for errors + +### CSV export not working +**Solution:** +1. Check browser permissions for downloads +2. Verify storage has data +3. Check browser console for errors + +### Stats page not scraping +**Solution:** +1. Verify you're on the correct URL +2. Check if page loaded completely +3. Look for errors in browser console +4. Try refreshing the stats page + +### Performance issues +**Solution:** +1. Clear old data from History tab +2. Check browser memory usage +3. Restart browser if needed + +--- + +## Best Practices + +### For Users +✅ Visit stats page regularly for accurate data +✅ Export data periodically as backup +✅ Clear old data to improve performance +✅ Keep browser updated + +### For Developers +✅ Use storage service for all storage operations +✅ Use TanStack Query hooks for data access +✅ Memoize expensive calculations +✅ Follow separation of concerns +✅ Write clear comments +✅ Test changes thoroughly +✅ Update documentation + +--- + +## Useful Links + +- **Game:** https://www.syrnia.com +- **Stats Page:** https://www.syrnia.com/theGame/includes2/stats.php +- **TanStack Query Docs:** https://tanstack.com/query/latest +- **shadcn/ui Docs:** https://ui.shadcn.com + +--- + +## Support + +If you need help: +1. Check this guide first +2. Review the CHANGELOG.md +3. Check the code comments +4. Test in browser console +5. Report issues with details + +--- + +**Last Updated:** January 2026 +**Version:** 2.0.0 (Post-Refactor) diff --git a/packages/ui/lib/components/error-display/ErrorDisplay.tsx b/packages/ui/lib/components/error-display/ErrorDisplay.tsx deleted file mode 100644 index 48ba66a..0000000 --- a/packages/ui/lib/components/error-display/ErrorDisplay.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { ErrorHeader } from '@/lib/components/error-display/ErrorHeader'; -import { ErrorResetButton } from '@/lib/components/error-display/ErrorResetButton'; -import { ErrorStackTraceList } from '@/lib/components/error-display/ErrorStackTraceList'; - -export const ErrorDisplay = ({ error, resetErrorBoundary }: { error?: Error; resetErrorBoundary?: () => void }) => ( -
-
- - - -
-
-); diff --git a/packages/ui/lib/components/error-display/ErrorHeader.tsx b/packages/ui/lib/components/error-display/ErrorHeader.tsx deleted file mode 100644 index bfc820d..0000000 --- a/packages/ui/lib/components/error-display/ErrorHeader.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { t } from '@extension/i18n'; - -// FIXME: IMPORT SVG ICON INSTEAD OF DEFINING INLINE IT HERE -const WarningIcon = ({ className }: { className: string }) => ( - - - -); - -export const ErrorHeader = () => ( -
- -

{t('displayErrorInfo')}

-

{t('displayErrorDescription')}.

-
-); diff --git a/packages/ui/lib/components/error-display/ErrorResetButton.tsx b/packages/ui/lib/components/error-display/ErrorResetButton.tsx deleted file mode 100644 index aa3ef85..0000000 --- a/packages/ui/lib/components/error-display/ErrorResetButton.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { t } from '@extension/i18n'; - -export const ErrorResetButton = ({ resetErrorBoundary }: { resetErrorBoundary?: () => void }) => ( -
- -
-); diff --git a/packages/ui/lib/components/error-display/ErrorStackTraceList.tsx b/packages/ui/lib/components/error-display/ErrorStackTraceList.tsx deleted file mode 100644 index 6e28815..0000000 --- a/packages/ui/lib/components/error-display/ErrorStackTraceList.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { t } from '@extension/i18n'; - -export const ErrorStackTraceList = ({ error }: { error?: Error }) => ( -
-
-
-

{t('displayErrorDetailsInfo')}

-
-

{error?.message || t('displayErrorUnknownErrorInfo')}

- {error?.stack && ( -
- Stack trace -
{error?.stack}
-
- )} -
-
-
-
-); diff --git a/pages/side-panel/src/components/Settings/index.tsx b/pages/side-panel/src/components/Settings/index.tsx new file mode 100644 index 0000000..d5e4125 --- /dev/null +++ b/pages/side-panel/src/components/Settings/index.tsx @@ -0,0 +1,61 @@ +import { useStorage, themes, applyTheme, getTheme } from '@extension/shared'; +import { exampleThemeStorage } from '@extension/storage'; +import { cn, Card, CardContent, CardHeader, CardTitle, Label, Select } from '@extension/ui'; +import { memo, useEffect } from 'react'; + +/** + * Settings Component + */ +const Settings = memo(() => { + const themeStorage = useStorage(exampleThemeStorage); + const currentThemeName = themeStorage?.themeName || 'default'; + const isDark = themeStorage ? !themeStorage.isLight : true; + + // Apply theme when it changes + useEffect(() => { + if (themeStorage?.themeName) { + const theme = getTheme(themeStorage.themeName); + if (theme) { + applyTheme(theme, isDark); + } + } + }, [themeStorage?.themeName, isDark]); + + const handleThemeChange = async (themeName: string) => { + await exampleThemeStorage.setThemeName(themeName); + const theme = getTheme(themeName); + if (theme) { + applyTheme(theme, isDark); + } + }; + + return ( +
+ {/* Theme Selection */} + + + Theme + + +
+ +

Choose a color theme for the extension

+ +
+
+
+
+ ); +}); + +Settings.displayName = 'Settings'; + +export default Settings; diff --git a/pages/side-panel/src/components/Stats/index.tsx b/pages/side-panel/src/components/Stats/index.tsx deleted file mode 100644 index bfd3f52..0000000 --- a/pages/side-panel/src/components/Stats/index.tsx +++ /dev/null @@ -1,580 +0,0 @@ -import { useHourlyExp, useTrackedDataQuery, useFormatting } from '@extension/shared'; -import { cn, Card, CardContent, CardHeader, CardTitle, Button, Tabs, TabsList, TabsTrigger } from '@extension/ui'; -import { useMemo, memo, useState, useEffect } from 'react'; -import type { CSVRow } from '@extension/shared'; - -const Stats = memo(() => { - const hourlyExp = useHourlyExp(); - const { allData, clearByHour, loading } = useTrackedDataQuery(); - const { formatExp } = useFormatting(); - const [selectedLocation, setSelectedLocation] = useState('all'); - - // Group all data by location - const dataByLocation = useMemo(() => { - const locationMap = new Map(); - const allLocations: CSVRow[] = []; - - allData.forEach(row => { - allLocations.push(row); - const location = row.location?.trim() || 'Unknown'; - if (!locationMap.has(location)) { - locationMap.set(location, []); - } - locationMap.get(location)!.push(row); - }); - - // Add "All" location with all data - const result = new Map(); - result.set('all', allLocations); - - // Sort locations by name (excluding 'all') - const sortedLocations = Array.from(locationMap.entries()) - .filter(([loc]) => loc !== 'all') - .sort(([a], [b]) => a.localeCompare(b)); - - sortedLocations.forEach(([location, rows]) => { - result.set(location, rows); - }); - - return result; - }, [allData]); - - // Get list of locations for tabs - const locations = useMemo(() => Array.from(dataByLocation.keys()), [dataByLocation]); - - // Set default selected location to first available (or 'all') - useEffect(() => { - if (locations.length > 0) { - if (selectedLocation === 'all' && !locations.includes('all')) { - setSelectedLocation(locations[0]); - } else if (!locations.includes(selectedLocation)) { - setSelectedLocation(locations[0]); - } - } - }, [locations, selectedLocation]); - - // Get data for selected location - const selectedLocationData = useMemo( - () => dataByLocation.get(selectedLocation) || [], - [dataByLocation, selectedLocation], - ); - - // Prepare display data - use saved gainedExp directly with deduplication - // Use selectedLocationData instead of currentHourData for location-based stats - const displayData = useMemo(() => { - if (selectedLocationData.length === 0) return []; - - // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) - // This matches the logic in aggregateStats used by the history tab - const uniqueEntriesMap = new Map(); - - selectedLocationData.forEach(row => { - const skill = row.skill || ''; - const key = `${row.timestamp}-${skill}`; - const existing = uniqueEntriesMap.get(key); - - if (!existing) { - uniqueEntriesMap.set(key, row); - } else { - // Keep the one with higher gainedExp or more complete data - const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; - const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; - if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { - uniqueEntriesMap.set(key, row); - } - } - }); - - // Process unique entries only - const uniqueEntries = Array.from(uniqueEntriesMap.values()); - const result: Array & { gainedExp: number }> = []; - - uniqueEntries.forEach(row => { - try { - // Use saved gainedExp directly (it's already calculated and saved) - const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; - - // Only include entries with gainedExp > 0 (matches aggregateStats logic) - if (gainedExp > 0) { - // Ensure all CSVRow fields are present with defaults - result.push({ - timestamp: row.timestamp || '', - skill: row.skill || '', - skillLevel: row.skillLevel || '', - expForNextLevel: row.expForNextLevel || '', - drops: row.drops || '', - hp: row.hp || '', - monster: row.monster || '', - location: row.location || '', - damageDealt: row.damageDealt || '', - damageReceived: row.damageReceived || '', - peopleFighting: row.peopleFighting || '', - totalFights: row.totalFights || '', - totalInventoryHP: row.totalInventoryHP || '', - hpUsed: row.hpUsed || '', - gainedExp, - }); - } - } catch (error) { - console.error('Error processing row in displayData:', error, row); - } - }); - - // Sort by timestamp descending (most recent first) for display - return result.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - }, [selectedLocationData]); - - // Calculate total gained exp for the hour (sum of all gained exp, not total exp) - const totalGainedExpThisHour = useMemo(() => displayData.reduce((sum, row) => sum + row.gainedExp, 0), [displayData]); - - // Calculate current hour - must be a hook to maintain hook order - const currentHour = useMemo(() => { - try { - return hourlyExp?.currentHour ?? new Date().getHours(); - } catch (error) { - console.error('Error getting current hour:', error); - return new Date().getHours(); - } - }, [hourlyExp?.currentHour]); - - // Calculate total gained exp formatted - const totalGainedExp = useMemo(() => { - try { - return formatExp(totalGainedExpThisHour); - } catch (error) { - console.error('Error formatting total gained exp:', error); - return '0'; - } - }, [totalGainedExpThisHour, formatExp]); - - // Calculate performance stats: max hit, avg hit, HP lost - // Use selectedLocationData instead of currentHourData for location-based stats - const performanceStats = useMemo(() => { - try { - if (!Array.isArray(selectedLocationData) || selectedLocationData.length === 0) { - return { - maxHit: 0, - avgHit: 0, - maxHitByMonster: {}, - avgHitByMonster: {}, - maxDamageReceived: 0, - avgDamageReceived: 0, - maxDamageReceivedByMonster: {}, - avgDamageReceivedByMonster: {}, - hpLostPerHour: 0, - hpLostPer15Min: 0, - }; - } - - // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) - // This matches the logic in aggregateStats used by the history tab - const uniqueEntriesMap = new Map(); - - selectedLocationData.forEach(row => { - const skill = row.skill || ''; - const key = `${row.timestamp}-${skill}`; - const existing = uniqueEntriesMap.get(key); - - if (!existing) { - uniqueEntriesMap.set(key, row); - } else { - // Keep the one with higher gainedExp or more complete data - const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; - const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; - if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { - uniqueEntriesMap.set(key, row); - } - } - }); - - // Use unique entries only - const uniqueEntries = Array.from(uniqueEntriesMap.values()); - - const allDamageDealt: number[] = []; - const allDamageReceived: number[] = []; - const monsterStats: Record = {}; - - // Calculate time span for rate calculations (use earliest and latest timestamps) - const timestamps = uniqueEntries - .map(row => new Date(row.timestamp).getTime()) - .filter(ts => !isNaN(ts)) - .sort((a, b) => a - b); - - const earliestTime = timestamps.length > 0 ? timestamps[0] : Date.now(); - const latestTime = timestamps.length > 0 ? timestamps[timestamps.length - 1] : Date.now(); - const elapsedMinutes = Math.max(1, (latestTime - earliestTime) / (1000 * 60)); // At least 1 minute to avoid division by zero - - uniqueEntries.forEach(row => { - try { - // Normalize monster name for consistent grouping (case-insensitive, trimmed) - const rawMonster = row.monster || 'Unknown'; - const normalizedKey = rawMonster.trim().toLowerCase(); - const displayName = rawMonster.trim() || 'Unknown'; - - // Parse damage dealt - const damageDealtStr = row.damageDealt || ''; - if (damageDealtStr) { - const damageValues = damageDealtStr - .split(';') - .map(d => d.trim()) - .filter(d => d.length > 0); - damageValues.forEach(damageStr => { - const damage = parseInt(String(damageStr).replace(/,/g, ''), 10); - if (!isNaN(damage) && damage >= 0) { - allDamageDealt.push(damage); - if (!monsterStats[normalizedKey]) { - monsterStats[normalizedKey] = { damage: [], received: [], displayName }; - } - monsterStats[normalizedKey].damage.push(damage); - } - }); - } - - // Parse damage received - const damageReceivedStr = row.damageReceived || ''; - if (damageReceivedStr) { - const receivedValues = damageReceivedStr - .split(';') - .map(d => d.trim()) - .filter(d => d.length > 0); - receivedValues.forEach(damageStr => { - const damage = parseInt(String(damageStr).replace(/,/g, ''), 10); - if (!isNaN(damage) && damage >= 0) { - allDamageReceived.push(damage); - if (!monsterStats[normalizedKey]) { - monsterStats[normalizedKey] = { damage: [], received: [], displayName }; - } - monsterStats[normalizedKey].received.push(damage); - } - }); - } - } catch (err) { - console.error('Error processing row in performanceStats:', err, row); - } - }); - - // Calculate overall stats for damage dealt (by user) - const validHits = allDamageDealt.filter(d => d > 0); - const maxHit = validHits.length > 0 ? Math.max(...validHits) : 0; - const avgHit = validHits.length > 0 ? validHits.reduce((sum, d) => sum + d, 0) / validHits.length : 0; - - // Calculate overall stats for damage received (by user) - const validReceived = allDamageReceived.filter(d => d > 0); - const maxDamageReceived = validReceived.length > 0 ? Math.max(...validReceived) : 0; - const avgDamageReceived = - validReceived.length > 0 ? validReceived.reduce((sum, d) => sum + d, 0) / validReceived.length : 0; - - // Calculate HP lost - const totalHPLost = allDamageReceived.reduce((sum, d) => sum + d, 0); - const hpLostPerHour = totalHPLost; - const hpLostPer15Min = (totalHPLost / elapsedMinutes) * 15; - - // Calculate per-monster stats for damage dealt - const maxHitByMonster: Record = {}; - const avgHitByMonster: Record = {}; - - // Calculate per-monster stats for damage received - const maxDamageReceivedByMonster: Record = {}; - const avgDamageReceivedByMonster: Record = {}; - - Object.entries(monsterStats).forEach(([, stats]) => { - // Use display name for the key (preserves original casing/formatting) - const monsterDisplayName = stats.displayName; - - // Damage dealt stats - all hits for this monster are already aggregated in stats.damage - const validMonsterHits = stats.damage.filter(d => d > 0); - if (validMonsterHits.length > 0) { - // Calculate max from all accumulated hits for this monster - maxHitByMonster[monsterDisplayName] = Math.max(...validMonsterHits); - // Calculate average from all accumulated hits for this monster - avgHitByMonster[monsterDisplayName] = - validMonsterHits.reduce((sum, d) => sum + d, 0) / validMonsterHits.length; - } - - // Damage received stats - all received damage for this monster are already aggregated in stats.received - const validMonsterReceived = stats.received.filter(d => d > 0); - if (validMonsterReceived.length > 0) { - // Calculate max from all accumulated received damage for this monster - maxDamageReceivedByMonster[monsterDisplayName] = Math.max(...validMonsterReceived); - // Calculate average from all accumulated received damage for this monster - avgDamageReceivedByMonster[monsterDisplayName] = - validMonsterReceived.reduce((sum, d) => sum + d, 0) / validMonsterReceived.length; - } - }); - - return { - maxHit, - avgHit, - maxHitByMonster, - avgHitByMonster, - maxDamageReceived, - avgDamageReceived, - maxDamageReceivedByMonster, - avgDamageReceivedByMonster, - hpLostPerHour, - hpLostPer15Min, - }; - } catch (error) { - console.error('Error calculating performanceStats:', error); - return { - maxHit: 0, - avgHit: 0, - maxHitByMonster: {}, - avgHitByMonster: {}, - hpLostPerHour: 0, - hpLostPer15Min: 0, - }; - } - }, [selectedLocationData]); - - if (loading) { - return
Loading tracked data...
; - } - - const handleClearCurrentHour = async () => { - if ( - confirm( - `Are you sure you want to clear all tracked data for hour ${currentHour}:00? This action cannot be undone.`, - ) - ) { - try { - await clearByHour(currentHour); - alert(`Data for hour ${currentHour}:00 cleared successfully!`); - } catch (error) { - alert('Error clearing data for current hour'); - console.error(error); - } - } - }; - - return ( -
- {/* Location Tabs */} - - - - - {locations.map(location => ( - - {location === 'all' ? 'All Locations' : location} - - ))} - - - - - - {/* Summary Header */} - - -
- - {selectedLocation === 'all' ? 'All Locations' : selectedLocation} - -
- - Total Gained: +{totalGainedExp} - - {selectedLocation !== 'all' && ( - - )} -
-
-
-
- - {/* Performance Card */} - - - Performance - - -
- {/* Max and Average Hit Card - Damage Dealt */} - - -
- Your Max and Average Hit per creature -
-
-
-
- You (Max): - - {performanceStats.maxHit > 0 ? performanceStats.maxHit.toLocaleString() : '—'} - -
-
- You (Avg): - - {performanceStats.avgHit > 0 ? Math.round(performanceStats.avgHit).toLocaleString() : '—'} - -
-
- {Object.keys(performanceStats.maxHitByMonster).length > 0 && ( - <> -
- {Object.entries(performanceStats.maxHitByMonster) - .sort((a, b) => b[1] - a[1]) - .map(([monster, maxHit]) => { - const avgHit = (performanceStats.avgHitByMonster as Record)[monster]; - return ( -
-
- {monster} (Max): - {maxHit.toLocaleString()} -
-
- {monster} (Avg): - - {avgHit ? Math.round(avgHit).toLocaleString() : '—'} - -
-
- ); - })} -
- - )} -
-
-
- - {/* Max and Average Damage Received Card */} - - -
- Your Max and Average Damage Received per creature -
-
-
-
- You (Max): - 0 ? 'text-red-500' : 'text-foreground', - )}> - {(performanceStats.maxDamageReceived ?? 0) > 0 - ? (performanceStats.maxDamageReceived ?? 0).toLocaleString() - : '—'} - -
-
- You (Avg): - 0 ? 'text-red-500' : 'text-foreground', - )}> - {(performanceStats.avgDamageReceived ?? 0) > 0 - ? Math.round(performanceStats.avgDamageReceived ?? 0).toLocaleString() - : '—'} - -
-
- {performanceStats.maxDamageReceivedByMonster && - Object.keys(performanceStats.maxDamageReceivedByMonster).length > 0 && ( - <> -
- {Object.entries(performanceStats.maxDamageReceivedByMonster) - .sort((a, b) => b[1] - a[1]) - .map(([monster, maxReceived]) => { - const avgReceived = ( - performanceStats.avgDamageReceivedByMonster as Record | undefined - )?.[monster]; - return ( -
-
- {monster} (Max): - 0 ? 'text-red-500' : 'text-foreground', - )}> - {maxReceived.toLocaleString()} - -
-
- {monster} (Avg): - 0 ? 'text-red-500' : 'text-foreground', - )}> - {avgReceived ? Math.round(avgReceived).toLocaleString() : '—'} - -
-
- ); - })} -
- - )} -
-
-
- - {/* Estimated HP Lost Card */} - - -
- Estimated HP Lost -
-
-
- Per Hour: -

0 ? 'text-red-500' : 'text-foreground', - )}> - {performanceStats.hpLostPerHour > 0 ? performanceStats.hpLostPerHour.toLocaleString() : '—'} -

-
-
- Per 15 Minutes: -

0 ? 'text-red-500' : 'text-foreground', - )}> - {performanceStats.hpLostPer15Min > 0 - ? Math.round(performanceStats.hpLostPer15Min).toLocaleString() - : '—'} -

-
-
-
-
-
-
-
-
- ); -}); - -Stats.displayName = 'Stats'; - -export default Stats; diff --git a/pages/side-panel/src/components/TrackedHistory/index.tsx b/pages/side-panel/src/components/TrackedHistory/index.tsx index 13ee6cf..263ac15 100644 --- a/pages/side-panel/src/components/TrackedHistory/index.tsx +++ b/pages/side-panel/src/components/TrackedHistory/index.tsx @@ -1,7 +1,7 @@ -import { useTrackedDataQuery, useDataExport, useItemValuesQuery, useFormatting } from '@extension/shared'; +import { useTrackedDataQuery, useDataExport, usePeriodStats } from '@extension/shared'; import { cn, - Button, + IconButton, Card, CardContent, CardHeader, @@ -16,275 +16,26 @@ import { TabsList, TabsTrigger, } from '@extension/ui'; -import React, { useState, useMemo } from 'react'; -import type { CSVRow } from '@extension/shared'; - -type TimePeriod = 'hour' | 'day' | 'week' | 'month'; +import { DownloadIcon, RefreshIcon, TrashIcon } from '@src/assets/icons'; +import React, { useState } from 'react'; +import type { TimePeriod } from '@extension/shared'; const TrackedHistory = () => { - const { allData, stats, statsByPeriod, refresh, clear, loading } = useTrackedDataQuery(); + const { statsByPeriod, refresh, clear } = useTrackedDataQuery(); const { exportData, isExporting } = useDataExport(); - const { itemValues } = useItemValuesQuery(); - const { parseDrops, parseDropAmount } = useFormatting(); + const { + periodBreakdown, + selectedPeriod, + setSelectedPeriod, + loading, + itemValues, + overallStats: stats, + } = usePeriodStats('day'); - const [selectedPeriod, setSelectedPeriod] = useState('day'); const [expandedHours, setExpandedHours] = useState>(new Set()); const periodStats = statsByPeriod(selectedPeriod); - // Deduplicate all entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) - // This version includes ALL rows (not filtered by exp) - used for drops and HP calculations - // IMPORTANT: Merge drops from all rows with the same timestamp+skill to preserve all drop data - const allDeduplicatedData = useMemo(() => { - const uniqueEntriesMap = new Map(); - - allData.forEach(row => { - const key = `${row.timestamp}-${row.skill}`; - const existing = uniqueEntriesMap.get(key); - - if (!existing) { - uniqueEntriesMap.set(key, { ...row }); - } else { - // Merge drops from both rows - const existingDrops = existing.drops || ''; - const currentDrops = row.drops || ''; - const mergedDrops = [existingDrops, currentDrops].filter(d => d && d.trim() !== '').join(';'); - - // Keep the one with higher gainedExp or most complete data, but preserve merged drops - const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; - const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; - - if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { - // Current row is better, but use merged drops - uniqueEntriesMap.set(key, { ...row, drops: mergedDrops }); - } else { - // Existing row is better, but update with merged drops - uniqueEntriesMap.set(key, { ...existing, drops: mergedDrops }); - } - } - }); - - return Array.from(uniqueEntriesMap.values()); - }, [allData]); - - // Filtered version for exp calculations (only rows with gainedExp > 0) - const allDeduplicatedDataWithExp = useMemo( - () => allDeduplicatedData.filter(row => parseInt(row.gainedExp || '0', 10) > 0), - [allDeduplicatedData], - ); - - // Group all data by the selected period type (hour/day/week/month) - // We need to group ALL data (not just filtered) for accurate drop counting - const periodBreakdown = useMemo(() => { - // Group ALL deduplicated data by period (for drops and HP calculations) - const allDataPeriodMap = new Map(); - allDeduplicatedData.forEach(row => { - const date = new Date(row.timestamp); - let periodKey: string; - let periodDate: Date; - - if (selectedPeriod === 'hour') { - // Group by hour - periodKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}`; - periodDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0); - } else if (selectedPeriod === 'day') { - // Group by day - periodKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; - periodDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0); - } else if (selectedPeriod === 'week') { - // Group by week (start of week = Sunday) - const weekStart = new Date(date); - const day = weekStart.getDay(); - weekStart.setDate(weekStart.getDate() - day); - weekStart.setHours(0, 0, 0, 0); - periodKey = `${weekStart.getFullYear()}-${weekStart.getMonth()}-${weekStart.getDate()}`; - periodDate = weekStart; - } else { - // month - // Group by month - periodKey = `${date.getFullYear()}-${date.getMonth()}`; - periodDate = new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0); - } - - if (!allDataPeriodMap.has(periodKey)) { - allDataPeriodMap.set(periodKey, { - periodKey, - date: periodDate, - rows: [], - }); - } - allDataPeriodMap.get(periodKey)!.rows.push(row); - }); - - // Group filtered data (with exp > 0) by period (for exp calculations) - const periodMap = new Map(); - - allDeduplicatedDataWithExp.forEach(row => { - const date = new Date(row.timestamp); - let periodKey: string; - let periodDate: Date; - - if (selectedPeriod === 'hour') { - // Group by hour - periodKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}`; - periodDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0); - } else if (selectedPeriod === 'day') { - // Group by day - periodKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; - periodDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0); - } else if (selectedPeriod === 'week') { - // Group by week (start of week = Sunday) - const weekStart = new Date(date); - const day = weekStart.getDay(); - weekStart.setDate(weekStart.getDate() - day); - weekStart.setHours(0, 0, 0, 0); - periodKey = `${weekStart.getFullYear()}-${weekStart.getMonth()}-${weekStart.getDate()}`; - periodDate = weekStart; - } else { - // month - // Group by month - periodKey = `${date.getFullYear()}-${date.getMonth()}`; - periodDate = new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0); - } - - if (!periodMap.has(periodKey)) { - periodMap.set(periodKey, { - periodKey, - date: periodDate, - rows: [], - }); - } - periodMap.get(periodKey)!.rows.push(row); - }); - - // Use parseDrops and parseDropAmount from useFormatting hook - - // Convert to array and calculate stats for each period - // Use allDataPeriodMap for drops/HP, periodMap for exp - return Array.from(allDataPeriodMap.values()) - .map(({ periodKey, date, rows: allRows }) => { - // Get exp rows for this period (filtered) - // Note: expRows are already deduplicated, so we calculate exp directly - // instead of using aggregateStats which would deduplicate again - const expRows = periodMap.get(periodKey)?.rows || []; - - // Calculate exp manually to avoid double deduplication - // aggregateStats does its own deduplication, but our rows are already deduplicated - let totalGainedExp = 0; - const skills: Record = {}; - - expRows.forEach(row => { - const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; - if (gainedExp > 0) { - totalGainedExp += gainedExp; - const skill = row.skill || ''; - if (skill) { - skills[skill] = (skills[skill] || 0) + gainedExp; - } - } - }); - - const periodStats = { - totalExp: totalGainedExp, - skills, - }; - - // Calculate HP used for this period (use ALL rows) - // First try to use hpUsed from fight log (food eaten during fight) - let totalHpUsed = 0; - allRows.forEach(row => { - if (row.hpUsed && row.hpUsed.trim() !== '') { - const hpUsedValue = parseInt(row.hpUsed.replace(/,/g, ''), 10); - if (!isNaN(hpUsedValue) && hpUsedValue > 0) { - totalHpUsed += hpUsedValue; - } - } - }); - - // Get totalInventoryHP for start/end (for display purposes) - const hpEntries = allRows - .filter(row => row.totalInventoryHP && row.totalInventoryHP.trim() !== '') - .map(row => { - const hpValue = parseInt(row.totalInventoryHP.replace(/,/g, ''), 10); - return { - timestamp: row.timestamp, - hp: isNaN(hpValue) ? null : hpValue, - }; - }) - .filter(entry => entry.hp !== null) - .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); - - let hpUsed: { used: number; startHP: number; endHP: number } | null = null; - if (totalHpUsed > 0) { - // Use hpUsed from fight log (food eaten during fight) - const startHP = hpEntries.length > 0 ? hpEntries[0].hp! : 0; - const endHP = hpEntries.length > 0 ? hpEntries[hpEntries.length - 1].hp! : 0; - hpUsed = { - used: totalHpUsed, - startHP, - endHP, - }; - } else if (hpEntries.length >= 2) { - // Fallback to old calculation if no hpUsed from fight log - const firstHP = hpEntries[0].hp!; - const lastHP = hpEntries[hpEntries.length - 1].hp!; - hpUsed = { - used: firstHP - lastHP, - startHP: firstHP, - endHP: lastHP, - }; - } - - // Calculate drops for this period (use ALL rows, not just exp rows) - const dropStats: Record = {}; - allRows.forEach(row => { - const drops = parseDrops(row.drops || ''); - drops.forEach(drop => { - const { amount, name } = parseDropAmount(drop); - if (!dropStats[name]) { - dropStats[name] = { count: 0, totalAmount: 0 }; - } - dropStats[name].count += 1; - dropStats[name].totalAmount += amount; - }); - }); - - const totalDrops = Object.values(dropStats).reduce((sum, stat) => sum + stat.count, 0); - const totalDropAmount = Object.values(dropStats).reduce((sum, stat) => sum + stat.totalAmount, 0); - - // Calculate total drop value using item values - let totalDropValue = 0; - Object.entries(dropStats).forEach(([name, stats]) => { - const itemValue = parseFloat(itemValues[name] || '0'); - if (!isNaN(itemValue)) { - totalDropValue += stats.totalAmount * itemValue; - } - }); - - // Calculate HP value (HP used * 2.5) - const hpValue = hpUsed ? hpUsed.used * 2.5 : 0; - - // Calculate net profit (drop value - HP value) - const netProfit = totalDropValue - hpValue; - - return { - periodKey, - date, - totalGainedExp: periodStats.totalExp, - skills: periodStats.skills, - hpUsed, - dropStats, - totalDrops, - totalDropAmount, - totalDropValue, - hpValue, - netProfit, - rows: expRows, // Keep exp rows for display - }; - }) - .sort((a, b) => b.date.getTime() - a.date.getTime()); // Most recent first - }, [allDeduplicatedData, allDeduplicatedDataWithExp, selectedPeriod, itemValues, parseDrops, parseDropAmount]); - const togglePeriod = (periodKey: string) => { setExpandedHours(prev => { const next = new Set(prev); @@ -346,9 +97,8 @@ const TrackedHistory = () => { // Export tracked data CSV await exportData('tracked', true); alert('CSV file downloaded successfully!'); - } catch (error) { + } catch { alert('Error downloading CSV file'); - console.error(error); } }; @@ -357,9 +107,8 @@ const TrackedHistory = () => { try { await clear(); alert('All tracked data cleared successfully!'); - } catch (error) { + } catch { alert('Error clearing tracked data'); - console.error(error); } } }; @@ -372,60 +121,31 @@ const TrackedHistory = () => {
- - - + label="Download CSV" + className="flex-shrink-0" + Icon={DownloadIcon} + /> + +
@@ -448,7 +168,7 @@ const TrackedHistory = () => { {Object.keys(periodStats.skills).length > 0 && (
- {Object.entries(periodStats.skills) + {(Object.entries(periodStats.skills) as [string, number][]) .sort(([, a], [, b]) => b - a) .map(([skill, exp]) => (
@@ -693,11 +413,11 @@ const TrackedHistory = () => {

{stats.totalExp.toLocaleString()}

- {stats.timeRange.start && ( + {stats.timeRange.start && stats.timeRange.end && (

Tracking from:{' '} - {new Date(stats.timeRange.start).toLocaleString(undefined, { + {stats.timeRange.start.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', @@ -706,7 +426,7 @@ const TrackedHistory = () => { hour12: true, })}{' '} to{' '} - {new Date(stats.timeRange.end).toLocaleString(undefined, { + {stats.timeRange.end.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', diff --git a/pages/side-panel/src/constants/Tabs/index.js b/pages/side-panel/src/constants/Tabs/index.js index 6644e99..16307bc 100644 --- a/pages/side-panel/src/constants/Tabs/index.js +++ b/pages/side-panel/src/constants/Tabs/index.js @@ -6,6 +6,8 @@ const DISPLAY = { ITEMS: 'ITEMS', CALCULATOR: 'CALCULATOR', HISTORY: 'HISTORY', + SETTINGS: 'SETTINGS', + DATA_VIEW: 'DATA_VIEW', }; export default DISPLAY; diff --git a/pages/side-panel/src/hooks/useGlobalDataSync.ts b/pages/side-panel/src/hooks/useGlobalDataSync.ts new file mode 100644 index 0000000..01d8182 --- /dev/null +++ b/pages/side-panel/src/hooks/useGlobalDataSync.ts @@ -0,0 +1,72 @@ +import { UPDATE_SCREEN_DATA, UPDATE_USER_STATS, getTrackedData } from '@extension/shared'; +import { TRACKED_DATA_QUERY_KEY } from '@extension/shared/lib/hooks/useTrackedDataQuery'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +/** + * Global hook to sync data updates across all components + * This should be called at the app root level to ensure message listeners + * are always active regardless of which component is currently mounted + * + * Uses optimistic cache updates instead of invalidation for better performance + */ +export const useGlobalDataSync = () => { + const queryClient = useQueryClient(); + + useEffect(() => { + // Listen for UPDATE_SCREEN_DATA messages from background script + // Instead of invalidating, we directly update the cache with fresh data + const messageListener = async (message: { type: string; data?: unknown }) => { + if (message.type === UPDATE_SCREEN_DATA) { + // Fetch fresh data and update cache directly + // This is more efficient than invalidate + refetch + try { + const freshData = await getTrackedData(); + queryClient.setQueryData(TRACKED_DATA_QUERY_KEY, freshData); + } catch { + // If fetch fails, fall back to invalidation + queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); + } + } + + if (message.type === UPDATE_USER_STATS) { + // Update user stats cache directly + try { + // User stats are passed in the message, so we can update directly + if (message.data) { + queryClient.setQueryData(['userStats'], message.data); + } + } catch { + // If update fails, fall back to invalidation + queryClient.invalidateQueries({ queryKey: ['userStats'] }); + } + } + }; + + // Storage listener as a backup mechanism + // Only triggers if message listener somehow fails + const storageListener = async (changes: Record, areaName: string) => { + if (areaName === 'local' && changes.tracked_data_csv) { + const newValue = changes.tracked_data_csv.newValue; + if (newValue && newValue.trim().length > 0) { + // Directly update cache with fresh data + try { + const freshData = await getTrackedData(); + queryClient.setQueryData(TRACKED_DATA_QUERY_KEY, freshData); + } catch { + // If fetch fails, fall back to invalidation + queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); + } + } + } + }; + + chrome.runtime.onMessage.addListener(messageListener); + chrome.storage.onChanged.addListener(storageListener); + + return () => { + chrome.runtime.onMessage.removeListener(messageListener); + chrome.storage.onChanged.removeListener(storageListener); + }; + }, [queryClient]); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6da49e9..af7406b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,9 +303,21 @@ importers: '@extension/storage': specifier: workspace:* version: link:../storage + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) clsx: specifier: ^2.1.1 version: 2.1.1 + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.1.0) recharts: specifier: ^3.6.0 version: 3.6.0(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react-is@18.3.1)(react@19.1.0)(redux@5.0.1) @@ -365,6 +377,9 @@ importers: '@extension/storage': specifier: workspace:* version: link:../../packages/storage + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@extension/hmr': specifier: workspace:* @@ -376,149 +391,8 @@ importers: specifier: workspace:* version: link:../../packages/vite-config - pages/content-runtime: - dependencies: - '@extension/env': - specifier: workspace:* - version: link:../../packages/env - '@extension/ui': - specifier: workspace:* - version: link:../../packages/ui - devDependencies: - '@extension/hmr': - specifier: workspace:* - version: link:../../packages/hmr - '@extension/shared': - specifier: workspace:* - version: link:../../packages/shared - '@extension/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - '@extension/vite-config': - specifier: workspace:* - version: link:../../packages/vite-config - - pages/content-ui: - dependencies: - '@extension/env': - specifier: workspace:* - version: link:../../packages/env - '@extension/i18n': - specifier: workspace:* - version: link:../../packages/i18n - '@extension/shared': - specifier: workspace:* - version: link:../../packages/shared - '@extension/ui': - specifier: workspace:* - version: link:../../packages/ui - devDependencies: - '@extension/hmr': - specifier: workspace:* - version: link:../../packages/hmr - '@extension/tailwindcss-config': - specifier: workspace:* - version: link:../../packages/tailwindcss-config - '@extension/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - '@extension/vite-config': - specifier: workspace:* - version: link:../../packages/vite-config - - pages/devtools: - dependencies: - '@extension/shared': - specifier: workspace:* - version: link:../../packages/shared - devDependencies: - '@extension/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - '@extension/vite-config': - specifier: workspace:* - version: link:../../packages/vite-config - - pages/devtools-panel: - dependencies: - '@extension/i18n': - specifier: workspace:* - version: link:../../packages/i18n - '@extension/shared': - specifier: workspace:* - version: link:../../packages/shared - '@extension/storage': - specifier: workspace:* - version: link:../../packages/storage - '@extension/ui': - specifier: workspace:* - version: link:../../packages/ui - devDependencies: - '@extension/tailwindcss-config': - specifier: workspace:* - version: link:../../packages/tailwindcss-config - '@extension/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - '@extension/vite-config': - specifier: workspace:* - version: link:../../packages/vite-config - - pages/options: - dependencies: - '@extension/i18n': - specifier: workspace:* - version: link:../../packages/i18n - '@extension/shared': - specifier: workspace:* - version: link:../../packages/shared - '@extension/storage': - specifier: workspace:* - version: link:../../packages/storage - '@extension/ui': - specifier: workspace:* - version: link:../../packages/ui - devDependencies: - '@extension/tailwindcss-config': - specifier: workspace:* - version: link:../../packages/tailwindcss-config - '@extension/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - '@extension/vite-config': - specifier: workspace:* - version: link:../../packages/vite-config - - pages/popup: - dependencies: - '@extension/i18n': - specifier: workspace:* - version: link:../../packages/i18n - '@extension/shared': - specifier: workspace:* - version: link:../../packages/shared - '@extension/storage': - specifier: workspace:* - version: link:../../packages/storage - '@extension/ui': - specifier: workspace:* - version: link:../../packages/ui - devDependencies: - '@extension/tailwindcss-config': - specifier: workspace:* - version: link:../../packages/tailwindcss-config - '@extension/tsconfig': - specifier: workspace:* - version: link:../../packages/tsconfig - '@extension/vite-config': - specifier: workspace:* - version: link:../../packages/vite-config - pages/side-panel: dependencies: - '@extension/i18n': - specifier: workspace:* - version: link:../../packages/i18n '@extension/shared': specifier: workspace:* version: link:../../packages/shared @@ -528,6 +402,9 @@ importers: '@extension/ui': specifier: workspace:* version: link:../../packages/ui + '@radix-ui/react-icons': + specifier: ^1.3.2 + version: 1.3.2(react@19.1.0) '@tanstack/react-query': specifier: ^5.62.0 version: 5.90.16(react@19.1.0) @@ -810,6 +687,21 @@ packages: resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1157,21 +1049,331 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.4': + resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@promptbook/utils@0.69.5': + resolution: {integrity: sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==} + + '@puppeteer/browsers@2.10.5': + resolution: {integrity: sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==} + engines: {node: '>=18'} + hasBin: true + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-icons@1.3.2': + resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} + peerDependencies: + react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@pkgr/core@0.2.4': - resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@promptbook/utils@0.69.5': - resolution: {integrity: sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==} + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@puppeteer/browsers@2.10.5': - resolution: {integrity: sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==} - engines: {node: '>=18'} - hasBin: true + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} @@ -1905,6 +2107,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2435,6 +2641,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -2953,6 +3162,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -4139,12 +4352,42 @@ packages: redux: optional: true + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-spinners@0.17.0: resolution: {integrity: sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==} peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -4806,6 +5049,26 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -4821,6 +5084,10 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -5234,6 +5501,23 @@ snapshots: '@eslint/core': 0.14.0 levn: 0.4.1 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/utils@0.2.10': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -5628,6 +5912,302 @@ snapshots: - bare-buffer - supports-color + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.5)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-context@1.1.2(@types/react@19.1.5)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-direction@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.5)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-icons@1.3.2(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@radix-ui/react-id@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.5)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.2(@types/react@19.1.5)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.5)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.2(@types/react@19.1.5)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-slot@1.2.3(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.5)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.1.5)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.5)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.5 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + '@types/react-dom': 19.1.5(@types/react@19.1.5) + + '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.1.5)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': dependencies: '@standard-schema/spec': 1.1.0 @@ -6417,6 +6997,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -7000,6 +7584,8 @@ snapshots: detect-libc@1.0.3: optional: true + detect-node-es@1.1.0: {} + didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -7724,6 +8310,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-port@7.1.0: {} get-proto@1.0.1: @@ -8894,11 +9482,38 @@ snapshots: '@types/react': 19.1.5 redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.1.5)(react@19.1.0): + dependencies: + react: 19.1.0 + react-style-singleton: 2.2.3(@types/react@19.1.5)(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.5 + + react-remove-scroll@2.7.2(@types/react@19.1.5)(react@19.1.0): + dependencies: + react: 19.1.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.1.5)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@19.1.5)(react@19.1.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.1.5)(react@19.1.0) + use-sidecar: 1.1.3(@types/react@19.1.5)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.5 + react-spinners@0.17.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + react-style-singleton@2.2.3(@types/react@19.1.5)(react@19.1.0): + dependencies: + get-nonce: 1.0.1 + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.5 + react@19.1.0: {} read-cache@1.0.0: @@ -9726,6 +10341,21 @@ snapshots: urlpattern-polyfill@10.1.0: {} + use-callback-ref@1.3.3(@types/react@19.1.5)(react@19.1.0): + dependencies: + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.5 + + use-sidecar@1.1.3(@types/react@19.1.5)(react@19.1.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.5 + use-sync-external-store@1.6.0(react@19.1.0): dependencies: react: 19.1.0 @@ -9742,6 +10372,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.19 + uuid@13.0.0: {} + v8-compile-cache-lib@3.0.1: {} validate-npm-package-license@3.0.4: From 5e878a60139f93152f1c75440ad1ff86257525ce Mon Sep 17 00:00:00 2001 From: ParkerES Date: Sat, 7 Feb 2026 13:25:43 -0500 Subject: [PATCH 02/13] Style-fix-and-clean-up-code-structure-maintainability --- .claude/settings.local.json | 10 +- CLAUDE.md | 7 + chrome-extension/manifest.ts | 4 - chrome-extension/src/background/index.ts | 371 +++++--- packages/shared/lib/hooks/index.ts | 1 + packages/shared/lib/hooks/useHourStats.ts | 112 ++- packages/shared/lib/hooks/useHourlyExp.ts | 59 +- .../shared/lib/hooks/useItemValuesQuery.ts | 11 +- packages/shared/lib/hooks/usePeriodStats.ts | 307 ++++++ packages/shared/lib/hooks/useScreenData.ts | 62 -- packages/shared/lib/hooks/useTrackedData.ts | 29 +- .../shared/lib/hooks/useTrackedDataQuery.ts | 85 +- packages/shared/lib/hooks/useUserStats.ts | 4 +- packages/shared/lib/utils/colorful-logger.ts | 34 +- packages/shared/lib/utils/csv-storage.ts | 150 +-- packages/shared/lib/utils/csv-tracker.ts | 286 +++--- packages/shared/lib/utils/exp-calculator.ts | 163 ++++ packages/shared/lib/utils/helpers.ts | 16 + packages/shared/lib/utils/index.ts | 2 + packages/shared/lib/utils/storage-service.ts | 10 +- packages/shared/lib/utils/themes.ts | 164 ++++ packages/shared/lib/utils/types.ts | 1 + .../shared/lib/utils/user-stats-storage.ts | 10 +- .../shared/lib/utils/weekly-stats-storage.ts | 12 +- packages/storage/lib/base/types.ts | 2 + .../storage/lib/impl/example-theme-storage.ts | 8 + packages/ui/global.css | 4 +- packages/ui/lib/components/ErrorDisplay.tsx | 42 + packages/ui/lib/components/IconButton.tsx | 37 + packages/ui/lib/components/ThemeToggle.tsx | 7 - packages/ui/lib/components/index.ts | 8 +- packages/ui/lib/components/ui/badge.tsx | 41 +- packages/ui/lib/components/ui/chart.tsx | 3 +- .../ui/lib/components/ui/dropdown-menu.tsx | 46 + packages/ui/lib/components/ui/input.tsx | 4 +- packages/ui/lib/components/ui/label.tsx | 20 + packages/ui/lib/components/ui/popover.tsx | 29 + packages/ui/lib/components/ui/select.tsx | 24 + packages/ui/lib/components/ui/switch.tsx | 10 +- packages/ui/lib/components/ui/tooltip.tsx | 27 + packages/ui/lib/with-ui.ts | 3 +- packages/ui/package.json | 4 + pages/content/package.json | 3 +- pages/content/src/matches/all/index.ts | 4 - .../src/matches/all/scrapeScreenData/index.ts | 2 - .../content/src/matches/all/sendData/index.ts | 2 - pages/content/src/matches/stats/index.ts | 154 +-- pages/content/src/scrapeScreenData.ts | 139 +-- pages/content/src/scrapeUserStats.ts | 3 +- pages/content/src/sendData.ts | 576 +++--------- pages/side-panel/package.json | 1 + pages/side-panel/src/SidePanel.tsx | 50 +- pages/side-panel/src/assets/icons/index.tsx | 212 +++++ .../Dashboard/ExpChart/ChartHeader.tsx | 77 +- .../Dashboard/ExpChart/ChartVisualization.tsx | 2 +- .../ExpChart/HeatmapVisualization.tsx | 186 ++++ .../Dashboard/ExpChart/SimpleExpChart.tsx | 120 +++ .../Dashboard/ExpChart/SkillPills.tsx | 1 - .../ExpChart/charts/HeatmapChart.tsx | 115 +++ .../Dashboard/ExpChart/charts/LineChart.tsx | 32 +- .../Dashboard/ExpChart/constants.ts | 18 +- .../components/Dashboard/ExpChart/index.tsx | 322 +------ .../components/Dashboard/ExpChart/types.ts | 2 +- .../Dashboard/ExpChart/useChartData.ts | 130 +-- .../Dashboard/ExpChart/useExpChart.ts | 29 + .../components/Dashboard/ExpChart/utils.ts | 4 +- .../src/components/Dashboard/index.tsx | 880 +++--------------- .../src/components/Dashboard/useDashboard.ts | 250 +++++ .../src/components/DataView/index.tsx | 350 +++++++ .../src/components/DataView/useDataView.ts | 59 ++ .../src/components/Header/index.tsx | 178 +++- .../src/components/LootMap/LootGrid/index.tsx | 181 ++++ .../components/LootMap/LootTable/index.tsx | 173 ++++ .../components/LootMap/helpers/lootHelpers.ts | 102 ++ .../src/components/LootMap/index.tsx | 607 ++++-------- .../src/components/LootMap/useLootMap.ts | 301 ++++++ .../Performance/EquipmentDisplay/index.tsx | 167 ++++ .../EquipmentDisplay/useEquipmentDisplay.ts | 244 +++++ .../Performance/PerformanceCard/index.tsx | 34 + .../PerformanceCard/usePerformanceCard.ts | 14 + .../Performance/StatTable/index.tsx | 56 ++ .../components/Performance/StatTable/types.ts | 7 + .../Performance/StatTable/useStatTable.ts | 38 + .../src/components/Performance/index.tsx | 312 +++++++ .../components/Performance/statRowHelpers.ts | 74 ++ .../components/Performance/usePerformance.ts | 837 +++++++++++++++++ .../src/components/Profile/index.tsx | 7 +- pages/side-panel/tailwind.config.ts | 1 + 88 files changed, 6416 insertions(+), 2869 deletions(-) create mode 100644 CLAUDE.md create mode 100644 packages/shared/lib/hooks/usePeriodStats.ts create mode 100644 packages/shared/lib/utils/exp-calculator.ts create mode 100644 packages/shared/lib/utils/themes.ts create mode 100644 packages/ui/lib/components/ErrorDisplay.tsx create mode 100644 packages/ui/lib/components/IconButton.tsx create mode 100644 packages/ui/lib/components/ui/dropdown-menu.tsx create mode 100644 packages/ui/lib/components/ui/label.tsx create mode 100644 packages/ui/lib/components/ui/popover.tsx create mode 100644 packages/ui/lib/components/ui/select.tsx create mode 100644 packages/ui/lib/components/ui/tooltip.tsx create mode 100644 pages/side-panel/src/assets/icons/index.tsx create mode 100644 pages/side-panel/src/components/Dashboard/ExpChart/HeatmapVisualization.tsx create mode 100644 pages/side-panel/src/components/Dashboard/ExpChart/SimpleExpChart.tsx create mode 100644 pages/side-panel/src/components/Dashboard/ExpChart/charts/HeatmapChart.tsx create mode 100644 pages/side-panel/src/components/Dashboard/ExpChart/useExpChart.ts create mode 100644 pages/side-panel/src/components/Dashboard/useDashboard.ts create mode 100644 pages/side-panel/src/components/DataView/index.tsx create mode 100644 pages/side-panel/src/components/DataView/useDataView.ts create mode 100644 pages/side-panel/src/components/LootMap/LootGrid/index.tsx create mode 100644 pages/side-panel/src/components/LootMap/LootTable/index.tsx create mode 100644 pages/side-panel/src/components/LootMap/helpers/lootHelpers.ts create mode 100644 pages/side-panel/src/components/LootMap/useLootMap.ts create mode 100644 pages/side-panel/src/components/Performance/EquipmentDisplay/index.tsx create mode 100644 pages/side-panel/src/components/Performance/EquipmentDisplay/useEquipmentDisplay.ts create mode 100644 pages/side-panel/src/components/Performance/PerformanceCard/index.tsx create mode 100644 pages/side-panel/src/components/Performance/PerformanceCard/usePerformanceCard.ts create mode 100644 pages/side-panel/src/components/Performance/StatTable/index.tsx create mode 100644 pages/side-panel/src/components/Performance/StatTable/types.ts create mode 100644 pages/side-panel/src/components/Performance/StatTable/useStatTable.ts create mode 100644 pages/side-panel/src/components/Performance/index.tsx create mode 100644 pages/side-panel/src/components/Performance/statRowHelpers.ts create mode 100644 pages/side-panel/src/components/Performance/usePerformance.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a838d64..aedbb7a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,15 @@ "Bash(npm run dev:*)", "Bash(node --version:*)", "Bash(npm --version)", - "Bash(npx prettier:*)" + "Bash(npx prettier:*)", + "Bash(pnpm lint:*)", + "Bash(pnpm lint:fix:*)", + "Bash(pnpm --filter chrome-extension lint:*)", + "Bash(pnpm --filter @extension/content-script lint:*)", + "Bash(pnpm --filter @extension/ui lint:*)", + "Bash(pnpm --filter @extension/shared lint:*)", + "Bash(pnpm --filter @extension/shared lint:fix:*)", + "Bash(pnpm --filter @extension/sidepanel lint:fix:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..861b5af --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +## After Every Code Change + +1. **Lint check**: Run `pnpm lint` to check for lint errors. Fix all errors before considering the task complete. +2. **Format**: Run `pnpm format` to format all modified files with Prettier. +3. **Verify**: Run `pnpm lint` once more to confirm zero errors remain. diff --git a/chrome-extension/manifest.ts b/chrome-extension/manifest.ts index e481c08..7ad8231 100644 --- a/chrome-extension/manifest.ts +++ b/chrome-extension/manifest.ts @@ -48,10 +48,6 @@ const manifest = { matches: ['http://*/*', 'https://*/*'], css: ['content.css'], }, - { - matches: ['http://*/*', 'https://*/*', ''], - js: ['refresh.js'], - }, { matches: ['https://www.syrnia.com/theGame/includes2/stats.php*'], js: ['content/stats.iife.js'], diff --git a/chrome-extension/src/background/index.ts b/chrome-extension/src/background/index.ts index 76a48b2..e21a7e1 100644 --- a/chrome-extension/src/background/index.ts +++ b/chrome-extension/src/background/index.ts @@ -9,18 +9,37 @@ import { } from '@extension/shared/lib/utils/storage-service'; import { updateWeeklyStatsFromStatsURL } from '@extension/shared/lib/utils/weekly-stats-storage'; import type { CSVRow } from '@extension/shared/lib/utils/csv-tracker'; +import type { ScreenData } from '@extension/shared/lib/utils/types'; + +chrome.runtime.onMessage.addListener((message, sender) => { + // Only process messages from content scripts (sender.tab exists) + // Ignore messages from background script itself or side panel + if (!sender || !sender.tab) { + return false; + } -chrome.runtime.onMessage.addListener(message => { if (message.type === UPDATE_SCREEN_DATA) { // Process and save screen data to CSV storage // This is the PRIMARY source for current hour exp tracking // Stats page data does NOT interfere with this - they are separate systems - processScreenData(message.data).catch(error => { - console.error('[Background] Error processing screen data:', error); - }); - - // Forward data to the side panel for real-time updates - chrome.runtime.sendMessage({ type: UPDATE_SCREEN_DATA, data: message.data }); + processScreenData(message.data as ScreenData) + .then(dataSaved => { + // Only forward data to the side panel AFTER successfully saving unique scrape data + // This ensures the React Query cache is updated only when data is actually persisted + if (dataSaved) { + // Forward to side panel - use a small delay to ensure storage is updated + setTimeout(() => { + chrome.runtime.sendMessage({ type: UPDATE_SCREEN_DATA, data: message.data }).catch(() => { + // Silently handle errors (side panel might not be open) + }); + }, 50); + } + }) + .catch(() => { + // Silently handle errors + }); + // Return false since we're handling this asynchronously + return false; } else if (message.type === UPDATE_USER_STATS) { // Only save if we have valid data (username and at least one skill) // This comes from the stats page and is used for: @@ -32,21 +51,27 @@ chrome.runtime.onMessage.addListener(message => { // The tracked current hour exp is calculated independently from screen scraping if (message.data && message.data.username && message.data.skills && Object.keys(message.data.skills).length > 0) { // Save user stats (source of truth for profile/weekly data, NOT for tracked current hour) - saveUserStats(message.data).catch(error => { - console.error('Error saving user stats:', error); + saveUserStats(message.data).catch(() => { + // Silently handle errors }); // Update weekly stats from stats URL (source of truth for weekly totals) // This uses gainedThisWeek from stats page, but doesn't affect tracked current hour getTrackedData() .then(allRows => updateWeeklyStatsFromStatsURL(message.data, allRows)) - .catch(error => { - console.error('Error updating weekly stats from stats URL:', error); + .catch(() => { + // Silently handle errors }); // Forward data to the side panel for real-time updates - chrome.runtime.sendMessage({ type: UPDATE_USER_STATS, data: message.data }); + setTimeout(() => { + chrome.runtime.sendMessage({ type: UPDATE_USER_STATS, data: message.data }).catch(() => { + // Silently handle errors (side panel might not be open) + }); + }, 50); } + // Return false since we're handling this asynchronously + return false; } else if (message.type === REQUEST_SCREEN_DATA) { // Request data from the content script chrome.tabs.query({ active: true, currentWindow: true }, tabs => { @@ -54,6 +79,7 @@ chrome.runtime.onMessage.addListener(message => { chrome.tabs.sendMessage(tabs[0].id, { type: REQUEST_SCREEN_DATA }); } }); + return false; } // Return false since we're not using sendResponse @@ -66,34 +92,46 @@ chrome.runtime.onMessage.addListener(message => { * IMPORTANT: This function ONLY processes screen data. Stats page data does NOT * interfere with tracked exp calculations. The lastExpBySkill is ONLY updated * from screen data to ensure accurate delta calculations for current hour tracking. + * + * @returns true if data was successfully saved, false otherwise */ -const processScreenData = async (data: Record): Promise => { - try { - // Get last exp per skill for calculating deltas - // This is ONLY updated from screen data, never from stats page - const lastExpBySkill = await getLastExpBySkill(); - - // Get all rows (main + combat exp gains) - const rows = screenDataToCSVRows(data); - - // Get the main skill's exp from screen data - const mainSkillExp = parseInt(data.actionText.exp || '0', 10) || 0; - const mainSkill = data.actionText.currentActionText || ''; - - // Calculate gainedExp for each row - const rowsWithGainedExp = rows.map(row => { - // Combat exp entries already have gainedExp set (these are direct gains) - if (row.gainedExp) { - return row; - } +const processScreenData = async (data: ScreenData): Promise => { + // Get last exp per skill for calculating deltas + // This is ONLY updated from screen data, never from stats page + const lastExpBySkill = await getLastExpBySkill(); - // For main skill entries, calculate gainedExp from exp delta - // This tracks the change in total exp since last screen update - if (row.skill === mainSkill) { - let gainedExp = '0'; + // Get all rows (main + combat exp gains) + const rows = screenDataToCSVRows(data); - if (mainSkillExp > 0) { - const lastExp = lastExpBySkill[mainSkill] || 0; + // Get the main skill's exp from screen data + const mainSkillExp = parseInt(data.actionText.exp || '0', 10) || 0; + const mainSkill = data.actionText.currentActionText || ''; + + // Calculate gainedExp for each row + const rowsWithGainedExp = rows.map(row => { + // If gainedExp is already set, use it + if (row.gainedExp) { + return row; + } + + // For main skill entries, calculate gainedExp from exp delta + // This tracks the change in total exp since last screen update + if (row.skill === mainSkill) { + let gainedExp = '0'; + + if (mainSkillExp > 0) { + const lastExp = lastExpBySkill[mainSkill] || 0; + + // CRITICAL: If this is the first time seeing this skill (lastExp === 0), + // initialize but don't count the total exp as gained exp + // This prevents showing total exp instead of gained exp on first scrape + if (lastExp === 0) { + // First time seeing this skill - initialize but don't count as gain + // This prevents huge deltas on first screen update + lastExpBySkill[mainSkill] = mainSkillExp; + gainedExp = '0'; // Don't count total exp as gained exp on first scrape + } else { + // Calculate delta for subsequent scrapes const delta = mainSkillExp - lastExp; // Only record positive deltas (exp gains) @@ -110,138 +148,167 @@ const processScreenData = async (data: Record): Promise = // IMPORTANT: This keeps tracked current hour exp independent from stats page if (delta > 0) { lastExpBySkill[mainSkill] = mainSkillExp; - } else if (lastExp === 0 && mainSkillExp > 0) { - // First time seeing this skill - initialize but don't count as gain - // This prevents huge deltas on first screen update - lastExpBySkill[mainSkill] = mainSkillExp; } } - - return { - ...row, - gainedExp, - }; } - // If we can't calculate gainedExp, set to 0 return { ...row, - gainedExp: '0', + gainedExp, }; - }); + } + + // If we can't calculate gainedExp, set to 0 + return { + ...row, + gainedExp: '0', + }; + }); + + // Save updated last exp per skill (ONLY if we had valid gains) + // This ensures stats page refreshes don't interfere with tracking + await saveLastExpBySkill(lastExpBySkill); - // Save updated last exp per skill (ONLY if we had valid gains) - // This ensures stats page refreshes don't interfere with tracking - await saveLastExpBySkill(lastExpBySkill); - - // CRITICAL: Deduplicate rows before saving to prevent counting the same exp multiple times - // Group by skill + gainedExp + rounded timestamp (to nearest second) to catch rapid scrapes - const deduplicatedRows = new Map(); - - rowsWithGainedExp.forEach(row => { - const skill = row.skill || ''; - const gainedExp = row.gainedExp || '0'; - const timestamp = new Date(row.timestamp); - // Round timestamp to nearest second to group rapid scrapes together - const roundedTimestamp = new Date( - timestamp.getFullYear(), - timestamp.getMonth(), - timestamp.getDate(), - timestamp.getHours(), - timestamp.getMinutes(), - timestamp.getSeconds(), - ); - const roundedTimestampStr = roundedTimestamp.toISOString(); - - // For combat exp (already has gainedExp set), use skill + gainedExp + rounded timestamp - // For main skill (calculated delta), use skill + rounded timestamp (only one per second) - const key = - gainedExp && parseInt(gainedExp, 10) > 0 - ? `${roundedTimestampStr}-${skill}-${gainedExp}` // Combat exp: include exp value - : `${roundedTimestampStr}-${skill}`; // Main skill: one per second - - const existing = deduplicatedRows.get(key); - - if (!existing) { - // No existing entry, add this one - deduplicatedRows.set(key, row); + // CRITICAL: Deduplicate rows before saving to prevent counting the same exp multiple times + // When a fight ends, all data is available at once - we should only process it once + // Use UUID as primary deduplication key since each screen scrape has a unique UUID + // All rows from the same screen scrape share the same UUID, so group by UUID + skill + const deduplicatedRows = new Map(); + + rowsWithGainedExp.forEach(row => { + const skill = row.skill || ''; + const uuid = row.uuid || ''; + const gainedExp = row.gainedExp || '0'; + const monster = row.monster || ''; + const timestamp = new Date(row.timestamp); + // Round timestamp to nearest second to group rapid scrapes together + const roundedTimestamp = new Date( + timestamp.getFullYear(), + timestamp.getMonth(), + timestamp.getDate(), + timestamp.getHours(), + timestamp.getMinutes(), + timestamp.getSeconds(), + ); + const roundedTimestampStr = roundedTimestamp.toISOString(); + + // Primary deduplication: Use UUID + skill if UUID is available (new format) + // This ensures all rows from the same screen scrape are properly grouped + // Fallback: Use timestamp + monster + skill + gainedExp for old format rows without UUID + const key = uuid + ? `${uuid}-${skill}` // New format: UUID + skill (most reliable) + : monster && (row.totalFights === '1' || parseInt(gainedExp, 10) > 0) + ? gainedExp && parseInt(gainedExp, 10) > 0 + ? `${roundedTimestampStr}-${monster}-${skill}-${gainedExp}` // Fight end combat exp: include monster + : `${roundedTimestampStr}-${monster}-${skill}` // Fight end main skill: include monster + : gainedExp && parseInt(gainedExp, 10) > 0 + ? `${roundedTimestampStr}-${skill}-${gainedExp}` // Non-fight combat exp: include exp value + : `${roundedTimestampStr}-${skill}`; // Non-fight main skill: one per second + + const existing = deduplicatedRows.get(key); + + if (!existing) { + // No existing entry, add this one + deduplicatedRows.set(key, row); + } else { + // Entry exists - merge data, ensuring totalFights is only counted once + const existingHasData = + (existing.drops && existing.drops.trim()) || + (existing.damageDealt && existing.damageDealt.trim()) || + (existing.damageReceived && existing.damageReceived.trim()); + const currentHasData = + (row.drops && row.drops.trim()) || + (row.damageDealt && row.damageDealt.trim()) || + (row.damageReceived && row.damageReceived.trim()); + + // Determine which row to keep (prefer one with more complete data) + // Also preserve location and monster from the row with more complete data + let rowToKeep = existing; + if (currentHasData && !existingHasData) { + rowToKeep = row; + } else if (!currentHasData && existingHasData) { + rowToKeep = existing; } else { - // Entry exists - merge data, ensuring totalFights is only counted once - const existingHasData = - (existing.drops && existing.drops.trim()) || - (existing.damageDealt && existing.damageDealt.trim()) || - (existing.damageReceived && existing.damageReceived.trim()); - const currentHasData = - (row.drops && row.drops.trim()) || - (row.damageDealt && row.damageDealt.trim()) || - (row.damageReceived && row.damageReceived.trim()); - - // Determine which row to keep (prefer one with more complete data) - let rowToKeep = existing; - if (currentHasData && !existingHasData) { + // Both have data or neither has data - keep the one with higher gainedExp + const existingExp = parseInt(existing.gainedExp || '0', 10); + const currentExp = parseInt(row.gainedExp || '0', 10); + if (currentExp > existingExp) { rowToKeep = row; - } else if (!currentHasData && existingHasData) { - rowToKeep = existing; - } else { - // Both have data or neither has data - keep the one with higher gainedExp - const existingExp = parseInt(existing.gainedExp || '0', 10); - const currentExp = parseInt(row.gainedExp || '0', 10); - if (currentExp > existingExp) { - rowToKeep = row; - } } + } - // Merge totalFights: if either row has totalFights, keep it, but only count it once - // If both have totalFights, only keep it in the merged row (don't double count) - const existingFights = parseInt(existing.totalFights || '0', 10) || 0; - const currentFights = parseInt(row.totalFights || '0', 10) || 0; - const mergedFights = existingFights > 0 || currentFights > 0 ? '1' : ''; + // Preserve location and monster from the row with more complete data + // If rowToKeep doesn't have location/monster but the other row does, use the other row's values + const locationToKeep = rowToKeep.location?.trim() || row.location?.trim() || existing.location?.trim() || ''; + const monsterToKeep = rowToKeep.monster?.trim() || row.monster?.trim() || existing.monster?.trim() || ''; - // Create merged row with deduplicated totalFights - const mergedRow: CSVRow = { - ...rowToKeep, - totalFights: mergedFights, - }; + // Merge totalFights: if either row has totalFights, keep it, but only count it once + // If both have totalFights, only keep it in the merged row (don't double count) + const existingFights = parseInt(existing.totalFights || '0', 10) || 0; + const currentFights = parseInt(row.totalFights || '0', 10) || 0; + const mergedFights = existingFights > 0 || currentFights > 0 ? '1' : ''; - deduplicatedRows.set(key, mergedRow); - } - }); + // Merge drops from both rows to preserve all drop data + const existingDrops = existing.drops || ''; + const currentDrops = row.drops || ''; + const mergedDrops = [existingDrops, currentDrops].filter(d => d && d.trim() !== '').join(';'); - const uniqueRows = Array.from(deduplicatedRows.values()); - - // Filter out rows that have no useful data (no exp, no drops, no HP, no damage) - // But keep rows that have ANY data (exp, drops, HP, damage, location, monster) - const rowsToSave = uniqueRows.filter(row => { - const hasExp = parseInt(row.gainedExp || '0', 10) > 0; - const hasDrops = row.drops && row.drops.trim() !== ''; - const hasHP = row.hp && row.hp.trim() !== ''; - const hasDamage = - (row.damageDealt && row.damageDealt.trim() !== '') || (row.damageReceived && row.damageReceived.trim() !== ''); - const hasLocation = row.location && row.location.trim() !== ''; - const hasMonster = row.monster && row.monster.trim() !== ''; - const hasSkill = row.skill && row.skill.trim() !== ''; - - // Save if it has ANY useful data - return hasExp || hasDrops || hasHP || hasDamage || hasLocation || hasMonster || hasSkill; - }); + // Create merged row with deduplicated totalFights, merged drops, and preserved location/monster + const mergedRow: CSVRow = { + ...rowToKeep, + totalFights: mergedFights, + drops: mergedDrops, + location: locationToKeep, + monster: monsterToKeep, + }; - // Append to tracked data - // This is the source of truth for current hour exp tracking - // We save ALL rows with useful data, not just exp gains - if (rowsToSave.length > 0) { - await appendTrackedData(rowsToSave); + deduplicatedRows.set(key, mergedRow); } + }); - // Update weekly stats after saving - // Note: Weekly stats use stats page as source of truth, but this doesn't - // affect the tracked current hour data which comes from screen scraping - const allRows = await getTrackedData(); - const { updateWeeklyStats } = await import('@extension/shared/lib/utils/weekly-stats-storage'); - await updateWeeklyStats(allRows).catch(error => { - console.error('Error updating weekly stats:', error); - }); - } catch (error) { - console.error('Error in processScreenData:', error); - throw error; + const uniqueRows = Array.from(deduplicatedRows.values()); + + // Only save rows that have meaningful complete data + // A row is considered complete if it has: + // - A skill name (indicates actual activity) + // - AND at least one of: exp gain, damage, equipment, location+monster (fight data) + // This prevents saving empty/incomplete scrapes + const rowsToSave = uniqueRows.filter(row => { + const hasSkill = row.skill && row.skill.trim() !== ''; + if (!hasSkill) { + // No skill = incomplete scrape, don't save + return false; + } + + // If it has a skill, check if it has meaningful data + const hasExp = parseInt(row.gainedExp || '0', 10) > 0; + const hasDrops = row.drops && row.drops.trim() !== ''; + const hasDamage = + (row.damageDealt && row.damageDealt.trim() !== '') || (row.damageReceived && row.damageReceived.trim() !== ''); + const hasEquipment = row.equipment && row.equipment.trim() !== ''; + const hasLocationAndMonster = + row.location && row.location.trim() !== '' && row.monster && row.monster.trim() !== ''; + const hasTotalFights = row.totalFights && row.totalFights.trim() !== '' && parseInt(row.totalFights, 10) > 0; + + // Save if it has skill AND meaningful data + return hasExp || hasDrops || hasDamage || hasEquipment || hasLocationAndMonster || hasTotalFights; + }); + + // Append to tracked data + let dataSaved = false; + if (rowsToSave.length > 0) { + await appendTrackedData(rowsToSave); + dataSaved = true; } + + // Update weekly stats after saving + // Note: Weekly stats use stats page as source of truth, but this doesn't + // affect the tracked current hour data which comes from screen scraping + const allRows = await getTrackedData(); + const { updateWeeklyStats } = await import('@extension/shared/lib/utils/weekly-stats-storage'); + await updateWeeklyStats(allRows).catch(() => { + // Silently handle errors + }); + + return dataSaved; }; diff --git a/packages/shared/lib/hooks/index.ts b/packages/shared/lib/hooks/index.ts index c1e07ac..b776840 100644 --- a/packages/shared/lib/hooks/index.ts +++ b/packages/shared/lib/hooks/index.ts @@ -10,3 +10,4 @@ export * from './useUserStatsQuery.js'; export * from './useWeeklyStatsQuery.js'; export * from './useDataExport.js'; export * from './useItemValuesQuery.js'; +export * from './usePeriodStats.js'; diff --git a/packages/shared/lib/hooks/useHourStats.ts b/packages/shared/lib/hooks/useHourStats.ts index 8716865..f36a248 100644 --- a/packages/shared/lib/hooks/useHourStats.ts +++ b/packages/shared/lib/hooks/useHourStats.ts @@ -1,17 +1,31 @@ +import { useItemValuesQuery } from './useItemValuesQuery.js'; import { useTrackedDataQuery } from './useTrackedDataQuery.js'; import { parseDrops, parseDropAmount } from '../utils/formatting.js'; import { useMemo } from 'react'; import type { CSVRow } from '../utils/csv-tracker.js'; +import type { CombatExpGain } from '../utils/types.js'; export interface HourStats { totalExp: number; expBySkill: Record; dropStats: Record; + lootItems: HourLootItem[]; + totalDropValue: number; + hpValue: number; + netProfit: number; hpUsed: { used: number; startHP: number; endHP: number } | null; averageHitByLocation: Record; totalFights: number; } +export interface HourLootItem { + name: string; + imageUrl: string; + quantity: number; + valuePerItem: number; + totalValue: number; +} + /** * Hook to calculate statistics for a specific hour * @@ -25,6 +39,7 @@ export interface HourStats { export const useHourStats = (hour: number, date?: Date): HourStats => { // dataByHour comes from tracked_data_csv via useTrackedDataQuery const { dataByHour } = useTrackedDataQuery(); + const { itemValues } = useItemValuesQuery(); return useMemo(() => { if (!dataByHour) { @@ -32,6 +47,10 @@ export const useHourStats = (hour: number, date?: Date): HourStats => { totalExp: 0, expBySkill: {}, dropStats: {}, + lootItems: [], + totalDropValue: 0, + hpValue: 0, + netProfit: 0, hpUsed: null, averageHitByLocation: {}, totalFights: 0, @@ -39,6 +58,19 @@ export const useHourStats = (hour: number, date?: Date): HourStats => { } try { + const isValidDrop = (drop: string, name: string) => { + const trimmedDrop = drop.trim(); + if (!trimmedDrop) return false; + if (/^[\d,]+$/.test(trimmedDrop)) return false; + const trimmedName = name?.trim() || ''; + if (!trimmedName) return false; + const lower = trimmedName.toLowerCase(); + if (lower.includes('experience') || lower.includes('exp ') || /^\d+\s*exp$/i.test(trimmedName)) { + return false; + } + return true; + }; + const now = date || new Date(); const hourData = dataByHour(hour, now); @@ -52,20 +84,24 @@ export const useHourStats = (hour: number, date?: Date): HourStats => { const dropStats: Record = {}; const averageHitByLocation: Record = {}; - // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) - // This matches the logic in aggregateStats used by the history tab + // Deduplicate entries by UUID+skill to prevent counting the same fight multiple times + // Each unique fight should be counted once, but sum all gainedExp values const uniqueEntriesMap = new Map(); sortedData.forEach(row => { const skill = row.skill || ''; - const key = `${row.timestamp}-${skill}`; + const uuid = row.uuid || ''; + + // Use UUID+skill as key if UUID exists (prevents counting same fight twice) + // Otherwise use timestamp+skill (fallback for old data) + const key = uuid ? `${uuid}-${skill}` : `${row.timestamp}-${skill}`; const existing = uniqueEntriesMap.get(key); if (!existing) { + // First time seeing this fight - add it uniqueEntriesMap.set(key, row); } else { - // Merge data from both rows to preserve all information - // Keep the one with higher gainedExp, but merge drops, HP, damage, etc. + // Same fight (same UUID) - merge data but keep the one with higher gainedExp const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; @@ -117,13 +153,35 @@ export const useHourStats = (hour: number, date?: Date): HourStats => { uniqueEntries.forEach(row => { // Calculate exp - only count entries with gainedExp > 0 const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; + const rowSkill = row.skill || ''; if (gainedExp > 0) { totalExp += gainedExp; - const skill = row.skill || ''; - if (skill) { - expBySkill[skill] = (expBySkill[skill] || 0) + gainedExp; + if (rowSkill) { + expBySkill[rowSkill] = (expBySkill[rowSkill] || 0) + gainedExp; + } + } + + // Parse and add secondary exp (combatExp) from the row + // IMPORTANT: Skip the main skill if it appears in combatExp, since its exp + // is already calculated from total exp delta and stored in gainedExp + if (row.combatExp && row.combatExp.trim() !== '') { + try { + const combatExpGains: CombatExpGain[] = JSON.parse(row.combatExp); + if (Array.isArray(combatExpGains)) { + combatExpGains.forEach((gain: CombatExpGain) => { + const combatSkill = gain.skill || ''; + const exp = parseInt(gain.exp || '0', 10) || 0; + // Skip if this is the main skill - its exp is already in gainedExp (calculated from total exp delta) + if (combatSkill && exp > 0 && combatSkill !== rowSkill) { + totalExp += exp; + expBySkill[combatSkill] = (expBySkill[combatSkill] || 0) + exp; + } + }); + } + } catch { + // Silently handle JSON parse errors } } @@ -131,6 +189,9 @@ export const useHourStats = (hour: number, date?: Date): HourStats => { const drops = parseDrops(row.drops || ''); drops.forEach(drop => { const { amount, name } = parseDropAmount(drop); + if (!isValidDrop(drop, name)) { + return; + } if (!dropStats[name]) { dropStats[name] = { count: 0, totalAmount: 0 }; } @@ -241,26 +302,55 @@ export const useHourStats = (hour: number, date?: Date): HourStats => { } }); + const lootItems = Object.entries(dropStats) + .map(([name, stats]) => { + const valuePerItem = parseFloat(itemValues[name] || '0') || 0; + const totalValue = stats.totalAmount * valuePerItem; + const imageUrl = `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, '%20')}.png`; + return { + name, + imageUrl, + quantity: stats.totalAmount, + valuePerItem, + totalValue, + }; + }) + .sort((a, b) => { + if (b.totalValue !== a.totalValue) return b.totalValue - a.totalValue; + return a.name.localeCompare(b.name); + }); + + const totalDropValue = lootItems.reduce((sum, item) => sum + item.totalValue, 0); + const hpValue = hpUsed ? hpUsed.used * 2.5 : 0; + const netProfit = totalDropValue - hpValue; + const result = { totalExp, expBySkill, dropStats, + lootItems, + totalDropValue, + hpValue, + netProfit, hpUsed, averageHitByLocation: avgHits, totalFights, }; return result; - } catch (error) { - console.error('[useHourStats] Error calculating hour stats:', error); + } catch { return { totalExp: 0, expBySkill: {}, dropStats: {}, + lootItems: [], + totalDropValue: 0, + hpValue: 0, + netProfit: 0, hpUsed: null, averageHitByLocation: {}, totalFights: 0, }; } - }, [dataByHour, hour, date]); + }, [dataByHour, hour, date, itemValues]); }; diff --git a/packages/shared/lib/hooks/useHourlyExp.ts b/packages/shared/lib/hooks/useHourlyExp.ts index 8bf109d..443b24b 100644 --- a/packages/shared/lib/hooks/useHourlyExp.ts +++ b/packages/shared/lib/hooks/useHourlyExp.ts @@ -1,7 +1,7 @@ import { useTrackedDataQuery } from './useTrackedDataQuery.js'; import { filterByHour } from '../utils/csv-tracker.js'; import { useState, useEffect, useCallback } from 'react'; -import type { CSVRow } from '../utils/csv-tracker.js'; +import type { CombatExpGain } from '../utils/types.js'; export interface HourlyExpStats { totalExpThisHour: number; @@ -36,39 +36,16 @@ export const useHourlyExp = (): HourlyExpStats => { (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); - // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) - // This matches the logic in useHourStats to ensure consistent values - const uniqueEntriesMap = new Map(); - - currentHourRows.forEach(row => { - const skill = row.skill || ''; - const key = `${row.timestamp}-${skill}`; - const existing = uniqueEntriesMap.get(key); - - if (!existing) { - uniqueEntriesMap.set(key, row); - } else { - // Keep the one with higher gainedExp or more complete data - const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; - const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; - if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { - uniqueEntriesMap.set(key, row); - } - } - }); - - // Process unique entries only - const uniqueEntries = Array.from(uniqueEntriesMap.values()); - - // Use saved gainedExp directly (it's already calculated and saved) + // Sum all gainedExp values for each skill in the current hour + // This directly sums all tracked gainedExp from storage for the current hour let totalGainedExp = 0; const expBySkill: Record = {}; - uniqueEntries.forEach(row => { + currentHourRows.forEach(row => { const skill = row.skill || ''; const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; - // Only count entries with gainedExp > 0 + // Sum all gainedExp values for each skill if (gainedExp > 0) { totalGainedExp += gainedExp; @@ -76,6 +53,28 @@ export const useHourlyExp = (): HourlyExpStats => { expBySkill[skill] = (expBySkill[skill] || 0) + gainedExp; } } + + // Parse and add secondary exp (combatExp) from the row + // IMPORTANT: Skip the main skill if it appears in combatExp, since its exp + // is already calculated from total exp delta and stored in gainedExp + if (row.combatExp && row.combatExp.trim() !== '') { + try { + const combatExpGains: CombatExpGain[] = JSON.parse(row.combatExp); + if (Array.isArray(combatExpGains)) { + combatExpGains.forEach((gain: CombatExpGain) => { + const combatSkill = gain.skill || ''; + const combatExp = parseInt(gain.exp || '0', 10) || 0; + // Skip if this is the main skill - its exp is already in gainedExp (calculated from total exp delta) + if (combatSkill && combatExp > 0 && combatSkill !== skill) { + totalGainedExp += combatExp; + expBySkill[combatSkill] = (expBySkill[combatSkill] || 0) + combatExp; + } + }); + } + } catch { + // Silently handle JSON parse errors + } + } }); setStats({ @@ -83,8 +82,8 @@ export const useHourlyExp = (): HourlyExpStats => { expBySkill, currentHour, }); - } catch (error) { - console.error('Error calculating hourly exp:', error); + } catch { + // Silently handle errors } }, [allData]); diff --git a/packages/shared/lib/hooks/useItemValuesQuery.ts b/packages/shared/lib/hooks/useItemValuesQuery.ts index 2869f5a..6c54adb 100644 --- a/packages/shared/lib/hooks/useItemValuesQuery.ts +++ b/packages/shared/lib/hooks/useItemValuesQuery.ts @@ -24,8 +24,7 @@ export const useItemValuesQuery = () => { try { const values = await getItemValues(); return values; - } catch (err) { - console.error('[useItemValuesQuery] Error loading item values:', err); + } catch { return {}; } }, @@ -40,7 +39,6 @@ export const useItemValuesQuery = () => { useEffect(() => { const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { if (areaName === 'local' && changes.drop_gp_values) { - console.log('[useItemValuesQuery] drop_gp_values storage changed'); queryClient.invalidateQueries({ queryKey: ITEM_VALUES_QUERY_KEY }); } }; @@ -67,12 +65,7 @@ export const useItemValuesQuery = () => { // Save function const save = async (values: Record) => { - try { - await saveMutation.mutateAsync(values); - } catch (error) { - console.error('Error saving item values:', error); - throw error; - } + await saveMutation.mutateAsync(values); }; return { diff --git a/packages/shared/lib/hooks/usePeriodStats.ts b/packages/shared/lib/hooks/usePeriodStats.ts new file mode 100644 index 0000000..d48e904 --- /dev/null +++ b/packages/shared/lib/hooks/usePeriodStats.ts @@ -0,0 +1,307 @@ +import { useFormatting } from './useFormatting.js'; +import { useItemValuesQuery } from './useItemValuesQuery.js'; +import { useTrackedDataQuery } from './useTrackedDataQuery.js'; +import { useMemo, useState } from 'react'; +import type { CSVRow, TimePeriod } from '../utils/csv-tracker.js'; +import type { CombatExpGain } from '../utils/types.js'; + +export interface PeriodStats { + periodKey: string; + date: Date; + totalGainedExp: number; + skills: Record; + hpUsed: { used: number; startHP: number; endHP: number } | null; + dropStats: Record; + lootItems: PeriodLootItem[]; + totalDrops: number; + totalDropAmount: number; + totalDropValue: number; + hpValue: number; + netProfit: number; +} + +export interface PeriodLootItem { + name: string; + imageUrl: string; + quantity: number; + valuePerItem: number; + totalValue: number; +} + +export const usePeriodStats = (initialPeriod: TimePeriod = 'day') => { + const { allData, loading } = useTrackedDataQuery(); + const { itemValues, loading: itemValuesLoading } = useItemValuesQuery(); + const { parseDrops, parseDropAmount } = useFormatting(); + const [selectedPeriod, setSelectedPeriod] = useState(initialPeriod); + + // Deduplicate all entries: one entry per timestamp+skill + const allDeduplicatedData = useMemo(() => { + const uniqueEntriesMap = new Map(); + + allData.forEach((row: CSVRow) => { + const key = `${row.timestamp}-${row.skill}`; + const existing = uniqueEntriesMap.get(key); + + if (!existing) { + uniqueEntriesMap.set(key, { ...row }); + } else { + // Merge drops from both rows + const existingDrops = existing.drops || ''; + const currentDrops = row.drops || ''; + const mergedDrops = [existingDrops, currentDrops].filter(d => d && d.trim() !== '').join(';'); + + // Keep the one with higher gainedExp or most complete data + const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; + + if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { + uniqueEntriesMap.set(key, { ...row, drops: mergedDrops }); + } else { + uniqueEntriesMap.set(key, { ...existing, drops: mergedDrops }); + } + } + }); + + return Array.from(uniqueEntriesMap.values()); + }, [allData]); + + // Filtered version for exp calculations + const allDeduplicatedDataWithExp = useMemo( + () => allDeduplicatedData.filter(row => parseInt(row.gainedExp || '0', 10) > 0), + [allDeduplicatedData], + ); + + // Calculate overall stats + const overallStats = useMemo(() => { + const totalEntries = allDeduplicatedData.length; + const totalExp = allDeduplicatedDataWithExp.reduce( + (sum, row) => sum + (parseInt(row.gainedExp || '0', 10) || 0), + 0, + ); + + let start: Date | null = null; + let end: Date | null = null; + + if (allDeduplicatedData.length > 0) { + const timestamps = allDeduplicatedData.map(d => new Date(d.timestamp).getTime()); + start = new Date(Math.min(...timestamps)); + end = new Date(Math.max(...timestamps)); + } + + return { + totalEntries, + totalExp, + timeRange: { start, end }, + }; + }, [allDeduplicatedData, allDeduplicatedDataWithExp]); + + // Group and calculate stats + const periodBreakdown = useMemo(() => { + // Group ALL data (for drops/HP) + const allDataPeriodMap = new Map(); + + // Helper to get period key and date + const getPeriodInfo = (date: Date) => { + let periodKey: string; + let periodDate: Date; + + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + const hour = date.getUTCHours(); + + if (selectedPeriod === 'hour') { + periodKey = `${year}-${month}-${day}-${hour}`; + periodDate = new Date(Date.UTC(year, month, day, hour, 0, 0, 0)); + } else if (selectedPeriod === 'day') { + periodKey = `${year}-${month}-${day}`; + periodDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + } else if (selectedPeriod === 'week') { + const weekStart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + const weekday = weekStart.getUTCDay(); + weekStart.setUTCDate(weekStart.getUTCDate() - weekday); + weekStart.setUTCHours(0, 0, 0, 0); + periodKey = `${weekStart.getUTCFullYear()}-${weekStart.getUTCMonth()}-${weekStart.getUTCDate()}`; + periodDate = weekStart; + } else { + periodKey = `${year}-${month}`; + periodDate = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)); + } + return { periodKey, periodDate }; + }; + + allDeduplicatedData.forEach(row => { + const date = new Date(row.timestamp); + const { periodKey, periodDate } = getPeriodInfo(date); + + if (!allDataPeriodMap.has(periodKey)) { + allDataPeriodMap.set(periodKey, { periodKey, date: periodDate, rows: [] }); + } + allDataPeriodMap.get(periodKey)!.rows.push(row); + }); + + // Group filtered data (for exp) + const periodMap = new Map(); + allDeduplicatedDataWithExp.forEach(row => { + const date = new Date(row.timestamp); + const { periodKey, periodDate } = getPeriodInfo(date); + + if (!periodMap.has(periodKey)) { + periodMap.set(periodKey, { periodKey, date: periodDate, rows: [] }); + } + periodMap.get(periodKey)!.rows.push(row); + }); + + // Calculate stats + return Array.from(allDataPeriodMap.values()) + .map(({ periodKey, date, rows: allRows }) => { + const expRows = periodMap.get(periodKey)?.rows || []; + + // EXP Calculation + let totalGainedExp = 0; + const skills: Record = {}; + + expRows.forEach(row => { + const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; + const mainSkill = row.skill || ''; + + if (gainedExp > 0) { + totalGainedExp += gainedExp; + if (mainSkill) { + skills[mainSkill] = (skills[mainSkill] || 0) + gainedExp; + } + } + + if (row.combatExp && row.combatExp.trim() !== '') { + try { + const combatExpGains: CombatExpGain[] = JSON.parse(row.combatExp); + if (Array.isArray(combatExpGains)) { + combatExpGains.forEach((gain: CombatExpGain) => { + const skill = gain.skill || ''; + const exp = parseInt(gain.exp || '0', 10) || 0; + if (skill && exp > 0 && skill !== mainSkill) { + totalGainedExp += exp; + skills[skill] = (skills[skill] || 0) + exp; + } + }); + } + } catch { + // Ignore errors + } + } + }); + + // HP Used Calculation + let totalHpUsed = 0; + allRows.forEach((row: CSVRow) => { + if (row.hpUsed && row.hpUsed.trim() !== '') { + const hpUsedValue = parseInt(row.hpUsed.replace(/,/g, ''), 10); + if (!isNaN(hpUsedValue) && hpUsedValue > 0) { + totalHpUsed += hpUsedValue; + } + } + }); + + const hpEntries = allRows + .filter(row => row.totalInventoryHP && row.totalInventoryHP.trim() !== '') + .map(row => ({ + timestamp: row.timestamp, + hp: parseInt(row.totalInventoryHP.replace(/,/g, ''), 10), + })) + .filter(entry => !isNaN(entry.hp)) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + let hpUsed: { used: number; startHP: number; endHP: number } | null = null; + if (totalHpUsed > 0) { + const startHP = hpEntries.length > 0 ? hpEntries[0].hp : 0; + const endHP = hpEntries.length > 0 ? hpEntries[hpEntries.length - 1].hp : 0; + hpUsed = { used: totalHpUsed, startHP, endHP }; + } else if (hpEntries.length >= 2) { + const firstHP = hpEntries[0].hp; + const lastHP = hpEntries[hpEntries.length - 1].hp; + hpUsed = { used: firstHP - lastHP, startHP: firstHP, endHP: lastHP }; + } + + const isValidDrop = (drop: string, name: string) => { + const trimmedDrop = drop.trim(); + if (!trimmedDrop) return false; + if (/^[\d,]+$/.test(trimmedDrop)) return false; + const trimmedName = name?.trim() || ''; + if (!trimmedName) return false; + const lower = trimmedName.toLowerCase(); + if (lower.includes('experience') || lower.includes('exp ') || /^\d+\s*exp$/i.test(trimmedName)) { + return false; + } + return true; + }; + + // Drops Calculation + const dropStats: Record = {}; + allRows.forEach((row: CSVRow) => { + const drops = parseDrops(row.drops || ''); + drops.forEach((drop: string) => { + const { amount, name } = parseDropAmount(drop); + if (!isValidDrop(drop, name)) { + return; + } + if (!dropStats[name]) { + dropStats[name] = { count: 0, totalAmount: 0 }; + } + dropStats[name].count += 1; + dropStats[name].totalAmount += amount; + }); + }); + + const totalDrops = Object.values(dropStats).reduce((sum, stat) => sum + stat.count, 0); + const totalDropAmount = Object.values(dropStats).reduce((sum, stat) => sum + stat.totalAmount, 0); + + const lootItems = Object.entries(dropStats) + .map(([name, stats]) => { + const valuePerItem = parseFloat(itemValues[name] || '0') || 0; + const totalValue = stats.totalAmount * valuePerItem; + const imageUrl = `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, '%20')}.png`; + return { + name, + imageUrl, + quantity: stats.totalAmount, + valuePerItem, + totalValue, + }; + }) + .sort((a, b) => { + if (b.totalValue !== a.totalValue) return b.totalValue - a.totalValue; + return a.name.localeCompare(b.name); + }); + + const totalDropValue = lootItems.reduce((sum, item) => sum + item.totalValue, 0); + + const hpValue = hpUsed ? hpUsed.used * 2.5 : 0; + const netProfit = totalDropValue - hpValue; + + return { + periodKey, + date, + totalGainedExp, + skills, + hpUsed, + dropStats, + lootItems, + totalDrops, + totalDropAmount, + totalDropValue, + hpValue, + netProfit, + }; + }) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + }, [allDeduplicatedData, allDeduplicatedDataWithExp, selectedPeriod, itemValues, parseDrops, parseDropAmount]); + + return { + periodBreakdown, + selectedPeriod, + setSelectedPeriod, + loading: loading || itemValuesLoading, + itemValues, + overallStats, + }; +}; diff --git a/packages/shared/lib/hooks/useScreenData.ts b/packages/shared/lib/hooks/useScreenData.ts index adf0664..d208801 100644 --- a/packages/shared/lib/hooks/useScreenData.ts +++ b/packages/shared/lib/hooks/useScreenData.ts @@ -58,67 +58,5 @@ export const useScreenData = (): ScreenData | null => { }; }, []); - // // Standalone isVisible function using HTMLElement - // const isVisible = (element: HTMLElement): boolean => { - // const style = window.getComputedStyle(element); - // return ( - // style.display !== 'none' && - // style.visibility !== 'hidden' && - // style.opacity !== '0' && - // element.offsetParent !== null - // ); - // }; - - // const scrapeScreenData = (): ScreenData => { - // // Get all visible text content - // const textNodes = document.body.querySelectorAll('*'); - // const textContent: string[] = []; - // textNodes.forEach((node) => { - // // Ensure node is HTMLElement before calling isVisible - // if (node instanceof HTMLElement) { - // const text = node.textContent?.trim(); - // if (text && isVisible(node)) { - // textContent.push(text); - // } - // } - // }); - - // // Get all visible images - // const images = Array.from(document.images) - // .filter((img) => isVisible(img)) - // .map((img) => img.src); - - // // Get all visible links - // const links = Array.from(document.links) - // .filter((link) => isVisible(link)) - // .map((link) => link.href); - - // return { - // textContent: [...new Set(textContent)], - // images: [...new Set(images)], - // links: [...new Set(links)], - // timestamp: new Date().toISOString(), - // }; - // }; - - // useEffect(() => { - // // Initial scrape - // setScreenData(scrapeScreenData()); - - // // Set up MutationObserver to detect DOM changes - // const observer = new MutationObserver(() => { - // setScreenData(scrapeScreenData()); - // }); - - // observer.observe(document.body, { - // childList: true, - // subtree: true, - // characterData: true, - // }); - - // // Cleanup observer on unmount - // return () => observer.disconnect(); - // }, []); - return screenData; }; diff --git a/packages/shared/lib/hooks/useTrackedData.ts b/packages/shared/lib/hooks/useTrackedData.ts index 0e6f943..692daa5 100644 --- a/packages/shared/lib/hooks/useTrackedData.ts +++ b/packages/shared/lib/hooks/useTrackedData.ts @@ -29,8 +29,8 @@ export const useTrackedData = (): UseTrackedDataReturn => { setLoading(true); const rows = await getCSVRows(); setAllData(rows); - } catch (error) { - console.error('Error loading tracked data:', error); + } catch { + // Silently handle errors } finally { setLoading(false); } @@ -72,33 +72,18 @@ export const useTrackedData = (): UseTrackedDataReturn => { ); const download = useCallback(async (saveAs: boolean = true) => { - try { - await downloadCSV(saveAs); - } catch (error) { - console.error('Error downloading CSV:', error); - throw error; - } + await downloadCSV(saveAs); }, []); const clear = useCallback(async () => { - try { - await clearCSVData(); - await refresh(); - } catch (error) { - console.error('Error clearing CSV data:', error); - throw error; - } + await clearCSVData(); + await refresh(); }, [refresh]); const clearByHour = useCallback( async (hour: number, date?: Date) => { - try { - await clearCSVDataByHour(hour, date); - await refresh(); - } catch (error) { - console.error('Error clearing CSV data by hour:', error); - throw error; - } + await clearCSVDataByHour(hour, date); + await refresh(); }, [refresh], ); diff --git a/packages/shared/lib/hooks/useTrackedDataQuery.ts b/packages/shared/lib/hooks/useTrackedDataQuery.ts index 9c02124..d3bd6ff 100644 --- a/packages/shared/lib/hooks/useTrackedDataQuery.ts +++ b/packages/shared/lib/hooks/useTrackedDataQuery.ts @@ -1,3 +1,4 @@ +import { UPDATE_SCREEN_DATA } from '../../const.js'; import { filterByTimePeriod, filterByHour, filterByDay, aggregateStats } from '../utils/csv-tracker.js'; import { getTrackedData, @@ -6,8 +7,9 @@ import { downloadTrackedDataCSV, } from '../utils/storage-service.js'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import type { CSVRow, TimePeriod, TrackedStats } from '../utils/csv-tracker.js'; +import type { ScreenData } from '../utils/types.js'; // Query key for tracked data export const TRACKED_DATA_QUERY_KEY = ['trackedData'] as const; @@ -39,41 +41,77 @@ export const useTrackedDataQuery = () => { // getTrackedData() reads from tracked_data_csv storage key const rows = await getTrackedData(); return rows; - } catch (err) { - console.error('[useTrackedDataQuery] Error loading tracked data from tracked_data_csv:', err); + } catch { return []; } }, - // Data is fresh for 1 second, preventing unnecessary refetches - staleTime: 1000, + // Data is considered stale after 100ms to allow quick updates while preventing excessive refetches + staleTime: 100, // Keep data in cache for 5 minutes gcTime: 5 * 60 * 1000, // Don't refetch on window focus refetchOnWindowFocus: false, // Don't refetch on reconnect refetchOnReconnect: false, - // Only refetch on mount if data is stale - refetchOnMount: false, + // Refetch on mount if data is stale or missing (ensures initial load) + refetchOnMount: 'always', }); + // Track if we just updated via message to avoid double-updating + const justUpdatedViaMessageRef = useRef(false); + // Listen for storage changes and invalidate query (triggers background refetch) // This ensures UI updates automatically when tracked_data_csv changes useEffect(() => { const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { if (areaName === 'local' && changes.tracked_data_csv) { - // Invalidate and refetch the query to trigger a background update - // This will update the data without showing a loading state - queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); - queryClient.refetchQueries({ queryKey: TRACKED_DATA_QUERY_KEY }).catch(err => { - console.error('[useTrackedDataQuery] Error refetching query:', err); - }); + // If we just updated via message, skip the storage invalidation to avoid double-update + if (justUpdatedViaMessageRef.current) { + justUpdatedViaMessageRef.current = false; + return; + } + + // Only invalidate if the new value is not empty/null and has actual data (not just header) + const newValue = changes.tracked_data_csv.newValue; + if (newValue && newValue.trim().length > 0) { + // Force refetch by invalidating and refetching + // This ensures data updates even if it was recently fetched + queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); + queryClient.refetchQueries({ + queryKey: TRACKED_DATA_QUERY_KEY, + type: 'active', + }); + } + } + }; + + // Also listen for runtime messages to update cache immediately when scrape is saved + // The background script only sends this message AFTER successfully saving unique scrape data + const messageListener = (message: { type: string; data?: ScreenData }) => { + if (message.type === UPDATE_SCREEN_DATA && message.data) { + justUpdatedViaMessageRef.current = true; + // Invalidate and refetch to get the updated data + // Use a small delay to ensure storage changes have propagated + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); + queryClient.refetchQueries({ + queryKey: TRACKED_DATA_QUERY_KEY, + type: 'active', + }); + // Reset flag after a short delay + setTimeout(() => { + justUpdatedViaMessageRef.current = false; + }, 1000); + }, 50); // Small delay to ensure storage changes have propagated } }; chrome.storage.onChanged.addListener(storageListener); + chrome.runtime.onMessage.addListener(messageListener); return () => { chrome.storage.onChanged.removeListener(storageListener); + chrome.runtime.onMessage.removeListener(messageListener); }; }, [queryClient]); @@ -144,32 +182,17 @@ export const useTrackedDataQuery = () => { // Download function - moved to useDataExport hook for better separation of concerns const download = async (saveAs: boolean = true) => { - try { - await downloadTrackedDataCSV(saveAs); - } catch (error) { - console.error('Error downloading CSV:', error); - throw error; - } + await downloadTrackedDataCSV(saveAs); }; // Clear function const clear = async () => { - try { - await clearMutation.mutateAsync(); - } catch (error) { - console.error('Error clearing CSV data:', error); - throw error; - } + await clearMutation.mutateAsync(); }; // Clear by hour function const clearByHour = async (hour: number, date?: Date) => { - try { - await clearByHourMutation.mutateAsync({ hour, date }); - } catch (error) { - console.error('Error clearing CSV data by hour:', error); - throw error; - } + await clearByHourMutation.mutateAsync({ hour, date }); }; return { diff --git a/packages/shared/lib/hooks/useUserStats.ts b/packages/shared/lib/hooks/useUserStats.ts index b376ac6..517a971 100644 --- a/packages/shared/lib/hooks/useUserStats.ts +++ b/packages/shared/lib/hooks/useUserStats.ts @@ -15,8 +15,8 @@ export const useUserStats = () => { setLoading(true); const stats = await getUserStatsFromStorage(); setUserStats(stats); - } catch (error) { - console.error('Error loading user stats:', error); + } catch { + // Silently handle errors } finally { setLoading(false); } diff --git a/packages/shared/lib/utils/colorful-logger.ts b/packages/shared/lib/utils/colorful-logger.ts index ccd2833..ac15659 100644 --- a/packages/shared/lib/utils/colorful-logger.ts +++ b/packages/shared/lib/utils/colorful-logger.ts @@ -1,27 +1,11 @@ -import { COLORS } from './const.js'; -import type { ColorType, ValueOf } from './types.js'; +import type { ColorType } from './types.js'; -export const colorfulLog = (message: string, type: ColorType) => { - let color: ValueOf; - - switch (type) { - case 'success': - color = COLORS.FgGreen; - break; - case 'info': - color = COLORS.FgBlue; - break; - case 'error': - color = COLORS.FgRed; - break; - case 'warning': - color = COLORS.FgYellow; - break; - default: - color = COLORS[type]; - break; - } - - console.info(color, message); - console.info(COLORS['Reset']); +/** + * Logging utility (no-op in production) + * Maintains API compatibility but produces no console output + */ +export const colorfulLog = (_message: string, _type: ColorType): void => { + // No-op: all console logging removed for production + void _message; + void _type; }; diff --git a/packages/shared/lib/utils/csv-storage.ts b/packages/shared/lib/utils/csv-storage.ts index 2985444..2128da9 100644 --- a/packages/shared/lib/utils/csv-storage.ts +++ b/packages/shared/lib/utils/csv-storage.ts @@ -13,9 +13,41 @@ const LAST_EXP_BY_SKILL_KEY = 'last_exp_by_skill'; // Store last exp per skill f export const getCSVFromStorage = async (): Promise => { try { const result = await chrome.storage.local.get(CSV_STORAGE_KEY); - return result[CSV_STORAGE_KEY] || getCSVHeader(); - } catch (error) { - console.error('Error reading CSV from storage:', error); + const csvContent = result[CSV_STORAGE_KEY] || getCSVHeader(); + + // Ensure CSV header includes equipment and combatExp columns (migrate old CSV files) + const lines = csvContent.trim().split('\n'); + if (lines.length > 0) { + const currentHeader = lines[0]; + const expectedHeader = getCSVHeader(); + + // If header doesn't match, update it (migration for old CSV files) + if (currentHeader !== expectedHeader) { + const dataLines = lines.slice(1); + const currentFieldCount = currentHeader.split(',').length; + const expectedFieldCount = expectedHeader.split(',').length; + + if (currentFieldCount < expectedFieldCount) { + // Old format detected - update header and add missing columns to existing rows + const updatedLines = [ + expectedHeader, + ...dataLines.map((line: string) => { + const trimmedLine = line.trim(); + // Add missing fields (equipment and/or combatExp) as empty strings + const missingFields = expectedFieldCount - currentFieldCount; + return `${trimmedLine}${','.repeat(missingFields)}`; + }), + ]; + const updatedCSV = updatedLines.join('\n'); + // Save updated CSV back to storage + await chrome.storage.local.set({ [CSV_STORAGE_KEY]: updatedCSV }); + return updatedCSV; + } + } + } + + return csvContent; + } catch { return getCSVHeader(); } }; @@ -42,7 +74,7 @@ export const appendToCSV = async (data: ScreenData): Promise => { // Calculate gainedExp for each row const rowsWithGainedExp = rows.map(row => { - // Combat exp entries already have gainedExp set in screenDataToCSVRows + // If gainedExp is already set, use it if (row.gainedExp) { return row; } @@ -86,11 +118,9 @@ export const appendToCSV = async (data: ScreenData): Promise => { // Update weekly stats after saving to CSV // Get all rows to calculate weekly stats const allRows = await getCSVRows(); - await updateWeeklyStats(allRows).catch(error => { - console.error('Error updating weekly stats:', error); - }); - } catch (error) { - console.error('Error appending to CSV:', error); + await updateWeeklyStats(allRows); + } catch { + // Silently handle errors } }; @@ -101,8 +131,7 @@ export const getCSVRows = async (): Promise => { try { const csvContent = await getCSVFromStorage(); return parseCSV(csvContent); - } catch (error) { - console.error('Error getting CSV rows:', error); + } catch { return []; } }; @@ -112,75 +141,60 @@ export const getCSVRows = async (): Promise => { * @param saveAs - If true, shows file picker dialog to let user choose save location. If false, saves to default Downloads folder. */ export const downloadCSV = async (saveAs: boolean = true): Promise => { - try { - const csvContent = await getCSVFromStorage(); - const blob = new Blob([csvContent], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - - // Get current date for filename - const date = new Date(); - const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD - const filename = `tracked_data_${dateStr}.csv`; - - await chrome.downloads.download({ - url: url, - filename: filename, - saveAs: saveAs, // If true, shows file picker; if false, saves to default Downloads folder - }); - - // Clean up the object URL after a delay - setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (error) { - console.error('Error downloading CSV:', error); - throw error; - } + const csvContent = await getCSVFromStorage(); + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + + // Get current date for filename + const date = new Date(); + const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD + const filename = `tracked_data_${dateStr}.csv`; + + await chrome.downloads.download({ + url: url, + filename: filename, + saveAs: saveAs, // If true, shows file picker; if false, saves to default Downloads folder + }); + + // Clean up the object URL after a delay + setTimeout(() => URL.revokeObjectURL(url), 1000); }; /** * Clear all CSV data from storage */ export const clearCSVData = async (): Promise => { - try { - await chrome.storage.local.set({ [CSV_STORAGE_KEY]: getCSVHeader() }); - } catch (error) { - console.error('Error clearing CSV data:', error); - throw error; - } + await chrome.storage.local.set({ [CSV_STORAGE_KEY]: getCSVHeader() }); }; /** * Clear CSV data for a specific hour */ export const clearCSVDataByHour = async (hour: number, date?: Date): Promise => { - try { - const csvContent = await getCSVFromStorage(); - const allRows = parseCSV(csvContent); - - // Filter out rows for the specified hour - const refDate = date || new Date(); - const targetDate = new Date(refDate); - targetDate.setHours(hour, 0, 0, 0); - const startTime = targetDate.getTime(); - const endTime = startTime + 60 * 60 * 1000; // 1 hour later - - const filteredRows = allRows.filter(row => { - const rowTime = new Date(row.timestamp).getTime(); - // Keep rows that are NOT in the specified hour - return !(rowTime >= startTime && rowTime < endTime); - }); - - // Rebuild CSV with filtered rows - if (filteredRows.length === 0) { - await chrome.storage.local.set({ [CSV_STORAGE_KEY]: getCSVHeader() }); - } else { - const header = getCSVHeader(); - const lines = filteredRows.map(row => csvRowToString(row)); - const updatedCSV = `${header}\n${lines.join('\n')}`; - await chrome.storage.local.set({ [CSV_STORAGE_KEY]: updatedCSV }); - } - } catch (error) { - console.error('Error clearing CSV data by hour:', error); - throw error; + const csvContent = await getCSVFromStorage(); + const allRows = parseCSV(csvContent); + + // Filter out rows for the specified hour + const refDate = date || new Date(); + const targetDate = new Date(refDate); + targetDate.setHours(hour, 0, 0, 0); + const startTime = targetDate.getTime(); + const endTime = startTime + 60 * 60 * 1000; // 1 hour later + + const filteredRows = allRows.filter(row => { + const rowTime = new Date(row.timestamp).getTime(); + // Keep rows that are NOT in the specified hour + return !(rowTime >= startTime && rowTime < endTime); + }); + + // Rebuild CSV with filtered rows + if (filteredRows.length === 0) { + await chrome.storage.local.set({ [CSV_STORAGE_KEY]: getCSVHeader() }); + } else { + const header = getCSVHeader(); + const lines = filteredRows.map(row => csvRowToString(row)); + const updatedCSV = `${header}\n${lines.join('\n')}`; + await chrome.storage.local.set({ [CSV_STORAGE_KEY]: updatedCSV }); } }; diff --git a/packages/shared/lib/utils/csv-tracker.ts b/packages/shared/lib/utils/csv-tracker.ts index f248a3b..74804fe 100644 --- a/packages/shared/lib/utils/csv-tracker.ts +++ b/packages/shared/lib/utils/csv-tracker.ts @@ -3,6 +3,7 @@ import type { ScreenData } from './types.js'; export interface CSVRow { timestamp: string; + uuid: string; // Unique identifier for this screen scrape (UUID v4) skill: string; skillLevel: string; expForNextLevel: string; @@ -17,85 +18,45 @@ export interface CSVRow { totalFights: string; // Total number of fights completed (empty string if not available) totalInventoryHP: string; // Current HP value from inventory (empty string if not available) hpUsed: string; // HP used from fight log (parsed from "gained X HP" lines, empty string if not available) + equipment: string; // Equipment data as JSON string (empty string if not available) + combatExp: string; // All combat exp gains as JSON string: [{"skill":"Strength","exp":"27"},...] (empty string if not available) } /** * Convert ScreenData to CSV row format - * Returns array of rows - one for main skill, plus one for each combatExp gain + * Returns a single row with all data from the screen scrape, including all combat exp gains * Note: gainedExp will be calculated when saving (in appendToCSV) */ export const screenDataToCSVRows = (data: ScreenData): CSVRow[] => { - const rows: CSVRow[] = []; - - // Main skill row (from LocationContent) - if (data.actionText.currentActionText || data.actionText.exp) { - rows.push({ - timestamp: data.timestamp, - skill: data.actionText.currentActionText || '', - skillLevel: data.actionText.skillLevel || '', - expForNextLevel: data.actionText.expForNextLevel || '', - gainedExp: '', // Will be calculated when saving - drops: data.actionText.drops.join(';'), // Save all drops - hp: data.actionText.inventory.hp || '', // Save HP (deprecated, kept for backward compatibility) - monster: data.monster || '', - location: data.location || '', - damageDealt: (data.damageDealt || []).join(';'), // Save all damage dealt as semicolon-separated - damageReceived: (data.damageReceived || []).join(';'), // Save all damage received as semicolon-separated - peopleFighting: - data.peopleFighting !== null && data.peopleFighting !== undefined ? String(data.peopleFighting) : '', - totalFights: data.totalFights !== null && data.totalFights !== undefined ? String(data.totalFights) : '', - totalInventoryHP: data.totalInventoryHP || '', // Save current HP from inventory - hpUsed: data.hpUsed !== null && data.hpUsed !== undefined ? String(data.hpUsed) : '', // Save HP used from fight log - }); - } - - // Combat exp gain rows (from fight results) - // For combat exp, the exp value IS the gained exp - data.actionText.combatExp.forEach(combatExp => { - rows.push({ - timestamp: data.timestamp, - skill: combatExp.skill, - skillLevel: combatExp.skillLevel || '', - expForNextLevel: combatExp.expForNextLevel || '', - gainedExp: combatExp.exp, // For combat exp, the exp value IS the gained exp - drops: '', // Combat exp rows don't have drops - hp: data.actionText.inventory.hp || '', // Save HP (deprecated, kept for backward compatibility) - monster: data.monster || '', - location: data.location || '', - damageDealt: (data.damageDealt || []).join(';'), // Save all damage dealt as semicolon-separated - damageReceived: (data.damageReceived || []).join(';'), // Save all damage received as semicolon-separated - peopleFighting: - data.peopleFighting !== null && data.peopleFighting !== undefined ? String(data.peopleFighting) : '', - totalFights: data.totalFights !== null && data.totalFights !== undefined ? String(data.totalFights) : '', - totalInventoryHP: data.totalInventoryHP || '', // Save current HP from inventory - hpUsed: data.hpUsed !== null && data.hpUsed !== undefined ? String(data.hpUsed) : '', // Save HP used from fight log - }); - }); + // Create a single row with all the data from the screen scrape + const row: CSVRow = { + timestamp: data.timestamp, + uuid: data.uuid, + skill: data.actionText.currentActionText || '', + skillLevel: data.actionText.skillLevel || '', + expForNextLevel: data.actionText.expForNextLevel || '', + gainedExp: '', // Will be calculated when saving + drops: data.actionText.drops.join(';'), + hp: data.actionText.inventory.hp || '', + monster: data.monster || '', + location: data.location || '', + damageDealt: (data.damageDealt || []).join(';'), + damageReceived: (data.damageReceived || []).join(';'), + peopleFighting: + data.peopleFighting !== null && data.peopleFighting !== undefined ? String(data.peopleFighting) : '', + totalFights: data.totalFights !== null && data.totalFights !== undefined ? String(data.totalFights) : '', + totalInventoryHP: data.totalInventoryHP || '', + hpUsed: data.hpUsed !== null && data.hpUsed !== undefined ? String(data.hpUsed) : '', + equipment: data.equipment ? JSON.stringify(data.equipment) : '', + combatExp: + data.actionText.combatExp && data.actionText.combatExp.length > 0 + ? JSON.stringify(data.actionText.combatExp) + : '', + }; - return rows; + return [row]; }; -/** - * Convert ScreenData to CSV row format (single row - for backward compatibility) - */ -export const screenDataToCSVRow = (data: ScreenData): CSVRow => ({ - timestamp: data.timestamp, - skill: data.actionText.currentActionText || '', - skillLevel: data.actionText.skillLevel || '', - expForNextLevel: data.actionText.expForNextLevel || '', - gainedExp: '', // Will be calculated when saving - drops: data.actionText.drops.join(';'), - hp: data.actionText.inventory.hp || '', // Deprecated, kept for backward compatibility - monster: data.monster || '', - location: data.location || '', - damageDealt: (data.damageDealt || []).join(';'), // Save all damage dealt as semicolon-separated - damageReceived: (data.damageReceived || []).join(';'), // Save all damage received as semicolon-separated - peopleFighting: data.peopleFighting !== null && data.peopleFighting !== undefined ? String(data.peopleFighting) : '', - totalFights: data.totalFights !== null && data.totalFights !== undefined ? String(data.totalFights) : '', - totalInventoryHP: data.totalInventoryHP || '', // Save current HP from inventory - hpUsed: data.hpUsed !== null && data.hpUsed !== undefined ? String(data.hpUsed) : '', // Save HP used from fight log -}); - /** * Convert CSV row to object */ @@ -104,17 +65,17 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { if (row.length < 2) return null; // Format versions: - // Old (7 fields): timestamp,skill,exp,speedText,addExp,images,links - // Medium (9 fields): timestamp,skill,exp,speedText,addExp,skillLevel,expForNextLevel,images,links - // Old New (11 fields): timestamp,skill,exp,speedText,addExp,skillLevel,expForNextLevel,gainedExp,drops,images,links - // New (6 fields): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops - // New with HP (7 fields): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp - // New with HP and combat (11 fields): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived - // New with HP, combat, and people (12 fields): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting - // New with HP, combat, people, and totalFights (13 fields): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights - // New with HP, combat, people, totalFights, totalInventoryHP, and hpUsed (15 fields): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed - - // Check for 15 fields first - new format with all fields including totalInventoryHP and hpUsed + // New with UUID (16 fields): timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed + // New with UUID and equipment (17 fields): timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed,equipment + // New with UUID, equipment, and combatExp (18 fields): timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed,equipment,combatExp + + // Check for 18 fields first - new format with UUID, equipment, and combatExp + const isNewFormatWithUUIDAndEquipmentAndCombatExp = row.length === 18; + // Check for 17 fields - new format with UUID and equipment + const isNewFormatWithUUIDAndEquipment = row.length === 17; + // Check for 16 fields - new format with UUID + const isNewFormatWithUUID = row.length === 16; + // Check for 15 fields - new format with all fields including totalInventoryHP and hpUsed (but no UUID) const isNewFormatWithHpFields = row.length === 15; // Check for 13 fields - new format with all fields including totalFights const isNewFormatWithTotalFights = row.length === 13; @@ -137,10 +98,81 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { const isNewFormatWithHP = row.length === 7 && row[3] && /^\d+$/.test(row[3].replace(/,/g, '')); // expForNextLevel is a number const isOldFormat = row.length === 7 && !isNewFormatWithHP; + if (isNewFormatWithUUIDAndEquipmentAndCombatExp) { + // New format with UUID, equipment, and combatExp: timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed,equipment,combatExp + return { + timestamp: row[0] || '', + uuid: row[1] || '', + skill: row[2] || '', + skillLevel: row[3] || '', + expForNextLevel: row[4] || '', + gainedExp: row[5] || '', + drops: row[6] || '', + hp: row[7] || '', + monster: row[8] || '', + location: row[9] || '', + damageDealt: row[10] || '', + damageReceived: row[11] || '', + peopleFighting: row[12] || '', + totalFights: row[13] || '', + totalInventoryHP: row[14] || '', + hpUsed: row[15] || '', + equipment: row[16] || '', + combatExp: row[17] || '', + }; + } else if (isNewFormatWithUUIDAndEquipment) { + // New format with UUID and equipment: timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed,equipment + return { + timestamp: row[0] || '', + uuid: row[1] || '', + skill: row[2] || '', + skillLevel: row[3] || '', + expForNextLevel: row[4] || '', + gainedExp: row[5] || '', + drops: row[6] || '', + hp: row[7] || '', + monster: row[8] || '', + location: row[9] || '', + damageDealt: row[10] || '', + damageReceived: row[11] || '', + peopleFighting: row[12] || '', + totalFights: row[13] || '', + totalInventoryHP: row[14] || '', + hpUsed: row[15] || '', + equipment: row[16] || '', + combatExp: '', // Backward compatible: no combatExp in old format + }; + } + + if (isNewFormatWithUUID) { + // New format with UUID: timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed + return { + timestamp: row[0] || '', + uuid: row[1] || '', + skill: row[2] || '', + skillLevel: row[3] || '', + expForNextLevel: row[4] || '', + gainedExp: row[5] || '', + drops: row[6] || '', + hp: row[7] || '', + monster: row[8] || '', + location: row[9] || '', + damageDealt: row[10] || '', + damageReceived: row[11] || '', + peopleFighting: row[12] || '', + totalFights: row[13] || '', + totalInventoryHP: row[14] || '', + hpUsed: row[15] || '', + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format + }; + } + if (isNewFormatWithHpFields) { - // New format with HP, combat, people, totalFights, totalInventoryHP, and hpUsed: timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed + // New format with HP, combat, people, totalFights, totalInventoryHP, and hpUsed (no UUID): timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[2] || '', expForNextLevel: row[3] || '', @@ -155,6 +187,8 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: row[12] || '', totalInventoryHP: row[13] || '', hpUsed: row[14] || '', + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } @@ -162,6 +196,7 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { // New format with HP, combat, people, and totalFights: timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[2] || '', expForNextLevel: row[3] || '', @@ -176,6 +211,8 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: row[12] || '', totalInventoryHP: '', // Backward compatible: not available in old format hpUsed: '', // Backward compatible: not available in old format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } @@ -183,6 +220,7 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { // New format with HP, combat, and people: timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[2] || '', expForNextLevel: row[3] || '', @@ -197,11 +235,14 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Backward compatible: no totalFights in old format totalInventoryHP: '', // Backward compatible: not available in old format hpUsed: '', // Backward compatible: not available in old format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } else if (isNewFormatWithCombat) { // New format with HP and combat: timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[2] || '', expForNextLevel: row[3] || '', @@ -216,11 +257,14 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Not available in this format totalInventoryHP: '', // Not available in this format hpUsed: '', // Not available in this format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } else if (isNewFormatWithHP) { // New format with HP: timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[2] || '', expForNextLevel: row[3] || '', @@ -235,11 +279,14 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Not available in this format totalInventoryHP: '', // Not available in this format hpUsed: '', // Not available in this format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } else if (isNewFormat) { // New format: timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[2] || '', expForNextLevel: row[3] || '', @@ -254,11 +301,14 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Not available in this format totalInventoryHP: '', // Not available in this format hpUsed: '', // Not available in this format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } else if (isOldNewFormat) { // Old new format (11 fields): timestamp,skill,exp,speedText,addExp,skillLevel,expForNextLevel,gainedExp,drops,images,links return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[5] || '', expForNextLevel: row[6] || '', @@ -273,11 +323,14 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Not available in old format totalInventoryHP: '', // Not available in old format hpUsed: '', // Not available in old format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } else if (isMediumFormat) { // Medium format (9 fields): timestamp,skill,exp,speedText,addExp,skillLevel,expForNextLevel,images,links return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: row[5] || '', expForNextLevel: row[6] || '', @@ -292,11 +345,14 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Not available in old format totalInventoryHP: '', // Not available in old format hpUsed: '', // Not available in old format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } else if (isOldFormat) { // Old format (7 fields): timestamp,skill,exp,speedText,addExp,images,links return { timestamp: row[0] || '', + uuid: '', // Backward compatible: no UUID in old format skill: row[1] || '', skillLevel: '', // Not available in this format expForNextLevel: '', // Not available in this format @@ -311,6 +367,8 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { totalFights: '', // Not available in old format totalInventoryHP: '', // Not available in old format hpUsed: '', // Not available in old format + equipment: '', // Backward compatible: no equipment in old format + combatExp: '', // Backward compatible: no combatExp in old format }; } @@ -318,20 +376,23 @@ export const csvRowToObject = (row: string[]): CSVRow | null => { // Always return a complete CSVRow object with all fields const result: CSVRow = { timestamp: row[0] || '', - skill: row[1] || '', - skillLevel: row[2] || '', - expForNextLevel: row[3] || '', - gainedExp: row[4] || '', - drops: row[5] || '', - hp: row[6] || '', // Try to get HP if available - monster: row[7] || '', // Try to get monster if available - location: row[8] || '', // Try to get location if available - damageDealt: row[9] || '', // Try to get damageDealt if available - damageReceived: row[10] || '', // Try to get damageReceived if available - peopleFighting: row[11] || '', // Try to get peopleFighting if available - totalFights: row[12] || '', // Try to get totalFights if available - totalInventoryHP: row[13] || '', // Try to get totalInventoryHP if available - hpUsed: row[14] || '', // Try to get hpUsed if available + uuid: row[1] || '', // Try to get UUID if available (may be empty for old formats) + skill: row[2] || row[1] || '', // Try row[2] first (new format), fallback to row[1] (old format) + skillLevel: row[3] || row[2] || '', + expForNextLevel: row[4] || row[3] || '', + gainedExp: row[5] || row[4] || '', + drops: row[6] || row[5] || '', + hp: row[7] || row[6] || '', // Try to get HP if available + monster: row[8] || row[7] || '', // Try to get monster if available + location: row[9] || row[8] || '', // Try to get location if available + damageDealt: row[10] || row[9] || '', // Try to get damageDealt if available + damageReceived: row[11] || row[10] || '', // Try to get damageReceived if available + peopleFighting: row[12] || row[11] || '', // Try to get peopleFighting if available + totalFights: row[13] || row[12] || '', // Try to get totalFights if available + totalInventoryHP: row[14] || row[13] || '', // Try to get totalInventoryHP if available + hpUsed: row[15] || row[14] || '', // Try to get hpUsed if available + equipment: row[16] || '', // Try to get equipment if available + combatExp: row[17] || '', // Try to get combatExp if available }; return result; }; @@ -353,6 +414,7 @@ const escapeCSVField = (field: string | undefined | null): string => { export const csvRowToString = (row: CSVRow): string => [ escapeCSVField(row.timestamp), + escapeCSVField(row.uuid), escapeCSVField(row.skill), escapeCSVField(row.skillLevel), escapeCSVField(row.expForNextLevel), @@ -367,18 +429,15 @@ export const csvRowToString = (row: CSVRow): string => escapeCSVField(row.totalFights), escapeCSVField(row.totalInventoryHP), escapeCSVField(row.hpUsed), + escapeCSVField(row.equipment), + escapeCSVField(row.combatExp), ].join(','); /** * Get CSV header row */ export const getCSVHeader = (): string => - 'timestamp,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed'; - -/** - * Convert ScreenData to CSV line - */ -export const screenDataToCSVLine = (data: ScreenData): string => csvRowToString(screenDataToCSVRow(data)); + 'timestamp,uuid,skill,skillLevel,expForNextLevel,gainedExp,drops,hp,monster,location,damageDealt,damageReceived,peopleFighting,totalFights,totalInventoryHP,hpUsed,equipment,combatExp'; /** * Parse CSV content to array of CSVRow objects @@ -465,10 +524,11 @@ export const filterByTimePeriod = (rows: CSVRow[], period: TimePeriod, reference */ export const filterByHour = (rows: CSVRow[], hour: number, date?: Date): CSVRow[] => { const refDate = date || new Date(); - const targetDate = new Date(refDate); - targetDate.setHours(hour, 0, 0, 0); - const startTime = targetDate.getTime(); - const endTime = startTime + 60 * 60 * 1000; // 1 hour later + const year = refDate.getUTCFullYear(); + const month = refDate.getUTCMonth(); + const day = refDate.getUTCDate(); + const startTime = Date.UTC(year, month, day, hour, 0, 0, 0); + const endTime = startTime + 60 * 60 * 1000; return rows.filter(row => { const rowTime = new Date(row.timestamp).getTime(); @@ -480,13 +540,11 @@ export const filterByHour = (rows: CSVRow[], hour: number, date?: Date): CSVRow[ * Get data for specific day */ export const filterByDay = (rows: CSVRow[], date: Date): CSVRow[] => { - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(date); - endOfDay.setHours(23, 59, 59, 999); - - const startTime = startOfDay.getTime(); - const endTime = endOfDay.getTime(); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + const startTime = Date.UTC(year, month, day, 0, 0, 0, 0); + const endTime = Date.UTC(year, month, day, 23, 59, 59, 999); return rows.filter(row => { const rowTime = new Date(row.timestamp).getTime(); @@ -522,7 +580,7 @@ export const aggregateStats = (rows: CSVRow[]): TrackedStats => { const timestamps = rows.map(r => new Date(r.timestamp).getTime()).sort((a, b) => a - b); const skills: Record = {}; - let totalGainedExp = 0; + const totalGainedExp = 0; // Sort rows by timestamp and skill const sortedRows = [...rows].sort((a, b) => { @@ -563,8 +621,6 @@ export const aggregateStats = (rows: CSVRow[]): TrackedStats => { // Only count entries with gained exp > 0 if (gainedExp > 0) { - totalGainedExp += gainedExp; - if (skill) { skills[skill] = (skills[skill] || 0) + gainedExp; } diff --git a/packages/shared/lib/utils/exp-calculator.ts b/packages/shared/lib/utils/exp-calculator.ts new file mode 100644 index 0000000..a01921e --- /dev/null +++ b/packages/shared/lib/utils/exp-calculator.ts @@ -0,0 +1,163 @@ +/** + * Experience Calculator for Syrnia + * + * This utility calculates exp requirements based on level intervals. + * Uses interpolation between known data points for accuracy. + * + * Data points from game: + * - Level 10: 3,219 exp + * - Level 20: 36,608 exp + * - Level 40: 416,365 exp + * - Level 60: 1,726,400 exp + * - Level 100: 10,358,819 exp + * - Level 200: 117,820,168 exp + */ + +// Known data points: [level, totalExp] +const EXP_DATA_POINTS: Array<[number, number]> = [ + [1, 0], // Level 1 requires 0 exp + [10, 3219], + [20, 36608], + [40, 416365], + [60, 1726400], + [100, 10358819], + [200, 117820168], +]; + +/** + * Interpolate exp for a given level using known data points + * Uses linear interpolation between the two closest data points + */ +const interpolateExp = (level: number): number => { + // Find the two closest data points + let lower: [number, number] | null = null; + let upper: [number, number] | null = null; + + for (let i = 0; i < EXP_DATA_POINTS.length; i++) { + const [dataLevel] = EXP_DATA_POINTS[i]; + if (dataLevel <= level) { + lower = EXP_DATA_POINTS[i]; + } + if (dataLevel >= level && !upper) { + upper = EXP_DATA_POINTS[i]; + break; + } + } + + // If level is below first data point, return 0 + if (!lower) return 0; + + // If level is at or above last data point, extrapolate + if (!upper) { + const [lastLevel, lastExp] = EXP_DATA_POINTS[EXP_DATA_POINTS.length - 1]; + const [secondLastLevel, secondLastExp] = EXP_DATA_POINTS[EXP_DATA_POINTS.length - 2]; + + // Extrapolate using the rate of change from the last two points + const levelDiff = lastLevel - secondLastLevel; + const expDiff = lastExp - secondLastExp; + const expPerLevel = expDiff / levelDiff; + + const levelsBeyond = level - lastLevel; + return lastExp + levelsBeyond * expPerLevel; + } + + // If level matches a data point exactly, return that value + if (lower[0] === upper[0]) return lower[1]; + + // Linear interpolation between lower and upper + const [lowerLevel, lowerExp] = lower; + const [upperLevel, upperExp] = upper; + const ratio = (level - lowerLevel) / (upperLevel - lowerLevel); + + return lowerExp + (upperExp - lowerExp) * ratio; +}; + +/** + * Calculate total exp required to reach a specific level + * Uses interpolation between known data points for accuracy + */ +export const calculateTotalExpForLevel = (level: number): number => { + if (level <= 0) return 0; + if (level === 1) return 0; // Level 1 requires 0 exp (starting level) + + return Math.floor(interpolateExp(level)); +}; + +/** + * Calculate exp required to go from current level to next level + * This is the expForNextLevel value + */ +export const calculateExpForNextLevel = (currentLevel: number): number => { + if (currentLevel <= 0) return 0; + + const totalExpForCurrentLevel = calculateTotalExpForLevel(currentLevel); + const totalExpForNextLevel = calculateTotalExpForLevel(currentLevel + 1); + + return totalExpForNextLevel - totalExpForCurrentLevel; +}; + +/** + * Calculate progress percentage (0-100) to next level + * @param currentLevel - Current skill level + * @param currentTotalExp - Total exp accumulated so far + * @returns Progress percentage (0-100) + */ +export const calculatePercentToNext = (currentLevel: number, currentTotalExp: number): number => { + if (currentLevel <= 0) return 0; + + const totalExpForCurrentLevel = calculateTotalExpForLevel(currentLevel); + const totalExpForNextLevel = calculateTotalExpForLevel(currentLevel + 1); + const expForNextLevel = totalExpForNextLevel - totalExpForCurrentLevel; + + if (expForNextLevel <= 0) return 100; // Already max level or invalid + + const expAtCurrentLevel = currentTotalExp - totalExpForCurrentLevel; + const progress = (expAtCurrentLevel / expForNextLevel) * 100; + + // Clamp between 0 and 100 + return Math.max(0, Math.min(100, progress)); +}; + +/** + * Calculate exp left until next level + * @param currentLevel - Current skill level + * @param currentTotalExp - Total exp accumulated so far + * @returns Exp remaining until next level + */ +export const calculateExpLeft = (currentLevel: number, currentTotalExp: number): number => { + if (currentLevel <= 0) return 0; + + const totalExpForCurrentLevel = calculateTotalExpForLevel(currentLevel); + const totalExpForNextLevel = calculateTotalExpForLevel(currentLevel + 1); + const expForNextLevel = totalExpForNextLevel - totalExpForCurrentLevel; + + if (expForNextLevel <= 0) return 0; // Already max level or invalid + + const expAtCurrentLevel = currentTotalExp - totalExpForCurrentLevel; + const expLeft = expForNextLevel - expAtCurrentLevel; + + return Math.max(0, expLeft); +}; + +/** + * Determine level from total exp + * This is useful when we only have total exp but need to know the level + */ +export const calculateLevelFromTotalExp = (totalExp: number): number => { + if (totalExp <= 0) return 1; + + let level = 1; + while (calculateTotalExpForLevel(level + 1) <= totalExp) { + level++; + // Safety check to prevent infinite loops + if (level > 1000) break; + } + + return level; +}; + +/** + * Get the known data points used for interpolation + * Useful for debugging or displaying formula information + */ +export const getExpDataPoints = (): Array<[number, number]> => [...EXP_DATA_POINTS]; diff --git a/packages/shared/lib/utils/helpers.ts b/packages/shared/lib/utils/helpers.ts index 3350165..1fc1514 100644 --- a/packages/shared/lib/utils/helpers.ts +++ b/packages/shared/lib/utils/helpers.ts @@ -37,3 +37,19 @@ export const matchText = (text: string): SkillInfo => { return result; }; + +/** + * Normalize location name for display + * Converts location names to their commonly used game names + * @param location - The location name to normalize + * @returns The normalized location name + */ +export const formatLocation = (location: string): string => { + const normalized = location.toLowerCase().trim(); + // Only shorten "Rima City - Barracks" to "Barracks" + if (normalized === 'rima city - barracks') { + return 'Barracks'; + } + // Return the original location name for all other cases + return location; +}; diff --git a/packages/shared/lib/utils/index.ts b/packages/shared/lib/utils/index.ts index a45d224..1814c23 100644 --- a/packages/shared/lib/utils/index.ts +++ b/packages/shared/lib/utils/index.ts @@ -6,6 +6,8 @@ export type * from './types.js'; export * from './csv-tracker.js'; export * from './csv-storage.js'; export * from './formatting.js'; +export * from './exp-calculator.js'; export * from './user-stats-storage.js'; export * from './weekly-stats-storage.js'; export * from './storage-service.js'; +export * from './themes.js'; diff --git a/packages/shared/lib/utils/storage-service.ts b/packages/shared/lib/utils/storage-service.ts index c038dd6..4bfe98a 100644 --- a/packages/shared/lib/utils/storage-service.ts +++ b/packages/shared/lib/utils/storage-service.ts @@ -34,8 +34,7 @@ const getFromStorage = async (key: string, defaultValue: T): Promise => { try { const result = await chrome.storage.local.get(key); return result[key] !== undefined ? result[key] : defaultValue; - } catch (error) { - console.error(`Error reading ${key} from storage:`, error); + } catch { return defaultValue; } }; @@ -44,12 +43,7 @@ const getFromStorage = async (key: string, defaultValue: T): Promise => { * Set data in chrome.storage.local */ const setInStorage = async (key: string, value: unknown): Promise => { - try { - await chrome.storage.local.set({ [key]: value }); - } catch (error) { - console.error(`Error writing ${key} to storage:`, error); - throw error; - } + await chrome.storage.local.set({ [key]: value }); }; // ============================================================================ diff --git a/packages/shared/lib/utils/themes.ts b/packages/shared/lib/utils/themes.ts new file mode 100644 index 0000000..45102a4 --- /dev/null +++ b/packages/shared/lib/utils/themes.ts @@ -0,0 +1,164 @@ +/** + * Theme definitions for shadcn/ui + * Themes are based on tweakcn.com themes + */ + +export interface ThemeColors { + background: string; + foreground: string; + card: string; + 'card-foreground': string; + popover: string; + 'popover-foreground': string; + primary: string; + 'primary-foreground': string; + secondary: string; + 'secondary-foreground': string; + muted: string; + 'muted-foreground': string; + accent: string; + 'accent-foreground': string; + destructive: string; + 'destructive-foreground': string; + border: string; + input: string; + ring: string; +} + +export interface Theme { + name: string; + displayName: string; + colors: { + light: ThemeColors; + dark: ThemeColors; + }; +} + +/** + * Perpetuity theme from tweakcn.com + * Source: https://tweakcn.com/editor/theme?theme=perpetuity + * A dark teal/cyan theme with deep blue backgrounds + */ +export const perpetuityTheme: Theme = { + name: 'perpetuity', + displayName: 'Perpetuity', + colors: { + light: { + background: '196.36 52.38% 8.24%', + foreground: '180 77.11% 60.59%', + card: '192 51.02% 9.61%', + 'card-foreground': '180 77.11% 60.59%', + popover: '196.36 52.38% 8.24%', + 'popover-foreground': '180 77.11% 60.59%', + primary: '180 77.11% 60.59%', + 'primary-foreground': '196.36 52.38% 8.24%', + secondary: '192 51.02% 12%', + 'secondary-foreground': '180 77.11% 60.59%', + muted: '192 51.02% 12%', + 'muted-foreground': '180 50% 50%', + accent: '180 77.11% 60.59%', + 'accent-foreground': '196.36 52.38% 8.24%', + destructive: '0 84.2% 60.2%', + 'destructive-foreground': '180 77.11% 60.59%', + border: '192 51.02% 15%', + input: '192 51.02% 15%', + ring: '180 77.11% 60.59%', + }, + dark: { + background: '196.36 52.38% 8.24%', + foreground: '180 77.11% 60.59%', + card: '192 51.02% 9.61%', + 'card-foreground': '180 77.11% 60.59%', + popover: '196.36 52.38% 8.24%', + 'popover-foreground': '180 77.11% 60.59%', + primary: '180 77.11% 60.59%', + 'primary-foreground': '196.36 52.38% 8.24%', + secondary: '192 51.02% 12%', + 'secondary-foreground': '180 77.11% 60.59%', + muted: '192 51.02% 12%', + 'muted-foreground': '180 50% 50%', + accent: '180 77.11% 60.59%', + 'accent-foreground': '196.36 52.38% 8.24%', + destructive: '0 62.8% 50%', + 'destructive-foreground': '180 77.11% 60.59%', + border: '192 51.02% 15%', + input: '192 51.02% 15%', + ring: '180 77.11% 60.59%', + }, + }, +}; + +/** + * Default theme (shadcn default) + */ +export const defaultTheme: Theme = { + name: 'default', + displayName: 'Default', + colors: { + light: { + background: '0 0% 100%', + foreground: '222.2 84% 4.9%', + card: '0 0% 100%', + 'card-foreground': '222.2 84% 4.9%', + popover: '0 0% 100%', + 'popover-foreground': '222.2 84% 4.9%', + primary: '222.2 47.4% 11.2%', + 'primary-foreground': '210 40% 98%', + secondary: '210 40% 96.1%', + 'secondary-foreground': '222.2 47.4% 11.2%', + muted: '210 40% 96.1%', + 'muted-foreground': '215.4 16.3% 46.9%', + accent: '210 40% 96.1%', + 'accent-foreground': '222.2 47.4% 11.2%', + destructive: '0 84.2% 60.2%', + 'destructive-foreground': '210 40% 98%', + border: '214.3 31.8% 91.4%', + input: '214.3 31.8% 91.4%', + ring: '222.2 84% 4.9%', + }, + dark: { + background: '222.2 84% 4.9%', + foreground: '210 40% 98%', + card: '222.2 84% 4.9%', + 'card-foreground': '210 40% 98%', + popover: '222.2 84% 4.9%', + 'popover-foreground': '210 40% 98%', + primary: '210 40% 98%', + 'primary-foreground': '222.2 47.4% 11.2%', + secondary: '217.2 32.6% 17.5%', + 'secondary-foreground': '210 40% 98%', + muted: '217.2 32.6% 17.5%', + 'muted-foreground': '215 20.2% 65.1%', + accent: '217.2 32.6% 17.5%', + 'accent-foreground': '210 40% 98%', + destructive: '0 62.8% 30.6%', + 'destructive-foreground': '210 40% 98%', + border: '217.2 32.6% 17.5%', + input: '217.2 32.6% 17.5%', + ring: '212.7 26.8% 83.9%', + }, + }, +}; + +/** + * All available themes + */ +export const themes: Theme[] = [defaultTheme, perpetuityTheme]; + +/** + * Get theme by name + */ +export const getTheme = (name: string): Theme | undefined => themes.find(theme => theme.name === name); + +/** + * Apply theme colors to document root + */ +export const applyTheme = (theme: Theme, isDark: boolean): void => { + const root = document.documentElement; + const colors = isDark ? theme.colors.dark : theme.colors.light; + + Object.entries(colors).forEach(([key, value]) => { + const cssVar = `--${key}`; + root.style.setProperty(cssVar, value); + }); +}; diff --git a/packages/shared/lib/utils/types.ts b/packages/shared/lib/utils/types.ts index 489deab..faff51d 100644 --- a/packages/shared/lib/utils/types.ts +++ b/packages/shared/lib/utils/types.ts @@ -54,6 +54,7 @@ export interface ScreenData { images: string[]; links: string[]; timestamp: string; + uuid: string; // Unique identifier for this screen scrape (UUID v4) monster?: string; // Name of the monster being fought location?: string; // Location name where fighting damageDealt?: string[]; // Array of damage dealt by player (from fight log) diff --git a/packages/shared/lib/utils/user-stats-storage.ts b/packages/shared/lib/utils/user-stats-storage.ts index 94c3a02..e382b8a 100644 --- a/packages/shared/lib/utils/user-stats-storage.ts +++ b/packages/shared/lib/utils/user-stats-storage.ts @@ -188,8 +188,7 @@ const getUserStatsCSVFromStorage = async (): Promise => { try { const result = await chrome.storage.local.get(USER_STATS_STORAGE_KEY); return result[USER_STATS_STORAGE_KEY] || getUserStatsCSVHeader(); - } catch (error) { - console.error('Error reading user stats CSV from storage:', error); + } catch { return getUserStatsCSVHeader(); } }; @@ -202,8 +201,8 @@ const saveUserStatsToCSV = async (stats: UserStats): Promise => { try { const csvContent = `${getUserStatsCSVHeader()}\n${userStatsToCSV(stats)}`; await chrome.storage.local.set({ [USER_STATS_STORAGE_KEY]: csvContent }); - } catch (error) { - console.error('Error saving user stats to CSV:', error); + } catch { + // Silently handle errors } }; @@ -214,8 +213,7 @@ const getUserStatsFromStorage = async (): Promise => { try { const csvContent = await getUserStatsCSVFromStorage(); return parseUserStatsCSV(csvContent); - } catch (error) { - console.error('Error getting user stats from storage:', error); + } catch { return null; } }; diff --git a/packages/shared/lib/utils/weekly-stats-storage.ts b/packages/shared/lib/utils/weekly-stats-storage.ts index 9945da8..a7618b8 100644 --- a/packages/shared/lib/utils/weekly-stats-storage.ts +++ b/packages/shared/lib/utils/weekly-stats-storage.ts @@ -214,8 +214,7 @@ const getWeeklyStatsFromStorage = async (): Promise => { return parseWeeklyStatsRow(row); }) .filter((row: WeeklyStatsRow | null): row is WeeklyStatsRow => row !== null); - } catch (error) { - console.error('Error reading weekly stats from storage:', error); + } catch { return []; } }; @@ -351,8 +350,8 @@ const updateWeeklyStats = async (allRows: CSVRow[]): Promise => { const csvContent = `${header}\n${lines.join('\n')}`; await chrome.storage.local.set({ [WEEKLY_STATS_STORAGE_KEY]: csvContent }); - } catch (error) { - console.error('Error updating weekly stats:', error); + } catch { + // Silently handle errors } }; @@ -428,7 +427,6 @@ const updateWeeklyStatsFromStatsURL = async (userStats: UserStats, allRows: CSVR totalEntries = parseInt(existingWeekStats.totalEntries || '0', 10) || 0; } catch { // If parsing fails, calculate from tracked data - console.warn('Error parsing existing drops, recalculating from tracked data'); } } @@ -520,8 +518,8 @@ const updateWeeklyStatsFromStatsURL = async (userStats: UserStats, allRows: CSVR const csvContent = `${header}\n${lines.join('\n')}`; await chrome.storage.local.set({ [WEEKLY_STATS_STORAGE_KEY]: csvContent }); - } catch (error) { - console.error('Error updating weekly stats from stats URL:', error); + } catch { + // Silently handle errors } }; diff --git a/packages/storage/lib/base/types.ts b/packages/storage/lib/base/types.ts index 3f8e4b9..de948bf 100644 --- a/packages/storage/lib/base/types.ts +++ b/packages/storage/lib/base/types.ts @@ -47,8 +47,10 @@ export type StorageConfigType = { export interface ThemeStateType { theme: 'light' | 'dark'; isLight: boolean; + themeName: string; // Theme name like 'default', 'perpetuity', etc. } export type ThemeStorageType = BaseStorageType & { toggle: () => Promise; + setThemeName: (themeName: string) => Promise; }; diff --git a/packages/storage/lib/impl/example-theme-storage.ts b/packages/storage/lib/impl/example-theme-storage.ts index 02788ea..3f74756 100644 --- a/packages/storage/lib/impl/example-theme-storage.ts +++ b/packages/storage/lib/impl/example-theme-storage.ts @@ -6,6 +6,7 @@ const storage = createStorage( { theme: 'dark', isLight: false, + themeName: 'default', }, { storageEnum: StorageEnum.Local, @@ -20,9 +21,16 @@ export const exampleThemeStorage: ThemeStorageType = { const newTheme = currentState.theme === 'light' ? 'dark' : 'light'; return { + ...currentState, theme: newTheme, isLight: newTheme === 'light', }; }); }, + setThemeName: async (themeName: string) => { + await storage.set(currentState => ({ + ...currentState, + themeName, + })); + }, }; diff --git a/packages/ui/global.css b/packages/ui/global.css index 956cdce..95c4486 100644 --- a/packages/ui/global.css +++ b/packages/ui/global.css @@ -60,8 +60,8 @@ padding: 0; box-sizing: border-box; font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', - 'Helvetica Neue', sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', + 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/packages/ui/lib/components/ErrorDisplay.tsx b/packages/ui/lib/components/ErrorDisplay.tsx new file mode 100644 index 0000000..08a9a0e --- /dev/null +++ b/packages/ui/lib/components/ErrorDisplay.tsx @@ -0,0 +1,42 @@ +import { Button } from './ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import type { FallbackProps } from 'react-error-boundary'; + +export const ErrorDisplay = ({ error, resetErrorBoundary }: FallbackProps) => { + const handleReturnToDashboard = () => { + // Reset the error boundary + resetErrorBoundary(); + + // Navigate to dashboard by reloading the extension + // In a Chrome extension context, we can reload the current window + if (typeof window !== 'undefined' && window.location) { + window.location.reload(); + } + }; + + return ( +

+ + + Error + + +
+

+ Something went wrong. Don't worry, you can return to the dashboard to continue. +

+ {error && ( +
+ Error Details +
{error.message || String(error)}
+
+ )} +
+ +
+
+
+ ); +}; diff --git a/packages/ui/lib/components/IconButton.tsx b/packages/ui/lib/components/IconButton.tsx new file mode 100644 index 0000000..11a5a9f --- /dev/null +++ b/packages/ui/lib/components/IconButton.tsx @@ -0,0 +1,37 @@ +import { Button } from './ui/button'; +import { cn } from '../utils'; +import * as React from 'react'; +import type { ButtonProps } from './ui/button'; + +type IconButtonProps = { + onClick: React.ButtonHTMLAttributes['onClick']; + variant: ButtonProps['variant']; + size: ButtonProps['size']; + label: string; + className: string; + Icon: React.ComponentType>; + disabled?: boolean; + type?: React.ButtonHTMLAttributes['type']; +}; + +const IconButton = React.forwardRef( + ({ onClick, variant, size, label, className, Icon, disabled, type = 'button' }, ref) => ( + + ), +); + +IconButton.displayName = 'IconButton'; + +export { IconButton }; +export type { IconButtonProps }; diff --git a/packages/ui/lib/components/ThemeToggle.tsx b/packages/ui/lib/components/ThemeToggle.tsx index 67ac3d7..9a0f07d 100644 --- a/packages/ui/lib/components/ThemeToggle.tsx +++ b/packages/ui/lib/components/ThemeToggle.tsx @@ -19,13 +19,6 @@ export const ThemeToggle = () => { } else { root.classList.remove('dark'); } - // Debug log - console.log('ThemeToggle theme effect:', { - isLight, - storageData, - shouldBeDark, - hasDarkClass: root.classList.contains('dark'), - }); }, [isLight, storageData]); return ( diff --git a/packages/ui/lib/components/index.ts b/packages/ui/lib/components/index.ts index a504583..9fbff70 100644 --- a/packages/ui/lib/components/index.ts +++ b/packages/ui/lib/components/index.ts @@ -1,7 +1,8 @@ export * from './ToggleButton'; export * from './LoadingSpinner'; -export * from './error-display/ErrorDisplay'; +export * from './ErrorDisplay'; export * from './ThemeToggle'; +export * from './IconButton'; export * from './ui/button'; export * from './ui/card'; export * from './ui/chart'; @@ -10,5 +11,10 @@ export * from './ui/tabs'; export * from './ui/badge'; export * from './ui/dialog'; export * from './ui/switch'; +export * from './ui/dropdown-menu'; +export * from './ui/popover'; export * from './ui/input'; +export * from './ui/select'; +export * from './ui/label'; export * from './ui/progress'; +export * from './ui/tooltip'; diff --git a/packages/ui/lib/components/ui/badge.tsx b/packages/ui/lib/components/ui/badge.tsx index 80fa8b3..e95455e 100644 --- a/packages/ui/lib/components/ui/badge.tsx +++ b/packages/ui/lib/components/ui/badge.tsx @@ -2,25 +2,38 @@ import { cn } from '../../utils'; import type * as React from 'react'; -interface BadgeProps extends React.HTMLAttributes { - variant?: 'default' | 'secondary' | 'destructive' | 'outline'; -} +type BadgeBaseProps = { + isActive?: boolean; +}; + +type BadgeAsButton = BadgeBaseProps & { + as: 'button'; +} & React.ButtonHTMLAttributes; + +type BadgeAsAnchor = BadgeBaseProps & { + as: 'a'; +} & React.AnchorHTMLAttributes; + +type BadgeAsDiv = BadgeBaseProps & { + as?: 'div'; +} & React.HTMLAttributes; + +type BadgeProps = BadgeAsButton | BadgeAsAnchor | BadgeAsDiv; -function Badge({ className, variant = 'default', ...props }: BadgeProps) { +function Badge({ className, isActive = false, as: Component = 'div', ...props }: BadgeProps) { return ( -
)} /> ); } diff --git a/packages/ui/lib/components/ui/chart.tsx b/packages/ui/lib/components/ui/chart.tsx index 9c3da2a..028625c 100644 --- a/packages/ui/lib/components/ui/chart.tsx +++ b/packages/ui/lib/components/ui/chart.tsx @@ -44,9 +44,10 @@ const ChartContainer = React.forwardRef< data-chart={chartId} ref={ref} className={cn( - '[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid-horizontal_line]:stroke-border [&_.recharts-cartesian-grid-vertical_line]:stroke-border [&_.recharts-cartesian-grid_line]:stroke-border [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke="#ccc"]]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-reference-line-line]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke="#fff"]]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-rectangle.recharts-tooltip-wrapper]:outline-none [&_.recharts-sector[stroke="#fff"]]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none', + '[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid-horizontal_line]:stroke-border [&_.recharts-cartesian-grid-vertical_line]:stroke-border [&_.recharts-cartesian-grid_line]:stroke-border [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke="#ccc"]]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-reference-line-line]:stroke-border flex aspect-video min-w-0 justify-center text-xs [&_.recharts-dot[stroke="#fff"]]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-rectangle.recharts-tooltip-wrapper]:outline-none [&_.recharts-sector[stroke="#fff"]]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none', className, )} + style={{ minWidth: 0, minHeight: 0 }} {...props}> {children}
diff --git a/packages/ui/lib/components/ui/dropdown-menu.tsx b/packages/ui/lib/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b60492b --- /dev/null +++ b/packages/ui/lib/components/ui/dropdown-menu.tsx @@ -0,0 +1,46 @@ +import { cn } from '../../utils'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import * as React from 'react'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className = '', sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className = '', inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }; diff --git a/packages/ui/lib/components/ui/input.tsx b/packages/ui/lib/components/ui/input.tsx index ed4ddd5..cd57992 100644 --- a/packages/ui/lib/components/ui/input.tsx +++ b/packages/ui/lib/components/ui/input.tsx @@ -8,9 +8,7 @@ const Input = React.forwardRef(({ className, type, type={type} className={cn( 'border-input text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', - // Use background color that adapts to theme - light mode uses background, dark mode uses secondary for contrast - 'bg-background', - 'dark:bg-[hsl(var(--secondary))]', + 'bg-background dark:bg-secondary', className, )} ref={ref} diff --git a/packages/ui/lib/components/ui/label.tsx b/packages/ui/lib/components/ui/label.tsx new file mode 100644 index 0000000..934da0f --- /dev/null +++ b/packages/ui/lib/components/ui/label.tsx @@ -0,0 +1,20 @@ +import { cn } from '../../utils'; +import * as React from 'react'; + +type LabelProps = React.LabelHTMLAttributes; + +const Label = React.forwardRef(({ className, ...props }, ref) => ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control +