diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..1d3d98c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,46 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(timeout:*)",
+ "Bash(npm run dev:*)",
+ "Bash(node --version:*)",
+ "Bash(npm --version)",
+ "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:*)",
+ "Bash(xargs sed -i \"s|from ''@extension/shared/lib/utils/csv-tracker''|from ''@app/utils/csv-tracker''|g; s|from ''@extension/shared/lib/utils/storage-service''|from ''@app/utils/storage-service''|g; s|from ''@extension/shared/lib/utils/weekly-stats-storage''|from ''@app/utils/weekly-stats-storage''|g; s|from ''@extension/shared/lib/utils/user-stats-storage''|from ''@app/utils/user-stats-storage''|g; s|from ''@extension/shared/lib/utils/types''|from ''@app/types''|g; s|from ''@extension/shared/lib/hooks/useTrackedDataQuery''|from ''@app/hooks/useTrackedDataQuery''|g; s|from ''@extension/shared/lib/hooks/useUserStatsQuery''|from ''@app/hooks/useUserStatsQuery''|g; s|from ''@extension/shared/lib/hooks/useWeeklyStatsQuery''|from ''@app/hooks/useWeeklyStatsQuery''|g; s|from ''@extension/shared/lib/hooks/useFormatting''|from ''@app/hooks/useFormatting''|g; s|from ''@extension/shared/lib/hooks/useHourlyExp''|from ''@app/hooks/useHourlyExp''|g; s|from ''@extension/shared/lib/hooks/useScreenData''|from ''@app/hooks/useScreenData''|g; s|from ''@extension/shared/lib/hooks/useTrackedData''|from ''@app/hooks/useTrackedData''|g; s|from ''@extension/shared/lib/hooks/useUserStats''|from ''@app/hooks/useUserStats''|g; s|from ''@extension/shared/lib/hooks/useHourStats''|from ''@app/hooks/useHourStats''|g; s|from ''@extension/shared/lib/hooks/usePeriodStats''|from ''@app/hooks/usePeriodStats''|g; s|from ''@extension/shared/lib/hooks/useDataExport''|from ''@app/hooks/useDataExport''|g; s|from ''@extension/shared/lib/hooks/useItemValuesQuery''|from ''@app/hooks/useItemValuesQuery''|g; s|import\\(''@extension/shared/lib/utils/weekly-stats-storage''\\)|import\\(''@app/utils/weekly-stats-storage''\\)|g\")",
+ "Bash(xargs sed:*)",
+ "Bash(pnpm install:*)",
+ "Bash(pnpm build:*)",
+ "Bash(pnpm add:*)",
+ "Bash(pnpm format:*)",
+ "Bash(git add:*)",
+ "Bash(git commit:*)",
+ "Bash(npx eslint:*)",
+ "Bash(git push:*)",
+ "Bash(pnpm type-check:*)",
+ "Bash(pnpm dev)",
+ "Bash(npx vite:*)",
+ "Bash(taskkill:*)",
+ "Bash(ls:*)",
+ "Bash(python3 -c \" import sys, json for line in sys.stdin: try: obj = json.loads\\(line.strip\\(\\)\\) if obj.get\\(''type''\\) == ''human'': msg = obj.get\\(''message'', {}\\) if isinstance\\(msg, dict\\): content = msg.get\\(''content'', ''''\\) if isinstance\\(content, str\\): print\\(''HUMAN:'', content[:300]\\) elif isinstance\\(content, list\\): for c in content: if isinstance\\(c, dict\\) and c.get\\(''type''\\) == ''text'': print\\(''HUMAN:'', c.get\\(''text'', ''''\\)[:300]\\) elif obj.get\\(''type''\\) == ''assistant'': msg = obj.get\\(''message'', {}\\) if isinstance\\(msg, dict\\): content = msg.get\\(''content'', ''''\\) if isinstance\\(content, list\\): for c in content: if isinstance\\(c, dict\\) and c.get\\(''type''\\) == ''text'': print\\(''ASSISTANT:'', c.get\\(''text'', ''''\\)[:200]\\) except: pass \")",
+ "Bash(node -e:*)",
+ "WebFetch(domain:ui.shadcn.com)",
+ "WebFetch(domain:raw.githubusercontent.com)",
+ "Bash(cd:*)",
+ "Bash(node:*)",
+ "Bash(pnpm remove:*)",
+ "Bash(pnpm test:*)",
+ "Bash(python -m json.tool:*)",
+ "Bash(git ls-tree:*)",
+ "Bash(echo:*)",
+ "Bash(awk:*)"
+ ]
+ }
+}
diff --git a/.github/workflows/auto-change-prs-branch.yml b/.github/workflows/auto-change-prs-branch.yml
deleted file mode 100644
index b7ba9b5..0000000
--- a/.github/workflows/auto-change-prs-branch.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: Make sure new PRs are sent to development
-
-on:
- pull_request_target:
- types: [ opened ]
-
-jobs:
- check-branch:
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- steps:
- - uses: Vankka/pr-target-branch-action@v3
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- target: main
- exclude: dev
- change-to: dev
- comment: |
- Your PR's base branch was set to `main`, PRs should be set to target `dev`.
- The base branch of this PR has been automatically changed to `dev`, please check that there are no merge conflicts.
- already-exists-action: close_this
- already-exists-comment: "Closing, because {url} basing on the same branch"
diff --git a/.github/workflows/build-zip.yml b/.github/workflows/build-zip.yml
index 53d5d65..f55ec17 100644
--- a/.github/workflows/build-zip.yml
+++ b/.github/workflows/build-zip.yml
@@ -1,6 +1,9 @@
-name: Build And Upload Extension Zip Via Artifact
+name: Build Extension
-on: [ pull_request_target ]
+on: [pull_request]
+
+permissions:
+ contents: read
jobs:
build:
@@ -10,10 +13,16 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- node-version-file: '.nvmrc'
+ node-version-file: ".nvmrc"
cache: pnpm
- run: pnpm install --frozen-lockfile --prefer-offline
+ - name: Create .env for build
+ run: |
+ echo "CLI_CEB_DEV=false" > .env
+ echo "CLI_CEB_FIREFOX=false" >> .env
- run: pnpm build
+ - run: pnpm zip
- uses: actions/upload-artifact@v4
with:
- path: dist/*
+ name: syrnia-tracker
+ path: dist-zip/*.zip
diff --git a/.github/workflows/cancel-other-workflows-on-close.yml b/.github/workflows/cancel-other-workflows-on-close.yml
deleted file mode 100644
index 1d6f9c7..0000000
--- a/.github/workflows/cancel-other-workflows-on-close.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-name: Cancel PR workflows on close
-on:
- pull_request_target:
- types: [ closed ]
-
-jobs:
- cancel:
- runs-on: ubuntu-latest
- permissions:
- actions: write
- if: ${{ github.event.pull_request.merged == false }}
- steps:
- - name: Cancel all queued/in_progress workflows for this PR
- env:
- BRANCH: ${{ github.event.pull_request.head.ref }}
- run: |
- owner="${GITHUB_REPOSITORY%%/*}"
- repo="${GITHUB_REPOSITORY##*/}"
- branch="$BRANCH"
- token="${{ secrets.GITHUB_TOKEN }}"
-
- runs=$(curl -s -H "Accept: application/vnd.github+json" \
- -H "Authorization: Bearer $token" \
- -H "X-GitHub-Api-Version: 2022-11-28" \
- "https://api.github.com/repos/$owner/$repo/actions/runs?branch=$branch&status=queued" | jq -r '.workflow_runs[].id')
-
- runs+=" "
- runs+=$(curl -s -H "Accept: application/vnd.github+json" \
- -H "Authorization: Bearer $token" \
- -H "X-GitHub-Api-Version: 2022-11-28" \
- "https://api.github.com/repos/$owner/$repo/actions/runs?branch=$branch&status=in_progress" | jq -r '.workflow_runs[].id')
-
- for run_id in $runs; do
- if [ -n "$run_id" ]; then
- echo "Cancelling workflow run: $run_id"
- curl -s -X POST \
- -H "Accept: application/vnd.github+json" \
- -H "Authorization: Bearer $token" \
- -H "X-GitHub-Api-Version: 2022-11-28" \
- "https://api.github.com/repos/$owner/$repo/actions/runs/$run_id/cancel"
- fi
- done
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index bcbcf9e..0000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: "CodeQL Advanced"
-
-on:
- push:
- branches: [ "main", "dev" ]
- pull_request:
- branches: [ "main", "dev" ]
- schedule:
- - cron: '40 18 * * 0'
-
-jobs:
- analyze:
- name: Analyze (${{ matrix.language }})
- runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
- permissions:
- security-events: write
- packages: read
- strategy:
- fail-fast: false
- matrix:
- include:
- - language: actions
- build-mode: none
- - language: javascript-typescript
- build-mode: none
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v3
- with:
- languages: ${{ matrix.language }}
- build-mode: ${{ matrix.build-mode }}
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3
- with:
- category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/dependencies-auto-merge.yml b/.github/workflows/dependencies-auto-merge.yml
deleted file mode 100644
index 0553b83..0000000
--- a/.github/workflows/dependencies-auto-merge.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: Dependabot Pull Request Approve and Merge
-on: pull_request_target
-
-permissions:
- pull-requests: write
- contents: write
-
-jobs:
- dependabot:
- runs-on: ubuntu-latest
- if: ${{ github.actor == 'dependabot[bot]' }}
- steps:
- - name: Dependabot metadata
- id: dependabot-metadata
- uses: dependabot/fetch-metadata@v2
- with:
- github-token: "${{ secrets.GITHUB_TOKEN }}"
-
- - name: Approve a PR
- run: gh pr review --approve "$PR_URL"
- env:
- PR_URL: ${{ github.event.pull_request.html_url }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Enable auto-merge for Dependabot PRs
- if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}
- run: gh pr merge --auto --squash "$PR_URL"
- env:
- PR_URL: ${{ github.event.pull_request.html_url }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/e2e-modular.yml b/.github/workflows/e2e-modular.yml
deleted file mode 100644
index d473d23..0000000
--- a/.github/workflows/e2e-modular.yml
+++ /dev/null
@@ -1,71 +0,0 @@
-name: Modular E2E Tests Matrix
-
-on: [ pull_request_target ]
-
-permissions:
- contents: read
-
-env:
- ALL_SCENARIOS: '["content", "content-ui", "content-runtime", "background", "new-tab", "popup", "devtools", "side-panel", "options"]'
-
-jobs:
- e2e-tests:
- name: '${{ matrix.browser }} | Scenario: ${{ matrix.scenario }}'
- runs-on: ubuntu-latest
-
- strategy:
- fail-fast: false
- matrix:
- browser: ['chrome', 'firefox']
- scenario: ['content', 'content-ui', 'content-runtime', 'background', 'new-tab', 'popup', 'devtools', 'side-panel', 'options']
-
- env:
- CEB_CI: true
- CURRENT_SCENARIO: ${{ matrix.scenario }}
-
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version-file: '.nvmrc'
- cache: pnpm
-
- - run: pnpm install --frozen-lockfile --prefer-offline
-
- - name: Configure modules | scenario "${{ matrix.scenario }}"
- run: |
- echo "Deleting all features except '${{ matrix.scenario }}'"
- pnpm module-manager --de ${{ matrix.scenario }} tests
-
- - name: Run E2E tests (${{ matrix.browser }})
- run: pnpm e2e${{ matrix.browser == 'firefox' && ':firefox' || '' }}
-
- - name: Restore one random feature and re-run E2E tests
- run: |
- echo "ALL_SCENARIOS=$ALL_SCENARIOS"
- echo "CURRENT_SCENARIO=$CURRENT_SCENARIO"
-
- scenarios=($(echo "$ALL_SCENARIOS" | jq -r '.[]'))
-
- # Filter out current scenario
- filtered_scenarios=()
- for scenario in "${scenarios[@]}"; do
- if [ "$scenario" != "$CURRENT_SCENARIO" ]; then
- filtered_scenarios+=("$scenario")
- fi
- done
-
- if [ ${#filtered_scenarios[@]} -eq 0 ]; then
- echo "::warning::No other scenario available, skipping restoration."
- exit 0
- fi
-
- random_feature="${filtered_scenarios[$RANDOM % ${#filtered_scenarios[@]}]}"
-
- echo "Restoring feature: $random_feature"
- pnpm module-manager -r "$random_feature"
-
- - name: Run again E2E tests (${{ matrix.browser }})
- run: pnpm ${{ matrix.browser == 'chrome' && 'e2e' || 'e2e:firefox' }}
-
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
deleted file mode 100644
index 0c1e70f..0000000
--- a/.github/workflows/e2e.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: Run E2E Tests
-
-on: [ pull_request_target ]
-
-permissions:
- contents: read
-
-jobs:
- e2e:
- name: E2E tests on ${{ matrix.browser }}
- runs-on: ubuntu-latest
- strategy:
- matrix:
- browser: [chrome, firefox]
- env:
- CEB_CI: true
- steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v4
- with:
- node-version-file: '.nvmrc'
- cache: pnpm
- - run: pnpm install --frozen-lockfile --prefer-offline
- - run: pnpm ${{ matrix.browser == 'chrome' && 'e2e' || 'e2e:firefox' }}
diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml
deleted file mode 100644
index 5435e78..0000000
--- a/.github/workflows/greetings.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: Greetings
-
-on: [ pull_request_target, issues ]
-
-jobs:
- greeting:
- runs-on: ubuntu-latest
- permissions:
- issues: write
- pull-requests: write
- steps:
- - uses: actions/first-interaction@v1
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- issue-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.'
- pr-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.'
\ No newline at end of file
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 90279d9..9e97f19 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,6 +1,9 @@
name: Lint Check
-on: [ pull_request_target ]
+on: [pull_request]
+
+permissions:
+ contents: read
jobs:
eslint:
@@ -10,8 +13,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- node-version-file: '.nvmrc'
+ node-version-file: ".nvmrc"
cache: pnpm
-
- run: pnpm install --frozen-lockfile --prefer-offline
- run: pnpm lint
diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml
deleted file mode 100644
index d86e508..0000000
--- a/.github/workflows/prettier.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: Formating validation
-
-on: [ pull_request_target ]
-
-jobs:
- prettier:
- name: Prettier Check
- runs-on: ubuntu-latest
- steps:
- - name: Checkout Repository
- uses: actions/checkout@v4
-
- - name: Run Prettier
- id: prettier-run
- uses: rutajdash/prettier-cli-action@v1.0.0
- with:
- config_path: ./.prettierrc
- file_pattern: "*.{js,jsx,ts,tsx,json}"
-
- - name: Prettier Output
- if: ${{ failure() }}
- shell: bash
- run: |
- echo "The following files aren't formatted properly:"
- echo "${{steps.prettier-run.outputs.prettier_output}}"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..5c7e803
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,20 @@
+name: Tests
+
+on: [pull_request]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: ".nvmrc"
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile --prefer-offline
+ - run: pnpm test
diff --git a/.gitignore b/.gitignore
index 707050d..08afbac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,12 +4,10 @@
# testing
**/coverage
-# build
+# build output
**/dist
-**/build
+!build/hmr/dist
**/dist-zip
-chrome-extension/manifest.js
-chrome-extension/pre-build.tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
# env
@@ -19,7 +17,6 @@ tsconfig.tsbuildinfo
# etc
.DS_Store
.idea
-**/.turbo
# compiled
**/tailwind-output.css
diff --git a/.prettierrc b/.prettierrc
index 3537583..e274e57 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,11 +1,15 @@
{
"trailingComma": "all",
"semi": true,
- "singleQuote": true,
+ "singleQuote": false,
+ "jsxSingleQuote": false,
"arrowParens": "avoid",
"printWidth": 120,
+ "tabWidth": 2,
+ "useTabs": false,
"bracketSameLine": true,
"htmlWhitespaceSensitivity": "strict",
+ "endOfLine": "lf",
"plugins": [
"prettier-plugin-tailwindcss"
]
diff --git a/.turbo/preferences/tui.json b/.turbo/preferences/tui.json
new file mode 100644
index 0000000..15055d5
--- /dev/null
+++ b/.turbo/preferences/tui.json
@@ -0,0 +1,4 @@
+{
+ "is_task_list_visible": true,
+ "active_task": null
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..85c56a2
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,38 @@
+{
+ "editor.formatOnSave": true,
+ "editor.formatOnPaste": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit",
+ "source.organizeImports": "never"
+ },
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true,
+ "editor.trimAutoWhitespace": true,
+ "files.trimTrailingWhitespace": true,
+ "files.insertFinalNewline": true,
+ "files.trimFinalNewlines": true,
+ "files.eol": "\n",
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[json]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[css]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[javascript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[markdown]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "files.trimTrailingWhitespace": false
+ },
+ "eslint.validate": ["typescript", "typescriptreact"],
+ "typescript.preferences.quoteStyle": "double",
+ "javascript.preferences.quoteStyle": "double"
+}
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e5ec62d..4c00900 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,237 +1,193 @@
-# Architecture Documentation
-
-This document outlines the architecture, patterns, and structure of the Chrome Extension codebase.
+# Architecture
## Overview
-This extension is a **Side-Panel only** Chrome extension that tracks game statistics, experience gains, and loot data. The codebase follows **MVP (Model-View-Presenter)** patterns with clear separation of concerns.
+Syrnia Tracker is a Chrome Extension (Manifest V3) that tracks game statistics for the browser-based MMORPG Syrnia. Built with React 19, TypeScript, Tailwind CSS, and Vite 6.
+
+The extension has three runtime contexts:
+
+1. **Content Scripts** - Injected into web pages to scrape game data from the DOM
+2. **Background Service Worker** - Processes and stores data received from content scripts
+3. **Side Panel** - React UI that displays charts, stats, and player data
## Project Structure
```
Chrome-Ext/
-├── chrome-extension/ # Extension manifest and build config
-│ ├── manifest.ts # Extension manifest (side-panel only)
-│ └── src/
-│ └── background/ # Background service worker
-├── pages/
-│ ├── content/ # Content scripts (data scraping)
-│ └── side-panel/ # Main UI (React components)
-│ └── src/
-│ ├── components/ # React components
-│ ├── constants/ # Constants and configuration
-│ └── SidePanel.tsx # Main entry point
-└── packages/
- ├── shared/ # Shared utilities and hooks
- │ └── lib/
- │ ├── hooks/ # Reusable React hooks
- │ └── utils/ # Utility functions
- └── ui/ # UI component library
+├── app/ # All application source code
+│ ├── background/ # Chrome service worker
+│ │ └── index.ts
+│ ├── content/ # Content scripts (DOM scraping)
+│ │ ├── scrapeScreenData.ts
+│ │ ├── scrapeUserStats.ts
+│ │ ├── sendData.ts
+│ │ └── matches/ # Build entry points
+│ │ ├── all/ # Runs on all pages
+│ │ └── stats/ # Runs on stats page
+│ ├── panel/ # React side panel UI
+│ │ ├── index.html
+│ │ ├── index.tsx
+│ │ ├── SidePanel.tsx
+│ │ ├── providers/ # React Query provider
+│ │ ├── hooks/ # Panel-specific hooks
+│ │ ├── constants/ # Tabs, FightingLocations
+│ │ └── components/ # Page-level views
+│ │ ├── Dashboard/ # Overview with exp charts
+│ │ ├── Profile/ # Player profile
+│ │ ├── Performance/ # Combat stats and equipment
+│ │ ├── LootMap/ # Loot tracking
+│ │ ├── DataView/ # Raw data table
+│ │ ├── Header/ # Navigation
+│ │ ├── Settings/ # Theme and preferences
+│ │ └── TrackedHistory/ # Historical data
+│ ├── components/ # Shared components
+│ │ ├── ui/ # Radix UI primitives (shadcn/ui)
+│ │ └── *.tsx # ErrorDisplay, LoadingSpinner, etc.
+│ ├── hooks/ # All shared React hooks
+│ ├── utils/ # All utility functions
+│ │ └── storage/ # Chrome storage abstraction
+│ ├── hoc/ # HOCs (withSuspense, withErrorBoundary)
+│ ├── types/ # Shared TypeScript types
+│ ├── constants/ # Message types, skill lists
+│ ├── styles/ # Global CSS
+│ └── assets/ # Icons
+├── build/ # Build tooling
+│ ├── build.ts # Orchestrates content + background builds
+│ ├── env.ts # Environment variables
+│ ├── plugins/ # Vite plugins (make-manifest)
+│ ├── hmr/ # Hot module reload (dev)
+│ ├── dev-utils/ # Manifest parser
+│ └── zipper/ # Extension packaging
+├── public/ # Static assets (icons, armor images)
+├── manifest.js # Chrome MV3 manifest definition
+├── vite.config.ts # Side panel Vite config
+├── tsconfig.json # TypeScript config (@app/* alias)
+├── tailwind.config.ts # Tailwind CSS config
+└── eslint.config.ts # ESLint config
```
-## Architecture Patterns
-
-### MVP (Model-View-Presenter) Pattern
-
-Components follow the MVP pattern for clear separation:
-
-- **Model**: Data layer (hooks, storage, data fetching)
-- **View**: Presentation layer (React components, UI)
-- **Presenter**: Business logic layer (custom hooks, data transformation)
-
-#### Example Structure
+## Data Flow
```
-Component/
-├── index.tsx # View (presentation only)
-├── hooks/ # Presenter (business logic)
-│ └── useComponentLogic.ts
-└── types.ts # Model (data types)
+[Syrnia Game Page]
+ |
+ v
+[Content Scripts] -- chrome.runtime.sendMessage() --> [Background Worker]
+ (all.iife.js) (background.js)
+ (stats.iife.js) |
+ v
+ [Chrome Storage API]
+ (local storage)
+ |
+ v
+ [Side Panel React App]
+ (React Query hooks)
```
-### Component Guidelines
-
-1. **Components should be presentational** - minimal logic, focused on rendering
-2. **Business logic in hooks** - extract complex logic to custom hooks
-3. **Shared utilities** - common functions in `packages/shared/lib/utils`
-4. **Type safety** - use TypeScript types for all data structures
+### Content Scripts
-## Key Packages
+- **`matches/all/`** - Runs on all pages. Scrapes screen data (current action, exp gains, loot) via `scrapeScreenData.ts` and sends to background worker via `sendData.ts`.
+- **`matches/stats/`** - Runs on the Syrnia stats page. Scrapes detailed user stats via `scrapeUserStats.ts`.
-### `@extension/shared`
+### Background Worker
-Shared utilities and hooks used across the extension.
+- **`background/index.ts`** - Listens for messages from content scripts, processes data, and stores it in Chrome storage using CSV-based tracking (`csv-tracker.ts`, `csv-storage.ts`).
-#### Hooks
+### Side Panel
-- **`useHourlyExp`**: Tracks experience gains for the current hour
-- **`useTrackedData`**: Manages CSV-tracked data with filtering and aggregation
-- **`useScreenData`**: Receives real-time screen data from content scripts
-- **`useFormatting`**: Provides formatting utilities (exp, time, drops)
-- **`useHourStats`**: Calculates statistics for a specific hour
+- React 19 app with React Query for data caching
+- Views are tab-based, switched via Header component
+- Each view has a co-located `use*.ts` hook for business logic
-#### Utilities
+## Key Patterns
-- **`formatting.ts`**: Formatting functions (formatExp, formatTime, parseDrops, parseDropAmount)
-- **`csv-tracker.ts`**: CSV data tracking and aggregation
-- **`csv-storage.ts`**: Chrome storage operations for CSV data
+### Presentational Components + Hook Logic
-### `@extension/ui`
+All business logic lives in hooks. Components only render:
-UI component library with shadcn/ui components.
-
-## Component Structure
+```typescript
+// useDashboard.ts - all logic here
+export const useDashboard = () => {
+ const { allData } = useTrackedDataQuery();
+ // ... calculations, formatting, memoization
+ return { stats, chartData, ... };
+};
-### Side Panel Components
+// index.tsx - pure presentation
+const Dashboard = memo(() => {
+ const { stats, chartData } = useDashboard();
+ return ...;
+});
+```
-Located in `pages/side-panel/src/components/`:
+### React Query for Data
-1. **Dashboard** - Overview of current and previous hour stats
-2. **Stats** - Detailed experience tracking per skill
-3. **LootMap** - Loot and drop tracking
-4. **TrackedHistory** - Historical data with time period filtering
-5. **Header** - Navigation and tab switching
+All data access goes through React Query hooks in `app/hooks/`:
+- `useTrackedDataQuery` - CSV-tracked hourly EXP data
+- `useUserStatsQuery` - Player profile stats from stats page
+- `useWeeklyStatsQuery` - Weekly aggregated statistics
+- `useItemValuesQuery` - Item value data
-### Component Pattern
+### Chrome Storage Abstraction
-Each component should:
+`app/utils/storage/` provides a typed wrapper around `chrome.storage`:
```typescript
-// 1. Import hooks and utilities
-import { useHourlyExp, useTrackedData, useFormatting } from '@extension/shared';
-import { Card, CardContent } from '@extension/ui';
-
-// 2. Use custom hooks for business logic
-const MyComponent = memo(() => {
- const { formatExp } = useFormatting();
- const hourlyExp = useHourlyExp();
-
- // 3. Minimal component logic
- // 4. Return JSX
+const storage = createStorage('key', defaultValue, {
+ storageEnum: StorageEnum.Local,
+ liveUpdate: true,
});
```
-## Data Flow
+- `createStorage()` - Creates typed storage with get/set/subscribe
+- Supports Local, Session, and Sync storage areas
+- Live updates via `chrome.storage.onChanged` listener
+### Import Alias
+
+Single path alias: `@app/*` maps to `app/*`
+
+```typescript
+import { useStorage } from '@app/hooks';
+import { Card, Button } from '@app/components';
+import { cn } from '@app/utils/cn';
+import type { CSVRow } from '@app/types';
```
-Content Script (pages/content)
- ↓ (scrapes game data)
-Background Script (chrome-extension/src/background)
- ↓ (processes and stores)
-Chrome Storage (local)
- ↓ (hooks read from storage)
-Side Panel Components
- ↓ (display data)
-User Interface
-```
+
+## Build System
+
+Three separate Vite builds:
+
+1. **Content scripts** (IIFE) - Each `app/content/matches/*/index.ts` builds to `dist/content/*.iife.js`
+2. **Background** (ES module library) - `app/background/index.ts` builds to `dist/background.js`
+3. **Side panel** (standard HTML app) - `app/panel/` builds to `dist/side-panel/`
+
+Build orchestration: `tsx build/build.ts` runs builds 1+2, then `vite build` runs build 3.
## Adding New Features
-### 1. Create a New Component
+### New Side Panel View
-```typescript
-// pages/side-panel/src/components/NewFeature/index.tsx
-import { useTrackedData, useFormatting } from '@extension/shared';
-import { Card } from '@extension/ui';
-
-export const NewFeature = memo(() => {
- const { allData } = useTrackedData();
- const { formatExp } = useFormatting();
-
- return ...;
-});
-```
+1. Create component in `app/panel/components/NewFeature/`
+2. Add `useNewFeature.ts` hook with all logic
+3. Add `index.tsx` as presentational component
+4. Register tab in `app/panel/constants/Tabs.ts`
+5. Add case to `SidePanel.tsx` render switch
-### 2. Add to Side Panel
+### New Shared Hook
-```typescript
-// pages/side-panel/src/SidePanel.tsx
-import NewFeature from './components/NewFeature';
-
-const renderComponent = (screen: String) => {
- switch (screen) {
- case DISPLAY.NEW_FEATURE:
- return ;
- // ...
- }
-};
-```
+Add to `app/hooks/` and export from `app/hooks/index.ts`.
-### 3. Create Reusable Hooks
+### New Shared Component
-If logic is reusable, create a hook in `packages/shared/lib/hooks/`:
+Add to `app/components/` (or `app/components/ui/` for primitives) and export from barrel.
-```typescript
-// packages/shared/lib/hooks/useNewFeature.ts
-export const useNewFeature = () => {
- // Business logic here
- return { /* data */ };
-};
-```
+## Conventions
-## Best Practices
-
-1. **Separation of Concerns**
- - View: Only rendering logic
- - Presenter: Business logic in hooks
- - Model: Data types and storage
-
-2. **Reusability**
- - Extract common logic to hooks
- - Use shared utilities for formatting
- - Create reusable UI components
-
-3. **Type Safety**
- - Define types for all data structures
- - Use TypeScript strictly
- - Export types from shared package
-
-4. **Performance**
- - Use `memo()` for components
- - Use `useMemo()` for expensive calculations
- - Avoid unnecessary re-renders
-
-5. **Error Handling**
- - Use try-catch for async operations
- - Provide user-friendly error messages
- - Log errors appropriately
-
-## File Naming Conventions
-
-- **Components**: PascalCase (`Dashboard.tsx`)
-- **Hooks**: camelCase with `use` prefix (`useHourStats.ts`)
-- **Utilities**: camelCase (`formatting.ts`)
-- **Types**: camelCase with `.ts` extension (`types.ts`)
-- **Constants**: UPPER_SNAKE_CASE (`DISPLAY.ts`)
-
-## Extension Manifest
-
-The extension only includes:
-- **Side Panel**: Main UI interface
-- **Content Scripts**: Data scraping from game pages
-- **Background Script**: Data processing and storage
-
-**Removed Features** (not part of this extension):
-- New Tab override
-- Popup
-- Options page
-- DevTools
-
-## Testing Considerations
-
-When adding new features:
-1. Test with real game data
-2. Verify data persistence across sessions
-3. Check performance with large datasets
-4. Ensure proper error handling
-
-## Code Review Checklist
-
-For game developer review:
-- ✅ Clean, readable code
-- ✅ Clear separation of concerns
-- ✅ Proper TypeScript types
-- ✅ No hardcoded values
-- ✅ Proper error handling
-- ✅ Performance considerations
-- ✅ Documentation for complex logic
+- Functional components with `memo()`
+- Arrow functions enforced (`func-style`)
+- `const` over `let`, no `var`
+- `import type {}` for type-only imports
+- Barrel `index.ts` in each directory
+- Parameterless `catch {}` (no `_error`)
+- Strict import ordering (auto-fixable)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2a44055
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,106 @@
+# CLAUDE.md
+
+## Project Overview
+
+Syrnia Tracker - A Chrome extension (Manifest V3) that tracks game stats for Syrnia. Built with React, TypeScript, Tailwind CSS, and Vite.
+
+## Commands
+
+- `pnpm build` - Build the extension (content scripts + background + side panel)
+- `pnpm dev` - Dev build with HMR
+- `pnpm lint` - ESLint check
+- `pnpm lint:fix` - Auto-fix lint errors
+- `pnpm format` - Format with Prettier
+- `pnpm type-check` - TypeScript type checking
+
+## After Every Code Change
+
+1. **Format**: Run `pnpm format` to format all modified files with Prettier.
+2. **Lint check**: Run `pnpm lint` to check for lint errors. Fix all errors before considering the task complete.
+3. **Verify**: Run `pnpm lint` once more to confirm zero errors remain.
+4. **Spot-check changed files**: Re-read each file you modified and verify:
+ - Double quotes `"` are used for all strings (not single quotes `'`)
+ - No unnecessary blank lines (max 1 consecutive empty line)
+ - Lines stay within 120 characters (`printWidth: 120`)
+ - Files end with a single newline
+ - No trailing whitespace on any line
+ - Indentation uses 2 spaces (no tabs)
+ - Trailing commas are present in multi-line structures
+ - If any of the above are wrong, run `pnpm format` again or fix manually
+
+## Project Structure
+
+```
+app/ # All application source code
+ background/ # Chrome service worker (background.js)
+ content/ # Content scripts injected into web pages
+ matches/ # Build entry points (all/, stats/)
+ panel/ # React side panel UI
+ components/ # Page-level components (Dashboard, Profile, etc.)
+ hooks/ # Panel-specific hooks (useGlobalDataSync)
+ providers/ # React Query provider
+ constants/ # Panel constants (Tabs, FightingLocations)
+ components/ # Shared React components
+ ui/ # Radix UI primitives (Button, Card, Dialog, etc.)
+ hooks/ # All React hooks
+ utils/ # All utility functions
+ storage/ # Chrome storage abstraction
+ hoc/ # Higher-order components (withSuspense, withErrorBoundary)
+ types/ # Shared TypeScript types
+ constants/ # App-wide constants (message types, skills)
+ styles/ # Global CSS
+ assets/ # Icons and images
+build/ # Build tooling (NOT app code)
+ build.ts # Orchestrates content + background builds
+ env.ts # Environment variable handling
+ plugins/ # Vite plugins (make-manifest)
+ hmr/ # Hot module reload (dev only)
+ dev-utils/ # Manifest parser
+ zipper/ # Extension packaging
+public/ # Static assets copied to dist/
+```
+
+## Import Alias
+
+Use `@app/*` to import from the `app/` directory:
+
+```ts
+import { useStorage } from '@app/hooks';
+import { cn } from '@app/utils/cn';
+import { Card, CardContent } from '@app/components';
+import type { CSVRow } from '@app/types';
+```
+
+## Conventions
+
+- **Functional components only** - Use `const Component = memo(() => { ... })` pattern
+- **Presentational hooks** - All logic in hooks, components are presentational
+- **Arrow functions** - `func-style` rule enforces expression-style functions
+- **Prefer const** - No `var`, prefer `const` over `let`
+- **Double quotes** - Use `"` for all strings (Prettier enforces this)
+- **Consistent type imports** - Use `import type { ... }` for type-only imports
+- **Barrel exports** - Each directory has an `index.ts` re-exporting its contents
+- **No `_` prefixed unused vars** - Use parameterless `catch {}` blocks
+- **Import order** - Strict alphabetical ordering enforced by eslint. Auto-fixable with `--fix`
+
+## Formatting Rules (Prettier)
+
+- `printWidth`: 120 (max line length)
+- `tabWidth`: 2 (2-space indentation)
+- `singleQuote`: false (use double quotes `"`)
+- `semi`: true (always use semicolons)
+- `trailingComma`: all (trailing commas everywhere)
+- `arrowParens`: avoid (omit parens for single-arg arrows)
+- `bracketSameLine`: true (closing `>` on same line as last prop)
+- `endOfLine`: lf (Unix line endings)
+- Files must end with a single newline, no trailing whitespace
+
+## Build System
+
+Three separate Vite builds orchestrated by `build/build.ts`:
+
+1. **Content scripts** (IIFE) - `app/content/matches/*/index.ts` -> `dist/content/*.iife.js`
+2. **Background** (ES module) - `app/background/index.ts` -> `dist/background.js`
+3. **Side panel** (HTML app) - `app/panel/` -> `dist/side-panel/`
+
+The manifest is defined in `manifest.js` (root) and compiled to `dist/manifest.json` by the make-manifest plugin.
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/LICENSE b/LICENSE
new file mode 100644
index 0000000..8cf6220
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024-2026 Syrnia Tracker Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..8cb15b4
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,42 @@
+# Privacy Policy — Syrnia Tracker
+
+**Last updated:** February 2026
+
+## Data Collection
+
+Syrnia Tracker collects game statistics exclusively from pages on `syrnia.com`. This includes experience points, skill levels, loot data, and other in-game metrics displayed on the page.
+
+No personal information (name, email, location, etc.) is collected.
+
+## Data Storage
+
+All collected data is stored locally in your browser using Chrome's built-in storage API (`chrome.storage.local`). Data never leaves your device.
+
+## Data Transmission
+
+Syrnia Tracker does **not** transmit any data to external servers. There are:
+
+- No analytics or telemetry services
+- No third-party APIs or SDKs
+- No remote databases or cloud storage
+- No advertising networks
+
+## Permissions
+
+The extension requests the following permissions:
+
+- **storage** — Save tracked game data locally
+- **tabs / activeTab** — Detect when you are on a Syrnia game page
+- **sidePanel** — Display the tracker UI in Chrome's side panel
+- **scraping** — scrapes to read game page data
+- **downloads** — Export tracked data as CSV files
+
+Host permissions are restricted to `*://*.syrnia.com/*`.
+
+## Data Retention
+
+All data remains in Chrome's local storage until you explicitly clear it (via the extension's settings or by clearing browser data). Uninstalling the extension removes all stored data.
+
+## Contact
+
+For privacy questions or concerns, please open an issue on the project's GitHub repository.
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/README.md b/README.md
index 2d2dca2..fab7325 100755
--- a/README.md
+++ b/README.md
@@ -143,23 +143,8 @@
- [Data Collection Process](#data-collection-process)
- [Data Storage](#data-storage)
- [Side Panel Interface](#side-panel-interface)
-- [Installation](#installation)
- - [Chrome](#installation-chrome)
- - [Firefox](#installation-firefox)
-- [Install dependency](#install-dependency)
- - [For root](#install-dependency-for-root)
- - [For module](#install-dependency-for-module)
-- [Environment variables](#env-variables)
- - [Add new](#env-variables-new)
- - [Set via CLI](#env-variables-cli-set)
-- [Troubleshooting](#troubleshooting)
- - [Hot module reload seems to have frozen](#hot-module-reload-seems-to-have-frozen)
- - [Imports not resolving correctly](#imports-not-resolving-correctly)
-- [Community](#community)
-- [Debugging](#debugging)
-- [Reference](#reference)
-- [Star History](#star-history)
-- [Contributors](#contributors)
+- [Tech Stack](#tech-stack)
+- [Project Structure](#project-structure)
## What is Syrnia Tracker?
@@ -451,163 +436,28 @@ Data flows unidirectionally:
Game Page → Data Scraper → Background Worker → Storage → Cache → Side Panel
```
-## Installation
+## Tech Stack
-1. Clone this repository.( ```git clone https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite``` )
-2. Ensure your node version is >= than in `.nvmrc` file, recommend to use [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#intro)
-3. Edit `/packages/i18n/locales/`{your locale(s)}/`messages.json`
-4. In the objects `extensionDescription` and `extensionName`, change the `message` fields (leave `description` alone)
-5. Install pnpm globally: `npm install -g pnpm`
-6. Run `pnpm install`
-7. Check if you have that configuration in your IDE/Editor:
- - VS Code:
- - Installed [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
- - Installed [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
- - Enabled `Typescript Workbench version` in settings:
- - CTRL + SHIFT + P -> Search: `Typescript: Select Typescript version...` -> `Use Workbench version`
- - [Read more](https://code.visualstudio.com/docs/languages/typescript#_using-newer-typescript-versions)
- - Optional, for imports to work correctly in WSL, you might need to install the [Remote - WSL](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) extension and connect to WSL remotely from VS Code. See overview section in the extension page for more information.
- - WebStorm:
- - Configured [ESLint](https://www.jetbrains.com/help/webstorm/eslint.html#ws_eslint_configure_run_eslint_on_save)
- - Configured [Prettier](https://prettier.io/docs/en/webstorm.html)
- - Optional, but useful `File | Settings | Tools | Actions on Save`\
- -> `Optimize imports` and `Reformat code`
-8. Run `pnpm update-version ` for change the `version` to the desired version of your extension.
-
-> [!IMPORTANT]
-> On Windows, make sure you have WSL enabled and Linux distribution (e.g. Ubuntu) installed on WSL.
->
-> [Installation Guide](https://learn.microsoft.com/en-us/windows/wsl/install)
-
-Then, depending on the target browser:
-
-### For Chrome:
-
-1. Run:
- - Dev: `pnpm dev` (on Windows, you should run as administrator;
- see [issue#456](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/456))
- - Prod: `pnpm build`
-2. Open in browser - `chrome://extensions`
-3. Check - Developer mode
-4. Click - Load unpacked in the upper left corner
-5. Select the `dist` directory from the boilerplate project
-
-### For Firefox:
-
-1. Run:
- - Dev: `pnpm dev:firefox`
- - Prod: `pnpm build:firefox`
-2. Open in browser - `about:debugging#/runtime/this-firefox`
-3. Click - Load Temporary Add-on... in the upper right corner
-4. Select the `./dist/manifest.json` file from the boilerplate project
-
-> [!NOTE]
-> In Firefox, you load add-ons in temporary mode. That means they'll disappear after each browser close. You have to
-> load the add-on on every browser launch.
-
-## Install dependency for turborepo:
-
-### For root:
-
-1. Run `pnpm i -w`
-
-### For module:
-
-1. Run `pnpm i -F `
-
-`package` - Name of the package you want to install e.g. `nodemon` \
-`module-name` - You can find it inside each `package.json` under the key `name`, e.g. `@extension/content-script`, you
-can use only `content-script` without `@extension/` prefix
-
-## How do I disable modules I'm not using?
-
-[Read here](packages/module-manager/README.md)
-
-## Environment variables
-
-Read: [Env Documentation](packages/env/README.md)
-
-## Boilerplate structure
-
-### Chrome extension
-
-The extension lives in the `chrome-extension` directory and includes the following files:
-
-- [`manifest.ts`](chrome-extension/manifest.ts) - script that outputs the `manifest.json`
-- [`src/background`](chrome-extension/src/background) - [background script](https://developer.chrome.com/docs/extensions/mv3/background_pages/)
- (`background.service_worker` in manifest.json)
-- [`public`](chrome-extension/public/) - icons referenced in the manifest; content CSS for user's page injection
-
-> [!IMPORTANT]
-> To facilitate development, the boilerplate is configured to "Read and change all your data on all websites".
-> In production, it's best practice to limit the premissions to only the strictly necessary websites. See
-> [Declaring permissions](https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions)
-> and edit `manifest.js` accordingly.
-
-### Pages
-
-Code that is transpiled to be part of the extension lives in the [pages](pages) directory.
-
-- [`content`](pages/content) - Scraping of data
-- [`side-panel`](pages/side-panel/) - [sidepanel (Chrome 114+)](https://developer.chrome.com/docs/extensions/reference/api/sidePanel)
- (`side_panel.default_path` in manifest.json)
-
-### Packages
-
-Some shared packages:
-
-- `dev-utils` - utilities for Chrome extension development (manifest-parser, logger)
-- `env` - exports object which contain all environment variables from `.env` and dynamically declared
-- `hmr` - custom HMR plugin for Vite, injection script for reload/refresh, HMR dev-server
-- `i18n` - custom internationalization package; provides i18n function with type safety and other validation
-- `shared` - shared code for the entire project (types, constants, custom hooks, components etc.)
-- `storage` - helpers for easier integration with [storage](https://developer.chrome.com/docs/extensions/reference/api/storage), e.g. local/session storages
-- `tailwind-config` - shared Tailwind config for entire project
-- `tsconfig` - shared tsconfig for the entire project
-- `ui` - function to merge your Tailwind config with the global one; you can save components here
-- `vite-config` - shared Vite config for the entire project
-
-Other useful packages:
-
-- `zipper` - run `pnpm zip` to pack the `dist` folder into `extension-YYYYMMDD-HHmmss.zip` inside the newly created
- `dist-zip`
-- `module-manager` - run `pnpm module-manager` to enable/disable modules
-- `e2e` - run `pnpm e2e` for end-to-end tests of your zipped extension on different browsers
-
-## Troubleshooting
-
-### Hot module reload seems to have frozen
-
-If saving source files doesn't cause the extension HMR code to trigger a reload of the browser page, try this:
-
-1. Ctrl+C the development server and restart it (`pnpm run dev`)
-2. If you get a [`grpc` error](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/612),
- [kill the
- `turbo` process](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/612#issuecomment-2518982339)
- and run `pnpm dev` again.
-
-### Imports not resolving correctly
-
-If you are using WSL and imports are not resolving correctly, ensure that you have connected VS Code to WSL remotely using the [Remote - WSL](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) extension.
-
-## Community
-
-To chat with other community members, you can join the [Discord](https://discord.gg/4ERQ6jgV9a) server.
-You can ask questions on that server, and you can also help others.
-
-Also, suggest new features or share any challenges you've faced while developing Chrome extensions!
-
-## Debugging
-
-If you're debugging one, you can use [Brie](https://go.briehq.com/github?utm_source=CEB) lets you capture screenshots, errors, and network activity, making it easier for us to help.
-
-## Reference
-
-- [Chrome Extensions](https://developer.chrome.com/docs/extensions)
-- [Vite Plugin](https://vitejs.dev/guide/api-plugin.html)
-- [Rollup](https://rollupjs.org/guide/en/)
-- [Turborepo](https://turbo.build/repo/docs)
-- [Rollup-plugin-chrome-extension](https://www.extend-chrome.dev/rollup-plugin)
+- **React 19** + **TypeScript** — Side panel UI
+- **Tailwind CSS** — Styling with custom theme support
+- **Vite** — Build tooling (three separate builds: content scripts, background worker, side panel)
+- **TanStack Query** — Data fetching and cache management
+- **Recharts** — Charts and data visualization
+- **Chrome Manifest V3** — Extension APIs (`chrome.storage.local`, `chrome.sidePanel`, `chrome.runtime`)
+## Project Structure
+```
+app/
+ background/ # Chrome service worker — receives and stores data
+ content/ # Content modules — reads visible page values
+ matches/ # Entry points per match pattern (all/, stats/)
+ panel/ # React side panel UI
+ components/ # Page components (Dashboard, Profile, Calendar, etc.)
+ components/ # Shared UI components (Radix primitives)
+ hooks/ # Shared React hooks
+ utils/ # Utilities and Chrome storage abstraction
+ types/ # Shared TypeScript types
+build/ # Build tooling (build.ts, plugins, HMR)
+```
diff --git a/app/__tests__/smoke.test.ts b/app/__tests__/smoke.test.ts
new file mode 100644
index 0000000..87dc6c9
--- /dev/null
+++ b/app/__tests__/smoke.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+
+describe("smoke tests", () => {
+ it("should resolve @app alias imports", async () => {
+ const utils = await import("@app/utils");
+ expect(utils).toBeDefined();
+ });
+
+ it("should resolve @app/utils/formatting", async () => {
+ const formatting = await import("@app/utils/formatting");
+ expect(formatting).toBeDefined();
+ });
+});
diff --git a/app/assets/icons/index.tsx b/app/assets/icons/index.tsx
new file mode 100644
index 0000000..3c6de49
--- /dev/null
+++ b/app/assets/icons/index.tsx
@@ -0,0 +1,237 @@
+import type * as React from "react";
+
+type IconProps = React.SVGProps;
+
+const ZoomOutIcon = (props: IconProps) => (
+
+);
+
+const ZoomInIcon = (props: IconProps) => (
+
+);
+
+const TableViewIcon = (props: IconProps) => (
+
+);
+
+const GridViewIcon = (props: IconProps) => (
+
+);
+
+const SettingsIcon = (props: IconProps) => (
+
+);
+
+const DownloadIcon = (props: IconProps) => (
+
+);
+
+const TrashIcon = (props: IconProps) => (
+
+);
+
+const RefreshIcon = (props: IconProps) => (
+
+);
+
+const ImportIcon = (props: IconProps) => (
+
+);
+
+const TrendUpIcon = (props: IconProps) => (
+
+);
+
+const FilterIcon = (props: IconProps) => (
+
+);
+
+const TrendDownIcon = (props: IconProps) => (
+
+);
+
+export {
+ DownloadIcon,
+ FilterIcon,
+ GridViewIcon,
+ ImportIcon,
+ RefreshIcon,
+ SettingsIcon,
+ TableViewIcon,
+ TrashIcon,
+ TrendDownIcon,
+ TrendUpIcon,
+ ZoomInIcon,
+ ZoomOutIcon,
+};
diff --git a/app/background/index.ts b/app/background/index.ts
new file mode 100644
index 0000000..fec5364
--- /dev/null
+++ b/app/background/index.ts
@@ -0,0 +1,308 @@
+import { REQUEST_SCREEN_DATA, UPDATE_SCREEN_DATA, UPDATE_USER_STATS } from "@app/constants";
+import { screenDataToCSVRows } from "@app/utils/csv-tracker";
+import {
+ getTrackedData,
+ appendTrackedData,
+ saveUserStats,
+ getLastExpBySkill,
+ saveLastExpBySkill,
+} from "@app/utils/storage-service";
+import { updateWeeklyStatsFromStatsURL } from "@app/utils/weekly-stats-storage";
+import type { ScreenData } from "@app/types";
+import type { CSVRow } from "@app/utils/csv-tracker";
+
+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;
+ }
+
+ 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 as ScreenData)
+ .then(() => {
+ // Always forward screen data to the side panel so it can update its live display
+ // (current skill, action text, etc.) regardless of whether CSV rows were saved.
+ // The baseline-setting scrape may not save rows but the panel still needs the data.
+ 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:
+ // - Profile display (current levels, total exp, etc.)
+ // - Weekly exp tracking (gainedThisWeek from stats page)
+ // - Hourly exp display on stats page (gainedThisHour)
+ //
+ // IMPORTANT: This does NOT affect tracked current hour exp from screen data
+ // 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(() => {
+ // 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(() => {
+ // Silently handle errors
+ });
+
+ // Forward data to the side panel for real-time updates
+ 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 => {
+ if (tabs[0]?.id) {
+ chrome.tabs.sendMessage(tabs[0].id, { type: REQUEST_SCREEN_DATA });
+ }
+ });
+ return false;
+ }
+
+ // Return false since we're not using sendResponse
+ return false;
+});
+
+/**
+ * Process screen data and save to storage with calculated exp deltas
+ *
+ * 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: 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();
+
+ // 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 || "";
+
+ // Maximum gap between scrapes before we consider the baseline stale (5 minutes)
+ const MAX_SCRAPE_GAP_MS = 5 * 60 * 1000;
+ const now = Date.now();
+
+ // 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 lastEntry = lastExpBySkill[mainSkill];
+
+ if (!lastEntry || lastEntry.exp === 0) {
+ // First time seeing this skill — initialize baseline, don't count as gain
+ lastExpBySkill[mainSkill] = { exp: mainSkillExp, ts: now };
+ gainedExp = "0";
+ } else if (now - lastEntry.ts > MAX_SCRAPE_GAP_MS) {
+ // Stale gap — reset baseline, don't attribute the accumulated delta
+ lastExpBySkill[mainSkill] = { exp: mainSkillExp, ts: now };
+ gainedExp = "0";
+ } else {
+ // Normal case — compute delta
+ const delta = mainSkillExp - lastEntry.exp;
+ gainedExp = delta > 0 ? delta.toString() : "0";
+
+ if (delta > 0) {
+ lastExpBySkill[mainSkill] = { exp: mainSkillExp, ts: now };
+ } else {
+ // Update timestamp even if no gain (keeps the baseline fresh)
+ lastExpBySkill[mainSkill] = { ...lastEntry, ts: now };
+ }
+ }
+ }
+
+ return {
+ ...row,
+ 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);
+
+ // 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 {
+ // 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;
+ }
+ }
+
+ // 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() || "";
+
+ // 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" : "";
+
+ // 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(";");
+
+ // Create merged row with deduplicated totalFights, merged drops, and preserved location/monster
+ const mergedRow: CSVRow = {
+ ...rowToKeep,
+ totalFights: mergedFights,
+ drops: mergedDrops,
+ location: locationToKeep,
+ monster: monsterToKeep,
+ };
+
+ deduplicatedRows.set(key, mergedRow);
+ }
+ });
+
+ const uniqueRows = Array.from(deduplicatedRows.values());
+
+ // Only save rows that have meaningful data.
+ // Require a skill name OR fight/combat indicators, plus at least one meaningful data field.
+ const rowsToSave = uniqueRows.filter(row => {
+ const hasSkill = row.skill && row.skill.trim() !== "";
+ 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;
+ const hasCombatExp = row.combatExp && row.combatExp.trim() !== "";
+ const hasActionOutput = row.actionOutput && row.actionOutput.trim() !== "" && row.actionOutput !== "[]";
+
+ // Must have at least one identity marker (skill name, fight count, or combat exp)
+ if (!hasSkill && !hasTotalFights && !hasCombatExp) {
+ return false;
+ }
+
+ // Save if it has meaningful data
+ return (
+ hasExp || hasDrops || hasDamage || hasEquipment || hasLocationAndMonster || hasTotalFights || hasActionOutput
+ );
+ });
+
+ // 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("@app/utils/weekly-stats-storage");
+ await updateWeeklyStats(allRows).catch(() => {
+ // Silently handle errors
+ });
+
+ return dataSaved;
+};
diff --git a/app/components/ErrorDisplay/index.tsx b/app/components/ErrorDisplay/index.tsx
new file mode 100644
index 0000000..0dbe014
--- /dev/null
+++ b/app/components/ErrorDisplay/index.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.
+