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. +

+ {error instanceof Error && ( +
+ Error Details +
{error.message || String(error)}
+
+ )} +
+ +
+
+
+ ); +}; diff --git a/app/components/IconButton/index.tsx b/app/components/IconButton/index.tsx new file mode 100644 index 0000000..7b66b6a --- /dev/null +++ b/app/components/IconButton/index.tsx @@ -0,0 +1,37 @@ +import { cn } from "../../utils"; +import { Button } from "../ui/button"; +import * as React from "react"; +import type { ButtonProps } from "../ui/button"; + +type IconButtonProps = { + onClick: React.ButtonHTMLAttributes["onClick"]; + variant: ButtonProps["variant"]; + size: ButtonProps["size"]; + label: string; + className: string; + Icon: React.ComponentType>; + disabled?: boolean; + type?: React.ButtonHTMLAttributes["type"]; +}; + +const IconButton = React.forwardRef( + ({ onClick, variant, size, label, className, Icon, disabled, type = "button" }, ref) => ( + + ), +); + +IconButton.displayName = "IconButton"; + +export { IconButton }; +export type { IconButtonProps }; diff --git a/app/components/ItemImage/index.tsx b/app/components/ItemImage/index.tsx new file mode 100644 index 0000000..acc686a --- /dev/null +++ b/app/components/ItemImage/index.tsx @@ -0,0 +1,40 @@ +import { cn } from "@app/utils/cn"; +import { memo, useState } from "react"; + +interface ItemImageProps { + src: string; + name: string; + quantity?: number; + prefix?: string; + className?: string; +} + +/** + * Reusable item image with optional quantity badge (top-left). + * Renders a fallback with the item name when the image fails to load. + */ +const ItemImage = memo(({ src, name, quantity, prefix, className }: ItemImageProps) => { + const [error, setError] = useState(false); + + return ( +
+ {!error ? ( + {name} setError(true)} /> + ) : ( +
+ {name} +
+ )} + {quantity != null && !error && ( + + {prefix} + {quantity.toLocaleString()} + + )} +
+ ); +}); + +ItemImage.displayName = "ItemImage"; + +export { ItemImage }; diff --git a/app/components/LoadingSpinner/index.tsx b/app/components/LoadingSpinner/index.tsx new file mode 100644 index 0000000..84eef8f --- /dev/null +++ b/app/components/LoadingSpinner/index.tsx @@ -0,0 +1,11 @@ +import { RingLoader } from "react-spinners"; + +interface ILoadingSpinnerProps { + size?: number; +} + +export const LoadingSpinner = ({ size }: ILoadingSpinnerProps) => ( +
+ +
+); diff --git a/packages/ui/lib/components/ThemeToggle.tsx b/app/components/ThemeToggle/index.tsx similarity index 63% rename from packages/ui/lib/components/ThemeToggle.tsx rename to app/components/ThemeToggle/index.tsx index 67ac3d7..aaf1c68 100644 --- a/packages/ui/lib/components/ThemeToggle.tsx +++ b/app/components/ThemeToggle/index.tsx @@ -1,41 +1,20 @@ -import { Button } from './ui/button'; -import { cn } from '../utils'; -import { useStorage } from '@extension/shared'; -import { exampleThemeStorage } from '@extension/storage'; -import { useEffect } from 'react'; +import { cn } from "../../utils"; +import { Button } from "../ui/button"; +import { useStorage } from "@app/hooks"; +import { exampleThemeStorage } from "@app/utils/storage"; export const ThemeToggle = () => { const storageData = useStorage(exampleThemeStorage); const isLight = storageData?.isLight ?? false; - // Ensure dark mode class is applied when theme changes - useEffect(() => { - const root = document.documentElement; - // Default to dark mode if storage hasn't loaded yet - const shouldBeDark = storageData ? !isLight : true; - - if (shouldBeDark) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - // Debug log - console.log('ThemeToggle theme effect:', { - isLight, - storageData, - shouldBeDark, - hasDarkClass: root.classList.contains('dark'), - }); - }, [isLight, storageData]); - return ( + ); +}; diff --git a/app/components/index.ts b/app/components/index.ts new file mode 100644 index 0000000..dda2aa8 --- /dev/null +++ b/app/components/index.ts @@ -0,0 +1,22 @@ +export { cn } from "../utils/cn"; +export * from "./ToggleButton"; +export * from "./LoadingSpinner"; +export * from "./ErrorDisplay"; +export * from "./ThemeToggle"; +export * from "./IconButton"; +export * from "./ItemImage"; +export * from "./ui/button"; +export * from "./ui/card"; +export * from "./ui/chart"; +export * from "./ui/table"; +export * from "./ui/tabs"; +export * from "./ui/badge"; +export * from "./ui/dialog"; +export * from "./ui/switch"; +export * from "./ui/dropdown-menu"; +export * from "./ui/popover"; +export * from "./ui/input"; +export * from "./ui/select"; +export * from "./ui/label"; +export * from "./ui/progress"; +export * from "./ui/tooltip"; diff --git a/app/components/ui/badge.tsx b/app/components/ui/badge.tsx new file mode 100644 index 0000000..17b17b7 --- /dev/null +++ b/app/components/ui/badge.tsx @@ -0,0 +1,42 @@ +/* eslint-disable func-style */ +import { cn } from "@app/utils/cn"; +import type * as React from "react"; + +type BadgeBaseProps = { + isActive?: boolean; +}; + +type BadgeAsButton = BadgeBaseProps & { + as: "button"; +} & React.ButtonHTMLAttributes; + +type BadgeAsAnchor = BadgeBaseProps & { + as: "a"; +} & React.AnchorHTMLAttributes; + +type BadgeAsDiv = BadgeBaseProps & { + as?: "div"; +} & React.HTMLAttributes; + +type BadgeProps = BadgeAsButton | BadgeAsAnchor | BadgeAsDiv; + +function Badge({ className, isActive = false, as: Component = "div", ...props }: BadgeProps) { + return ( + )} + /> + ); +} + +export { Badge }; +export type { BadgeProps }; diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx new file mode 100644 index 0000000..a850ba9 --- /dev/null +++ b/app/components/ui/button.tsx @@ -0,0 +1,36 @@ +import { cn } from "@app/utils/cn"; +import * as React from "react"; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + size?: "default" | "sm" | "lg" | "icon"; +} + +const Button = React.forwardRef( + ({ className, variant = "default", size = "default", ...props }, ref) => ( + + ), +); +Switch.displayName = "Switch"; + +export { Switch }; +export type { SwitchProps }; diff --git a/packages/ui/lib/components/ui/table.tsx b/app/components/ui/table.tsx similarity index 58% rename from packages/ui/lib/components/ui/table.tsx rename to app/components/ui/table.tsx index ae6a4fb..088f99e 100644 --- a/packages/ui/lib/components/ui/table.tsx +++ b/app/components/ui/table.tsx @@ -1,71 +1,71 @@ -import { cn } from '../../utils'; -import * as React from 'react'; +import { cn } from "@app/utils/cn"; +import * as React from "react"; const Table = React.forwardRef>( ({ className, ...props }, ref) => (
- +
), ); -Table.displayName = 'Table'; +Table.displayName = "Table"; const TableHeader = React.forwardRef>( - ({ className, ...props }, ref) => , + ({ className, ...props }, ref) => , ); -TableHeader.displayName = 'TableHeader'; +TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef>( ({ className, ...props }, ref) => ( - + ), ); -TableBody.displayName = 'TableBody'; +TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef>( ({ className, ...props }, ref) => ( - tr]:last:border-b-0', className)} {...props} /> + tr]:last:border-b-0", className)} {...props} /> ), ); -TableFooter.displayName = 'TableFooter'; +TableFooter.displayName = "TableFooter"; const TableRow = React.forwardRef>( ({ className, ...props }, ref) => ( ), ); -TableRow.displayName = 'TableRow'; +TableRow.displayName = "TableRow"; const TableHead = React.forwardRef>( ({ className, ...props }, ref) => ( rows */ const getAllFightText = (): string => { - const fightLogTopMarker = document.querySelector('#fightLogTop') as HTMLElement | null; + const fightLogTopMarker = document.querySelector("#fightLogTop") as HTMLElement | null; if (!fightLogTopMarker) { - return ''; + return ""; } // Get all following sibling elements @@ -337,11 +354,11 @@ const getAllFightText = (): string => { let currentSibling = fightLogTopMarker.nextElementSibling; while (currentSibling) { - if (currentSibling.tagName === 'TR') { + if (currentSibling.tagName === "TR") { // Find all that closes, and the actual content is in the NEXT sibling let addExpTextNode: HTMLElement | null = null; - const fightLogTopMarker = document.querySelector('#fightLogTop') as HTMLElement | null; + const fightLogTopMarker = document.querySelector("#fightLogTop") as HTMLElement | null; if (fightLogTopMarker) { // The content is in the next sibling element let nextSibling = fightLogTopMarker.nextElementSibling; while (nextSibling) { - if (nextSibling.tagName === 'TR') { + if (nextSibling.tagName === "TR") { addExpTextNode = nextSibling as HTMLElement; break; } @@ -686,17 +762,17 @@ export const scrapeScreenData = (): ScreenData => { // Fallback: try finding it directly (in case structure is different) if (!addExpTextNode) { - addExpTextNode = document.querySelector('#fightLogTop') as HTMLElement | null; + addExpTextNode = document.querySelector("#fightLogTop") as HTMLElement | null; } if (!addExpTextNode) { // Try finding it in a table - const table = document.querySelector('table'); + const table = document.querySelector("table"); if (table) { - const marker = table.querySelector('#fightLogTop') as HTMLElement | null; + const marker = table.querySelector("#fightLogTop") as HTMLElement | null; if (marker) { let nextSibling = marker.nextElementSibling; while (nextSibling) { - if (nextSibling.tagName === 'TR') { + if (nextSibling.tagName === "TR") { addExpTextNode = nextSibling as HTMLElement; break; } @@ -706,8 +782,8 @@ export const scrapeScreenData = (): ScreenData => { } } - const inventoryTextNode = document.body.querySelector('#inventoryStats')?.textContent; - const locationElement = document.body.querySelector('#LocationContent') as HTMLElement | null; + const inventoryTextNode = document.body.querySelector("#inventoryStats")?.textContent; + const locationElement = document.body.querySelector("#LocationContent") as HTMLElement | null; // Parse monster (always parse to detect new fights) const monster = parseMonster(locationElement, addExpTextNode); @@ -727,17 +803,31 @@ export const scrapeScreenData = (): ScreenData => { textContent.currentActionText = skillInfo.skill; textContent.skillLevel = skillInfo.level; textContent.expForNextLevel = skillInfo.expForNextLevel; - // textContent.speedText = speedTextNode?.trim() || ""; // Get addExp text but filter out timer text (numbers, colons, dashes in timer format) - let addExpText = addExpTextNode?.textContent || ''; + let addExpText = addExpTextNode?.textContent || ""; // Remove timer patterns (e.g., "00:05", "5s", countdown numbers) - addExpText = addExpText.replace(/\b[\d:]+[smh]?\b/g, '').trim(); + addExpText = addExpText.replace(/\b[\d:]+[smh]?\b/g, "").trim(); // Remove standalone number sequences that look like timers - addExpText = addExpText.replace(/^\d+[:]\d+$/gm, '').trim(); + addExpText = addExpText.replace(/^\d+[:]\d+$/gm, "").trim(); textContent.addExp = addExpText; } + // Fallback: if #LocationContent didn't yield a skill name, try #centerContent + // This covers speed/travel and other skilling activities where text appears in centerContent + if (!textContent.currentActionText) { + const centerContentText = document.body.querySelector("#centerContent")?.textContent?.trim() || ""; + if (centerContentText) { + const skillInfo = matchText(centerContentText); + if (skillInfo.skill) { + textContent.exp = skillInfo.exp; + textContent.currentActionText = skillInfo.skill; + textContent.skillLevel = skillInfo.level; + textContent.expForNextLevel = skillInfo.expForNextLevel; + } + } + } + // Parse inventory stats let totalInventoryHP: string | undefined = undefined; if (inventoryTextNode) { @@ -760,42 +850,100 @@ export const scrapeScreenData = (): ScreenData => { // Parse combat experience gains from fightLogTop let hpUsed: number | undefined = undefined; let equipment: EquipmentData | undefined = undefined; + + // Check for skill level information pattern: "Skill level: 132 (28080364 exp, 86538 for next level)" + // This pattern indicates a fight has ended and skill info is displayed + // Pattern allows any skill name, level number, exp values, and exp for next level + let fightText = ""; + let hasSkillLevelInfo = false; + + // Regex pattern to match: "Skill level: number (number exp, number for next level)" + const skillLevelPattern = /\w+\s+level:\s+\d+\s+\(\d+\s+exp,\s+\d+\s+for\s+next\s+level\)/i; + if (addExpTextNode) { textContent.combatExp = parseCombatExp(addExpTextNode); textContent.drops = parseDrops(addExpTextNode); + fightText = addExpTextNode.textContent || ""; + hasSkillLevelInfo = skillLevelPattern.test(fightText); + } - const fightText = addExpTextNode.textContent || ''; - const hasDefeatedText = /you\s+defeated/i.test(fightText); - - // ONLY parse damage and HP when "You defeated" text is present (fight just finished) - if (hasDefeatedText) { - // Check if this is the same fight text we already processed - // This prevents re-processing while sitting at the same fight end screen - if (fightText === lastProcessedFightText) { - // Same fight screen, skip ALL fight-end processing - // Use cached location and empty damage arrays - location = cachedLocation; - } else { - // New fight detected - process it - lastProcessedFightText = fightText; - - // Parse location only once at fight end - location = parseLocation(locationElement); - cachedLocation = location; // Cache for future calls - - damage = parseDamage(); - // Parse HP gained from fight log (food eaten during fight) - const hpGained = parseHpGained(); - if (hpGained !== null) { - hpUsed = hpGained; + // Fallback: if #LocationContent didn't yield a skill name, derive it from combat exp gains + if (!textContent.currentActionText && textContent.combatExp.length > 0) { + const primary = textContent.combatExp[0]; + textContent.currentActionText = primary.skill; + if (primary.skillLevel) textContent.skillLevel = primary.skillLevel; + if (primary.totalExp) textContent.exp = primary.totalExp; + if (primary.expForNextLevel) textContent.expForNextLevel = primary.expForNextLevel; + } + + // Also check all fight log rows for skill level info (in case addExpTextNode doesn't contain it) + if (!hasSkillLevelInfo) { + const fightLogTopMarker = document.querySelector("#fightLogTop") as HTMLElement | null; + if (fightLogTopMarker) { + let currentSibling: Element | null = fightLogTopMarker.nextElementSibling; + while (currentSibling) { + if (currentSibling.tagName === "TR") { + const siblingText = currentSibling.textContent || ""; + if (skillLevelPattern.test(siblingText)) { + fightText = siblingText; + hasSkillLevelInfo = true; + break; + } } + currentSibling = currentSibling.nextElementSibling; + } + } + } - // Parse equipment data (only once per fight) - equipment = parseEquipment(); + // ONLY parse damage, HP, and equipment when skill level info is present (fight just finished) + if (hasSkillLevelInfo) { + // Check if this is the same fight text we already processed + // This prevents re-processing while sitting at the same fight end screen + if (fightText === lastProcessedFightText) { + // Same fight screen, skip ALL fight-end processing + // Use cached location and empty damage arrays + location = cachedLocation; + } else { + // New fight detected - process it + lastProcessedFightText = fightText; + + // Parse location only once at fight end + location = parseLocation(locationElement); + cachedLocation = location; // Cache for future calls + + damage = parseDamage(); + + // Calculate HP Used from damage received (sum of all damage received values) + if (damage.received && damage.received.length > 0) { + hpUsed = damage.received.reduce((sum, dmg) => { + const damageValue = parseInt(dmg, 10); + return sum + (isNaN(damageValue) ? 0 : damageValue); + }, 0); } + + // Parse equipment data (only once per fight) + equipment = parseEquipment(); } } + // Parse action output from skilling activities + const actionOutput = parseActionOutput(locationElement, addExpTextNode); + + // Determine action type + // Combat skills that appear in combatExp: Attack, Defence, Strength, Health + const combatSkills = new Set(["Attack", "Defence", "Strength", "Health"]); + let actionType: "combat" | "skilling" | undefined; + if (monster) { + actionType = "combat"; + } else if (textContent.combatExp.length > 0) { + actionType = "combat"; + } else if (actionOutput.length > 0) { + actionType = "skilling"; + } else if (textContent.currentActionText && !combatSkills.has(textContent.currentActionText)) { + // Non-combat skill detected (Speed, Mining, Fishing, etc.) without items — still skilling + actionType = "skilling"; + } + // Get all visible images // const equipment = document.body.querySelector("#wearDisplayTD"); @@ -804,6 +952,7 @@ export const scrapeScreenData = (): ScreenData => { images: [], links: [], timestamp: new Date().toISOString(), + uuid: uuidv4(), // Generate unique identifier for this screen scrape monster: monster, location: location, damageDealt: damage.dealt, @@ -812,5 +961,7 @@ export const scrapeScreenData = (): ScreenData => { totalInventoryHP: totalInventoryHP, hpUsed: hpUsed, equipment: equipment, + actionType: actionType, + actionOutput: actionOutput, }; }; diff --git a/pages/content/src/scrapeUserStats.ts b/app/content/scrapeUserStats.ts similarity index 73% rename from pages/content/src/scrapeUserStats.ts rename to app/content/scrapeUserStats.ts index 3607979..cf85315 100644 --- a/pages/content/src/scrapeUserStats.ts +++ b/app/content/scrapeUserStats.ts @@ -1,4 +1,4 @@ -import type { UserStats, SkillStat } from '@extension/shared'; +import type { UserStats, SkillStat } from "@app/types"; /** * Scrape user stats from the stats page @@ -7,22 +7,22 @@ import type { UserStats, SkillStat } from '@extension/shared'; export const scrapeUserStats = (): UserStats | null => { try { // Get username from

{username}

- const userNameElement = document.querySelector('h2.userName'); - const username = userNameElement?.textContent?.trim() || ''; + const userNameElement = document.querySelector("h2.userName"); + const username = userNameElement?.textContent?.trim() || ""; const skills: Record = {}; // Find the main stats table - const statsTable = document.querySelector('table.statsTable'); - const tables = statsTable ? [statsTable] : document.querySelectorAll('table'); + const statsTable = document.querySelector("table.statsTable"); + const tables = statsTable ? [statsTable] : document.querySelectorAll("table"); tables.forEach(table => { - const rows = table.querySelectorAll('tr'); + const rows = table.querySelectorAll("tr"); rows.forEach(row => { // Skip header rows and summary rows - const isHeaderRow = row.querySelector('th') !== null; - const cells = row.querySelectorAll('td'); + const isHeaderRow = row.querySelector("th") !== null; + const cells = row.querySelectorAll("td"); // Skip if it's a header row or doesn't have enough cells for a skill row if (isHeaderRow || cells.length < 5) { @@ -30,8 +30,8 @@ export const scrapeUserStats = (): UserStats | null => { } // Check if it's a summary row (Total, Combat) - const secondCellText = cells[1]?.textContent?.trim() || ''; - if (secondCellText === 'Total' || secondCellText === 'Combat') { + const secondCellText = cells[1]?.textContent?.trim() || ""; + if (secondCellText === "Total" || secondCellText === "Combat") { return; } @@ -53,8 +53,8 @@ export const scrapeUserStats = (): UserStats | null => { const hourExpCell = cells[6]; const weekExpCell = cells[7]; - const skillName = skillNameCell?.textContent?.trim() || ''; - const level = levelCell?.textContent?.trim() || ''; + const skillName = skillNameCell?.textContent?.trim() || ""; + const level = levelCell?.textContent?.trim() || ""; // Track ALL skills from the stats page (not just a hardcoded list) if (!skillName || !level) { @@ -67,17 +67,17 @@ export const scrapeUserStats = (): UserStats | null => { } // Extract total exp from cell 4 - const totalExpText = totalExpCell?.textContent?.trim() || ''; - const totalExp = totalExpText.replace(/,/g, ''); + const totalExpText = totalExpCell?.textContent?.trim() || ""; + const totalExp = totalExpText.replace(/,/g, ""); // Extract exp left and percentage from cell 5 (next level cell) - const nextLevelText = nextLevelCell?.textContent?.trim() || ''; + const nextLevelText = nextLevelCell?.textContent?.trim() || ""; const expLeftMatch = nextLevelText.match(/([\d,]+)\s+exp\s+left/i); - const expLeft = expLeftMatch ? expLeftMatch[1].replace(/,/g, '') : '0'; + const expLeft = expLeftMatch ? expLeftMatch[1].replace(/,/g, "") : "0"; // Get percentage from progress bar let percentToNext = 0; - const progressBar = nextLevelCell?.querySelector('.completeProgress'); + const progressBar = nextLevelCell?.querySelector(".completeProgress"); if (progressBar) { const style = (progressBar as HTMLElement).style.width; const percentMatch = style?.match(/(\d+(?:\.\d+)?)%/); @@ -94,24 +94,24 @@ export const scrapeUserStats = (): UserStats | null => { } // Extract level gained this week from cell 3 - const levelGainedSpan = levelGainedCell?.querySelector('span.green'); - const levelGainedThisWeek = levelGainedSpan?.textContent?.trim().replace(/\+/g, '') || '0'; + const levelGainedSpan = levelGainedCell?.querySelector("span.green"); + const levelGainedThisWeek = levelGainedSpan?.textContent?.trim().replace(/\+/g, "") || "0"; // Extract +Hour from cell 6 - const hourExpSpan = hourExpCell?.querySelector('span.green'); - const gainedThisHour = hourExpSpan?.textContent?.trim().replace(/\+/g, '').replace(/,/g, '') || ''; + const hourExpSpan = hourExpCell?.querySelector("span.green"); + const gainedThisHour = hourExpSpan?.textContent?.trim().replace(/\+/g, "").replace(/,/g, "") || ""; // Extract +Week from cell 7 - const weekExpSpan = weekExpCell?.querySelector('span.green'); - const gainedThisWeek = weekExpSpan?.textContent?.trim().replace(/\+/g, '').replace(/,/g, '') || ''; + const weekExpSpan = weekExpCell?.querySelector("span.green"); + const gainedThisWeek = weekExpSpan?.textContent?.trim().replace(/\+/g, "").replace(/,/g, "") || ""; skills[skillName] = { skill: skillName, - level: level || '0', - totalExp: totalExp || '0', - expForNextLevel: expLeft || '0', + level: level || "0", + totalExp: totalExp || "0", + expForNextLevel: expLeft || "0", percentToNext, - expLeft: expLeft || '0', + expLeft: expLeft || "0", gainedThisHour: gainedThisHour || undefined, gainedThisWeek: gainedThisWeek || undefined, levelGainedThisWeek: levelGainedThisWeek || undefined, @@ -128,10 +128,10 @@ export const scrapeUserStats = (): UserStats | null => { if (!finalUsername) { // Try to get username from page title or other elements const pageTitle = document.title; - if (pageTitle && !pageTitle.includes('Sessions')) { + if (pageTitle && !pageTitle.includes("Sessions")) { finalUsername = pageTitle; } else { - finalUsername = 'Player'; // Default fallback + finalUsername = "Player"; // Default fallback } } @@ -140,8 +140,7 @@ export const scrapeUserStats = (): UserStats | null => { timestamp: new Date().toISOString(), skills, }; - } catch (error) { - console.error('[scrapeUserStats] Error scraping user stats:', error); + } catch { return null; } }; diff --git a/app/content/sendData.ts b/app/content/sendData.ts new file mode 100644 index 0000000..7750d64 --- /dev/null +++ b/app/content/sendData.ts @@ -0,0 +1,211 @@ +import { scrapeScreenData } from "./scrapeScreenData"; +import { UPDATE_SCREEN_DATA } from "@app/constants"; + +/** + * Content script for screen data scraping + * Runs in isolated content script context - invisible to website JavaScript + * ES modules are automatically isolated from page JavaScript + */ + +// Track if extension context is still valid +let extensionContextValid = true; + +// Track if we're currently processing a fight to prevent concurrent scrapes +let isProcessingFight = false; + +// Declare early so it's accessible in safeSendMessage +let periodicCheckInterval: number | null = null; + +// Helper function to check if extension context is still valid +const isExtensionContextValid = (): boolean => { + try { + // Try to access chrome.runtime.id - if it throws, context is invalidated + if (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.id) { + return true; + } + return false; + } catch { + return false; + } +}; + +// Helper function to safely send messages +const safeSendMessage = (message: { type: string; data?: unknown }, callback?: (response?: unknown) => void): void => { + if (!extensionContextValid || !isExtensionContextValid()) { + extensionContextValid = false; + return; + } + + try { + chrome.runtime.sendMessage(message, response => { + if (chrome.runtime.lastError) { + const error = chrome.runtime.lastError; + if (error.message && error.message.includes("Extension context invalidated")) { + extensionContextValid = false; + if (periodicCheckInterval) { + clearTimeout(periodicCheckInterval); + periodicCheckInterval = null; + } + } + } else if (callback) { + callback(response); + } + }); + } catch { + extensionContextValid = false; + if (periodicCheckInterval) { + clearTimeout(periodicCheckInterval); + periodicCheckInterval = null; + } + } +}; + +// Track last seen total exp values per skill to detect changes +// Key: skill name, Value: total exp value +const lastSeenExpBySkill: Map = new Map(); + +/** + * Parse skill level information from centerContent + * Pattern: "Skill level: X (Y exp, Z for next level)" + * Returns array of {skill, totalExp, level, expForNext} + */ +const parseSkillLevels = (): Array<{ skill: string; totalExp: number; level: number; expForNext: number }> => { + const centerContent = document.querySelector("#centerContent"); + if (!centerContent) return []; + + const text = centerContent.textContent || ""; + const results: Array<{ skill: string; totalExp: number; level: number; expForNext: number }> = []; + + // Pattern: "SkillName level: X (Y exp, Z for next level)" + // Matches: "Defence level: 133 (28343195 exp, 573592 for next level)" + const skillLevelPattern = /(\w+)\s+level:\s+(\d+)\s+\((\d+)\s+exp,\s+(\d+)\s+for\s+next\s+level\)/gi; + + let match; + while ((match = skillLevelPattern.exec(text)) !== null) { + const skill = match[1]; + const level = parseInt(match[2], 10); + const totalExp = parseInt(match[3], 10); + const expForNext = parseInt(match[4], 10); + + if (skill && !isNaN(totalExp) && !isNaN(level) && !isNaN(expForNext)) { + results.push({ skill, totalExp, level, expForNext }); + } + } + + return results; +}; + +/** + * Check if exp has increased for any skill + * This is the primary trigger for scraping - when total exp increases, fight ended + */ +const hasExpIncreased = (): { increased: boolean; skill?: string; newExp?: number } => { + const skillLevels = parseSkillLevels(); + + if (skillLevels.length === 0) { + return { increased: false }; + } + + // Check each skill to see if exp increased + for (const { skill, totalExp } of skillLevels) { + const lastExp = lastSeenExpBySkill.get(skill); + + // If we haven't seen this skill before, initialize it but don't trigger scrape + if (lastExp === undefined) { + lastSeenExpBySkill.set(skill, totalExp); + continue; + } + + // If exp increased, we have a fight end! + if (totalExp > lastExp) { + // Update the tracked value + lastSeenExpBySkill.set(skill, totalExp); + return { increased: true, skill, newExp: totalExp }; + } + } + + return { increased: false }; +}; + +// Send data when exp increases - this indicates fight end +const sendData = () => { + // Prevent concurrent processing + if (isProcessingFight) return; + + // Check if exp has increased for any skill + const expCheck = hasExpIncreased(); + + if (!expCheck.increased) { + return; // No exp change, no scrape needed + } + + // Exp increased - fight ended! Scrape all data now + isProcessingFight = true; + + // Scrape the data + const data = scrapeScreenData(); + if (data.actionType === "combat") { + data.totalFights = 1; + } + + safeSendMessage({ type: UPDATE_SCREEN_DATA, data }, () => { + // After message sent, reset processing flag + isProcessingFight = false; + }); + + // Reset flag after a short delay as fallback (in case callback doesn't fire) + setTimeout(() => { + isProcessingFight = false; + }, 100); +}; + +/** + * Periodic polling check - PRIMARY detection method + * No MutationObserver footprint - completely undetectable by website + * Uses randomized timing to avoid pattern detection + */ +const startPeriodicCheck = () => { + if (periodicCheckInterval) return; + + const scheduleNextCheck = () => { + if (!extensionContextValid || !isExtensionContextValid()) { + if (periodicCheckInterval) { + clearTimeout(periodicCheckInterval); + periodicCheckInterval = null; + } + return; + } + + // Check for exp increases (sendData handles deduplication via exp comparison) + sendData(); + + // Schedule next check with randomized timing (1.5-2.5 seconds) + // This prevents predictable timing patterns that could be detected + const randomDelay = 1500 + Math.random() * 1000; // 1500-2500ms + periodicCheckInterval = window.setTimeout(scheduleNextCheck, randomDelay); + }; + + // Start the first check with initial random delay (500-1500ms) + const initialDelay = 500 + Math.random() * 1000; + periodicCheckInterval = window.setTimeout(scheduleNextCheck, initialDelay); +}; + +// Start periodic checking when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", startPeriodicCheck); +} else { + startPeriodicCheck(); +} + +// Cleanup timeout on page unload +window.addEventListener("unload", () => { + if (periodicCheckInterval) { + clearTimeout(periodicCheckInterval); + periodicCheckInterval = null; + } +}); + +// Export at end of file +// Note: These exports are only accessible within the module system, not to page JavaScript +// Content scripts run in an isolated world, so page JavaScript cannot access these +export { sendData }; diff --git a/app/hoc/index.ts b/app/hoc/index.ts new file mode 100644 index 0000000..c932ff6 --- /dev/null +++ b/app/hoc/index.ts @@ -0,0 +1,2 @@ +export { withSuspense } from "./with-suspense"; +export { withErrorBoundary } from "./with-error-boundary"; diff --git a/packages/shared/lib/hoc/with-error-boundary.tsx b/app/hoc/with-error-boundary.tsx similarity index 68% rename from packages/shared/lib/hoc/with-error-boundary.tsx rename to app/hoc/with-error-boundary.tsx index 30dd980..4f3f3ea 100644 --- a/packages/shared/lib/hoc/with-error-boundary.tsx +++ b/app/hoc/with-error-boundary.tsx @@ -1,6 +1,6 @@ -import { ErrorBoundary } from 'react-error-boundary'; -import type { ComponentType } from 'react'; -import type { FallbackProps } from 'react-error-boundary'; +import { ErrorBoundary } from "react-error-boundary"; +import type { ComponentType } from "react"; +import type { FallbackProps } from "react-error-boundary"; export const withErrorBoundary = >( Component: ComponentType, diff --git a/packages/shared/lib/hoc/with-suspense.tsx b/app/hoc/with-suspense.tsx similarity index 72% rename from packages/shared/lib/hoc/with-suspense.tsx rename to app/hoc/with-suspense.tsx index 2f673a0..b1b2bab 100644 --- a/packages/shared/lib/hoc/with-suspense.tsx +++ b/app/hoc/with-suspense.tsx @@ -1,5 +1,5 @@ -import { Suspense } from 'react'; -import type { ComponentType, ReactElement } from 'react'; +import { Suspense } from "react"; +import type { ComponentType, ReactElement } from "react"; export const withSuspense = >(Component: ComponentType, SuspenseComponent: ReactElement) => diff --git a/app/hooks/data/index.ts b/app/hooks/data/index.ts new file mode 100644 index 0000000..02bb5f6 --- /dev/null +++ b/app/hooks/data/index.ts @@ -0,0 +1,7 @@ +export * from "./useDataExport"; +export * from "./useItemValuesQuery"; +export * from "./useScreenData"; +export * from "./useTrackedDataMap"; +export * from "./useTrackedDataQuery"; +export * from "./useUserStatsQuery"; +export * from "./useWeeklyStatsQuery"; diff --git a/packages/shared/lib/hooks/useDataExport.ts b/app/hooks/data/useDataExport.ts similarity index 79% rename from packages/shared/lib/hooks/useDataExport.ts rename to app/hooks/data/useDataExport.ts index d5f8e41..2732f8f 100644 --- a/packages/shared/lib/hooks/useDataExport.ts +++ b/app/hooks/data/useDataExport.ts @@ -9,10 +9,10 @@ import { downloadUserStatsCSV, downloadWeeklyStatsCSV, downloadAllDataCSV, -} from '../utils/storage-service.js'; -import { useState } from 'react'; +} from "../../utils/storage-service"; +import { useState } from "react"; -export type ExportType = 'tracked' | 'userStats' | 'weeklyStats' | 'all'; +export type ExportType = "tracked" | "userStats" | "weeklyStats" | "all"; export interface UseDataExportResult { exportData: (type: ExportType, saveAs?: boolean) => Promise; @@ -33,23 +33,23 @@ export const useDataExport = (): UseDataExportResult => { try { switch (type) { - case 'tracked': + case "tracked": await downloadTrackedDataCSV(saveAs); break; - case 'userStats': + case "userStats": await downloadUserStatsCSV(saveAs); break; - case 'weeklyStats': + case "weeklyStats": await downloadWeeklyStatsCSV(saveAs); break; - case 'all': + case "all": await downloadAllDataCSV(saveAs); break; default: throw new Error(`Unknown export type: ${type}`); } } catch (err) { - const error = err instanceof Error ? err : new Error('Failed to export data'); + const error = err instanceof Error ? err : new Error("Failed to export data"); setError(error); throw error; } finally { diff --git a/packages/shared/lib/hooks/useItemValuesQuery.ts b/app/hooks/data/useItemValuesQuery.ts similarity index 74% rename from packages/shared/lib/hooks/useItemValuesQuery.ts rename to app/hooks/data/useItemValuesQuery.ts index 2869f5a..6bf1ea5 100644 --- a/packages/shared/lib/hooks/useItemValuesQuery.ts +++ b/app/hooks/data/useItemValuesQuery.ts @@ -1,9 +1,9 @@ -import { getItemValues, saveItemValues } from '../utils/storage-service.js'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { getItemValues, saveItemValues } from "../../utils/storage-service"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; // Query key for item values -export const ITEM_VALUES_QUERY_KEY = ['itemValues'] as const; +export const ITEM_VALUES_QUERY_KEY = ["itemValues"] as const; /** * Hook to access and manage item values using TanStack Query @@ -24,8 +24,7 @@ export const useItemValuesQuery = () => { try { const values = await getItemValues(); return values; - } catch (err) { - console.error('[useItemValuesQuery] Error loading item values:', err); + } catch { return {}; } }, @@ -39,8 +38,7 @@ export const useItemValuesQuery = () => { // Listen for storage changes and invalidate query useEffect(() => { const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { - if (areaName === 'local' && changes.drop_gp_values) { - console.log('[useItemValuesQuery] drop_gp_values storage changed'); + if (areaName === "local" && changes.drop_gp_values) { queryClient.invalidateQueries({ queryKey: ITEM_VALUES_QUERY_KEY }); } }; @@ -67,12 +65,7 @@ export const useItemValuesQuery = () => { // Save function const save = async (values: Record) => { - try { - await saveMutation.mutateAsync(values); - } catch (error) { - console.error('Error saving item values:', error); - throw error; - } + await saveMutation.mutateAsync(values); }; return { diff --git a/packages/shared/lib/hooks/useScreenData.ts b/app/hooks/data/useScreenData.ts similarity index 51% rename from packages/shared/lib/hooks/useScreenData.ts rename to app/hooks/data/useScreenData.ts index adf0664..2202ec3 100644 --- a/packages/shared/lib/hooks/useScreenData.ts +++ b/app/hooks/data/useScreenData.ts @@ -1,6 +1,6 @@ -import { UPDATE_SCREEN_DATA } from '../../const.js'; -import { useState, useEffect, useRef } from 'react'; -import type { ScreenData } from '../utils/types.js'; +import { UPDATE_SCREEN_DATA } from "@app/constants"; +import { useState, useEffect, useRef } from "react"; +import type { ScreenData } from "@app/types"; // Helper to compare arrays const arraysEqual = (a: T[], b: T[]): boolean => { @@ -58,67 +58,5 @@ export const useScreenData = (): ScreenData | null => { }; }, []); - // // Standalone isVisible function using HTMLElement - // const isVisible = (element: HTMLElement): boolean => { - // const style = window.getComputedStyle(element); - // return ( - // style.display !== 'none' && - // style.visibility !== 'hidden' && - // style.opacity !== '0' && - // element.offsetParent !== null - // ); - // }; - - // const scrapeScreenData = (): ScreenData => { - // // Get all visible text content - // const textNodes = document.body.querySelectorAll('*'); - // const textContent: string[] = []; - // textNodes.forEach((node) => { - // // Ensure node is HTMLElement before calling isVisible - // if (node instanceof HTMLElement) { - // const text = node.textContent?.trim(); - // if (text && isVisible(node)) { - // textContent.push(text); - // } - // } - // }); - - // // Get all visible images - // const images = Array.from(document.images) - // .filter((img) => isVisible(img)) - // .map((img) => img.src); - - // // Get all visible links - // const links = Array.from(document.links) - // .filter((link) => isVisible(link)) - // .map((link) => link.href); - - // return { - // textContent: [...new Set(textContent)], - // images: [...new Set(images)], - // links: [...new Set(links)], - // timestamp: new Date().toISOString(), - // }; - // }; - - // useEffect(() => { - // // Initial scrape - // setScreenData(scrapeScreenData()); - - // // Set up MutationObserver to detect DOM changes - // const observer = new MutationObserver(() => { - // setScreenData(scrapeScreenData()); - // }); - - // observer.observe(document.body, { - // childList: true, - // subtree: true, - // characterData: true, - // }); - - // // Cleanup observer on unmount - // return () => observer.disconnect(); - // }, []); - return screenData; }; diff --git a/app/hooks/data/useTrackedDataMap.ts b/app/hooks/data/useTrackedDataMap.ts new file mode 100644 index 0000000..4d228f6 --- /dev/null +++ b/app/hooks/data/useTrackedDataMap.ts @@ -0,0 +1,78 @@ +import { useTrackedDataQuery } from "./useTrackedDataQuery"; +import { deduplicateRows, filterRowsByRange } from "@app/utils/aggregate-rows"; +import { useMemo } from "react"; +import type { CSVRow } from "@app/utils/csv-tracker"; + +/** + * Centralized deduplicated data map hook. + * Wraps useTrackedDataQuery and provides a single source of deduplicated data. + */ +export const useTrackedDataMap = () => { + const { allData, loading } = useTrackedDataQuery(); + + const deduplicated = useMemo(() => deduplicateRows(allData), [allData]); + + const dataMap = useMemo(() => { + const map = new Map(); + deduplicated.forEach(row => { + const ts = new Date(row.timestamp); + // Round to second precision + const key = new Date( + ts.getFullYear(), + ts.getMonth(), + ts.getDate(), + ts.getHours(), + ts.getMinutes(), + ts.getSeconds(), + ).toISOString(); + const existing = map.get(key); + if (existing) { + existing.push(row); + } else { + map.set(key, [row]); + } + }); + return map; + }, [deduplicated]); + + const getRowsInRange = useMemo( + () => (start: Date, end: Date) => filterRowsByRange(deduplicated, start, end), + [deduplicated], + ); + + const getRowsForHour = useMemo( + () => + (hour: number, date?: Date): CSVRow[] => { + const ref = date || new Date(); + const y = ref.getUTCFullYear(); + const m = ref.getUTCMonth(); + const d = ref.getUTCDate(); + const start = new Date(Date.UTC(y, m, d, hour, 0, 0, 0)); + const end = new Date(Date.UTC(y, m, d, hour, 59, 59, 999)); + return filterRowsByRange(deduplicated, start, end); + }, + [deduplicated], + ); + + const getRowsForDay = useMemo( + () => + (date: Date): CSVRow[] => { + const y = date.getUTCFullYear(); + const m = date.getUTCMonth(); + const d = date.getUTCDate(); + const start = new Date(Date.UTC(y, m, d, 0, 0, 0, 0)); + const end = new Date(Date.UTC(y, m, d, 23, 59, 59, 999)); + return filterRowsByRange(deduplicated, start, end); + }, + [deduplicated], + ); + + return { + dataMap, + allRows: deduplicated, + loading, + getRowsInRange, + getRowsForHour, + getRowsForDay, + }; +}; diff --git a/packages/shared/lib/hooks/useTrackedDataQuery.ts b/app/hooks/data/useTrackedDataQuery.ts similarity index 60% rename from packages/shared/lib/hooks/useTrackedDataQuery.ts rename to app/hooks/data/useTrackedDataQuery.ts index 9c02124..dda5742 100644 --- a/packages/shared/lib/hooks/useTrackedDataQuery.ts +++ b/app/hooks/data/useTrackedDataQuery.ts @@ -1,16 +1,18 @@ -import { filterByTimePeriod, filterByHour, filterByDay, aggregateStats } from '../utils/csv-tracker.js'; +import { filterByTimePeriod, filterByHour, filterByDay, aggregateStats } from "../../utils/csv-tracker"; import { getTrackedData, clearTrackedData, clearTrackedDataByHour, downloadTrackedDataCSV, -} from '../utils/storage-service.js'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; -import type { CSVRow, TimePeriod, TrackedStats } from '../utils/csv-tracker.js'; +} from "../../utils/storage-service"; +import { UPDATE_SCREEN_DATA } from "@app/constants"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; +import type { CSVRow, TimePeriod, TrackedStats } from "../../utils/csv-tracker"; +import type { ScreenData } from "@app/types"; // Query key for tracked data -export const TRACKED_DATA_QUERY_KEY = ['trackedData'] as const; +export const TRACKED_DATA_QUERY_KEY = ["trackedData"] as const; /** * Hook to access and filter tracked CSV data using TanStack Query @@ -39,41 +41,77 @@ export const useTrackedDataQuery = () => { // getTrackedData() reads from tracked_data_csv storage key const rows = await getTrackedData(); return rows; - } catch (err) { - console.error('[useTrackedDataQuery] Error loading tracked data from tracked_data_csv:', err); + } catch { return []; } }, - // Data is fresh for 1 second, preventing unnecessary refetches - staleTime: 1000, + // Data is considered stale after 100ms to allow quick updates while preventing excessive refetches + staleTime: 100, // Keep data in cache for 5 minutes gcTime: 5 * 60 * 1000, // Don't refetch on window focus refetchOnWindowFocus: false, // Don't refetch on reconnect refetchOnReconnect: false, - // Only refetch on mount if data is stale - refetchOnMount: false, + // Refetch on mount if data is stale or missing (ensures initial load) + refetchOnMount: "always", }); + // Track if we just updated via message to avoid double-updating + const justUpdatedViaMessageRef = useRef(false); + // Listen for storage changes and invalidate query (triggers background refetch) // This ensures UI updates automatically when tracked_data_csv changes useEffect(() => { const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { - if (areaName === 'local' && changes.tracked_data_csv) { - // Invalidate and refetch the query to trigger a background update - // This will update the data without showing a loading state - queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); - queryClient.refetchQueries({ queryKey: TRACKED_DATA_QUERY_KEY }).catch(err => { - console.error('[useTrackedDataQuery] Error refetching query:', err); - }); + if (areaName === "local" && changes.tracked_data_csv) { + // If we just updated via message, skip the storage invalidation to avoid double-update + if (justUpdatedViaMessageRef.current) { + justUpdatedViaMessageRef.current = false; + return; + } + + // Only invalidate if the new value is not empty/null and has actual data (not just header) + const newValue = changes.tracked_data_csv.newValue; + if (newValue && newValue.trim().length > 0) { + // Force refetch by invalidating and refetching + // This ensures data updates even if it was recently fetched + queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); + queryClient.refetchQueries({ + queryKey: TRACKED_DATA_QUERY_KEY, + type: "active", + }); + } + } + }; + + // Also listen for runtime messages to update cache immediately when scrape is saved + // The background script only sends this message AFTER successfully saving unique scrape data + const messageListener = (message: { type: string; data?: ScreenData }) => { + if (message.type === UPDATE_SCREEN_DATA && message.data) { + justUpdatedViaMessageRef.current = true; + // Invalidate and refetch to get the updated data + // Use a small delay to ensure storage changes have propagated + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: TRACKED_DATA_QUERY_KEY }); + queryClient.refetchQueries({ + queryKey: TRACKED_DATA_QUERY_KEY, + type: "active", + }); + // Reset flag after a short delay + setTimeout(() => { + justUpdatedViaMessageRef.current = false; + }, 1000); + }, 50); // Small delay to ensure storage changes have propagated } }; chrome.storage.onChanged.addListener(storageListener); + chrome.runtime.onMessage.addListener(messageListener); return () => { chrome.storage.onChanged.removeListener(storageListener); + chrome.runtime.onMessage.removeListener(messageListener); }; }, [queryClient]); @@ -144,32 +182,17 @@ export const useTrackedDataQuery = () => { // Download function - moved to useDataExport hook for better separation of concerns const download = async (saveAs: boolean = true) => { - try { - await downloadTrackedDataCSV(saveAs); - } catch (error) { - console.error('Error downloading CSV:', error); - throw error; - } + await downloadTrackedDataCSV(saveAs); }; // Clear function const clear = async () => { - try { - await clearMutation.mutateAsync(); - } catch (error) { - console.error('Error clearing CSV data:', error); - throw error; - } + await clearMutation.mutateAsync(); }; // Clear by hour function const clearByHour = async (hour: number, date?: Date) => { - try { - await clearByHourMutation.mutateAsync({ hour, date }); - } catch (error) { - console.error('Error clearing CSV data by hour:', error); - throw error; - } + await clearByHourMutation.mutateAsync({ hour, date }); }; return { diff --git a/packages/shared/lib/hooks/useUserStatsQuery.ts b/app/hooks/data/useUserStatsQuery.ts similarity index 89% rename from packages/shared/lib/hooks/useUserStatsQuery.ts rename to app/hooks/data/useUserStatsQuery.ts index 891c7ec..cec3258 100644 --- a/packages/shared/lib/hooks/useUserStatsQuery.ts +++ b/app/hooks/data/useUserStatsQuery.ts @@ -1,12 +1,12 @@ -import { UPDATE_USER_STATS } from '../../const.js'; -import { getUserStats } from '../utils/storage-service.js'; -import { getUserStatsCSVHeader } from '../utils/user-stats-storage.js'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; -import type { UserStats } from '../utils/types.js'; +import { getUserStats } from "../../utils/storage-service"; +import { getUserStatsCSVHeader } from "../../utils/user-stats-storage"; +import { UPDATE_USER_STATS } from "@app/constants"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import type { UserStats } from "@app/types"; // Query key for user stats -export const USER_STATS_QUERY_KEY = ['userStats'] as const; +export const USER_STATS_QUERY_KEY = ["userStats"] as const; /** * Hook to access user stats from storage using TanStack Query @@ -52,7 +52,7 @@ export const useUserStatsQuery = () => { // Listen for storage changes and invalidate query (triggers background refetch) useEffect(() => { const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { - if (areaName === 'local' && changes.user_stats_csv) { + if (areaName === "local" && changes.user_stats_csv) { // If we just updated via message, skip the storage invalidation to avoid double-update if (justUpdatedViaMessageRef.current) { justUpdatedViaMessageRef.current = false; diff --git a/packages/shared/lib/hooks/useWeeklyStatsQuery.ts b/app/hooks/data/useWeeklyStatsQuery.ts similarity index 84% rename from packages/shared/lib/hooks/useWeeklyStatsQuery.ts rename to app/hooks/data/useWeeklyStatsQuery.ts index abee4b3..c120e2b 100644 --- a/packages/shared/lib/hooks/useWeeklyStatsQuery.ts +++ b/app/hooks/data/useWeeklyStatsQuery.ts @@ -7,13 +7,13 @@ * 2. Tracked data is collected (fallback calculation) */ -import { getWeeklyStats } from '../utils/storage-service.js'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; -import type { WeeklyStatsRow } from '../utils/weekly-stats-storage.js'; +import { getWeeklyStats } from "../../utils/storage-service"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { WeeklyStatsRow } from "../../utils/weekly-stats-storage"; // Query key for weekly stats -export const WEEKLY_STATS_QUERY_KEY = ['weeklyStats'] as const; +export const WEEKLY_STATS_QUERY_KEY = ["weeklyStats"] as const; export interface UseWeeklyStatsQueryResult { weeklyStats: WeeklyStatsRow[]; @@ -48,7 +48,7 @@ export const useWeeklyStatsQuery = (): UseWeeklyStatsQueryResult => { // Listen for storage changes useEffect(() => { const storageListener = (changes: { [key: string]: chrome.storage.StorageChange }, areaName: string) => { - if (areaName === 'local' && changes.weekly_stats_csv) { + if (areaName === "local" && changes.weekly_stats_csv) { queryClient.invalidateQueries({ queryKey: WEEKLY_STATS_QUERY_KEY }); } }; diff --git a/app/hooks/index.ts b/app/hooks/index.ts new file mode 100644 index 0000000..0146c9b --- /dev/null +++ b/app/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./data"; +export * from "./stats"; +export * from "./utils"; diff --git a/app/hooks/stats/index.ts b/app/hooks/stats/index.ts new file mode 100644 index 0000000..62b48ef --- /dev/null +++ b/app/hooks/stats/index.ts @@ -0,0 +1,3 @@ +export * from "./useHourlyExp"; +export * from "./useHourStats"; +export * from "./usePeriodStats"; diff --git a/app/hooks/stats/useHourStats.ts b/app/hooks/stats/useHourStats.ts new file mode 100644 index 0000000..7417ad6 --- /dev/null +++ b/app/hooks/stats/useHourStats.ts @@ -0,0 +1,433 @@ +import { parseDrops, parseDropAmount } from "../../utils/formatting"; +import { useItemValuesQuery } from "../data/useItemValuesQuery"; +import { useTrackedDataQuery } from "../data/useTrackedDataQuery"; +import { useMemo } from "react"; +import type { CSVRow } from "../../utils/csv-tracker"; +import type { CombatExpGain } from "@app/types"; + +export interface HourStats { + totalExp: number; + expBySkill: Record; + dropStats: Record; + lootItems: HourLootItem[]; + totalDropValue: number; + hpValue: number; + netProfit: number; + hpUsed: { used: number; startHP: number; endHP: number } | null; + averageHitByLocation: Record; + totalFights: number; + totalSkillingActions: number; + itemsProduced: Record; +} + +export interface HourLootItem { + name: string; + imageUrl: string; + quantity: number; + valuePerItem: number; + totalValue: number; +} + +/** + * Hook to calculate statistics for a specific hour + * + * DATA SOURCE: tracked_data_csv (via useTrackedDataQuery) + * This calculates hour stats from tracked screen data, NOT from stats page. + * + * @param hour - Hour to calculate stats for (0-23) + * @param date - Optional date to use (defaults to now) + * @returns HourStats object with calculated statistics (exp, drops, HP, damage) + */ +export const useHourStats = (hour: number, date?: Date): HourStats => { + // dataByHour comes from tracked_data_csv via useTrackedDataQuery + const { dataByHour } = useTrackedDataQuery(); + const { itemValues } = useItemValuesQuery(); + + return useMemo(() => { + if (!dataByHour) { + return { + totalExp: 0, + expBySkill: {}, + dropStats: {}, + lootItems: [], + totalDropValue: 0, + hpValue: 0, + netProfit: 0, + hpUsed: null, + averageHitByLocation: {}, + totalFights: 0, + totalSkillingActions: 0, + itemsProduced: {}, + }; + } + + try { + const isValidDrop = (drop: string, name: string) => { + const trimmedDrop = drop.trim(); + if (!trimmedDrop) return false; + if (/^[\d,]+$/.test(trimmedDrop)) return false; + const trimmedName = name?.trim() || ""; + if (!trimmedName) return false; + const lower = trimmedName.toLowerCase(); + if (lower.includes("experience") || lower.includes("exp ") || /^\d+\s*exp$/i.test(trimmedName)) { + return false; + } + return true; + }; + + const now = date || new Date(); + const hourData = dataByHour(hour, now); + + const sortedData = [...hourData].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + let totalExp = 0; + let totalFights = 0; + let totalSkillingActions = 0; + const expBySkill: Record = {}; + const itemsProducedTracking: Record = {}; + const dropStats: Record = {}; + const averageHitByLocation: Record = {}; + + // Deduplicate entries by UUID+skill to prevent counting the same fight multiple times + // Each unique fight should be counted once, but sum all gainedExp values + const uniqueEntriesMap = new Map(); + + sortedData.forEach(row => { + const skill = row.skill || ""; + const uuid = row.uuid || ""; + + // Use UUID+skill as key if UUID exists (prevents counting same fight twice) + // Otherwise use timestamp+skill (fallback for old data) + const key = uuid ? `${uuid}-${skill}` : `${row.timestamp}-${skill}`; + const existing = uniqueEntriesMap.get(key); + + if (!existing) { + // First time seeing this fight - add it + uniqueEntriesMap.set(key, row); + } else { + // Same fight (same UUID) - merge data but keep the one with higher gainedExp + const existingGainedExp = parseInt(existing.gainedExp || "0", 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || "0", 10) || 0; + + // Merge drops + const existingDrops = existing.drops || ""; + const currentDrops = row.drops || ""; + const mergedDrops = [existingDrops, currentDrops].filter(d => d && d.trim() !== "").join(";"); + + // Merge damage dealt + const existingDamageDealt = existing.damageDealt || ""; + const currentDamageDealt = row.damageDealt || ""; + const mergedDamageDealt = [existingDamageDealt, currentDamageDealt] + .filter(d => d && d.trim() !== "") + .join(";"); + + // Merge damage received + const existingDamageReceived = existing.damageReceived || ""; + const currentDamageReceived = row.damageReceived || ""; + const mergedDamageReceived = [existingDamageReceived, currentDamageReceived] + .filter(d => d && d.trim() !== "") + .join(";"); + + // Choose the better row (higher exp or more complete), but use merged data + const betterRow = + currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel) + ? row + : existing; + + uniqueEntriesMap.set(key, { + ...betterRow, + drops: mergedDrops || betterRow.drops, + damageDealt: mergedDamageDealt || betterRow.damageDealt, + damageReceived: mergedDamageReceived || betterRow.damageReceived, + // Use the most recent HP value + hp: row.hp || existing.hp, + // Use the most complete location/monster info + location: row.location || existing.location, + monster: row.monster || existing.monster, + }); + } + }); + + // Process unique entries only + const uniqueEntries = Array.from(uniqueEntriesMap.values()); + + // Deduplicate fights by monster + rounded timestamp to prevent double counting + const fightKeys = new Set(); + + uniqueEntries.forEach(row => { + // Calculate exp - only count entries with gainedExp > 0 + const gainedExp = parseInt(row.gainedExp || "0", 10) || 0; + const rowSkill = row.skill || ""; + + if (gainedExp > 0) { + totalExp += gainedExp; + + if (rowSkill) { + expBySkill[rowSkill] = (expBySkill[rowSkill] || 0) + gainedExp; + } + } + + // Parse and add secondary exp (combatExp) from the row + // IMPORTANT: Skip the main skill if it appears in combatExp, since its exp + // is already calculated from total exp delta and stored in gainedExp + if (row.combatExp && row.combatExp.trim() !== "") { + try { + const combatExpGains: CombatExpGain[] = JSON.parse(row.combatExp); + if (Array.isArray(combatExpGains)) { + combatExpGains.forEach((gain: CombatExpGain) => { + const combatSkill = gain.skill || ""; + const exp = parseInt(gain.exp || "0", 10) || 0; + // Skip if this is the main skill - its exp is already in gainedExp (calculated from total exp delta) + if (combatSkill && exp > 0 && combatSkill !== rowSkill) { + totalExp += exp; + expBySkill[combatSkill] = (expBySkill[combatSkill] || 0) + exp; + } + }); + } + } catch { + // Silently handle JSON parse errors + } + } + + // Calculate drops + const drops = parseDrops(row.drops || ""); + drops.forEach(drop => { + const { amount, name } = parseDropAmount(drop); + if (!isValidDrop(drop, name)) { + return; + } + if (!dropStats[name]) { + dropStats[name] = { count: 0, totalAmount: 0 }; + } + dropStats[name].count += 1; + dropStats[name].totalAmount += amount; + }); + + // Calculate total fights - deduplicate by monster + rounded timestamp + const fights = parseInt(row.totalFights || "0", 10) || 0; + if (fights > 0) { + const monster = row.monster || "unknown"; + 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 fightKey = `${monster}-${roundedTimestamp.toISOString()}`; + + // Only count this fight if we haven't seen it before + if (!fightKeys.has(fightKey)) { + fightKeys.add(fightKey); + totalFights += 1; + } + } + + // Track skilling actions and items produced + if (row.actionType === "skilling") { + const hasGainedExp = parseInt(row.gainedExp || "0", 10) > 0; + const hasOutput = row.actionOutput && row.actionOutput.trim() !== "" && row.actionOutput !== "[]"; + if (hasGainedExp || hasOutput) { + totalSkillingActions += 1; + } + } + + if (row.actionOutput && row.actionOutput.trim() !== "" && row.actionOutput !== "[]") { + try { + const outputs: Array<{ item: string; quantity: number }> = JSON.parse(row.actionOutput); + if (Array.isArray(outputs)) { + outputs.forEach(output => { + if (output.item && output.quantity > 0) { + const existing = itemsProducedTracking[output.item]; + if (existing) { + // Track min/max — quantities are running totals (e.g., 220, 222, 224) + // Delta (max - min) gives the actual amount produced during the period + existing.min = Math.min(existing.min, output.quantity); + existing.max = Math.max(existing.max, output.quantity); + } else { + itemsProducedTracking[output.item] = { + min: output.quantity, + max: output.quantity, + skill: row.skill || "", + }; + } + } + }); + } + } catch { + // Silently handle JSON parse errors + } + } + + // Calculate average hit by location + const location = row.location || ""; + const damageDealtStr = row.damageDealt || ""; + if (location && damageDealtStr) { + // Parse semicolon-separated damage values into array + const damageValues = damageDealtStr + .split(";") + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0); + + if (damageValues.length > 0) { + if (!averageHitByLocation[location]) { + averageHitByLocation[location] = { totalDamage: 0, hitCount: 0 }; + } + + // Process each damage value (exclude misses/zeros from average calculation) + damageValues.forEach((damageStr: string) => { + const damage = parseInt(String(damageStr).replace(/,/g, ""), 10); + if (!isNaN(damage) && damage > 0) { + averageHitByLocation[location].totalDamage += damage; + averageHitByLocation[location].hitCount += 1; + } + // Note: misses (0) are not counted in average hit calculation + }); + } + } + }); + + // Calculate total damage received (for net profit cost basis) + let totalDamageReceived = 0; + uniqueEntries.forEach(row => { + if (row.damageReceived && row.damageReceived.trim() !== "") { + row.damageReceived.split(";").forEach(dmg => { + const val = parseInt(dmg.trim(), 10); + if (!isNaN(val) && val > 0) { + totalDamageReceived += val; + } + }); + } + }); + + // Calculate HP used from fight log (for display purposes only) + let totalHpUsed = 0; + uniqueEntries.forEach(row => { + if (row.hpUsed && row.hpUsed.trim() !== "") { + const hpUsedValue = parseInt(row.hpUsed.replace(/,/g, ""), 10); + if (!isNaN(hpUsedValue) && hpUsedValue > 0) { + totalHpUsed += hpUsedValue; + } + } + }); + + // Get totalInventoryHP for start/end (for display purposes) + const hpEntries = uniqueEntries + .filter(row => row.totalInventoryHP && row.totalInventoryHP.trim() !== "") + .map(row => { + const hpValue = parseInt(row.totalInventoryHP.replace(/,/g, ""), 10); + return { + timestamp: row.timestamp, + hp: isNaN(hpValue) ? null : hpValue, + }; + }) + .filter(entry => entry.hp !== null) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + let hpUsed: { used: number; startHP: number; endHP: number } | null = null; + if (totalHpUsed > 0) { + const startHP = hpEntries.length > 0 ? hpEntries[0].hp! : 0; + const endHP = hpEntries.length > 0 ? hpEntries[hpEntries.length - 1].hp! : 0; + hpUsed = { + used: totalHpUsed, + startHP, + endHP, + }; + } else if (hpEntries.length >= 2) { + // Fallback to old calculation if no hpUsed from fight log + const firstHP = hpEntries[0].hp!; + const lastHP = hpEntries[hpEntries.length - 1].hp!; + hpUsed = { + used: firstHP - lastHP, + startHP: firstHP, + endHP: lastHP, + }; + } + + // Calculate average hits + const avgHits: Record = {}; + Object.entries(averageHitByLocation).forEach(([location, stats]) => { + if (stats.hitCount > 0) { + avgHits[location] = stats.totalDamage / stats.hitCount; + } + }); + + const lootItems = Object.entries(dropStats) + .map(([name, stats]) => { + const valuePerItem = parseFloat(itemValues[name] || "0") || 0; + const totalValue = stats.totalAmount * valuePerItem; + const imageUrl = `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, "%20")}.png`; + return { + name, + imageUrl, + quantity: stats.totalAmount, + valuePerItem, + totalValue, + }; + }) + .sort((a, b) => { + if (b.totalValue !== a.totalValue) return b.totalValue - a.totalValue; + return a.name.localeCompare(b.name); + }); + + const totalDropValue = lootItems.reduce((sum, item) => sum + item.totalValue, 0); + + // Convert min/max tracking to delta quantities (amount produced during the period) + const itemsProduced: Record = {}; + Object.entries(itemsProducedTracking).forEach(([name, data]) => { + const produced = data.max - data.min; + if (produced > 0) { + itemsProduced[name] = { quantity: produced, skill: data.skill }; + } + }); + + // Calculate produced items value + let producedItemsValue = 0; + Object.entries(itemsProduced).forEach(([name, data]) => { + const valuePerItem = parseFloat(itemValues[name] || "0") || 0; + producedItemsValue += data.quantity * valuePerItem; + }); + + // Net profit: revenue (drops + produced items) - cost (damageReceived * 2.5) + const hpValue = totalDamageReceived * 2.5; + const netProfit = totalDropValue + producedItemsValue - hpValue; + + const result = { + totalExp, + expBySkill, + dropStats, + lootItems, + totalDropValue, + hpValue, + netProfit, + hpUsed, + averageHitByLocation: avgHits, + totalFights, + totalSkillingActions, + itemsProduced, + }; + + return result; + } catch { + return { + totalExp: 0, + expBySkill: {}, + dropStats: {}, + lootItems: [], + totalDropValue: 0, + hpValue: 0, + netProfit: 0, + hpUsed: null, + averageHitByLocation: {}, + totalFights: 0, + totalSkillingActions: 0, + itemsProduced: {}, + }; + } + }, [dataByHour, hour, date, itemValues]); +}; diff --git a/packages/shared/lib/hooks/useHourlyExp.ts b/app/hooks/stats/useHourlyExp.ts similarity index 55% rename from packages/shared/lib/hooks/useHourlyExp.ts rename to app/hooks/stats/useHourlyExp.ts index 8bf109d..583e611 100644 --- a/packages/shared/lib/hooks/useHourlyExp.ts +++ b/app/hooks/stats/useHourlyExp.ts @@ -1,7 +1,7 @@ -import { useTrackedDataQuery } from './useTrackedDataQuery.js'; -import { filterByHour } from '../utils/csv-tracker.js'; -import { useState, useEffect, useCallback } from 'react'; -import type { CSVRow } from '../utils/csv-tracker.js'; +import { filterByHour } from "../../utils/csv-tracker"; +import { useTrackedDataQuery } from "../data/useTrackedDataQuery"; +import { useState, useEffect, useCallback } from "react"; +import type { CombatExpGain } from "@app/types"; export interface HourlyExpStats { totalExpThisHour: number; @@ -36,39 +36,16 @@ export const useHourlyExp = (): HourlyExpStats => { (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); - // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) - // This matches the logic in useHourStats to ensure consistent values - const uniqueEntriesMap = new Map(); - - currentHourRows.forEach(row => { - const skill = row.skill || ''; - const key = `${row.timestamp}-${skill}`; - const existing = uniqueEntriesMap.get(key); - - if (!existing) { - uniqueEntriesMap.set(key, row); - } else { - // Keep the one with higher gainedExp or more complete data - const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; - const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; - if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { - uniqueEntriesMap.set(key, row); - } - } - }); - - // Process unique entries only - const uniqueEntries = Array.from(uniqueEntriesMap.values()); - - // Use saved gainedExp directly (it's already calculated and saved) + // Sum all gainedExp values for each skill in the current hour + // This directly sums all tracked gainedExp from storage for the current hour let totalGainedExp = 0; const expBySkill: Record = {}; - uniqueEntries.forEach(row => { - const skill = row.skill || ''; - const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; + currentHourRows.forEach(row => { + const skill = row.skill || ""; + const gainedExp = parseInt(row.gainedExp || "0", 10) || 0; - // Only count entries with gainedExp > 0 + // Sum all gainedExp values for each skill if (gainedExp > 0) { totalGainedExp += gainedExp; @@ -76,6 +53,28 @@ export const useHourlyExp = (): HourlyExpStats => { expBySkill[skill] = (expBySkill[skill] || 0) + gainedExp; } } + + // Parse and add secondary exp (combatExp) from the row + // IMPORTANT: Skip the main skill if it appears in combatExp, since its exp + // is already calculated from total exp delta and stored in gainedExp + if (row.combatExp && row.combatExp.trim() !== "") { + try { + const combatExpGains: CombatExpGain[] = JSON.parse(row.combatExp); + if (Array.isArray(combatExpGains)) { + combatExpGains.forEach((gain: CombatExpGain) => { + const combatSkill = gain.skill || ""; + const combatExp = parseInt(gain.exp || "0", 10) || 0; + // Skip if this is the main skill - its exp is already in gainedExp (calculated from total exp delta) + if (combatSkill && combatExp > 0 && combatSkill !== skill) { + totalGainedExp += combatExp; + expBySkill[combatSkill] = (expBySkill[combatSkill] || 0) + combatExp; + } + }); + } + } catch { + // Silently handle JSON parse errors + } + } }); setStats({ @@ -83,8 +82,8 @@ export const useHourlyExp = (): HourlyExpStats => { expBySkill, currentHour, }); - } catch (error) { - console.error('Error calculating hourly exp:', error); + } catch { + // Silently handle errors } }, [allData]); diff --git a/app/hooks/stats/usePeriodStats.ts b/app/hooks/stats/usePeriodStats.ts new file mode 100644 index 0000000..f50f4ca --- /dev/null +++ b/app/hooks/stats/usePeriodStats.ts @@ -0,0 +1,380 @@ +import { useItemValuesQuery } from "../data/useItemValuesQuery"; +import { useTrackedDataQuery } from "../data/useTrackedDataQuery"; +import { useFormatting } from "../utils/useFormatting"; +import { useMemo, useState } from "react"; +import type { CSVRow, TimePeriod } from "../../utils/csv-tracker"; +import type { CombatExpGain } from "@app/types"; + +export interface PeriodStats { + periodKey: string; + date: Date; + totalGainedExp: number; + skills: Record; + hpUsed: { used: number; startHP: number; endHP: number } | null; + dropStats: Record; + lootItems: PeriodLootItem[]; + totalDrops: number; + totalDropAmount: number; + totalDropValue: number; + hpValue: number; + netProfit: number; + totalSkillingActions: number; + itemsProduced: Record; +} + +export interface PeriodLootItem { + name: string; + imageUrl: string; + quantity: number; + valuePerItem: number; + totalValue: number; +} + +export const usePeriodStats = (initialPeriod: TimePeriod = "day") => { + const { allData, loading } = useTrackedDataQuery(); + const { itemValues, loading: itemValuesLoading } = useItemValuesQuery(); + const { parseDrops, parseDropAmount } = useFormatting(); + const [selectedPeriod, setSelectedPeriod] = useState(initialPeriod); + + // Deduplicate all entries: one entry per timestamp+skill + const allDeduplicatedData = useMemo(() => { + const uniqueEntriesMap = new Map(); + + allData.forEach((row: CSVRow) => { + const key = `${row.timestamp}-${row.skill}`; + const existing = uniqueEntriesMap.get(key); + + if (!existing) { + uniqueEntriesMap.set(key, { ...row }); + } else { + // Merge drops from both rows + const existingDrops = existing.drops || ""; + const currentDrops = row.drops || ""; + const mergedDrops = [existingDrops, currentDrops].filter(d => d && d.trim() !== "").join(";"); + + // Keep the one with higher gainedExp or most complete data + const existingGainedExp = parseInt(existing.gainedExp || "0", 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || "0", 10) || 0; + + if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { + uniqueEntriesMap.set(key, { ...row, drops: mergedDrops }); + } else { + uniqueEntriesMap.set(key, { ...existing, drops: mergedDrops }); + } + } + }); + + return Array.from(uniqueEntriesMap.values()); + }, [allData]); + + // Filtered version for exp calculations + const allDeduplicatedDataWithExp = useMemo( + () => allDeduplicatedData.filter(row => parseInt(row.gainedExp || "0", 10) > 0), + [allDeduplicatedData], + ); + + // Calculate overall stats + const overallStats = useMemo(() => { + const totalEntries = allDeduplicatedData.length; + const totalExp = allDeduplicatedDataWithExp.reduce( + (sum, row) => sum + (parseInt(row.gainedExp || "0", 10) || 0), + 0, + ); + + let start: Date | null = null; + let end: Date | null = null; + + if (allDeduplicatedData.length > 0) { + const timestamps = allDeduplicatedData.map(d => new Date(d.timestamp).getTime()); + start = new Date(Math.min(...timestamps)); + end = new Date(Math.max(...timestamps)); + } + + return { + totalEntries, + totalExp, + timeRange: { start, end }, + }; + }, [allDeduplicatedData, allDeduplicatedDataWithExp]); + + // Group and calculate stats + const periodBreakdown = useMemo(() => { + // Group ALL data (for drops/HP) + const allDataPeriodMap = new Map(); + + // Helper to get period key and date + const getPeriodInfo = (date: Date) => { + let periodKey: string; + let periodDate: Date; + + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + const hour = date.getUTCHours(); + + if (selectedPeriod === "hour") { + periodKey = `${year}-${month}-${day}-${hour}`; + periodDate = new Date(Date.UTC(year, month, day, hour, 0, 0, 0)); + } else if (selectedPeriod === "day") { + periodKey = `${year}-${month}-${day}`; + periodDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + } else if (selectedPeriod === "week") { + const weekStart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + const weekday = weekStart.getUTCDay(); + weekStart.setUTCDate(weekStart.getUTCDate() - weekday); + weekStart.setUTCHours(0, 0, 0, 0); + periodKey = `${weekStart.getUTCFullYear()}-${weekStart.getUTCMonth()}-${weekStart.getUTCDate()}`; + periodDate = weekStart; + } else { + periodKey = `${year}-${month}`; + periodDate = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)); + } + return { periodKey, periodDate }; + }; + + allDeduplicatedData.forEach(row => { + const date = new Date(row.timestamp); + const { periodKey, periodDate } = getPeriodInfo(date); + + if (!allDataPeriodMap.has(periodKey)) { + allDataPeriodMap.set(periodKey, { periodKey, date: periodDate, rows: [] }); + } + allDataPeriodMap.get(periodKey)!.rows.push(row); + }); + + // Group filtered data (for exp) + const periodMap = new Map(); + allDeduplicatedDataWithExp.forEach(row => { + const date = new Date(row.timestamp); + const { periodKey, periodDate } = getPeriodInfo(date); + + if (!periodMap.has(periodKey)) { + periodMap.set(periodKey, { periodKey, date: periodDate, rows: [] }); + } + periodMap.get(periodKey)!.rows.push(row); + }); + + // Calculate stats + return Array.from(allDataPeriodMap.values()) + .map(({ periodKey, date, rows: allRows }) => { + const expRows = periodMap.get(periodKey)?.rows || []; + + // EXP Calculation + let totalGainedExp = 0; + const skills: Record = {}; + + expRows.forEach(row => { + const gainedExp = parseInt(row.gainedExp || "0", 10) || 0; + const mainSkill = row.skill || ""; + + if (gainedExp > 0) { + totalGainedExp += gainedExp; + if (mainSkill) { + skills[mainSkill] = (skills[mainSkill] || 0) + gainedExp; + } + } + + if (row.combatExp && row.combatExp.trim() !== "") { + try { + const combatExpGains: CombatExpGain[] = JSON.parse(row.combatExp); + if (Array.isArray(combatExpGains)) { + combatExpGains.forEach((gain: CombatExpGain) => { + const skill = gain.skill || ""; + const exp = parseInt(gain.exp || "0", 10) || 0; + if (skill && exp > 0 && skill !== mainSkill) { + totalGainedExp += exp; + skills[skill] = (skills[skill] || 0) + exp; + } + }); + } + } catch { + // Ignore errors + } + } + }); + + // Calculate total damage received (for net profit cost basis) + let totalDamageReceived = 0; + allRows.forEach((row: CSVRow) => { + if (row.damageReceived && row.damageReceived.trim() !== "") { + row.damageReceived.split(";").forEach(dmg => { + const val = parseInt(dmg.trim(), 10); + if (!isNaN(val) && val > 0) { + totalDamageReceived += val; + } + }); + } + }); + + // HP Used Calculation (for display purposes) + let totalHpUsed = 0; + allRows.forEach((row: CSVRow) => { + if (row.hpUsed && row.hpUsed.trim() !== "") { + const hpUsedValue = parseInt(row.hpUsed.replace(/,/g, ""), 10); + if (!isNaN(hpUsedValue) && hpUsedValue > 0) { + totalHpUsed += hpUsedValue; + } + } + }); + + const hpEntries = allRows + .filter(row => row.totalInventoryHP && row.totalInventoryHP.trim() !== "") + .map(row => ({ + timestamp: row.timestamp, + hp: parseInt(row.totalInventoryHP.replace(/,/g, ""), 10), + })) + .filter(entry => !isNaN(entry.hp)) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + let hpUsed: { used: number; startHP: number; endHP: number } | null = null; + if (totalHpUsed > 0) { + const startHP = hpEntries.length > 0 ? hpEntries[0].hp : 0; + const endHP = hpEntries.length > 0 ? hpEntries[hpEntries.length - 1].hp : 0; + hpUsed = { used: totalHpUsed, startHP, endHP }; + } else if (hpEntries.length >= 2) { + const firstHP = hpEntries[0].hp; + const lastHP = hpEntries[hpEntries.length - 1].hp; + hpUsed = { used: firstHP - lastHP, startHP: firstHP, endHP: lastHP }; + } + + const isValidDrop = (drop: string, name: string) => { + const trimmedDrop = drop.trim(); + if (!trimmedDrop) return false; + if (/^[\d,]+$/.test(trimmedDrop)) return false; + const trimmedName = name?.trim() || ""; + if (!trimmedName) return false; + const lower = trimmedName.toLowerCase(); + if (lower.includes("experience") || lower.includes("exp ") || /^\d+\s*exp$/i.test(trimmedName)) { + return false; + } + return true; + }; + + // Drops Calculation + const dropStats: Record = {}; + allRows.forEach((row: CSVRow) => { + const drops = parseDrops(row.drops || ""); + drops.forEach((drop: string) => { + const { amount, name } = parseDropAmount(drop); + if (!isValidDrop(drop, name)) { + return; + } + if (!dropStats[name]) { + dropStats[name] = { count: 0, totalAmount: 0 }; + } + dropStats[name].count += 1; + dropStats[name].totalAmount += amount; + }); + }); + + const totalDrops = Object.values(dropStats).reduce((sum, stat) => sum + stat.count, 0); + const totalDropAmount = Object.values(dropStats).reduce((sum, stat) => sum + stat.totalAmount, 0); + + const lootItems = Object.entries(dropStats) + .map(([name, stats]) => { + const valuePerItem = parseFloat(itemValues[name] || "0") || 0; + const totalValue = stats.totalAmount * valuePerItem; + const imageUrl = `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, "%20")}.png`; + return { + name, + imageUrl, + quantity: stats.totalAmount, + valuePerItem, + totalValue, + }; + }) + .sort((a, b) => { + if (b.totalValue !== a.totalValue) return b.totalValue - a.totalValue; + return a.name.localeCompare(b.name); + }); + + const totalDropValue = lootItems.reduce((sum, item) => sum + item.totalValue, 0); + + // Aggregate skilling actions and items produced + let totalSkillingActions = 0; + const itemsProducedTracking: Record = {}; + + allRows.forEach((row: CSVRow) => { + if (row.actionType === "skilling") { + const hasGainedExp = parseInt(row.gainedExp || "0", 10) > 0; + const hasOutput = row.actionOutput && row.actionOutput.trim() !== "" && row.actionOutput !== "[]"; + if (hasGainedExp || hasOutput) { + totalSkillingActions += 1; + } + } + + if (row.actionOutput && row.actionOutput.trim() !== "" && row.actionOutput !== "[]") { + try { + const outputs: Array<{ item: string; quantity: number }> = JSON.parse(row.actionOutput); + if (Array.isArray(outputs)) { + outputs.forEach(output => { + if (output.item && output.quantity > 0) { + const existing = itemsProducedTracking[output.item]; + if (existing) { + existing.min = Math.min(existing.min, output.quantity); + existing.max = Math.max(existing.max, output.quantity); + } else { + itemsProducedTracking[output.item] = { + min: output.quantity, + max: output.quantity, + skill: row.skill || "", + }; + } + } + }); + } + } catch { + // Silently handle JSON parse errors + } + } + }); + + // Convert min/max tracking to delta quantities (amount produced during the period) + const itemsProduced: Record = {}; + Object.entries(itemsProducedTracking).forEach(([name, data]) => { + const produced = data.max - data.min; + if (produced > 0) { + itemsProduced[name] = { quantity: produced, skill: data.skill }; + } + }); + + // Calculate produced items value + let producedItemsValue = 0; + Object.entries(itemsProduced).forEach(([name, data]) => { + const valuePerItem = parseFloat(itemValues[name] || "0") || 0; + producedItemsValue += data.quantity * valuePerItem; + }); + + // Net profit: revenue (drops + produced items) - cost (damageReceived * 2.5) + const hpValue = totalDamageReceived * 2.5; + const netProfit = totalDropValue + producedItemsValue - hpValue; + + return { + periodKey, + date, + totalGainedExp, + skills, + hpUsed, + dropStats, + lootItems, + totalDrops, + totalDropAmount, + totalDropValue, + hpValue, + netProfit, + totalSkillingActions, + itemsProduced, + }; + }) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + }, [allDeduplicatedData, allDeduplicatedDataWithExp, selectedPeriod, itemValues, parseDrops, parseDropAmount]); + + return { + periodBreakdown, + selectedPeriod, + setSelectedPeriod, + loading: loading || itemValuesLoading, + itemValues, + overallStats, + }; +}; diff --git a/app/hooks/utils/index.ts b/app/hooks/utils/index.ts new file mode 100644 index 0000000..d6feb84 --- /dev/null +++ b/app/hooks/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./use-storage"; +export * from "./useFormatting"; diff --git a/packages/shared/lib/hooks/use-storage.tsx b/app/hooks/utils/use-storage.tsx similarity index 83% rename from packages/shared/lib/hooks/use-storage.tsx rename to app/hooks/utils/use-storage.tsx index da50e83..814ad17 100644 --- a/packages/shared/lib/hooks/use-storage.tsx +++ b/app/hooks/utils/use-storage.tsx @@ -1,21 +1,21 @@ -import { useRef, useSyncExternalStore } from 'react'; -import type { BaseStorageType } from '@extension/storage'; +import { useRef, useSyncExternalStore } from "react"; +import type { BaseStorageType } from "@app/utils/storage"; type WrappedPromise = ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any const storageMap: Map, WrappedPromise> = new Map(); const wrapPromise = (promise: Promise) => { - let status = 'pending'; + let status = "pending"; let result: R; const suspender = promise.then( r => { - status = 'success'; + status = "success"; result = r; }, e => { - status = 'error'; + status = "error"; result = e; }, ); @@ -23,9 +23,9 @@ const wrapPromise = (promise: Promise) => { return { read() { switch (status) { - case 'pending': + case "pending": throw suspender; - case 'error': + case "error": throw result; default: return result; diff --git a/packages/shared/lib/hooks/useFormatting.ts b/app/hooks/utils/useFormatting.ts similarity index 82% rename from packages/shared/lib/hooks/useFormatting.ts rename to app/hooks/utils/useFormatting.ts index 472b61e..06fafb9 100644 --- a/packages/shared/lib/hooks/useFormatting.ts +++ b/app/hooks/utils/useFormatting.ts @@ -1,5 +1,5 @@ -import { formatExp, formatTime, parseDrops, parseDropAmount } from '../utils/formatting.js'; -import { useMemo } from 'react'; +import { formatExp, formatTime, parseDrops, parseDropAmount } from "../../utils/formatting"; +import { useMemo } from "react"; /** * Hook providing formatting utilities diff --git a/app/panel/SidePanel.tsx b/app/panel/SidePanel.tsx new file mode 100644 index 0000000..9909c2d --- /dev/null +++ b/app/panel/SidePanel.tsx @@ -0,0 +1,25 @@ +import Header from "./components/Header"; +import { useSidePanel } from "./hooks/useSidePanel"; +import { QueryProvider } from "./providers/QueryProvider"; +import { ErrorDisplay, LoadingSpinner, ThemeToggle } from "@app/components"; +import { withErrorBoundary, withSuspense } from "@app/hoc"; + +const SidePanelContent = () => { + const { display, setDisplay, ActiveScreen } = useSidePanel(); + + return ( +
+
+ + +
+ ); +}; + +const SidePanel = () => ( + + + +); + +export default withErrorBoundary(withSuspense(SidePanel, ), ErrorDisplay); diff --git a/app/panel/components/Calendar/CalendarCell.tsx b/app/panel/components/Calendar/CalendarCell.tsx new file mode 100644 index 0000000..6b696b3 --- /dev/null +++ b/app/panel/components/Calendar/CalendarCell.tsx @@ -0,0 +1,75 @@ +import { cn } from "@app/utils/cn"; +import { formatExp } from "@app/utils/formatting"; +import { memo } from "react"; +import type { CalendarCellData } from "./types"; + +interface CalendarCellProps { + label: string; + data: CalendarCellData; + isToday?: boolean; + isMuted?: boolean; + onClick?: () => void; +} + +const formatGP = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + +/** + * Compute background opacity (0–25%) based on totalExp relative to a reasonable threshold. + */ +const getIntensityClass = (totalExp: number): string => { + if (totalExp <= 0) return ""; + if (totalExp < 500) return "bg-primary/5"; + if (totalExp < 2000) return "bg-primary/10"; + if (totalExp < 10000) return "bg-primary/15"; + if (totalExp < 50000) return "bg-primary/20"; + return "bg-primary/25"; +}; + +const CalendarCell = memo(({ label, data, isToday = false, isMuted = false, onClick }: CalendarCellProps) => { + const hasData = data.hasData; + + return ( + + ); +}); + +CalendarCell.displayName = "CalendarCell"; + +export { CalendarCell }; diff --git a/app/panel/components/Calendar/CalendarToolbar.tsx b/app/panel/components/Calendar/CalendarToolbar.tsx new file mode 100644 index 0000000..425d834 --- /dev/null +++ b/app/panel/components/Calendar/CalendarToolbar.tsx @@ -0,0 +1,106 @@ +import { Badge, Tabs, TabsList, TabsTrigger } from "@app/components"; +import { cn } from "@app/utils/cn"; +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { memo } from "react"; +import type { CalendarViewMode } from "./types"; + +interface CalendarToolbarProps { + viewMode: CalendarViewMode; + currentDate: Date; + hasHistory: boolean; + onViewModeChange: (mode: CalendarViewMode) => void; + onPrev: () => void; + onNext: () => void; + onToday: () => void; + onBack: () => void; +} + +const formatTitle = (viewMode: CalendarViewMode, date: Date): string => { + if (viewMode === "year") { + return String(date.getFullYear()); + } + if (viewMode === "month") { + return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + } + if (viewMode === "week") { + const dayOfWeek = date.getDay(); + const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - dayOfWeek); + const weekEnd = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 6); + const startStr = weekStart.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + const endStr = weekEnd.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return `${startStr} - ${endStr}, ${weekEnd.getFullYear()}`; + } + // day + return date.toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); +}; + +const CalendarToolbar = memo( + ({ viewMode, currentDate, hasHistory, onViewModeChange, onPrev, onNext, onToday, onBack }: CalendarToolbarProps) => ( +
+ {/* Navigation row */} +
+
+ {hasHistory && ( + + + Back + + )} + + +
+ +

{formatTitle(viewMode, currentDate)}

+ + + Today + +
+ + {/* View mode switcher */} + onViewModeChange(v as CalendarViewMode)}> + + + Year + + + Month + + + Week + + + Day + + + +
+ ), +); + +CalendarToolbar.displayName = "CalendarToolbar"; + +export { CalendarToolbar }; diff --git a/app/panel/components/Calendar/index.tsx b/app/panel/components/Calendar/index.tsx new file mode 100644 index 0000000..e9c6243 --- /dev/null +++ b/app/panel/components/Calendar/index.tsx @@ -0,0 +1,49 @@ +import { CalendarToolbar } from "./CalendarToolbar"; +import { useCalendar } from "./useCalendar"; +import { useCalendarData } from "./useCalendarData"; +import { DayView } from "./views/DayView"; +import { MonthView } from "./views/MonthView"; +import { WeekView } from "./views/WeekView"; +import { YearView } from "./views/YearView"; +import { Card, CardContent, CardHeader } from "@app/components"; +import { memo } from "react"; + +const Calendar = memo(() => { + const { viewMode, setViewMode, currentDate, viewStack, drillDown, goBack, navigatePrev, navigateNext, goToToday } = + useCalendar(); + + const { cellData, getCell, loading } = useCalendarData(viewMode, currentDate); + + if (loading) { + return
Loading calendar data...
; + } + + return ( + + + 0} + onViewModeChange={setViewMode} + onPrev={navigatePrev} + onNext={navigateNext} + onToday={goToToday} + onBack={goBack} + /> + + + {viewMode === "year" && } + {viewMode === "month" && ( + + )} + {viewMode === "week" && } + {viewMode === "day" && } + + + ); +}); + +Calendar.displayName = "Calendar"; + +export default Calendar; diff --git a/app/panel/components/Calendar/types.ts b/app/panel/components/Calendar/types.ts new file mode 100644 index 0000000..9e8580a --- /dev/null +++ b/app/panel/components/Calendar/types.ts @@ -0,0 +1,12 @@ +import type { AggregatedStats } from "@app/utils/aggregate-rows"; + +export type CalendarViewMode = "year" | "month" | "week" | "day"; + +export interface CalendarCellData extends AggregatedStats { + hasData: boolean; +} + +export interface ViewStackEntry { + viewMode: CalendarViewMode; + date: Date; +} diff --git a/app/panel/components/Calendar/useCalendar.ts b/app/panel/components/Calendar/useCalendar.ts new file mode 100644 index 0000000..09a8657 --- /dev/null +++ b/app/panel/components/Calendar/useCalendar.ts @@ -0,0 +1,78 @@ +import { useCallback, useState } from "react"; +import type { CalendarViewMode, ViewStackEntry } from "./types"; + +export const useCalendar = () => { + const [viewMode, setViewMode] = useState("month"); + const [currentDate, setCurrentDate] = useState(() => new Date()); + const [viewStack, setViewStack] = useState([]); + + const drillDown = useCallback( + (mode: CalendarViewMode, date: Date) => { + setViewStack(prev => [...prev, { viewMode, date: currentDate }]); + setViewMode(mode); + setCurrentDate(date); + }, + [viewMode, currentDate], + ); + + const goBack = useCallback(() => { + setViewStack(prev => { + if (prev.length === 0) return prev; + const next = [...prev]; + const entry = next.pop()!; + setViewMode(entry.viewMode); + setCurrentDate(entry.date); + return next; + }); + }, []); + + const navigatePrev = useCallback(() => { + setCurrentDate(prev => { + const d = new Date(prev); + if (viewMode === "year") { + d.setFullYear(d.getFullYear() - 1); + } else if (viewMode === "month") { + d.setMonth(d.getMonth() - 1); + } else if (viewMode === "week") { + d.setDate(d.getDate() - 7); + } else { + d.setDate(d.getDate() - 1); + } + return d; + }); + }, [viewMode]); + + const navigateNext = useCallback(() => { + setCurrentDate(prev => { + const d = new Date(prev); + if (viewMode === "year") { + d.setFullYear(d.getFullYear() + 1); + } else if (viewMode === "month") { + d.setMonth(d.getMonth() + 1); + } else if (viewMode === "week") { + d.setDate(d.getDate() + 7); + } else { + d.setDate(d.getDate() + 1); + } + return d; + }); + }, [viewMode]); + + const goToToday = useCallback(() => { + setViewStack([]); + setViewMode("month"); + setCurrentDate(new Date()); + }, []); + + return { + viewMode, + setViewMode, + currentDate, + viewStack, + drillDown, + goBack, + navigatePrev, + navigateNext, + goToToday, + }; +}; diff --git a/app/panel/components/Calendar/useCalendarData.ts b/app/panel/components/Calendar/useCalendarData.ts new file mode 100644 index 0000000..7a95a05 --- /dev/null +++ b/app/panel/components/Calendar/useCalendarData.ts @@ -0,0 +1,133 @@ +import { useItemValuesQuery } from "@app/hooks/data/useItemValuesQuery"; +import { useTrackedDataMap } from "@app/hooks/data/useTrackedDataMap"; +import { aggregateRows, filterRowsByRange } from "@app/utils/aggregate-rows"; +import { useMemo } from "react"; +import type { CalendarCellData, CalendarViewMode } from "./types"; + +const EMPTY_CELL: CalendarCellData = { + hasData: false, + totalExp: 0, + expBySkill: {}, + skillLevels: {}, + totalDamageReceived: 0, + totalDamageDealt: 0, + foodUsed: 0, + hpUsed: null, + drops: [], + totalDropValue: 0, + producedItems: [], + totalProducedValue: 0, + netProfit: 0, + totalFights: 0, + totalSkillingActions: 0, + avgExpPerHour: 0, + timeRange: null, +}; + +const getVisibleRange = (viewMode: CalendarViewMode, currentDate: Date): { start: Date; end: Date } => { + const y = currentDate.getFullYear(); + const m = currentDate.getMonth(); + const d = currentDate.getDate(); + + if (viewMode === "year") { + return { + start: new Date(y, 0, 1, 0, 0, 0, 0), + end: new Date(y, 11, 31, 23, 59, 59, 999), + }; + } + if (viewMode === "month") { + // Include days from previous/next months visible in the grid + const firstOfMonth = new Date(y, m, 1); + const dayOfWeek = firstOfMonth.getDay(); + const gridStart = new Date(y, m, 1 - dayOfWeek, 0, 0, 0, 0); + const lastOfMonth = new Date(y, m + 1, 0); + const endDayOfWeek = lastOfMonth.getDay(); + const gridEnd = new Date(y, m + 1, 6 - endDayOfWeek, 23, 59, 59, 999); + return { start: gridStart, end: gridEnd }; + } + if (viewMode === "week") { + const dayOfWeek = currentDate.getDay(); + const weekStart = new Date(y, m, d - dayOfWeek, 0, 0, 0, 0); + const weekEnd = new Date(y, m, d - dayOfWeek + 6, 23, 59, 59, 999); + return { start: weekStart, end: weekEnd }; + } + // day + return { + start: new Date(y, m, d, 0, 0, 0, 0), + end: new Date(y, m, d, 23, 59, 59, 999), + }; +}; + +/** + * Builds cell data for the visible calendar grid. + * Groups rows by cell (local timezone) and calls aggregateRows() per cell group. + */ +export const useCalendarData = (viewMode: CalendarViewMode, currentDate: Date) => { + const { allRows, loading } = useTrackedDataMap(); + const { itemValues } = useItemValuesQuery(); + + const cellData = useMemo(() => { + const map = new Map(); + const { start, end } = getVisibleRange(viewMode, currentDate); + const visibleRows = filterRowsByRange(allRows, start, end); + + if (visibleRows.length === 0) return map; + + if (viewMode === "year") { + // Group by month (local) + const groups = new Map(); + visibleRows.forEach(row => { + const month = new Date(row.timestamp).getMonth(); + const existing = groups.get(month); + if (existing) { + existing.push(row); + } else { + groups.set(month, [row]); + } + }); + groups.forEach((rows, month) => { + const stats = aggregateRows(rows, itemValues); + map.set(String(month), { ...stats, hasData: true }); + }); + } else if (viewMode === "month" || viewMode === "week") { + // Group by date (YYYY-MM-DD, local) + const groups = new Map(); + visibleRows.forEach(row => { + const dt = new Date(row.timestamp); + const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`; + const existing = groups.get(key); + if (existing) { + existing.push(row); + } else { + groups.set(key, [row]); + } + }); + groups.forEach((rows, key) => { + const stats = aggregateRows(rows, itemValues); + map.set(key, { ...stats, hasData: true }); + }); + } else { + // Day view: group by hour (local) + const groups = new Map(); + visibleRows.forEach(row => { + const h = new Date(row.timestamp).getHours(); + const existing = groups.get(h); + if (existing) { + existing.push(row); + } else { + groups.set(h, [row]); + } + }); + groups.forEach((rows, hour) => { + const stats = aggregateRows(rows, itemValues); + map.set(String(hour), { ...stats, hasData: true }); + }); + } + + return map; + }, [viewMode, currentDate, allRows, itemValues]); + + const getCell = (key: string): CalendarCellData => cellData.get(key) || EMPTY_CELL; + + return { cellData, getCell, loading }; +}; diff --git a/app/panel/components/Calendar/views/DayView.tsx b/app/panel/components/Calendar/views/DayView.tsx new file mode 100644 index 0000000..ce93e32 --- /dev/null +++ b/app/panel/components/Calendar/views/DayView.tsx @@ -0,0 +1,286 @@ +import { TrendUpIcon } from "@app/assets/icons"; +import { Badge, Card, CardContent, CardHeader, CardTitle, ItemImage } from "@app/components"; +import { cn } from "@app/utils/cn"; +import { calculateExpForNextLevel } from "@app/utils/exp-calculator"; +import { formatExp } from "@app/utils/formatting"; +import { memo, useCallback, useMemo, useState } from "react"; +import type { CalendarCellData } from "../types"; +import type { CSSProperties } from "react"; + +interface DayViewProps { + currentDate: Date; + getCell: (key: string) => CalendarCellData; +} + +const formatGP = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + +const formatHourLabel = (hour: number): string => { + const h = hour % 12 || 12; + const ampm = hour < 12 ? "AM" : "PM"; + return `${h}:00 ${ampm}`; +}; + +const EXP_BADGE_STYLE: CSSProperties = { + backgroundColor: "color-mix(in srgb, var(--primary) 20%, transparent)", + borderWidth: "1px", + borderColor: "color-mix(in srgb, var(--primary) 30%, transparent)", + color: "var(--primary)", +}; + +const SKILL_BADGE_STYLE: CSSProperties = { + backgroundColor: "color-mix(in srgb, var(--accent-foreground) 10%, transparent)", + borderWidth: "1px", + borderColor: "color-mix(in srgb, var(--accent-foreground) 20%, transparent)", + color: "var(--accent-foreground)", +}; + +// --- Hour row with data --- + +interface HourRowProps { + hour: number; + data: CalendarCellData; + mainSkill?: string; + trendPct?: number; +} + +const HourRow = memo(({ hour, data, mainSkill, trendPct }: HourRowProps) => { + const [expanded, setExpanded] = useState(false); + const toggle = useCallback(() => setExpanded(prev => !prev), []); + + const hasStats = + data.totalDropValue > 0 || + data.netProfit !== 0 || + data.foodUsed > 0 || + data.totalFights > 0 || + data.totalSkillingActions > 0; + + return ( + + +
+ {formatHourLabel(hour)} +
+ {mainSkill && ( +
+ {mainSkill} +
+ )} +
+ {formatExp(data.totalExp)} xp + {trendPct !== undefined && ( + <> + | + {trendPct}% + + + )} +
+
+
+
+ + {expanded && ( + + {Object.keys(data.expBySkill).length > 0 && ( +
+ {Object.entries(data.expBySkill) + .sort(([, a], [, b]) => b - a) + .map(([skill, exp]) => ( + + {skill} + {formatExp(exp)} + + ))} +
+ )} + + {data.drops.length > 0 && ( +
+

Drops

+
+ {data.drops.map(item => ( + + ))} +
+
+ )} + + {data.producedItems.length > 0 && ( +
+

Items Produced

+
+ {data.producedItems.map(item => ( + + ))} +
+
+ )} + + {hasStats && ( +
+ {data.totalDropValue > 0 && Total: {formatGP(data.totalDropValue)} GP} + {data.netProfit !== 0 && ( + 0 ? "text-primary" : "text-destructive")}> + Net: {data.netProfit > 0 ? "+" : ""} + {formatGP(data.netProfit)} GP + + )} + {data.foodUsed > 0 && Food: {formatGP(data.foodUsed)} GP} + {data.totalFights > 0 && Fights: {data.totalFights.toLocaleString()}} + {data.totalSkillingActions > 0 && Actions: {data.totalSkillingActions.toLocaleString()}} +
+ )} +
+ )} +
+ ); +}); + +HourRow.displayName = "HourRow"; + +// --- Empty hour (single) --- + +const EmptyHourRow = memo(({ hour }: { hour: number }) => ( +
+ {formatHourLabel(hour)} + No data +
+)); + +EmptyHourRow.displayName = "EmptyHourRow"; + +// --- Collapsible group of consecutive empty hours --- + +interface EmptyGroupProps { + hours: number[]; +} + +const EmptyHourGroup = memo(({ hours }: EmptyGroupProps) => { + const [expanded, setExpanded] = useState(false); + const toggle = useCallback(() => setExpanded(prev => !prev), []); + + const label = `${formatHourLabel(hours[0])} - ${formatHourLabel(hours[hours.length - 1])}`; + + return ( +
+ + {expanded && ( +
+ {hours.map(h => ( + + ))} +
+ )} +
+ ); +}); + +EmptyHourGroup.displayName = "EmptyHourGroup"; + +// --- Segment types for rendering --- + +type DaySegment = { type: "active"; hour: number; data: CalendarCellData } | { type: "empty"; hours: number[] }; + +// --- DayView --- + +const DayView = memo(({ getCell }: DayViewProps) => { + const segments = useMemo(() => { + const result: DaySegment[] = []; + let emptyBuffer: number[] = []; + + const flushEmpty = () => { + if (emptyBuffer.length > 0) { + result.push({ type: "empty", hours: emptyBuffer }); + emptyBuffer = []; + } + }; + + for (let h = 23; h >= 0; h--) { + const cell = getCell(String(h)); + if (cell.hasData) { + flushEmpty(); + result.push({ type: "active", hour: h, data: cell }); + } else { + emptyBuffer.push(h); + } + } + flushEmpty(); + + return result; + }, [getCell]); + + const hourMeta = useMemo(() => { + const trendMap = new Map(); + const skillMap = new Map(); + for (const seg of segments) { + if (seg.type !== "active") continue; + const { expBySkill, skillLevels } = seg.data; + const mainEntry = Object.entries(expBySkill).sort(([, a], [, b]) => b - a)[0]; + if (!mainEntry) continue; + const [skillName, skillExp] = mainEntry; + skillMap.set(seg.hour, skillName); + const level = skillLevels[skillName]; + if (!level || level <= 0) continue; + const expForNext = calculateExpForNextLevel(level); + if (expForNext <= 0) continue; + const pct = Number.parseFloat(((skillExp / expForNext) * 100).toFixed(2)); + trendMap.set(seg.hour, pct); + } + return { trendMap, skillMap }; + }, [segments]); + + return ( +
+ {segments.map(seg => { + if (seg.type === "active") { + return ( + + ); + } + // Single empty hour — show inline, no collapse needed + if (seg.hours.length === 1) { + return ; + } + // Multiple consecutive empty hours — collapsible group + return ; + })} +
+ ); +}); + +DayView.displayName = "DayView"; + +export { DayView }; diff --git a/app/panel/components/Calendar/views/MonthView.tsx b/app/panel/components/Calendar/views/MonthView.tsx new file mode 100644 index 0000000..c00a575 --- /dev/null +++ b/app/panel/components/Calendar/views/MonthView.tsx @@ -0,0 +1,83 @@ +import { CalendarCell } from "../CalendarCell"; +import { memo, useMemo } from "react"; +import type { CalendarCellData, CalendarViewMode } from "../types"; + +interface MonthViewProps { + currentDate: Date; + cellData: Map; + getCell: (key: string) => CalendarCellData; + onDrillDown: (mode: CalendarViewMode, date: Date) => void; +} + +interface DayCellInfo { + date: Date; + day: number; + key: string; + isCurrentMonth: boolean; + isToday: boolean; +} + +const MonthView = memo(({ currentDate, getCell, onDrillDown }: MonthViewProps) => { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + const days = useMemo(() => { + const now = new Date(); + const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + + const firstOfMonth = new Date(year, month, 1); + const startDay = firstOfMonth.getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + // Previous month days + const prevMonthDays = new Date(year, month, 0).getDate(); + + const cells: DayCellInfo[] = []; + + // Fill in previous month + for (let i = startDay - 1; i >= 0; i--) { + const d = prevMonthDays - i; + const dateObj = new Date(year, month - 1, d); + const key = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + cells.push({ date: dateObj, day: d, key, isCurrentMonth: false, isToday: key === todayKey }); + } + + // Current month + for (let d = 1; d <= daysInMonth; d++) { + const dateObj = new Date(year, month, d); + const key = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + cells.push({ date: dateObj, day: d, key, isCurrentMonth: true, isToday: key === todayKey }); + } + + // Next month (fill to complete last row) + const remaining = 7 - (cells.length % 7); + if (remaining < 7) { + for (let d = 1; d <= remaining; d++) { + const dateObj = new Date(year, month + 1, d); + const key = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + cells.push({ date: dateObj, day: d, key, isCurrentMonth: false, isToday: key === todayKey }); + } + } + + return cells; + }, [year, month]); + + return ( +
+ {days.map(({ date, day, key, isCurrentMonth, isToday }) => ( + onDrillDown("day", date)} + /> + ))} +
+ ); +}); + +MonthView.displayName = "MonthView"; + +export { MonthView }; diff --git a/app/panel/components/Calendar/views/WeekView.tsx b/app/panel/components/Calendar/views/WeekView.tsx new file mode 100644 index 0000000..bf18285 --- /dev/null +++ b/app/panel/components/Calendar/views/WeekView.tsx @@ -0,0 +1,53 @@ +import { CalendarCell } from "../CalendarCell"; +import { memo, useMemo } from "react"; +import type { CalendarCellData, CalendarViewMode } from "../types"; + +interface WeekViewProps { + currentDate: Date; + getCell: (key: string) => CalendarCellData; + onDrillDown: (mode: CalendarViewMode, date: Date) => void; +} + +const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +const WeekView = memo(({ currentDate, getCell, onDrillDown }: WeekViewProps) => { + const days = useMemo(() => { + const y = currentDate.getFullYear(); + const m = currentDate.getMonth(); + const d = currentDate.getDate(); + const dayOfWeek = currentDate.getDay(); + const weekStart = new Date(y, m, d - dayOfWeek); + + const now = new Date(); + const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + + return Array.from({ length: 7 }, (_, i) => { + const date = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + return { + date, + key, + label: `${DAY_NAMES[i]} ${date.getDate()}`, + isToday: key === todayKey, + }; + }); + }, [currentDate]); + + return ( +
+ {days.map(({ date, key, label, isToday }) => ( + onDrillDown("day", date)} + /> + ))} +
+ ); +}); + +WeekView.displayName = "WeekView"; + +export { WeekView }; diff --git a/app/panel/components/Calendar/views/YearView.tsx b/app/panel/components/Calendar/views/YearView.tsx new file mode 100644 index 0000000..27ed094 --- /dev/null +++ b/app/panel/components/Calendar/views/YearView.tsx @@ -0,0 +1,40 @@ +import { CalendarCell } from "../CalendarCell"; +import { memo } from "react"; +import type { CalendarCellData, CalendarViewMode } from "../types"; + +interface YearViewProps { + currentDate: Date; + getCell: (key: string) => CalendarCellData; + onDrillDown: (mode: CalendarViewMode, date: Date) => void; +} + +const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +const YearView = memo(({ currentDate, getCell, onDrillDown }: YearViewProps) => { + const year = currentDate.getFullYear(); + const now = new Date(); + const currentMonth = now.getMonth(); + const currentYear = now.getFullYear(); + + return ( +
+ {MONTH_NAMES.map((name, i) => { + const cellData = getCell(String(i)); + const isCurrentMonth = year === currentYear && i === currentMonth; + return ( + onDrillDown("month", new Date(year, i, 1))} + /> + ); + })} +
+ ); +}); + +YearView.displayName = "YearView"; + +export { YearView }; diff --git a/app/panel/components/Dashboard/ExpChart/ChartHeader.tsx b/app/panel/components/Dashboard/ExpChart/ChartHeader.tsx new file mode 100644 index 0000000..cb76111 --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/ChartHeader.tsx @@ -0,0 +1,56 @@ +import { ImportIcon } from "@app/assets/icons"; +import { CardHeader, CardTitle, Button, IconButton } from "@app/components"; +import { memo } from "react"; + +interface ChartHeaderProps { + userName: string; + skillsCount: number; + onSkillFilterClick: () => void; + onTimeFilterClick: () => void; + onImportClick: () => void; + timeFrameLabel: string; + children?: React.ReactNode; +} + +const ChartHeader = memo( + ({ + userName, + skillsCount, + onSkillFilterClick, + onTimeFilterClick, + onImportClick, + timeFrameLabel, + children, + }: ChartHeaderProps) => ( + +
+
+
+ {userName} +
+
+ + + +
+
+ {children} +
+
+ ), +); + +ChartHeader.displayName = "ChartHeader"; + +export { ChartHeader }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/charts/AreaChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/AreaChart.tsx similarity index 67% rename from pages/side-panel/src/components/Dashboard/ExpChart/charts/AreaChart.tsx rename to app/panel/components/Dashboard/ExpChart/charts/AreaChart.tsx index 8ab1e8e..5ddbae7 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/charts/AreaChart.tsx +++ b/app/panel/components/Dashboard/ExpChart/charts/AreaChart.tsx @@ -1,9 +1,9 @@ -import { formatChartDate, formatTooltipDate } from '../utils'; -import { ChartTooltip, ChartTooltipContent } from '@extension/ui'; -import { memo } from 'react'; -import { Area, AreaChart as RechartsAreaChart, CartesianGrid, XAxis } from 'recharts'; -import type { ChartDataPoint, TimeFrame } from '../types'; -import type { ChartConfig } from '@extension/ui'; +import { formatChartDate, formatTooltipDate } from "../utils"; +import { ChartTooltip, ChartTooltipContent } from "@app/components"; +import { memo } from "react"; +import { Area, AreaChart as RechartsAreaChart, CartesianGrid, XAxis } from "recharts"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint, TimeFrame } from "@app/types"; interface AreaChartProps { chartData: ChartDataPoint[]; @@ -27,10 +27,9 @@ const AreaChart = memo(({ chartData, chartConfig, skills, timeFrame }: AreaChart {skills.map(skill => { const skillConfig = chartConfig[skill]; - const color = - typeof skillConfig === 'object' && 'color' in skillConfig ? skillConfig.color : 'hsl(var(--primary))'; + const color = typeof skillConfig === "object" && "color" in skillConfig ? skillConfig.color : "var(--primary)"; - const gradientId = `gradient-${skill.replace(/\s+/g, '-')}`; + const gradientId = `gradient-${skill.replace(/\s+/g, "-")}`; return ( @@ -40,7 +39,7 @@ const AreaChart = memo(({ chartData, chartConfig, skills, timeFrame }: AreaChart ); })} - + formatTooltipDate(value, timeFrame)} />} /> {skills.map(skill => { const skillConfig = chartConfig[skill]; - const color = - typeof skillConfig === 'object' && 'color' in skillConfig ? skillConfig.color : 'hsl(var(--primary))'; - const gradientId = `gradient-${skill.replace(/\s+/g, '-')}`; + const color = typeof skillConfig === "object" && "color" in skillConfig ? skillConfig.color : "var(--primary)"; + const gradientId = `gradient-${skill.replace(/\s+/g, "-")}`; return ( )); -AreaChart.displayName = 'AreaChart'; +AreaChart.displayName = "AreaChart"; export { AreaChart }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/charts/BarChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/BarChart.tsx similarity index 68% rename from pages/side-panel/src/components/Dashboard/ExpChart/charts/BarChart.tsx rename to app/panel/components/Dashboard/ExpChart/charts/BarChart.tsx index 948f60c..51ebe13 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/charts/BarChart.tsx +++ b/app/panel/components/Dashboard/ExpChart/charts/BarChart.tsx @@ -1,9 +1,9 @@ -import { formatChartDate, formatTooltipDate } from '../utils'; -import { ChartTooltip, ChartTooltipContent } from '@extension/ui'; -import { memo } from 'react'; -import { Bar, BarChart as RechartsBarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import type { ChartDataPoint, TimeFrame } from '../types'; -import type { ChartConfig } from '@extension/ui'; +import { formatChartDate, formatTooltipDate } from "../utils"; +import { ChartTooltip, ChartTooltipContent } from "@app/components"; +import { memo } from "react"; +import { Bar, BarChart as RechartsBarChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint, TimeFrame } from "@app/types"; interface BarChartProps { chartData: ChartDataPoint[]; @@ -24,7 +24,7 @@ const commonProps = { const BarChart = memo(({ chartData, chartConfig, skills, timeFrame }: BarChartProps) => ( - + formatTooltipDate(value, timeFrame)} />} /> {skills.map(skill => { const skillConfig = chartConfig[skill]; - const color = - typeof skillConfig === 'object' && 'color' in skillConfig ? skillConfig.color : 'hsl(var(--primary))'; + const color = typeof skillConfig === "object" && "color" in skillConfig ? skillConfig.color : "var(--primary)"; return ; })} )); -BarChart.displayName = 'BarChart'; +BarChart.displayName = "BarChart"; export { BarChart }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/ChartVisualization.tsx b/app/panel/components/Dashboard/ExpChart/charts/ChartVisualization.tsx similarity index 65% rename from pages/side-panel/src/components/Dashboard/ExpChart/ChartVisualization.tsx rename to app/panel/components/Dashboard/ExpChart/charts/ChartVisualization.tsx index 8322881..83984ec 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/ChartVisualization.tsx +++ b/app/panel/components/Dashboard/ExpChart/charts/ChartVisualization.tsx @@ -1,13 +1,13 @@ -import { AreaChart } from './charts/AreaChart'; -import { BarChart } from './charts/BarChart'; -import { LineChart } from './charts/LineChart'; -import { PieChart } from './charts/PieChart'; -import { RadarChart } from './charts/RadarChart'; -import { RadialChart } from './charts/RadialChart'; -import { ChartContainer } from '@extension/ui'; -import { memo } from 'react'; -import type { ChartDataPoint, ChartType, TimeFrame } from './types'; -import type { ChartConfig } from '@extension/ui'; +import { AreaChart } from "./AreaChart"; +import { BarChart } from "./BarChart"; +import { LineChart } from "./LineChart"; +import { PieChart } from "./PieChart"; +import { RadarChart } from "./RadarChart"; +import { RadialChart } from "./RadialChart"; +import { ChartContainer } from "@app/components"; +import { memo } from "react"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint, ChartType, TimeFrame } from "@app/types"; interface ChartVisualizationProps { chartData: ChartDataPoint[]; @@ -22,13 +22,13 @@ const ChartVisualization = memo( ({ chartData, chartConfig, skills, chartType, timeFrame, userStats }: ChartVisualizationProps) => { const renderChart = () => { switch (chartType) { - case 'line': + case "line": return ; - case 'bar': + case "bar": return ; - case 'pie': + case "pie": return ; - case 'radar': + case "radar": return ( ); - case 'radial': + case "radial": return ; default: return ; @@ -46,13 +46,13 @@ const ChartVisualization = memo( }; return ( - + {renderChart()} ); }, ); -ChartVisualization.displayName = 'ChartVisualization'; +ChartVisualization.displayName = "ChartVisualization"; export { ChartVisualization }; diff --git a/app/panel/components/Dashboard/ExpChart/charts/HeatmapChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/HeatmapChart.tsx new file mode 100644 index 0000000..eb95d38 --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/charts/HeatmapChart.tsx @@ -0,0 +1,113 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components"; +import type { PeriodStats, TimePeriod } from "@app/types"; +import type { CSSProperties } from "react"; + +interface HeatmapChartProps { + data: PeriodStats[]; + valueKey: keyof PeriodStats; + title: string; + selectedPeriod: TimePeriod; + formatter?: (value: number) => string; +} + +export const HeatmapChart = ({ + data, + valueKey, + title, + selectedPeriod, + formatter = val => val.toLocaleString(), +}: HeatmapChartProps) => { + // Sort data chronologically (oldest first) + const sortedData = [...data].sort((a, b) => a.date.getTime() - b.date.getTime()); + + const values = sortedData.map(d => Number(d[valueKey]) || 0); + const maxValue = Math.max(...values, 0); + const minValue = Math.min(...values, 0); + + const getIntensityStyle = (value: number): CSSProperties => { + if (value === 0) return {}; + + const isNegative = value < 0; + const cssVar = isNegative ? "var(--destructive)" : "var(--primary)"; + const absValue = Math.abs(value); + const referenceMax = isNegative ? Math.abs(minValue) : maxValue; + const ratio = referenceMax > 0 ? absValue / referenceMax : 0; + const percent = Math.round(20 + ratio * 80); + + return { backgroundColor: `color-mix(in srgb, ${cssVar} ${percent}%, transparent)` }; + }; + + const getLabel = (date: Date) => { + if (selectedPeriod === "hour") { + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } else if (selectedPeriod === "day") { + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + weekday: "short", + }); + } else if (selectedPeriod === "week") { + return `Week of ${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`; + } else { + return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + } + }; + + return ( + + + {title} + + + {sortedData.length === 0 ? ( +
+ No data available for this period +
+ ) : ( +
+ + {sortedData.map(item => { + const value = Number(item[valueKey]) || 0; + return ( + + +
+
+ +
{getLabel(item.date)}
+
{formatter(value)}
+ {/* Add extra details if needed */} + {valueKey === "netProfit" && ( +
Drops: {item.totalDrops}
+ )} +
+
+ ); + })} +
+
+ )} +
+
+ ); +}; diff --git a/app/panel/components/Dashboard/ExpChart/charts/HeatmapVisualization.tsx b/app/panel/components/Dashboard/ExpChart/charts/HeatmapVisualization.tsx new file mode 100644 index 0000000..ce353ca --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/charts/HeatmapVisualization.tsx @@ -0,0 +1,194 @@ +import { HeatmapChart } from "./HeatmapChart"; +import type { PeriodStats, TimePeriod } from "@app/types"; + +interface HeatmapVisualizationProps { + data: PeriodStats[]; + selectedPeriod: TimePeriod; +} + +const buildTimeline = (data: PeriodStats[], selectedPeriod: TimePeriod): PeriodStats[] => { + if (selectedPeriod === "month") { + const map = new Map(); + data.forEach(item => { + const key = `${item.date.getUTCFullYear()}-${item.date.getUTCMonth()}`; + if (!map.has(key)) { + map.set(key, item); + } + }); + + const referenceDate = data.length > 0 ? data[data.length - 1].date : new Date(); + const year = referenceDate.getUTCFullYear(); + const result: PeriodStats[] = []; + + for (let month = 0; month < 12; month++) { + const bucketDate = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)); + const key = `${year}-${month}`; + const existing = map.get(key); + if (existing) { + result.push(existing); + } else { + result.push({ + periodKey: key, + date: bucketDate, + totalGainedExp: 0, + skills: {}, + hpUsed: null, + dropStats: {}, + lootItems: [], + totalDrops: 0, + totalDropAmount: 0, + totalDropValue: 0, + hpValue: 0, + netProfit: 0, + totalSkillingActions: 0, + itemsProduced: {}, + } as PeriodStats); + } + } + + return result; + } + + const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime()); + const referenceDate = sorted.length > 0 ? sorted[sorted.length - 1].date : new Date(); + + const map = new Map(); + sorted.forEach(item => { + let key: string; + if (selectedPeriod === "hour") { + key = `${item.date.getUTCFullYear()}-${item.date.getUTCMonth()}-${item.date.getUTCDate()}-${item.date.getUTCHours()}`; + } else if (selectedPeriod === "day") { + key = `${item.date.getUTCFullYear()}-${item.date.getUTCMonth()}-${item.date.getUTCDate()}`; + } else { + const weekStart = new Date(item.date); + const day = weekStart.getUTCDay(); + weekStart.setUTCDate(weekStart.getUTCDate() - day); + weekStart.setUTCHours(0, 0, 0, 0); + key = `${weekStart.getUTCFullYear()}-${weekStart.getUTCMonth()}-${weekStart.getUTCDate()}`; + } + if (!map.has(key)) { + map.set(key, item); + } + }); + + const result: PeriodStats[] = []; + + if (selectedPeriod === "hour") { + const base = new Date( + Date.UTC(referenceDate.getUTCFullYear(), referenceDate.getUTCMonth(), referenceDate.getUTCDate(), 0, 0, 0, 0), + ); + for (let hour = 0; hour < 24; hour++) { + const bucketDate = new Date(base); + bucketDate.setUTCHours(hour, 0, 0, 0); + const key = `${bucketDate.getUTCFullYear()}-${bucketDate.getUTCMonth()}-${bucketDate.getUTCDate()}-${bucketDate.getUTCHours()}`; + const existing = map.get(key); + if (existing) { + result.push(existing); + } else { + result.push({ + periodKey: key, + date: bucketDate, + totalGainedExp: 0, + skills: {}, + hpUsed: null, + dropStats: {}, + lootItems: [], + totalDrops: 0, + totalDropAmount: 0, + totalDropValue: 0, + hpValue: 0, + netProfit: 0, + totalSkillingActions: 0, + itemsProduced: {}, + } as PeriodStats); + } + } + } else if (selectedPeriod === "day") { + const year = referenceDate.getUTCFullYear(); + const month = referenceDate.getUTCMonth(); + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + for (let day = 1; day <= daysInMonth; day++) { + const bucketDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + const key = `${year}-${month}-${day}`; + const existing = map.get(key); + if (existing) { + result.push(existing); + } else { + result.push({ + periodKey: key, + date: bucketDate, + totalGainedExp: 0, + skills: {}, + hpUsed: null, + dropStats: {}, + lootItems: [], + totalDrops: 0, + totalDropAmount: 0, + totalDropValue: 0, + hpValue: 0, + netProfit: 0, + totalSkillingActions: 0, + itemsProduced: {}, + } as PeriodStats); + } + } + } else { + const year = referenceDate.getUTCFullYear(); + const yearStart = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); + const firstWeekStart = new Date(yearStart); + const day = firstWeekStart.getUTCDay(); + firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() - day); + firstWeekStart.setUTCHours(0, 0, 0, 0); + + for (let i = 0; i < 52; i++) { + const bucketDate = new Date(firstWeekStart); + bucketDate.setUTCDate(bucketDate.getUTCDate() + i * 7); + const key = `${bucketDate.getUTCFullYear()}-${bucketDate.getUTCMonth()}-${bucketDate.getUTCDate()}`; + const existing = map.get(key); + if (existing) { + result.push(existing); + } else { + result.push({ + periodKey: key, + date: bucketDate, + totalGainedExp: 0, + skills: {}, + hpUsed: null, + dropStats: {}, + lootItems: [], + totalDrops: 0, + totalDropAmount: 0, + totalDropValue: 0, + hpValue: 0, + netProfit: 0, + totalSkillingActions: 0, + itemsProduced: {}, + }); + } + } + } + + return result; +}; + +export const HeatmapVisualization = ({ data, selectedPeriod }: HeatmapVisualizationProps) => { + const normalizedData = buildTimeline(data, selectedPeriod); + return ( +
+ `${val.toLocaleString()} XP`} + /> + `${val.toLocaleString()} Gold`} + /> +
+ ); +}; diff --git a/app/panel/components/Dashboard/ExpChart/charts/LineChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/LineChart.tsx new file mode 100644 index 0000000..d21f7d8 --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/charts/LineChart.tsx @@ -0,0 +1,88 @@ +import { formatChartDate, formatTooltipDate } from "../utils"; +import { ChartTooltip, ChartTooltipContent } from "@app/components"; +import { SKILL_COLORS } from "@app/constants"; +import { memo } from "react"; +import { Area, CartesianGrid, XAxis, YAxis, Line, ComposedChart } from "recharts"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint, TimeFrame } from "@app/types"; + +interface LineChartProps { + chartData: ChartDataPoint[]; + chartConfig: ChartConfig; + skills: string[]; + timeFrame: TimeFrame; +} + +const commonProps = { + accessibilityLayer: true, + margin: { + left: 12, + right: 12, + top: 12, + bottom: 12, + }, +}; + +const LineChart = memo(({ chartData, chartConfig, skills, timeFrame }: LineChartProps) => ( + + + {skills.map((skill, index) => { + const skillConfig = chartConfig[skill]; + const color = + typeof skillConfig === "object" && "color" in skillConfig + ? skillConfig.color + : SKILL_COLORS[index % SKILL_COLORS.length]; + + const gradientId = `gradient-${skill.replace(/\s+/g, "-")}`; + + // Gradient: lighter at top (higher opacity), darker at bottom (lower opacity) + // Matching screenshot style with teal/cyan theme + return ( + + + + + + ); + })} + + + formatChartDate(value, timeFrame)} + className="fill-muted-foreground text-xs" + /> + + formatTooltipDate(value, timeFrame)} />} /> + {skills.map((skill, index) => { + const skillConfig = chartConfig[skill]; + const color = + typeof skillConfig === "object" && "color" in skillConfig + ? skillConfig.color + : SKILL_COLORS[index % SKILL_COLORS.length]; + const gradientId = `gradient-${skill.replace(/\s+/g, "-")}`; + return ( + + + + + ); + })} + +)); + +LineChart.displayName = "LineChart"; + +export { LineChart }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/charts/PieChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/PieChart.tsx similarity index 75% rename from pages/side-panel/src/components/Dashboard/ExpChart/charts/PieChart.tsx rename to app/panel/components/Dashboard/ExpChart/charts/PieChart.tsx index ad8bfd0..56df6fd 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/charts/PieChart.tsx +++ b/app/panel/components/Dashboard/ExpChart/charts/PieChart.tsx @@ -1,8 +1,9 @@ -import { ChartTooltip, ChartTooltipContent } from '@extension/ui'; -import { memo, useMemo } from 'react'; -import { Pie, PieChart as RechartsPieChart, Cell, Legend } from 'recharts'; -import type { ChartDataPoint } from '../types'; -import type { ChartConfig } from '@extension/ui'; +import { ChartTooltip, ChartTooltipContent } from "@app/components"; +import { memo, useMemo } from "react"; +// eslint-disable-next-line import-x/no-deprecated +import { Pie, PieChart as RechartsPieChart, Cell, Legend } from "recharts"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint } from "@app/types"; interface PieChartProps { chartData: ChartDataPoint[]; @@ -27,8 +28,7 @@ const PieChart = memo(({ chartData, chartConfig, skills }: PieChartProps) => { skills.map(skill => { const total = chartData.reduce((sum, point) => sum + (Number(point[skill]) || 0), 0); const skillConfig = chartConfig[skill]; - const color = - typeof skillConfig === 'object' && 'color' in skillConfig ? skillConfig.color : 'hsl(var(--primary))'; + const color = typeof skillConfig === "object" && "color" in skillConfig ? skillConfig.color : "var(--primary)"; return { name: skill, value: total, @@ -59,6 +59,6 @@ const PieChart = memo(({ chartData, chartConfig, skills }: PieChartProps) => { ); }); -PieChart.displayName = 'PieChart'; +PieChart.displayName = "PieChart"; export { PieChart }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/charts/RadarChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/RadarChart.tsx similarity index 74% rename from pages/side-panel/src/components/Dashboard/ExpChart/charts/RadarChart.tsx rename to app/panel/components/Dashboard/ExpChart/charts/RadarChart.tsx index dc37c99..a101edd 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/charts/RadarChart.tsx +++ b/app/panel/components/Dashboard/ExpChart/charts/RadarChart.tsx @@ -1,8 +1,8 @@ -import { ChartTooltip, ChartTooltipContent } from '@extension/ui'; -import { memo, useMemo } from 'react'; -import { Radar, RadarChart as RechartsRadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from 'recharts'; -import type { ChartDataPoint, TimeFrame } from '../types'; -import type { ChartConfig } from '@extension/ui'; +import { ChartTooltip, ChartTooltipContent } from "@app/components"; +import { memo, useMemo } from "react"; +import { Radar, RadarChart as RechartsRadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from "recharts"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint, TimeFrame } from "@app/types"; interface RadarChartProps { chartData: ChartDataPoint[]; @@ -44,8 +44,8 @@ const RadarChart = memo(({ chartData, chartConfig, skills, timeFrame, userStats // For short timeframes, always use tracked data (accumulated gains) // For longer timeframes, fallback to gainedThisWeek if no tracked data - if (exp === 0 && (timeFrame === '7d' || timeFrame === '30d') && userStats?.skills?.[skill]?.gainedThisWeek) { - const weeklyExp = parseInt(userStats.skills[skill].gainedThisWeek?.replace(/,/g, '') || '0', 10); + if (exp === 0 && (timeFrame === "7d" || timeFrame === "30d") && userStats?.skills?.[skill]?.gainedThisWeek) { + const weeklyExp = parseInt(userStats.skills[skill].gainedThisWeek?.replace(/,/g, "") || "0", 10); exp = isNaN(weeklyExp) ? 0 : weeklyExp; } @@ -62,19 +62,19 @@ const RadarChart = memo(({ chartData, chartConfig, skills, timeFrame, userStats // Use a single color for the radar polygon const primaryColor = useMemo(() => { - if (allSkillsFromStats.length === 0) return 'hsl(var(--primary))'; + if (allSkillsFromStats.length === 0) return "var(--primary)"; const skillConfig = chartConfig[allSkillsFromStats[0]]; - return typeof skillConfig === 'object' && 'color' in skillConfig ? skillConfig.color : 'hsl(var(--primary))'; + return typeof skillConfig === "object" && "color" in skillConfig ? skillConfig.color : "var(--primary)"; }, [allSkillsFromStats, chartConfig]); return ( - - + + value.toLocaleString()} /> } /> @@ -83,6 +83,6 @@ const RadarChart = memo(({ chartData, chartConfig, skills, timeFrame, userStats ); }); -RadarChart.displayName = 'RadarChart'; +RadarChart.displayName = "RadarChart"; export { RadarChart }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/charts/RadialChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/RadialChart.tsx similarity index 73% rename from pages/side-panel/src/components/Dashboard/ExpChart/charts/RadialChart.tsx rename to app/panel/components/Dashboard/ExpChart/charts/RadialChart.tsx index 3a0c5c1..04613f0 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/charts/RadialChart.tsx +++ b/app/panel/components/Dashboard/ExpChart/charts/RadialChart.tsx @@ -1,8 +1,9 @@ -import { ChartTooltip, ChartTooltipContent } from '@extension/ui'; -import { memo, useMemo } from 'react'; -import { RadialBar, RadialBarChart as RechartsRadialBarChart, Cell, Legend } from 'recharts'; -import type { ChartDataPoint } from '../types'; -import type { ChartConfig } from '@extension/ui'; +import { ChartTooltip, ChartTooltipContent } from "@app/components"; +import { memo, useMemo } from "react"; +// eslint-disable-next-line import-x/no-deprecated +import { RadialBar, RadialBarChart as RechartsRadialBarChart, Cell, Legend } from "recharts"; +import type { ChartConfig } from "@app/components"; +import type { ChartDataPoint } from "@app/types"; interface RadialChartProps { chartData: ChartDataPoint[]; @@ -27,8 +28,7 @@ const RadialChart = memo(({ chartData, chartConfig, skills }: RadialChartProps) skills.map(skill => { const total = chartData.reduce((sum, point) => sum + (Number(point[skill]) || 0), 0); const skillConfig = chartConfig[skill]; - const color = - typeof skillConfig === 'object' && 'color' in skillConfig ? skillConfig.color : 'hsl(var(--primary))'; + const color = typeof skillConfig === "object" && "color" in skillConfig ? skillConfig.color : "var(--primary)"; return { name: skill, value: total, @@ -51,6 +51,6 @@ const RadialChart = memo(({ chartData, chartConfig, skills }: RadialChartProps) ); }); -RadialChart.displayName = 'RadialChart'; +RadialChart.displayName = "RadialChart"; export { RadialChart }; diff --git a/app/panel/components/Dashboard/ExpChart/charts/SimpleExpChart.tsx b/app/panel/components/Dashboard/ExpChart/charts/SimpleExpChart.tsx new file mode 100644 index 0000000..d191406 --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/charts/SimpleExpChart.tsx @@ -0,0 +1,120 @@ +import { LineChart } from "./LineChart"; +import { useChartData } from "../hooks/useChartData"; +import { Card, CardContent, CardHeader, CardTitle, ChartContainer, cn } from "@app/components"; +import { memo, useState, useMemo } from "react"; +import type { TimeFrame } from "@app/types"; + +/** + * Simple Exp Chart Component - matches screenshot design + * Shows exp/hr over time with lines for each tracked skill + */ +const SimpleExpChart = memo(() => { + const [timeFrame, setTimeFrame] = useState("24h"); + + // Get chart data - we'll select all skills after getting the list + const initialData = useChartData({ + timeFrame: "24h", + selectedSkills: new Set(), + }); + + // Get chart data with all available skills selected + const { chartData, chartConfig } = useChartData({ + timeFrame, + selectedSkills: new Set(initialData.allAvailableSkills), // Show all skills + }); + + const skills = Object.keys(chartConfig); + + // Time frame options - focus on hourly view + const timeFrameOptions: Array<{ value: TimeFrame; label: string }> = [ + { value: "24h", label: "Last 24 hours" }, + { value: "7d", label: "Last 7 days" }, + { value: "30d", label: "Last 30 days" }, + ]; + + // Get subtitle text based on timeframe + const subtitle = useMemo(() => { + switch (timeFrame) { + case "24h": + return "Hourly exp data for the past 24 hours"; + case "7d": + return "Total for the last 7 days"; + case "30d": + return "Total for the last 30 days"; + default: + return "Hourly exp data"; + } + }, [timeFrame]); + + if (chartData.length === 0 || skills.length === 0) { + return ( + + +
+
+ Exp per Hour +

{subtitle}

+
+
+ {timeFrameOptions.map(option => ( + + ))} +
+
+
+ +
No data available
+
+
+ ); + } + + return ( + + +
+
+ Exp per Hour +

{subtitle}

+
+
+ {timeFrameOptions.map(option => ( + + ))} +
+
+
+ +
+ + + +
+
+
+ ); +}); + +SimpleExpChart.displayName = "SimpleExpChart"; + +export default SimpleExpChart; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/SkillFilterDialog.tsx b/app/panel/components/Dashboard/ExpChart/filters/SkillFilterDialog.tsx similarity index 83% rename from pages/side-panel/src/components/Dashboard/ExpChart/SkillFilterDialog.tsx rename to app/panel/components/Dashboard/ExpChart/filters/SkillFilterDialog.tsx index 7136b31..c9b4f0f 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/SkillFilterDialog.tsx +++ b/app/panel/components/Dashboard/ExpChart/filters/SkillFilterDialog.tsx @@ -1,4 +1,3 @@ -import { useFormatting } from '@extension/shared'; import { Dialog, DialogContent, @@ -7,8 +6,9 @@ import { DialogDescription, DialogFooter, Button, -} from '@extension/ui'; -import { memo } from 'react'; +} from "@app/components"; +import { useFormatting } from "@app/hooks"; +import { memo } from "react"; interface SkillFilterDialogProps { open: boolean; @@ -55,16 +55,16 @@ const SkillFilterDialog = memo( {allAvailableSkills.map(skill => ( ))} @@ -78,6 +78,6 @@ const SkillFilterDialog = memo( }, ); -SkillFilterDialog.displayName = 'SkillFilterDialog'; +SkillFilterDialog.displayName = "SkillFilterDialog"; export { SkillFilterDialog }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/SkillPills.tsx b/app/panel/components/Dashboard/ExpChart/filters/SkillPills.tsx similarity index 72% rename from pages/side-panel/src/components/Dashboard/ExpChart/SkillPills.tsx rename to app/panel/components/Dashboard/ExpChart/filters/SkillPills.tsx index 2852362..ec80e2b 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/SkillPills.tsx +++ b/app/panel/components/Dashboard/ExpChart/filters/SkillPills.tsx @@ -1,7 +1,7 @@ -import { extractHslValues } from './utils'; -import { Badge } from '@extension/ui'; -import { memo } from 'react'; -import type { ChartConfig } from '@extension/ui'; +import { extractHslValues } from "../utils"; +import { Badge } from "@app/components"; +import { memo } from "react"; +import type { ChartConfig } from "@app/components"; interface SkillPillsProps { skills: string[]; @@ -16,16 +16,15 @@ const SkillPills = memo(({ skills, chartConfig }: SkillPillsProps) => { {skills.map(skill => { const skillConfig = chartConfig[skill]; const color = - (typeof skillConfig === 'object' && skillConfig && 'color' in skillConfig + (typeof skillConfig === "object" && skillConfig && "color" in skillConfig ? skillConfig.color - : 'hsl(var(--primary))') || 'hsl(var(--primary))'; + : "var(--primary)") || "var(--primary)"; const hslValues = extractHslValues(color); return ( { ); }); -SkillPills.displayName = 'SkillPills'; +SkillPills.displayName = "SkillPills"; export { SkillPills }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/TimeFrameFilterDialog.tsx b/app/panel/components/Dashboard/ExpChart/filters/TimeFrameFilterDialog.tsx similarity index 73% rename from pages/side-panel/src/components/Dashboard/ExpChart/TimeFrameFilterDialog.tsx rename to app/panel/components/Dashboard/ExpChart/filters/TimeFrameFilterDialog.tsx index e7c65a8..666b0ad 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/TimeFrameFilterDialog.tsx +++ b/app/panel/components/Dashboard/ExpChart/filters/TimeFrameFilterDialog.tsx @@ -1,7 +1,7 @@ -import { TIME_FRAME_OPTIONS } from './constants'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@extension/ui'; -import { memo } from 'react'; -import type { TimeFrame } from './types'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@app/components"; +import { TIME_FRAME_OPTIONS } from "@app/constants"; +import { memo } from "react"; +import type { TimeFrame } from "@app/types"; interface TimeFrameFilterDialogProps { open: boolean; @@ -29,8 +29,8 @@ const TimeFrameFilterDialog = memo( @@ -42,6 +42,6 @@ const TimeFrameFilterDialog = memo( }, ); -TimeFrameFilterDialog.displayName = 'TimeFrameFilterDialog'; +TimeFrameFilterDialog.displayName = "TimeFrameFilterDialog"; export { TimeFrameFilterDialog }; diff --git a/pages/side-panel/src/components/Dashboard/ExpChart/useChartData.ts b/app/panel/components/Dashboard/ExpChart/hooks/useChartData.ts similarity index 53% rename from pages/side-panel/src/components/Dashboard/ExpChart/useChartData.ts rename to app/panel/components/Dashboard/ExpChart/hooks/useChartData.ts index 67d09c1..c943c35 100644 --- a/pages/side-panel/src/components/Dashboard/ExpChart/useChartData.ts +++ b/app/panel/components/Dashboard/ExpChart/hooks/useChartData.ts @@ -1,24 +1,25 @@ -import { TIME_FRAME_OPTIONS, SKILL_COLORS } from './constants'; -import { useTrackedDataQuery, useUserStatsQuery } from '@extension/shared'; -import { useMemo } from 'react'; -import type { TimeFrame, ChartDataPoint, ChartDataResult } from './types'; -import type { CSVRow } from '@extension/shared'; +import { SKILL_COLORS, TIME_FRAME_OPTIONS } from "@app/constants"; +import { useTrackedDataQuery } from "@app/hooks"; +import { useMemo } from "react"; +import type { CSVRow, ChartDataPoint, ChartDataResult, TimeFrame } from "@app/types"; /** * Get interval minutes based on timeframe */ const getIntervalMinutes = (timeFrame: TimeFrame): number => { switch (timeFrame) { - case '6h': + case "6h": return 30; // 30 minute intervals for 6 hours (12 data points) - case '12h': + case "12h": return 60; // 1 hour intervals for 12 hours (12 data points) - case '24h': + case "24h": return 60; // 1 hour intervals for 24 hours (24 data points) - case '7d': + case "7d": return 6 * 60; // 6 hour intervals for 7 days (28 data points) - case '30d': + case "30d": return 24 * 60; // 1 day intervals for 30 days (30 data points) + case "90d": + return 3 * 24 * 60; // 3 day intervals for 90 days (30 data points) default: return 60; // Default to 1 hour } @@ -31,60 +32,27 @@ interface UseChartDataParams { export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): ChartDataResult => { const { allData } = useTrackedDataQuery(); - const { userStats } = useUserStatsQuery(); return useMemo(() => { - // Get weekly exp from stats URL (source of truth) - const weeklyExpFromStats: Record = {}; - if (userStats?.skills) { - Object.values(userStats.skills).forEach(skillStat => { - if (skillStat.gainedThisWeek) { - const exp = parseInt(skillStat.gainedThisWeek.replace(/,/g, ''), 10) || 0; - if (exp > 0) { - weeklyExpFromStats[skillStat.skill] = exp; - } - } - }); - } - + // Only use tracked hourly data - no profile stats if (!allData || allData.length === 0) { - // If no tracked data, but we have weekly stats from URL, create a summary point - if (Object.keys(weeklyExpFromStats).length > 0 && (timeFrame === '7d' || timeFrame === '30d')) { - const now = new Date(); - const summaryPoint: Record = { - date: now.toISOString(), - }; - - const skillsFromStats = Object.keys(weeklyExpFromStats).sort(); - const config: Record = {}; - const totals: Record = {}; - - skillsFromStats.forEach((skill, index) => { - const exp = weeklyExpFromStats[skill]; - summaryPoint[skill] = exp; - totals[skill] = exp; - config[skill] = { - label: skill, - color: SKILL_COLORS[index % SKILL_COLORS.length], - }; - }); - - return { - chartData: [summaryPoint] as ChartDataPoint[], - chartConfig: config, - skillTotals: totals, - allAvailableSkills: skillsFromStats, - timeFrame, - }; - } - return { chartData: [], chartConfig: {}, skillTotals: {}, allAvailableSkills: [], timeFrame }; } // Filter by time frame + // For 24h, show all available hourly data (or last 24 hours if we have more) const now = new Date().getTime(); - const timeFrameHours = TIME_FRAME_OPTIONS.find(opt => opt.value === timeFrame)?.hours || 24; - const cutoffTime = now - timeFrameHours * 60 * 60 * 1000; + let cutoffTime: number; + + if (timeFrame === "24h") { + // For 24h, show all available data or last 24 hours, whichever is less + const timeFrameHours = 24; + cutoffTime = now - timeFrameHours * 60 * 60 * 1000; + } else { + const timeFrameHours = + TIME_FRAME_OPTIONS.find(opt => opt.value === timeFrame)?.hours || (timeFrame === "90d" ? 2160 : 24); + cutoffTime = now - timeFrameHours * 60 * 60 * 1000; + } const filteredData = allData.filter((row: CSVRow) => { const timestamp = new Date(row.timestamp).getTime(); @@ -95,7 +63,7 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): // This prevents duplicate data from appearing in charts const uniqueEntriesMap = new Map(); filteredData.forEach((row: CSVRow) => { - const skill = row.skill || 'Unknown'; + const skill = row.skill || "Unknown"; const key = `${row.timestamp}-${skill}`; const existing = uniqueEntriesMap.get(key); @@ -103,8 +71,8 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): uniqueEntriesMap.set(key, row); } else { // Keep the one with higher gainedExp or more complete data - const existingGainedExp = parseInt(existing.gainedExp || '0', 10) || 0; - const currentGainedExp = parseInt(row.gainedExp || '0', 10) || 0; + const existingGainedExp = parseInt(existing.gainedExp || "0", 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || "0", 10) || 0; if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { uniqueEntriesMap.set(key, row); } @@ -127,15 +95,24 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): const timestamp = new Date(row.timestamp).getTime(); const date = new Date(timestamp); - // Round down to the nearest interval - const totalMinutes = date.getHours() * 60 + date.getMinutes(); - const roundedTotalMinutes = Math.floor(totalMinutes / intervalMinutes) * intervalMinutes; - date.setHours(Math.floor(roundedTotalMinutes / 60)); - date.setMinutes(roundedTotalMinutes % 60, 0, 0); + // For 24h timeframe, group by exact hour (round down to hour) + // For other timeframes, use the interval-based grouping + let intervalKey: string; + if (timeFrame === "24h") { + // Round down to the exact hour + date.setMinutes(0, 0, 0); + intervalKey = date.toISOString(); + } else { + // Round down to the nearest interval + const totalMinutes = date.getHours() * 60 + date.getMinutes(); + const roundedTotalMinutes = Math.floor(totalMinutes / intervalMinutes) * intervalMinutes; + date.setHours(Math.floor(roundedTotalMinutes / 60)); + date.setMinutes(roundedTotalMinutes % 60, 0, 0); + intervalKey = date.toISOString(); + } - const intervalKey = date.toISOString(); - const gainedExp = parseInt(row.gainedExp || '0', 10) || 0; - const skill = row.skill || 'Unknown'; + const gainedExp = parseInt(row.gainedExp || "0", 10) || 0; + const skill = row.skill || "Unknown"; if (gainedExp > 0 && skill) { if (!timeIntervalMap.has(intervalKey)) { @@ -153,15 +130,12 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): } }); - // Get all unique skills from tracked data + // Get all unique skills from tracked data only const allSkills = new Set(); timeIntervalMap.forEach(skillMap => { skillMap.forEach((_, skill) => allSkills.add(skill)); }); - // Also include skills from stats URL that might not be in tracked data - Object.keys(weeklyExpFromStats).forEach(skill => allSkills.add(skill)); - // Filter skills based on selection const skillsToShow = selectedSkills.size > 0 @@ -191,6 +165,9 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): }); }); + // Calculate hours per interval for exp/hr calculation + const hoursPerInterval = intervalMinutes / 60; + intervals.forEach(interval => { const point: Record = { date: new Date(interval.timestamp).toISOString(), @@ -198,8 +175,9 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): skillsToShow.forEach(skill => { const expGain = interval.skills[skill] || 0; - // Use gained exp for this interval directly (not cumulative) - point[skill] = expGain; + // Calculate exp per hour: divide exp gained by hours in interval + const expPerHour = hoursPerInterval > 0 ? expGain / hoursPerInterval : expGain; + point[skill] = Math.round(expPerHour); // Track total for tooltip/display purposes const currentTotal = totalsBySkill.get(skill) || 0; @@ -209,37 +187,6 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): chartPoints.push(point); }); - // For weekly timeframes, use stats URL as source of truth for totals - // Stats URL weekly values fill in gaps where tracked data is missing - // IMPORTANT: We use stats URL for totals, but don't modify chart points to avoid duplication - if (timeFrame === '7d' || timeFrame === '30d') { - skillsToShow.forEach(skill => { - const weeklyExp = weeklyExpFromStats[skill] || 0; - const trackedTotal = trackedTotalsBySkill.get(skill) || 0; - - // Stats URL is the source of truth - use it for totals when available - if (weeklyExp > 0) { - // Use stats URL value as the total (it's the authoritative source) - totalsBySkill.set(skill, weeklyExp); - - // Only add to chart points if we have NO tracked data for this skill - // This ensures we show the skill in the chart even if we didn't track it - if (trackedTotal === 0 && chartPoints.length > 0) { - const lastPoint = chartPoints[chartPoints.length - 1]; - // Only set if not already set to avoid overwriting tracked data - if (!lastPoint[skill] || Number(lastPoint[skill]) === 0) { - // Add the weekly exp to the last point so the skill appears in the chart - // This represents the "missing" exp that we didn't track but exists in stats URL - lastPoint[skill] = weeklyExp; - } - } - // If trackedTotal > 0, we keep the tracked data in chart points - // The totals will show the stats URL value (which may be higher) - // This way we don't duplicate data - chart shows tracked intervals, totals show stats URL truth - } - }); - } - // Create chart config const config: Record = {}; const totals: Record = {}; @@ -260,5 +207,5 @@ export const useChartData = ({ timeFrame, selectedSkills }: UseChartDataParams): allAvailableSkills: Array.from(allSkills).sort(), timeFrame, }; - }, [allData, timeFrame, selectedSkills, userStats]); + }, [allData, timeFrame, selectedSkills]); }; diff --git a/app/panel/components/Dashboard/ExpChart/hooks/useExpChart.ts b/app/panel/components/Dashboard/ExpChart/hooks/useExpChart.ts new file mode 100644 index 0000000..0a78c27 --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/hooks/useExpChart.ts @@ -0,0 +1,29 @@ +import { useUserStatsQuery, usePeriodStats } from "@app/hooks"; +import { useCallback, useMemo } from "react"; +import type { TimePeriod } from "@app/types"; + +export const useExpChart = () => { + const { userStats } = useUserStatsQuery(); + const { periodBreakdown, selectedPeriod, setSelectedPeriod, loading } = usePeriodStats("hour"); + + const userName = useMemo(() => userStats?.username || "Player", [userStats?.username]); + + const handleImportClick = useCallback(() => { + chrome.tabs.create({ + url: "https://www.syrnia.com/theGame/includes2/stats.php", + }); + }, []); + + const showLoading = loading && periodBreakdown.length === 0; + + const handlePeriodChange = useCallback((v: string) => setSelectedPeriod(v as TimePeriod), [setSelectedPeriod]); + + return { + userName, + periodBreakdown, + selectedPeriod, + handlePeriodChange, + handleImportClick, + showLoading, + }; +}; diff --git a/app/panel/components/Dashboard/ExpChart/index.tsx b/app/panel/components/Dashboard/ExpChart/index.tsx new file mode 100644 index 0000000..da0a1d4 --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/index.tsx @@ -0,0 +1,47 @@ +import { HeatmapVisualization } from "./charts/HeatmapVisualization"; +import { useExpChart } from "./hooks/useExpChart"; +import { Card, CardContent, CardHeader, CardTitle, Tabs, TabsList, TabsTrigger } from "@app/components"; +import { memo } from "react"; + +const ExpChart = memo(() => { + const { userName, periodBreakdown, selectedPeriod, handlePeriodChange, showLoading } = useExpChart(); + + if (showLoading) { + return ( + + +
+
+ {userName} +
+
+
+ +
Loading chart data...
+
+
+ ); + } + + return ( + + + + + Hour + Day + Week + Month + + + + + + + + ); +}); + +ExpChart.displayName = "ExpChart"; + +export default ExpChart; diff --git a/app/panel/components/Dashboard/ExpChart/utils.ts b/app/panel/components/Dashboard/ExpChart/utils.ts new file mode 100644 index 0000000..4373b1d --- /dev/null +++ b/app/panel/components/Dashboard/ExpChart/utils.ts @@ -0,0 +1,121 @@ +import type { TimeFrame } from "@app/types"; + +/** + * Format date for chart display based on timeframe + */ +export const formatChartDate = (dateString: string, timeFrame?: TimeFrame): string => { + const date = new Date(dateString); + + if (!timeFrame) { + // Default fallback + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + } + + switch (timeFrame) { + case "6h": + case "12h": + // For short timeframes, show hour:minute + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + case "24h": + // For 24h, show hour + return date.toLocaleTimeString("en-US", { + hour: "numeric", + hour12: true, + }); + case "7d": + // For 7 days, show day of week and date + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + case "30d": + case "90d": + // For 30 days and 90 days, show month and day + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + default: + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + } +}; + +/** + * Format date for tooltip display + */ +export const formatTooltipDate = (dateString: string, timeFrame?: TimeFrame): string => { + const date = new Date(dateString); + + if (!timeFrame) { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + } + + switch (timeFrame) { + case "6h": + case "12h": + // For short timeframes, show full date and time + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + case "24h": + // For 24h, show date and time + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + case "7d": + case "30d": + case "90d": + // For longer periods, show full date + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + default: + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + } +}; + +/** + * Extract HSL values from color string + */ +export const extractHslValues = (color: string): string => { + const hslMatch = color.match(/hsl\(([^)]+)\)/); + return hslMatch ? hslMatch[1] : "221.2 83.2% 53.3%"; +}; diff --git a/app/panel/components/Dashboard/HourCard.tsx b/app/panel/components/Dashboard/HourCard.tsx new file mode 100644 index 0000000..44d54dc --- /dev/null +++ b/app/panel/components/Dashboard/HourCard.tsx @@ -0,0 +1,132 @@ +import { TrendDownIcon, TrendUpIcon } from "@app/assets/icons"; +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ItemImage } from "@app/components"; +import { memo, useMemo } from "react"; +import type { HourCardData } from "./useDashboard"; +import type { CSSProperties } from "react"; + +interface HourCardProps { + data: HourCardData; +} + +const formatGP = (value: number) => + value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + +const trendStyle = (isPositive: boolean): CSSProperties => { + const cssVar = isPositive ? "var(--primary)" : "var(--destructive)"; + return { + backgroundColor: `color-mix(in srgb, ${cssVar} 20%, transparent)`, + borderWidth: "1px", + borderColor: `color-mix(in srgb, ${cssVar} 30%, transparent)`, + color: cssVar, + }; +}; + +const HourCard = memo(({ data }: HourCardProps) => { + const hasStats = + data.totalDropValue > 0 || + data.netProfit !== 0 || + data.hpValue > 0 || + data.totalFights > 0 || + data.totalSkillingActions > 0; + + const profitStyle = useMemo(() => { + if (data.netProfit === 0) return undefined; + const cssVar = data.netProfit > 0 ? "var(--primary)" : "var(--destructive)"; + return { color: cssVar }; + }, [data.netProfit]); + + return ( + + +
+ {data.label} + {data.trend && ( +
+ {data.trend.isPositive ? ( + + ) : ( + + )} + {data.trend.isPositive ? "+" : ""} + {data.trend.value}% +
+ )} +
+ {data.timeRange} +
+ + {/* Skill badges */} + {data.skills.length > 0 && ( +
+ {data.skills.map(s => ( + + {s.skill} + {s.formattedExp} + {s.level !== null && Lv{s.level}} + + ))} +
+ )} + + {/* Loot items */} + {data.lootItems.length > 0 && ( +
+

Drops

+
+ {data.lootItems.map(item => ( + + ))} +
+
+ )} + + {/* Items produced from skilling */} + {data.producedItems.length > 0 && ( +
+

Items Produced

+
+ {data.producedItems.map(item => ( + + ))} +
+
+ )} + + {/* Summary footer */} + {hasStats && ( +
+ {data.totalDropValue > 0 && Total: {formatGP(data.totalDropValue)} GP} + {data.netProfit !== 0 && ( + + Net: {data.netProfit > 0 ? "+" : ""} + {formatGP(data.netProfit)} GP + + )} + {data.hpValue > 0 && Food: {formatGP(data.hpValue)} GP} + {data.totalFights > 0 && Fights: {data.totalFights.toLocaleString()}} + {data.totalSkillingActions > 0 && Actions: {data.totalSkillingActions.toLocaleString()}} +
+ )} +
+
+ ); +}); + +HourCard.displayName = "HourCard"; + +export { HourCard }; diff --git a/app/panel/components/Dashboard/index.tsx b/app/panel/components/Dashboard/index.tsx new file mode 100644 index 0000000..66bedfe --- /dev/null +++ b/app/panel/components/Dashboard/index.tsx @@ -0,0 +1,48 @@ +import ExpChart from "./ExpChart"; +import { HourCard } from "./HourCard"; +import { useDashboard } from "./useDashboard"; +import { cn, Card, CardContent } from "@app/components"; +import { memo } from "react"; + +/** + * Dashboard Component + * Pure JSX component - all logic is in useDashboard hook + */ +const Dashboard = memo(() => { + const { loading, hasAnyData, currentHourData, previousHourData } = useDashboard(); + + if (loading) { + return
Loading tracked data...
; + } + + if (!hasAnyData) { + return ( +
+ + +

No tracked data found.

+

+ Start playing to track your experience, drops, and stats. +

+
+
+
+ ); + } + + return ( +
+ {/* Hour Cards */} +
+ {currentHourData && } + {previousHourData && } +
+ {/* Exp Chart - Heatmap of exp/loot */} + +
+ ); +}); + +Dashboard.displayName = "Dashboard"; + +export default Dashboard; diff --git a/app/panel/components/Dashboard/useDashboard.ts b/app/panel/components/Dashboard/useDashboard.ts new file mode 100644 index 0000000..8f48b10 --- /dev/null +++ b/app/panel/components/Dashboard/useDashboard.ts @@ -0,0 +1,233 @@ +import { useTrackedDataQuery, useHourStats, useScreenData } from "@app/hooks"; +import { calculateExpForNextLevel, formatExp } from "@app/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { HourLootItem } from "@app/hooks/stats/useHourStats"; + +export interface SkillBadgeData { + skill: string; + exp: number; + formattedExp: string; + level: number | null; + isMainSkill: boolean; +} + +export interface HourCardData { + label: string; + timeRange: string; + skills: SkillBadgeData[]; + lootItems: HourLootItem[]; + totalDropValue: number; + hpValue: number; + netProfit: number; + totalFights: number; + totalSkillingActions: number; + producedItems: Array<{ name: string; imageUrl: string; quantity: number }>; + trend?: { value: number; isPositive: boolean }; +} + +/** + * Custom hook for Dashboard component + * Handles all business logic, state, and data processing + */ +export const useDashboard = () => { + const { loading, allData } = useTrackedDataQuery(); + const screenData = useScreenData(); + + // Get current hour - ensure it updates when hour changes + const [currentHour, setCurrentHour] = useState(() => new Date().getUTCHours()); + + useEffect(() => { + const updateHour = () => { + const hour = new Date().getUTCHours(); + setCurrentHour(prev => (prev === hour ? prev : hour)); + }; + + updateHour(); + const interval = setInterval(updateHour, 60000); + return () => clearInterval(interval); + }, []); + + const previousHour = useMemo(() => (currentHour === 0 ? 23 : currentHour - 1), [currentHour]); + + const currentHourStats = useHourStats(currentHour); + const previousHourStats = useHourStats(previousHour); + + const currentSkill = useMemo(() => screenData?.actionText.currentActionText || "", [screenData]); + const formatHourRange = useCallback((hour: number) => { + const now = new Date(); + const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, 0, 0, 0)); + const endDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour + 1, 0, 0, 0)); + + const startTime = startDate.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + const sameDay = startDate.toDateString() === endDate.toDateString(); + const endTime = endDate.toLocaleString( + "en-US", + sameDay + ? { + hour: "numeric", + minute: "2-digit", + hour12: true, + } + : { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }, + ); + + return `${startTime} - ${endTime}`; + }, []); + + const skillLevels = useMemo(() => { + const levels: Record = {}; + + if (!screenData) { + return levels; + } + + const { actionText } = screenData; + const mainSkillName = actionText.currentActionText?.trim(); + + if (mainSkillName) { + const parsedLevel = actionText.skillLevel ? parseInt(actionText.skillLevel, 10) : NaN; + levels[mainSkillName] = { + level: Number.isNaN(parsedLevel) ? null : parsedLevel, + }; + } + + (actionText.combatExp || []).forEach(gain => { + const skillName = gain.skill?.trim(); + if (!skillName) return; + + const parsedLevel = gain.skillLevel ? parseInt(gain.skillLevel, 10) : NaN; + levels[skillName] = { + level: Number.isNaN(parsedLevel) ? null : parsedLevel, + }; + }); + + return levels; + }, [screenData]); + + // Check if there's any data at all + const hasAnyData = useMemo(() => allData && allData.length > 0, [allData]); + + // Build consolidated current hour data + const currentHourData = useMemo(() => { + const expBySkill = currentHourStats.expBySkill || {}; + const entries = Object.entries(expBySkill); + const hasLoot = currentHourStats.lootItems.length > 0; + + if (entries.length === 0 && !hasLoot) { + return null; + } + + const trimmedCurrentSkill = currentSkill?.trim(); + + const skills: SkillBadgeData[] = entries.map(([skill, exp]) => { + const levelInfo = skillLevels[skill]; + return { + skill, + exp, + formattedExp: `+${formatExp(exp)}`, + level: levelInfo?.level ?? null, + isMainSkill: trimmedCurrentSkill ? skill === trimmedCurrentSkill : false, + }; + }); + + // If no skill matched currentSkill, mark the first one as main + if (skills.length > 0 && !skills.some(s => s.isMainSkill)) { + skills[0].isMainSkill = true; + } + + // Calculate trend for main skill + const mainSkillData = skills.find(s => s.isMainSkill); + let trend: HourCardData["trend"]; + if (mainSkillData && mainSkillData.level !== null && mainSkillData.exp > 0) { + const expForNext = calculateExpForNextLevel(mainSkillData.level); + if (expForNext > 0) { + trend = { + value: Number.parseFloat(((mainSkillData.exp / expForNext) * 100).toFixed(2)), + isPositive: true, + }; + } + } + + return { + label: "Current Hour", + timeRange: formatHourRange(currentHour), + skills, + lootItems: currentHourStats.lootItems, + totalDropValue: currentHourStats.totalDropValue, + hpValue: currentHourStats.hpValue, + netProfit: currentHourStats.netProfit, + totalFights: currentHourStats.totalFights, + totalSkillingActions: currentHourStats.totalSkillingActions, + producedItems: Object.entries(currentHourStats.itemsProduced).map(([name, data]) => ({ + name, + imageUrl: `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, "%20")}.png`, + quantity: data.quantity, + })), + trend, + }; + }, [currentHourStats, currentSkill, skillLevels, currentHour, formatHourRange]); + + // Build consolidated previous hour data + const previousHourData = useMemo(() => { + const expBySkill = previousHourStats.expBySkill || {}; + const entries = Object.entries(expBySkill); + const hasLoot = previousHourStats.lootItems.length > 0; + + if (entries.length === 0 && !hasLoot) { + return null; + } + + const trimmedCurrentSkill = currentSkill?.trim(); + + const skills: SkillBadgeData[] = entries.map(([skill, exp]) => { + const levelInfo = skillLevels[skill]; + return { + skill, + exp, + formattedExp: `+${formatExp(exp)}`, + level: levelInfo?.level ?? null, + isMainSkill: trimmedCurrentSkill ? skill === trimmedCurrentSkill : false, + }; + }); + + if (skills.length > 0 && !skills.some(s => s.isMainSkill)) { + skills[0].isMainSkill = true; + } + + return { + label: "Previous Hour", + timeRange: formatHourRange(previousHour), + skills, + lootItems: previousHourStats.lootItems, + totalDropValue: previousHourStats.totalDropValue, + hpValue: previousHourStats.hpValue, + netProfit: previousHourStats.netProfit, + totalFights: previousHourStats.totalFights, + totalSkillingActions: previousHourStats.totalSkillingActions, + producedItems: Object.entries(previousHourStats.itemsProduced).map(([name, data]) => ({ + name, + imageUrl: `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, "%20")}.png`, + quantity: data.quantity, + })), + }; + }, [previousHourStats, currentSkill, skillLevels, previousHour, formatHourRange]); + + return { + loading, + hasAnyData, + currentHourData, + previousHourData, + }; +}; diff --git a/app/panel/components/DataView/index.tsx b/app/panel/components/DataView/index.tsx new file mode 100644 index 0000000..e14f4c1 --- /dev/null +++ b/app/panel/components/DataView/index.tsx @@ -0,0 +1,419 @@ +import { useDataView } from "./useDataView"; +import { DownloadIcon, RefreshIcon, TrashIcon } from "@app/assets/icons"; +import { + cn, + Card, + CardContent, + CardHeader, + CardTitle, + Badge, + IconButton, + Switch, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@app/components"; +import { useDataExport, useTrackedDataQuery } from "@app/hooks"; +import { memo, useCallback } from "react"; +import type { CSVRow } from "@app/types"; + +/** + * Format timestamp for display + */ +const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp); + return date.toLocaleString(); +}; + +/** + * Truncate long text for mobile display + */ +const truncateText = (text: string, maxLength: number = 30): string => { + if (!text || text.length <= maxLength) return text; + return `${text.substring(0, maxLength)}...`; +}; + +/** + * Parse JSON fields safely + */ +const parseJSONField = (field: string): unknown => { + if (!field || field.trim() === "") return null; + try { + return JSON.parse(field); + } catch { + return null; + } +}; + +/** + * Transform CSV row to properly parse JSON string fields + */ +const transformRowForDisplay = (row: CSVRow): Record => ({ + ...row, + equipment: parseJSONField(row.equipment), + combatExp: parseJSONField(row.combatExp), + actionOutput: parseJSONField(row.actionOutput), + // Parse other semicolon-separated fields into arrays for better display + drops: row.drops ? row.drops.split(";").filter(d => d.trim()) : [], + damageDealt: row.damageDealt ? row.damageDealt.split(";").filter(d => d.trim()) : [], + damageReceived: row.damageReceived ? row.damageReceived.split(";").filter(d => d.trim()) : [], +}); + +/** + * Table View Component + */ +const TableView = memo(({ rows }: { rows: CSVRow[] }) => { + if (rows.length === 0) { + return
No data to display
; + } + + return ( +
+
), ); -TableHead.displayName = 'TableHead'; +TableHead.displayName = "TableHead"; const TableCell = React.forwardRef>( ({ className, ...props }, ref) => ( - + ), ); -TableCell.displayName = 'TableCell'; +TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef>( ({ className, ...props }, ref) => ( -
+ ), ); -TableCaption.displayName = 'TableCaption'; +TableCaption.displayName = "TableCaption"; export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/packages/ui/lib/components/ui/tabs.tsx b/app/components/ui/tabs.tsx similarity index 67% rename from packages/ui/lib/components/ui/tabs.tsx rename to app/components/ui/tabs.tsx index 1822302..07cd2a5 100644 --- a/packages/ui/lib/components/ui/tabs.tsx +++ b/app/components/ui/tabs.tsx @@ -1,5 +1,5 @@ -import { cn } from '../../utils'; -import * as React from 'react'; +import { cn } from "@app/utils/cn"; +import * as React from "react"; interface TabsContextValue { value: string; @@ -11,7 +11,7 @@ const TabsContext = React.createContext(undefined) const useTabsContext = () => { const context = React.useContext(TabsContext); if (!context) { - throw new Error('Tabs components must be used within a Tabs component'); + throw new Error("Tabs components must be used within a Tabs component"); } return context; }; @@ -38,26 +38,26 @@ const Tabs = React.forwardRef( return ( -
+
); }, ); -Tabs.displayName = 'Tabs'; +Tabs.displayName = "Tabs"; const TabsList = React.forwardRef>( ({ className, ...props }, ref) => (
), ); -TabsList.displayName = 'TabsList'; +TabsList.displayName = "TabsList"; interface TabsTriggerProps extends React.ButtonHTMLAttributes { value: string; @@ -75,17 +75,17 @@ const TabsTrigger = React.forwardRef(({ cla aria-selected={isSelected} onClick={() => onValueChange(value)} className={cn( - 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", isSelected - ? 'bg-card text-card-foreground hover:bg-card/80 border-2 border-slate-400 shadow-sm dark:border-slate-500' - : 'bg-secondary/50 text-muted-foreground border-border/50 hover:bg-secondary hover:text-foreground border hover:border-slate-400 dark:hover:border-slate-500', + ? "hover:bg-card/80 border-2 border-slate-400 bg-card text-card-foreground shadow-sm dark:border-slate-500" + : "border-border/50 bg-secondary/50 border text-muted-foreground hover:border-slate-400 hover:bg-secondary hover:text-foreground dark:hover:border-slate-500", className, )} {...props} /> ); }); -TabsTrigger.displayName = 'TabsTrigger'; +TabsTrigger.displayName = "TabsTrigger"; const TabsContent = React.forwardRef & { value: string }>( ({ className, value, ...props }, ref) => { @@ -99,7 +99,7 @@ const TabsContent = React.forwardRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/app/constants/index.ts b/app/constants/index.ts new file mode 100644 index 0000000..464e893 --- /dev/null +++ b/app/constants/index.ts @@ -0,0 +1,44 @@ +import type { TimeFrameOption } from "@app/types"; + +export const SKILLS_ARRAY = [ + "Attack", + "Defence", + "Strength", + "Health", + "Mining", + "Smithing", + "Fishing", + "Woodcutting", + "Construction", + "Trading", + "Thieving", + "Speed", + "Cooking", + "Magic", + "Farming", +]; + +export const UPDATE_SCREEN_DATA = "update_screen_data" as const; +export const REQUEST_SCREEN_DATA = "request_screen_data" as const; +export const UPDATE_USER_STATS = "update_user_stats" as const; + +export const TIME_FRAME_OPTIONS: TimeFrameOption[] = [ + { value: "6h", label: "Last 6 Hours", hours: 6 }, + { value: "12h", label: "Last 12 Hours", hours: 12 }, + { value: "24h", label: "Last 24 Hours", hours: 24 }, + { value: "7d", label: "Last 7 Days", hours: 168 }, + { value: "30d", label: "Last 30 Days", hours: 720 }, + { value: "90d", label: "Last 3 Months", hours: 2160 }, +]; + +// Teal/cyan color palette matching screenshot +export const SKILL_COLORS = [ + "hsl(180 77.11% 60.59%)", // Primary teal/cyan + "hsl(180 70% 55%)", // Slightly darker teal + "hsl(180 75% 65%)", // Slightly lighter teal + "hsl(180 65% 50%)", // Darker teal + "hsl(180 80% 70%)", // Lighter teal + "hsl(180 72% 58%)", // Medium teal + "hsl(180 68% 52%)", // Dark teal + "hsl(180 78% 68%)", // Light teal +]; diff --git a/app/content/matches/all/index.ts b/app/content/matches/all/index.ts new file mode 100644 index 0000000..921d75c --- /dev/null +++ b/app/content/matches/all/index.ts @@ -0,0 +1,3 @@ +import { sendData } from "../../sendData"; + +void sendData(); diff --git a/app/content/matches/all/scrapeScreenData/index.ts b/app/content/matches/all/scrapeScreenData/index.ts new file mode 100644 index 0000000..5cabdbd --- /dev/null +++ b/app/content/matches/all/scrapeScreenData/index.ts @@ -0,0 +1,3 @@ +import { scrapeScreenData } from "../../../scrapeScreenData"; + +void scrapeScreenData(); diff --git a/app/content/matches/all/sendData/index.ts b/app/content/matches/all/sendData/index.ts new file mode 100644 index 0000000..e67c200 --- /dev/null +++ b/app/content/matches/all/sendData/index.ts @@ -0,0 +1,3 @@ +import { sendData } from "../../../sendData"; + +void sendData(); diff --git a/app/content/matches/stats/index.ts b/app/content/matches/stats/index.ts new file mode 100644 index 0000000..5c204dd --- /dev/null +++ b/app/content/matches/stats/index.ts @@ -0,0 +1,97 @@ +import { scrapeUserStats } from "../../scrapeUserStats"; +import { UPDATE_USER_STATS } from "@app/constants"; + +/** + * Stats page scraper - runs in isolated content script context + * Invisible to website JavaScript + */ +(() => { + "use strict"; + + // Verify we're on the correct URL before doing anything + const isStatsPage = () => { + const url = window.location.href; + return url.includes("syrnia.com/theGame/includes2/stats.php"); + }; + + // Track if we've already scraped in this session to avoid duplicate sends + let hasScraped = false; + let lastScrapeTime = 0; + const SCRAPE_COOLDOWN = 5000; // Minimum 5 seconds between scrapes to avoid spam + + // Wait for page to load, then scrape and send user stats + const scrapeAndSendUserStats = (force = false) => { + // Double-check we're on the stats page + if (!isStatsPage()) { + hasScraped = false; // Reset when navigating away + return; + } + + const now = Date.now(); + // Allow re-scraping if forced, or if enough time has passed, or if we haven't scraped yet + if (!force && hasScraped && now - lastScrapeTime < SCRAPE_COOLDOWN) { + return; + } + + // Wait a bit for the page to fully render + setTimeout(() => { + // Triple-check we're still on the stats page + if (!isStatsPage()) { + hasScraped = false; + return; + } + + const userStats = scrapeUserStats(); + if (userStats && userStats.username && Object.keys(userStats.skills).length > 0) { + hasScraped = true; + lastScrapeTime = Date.now(); + + chrome.runtime.sendMessage({ type: UPDATE_USER_STATS, data: userStats }); + } + }, 2000); // Wait 2 seconds for page to render + }; + + // Only set up scraping if we're on the stats page + if (isStatsPage()) { + // Run when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => scrapeAndSendUserStats()); + } else { + scrapeAndSendUserStats(); + } + + // Also set up a MutationObserver to catch dynamic content and page refreshes + const observer = new MutationObserver(mutations => { + // Check we're still on stats page + if (!isStatsPage()) { + hasScraped = false; // Reset when navigating away + observer.disconnect(); + return; + } + + // Re-scrape if significant content was added (page refresh or update) + if (mutations.some(mutation => mutation.addedNodes.length > 0)) { + const now = Date.now(); + // Only scrape if enough time has passed since last scrape + if (!hasScraped || now - lastScrapeTime >= SCRAPE_COOLDOWN) { + scrapeAndSendUserStats(true); + } + } + }); + + // Listen for page visibility changes (handles refresh/navigation) + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible" && isStatsPage()) { + // Reset scrape flag when page becomes visible to allow re-scraping on refresh + hasScraped = false; + // Scrape after a short delay to ensure page is loaded + setTimeout(() => scrapeAndSendUserStats(), 1000); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } +})(); diff --git a/pages/content/src/scrapeScreenData.ts b/app/content/scrapeScreenData.ts similarity index 60% rename from pages/content/src/scrapeScreenData.ts rename to app/content/scrapeScreenData.ts index f3649d6..c44c755 100644 --- a/pages/content/src/scrapeScreenData.ts +++ b/app/content/scrapeScreenData.ts @@ -1,11 +1,12 @@ -import { matchText, skillExpRegex } from '@extension/shared'; -import type { ScreenData, CombatExpGain, EquipmentData, EquipmentItem } from '@extension/shared'; +import { matchText, skillExpRegex } from "@app/utils/helpers"; +import { v4 as uuidv4 } from "uuid"; +import type { ScreenData, CombatExpGain, EquipmentData, EquipmentItem } from "@app/types"; // Track last processed fight to prevent duplicate wearDisplayTD collection let lastProcessedFightText: string | null = null; // Cache location - only parse once at fight end -let cachedLocation: string = ''; +let cachedLocation: string = ""; /** * Parse combat experience gains from fightLogTop element @@ -18,35 +19,35 @@ const parseCombatExp = (fightLogElement: HTMLElement | null): CombatExpGain[] => } const expGains: CombatExpGain[] = []; - const fullText = fightLogElement.textContent || ''; + const fullText = fightLogElement.textContent || ""; // Find all font elements with color attribute - red for experience, yellow for drops - const fontElements = fightLogElement.querySelectorAll('font[color]'); + const fontElements = fightLogElement.querySelectorAll("font[color]"); // Only check red font elements for experience (drops are in yellow) const redFontElements = Array.from(fontElements).filter(font => { - const color = font.getAttribute('color')?.toLowerCase(); - return color === 'red'; + const color = font.getAttribute("color")?.toLowerCase(); + return color === "red"; }); // Skill name normalization map const skillMap: Record = { - strength: 'Strength', - attack: 'Attack', - defence: 'Defence', - defense: 'Defence', - health: 'Health', - mining: 'Mining', - smithing: 'Smithing', - fishing: 'Fishing', - woodcutting: 'Woodcutting', - construction: 'Construction', - trading: 'Trading', - thieving: 'Thieving', - speed: 'Speed', - cooking: 'Cooking', - magic: 'Magic', - farming: 'Farming', + strength: "Strength", + attack: "Attack", + defence: "Defence", + defense: "Defence", + health: "Health", + mining: "Mining", + smithing: "Smithing", + fishing: "Fishing", + woodcutting: "Woodcutting", + construction: "Construction", + trading: "Trading", + thieving: "Thieving", + speed: "Speed", + cooking: "Cooking", + magic: "Magic", + farming: "Farming", }; // Only process red font elements (experience values) @@ -64,21 +65,21 @@ const parseCombatExp = (fightLogElement: HTMLElement | null): CombatExpGain[] => // Pattern: " [number]" " [skill] experience" or [number] [skill] experience const patterns = [ // Match: " [number]" " [skill] experience" (with quotes around number) - new RegExp(`"${expValue}"\\s+"?([a-z]+)\\s+experience`, 'i'), + new RegExp(`"${expValue}"\\s+"?([a-z]+)\\s+experience`, "i"), // Match: [number] " [skill] experience" (number followed by quote and skill) - new RegExp(`${expValue}"\\s+"?([a-z]+)\\s+experience`, 'i'), + new RegExp(`${expValue}"\\s+"?([a-z]+)\\s+experience`, "i"), // Match: [number] [skill] experience (without quotes, directly after font) - new RegExp(`${expValue}\\s+"?([a-z]+)\\s+experience`, 'i'), + new RegExp(`${expValue}\\s+"?([a-z]+)\\s+experience`, "i"), // Match: got [number] [skill] experience - new RegExp(`got\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, 'i'), + new RegExp(`got\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, "i"), // Match: You got [number] [skill] experience - new RegExp(`You\\s+got\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, 'i'), + new RegExp(`You\\s+got\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, "i"), // Match: also gained [number] [skill] experience (bonus exp from armor) - new RegExp(`also\\s+gained\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, 'i'), + new RegExp(`also\\s+gained\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, "i"), // Match: You also gained [number] [skill] experience (bonus exp from armor) - new RegExp(`You\\s+also\\s+gained\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, 'i'), + new RegExp(`You\\s+also\\s+gained\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience`, "i"), // Match: gained [number] [skill] experience from (bonus exp from armor) - new RegExp(`gained\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience\\s+from`, 'i'), + new RegExp(`gained\\s+"?${expValue}"?\\s+([a-z]+)\\s+experience\\s+from`, "i"), ]; for (let i = 0; i < patterns.length; i++) { @@ -129,9 +130,9 @@ const parseCombatExp = (fightLogElement: HTMLElement | null): CombatExpGain[] => * "The Rima General struck at you and did 3 damage" */ const parseMonster = (locationElement: HTMLElement | null, fightLogElement: HTMLElement | null): string => { - // Try fightLogTop first - format: "You defeated {monster}." + // Try fightLogTop first - format: "You defeated {monster}." or "The {monster} died. You got" if (fightLogElement) { - const fightText = fightLogElement.textContent || ''; + const fightText = fightLogElement.textContent || ""; // Primary pattern: "You defeated {monster}." const defeatedPattern = /you\s+defeated\s+(.+?)(?:\.|$)/i; @@ -139,14 +140,30 @@ const parseMonster = (locationElement: HTMLElement | null, fightLogElement: HTML if (defeatedMatch && defeatedMatch[1]) { let monster = defeatedMatch[1].trim(); // Remove "the " prefix if present - monster = monster.replace(/^the\s+/i, ''); + monster = monster.replace(/^the\s+/i, ""); // Filter out common false positives if ( monster.length > 2 && - !monster.toLowerCase().includes('level') && - !monster.toLowerCase().includes('exp') && - !monster.toLowerCase().includes('damage') && - !monster.toLowerCase().includes('you') + !monster.toLowerCase().includes("level") && + !monster.toLowerCase().includes("exp") && + !monster.toLowerCase().includes("damage") && + !monster.toLowerCase().includes("you") + ) { + return monster; + } + } + + // Secondary pattern: "The {monster} died. You got" + const diedPattern = /the\s+(.+?)\s+died\.\s+you\s+got/i; + const diedMatch = fightText.match(diedPattern); + if (diedMatch && diedMatch[1]) { + const monster = diedMatch[1].trim(); + // Filter out common false positives + if ( + monster.length > 2 && + !monster.toLowerCase().includes("level") && + !monster.toLowerCase().includes("exp") && + !monster.toLowerCase().includes("damage") ) { return monster; } @@ -174,14 +191,14 @@ const parseMonster = (locationElement: HTMLElement | null, fightLogElement: HTML if (match && match[1]) { let monster = match[1].trim(); // Remove "the " prefix if present - monster = monster.replace(/^the\s+/i, ''); + monster = monster.replace(/^the\s+/i, ""); // Filter out common false positives if ( monster.length > 2 && - !monster.toLowerCase().includes('level') && - !monster.toLowerCase().includes('exp') && - !monster.toLowerCase().includes('damage') && - !monster.toLowerCase().includes('you') + !monster.toLowerCase().includes("level") && + !monster.toLowerCase().includes("exp") && + !monster.toLowerCase().includes("damage") && + !monster.toLowerCase().includes("you") ) { return monster; } @@ -192,7 +209,7 @@ const parseMonster = (locationElement: HTMLElement | null, fightLogElement: HTML // Try to find monster name in LocationContent as fallback // Format: "You are attacking a {monster} {level} at {location}." if (locationElement) { - const locationText = locationElement.textContent || ''; + const locationText = locationElement.textContent || ""; // Pattern: "You are attacking a {monster} {level} at {location}." // Extract monster (before the level number and "at") @@ -201,14 +218,14 @@ const parseMonster = (locationElement: HTMLElement | null, fightLogElement: HTML if (attackingMatch && attackingMatch[1]) { let monster = attackingMatch[1].trim(); // Remove "the " prefix if present (shouldn't be needed here, but just in case) - monster = monster.replace(/^the\s+/i, ''); + monster = monster.replace(/^the\s+/i, ""); if (monster.length > 2) { return monster; } } } - return ''; + return ""; }; /** @@ -217,13 +234,13 @@ const parseMonster = (locationElement: HTMLElement | null, fightLogElement: HTML */ const parseLocation = (locationElement: HTMLElement | null): string => { if (!locationElement) { - return ''; + return ""; } // Try to find location in LocationContent // Format: "You are attacking a {monster} {level} at {location}." // Example: "You are attacking a Rima General (80) at Rima city - barracks." - const locationText = locationElement.textContent || ''; + const locationText = locationElement.textContent || ""; // Primary pattern: "...at {location}." - capture everything after "at" up to the period // This should match: "at Rima city - barracks." -> "Rima city - barracks" @@ -233,17 +250,17 @@ const parseLocation = (locationElement: HTMLElement | null): string => { let location = atMatch[1].trim(); // Clean up any trailing whitespace or punctuation - location = location.replace(/[.,;:]+$/, '').trim(); + location = location.replace(/[.,;:]+$/, "").trim(); // Filter out common false positives if ( location.length > 2 && - !location.toLowerCase().includes('level') && - !location.toLowerCase().includes('exp') && - !location.toLowerCase().includes('hp') && - !location.toLowerCase().includes('fighting') && - !location.toLowerCase().includes('attacking') && - !location.toLowerCase().includes('you are') && + !location.toLowerCase().includes("level") && + !location.toLowerCase().includes("exp") && + !location.toLowerCase().includes("hp") && + !location.toLowerCase().includes("fighting") && + !location.toLowerCase().includes("attacking") && + !location.toLowerCase().includes("you are") && !location.toLowerCase().includes("you're") ) { return location; @@ -264,11 +281,11 @@ const parseLocation = (locationElement: HTMLElement | null): string => { // Filter out common false positives if ( location.length > 2 && - !location.toLowerCase().includes('level') && - !location.toLowerCase().includes('exp') && - !location.toLowerCase().includes('hp') && - !location.toLowerCase().includes('fighting') && - !location.toLowerCase().includes('attacking') + !location.toLowerCase().includes("level") && + !location.toLowerCase().includes("exp") && + !location.toLowerCase().includes("hp") && + !location.toLowerCase().includes("fighting") && + !location.toLowerCase().includes("attacking") ) { return location; } @@ -276,15 +293,15 @@ const parseLocation = (locationElement: HTMLElement | null): string => { } // Try to find location in page heading or title - const heading = document.querySelector('h1, h2, .location, #location') as HTMLElement | null; + const heading = document.querySelector("h1, h2, .location, #location") as HTMLElement | null; if (heading) { - const headingText = heading.textContent || ''; + const headingText = heading.textContent || ""; if (headingText.trim().length > 0 && headingText.length < 100) { return headingText.trim(); } } - return ''; + return ""; }; /** @@ -297,7 +314,7 @@ const parsePeopleFighting = ( ): number | null => { // Check LocationContent first if (locationElement) { - const locationText = locationElement.textContent || ''; + 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); @@ -309,7 +326,7 @@ const parsePeopleFighting = ( // Check fight log element if (fightLogElement) { - const fightText = fightLogElement.textContent || ''; + 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); @@ -327,9 +344,9 @@ const parsePeopleFighting = ( * Collects text from all
elements in subsequent
elements in this row const centerCells = currentSibling.querySelectorAll('td[align="center"]'); centerCells.forEach(cell => { - const text = cell.textContent?.trim() || ''; + const text = cell.textContent?.trim() || ""; if (text) { allFightText.push(text); } @@ -350,7 +367,7 @@ const getAllFightText = (): string => { currentSibling = currentSibling.nextElementSibling; } - return allFightText.join(' '); + return allFightText.join(" "); }; /** @@ -388,7 +405,7 @@ const parseDamage = (): { dealt: string[]; received: string[] } => { const missMatches = [...fightText.matchAll(missPattern)]; missMatches.forEach(() => { - damageDealt.push('0'); + damageDealt.push("0"); }); // Pattern for damage received: "The [Monster] struck at you and did X damage" @@ -405,42 +422,6 @@ const parseDamage = (): { dealt: string[]; received: string[] } => { return { dealt: damageDealt, received: damageReceived }; }; -/** - * Parse HP gained from fight log - * Looks for lines like "{username} at X and gained X HP" - * Returns the total HP gained amount - */ -const parseHpGained = (): number | null => { - const fightText = getAllFightText(); - - if (!fightText) { - return null; - } - - // Pattern: "{username} at X and gained X HP" - // Example: "PlayerName at Rima General and gained 5 HP" - // We need to match the HP amount after "gained" - const hpGainedPattern = /gained\s+(\d+)\s+HP/gi; - const matches = [...fightText.matchAll(hpGainedPattern)]; - - if (matches.length === 0) { - return null; - } - - // Sum all HP gained amounts - let totalHpGained = 0; - matches.forEach(match => { - if (match && match[1]) { - const hpAmount = parseInt(match[1], 10); - if (!isNaN(hpAmount)) { - totalHpGained += hpAmount; - } - } - }); - - return totalHpGained > 0 ? totalHpGained : null; -}; - /** * Parse drops from fightLogTop element * Drops are typically in yellow font elements @@ -451,10 +432,10 @@ const parseDrops = (fightLogElement: HTMLElement | null): string[] => { const drops: string[] = []; // Find all yellow font elements (drops) - const fontElements = fightLogElement.querySelectorAll('font[color]'); + const fontElements = fightLogElement.querySelectorAll("font[color]"); const yellowFontElements = Array.from(fontElements).filter(font => { - const color = font.getAttribute('color')?.toLowerCase(); - return color === 'yellow'; + const color = font.getAttribute("color")?.toLowerCase(); + return color === "yellow"; }); // Extract drop text from yellow font elements @@ -466,14 +447,14 @@ const parseDrops = (fightLogElement: HTMLElement | null): string[] => { // This allows us to track both the count of drops and their total amounts const cleanDrop = dropText.trim(); - if (cleanDrop && !cleanDrop.toLowerCase().includes('experience')) { + if (cleanDrop && !cleanDrop.toLowerCase().includes("experience")) { drops.push(cleanDrop); } } }); // Also check text patterns for drops (fallback) - const text = fightLogElement.textContent || ''; + const text = fightLogElement.textContent || ""; const dropPatterns = [/(?:got|received|obtained)\s+([^.!?]+?)(?:\.|$)/gi]; dropPatterns.forEach(pattern => { @@ -481,7 +462,7 @@ const parseDrops = (fightLogElement: HTMLElement | null): string[] => { for (const match of matches) { const drop = match[1]?.trim(); // Filter out experience-related text and pure numbers - if (drop && !drop.toLowerCase().includes('experience') && !drop.match(/^\d+$/)) { + if (drop && !drop.toLowerCase().includes("experience") && !drop.match(/^\d+$/)) { // Check if it's not already in our drops list if (!drops.some(existing => existing.toLowerCase() === drop.toLowerCase())) { drops.push(drop); @@ -498,7 +479,7 @@ const parseDrops = (fightLogElement: HTMLElement | null): string[] => { * Extracts armor pieces, weapon, stats, enchants, and image URLs */ const parseEquipment = (): EquipmentData | undefined => { - const wearDisplayTD = document.querySelector('#wearDisplayTD') as HTMLElement | null; + const wearDisplayTD = document.querySelector("#wearDisplayTD") as HTMLElement | null; if (!wearDisplayTD) { return undefined; } @@ -509,15 +490,15 @@ const parseEquipment = (): EquipmentData | undefined => { // Slot mapping: id -> slot name const slotMap: Record = { - displayHelm: 'helm', - displayShield: 'shield', - displayBody: 'body', - displayHand: 'weapon', - displayLegs: 'legs', - displayGloves: 'gloves', - displayShoes: 'boots', - displayHorse: 'horse', - displayTrophy: 'trophy', + displayHelm: "helm", + displayShield: "shield", + displayBody: "body", + displayHand: "weapon", + displayLegs: "legs", + displayGloves: "gloves", + displayShoes: "boots", + displayHorse: "horse", + displayTrophy: "trophy", }; // Parse each equipment slot @@ -527,18 +508,18 @@ const parseEquipment = (): EquipmentData | undefined => { // Extract image URL from style attribute let imageUrl: string | undefined; - const style = element.getAttribute('style') || ''; + const style = element.getAttribute("style") || ""; const urlMatch = style.match(/url\(["']?([^"')]+)["']?\)/); if (urlMatch && urlMatch[1]) { imageUrl = urlMatch[1]; } // Extract title (contains name and enchant/stats) - const title = element.getAttribute('title') || ''; + const title = element.getAttribute("title") || ""; // Extract text content (stats numbers like "40", "55", "167/160") // textContent property automatically excludes HTML tags, giving us just the text - const textContent = element.textContent?.trim() || ''; + const textContent = element.textContent?.trim() || ""; // Parse title format: "Dragon helm [4 Aim]" or "Novariet scimitar [0 Durability]" // Title format: "Item Name [Enchant/Stats]" @@ -572,7 +553,7 @@ const parseEquipment = (): EquipmentData | undefined => { stats = statsMatch[1].trim(); } else { // Fallback: extract all numbers and slashes - const cleanStats = textContent.replace(/[^\d/]/g, '').trim(); + const cleanStats = textContent.replace(/[^\d/]/g, "").trim(); if (cleanStats) { stats = cleanStats; } @@ -594,7 +575,7 @@ const parseEquipment = (): EquipmentData | undefined => { // Calculate totals // Try to find totals in specific elements - const bodyText = document.body.textContent || ''; + 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); @@ -625,7 +606,7 @@ const parseEquipment = (): EquipmentData | undefined => { let totalTravelTime = 0; Object.values(equipment).forEach(item => { - if (item && typeof item === 'object' && 'enchant' in item && item.enchant) { + 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); @@ -648,35 +629,130 @@ const parseEquipment = (): EquipmentData | undefined => { return equipment; }; +/** + * Parse action output from skilling activities + * Extracts items produced from centerContent/LocationContent + * Phase 1: Mining, Fishing, Woodcutting, Cooking + */ +const parseActionOutput = ( + locationElement: HTMLElement | null, + fightLogElement: HTMLElement | null, +): Array<{ item: string; quantity: number }> => { + const items: Array<{ item: string; quantity: number }> = []; + + // Check centerContent and LocationContent for skilling output text + const elements: (HTMLElement | null)[] = [ + document.querySelector("#centerContent") as HTMLElement | null, + locationElement, + ]; + + for (const element of elements) { + if (!element) continue; + // Use innerText for proper line break handling (
→ \n), so regex stops at line boundaries + const text = element.innerText || ""; + + let matched = false; + + // Pattern 1: "[count] [item], you have" (Cooking, crafting — running total format) + // Example: "220 Cooked Salmon, you have 98 Salmon left. and 98 Wood" + // Must check BEFORE "You have" pattern to avoid matching the "you have 98 Salmon left" part + const countFirstMatch = text.match(/^(\d+) (.+?), you have/im); + if (countFirstMatch && countFirstMatch[1] && countFirstMatch[2]) { + const quantity = parseInt(countFirstMatch[1], 10); + const item = countFirstMatch[2].trim(); + if (!isNaN(quantity) && quantity > 0 && item.length > 0) { + // Avoid duplicates (LocationContent is nested inside centerContent) + if (!items.some(i => i.item === item)) { + items.push({ item, quantity }); + } + matched = true; + } + } + + // Pattern 2: "You have X [item]" (Mining, Fishing, Woodcutting — simple format) + // Only if Pattern 1 didn't match, to avoid matching "you have 98 Salmon left" in cooking text + if (!matched) { + const haveMatch = text.match(/You have (\d+) ([^\n,.]+)/i); + if (haveMatch && haveMatch[1] && haveMatch[2]) { + const quantity = parseInt(haveMatch[1], 10); + const item = haveMatch[2].trim().replace(/[.!]+$/, ""); + if (!isNaN(quantity) && quantity > 0 && item.length > 0) { + // Avoid duplicates (LocationContent is nested inside centerContent) + if (!items.some(i => i.item === item)) { + items.push({ item, quantity }); + } + } + } + } + + // Rare finds: "You found X [item]!" (any skill) + const foundPattern = /You found (\d+) (.+?)!/gi; + let foundMatch; + while ((foundMatch = foundPattern.exec(text)) !== null) { + if (foundMatch[1] && foundMatch[2]) { + const quantity = parseInt(foundMatch[1], 10); + const item = foundMatch[2].trim(); + if (!isNaN(quantity) && quantity > 0 && item.length > 0) { + // Avoid duplicates + if (!items.some(i => i.item === item)) { + items.push({ item, quantity }); + } + } + } + } + } + + // Bonus items from fightLogElement (Fishing: "You also caught X [item]") + if (fightLogElement) { + // Use innerText for proper line break handling + const fightText = fightLogElement.innerText || ""; + const caughtPattern = /You also caught (\d+) ([^\n.]+)/gi; + let caughtMatch; + while ((caughtMatch = caughtPattern.exec(fightText)) !== null) { + if (caughtMatch[1] && caughtMatch[2]) { + const quantity = parseInt(caughtMatch[1], 10); + const item = caughtMatch[2].trim().replace(/[.!]+$/, ""); + if (!isNaN(quantity) && quantity > 0 && item.length > 0) { + if (!items.some(i => i.item === item)) { + items.push({ item, quantity }); + } + } + } + } + } + + return items; +}; + export const scrapeScreenData = (): ScreenData => { const textContent = { - currentActionText: '', - exp: '', - speedText: '', - addExp: '', - skillLevel: '', - expForNextLevel: '', + currentActionText: "", + exp: "", + speedText: "", + addExp: "", + skillLevel: "", + expForNextLevel: "", inventory: { - hp: '', - farmingExp: '', + hp: "", + farmingExp: "", }, combatExp: [] as CombatExpGain[], drops: [] as string[], }; // const currentActionTextNode = document.body.querySelector('#centerContent')?.textContent; - const combatActionTextNode = document.body.querySelector('#LocationContent')?.textContent; + const combatActionTextNode = document.body.querySelector("#LocationContent")?.textContent; // const speedTextNode = document.body.querySelector('#SpeedDisplayTD')?.textContent; // Find fightLogTop - it's an empty
+ + + Timestamp + Skill + Level + Gained Exp + Combat Exp + Drops + Monster + Location + HP + HP Used + Equipment Totals + Helm + Shield + Weapon + Body + Legs + Gloves + Boots + Horse + Trophy + Type + Action Output + + + + {rows.map((row, index) => { + const equipment = parseJSONField(row.equipment) as Record> | null; + const combatExp = parseJSONField(row.combatExp) as Array<{ skill: string; exp: string }> | null; + + // Format equipment slot display + const formatEquipmentSlot = (slot: Record | null | undefined) => { + if (!slot) return "-"; + return (slot.name as string) || "-"; + }; + + // Format equipment totals + const formatEquipmentTotals = (eq: Record> | null | undefined) => { + if (!eq || !eq.totals) return "-"; + const totals = eq.totals; + const parts = []; + if (totals.aim !== undefined) parts.push(`Aim: ${totals.aim}`); + if (totals.power !== undefined) parts.push(`Power: ${totals.power}`); + if (totals.armour !== undefined) parts.push(`Armour: ${totals.armour}`); + return parts.length > 0 ? parts.join(", ") : "-"; + }; + + return ( + + {formatTimestamp(row.timestamp)} + {row.skill || "-"} + {row.skillLevel || "-"} + + {row.gainedExp ? parseInt(row.gainedExp).toLocaleString() : "0"} + + + {combatExp && Array.isArray(combatExp) && combatExp.length > 0 ? ( + + {combatExp.map(exp => `${exp.skill}: +${exp.exp}`).join(", ")} + + ) : ( + "-" + )} + + + {row.drops ? ( + + {truncateText(row.drops.replace(/;/g, ", "), 40)} + + ) : ( + "-" + )} + + {row.monster || "-"} + {row.location || "-"} + {row.totalInventoryHP || row.hp || "-"} + {row.hpUsed ? parseInt(row.hpUsed).toLocaleString() : "-"} + {formatEquipmentTotals(equipment)} + {formatEquipmentSlot(equipment?.helm)} + {formatEquipmentSlot(equipment?.shield)} + {formatEquipmentSlot(equipment?.weapon)} + {formatEquipmentSlot(equipment?.body)} + {formatEquipmentSlot(equipment?.legs)} + {formatEquipmentSlot(equipment?.gloves)} + {formatEquipmentSlot(equipment?.boots)} + {formatEquipmentSlot(equipment?.horse)} + {formatEquipmentSlot(equipment?.trophy)} + {row.actionType || ""} + + {row.actionOutput && row.actionOutput !== "[]" + ? (() => { + try { + const items: Array<{ item: string; quantity: number }> = JSON.parse(row.actionOutput); + return items.map(i => `${i.quantity} ${i.item}`).join(", "); + } catch { + return ""; + } + })() + : ""} + + + ); + })} + +
+
+ ); +}); + +TableView.displayName = "TableView"; + +/** + * Prettify and syntax highlight JSON string + */ +const prettifyJSON = (data: unknown): string => + // Use JSON.stringify with proper spacing for prettification + JSON.stringify(data, null, 2); +/** + * Syntax highlight JSON string with proper color coding + */ +const syntaxHighlight = (json: string): string => { + // Escape HTML entities + json = json.replace(/&/g, "&").replace(//g, ">"); + + // Apply syntax highlighting + return json.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, + match => { + let cls = "text-orange-400"; // number + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = "text-blue-400 font-semibold"; // key + } else { + cls = "text-green-400"; // string + } + } else if (/true|false/.test(match)) { + cls = "text-purple-400"; // boolean + } else if (/null/.test(match)) { + cls = "text-red-400"; // null + } + return `${match}`; + }, + ); +}; + +/** + * JSON View Component with syntax highlighting and prettification + */ +const JSONView = memo(({ rows }: { rows: CSVRow[] }) => { + if (rows.length === 0) { + return
No data to display
; + } + + // Transform rows to parse JSON string fields + const transformedRows = rows.map(transformRowForDisplay); + + // Prettify the JSON data + const jsonString = prettifyJSON(transformedRows); + const highlighted = syntaxHighlight(jsonString); + + return ( +
+
+ {/* Header */} +
+ JSON Data ({rows.length} records) + +
+ + {/* JSON Content - Left aligned with proper formatting */} +
+          
+        
+
+
+ ); +}); + +JSONView.displayName = "JSONView"; + +/** + * DataView Component + * Displays all tracked data in a table or JSON format with filters + */ +const DataView = memo(() => { + const { rows, filterType, setFilterType, viewMode, setViewMode, isLoading, error, totalCount, filteredCount } = + useDataView(); + const { refresh, clear } = useTrackedDataQuery(); + const { exportData, isExporting } = useDataExport(); + + const handleDownload = useCallback(async () => { + try { + await exportData("tracked", true); + alert("CSV file downloaded successfully!"); + } catch { + alert("Error downloading CSV file"); + } + }, [exportData]); + + const handleClear = useCallback(async () => { + if (confirm("Are you sure you want to clear all tracked data? This action cannot be undone.")) { + try { + await clear(); + alert("All tracked data cleared successfully!"); + } catch { + alert("Error clearing tracked data"); + } + } + }, [clear]); + + if (isLoading) { + return
Loading data...
; + } + + if (error) { + return ( +
+ Error loading data: {error instanceof Error ? error.message : "Unknown error"} +
+ ); + } + + return ( +
+ {/* Header Card with Filters and View Toggle */} + + +
+ Data View +
+ + + +
+
+
+ + {/* Stats */} +
+ + Total Records: {totalCount} + + + + Filtered: {filteredCount} + +
+ + {/* Filters */} +
+ +
+ setFilterType("all")} + isActive={filterType === "all"} + className="cursor-pointer"> + All Data + + setFilterType("loot")} + isActive={filterType === "loot"} + className="cursor-pointer"> + Loot Only + + setFilterType("exp")} + isActive={filterType === "exp"} + className="cursor-pointer"> + Exp Gains Only + + setFilterType("skilling")} + isActive={filterType === "skilling"} + className="cursor-pointer"> + Skilling + +
+
+ + {/* View Mode Toggle */} +
+
+ +

{viewMode === "table" ? "Table View" : "JSON View"}

+
+ setViewMode(checked ? "json" : "table")} + className="shrink-0" + /> +
+
+
+ + {/* Data Display Card */} + + + {viewMode === "table" ? : } + + +
+ ); +}); + +DataView.displayName = "DataView"; + +export default DataView; diff --git a/app/panel/components/DataView/useDataView.ts b/app/panel/components/DataView/useDataView.ts new file mode 100644 index 0000000..2022d2f --- /dev/null +++ b/app/panel/components/DataView/useDataView.ts @@ -0,0 +1,63 @@ +import { useTrackedDataQuery } from "@app/hooks"; +import { useState, useMemo } from "react"; + +export type FilterType = "all" | "loot" | "exp" | "skilling"; +export type ViewMode = "table" | "json"; + +export const useDataView = () => { + const [filterType, setFilterType] = useState("all"); + const [viewMode, setViewMode] = useState("table"); + + // Use shared tracked data query - benefits from automatic cache updates + // and listeners for storage changes + const { allData: allRows = [], loading: isLoading, error } = useTrackedDataQuery(); + + // Filter data based on selected filter + const filteredRows = useMemo(() => { + if (filterType === "all") { + return allRows; + } + + if (filterType === "loot") { + // Show rows that have drops (loot from monsters) + return allRows.filter(row => row.drops && row.drops.trim() !== ""); + } + + if (filterType === "exp") { + // Show rows with gained exp > 0 + return allRows.filter(row => { + const gainedExp = parseInt(row.gainedExp || "0", 10); + return gainedExp > 0; + }); + } + + if (filterType === "skilling") { + return allRows.filter(row => row.actionType === "skilling"); + } + + return allRows; + }, [allRows, filterType]); + + // Sort by timestamp descending (most recent first) + const sortedRows = useMemo( + () => + [...filteredRows].sort((a, b) => { + const timeA = new Date(a.timestamp).getTime(); + const timeB = new Date(b.timestamp).getTime(); + return timeB - timeA; // Descending order + }), + [filteredRows], + ); + + return { + rows: sortedRows, + filterType, + setFilterType, + viewMode, + setViewMode, + isLoading, + error, + totalCount: allRows.length, + filteredCount: sortedRows.length, + }; +}; diff --git a/app/panel/components/Header/index.tsx b/app/panel/components/Header/index.tsx new file mode 100644 index 0000000..2f2d6d7 --- /dev/null +++ b/app/panel/components/Header/index.tsx @@ -0,0 +1,186 @@ +import DISPLAY from "../../constants/Tabs"; +import { Badge, cn, Popover, PopoverTrigger, PopoverContent } from "@app/components"; +import * as RadixIcons from "@radix-ui/react-icons"; +import { useState } from "react"; + +interface HeaderProps { + headerText?: string; + display: string; + setDisplay: (value: string) => void; +} + +const tabIcons: Record> = { + [DISPLAY.DASHBOARD]: RadixIcons.HomeIcon, + [DISPLAY.CALENDAR]: RadixIcons.CalendarIcon, + [DISPLAY.PROFILE]: RadixIcons.PersonIcon, + [DISPLAY.STATS]: RadixIcons.BarChartIcon, + [DISPLAY.LOOT]: RadixIcons.CubeIcon, + [DISPLAY.HISTORY]: RadixIcons.ClockIcon, + [DISPLAY.SETTINGS]: RadixIcons.GearIcon, + [DISPLAY.DATA_VIEW]: RadixIcons.TableIcon, +}; + +const Header: React.FC = ({ display, setDisplay }) => { + const [desktopPopoverOpen, setDesktopPopoverOpen] = useState(false); + const [mobilePopoverOpen, setMobilePopoverOpen] = useState(false); + + // Main tabs (excluding settings) + const mainTabs = [ + { key: DISPLAY.DASHBOARD, label: "Dashboard" }, + { key: DISPLAY.CALENDAR, label: "Calendar" }, + { key: DISPLAY.PROFILE, label: "Profile" }, + { key: DISPLAY.STATS, label: "Performance" }, + { key: DISPLAY.LOOT, label: "Loot" }, + { key: DISPLAY.HISTORY, label: "History" }, + ]; + + // Settings dropdown items (only shown in dropdown) + const settingsDropdownItems = [ + { key: DISPLAY.DATA_VIEW, label: "Data View" }, + { key: DISPLAY.SETTINGS, label: "Settings" }, + ]; + + // All tabs including settings for popover menu + const allTabs = [...mainTabs, ...settingsDropdownItems]; + + const handleTabSelect = (tabKey: string, isMobile: boolean = false) => { + setDisplay(tabKey); + if (isMobile) { + setMobilePopoverOpen(false); + } else { + setDesktopPopoverOpen(false); + } + }; + + return ( +
+ {/* Tab Navigation - Responsive Badge Row */} + {/* Above 700: normal layout with main tabs and settings dropdown */} +
+ {mainTabs.map(tab => { + const isSelected = display === tab.key; + const IconComponent = tabIcons[tab.key] || RadixIcons.DotFilledIcon; + + return ( + setDisplay(tab.key)} + isActive={isSelected} + className="h-10 w-10 p-0 min-[700px]:h-auto min-[700px]:w-auto min-[700px]:px-2.5 min-[700px]:py-0.5" + aria-label={tab.label} + aria-selected={isSelected} + role="tab"> + + ); + })} + + {/* Settings dropdown for desktop */} + + + + + + +
+ {settingsDropdownItems.map(item => { + const IconComponent = tabIcons[item.key] || RadixIcons.DotFilledIcon; + const isSelected = display === item.key; + return ( + handleTabSelect(item.key, false)} + isActive={isSelected} + aria-label={item.label} + aria-selected={isSelected} + role="tab"> + + ); + })} +
+
+
+
+ + {/* Below 700: centered badges with text, settings dropdown on right */} +
+ {/* Centered badges with text */} +
+ {mainTabs.map(tab => { + const isSelected = display === tab.key; + const IconComponent = tabIcons[tab.key] || RadixIcons.DotFilledIcon; + + return ( + setDisplay(tab.key)} + isActive={isSelected} + className="px-2.5 py-0.5" + aria-label={tab.label} + aria-selected={isSelected} + role="tab"> + + ); + })} +
+ + {/* Settings popover on the right */} + + + + + + +
+ {allTabs.map(tab => { + const IconComponent = tabIcons[tab.key] || RadixIcons.DotFilledIcon; + const isSelected = display === tab.key; + return ( + handleTabSelect(tab.key, true)} + isActive={isSelected} + aria-label={tab.label} + aria-selected={isSelected} + role="tab"> + + ); + })} +
+
+
+
+
+ ); +}; + +export default Header; diff --git a/app/panel/components/LootMap/LootGrid/index.tsx b/app/panel/components/LootMap/LootGrid/index.tsx new file mode 100644 index 0000000..231d669 --- /dev/null +++ b/app/panel/components/LootMap/LootGrid/index.tsx @@ -0,0 +1,135 @@ +import { ItemImage } from "@app/components"; +import { memo, useMemo } from "react"; +import type { LootEntry, SortOption, LootGroup, TimeFilterOption } from "../useLootMap"; + +interface AggregatedItem { + name: string; + imageUrl: string; + totalQuantity: number; + totalValue: number; + mostRecentTimestamp: number; +} + +interface LootGridProps { + filteredLootEntries: LootEntry[]; + sortedAndGroupedLoot: LootGroup[]; + sortOption: SortOption; + timeFilter: TimeFilterOption; + zoomLevel: number; +} + +/** + * Aggregate entries by item name + */ +const aggregateItems = (entries: LootEntry[]): AggregatedItem[] => { + const itemMap = new Map(); + + entries.forEach(entry => { + const existing = itemMap.get(entry.name); + const timestamp = new Date(entry.timestamp).getTime(); + if (existing) { + existing.totalQuantity += entry.quantity; + existing.totalValue += entry.totalValue; + existing.mostRecentTimestamp = Math.max(existing.mostRecentTimestamp, timestamp); + } else { + itemMap.set(entry.name, { + name: entry.name, + imageUrl: entry.imageUrl, + totalQuantity: entry.quantity, + totalValue: entry.totalValue, + mostRecentTimestamp: timestamp, + }); + } + }); + + return Array.from(itemMap.values()); +}; + +/** + * Sort aggregated items based on sort option + */ +const sortAggregatedItems = (items: AggregatedItem[], sortOption: SortOption): AggregatedItem[] => { + const sorted = [...items]; + + switch (sortOption) { + case "alphabetical": + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + case "totalValue": + sorted.sort((a, b) => { + if (b.totalValue !== a.totalValue) return b.totalValue - a.totalValue; + return a.name.localeCompare(b.name); + }); + break; + } + + return sorted; +}; + +// Map zoomLevel 1-20 to column count: more zoom = fewer columns = bigger cards +// zoomLevel 1 → 12 cols, zoomLevel 8 → 8 cols, zoomLevel 20 → 2 cols +const getColumnCount = (zoomLevel: number) => Math.max(2, Math.round(13 - zoomLevel * 0.55)); + +/** + * Loot Grid Component + * Displays items in a grid with item image, total quantity, and total GP value + */ +const LootGrid = memo( + ({ filteredLootEntries, sortedAndGroupedLoot, sortOption, timeFilter, zoomLevel }: LootGridProps) => { + const isTimeBasedFilter = timeFilter !== "none"; + + const groupedData = useMemo(() => { + if (!isTimeBasedFilter) { + const aggregated = aggregateItems(filteredLootEntries); + return [{ header: "", items: sortAggregatedItems(aggregated, sortOption) }]; + } + + return sortedAndGroupedLoot.map(group => ({ + header: group.header, + items: sortAggregatedItems(aggregateItems(group.entries), sortOption), + })); + }, [filteredLootEntries, sortedAndGroupedLoot, sortOption, isTimeBasedFilter]); + + const cols = getColumnCount(zoomLevel); + const gridStyle = { + gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, + }; + + if (groupedData.length === 0 || groupedData.every(group => group.items.length === 0)) { + return null; + } + + return ( +
+ {groupedData.map((group, groupIndex) => ( +
+ {group.header &&
{group.header}
} +
+ {group.items.map(item => ( + + ))} +
+
+ ))} +
+ ); + }, +); + +LootGrid.displayName = "LootGrid"; + +/** + * Individual item card in the grid + */ +const LootGridCard = memo(({ item }: { item: AggregatedItem }) => ( +
+ + + {item.totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })} GP + +
+)); + +LootGridCard.displayName = "LootGridCard"; + +export default LootGrid; diff --git a/app/panel/components/LootMap/LootTable/index.tsx b/app/panel/components/LootMap/LootTable/index.tsx new file mode 100644 index 0000000..476fa8e --- /dev/null +++ b/app/panel/components/LootMap/LootTable/index.tsx @@ -0,0 +1,125 @@ +import { formatTimestamp } from "../helpers/lootHelpers"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ItemImage } from "@app/components"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { memo, useState } from "react"; +import type { LootEntry, TimeFilterOption } from "../useLootMap"; +import type { SortingState } from "@tanstack/react-table"; + +const columnHelper = createColumnHelper(); + +const buildColumns = (timeFilter: TimeFilterOption) => [ + columnHelper.accessor("timestamp", { + header: "Time", + cell: info => formatTimestamp(info.getValue(), timeFilter), + sortingFn: "datetime", + }), + columnHelper.display({ + id: "image", + header: "Item", + enableSorting: false, + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor("name", { + header: "Name", + cell: info => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor("totalValue", { + header: "Value", + cell: info => + `${info.getValue().toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })} GP`, + meta: { align: "right" }, + }), + columnHelper.accessor("source", { + header: "Source", + cell: info => (info.getValue() === "produced" ? "Produced" : "Drop"), + }), +]; + +interface LootTableProps { + filteredLootEntries: LootEntry[]; + timeFilter: TimeFilterOption; + zoomLevel: number; +} + +const LootTable = memo(({ filteredLootEntries, timeFilter }: LootTableProps) => { + const [sorting, setSorting] = useState([{ id: "timestamp", desc: true }]); + + const columns = buildColumns(timeFilter); + + const table = useReactTable({ + data: filteredLootEntries, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + if (filteredLootEntries.length === 0) return null; + + return ( +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const align = (header.column.columnDef.meta as { align?: string } | undefined)?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + + return ( + + + {flexRender(header.column.columnDef.header, header.getContext())} + {sorted === "asc" && } + {sorted === "desc" && } + + + ); + })} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => { + const align = (cell.column.columnDef.meta as { align?: string } | undefined)?.align; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} + +
+
+ ); +}); + +LootTable.displayName = "LootTable"; + +export default LootTable; diff --git a/app/panel/components/LootMap/helpers/lootHelpers.ts b/app/panel/components/LootMap/helpers/lootHelpers.ts new file mode 100644 index 0000000..660a62c --- /dev/null +++ b/app/panel/components/LootMap/helpers/lootHelpers.ts @@ -0,0 +1,153 @@ +/** + * Get day suffix (st, nd, rd, th) + */ +export const getDaySuffix = (day: number): string => { + if (day > 3 && day < 21) return "th"; + switch (day % 10) { + case 1: + return "st"; + case 2: + return "nd"; + case 3: + return "rd"; + default: + return "th"; + } +}; + +/** + * Format date like "July 4th, 1:32pm" + */ +export const formatHumanDate = (timestamp: string): string => { + const date = new Date(timestamp); + const month = date.toLocaleString("en-US", { month: "long" }); + const day = date.getDate(); + const daySuffix = getDaySuffix(day); + const time = date.toLocaleString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return `${month} ${day}${daySuffix}, ${time}`; +}; + +/** + * Format date for day header (e.g., "July 4th, 2024") + */ +export const formatDayHeader = (timestamp: string): string => { + const date = new Date(timestamp); + const month = date.toLocaleString("en-US", { month: "long" }); + const day = date.getDate(); + const daySuffix = getDaySuffix(day); + const year = date.getFullYear(); + return `${month} ${day}${daySuffix}, ${year}`; +}; + +/** + * Format date for week header (e.g., "Week of July 1st, 2024") + */ +export const formatWeekHeader = (timestamp: string): string => { + const date = new Date(timestamp); + // Get the start of the week (Sunday) + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - date.getDay()); + const month = weekStart.toLocaleString("en-US", { month: "long" }); + const day = weekStart.getDate(); + const daySuffix = getDaySuffix(day); + const year = weekStart.getFullYear(); + return `Week of ${month} ${day}${daySuffix}, ${year}`; +}; + +/** + * Format date for hour header (e.g., "July 4th, 2024 - 1:00 PM") + */ +export const formatHourHeader = (timestamp: string): string => { + const date = new Date(timestamp); + const month = date.toLocaleString("en-US", { month: "long" }); + const day = date.getDate(); + const daySuffix = getDaySuffix(day); + const year = date.getFullYear(); + const hour = date.getHours(); + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const ampm = hour >= 12 ? "PM" : "AM"; + return `${month} ${day}${daySuffix}, ${year} - ${hour12}:00 ${ampm}`; +}; + +/** + * Get start of day for a timestamp + */ +export const getDayStart = (timestamp: string): string => { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + return date.toISOString(); +}; + +/** + * Get start of week for a timestamp (Sunday) + */ +export const getWeekStart = (timestamp: string): string => { + const date = new Date(timestamp); + date.setDate(date.getDate() - date.getDay()); + date.setHours(0, 0, 0, 0); + return date.toISOString(); +}; + +/** + * Get start of hour for a timestamp + */ +export const getHourStart = (timestamp: string): string => { + const date = new Date(timestamp); + date.setMinutes(0, 0, 0); + return date.toISOString(); +}; + +/** + * Get start of month for a timestamp + */ +export const getMonthStart = (timestamp: string): string => { + const date = new Date(timestamp); + date.setDate(1); + date.setHours(0, 0, 0, 0); + return date.toISOString(); +}; + +/** + * Get start of year for a timestamp + */ +export const getYearStart = (timestamp: string): string => { + const date = new Date(timestamp); + date.setMonth(0, 1); + date.setHours(0, 0, 0, 0); + return date.toISOString(); +}; + +/** + * Format date for month header (e.g., "July 2024") + */ +export const formatMonthHeader = (timestamp: string): string => { + const date = new Date(timestamp); + const month = date.toLocaleString("en-US", { month: "long" }); + return `${month} ${date.getFullYear()}`; +}; + +/** + * Format date for year header (e.g., "2024") + */ +export const formatYearHeader = (timestamp: string): string => { + const date = new Date(timestamp); + return `${date.getFullYear()}`; +}; + +/** + * Format timestamp for the loot table based on the active time filter. + * - "hour" filter → "h:mm AM/PM" (e.g. "11:38 AM") + * - All others → "MMM D" (e.g. "Feb 8") + */ +export const formatTimestamp = (timestamp: string, timeFilter: string): string => { + const date = new Date(timestamp); + if (timeFilter === "hour") { + return date.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); + } + const month = date.toLocaleString("en-US", { month: "short" }); + return `${month} ${date.getDate()}`; +}; diff --git a/app/panel/components/LootMap/index.tsx b/app/panel/components/LootMap/index.tsx new file mode 100644 index 0000000..a652beb --- /dev/null +++ b/app/panel/components/LootMap/index.tsx @@ -0,0 +1,271 @@ +import LootGrid from "./LootGrid"; +import LootTable from "./LootTable"; +import { useLootMap } from "./useLootMap"; +import { FilterIcon, GridViewIcon, SettingsIcon, TableViewIcon, ZoomInIcon, ZoomOutIcon } from "@app/assets/icons"; +import { + cn, + Card, + CardContent, + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + IconButton, + CardFooter, + CardDescription, + Input, + Popover, + PopoverTrigger, + PopoverContent, + Select, + Tabs, + TabsList, + TabsTrigger, +} from "@app/components"; +import { memo, useState } from "react"; +import type { TimeFilterOption, SortOption, SourceFilterOption } from "./useLootMap"; + +type ViewMode = "table" | "grid"; + +/** + * LootMap Component + * Pure JSX component - all logic is in useLootMap hook + */ +const LootMap = memo(() => { + const [viewMode, setViewMode] = useState("grid"); + const [zoomLevel, setZoomLevel] = useState(8); + const { + loading, + sourceFilter, + setSourceFilter, + filterMonster, + setFilterMonster, + filterLocation, + setFilterLocation, + timeFilter, + setTimeFilter, + sortOption, + setSortOption, + isSettingsOpen, + setIsSettingsOpen, + tempItemValues, + isSaving, + allUniqueItems, + uniqueMonsters, + uniqueLocations, + sortedAndGroupedLoot, + filteredLootEntries, + handleOpenSettings, + handleCloseSettings, + handleSaveItemValues, + handleItemValueChange, + } = useLootMap(); + + if (loading) { + return
Loading tracked data...
; + } + + return ( +
+ {/* Header Bar */} +
+ {/* Center: Time period tabs */} +
+ setTimeFilter(v as TimeFilterOption)}> + + All + Hour + Day + Month + Year + + +
+
+ + {/* Content */} + {sortedAndGroupedLoot.length === 0 || sortedAndGroupedLoot.every(group => group.entries.length === 0) ? ( + + No loot found. + + ) : ( + +
+
+ setViewMode("table")} + variant={viewMode === "table" ? "default" : "outline"} + size="icon" + label="Table View" + className={cn( + "flex-shrink-0", + viewMode === "table" ? "hover:bg-accent/80 bg-accent text-accent-foreground" : "", + )} + Icon={TableViewIcon} + /> + setViewMode("grid")} + variant={viewMode === "grid" ? "default" : "outline"} + size="icon" + label="Grid View" + className={cn( + "flex-shrink-0", + viewMode === "grid" ? "hover:bg-accent/80 bg-accent text-accent-foreground" : "", + )} + Icon={GridViewIcon} + /> + + {viewMode === "table" ? "Table View" : "Grid View"} + +
+
+ + + + + +
+ Source + +
+
+ Monster + +
+
+ Location + +
+
+ Sort + +
+
+
+ + setZoomLevel(prev => Math.max(1, prev - 1))} + variant="outline" + size="icon" + label="Zoom Out" + className="flex-shrink-0" + Icon={ZoomOutIcon} + disabled={zoomLevel <= 1} + /> + setZoomLevel(prev => Math.min(20, prev + 1))} + variant="outline" + size="icon" + label="Zoom In" + className="flex-shrink-0" + Icon={ZoomInIcon} + disabled={zoomLevel >= 20} + /> +
+
+ + {viewMode === "table" ? ( + + ) : ( + + )} + + + + {filteredLootEntries?.map(item => item.totalValue).reduce((acc, curr) => acc + curr, 0)} GP + + +
+ )} + + {/* Settings Dialog */} + + + + Item Value Settings + + Assign GP (gold pieces) values to all tracked items. These values will be used for profit calculations. + + +
+ {allUniqueItems.length === 0 ? ( +
No items have been tracked yet.
+ ) : ( +
+ {allUniqueItems.map(itemName => ( +
+ + handleItemValueChange(itemName, e.target.value)} + className="flex-1" + /> +
+ ))} +
+ )} +
+ + + + +
+
+
+ ); +}); + +LootMap.displayName = "LootMap"; + +export default LootMap; diff --git a/app/panel/components/LootMap/useLootMap.ts b/app/panel/components/LootMap/useLootMap.ts new file mode 100644 index 0000000..7db7448 --- /dev/null +++ b/app/panel/components/LootMap/useLootMap.ts @@ -0,0 +1,344 @@ +import { + formatDayHeader, + formatHourHeader, + formatMonthHeader, + formatYearHeader, + getDayStart, + getHourStart, + getMonthStart, + getYearStart, +} from "./helpers/lootHelpers"; +import { useTrackedDataQuery, useFormatting, useItemValuesQuery } from "@app/hooks"; +import { useMemo, useState, useCallback } from "react"; + +export type SortOption = "alphabetical" | "totalValue"; +export type TimeFilterOption = "none" | "hour" | "day" | "month" | "year"; +export type SourceFilterOption = "all" | "drops" | "produced"; + +export interface LootEntry { + timestamp: string; + name: string; + quantity: number; + valuePerItem: number; + totalValue: number; + location: string; + monster: string; + imageUrl: string; + source: "drop" | "produced"; +} + +export interface LootGroup { + header: string; + entries: LootEntry[]; +} + +/** + * Main hook for LootMap component + * Handles all logic, state, and data processing + */ +export const useLootMap = () => { + const { allData, loading } = useTrackedDataQuery(); + const { parseDrops, parseDropAmount } = useFormatting(); + const { itemValues, save, isSaving } = useItemValuesQuery(); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [tempItemValues, setTempItemValues] = useState>({}); + const [filterMonster, setFilterMonster] = useState("none"); + const [filterLocation, setFilterLocation] = useState("none"); + const [timeFilter, setTimeFilter] = useState("none"); + const [sortOption, setSortOption] = useState("totalValue"); + const [sourceFilter, setSourceFilter] = useState("all"); + + // Extract all loot entries from all data (drops + produced items) + const allLootEntries = useMemo(() => { + const entries: LootEntry[] = []; + + // Sort rows by timestamp ascending for delta calculation + const sortedData = [...allData].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + // Track previous quantities for actionOutput delta computation + const previousQuantities = new Map(); + + sortedData.forEach(row => { + // Process drops + const drops = parseDrops(row.drops || ""); + drops.forEach(drop => { + const trimmedDrop = drop.trim(); + if (!trimmedDrop || trimmedDrop.length === 0) return; + if (/^[\d,]+$/.test(trimmedDrop)) return; + + const { amount, name } = parseDropAmount(drop); + const trimmedName = name?.trim() || ""; + + if ( + !trimmedName || + trimmedName.toLowerCase().includes("experience") || + trimmedName.toLowerCase().includes("exp ") || + /^\d+\s*exp$/i.test(trimmedName) + ) { + return; + } + + const itemValue = parseFloat(itemValues[name] || "0") || 0; + const imageUrl = `https://www.syrnia.com/images/inventory/${name.replace(/\s/g, "%20")}.png`; + + entries.push({ + timestamp: row.timestamp, + name, + quantity: amount, + valuePerItem: itemValue, + totalValue: amount * itemValue, + location: row.location?.trim() || "Unknown", + monster: row.monster?.trim() || "Unknown", + imageUrl, + source: "drop", + }); + }); + + // Process actionOutput (produced items) — compute deltas from running totals + if (row.actionOutput && row.actionOutput.trim() !== "" && row.actionOutput !== "[]") { + try { + const outputs: Array<{ item: string; quantity: number }> = JSON.parse(row.actionOutput); + if (Array.isArray(outputs)) { + outputs.forEach(output => { + if (!output.item || output.quantity <= 0) return; + + const prev = previousQuantities.get(output.item); + previousQuantities.set(output.item, output.quantity); + + // First occurrence is baseline — no entry created + if (prev === undefined) return; + + const delta = output.quantity - prev; + if (delta <= 0) return; + + const itemValue = parseFloat(itemValues[output.item] || "0") || 0; + const imageUrl = `https://www.syrnia.com/images/inventory/${output.item.replace(/\s/g, "%20")}.png`; + + entries.push({ + timestamp: row.timestamp, + name: output.item, + quantity: delta, + valuePerItem: itemValue, + totalValue: delta * itemValue, + location: "", + monster: "", + imageUrl, + source: "produced", + }); + }); + } + } catch { + // Silently handle JSON parse errors + } + } + }); + + return entries; + }, [allData, parseDrops, parseDropAmount, itemValues]); + + // Get unique monsters and locations for filters + const uniqueMonsters = useMemo(() => { + const monsters = new Set(); + allLootEntries.forEach(entry => { + if (entry.monster && entry.monster !== "Unknown") { + monsters.add(entry.monster); + } + }); + return Array.from(monsters).sort(); + }, [allLootEntries]); + + const uniqueLocations = useMemo(() => { + const locations = new Set(); + allLootEntries.forEach(entry => { + if (entry.location && entry.location !== "Unknown") { + locations.add(entry.location); + } + }); + return Array.from(locations).sort(); + }, [allLootEntries]); + + // Filter loot entries + const filteredLootEntries = useMemo(() => { + let filtered = [...allLootEntries]; + + // Apply source filter + if (sourceFilter === "drops") { + filtered = filtered.filter(entry => entry.source === "drop"); + } else if (sourceFilter === "produced") { + filtered = filtered.filter(entry => entry.source === "produced"); + } + + // Apply monster filter + if (filterMonster !== "none") { + filtered = filtered.filter(entry => entry.monster === filterMonster); + } + + // Apply location filter + if (filterLocation !== "none") { + filtered = filtered.filter(entry => entry.location === filterLocation); + } + + return filtered; + }, [allLootEntries, sourceFilter, filterMonster, filterLocation]); + + // Sort and group loot entries + const sortedAndGroupedLoot = useMemo(() => { + const entries = [...filteredLootEntries]; + + // Helper function to sort entries by sort option + const sortEntries = (entriesToSort: LootEntry[]): LootEntry[] => { + const sorted = [...entriesToSort]; + switch (sortOption) { + case "alphabetical": + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + case "totalValue": + sorted.sort((a, b) => { + if (b.totalValue !== a.totalValue) return b.totalValue - a.totalValue; // Highest value first + return a.name.localeCompare(b.name); // Then alphabetical + }); + break; + } + return sorted; + }; + + // Group by time filter + if (timeFilter === "day") { + const grouped = new Map(); + // Group entries by day + entries.forEach(entry => { + const dayStart = getDayStart(entry.timestamp); + if (!grouped.has(dayStart)) { + grouped.set(dayStart, []); + } + grouped.get(dayStart)!.push(entry); + }); + // Sort groups by day (newest first), then sort entries within each group by sortOption + return Array.from(grouped.entries()) + .sort(([a], [b]) => b.localeCompare(a)) // Sort groups by timestamp (newest first) + .map(([dayStart, groupEntries]) => ({ + header: formatDayHeader(dayStart), + entries: sortEntries(groupEntries), + })); + } else if (timeFilter === "month") { + const grouped = new Map(); + entries.forEach(entry => { + const monthStart = getMonthStart(entry.timestamp); + if (!grouped.has(monthStart)) { + grouped.set(monthStart, []); + } + grouped.get(monthStart)!.push(entry); + }); + return Array.from(grouped.entries()) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([monthStart, groupEntries]) => ({ + header: formatMonthHeader(monthStart), + entries: sortEntries(groupEntries), + })); + } else if (timeFilter === "year") { + const grouped = new Map(); + entries.forEach(entry => { + const yearStart = getYearStart(entry.timestamp); + if (!grouped.has(yearStart)) { + grouped.set(yearStart, []); + } + grouped.get(yearStart)!.push(entry); + }); + return Array.from(grouped.entries()) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([yearStart, groupEntries]) => ({ + header: formatYearHeader(yearStart), + entries: sortEntries(groupEntries), + })); + } else if (timeFilter === "hour") { + const grouped = new Map(); + // Group entries by hour + entries.forEach(entry => { + const hourStart = getHourStart(entry.timestamp); + if (!grouped.has(hourStart)) { + grouped.set(hourStart, []); + } + grouped.get(hourStart)!.push(entry); + }); + // Sort groups by hour (newest first), then sort entries within each group by sortOption + return Array.from(grouped.entries()) + .sort(([a], [b]) => b.localeCompare(a)) // Sort groups by timestamp (newest first) + .map(([hourStart, groupEntries]) => ({ + header: formatHourHeader(hourStart), + entries: sortEntries(groupEntries), + })); + } else { + // No time filter - no grouping, just sort all entries + return [{ header: "", entries: sortEntries(entries) }]; + } + }, [filteredLootEntries, sortOption, timeFilter]); + + // Get all unique item names for settings + const allUniqueItems = useMemo(() => { + const items = new Set(); + allLootEntries.forEach(entry => { + items.add(entry.name); + }); + return Array.from(items).sort(); + }, [allLootEntries]); + + // Handle settings dialog + const handleOpenSettings = useCallback(() => { + setTempItemValues({ ...itemValues }); + setIsSettingsOpen(true); + }, [itemValues]); + + const handleCloseSettings = useCallback(() => { + setIsSettingsOpen(false); + setTempItemValues({}); + }, []); + + const handleSaveItemValues = useCallback(async () => { + try { + await save(tempItemValues); + setIsSettingsOpen(false); + } catch { + alert("Error saving item values"); + } + }, [tempItemValues, save]); + + const handleItemValueChange = useCallback((itemName: string, value: string) => { + setTempItemValues(prev => ({ + ...prev, + [itemName]: value, + })); + }, []); + + return { + // State + loading, + sourceFilter, + setSourceFilter, + filterMonster, + setFilterMonster, + filterLocation, + setFilterLocation, + timeFilter, + setTimeFilter, + sortOption, + setSortOption, + isSettingsOpen, + setIsSettingsOpen, + tempItemValues, + isSaving, + allUniqueItems, + itemValues, + + // Data + uniqueMonsters, + uniqueLocations, + sortedAndGroupedLoot, + filteredLootEntries, + + // Handlers + handleOpenSettings, + handleCloseSettings, + handleSaveItemValues, + handleItemValueChange, + }; +}; diff --git a/app/panel/components/Performance/EquipmentDisplay/index.tsx b/app/panel/components/Performance/EquipmentDisplay/index.tsx new file mode 100644 index 0000000..dce23e7 --- /dev/null +++ b/app/panel/components/Performance/EquipmentDisplay/index.tsx @@ -0,0 +1,167 @@ +import { useEquipmentDisplay } from "./useEquipmentDisplay"; +import { memo } from "react"; +import type { EquipmentItem } from "@app/types"; + +interface EquipmentDisplayData { + items?: Record; + totals?: { + armour?: number; + aim?: number; + power?: number; + travelTime?: number; + }; +} + +interface EquipmentDisplayProps { + equipment: EquipmentDisplayData; +} + +/** + * Equipment Display Component + * Displays equipment items in a grid layout: + * Row 1: centered helmet + * Row 2: shield, centered chest/plate, weapon + * Row 3: centered legs, gloves/hands + * Row 4: horse, centered boots/shoes/feet + */ +const EquipmentDisplay = memo(({ equipment }: EquipmentDisplayProps) => { + const { equipmentRows, formattedTotals } = useEquipmentDisplay(equipment); + + return ( +
+

Average Equipment

+ + {/* Equipment Grid */} +
+ {/* Row 1: Centered Helmet */} +
+ {equipmentRows.row1.map(item => ( +
+ {item.name} +
+ ))} +
+ + {/* Row 2: Shield, Centered Chest/Plate, Weapon */} +
+ {/* Shield - Left */} + {equipmentRows.row2Map.shield && ( +
+ {equipmentRows.row2Map.shield.name} +
+ )} + + {/* Body - Center */} + {equipmentRows.row2Map.body && ( +
+ {equipmentRows.row2Map.body.name} +
+ )} + + {/* Weapon - Right */} + {equipmentRows.row2Map.weapon && ( +
+ {equipmentRows.row2Map.weapon.name} +
+ )} +
+ + {/* Row 3: Centered Legs (under chest), Gloves/Hands */} +
+ {/* Spacer to align with shield position */} +
+ + {/* Legs - Centered under body */} + {equipmentRows.row3Map.legs && ( +
+ {equipmentRows.row3Map.legs.name} +
+ )} + + {/* Gloves - Right (aligned with weapon) */} + {equipmentRows.row3Map.gloves && ( +
+ {equipmentRows.row3Map.gloves.name} +
+ )} +
+ + {/* Row 4: Horse, Centered Boots (under legs) */} +
+ {/* Spacer to align with shield position (same as row 3 for legs) */} +
+ + {/* Boots - Centered under legs (exact same position as legs) */} + {equipmentRows.row4Map.boots && ( +
+ {equipmentRows.row4Map.boots.name} +
+ )} + + {/* Horse - Right (aligned with gloves position) */} + {equipmentRows.row4Map.horse && ( +
+ {equipmentRows.row4Map.horse.name} +
+ )} +
+
+ + {/* Equipment Stats Totals */} + {formattedTotals && formattedTotals.length > 0 && ( +
+ {formattedTotals.map(total => ( +
+
+

{total.label}

+ +
+

{total.value}

+ {total.enchant ? ( +

+ {total.enchant} +

+ ) : ( +

+ )} +
+
+
+ ))} +
+ )} +
+ ); +}); + +EquipmentDisplay.displayName = "EquipmentDisplay"; + +export { EquipmentDisplay }; diff --git a/app/panel/components/Performance/EquipmentDisplay/useEquipmentDisplay.ts b/app/panel/components/Performance/EquipmentDisplay/useEquipmentDisplay.ts new file mode 100644 index 0000000..b339b80 --- /dev/null +++ b/app/panel/components/Performance/EquipmentDisplay/useEquipmentDisplay.ts @@ -0,0 +1,244 @@ +import { useMemo } from "react"; +import type { EquipmentItem } from "@app/types"; + +interface FormattedEquipmentItem { + slot: string; + name: string; + imageUrl: string; +} + +interface EquipmentDisplayData { + items?: Record; + totals?: { + armour?: number; + aim?: number; + power?: number; + travelTime?: number; + }; +} + +/** + * Hook for formatting and organizing equipment data for display + */ +export const useEquipmentDisplay = (equipment: EquipmentDisplayData | undefined) => { + // Format image URL like loot images: https://www.syrnia.com/images/inventory/Item%20Name.png?query + const formatImageUrl = (imageUrl: string | undefined): string => { + if (!imageUrl) return ""; + + if (imageUrl.startsWith("https://")) { + return imageUrl; + } + + // If it's a relative path, format it properly + if (imageUrl.startsWith("images/")) { + // Extract the path and query parameters + const [path, query] = imageUrl.split("?"); + // URL encode spaces in the path (like loot images) + const encodedPath = path.replace(/\s/g, "%20"); + // Reconstruct with query params if they exist + return `https://www.syrnia.com/${encodedPath}${query ? `?${query}` : ""}`; + } + + // Fallback: prepend base URL + return `https://www.syrnia.com/${imageUrl}`; + }; + + // Organize equipment items into rows for the grid layout + const equipmentRows = useMemo(() => { + const emptyRows = { + row1: [], + row2: [], + row3: [], + row4: [], + row2Map: { + shield: undefined, + body: undefined, + weapon: undefined, + }, + row3Map: { + legs: undefined, + gloves: undefined, + }, + row4Map: { + boots: undefined, + horse: undefined, + }, + }; + + if (!equipment?.items) { + return emptyRows; + } + + const items = equipment.items; + + // Row 1: centered helmet + const row1: FormattedEquipmentItem[] = []; + if (items.helm) { + row1.push({ + slot: "helm", + name: items.helm.name || "Helm", + imageUrl: formatImageUrl(items.helm.imageUrl), + }); + } + + // Row 2: shield, centered chest/plate, weapon + const row2: FormattedEquipmentItem[] = []; + if (items.shield) { + row2.push({ + slot: "shield", + name: items.shield.name || "Shield", + imageUrl: formatImageUrl(items.shield.imageUrl), + }); + } + if (items.body) { + row2.push({ + slot: "body", + name: items.body.name || "Body", + imageUrl: formatImageUrl(items.body.imageUrl), + }); + } + if (items.weapon) { + row2.push({ + slot: "weapon", + name: items.weapon.name || "Weapon", + imageUrl: formatImageUrl(items.weapon.imageUrl), + }); + } + + // Row 3: centered legs, gloves/hands + const row3: FormattedEquipmentItem[] = []; + if (items.legs) { + row3.push({ + slot: "legs", + name: items.legs.name || "Legs", + imageUrl: formatImageUrl(items.legs.imageUrl), + }); + } + if (items.gloves) { + row3.push({ + slot: "gloves", + name: items.gloves.name || "Gloves", + imageUrl: formatImageUrl(items.gloves.imageUrl), + }); + } + + // Row 4: horse, centered boots/shoes/feet + const row4: FormattedEquipmentItem[] = []; + if (items.horse) { + row4.push({ + slot: "horse", + name: items.horse.name || "Horse", + imageUrl: formatImageUrl(items.horse.imageUrl), + }); + } + if (items.boots) { + row4.push({ + slot: "boots", + name: items.boots.name || "Boots", + imageUrl: formatImageUrl(items.boots.imageUrl), + }); + } + + // Create lookup maps for easy access without repeated .find() calls + const row2Map = { + shield: row2.find(item => item.slot === "shield"), + body: row2.find(item => item.slot === "body"), + weapon: row2.find(item => item.slot === "weapon"), + }; + + const row3Map = { + legs: row3.find(item => item.slot === "legs"), + gloves: row3.find(item => item.slot === "gloves"), + }; + + const row4Map = { + boots: row4.find(item => item.slot === "boots"), + horse: row4.find(item => item.slot === "horse"), + }; + + return { + row1, + row2, + row3, + row4, + row2Map, + row3Map, + row4Map, + }; + }, [equipment]); + + // Format totals for display with enchant values + const formattedTotals = useMemo(() => { + if (!equipment || !equipment.totals || !equipment.items) { + return null; + } + + const totals: Array<{ + label: string; + value: number; + enchant?: string; + enchantType?: "aim" | "armour" | "power"; + enchantColorClass: string; + }> = []; + + // Extract and sum enchant values by type from equipment items + let totalArmourEnchant = 0; + let totalAimEnchant = 0; + let totalPowerEnchant = 0; + + Object.values(equipment.items).forEach(item => { + if (item?.enchant) { + const enchant = item.enchant; + // Parse enchant like "4 Aim", "4 Power", "4 Armour" + const aimMatch = enchant.match(/(\d+)\s+Aim/i); + const powerMatch = enchant.match(/(\d+)\s+Power/i); + const armourMatch = enchant.match(/(\d+)\s+Armour/i); + + if (aimMatch) { + totalAimEnchant += parseInt(aimMatch[1], 10); + } + if (powerMatch) { + totalPowerEnchant += parseInt(powerMatch[1], 10); + } + if (armourMatch) { + totalArmourEnchant += parseInt(armourMatch[1], 10); + } + } + }); + + if (equipment.totals.armour !== undefined) { + totals.push({ + label: "Total Armour", + value: Math.round(equipment.totals.armour), + enchant: totalArmourEnchant > 0 ? `+${totalArmourEnchant} Armour` : undefined, + enchantType: "armour", + enchantColorClass: totalArmourEnchant > 0 ? "text-blue-500" : "", + }); + } + if (equipment.totals.aim !== undefined) { + totals.push({ + label: "Aim", + value: Math.round(equipment.totals.aim), + enchant: totalAimEnchant > 0 ? `+${totalAimEnchant} Aim` : undefined, + enchantType: "aim", + enchantColorClass: totalAimEnchant > 0 ? "text-[#f8ef8c]" : "", + }); + } + if (equipment.totals.power !== undefined) { + totals.push({ + label: "Power", + value: Math.round(equipment.totals.power), + enchant: totalPowerEnchant > 0 ? `+${totalPowerEnchant} Power` : undefined, + enchantType: "power", + enchantColorClass: totalPowerEnchant > 0 ? "text-red-500" : "", + }); + } + + return totals; + }, [equipment]); + + return { + equipmentRows, + formattedTotals, + }; +}; diff --git a/app/panel/components/Performance/PerformanceCard/index.tsx b/app/panel/components/Performance/PerformanceCard/index.tsx new file mode 100644 index 0000000..b81757c --- /dev/null +++ b/app/panel/components/Performance/PerformanceCard/index.tsx @@ -0,0 +1,34 @@ +import { usePerformanceCard } from "./usePerformanceCard"; +import { StatTable } from "../StatTable"; +import { Card, CardContent, CardHeader, CardTitle } from "@app/components"; +import { memo } from "react"; +import type { StatRow } from "../StatTable"; + +interface PerformanceCardProps { + title: string; + rows: StatRow[]; + className?: string; +} + +/** + * Reusable performance card component + * Wraps StatTable in a Card with title + */ +const PerformanceCard = memo(({ title, rows, className }: PerformanceCardProps) => { + const { title: displayTitle, rows: displayRows } = usePerformanceCard(title, rows); + + return ( + + + {displayTitle} + + + + + + ); +}); + +PerformanceCard.displayName = "PerformanceCard"; + +export { PerformanceCard }; diff --git a/app/panel/components/Performance/PerformanceCard/usePerformanceCard.ts b/app/panel/components/Performance/PerformanceCard/usePerformanceCard.ts new file mode 100644 index 0000000..b4015f4 --- /dev/null +++ b/app/panel/components/Performance/PerformanceCard/usePerformanceCard.ts @@ -0,0 +1,14 @@ +import type { StatRow } from "../StatTable"; + +/** + * Hook for PerformanceCard component + * Handles any card-specific logic if needed + */ +export const usePerformanceCard = (title: string, rows: StatRow[]) => + // Future: Add any card-specific logic here + // For now, this is a placeholder for consistency with the pattern + + ({ + title, + rows, + }); diff --git a/app/panel/components/Performance/StatTable/index.tsx b/app/panel/components/Performance/StatTable/index.tsx new file mode 100644 index 0000000..b893701 --- /dev/null +++ b/app/panel/components/Performance/StatTable/index.tsx @@ -0,0 +1,56 @@ +import { useStatTable } from "./useStatTable"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn } from "@app/components"; +import { memo } from "react"; +import type { StatRow } from "./types"; + +interface StatTableProps { + rows: StatRow[]; + className?: string; +} + +/** + * Reusable stat table component with composition pattern + * Transposed: stats as columns, values as rows + */ +const StatTable = memo(({ rows, className }: StatTableProps) => { + const { visibleRows, formatRowValue } = useStatTable(rows); + + if (visibleRows.length === 0) { + return null; + } + + return ( +
+ + + + {visibleRows.map((row, index) => ( + + {row.label} + + ))} + + + + + {visibleRows.map((row, index) => { + const displayValue = formatRowValue(row); + const cellClassName = cn("text-right font-medium", row.className); + + return ( + + {displayValue} + + ); + })} + + +
+
+ ); +}); + +StatTable.displayName = "StatTable"; + +export { StatTable }; +export type { StatRow } from "./types"; diff --git a/app/panel/components/Performance/StatTable/types.ts b/app/panel/components/Performance/StatTable/types.ts new file mode 100644 index 0000000..b32b4f5 --- /dev/null +++ b/app/panel/components/Performance/StatTable/types.ts @@ -0,0 +1,7 @@ +export interface StatRow { + label: string; + value: number | string; + format?: (value: number | string) => string; + className?: string; + showIfZero?: boolean; +} diff --git a/app/panel/components/Performance/StatTable/useStatTable.ts b/app/panel/components/Performance/StatTable/useStatTable.ts new file mode 100644 index 0000000..db4df75 --- /dev/null +++ b/app/panel/components/Performance/StatTable/useStatTable.ts @@ -0,0 +1,38 @@ +import { useMemo } from "react"; +import type { StatRow } from "../StatTable/types"; + +/** + * Hook for StatTable component + * Handles row filtering and formatting logic + */ +export const useStatTable = (rows: StatRow[]) => { + const defaultFormat = (value: number | string): string => { + if (typeof value === "number") { + return value > 0 ? value.toLocaleString() : "—"; + } + return value || "—"; + }; + + // Filter and process rows + const visibleRows = useMemo( + () => + rows.filter(row => { + if (row.showIfZero === false && (row.value === 0 || row.value === "—" || row.value === "")) { + return false; + } + return true; + }), + [rows], + ); + + const formatRowValue = (row: StatRow): string => { + const format = row.format || defaultFormat; + return format(row.value); + }; + + return { + visibleRows, + formatRowValue, + defaultFormat, + }; +}; diff --git a/app/panel/components/Performance/index.tsx b/app/panel/components/Performance/index.tsx new file mode 100644 index 0000000..fde7ab3 --- /dev/null +++ b/app/panel/components/Performance/index.tsx @@ -0,0 +1,272 @@ +import { EquipmentDisplay } from "./EquipmentDisplay"; +import { useStatTable } from "./StatTable/useStatTable"; +import { usePerformance } from "./usePerformance"; +import { + cn, + Card, + CardContent, + CardHeader, + CardTitle, + Tabs, + TabsList, + TabsTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@app/components"; +import { formatLocation } from "@app/utils/helpers"; +import { memo, Fragment } from "react"; +import type { StatRow } from "./StatTable"; + +/** + * Monster Stat Table Component + * Displays stats with simplified headers: Max, Average, Mean, Mode + * Shows only hit stats (damage dealt for user, damage received for monster) + */ +const MonsterStatTable = memo(({ title, rows }: { title: string; rows: StatRow[] }) => { + const { visibleRows, formatRowValue } = useStatTable(rows); + + if (visibleRows.length === 0) { + return null; + } + + // Only use the first 4 rows (hit stats) + const hitStats = visibleRows.slice(0, 4); + + return ( +
+

{title}

+
+ + + + Max + Average + Mean + Mode + + + + + {hitStats.map((row, index) => { + const displayValue = formatRowValue(row); + const cellClassName = cn("text-right font-medium", row.className); + return ( + + {displayValue} + + ); + })} + + +
+
+
+ ); +}); + +MonsterStatTable.displayName = "MonsterStatTable"; + +/** + * Performance Component + * Pure JSX component - all logic is in usePerformance hook + */ +const Performance = memo(() => { + const { selectedLocation, setSelectedLocation, loading, userStats, locations, performanceStats, locationStats } = + usePerformance(); + + if (loading) { + return
Loading tracked data...
; + } + + // Helper to create simplified stat rows (Max, Average, Mean, Mode for hits only) + const createHitStats = ( + maxHit: number, + avgHit: number, + meanHit: number, + modeHit: number, + isDamageReceived: boolean = false, + ): StatRow[] => { + const className = isDamageReceived && maxHit > 0 ? "text-red-500" : undefined; + return [ + { label: "Max", value: maxHit, className }, + { + label: "Average", + value: avgHit, + format: (v: number | string) => (typeof v === "number" ? Math.round(v).toLocaleString() : v.toString()), + className, + }, + { + label: "Mean", + value: meanHit, + format: (v: number | string) => (typeof v === "number" ? Math.round(v).toLocaleString() : v.toString()), + className, + }, + { label: "Mode", value: modeHit, className }, + ]; + }; + + return ( +
+ {/* Location Tabs */} + + + {locations.map(location => ( + + {location === "all" ? "All Locations" : formatLocation(location)} + + ))} + + + + {/* Location Stats Card (only for specific locations) */} + {selectedLocation !== "all" && locationStats.totalFights > 0 && ( + + +
+ {/* Left Column - Stats */} +
+

{formatLocation(selectedLocation)}

+
+ Total Fights Tracked + {locationStats.totalFights.toLocaleString()} +
+ {locationStats.avgHitsToKill > 0 && ( +
+ Avg Hits to Kill + {locationStats.avgHitsToKill.toFixed(1)} +
+ )} +
+ Avg Damage per 15 Min + + {Math.round(locationStats.avgDamagePer15Min).toLocaleString()} + +
+
+ Max Damage per 15 Min + + {Math.round(locationStats.maxDamagePer15Min).toLocaleString()} + +
+
+ + {/* Right Column - Average Equipment */} + {locationStats.avgEquipment && } +
+
+
+ )} + + {/* Performance Table */} + {Object.keys(performanceStats.maxHitByMonster ?? {}).length > 0 && ( + + + Performance + + +
+ + + + Name + Max + Average + Mean + Mode + Hits/Kill + + + + {/* User Stats Row */} + + {userStats?.username || "User"} + {createHitStats( + performanceStats.maxHit, + performanceStats.avgHit, + performanceStats.meanHit, + performanceStats.modeHit, + false, + ).map((stat, index) => { + const displayValue = stat.format + ? stat.format(stat.value) + : typeof stat.value === "number" + ? stat.value.toLocaleString() + : stat.value.toString(); + const cellClassName = cn("text-right font-medium", stat.className); + return ( + + {displayValue} + + ); + })} + + {performanceStats.avgHitsToKill > 0 ? performanceStats.avgHitsToKill.toFixed(1) : "-"} + + + + {/* Monster Rows */} + {Object.entries(performanceStats.maxHitByMonster) + .sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)) + .map(([monster]) => { + const maxReceived = + (performanceStats.maxDamageReceivedByMonster as Record)?.[monster] ?? 0; + const avgReceived = + (performanceStats.avgDamageReceivedByMonster as Record)?.[monster] ?? 0; + const meanReceived = + (performanceStats.meanDamageReceivedByMonster as Record)?.[monster] ?? 0; + const modeReceived = + (performanceStats.modeDamageReceivedByMonster as Record)?.[monster] ?? 0; + const avgHitsToKill = + (performanceStats.avgHitsToKillByMonster as Record)?.[monster] ?? 0; + + // Monster stats (damage received from monster - monster's hits) + const monsterStats = createHitStats(maxReceived, avgReceived, meanReceived, modeReceived, true); + + return ( + + {/* Monster Name Separator Row */} + + + {monster} + + + {/* Monster Stats Row */} + + {monster} + {monsterStats.map((stat, index) => { + const displayValue = stat.format + ? stat.format(stat.value) + : typeof stat.value === "number" + ? stat.value.toLocaleString() + : stat.value.toString(); + const cellClassName = cn("text-right font-medium", stat.className); + return ( + + {displayValue} + + ); + })} + + {avgHitsToKill > 0 ? avgHitsToKill.toFixed(1) : "-"} + + + + ); + })} + +
+
+
+
+ )} +
+ ); +}); + +Performance.displayName = "Performance"; + +export default Performance; diff --git a/app/panel/components/Performance/statRowHelpers.ts b/app/panel/components/Performance/statRowHelpers.ts new file mode 100644 index 0000000..6c5bdb7 --- /dev/null +++ b/app/panel/components/Performance/statRowHelpers.ts @@ -0,0 +1,74 @@ +import type { StatRow } from "./StatTable"; + +/** + * Helper functions for building stat rows + * Provides composition utilities for creating table rows + */ + +export const createStatRow = ( + label: string, + value: number | string, + options?: { + format?: (value: number | string) => string; + className?: string; + showIfZero?: boolean; + }, +): StatRow => ({ + label, + value, + format: options?.format, + className: options?.className, + showIfZero: options?.showIfZero, +}); + +export const createNumberStatRow = ( + label: string, + value: number | undefined | null, + options?: { + round?: boolean; + decimals?: number; + className?: string; + showIfZero?: boolean; + }, +): StatRow => { + const safeValue = value ?? 0; + const format = (val: number | string) => { + if (typeof val === "number") { + if (val > 0) { + if (options?.round) { + return Math.round(val).toLocaleString(); + } + if (options?.decimals !== undefined) { + return val.toFixed(options.decimals); + } + return val.toLocaleString(); + } + return "—"; + } + return val || "—"; + }; + + return createStatRow(label, safeValue, { + format, + className: options?.className, + showIfZero: options?.showIfZero, + }); +}; + +export const createDamageStatRow = ( + label: string, + value: number | undefined | null, + options?: { + round?: boolean; + showIfZero?: boolean; + }, +): StatRow => { + const safeValue = value ?? 0; + const className = safeValue > 0 ? "text-red-500" : "text-foreground"; + + return createNumberStatRow(label, safeValue, { + round: options?.round, + className, + showIfZero: options?.showIfZero, + }); +}; diff --git a/app/panel/components/Performance/usePerformance.ts b/app/panel/components/Performance/usePerformance.ts new file mode 100644 index 0000000..c3e349e --- /dev/null +++ b/app/panel/components/Performance/usePerformance.ts @@ -0,0 +1,838 @@ +import { useHourlyExp, useTrackedDataQuery, useFormatting, useUserStatsQuery } from "@app/hooks"; +import { useMemo, useState, useEffect } from "react"; +import type { CSVRow, EquipmentData, EquipmentItem } from "@app/types"; + +// Combat skills list +const COMBAT_SKILLS = ["Attack", "Defence", "Strength", "Health"]; + +export interface PerformanceStats { + maxHit: number; + avgHit: number; + meanHit: number; + modeHit: number; + avgHitsToKill: number; + maxHitByMonster: Record; + avgHitByMonster: Record; + meanHitByMonster: Record; + modeHitByMonster: Record; + avgHitsToKillByMonster: Record; + maxDamageReceived: number; + avgDamageReceived: number; + meanDamageReceived: number; + modeDamageReceived: number; + maxDamageReceivedByMonster: Record; + avgDamageReceivedByMonster: Record; + meanDamageReceivedByMonster: Record; + modeDamageReceivedByMonster: Record; + hpLostPerHour: number; + hpLostPer15Min: number; +} + +/** + * Main hook for Performance component + * Handles all logic, state, and data processing + */ +export const usePerformance = () => { + const hourlyExp = useHourlyExp(); + const { allData, clearByHour, loading } = useTrackedDataQuery(); + const { formatExp } = useFormatting(); + const { userStats } = useUserStatsQuery(); + const [selectedLocation, setSelectedLocation] = useState("all"); + const [selectedMonster, setSelectedMonster] = useState("all"); + + // Group all data by location + const dataByLocation = useMemo(() => { + const locationMap = new Map(); + const allLocations: CSVRow[] = []; + + allData.forEach(row => { + allLocations.push(row); + const location = row.location?.trim() || "Unknown"; + if (!locationMap.has(location)) { + locationMap.set(location, []); + } + locationMap.get(location)!.push(row); + }); + + // Add "All" location with all data + const result = new Map(); + result.set("all", allLocations); + + // Sort locations by name (excluding 'all') + const sortedLocations = Array.from(locationMap.entries()) + .filter(([loc]) => loc !== "all") + .sort(([a], [b]) => a.localeCompare(b)); + + sortedLocations.forEach(([location, rows]) => { + result.set(location, rows); + }); + + return result; + }, [allData]); + + // Get list of locations for tabs + const locations = useMemo(() => Array.from(dataByLocation.keys()), [dataByLocation]); + + // Set default selected location to first available (or 'all') + useEffect(() => { + if (locations.length > 0) { + if (selectedLocation === "all" && !locations.includes("all")) { + setSelectedLocation(locations[0]); + } else if (!locations.includes(selectedLocation)) { + setSelectedLocation(locations[0]); + } + } + }, [locations, selectedLocation]); + + // Group all data by monster + const dataByMonster = useMemo(() => { + const monsterMap = new Map(); + const allMonsters: CSVRow[] = []; + + allData.forEach(row => { + allMonsters.push(row); + const monster = row.monster?.trim() || "Unknown"; + if (!monsterMap.has(monster)) { + monsterMap.set(monster, []); + } + monsterMap.get(monster)!.push(row); + }); + + // Add "All" monster with all data + const result = new Map(); + result.set("all", allMonsters); + + // Sort monsters by name (excluding 'all') + const sortedMonsters = Array.from(monsterMap.entries()) + .filter(([mon]) => mon !== "all") + .sort(([a], [b]) => a.localeCompare(b)); + + sortedMonsters.forEach(([monster, rows]) => { + result.set(monster, rows); + }); + + return result; + }, [allData]); + + // Get list of monsters for filter + const monsters = useMemo(() => Array.from(dataByMonster.keys()), [dataByMonster]); + + // Set default selected monster to first available (or 'all') + useEffect(() => { + if (monsters.length > 0) { + if (selectedMonster === "all" && !monsters.includes("all")) { + setSelectedMonster(monsters[0]); + } else if (!monsters.includes(selectedMonster)) { + setSelectedMonster(monsters[0]); + } + } + }, [monsters, selectedMonster]); + + // Get data for selected location + const selectedLocationData = useMemo( + () => dataByLocation.get(selectedLocation) || [], + [dataByLocation, selectedLocation], + ); + + // Get data for selected monster + const selectedMonsterData = useMemo(() => dataByMonster.get(selectedMonster) || [], [dataByMonster, selectedMonster]); + + // Get data filtered by both location and monster + const filteredData = useMemo(() => { + const locationData = selectedLocationData; + const monsterData = selectedMonsterData; + + // If both are 'all', return all data + if (selectedLocation === "all" && selectedMonster === "all") { + return allData; + } + + // If location is 'all', use monster data + if (selectedLocation === "all") { + return monsterData; + } + + // If monster is 'all', use location data + if (selectedMonster === "all") { + return locationData; + } + + // Both are specific - find intersection + // Create a set of UUIDs from location data for fast lookup + const locationUUIDs = new Set(locationData.map(row => row.uuid || `${row.timestamp}-${row.skill}`)); + + // Filter monster data to only include rows that are also in location data + return monsterData.filter(row => { + const key = row.uuid || `${row.timestamp}-${row.skill}`; + return locationUUIDs.has(key); + }); + }, [selectedLocationData, selectedMonsterData, selectedLocation, selectedMonster, allData]); + + // Prepare display data - use saved gainedExp directly with deduplication + const displayData = useMemo(() => { + if (filteredData.length === 0) return []; + + // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) + const uniqueEntriesMap = new Map(); + + filteredData.forEach(row => { + const skill = row.skill || ""; + const key = `${row.timestamp}-${skill}`; + const existing = uniqueEntriesMap.get(key); + + if (!existing) { + uniqueEntriesMap.set(key, row); + } else { + // Keep the one with higher gainedExp or more complete data + const existingGainedExp = parseInt(existing.gainedExp || "0", 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || "0", 10) || 0; + if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { + uniqueEntriesMap.set(key, row); + } + } + }); + + // Process unique entries only + const uniqueEntries = Array.from(uniqueEntriesMap.values()); + const result: Array & { gainedExp: number }> = []; + + uniqueEntries.forEach(row => { + try { + // Use saved gainedExp directly (it's already calculated and saved) + const gainedExp = parseInt(row.gainedExp || "0", 10) || 0; + + // Only include entries with gainedExp > 0 (matches aggregateStats logic) + if (gainedExp > 0) { + // Ensure all CSVRow fields are present with defaults + result.push({ + timestamp: row.timestamp || "", + skill: row.skill || "", + skillLevel: row.skillLevel || "", + expForNextLevel: row.expForNextLevel || "", + drops: row.drops || "", + hp: row.hp || "", + monster: row.monster || "", + location: row.location || "", + damageDealt: row.damageDealt || "", + damageReceived: row.damageReceived || "", + peopleFighting: row.peopleFighting || "", + totalFights: row.totalFights || "", + totalInventoryHP: row.totalInventoryHP || "", + hpUsed: row.hpUsed || "", + equipment: row.equipment || "", + combatExp: row.combatExp || "", + gainedExp, + uuid: row.uuid || "", + }); + } + } catch { + // Skip invalid rows + } + }); + + // Sort by timestamp descending (most recent first) for display + return result.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + }, [filteredData]); + + // Calculate total gained exp for the hour (sum of all gained exp, not total exp) + // Only count combat skills for Performance tab + const totalGainedExpThisHour = useMemo( + () => displayData.filter(row => COMBAT_SKILLS.includes(row.skill)).reduce((sum, row) => sum + row.gainedExp, 0), + [displayData], + ); + + // Calculate current hour - must be a hook to maintain hook order + const currentHour = useMemo(() => { + try { + return hourlyExp?.currentHour ?? new Date().getHours(); + } catch { + return new Date().getHours(); + } + }, [hourlyExp?.currentHour]); + + // Calculate total gained exp formatted + const totalGainedExp = useMemo(() => { + try { + return formatExp(totalGainedExpThisHour); + } catch { + return "0"; + } + }, [totalGainedExpThisHour, formatExp]); + + // Calculate performance stats: max hit, avg hit, HP lost + const performanceStats = useMemo((): PerformanceStats => { + try { + if (!Array.isArray(filteredData) || filteredData.length === 0) { + return { + maxHit: 0, + avgHit: 0, + meanHit: 0, + modeHit: 0, + avgHitsToKill: 0, + maxHitByMonster: {}, + avgHitByMonster: {}, + meanHitByMonster: {}, + modeHitByMonster: {}, + avgHitsToKillByMonster: {}, + maxDamageReceived: 0, + avgDamageReceived: 0, + meanDamageReceived: 0, + modeDamageReceived: 0, + maxDamageReceivedByMonster: {}, + avgDamageReceivedByMonster: {}, + meanDamageReceivedByMonster: {}, + modeDamageReceivedByMonster: {}, + hpLostPerHour: 0, + hpLostPer15Min: 0, + }; + } + + // Deduplicate entries: one entry per timestamp+skill (keep the one with highest gainedExp or most complete data) + const uniqueEntriesMap = new Map(); + + filteredData.forEach(row => { + const skill = row.skill || ""; + const key = `${row.timestamp}-${skill}`; + const existing = uniqueEntriesMap.get(key); + + if (!existing) { + uniqueEntriesMap.set(key, row); + } else { + // Keep the one with higher gainedExp or more complete data + const existingGainedExp = parseInt(existing.gainedExp || "0", 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || "0", 10) || 0; + if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { + uniqueEntriesMap.set(key, row); + } + } + }); + + // Use unique entries only + const uniqueEntries = Array.from(uniqueEntriesMap.values()); + + const allDamageDealt: number[] = []; + const allDamageReceived: number[] = []; + const monsterStats: Record = {}; + + // Calculate time span for rate calculations (use earliest and latest timestamps) + const timestamps = uniqueEntries + .map(row => new Date(row.timestamp).getTime()) + .filter(ts => !isNaN(ts)) + .sort((a, b) => a - b); + + const earliestTime = timestamps.length > 0 ? timestamps[0] : Date.now(); + const latestTime = timestamps.length > 0 ? timestamps[timestamps.length - 1] : Date.now(); + // At least 1 minute to avoid division by zero + const elapsedMinutes = Math.max(1, (latestTime - earliestTime) / (1000 * 60)); + + uniqueEntries.forEach(row => { + try { + // Normalize monster name for consistent grouping (case-insensitive, trimmed) + const rawMonster = row.monster || "Unknown"; + const normalizedKey = rawMonster.trim().toLowerCase(); + const displayName = rawMonster.trim() || "Unknown"; + + // Parse damage dealt + const damageDealtStr = row.damageDealt || ""; + if (damageDealtStr) { + const damageValues = damageDealtStr + .split(";") + .map(d => d.trim()) + .filter(d => d.length > 0); + damageValues.forEach(damageStr => { + const damage = parseInt(String(damageStr).replace(/,/g, ""), 10); + if (!isNaN(damage) && damage >= 0) { + allDamageDealt.push(damage); + if (!monsterStats[normalizedKey]) { + monsterStats[normalizedKey] = { damage: [], received: [], displayName }; + } + monsterStats[normalizedKey].damage.push(damage); + } + }); + } + + // Parse damage received + const damageReceivedStr = row.damageReceived || ""; + if (damageReceivedStr) { + const receivedValues = damageReceivedStr + .split(";") + .map(d => d.trim()) + .filter(d => d.length > 0); + receivedValues.forEach(damageStr => { + const damage = parseInt(String(damageStr).replace(/,/g, ""), 10); + if (!isNaN(damage) && damage >= 0) { + allDamageReceived.push(damage); + if (!monsterStats[normalizedKey]) { + monsterStats[normalizedKey] = { damage: [], received: [], displayName }; + } + monsterStats[normalizedKey].received.push(damage); + } + }); + } + } catch { + // Skip invalid rows + } + }); + + // Calculate overall stats for damage dealt (by user) + const validHits = allDamageDealt.filter(d => d > 0); + const maxHit = validHits.length > 0 ? Math.max(...validHits) : 0; + const avgHit = validHits.length > 0 ? validHits.reduce((sum, d) => sum + d, 0) / validHits.length : 0; + const meanHit = avgHit; // Mean is the same as average + + // Calculate mode hit (most frequent hit value) + const hitFrequency: Record = {}; + validHits.forEach(hit => { + hitFrequency[hit] = (hitFrequency[hit] || 0) + 1; + }); + const modeHit = + Object.keys(hitFrequency).length > 0 + ? parseInt(Object.entries(hitFrequency).sort((a, b) => b[1] - a[1])[0][0], 10) + : 0; + + // Calculate avg hits to kill (average number of hits per fight) + // Each row with damageDealt represents a fight, count hits per fight + const hitsPerFight: number[] = []; + uniqueEntries.forEach(row => { + const damageDealtStr = row.damageDealt || ""; + if (damageDealtStr) { + const damageValues = damageDealtStr + .split(";") + .map(d => d.trim()) + .filter(d => d.length > 0) + .map(d => parseInt(String(d).replace(/,/g, ""), 10)) + .filter(d => !isNaN(d) && d > 0); + + if (damageValues.length > 0) { + hitsPerFight.push(damageValues.length); + } + } + }); + + const avgHitsToKill = + hitsPerFight.length > 0 ? hitsPerFight.reduce((sum, count) => sum + count, 0) / hitsPerFight.length : 0; + + // Calculate overall stats for damage received (by user) + const validReceived = allDamageReceived.filter(d => d > 0); + const maxDamageReceived = validReceived.length > 0 ? Math.max(...validReceived) : 0; + const avgDamageReceived = + validReceived.length > 0 ? validReceived.reduce((sum, d) => sum + d, 0) / validReceived.length : 0; + + // Calculate HP lost + const totalHPLost = allDamageReceived.reduce((sum, d) => sum + d, 0); + const hpLostPerHour = totalHPLost; + const hpLostPer15Min = (totalHPLost / elapsedMinutes) * 15; + + // Calculate per-monster stats for damage dealt + const maxHitByMonster: Record = {}; + const avgHitByMonster: Record = {}; + const meanHitByMonster: Record = {}; + const modeHitByMonster: Record = {}; + const avgHitsToKillByMonster: Record = {}; + + // Calculate per-monster stats for damage received + const maxDamageReceivedByMonster: Record = {}; + const avgDamageReceivedByMonster: Record = {}; + const meanDamageReceivedByMonster: Record = {}; + const modeDamageReceivedByMonster: Record = {}; + + // Track hits per fight per monster for avgHitsToKill calculation + const monsterHitsPerFight: Record = {}; + + // First pass: collect hits per fight for each monster + uniqueEntries.forEach(row => { + try { + const rawMonster = row.monster || "Unknown"; + const displayName = rawMonster.trim() || "Unknown"; + + const damageDealtStr = row.damageDealt || ""; + if (damageDealtStr) { + const damageValues = damageDealtStr + .split(";") + .map(d => d.trim()) + .filter(d => d.length > 0) + .map(d => parseInt(String(d).replace(/,/g, ""), 10)) + .filter(d => !isNaN(d) && d >= 0); + + if (damageValues.length > 0) { + if (!monsterHitsPerFight[displayName]) { + monsterHitsPerFight[displayName] = []; + } + monsterHitsPerFight[displayName].push(damageValues.length); + } + } + } catch { + // Skip invalid rows + } + }); + + Object.entries(monsterStats).forEach(([, stats]) => { + // Use display name for the key (preserves original casing/formatting) + const monsterDisplayName = stats.displayName; + + // Damage dealt stats - all hits for this monster are already aggregated in stats.damage + const validMonsterHits = stats.damage.filter(d => d > 0); + if (validMonsterHits.length > 0) { + // Calculate max from all accumulated hits for this monster + maxHitByMonster[monsterDisplayName] = Math.max(...validMonsterHits); + // Calculate average from all accumulated hits for this monster + const avg = validMonsterHits.reduce((sum, d) => sum + d, 0) / validMonsterHits.length; + avgHitByMonster[monsterDisplayName] = avg; + meanHitByMonster[monsterDisplayName] = avg; // Mean is the same as average + + // Calculate mode hit for this monster + const monsterHitFrequency: Record = {}; + validMonsterHits.forEach(hit => { + monsterHitFrequency[hit] = (monsterHitFrequency[hit] || 0) + 1; + }); + modeHitByMonster[monsterDisplayName] = + Object.keys(monsterHitFrequency).length > 0 + ? parseInt(Object.entries(monsterHitFrequency).sort((a, b) => b[1] - a[1])[0][0], 10) + : 0; + + // Calculate average hits to kill for this monster + const hitsPerFightForMonster = monsterHitsPerFight[monsterDisplayName] || []; + if (hitsPerFightForMonster.length > 0) { + avgHitsToKillByMonster[monsterDisplayName] = + hitsPerFightForMonster.reduce((sum, count) => sum + count, 0) / hitsPerFightForMonster.length; + } + } + + // Damage received stats - all received damage for this monster are already aggregated in stats.received + const validMonsterReceived = stats.received.filter(d => d > 0); + if (validMonsterReceived.length > 0) { + // Calculate max from all accumulated received damage for this monster + maxDamageReceivedByMonster[monsterDisplayName] = Math.max(...validMonsterReceived); + // Calculate average from all accumulated received damage for this monster + const avgReceived = validMonsterReceived.reduce((sum, d) => sum + d, 0) / validMonsterReceived.length; + avgDamageReceivedByMonster[monsterDisplayName] = avgReceived; + meanDamageReceivedByMonster[monsterDisplayName] = avgReceived; // Mean is the same as average + + // Calculate mode damage received for this monster + const monsterReceivedFrequency: Record = {}; + validMonsterReceived.forEach(hit => { + monsterReceivedFrequency[hit] = (monsterReceivedFrequency[hit] || 0) + 1; + }); + modeDamageReceivedByMonster[monsterDisplayName] = + Object.keys(monsterReceivedFrequency).length > 0 + ? parseInt(Object.entries(monsterReceivedFrequency).sort((a, b) => b[1] - a[1])[0][0], 10) + : 0; + } + }); + + return { + maxHit, + avgHit, + meanHit, + modeHit, + avgHitsToKill, + maxHitByMonster, + avgHitByMonster, + meanHitByMonster, + modeHitByMonster, + avgHitsToKillByMonster, + maxDamageReceived, + avgDamageReceived, + meanDamageReceived: avgDamageReceived, // Mean is the same as average + modeDamageReceived: (() => { + const receivedFrequency: Record = {}; + validReceived.forEach(hit => { + receivedFrequency[hit] = (receivedFrequency[hit] || 0) + 1; + }); + return Object.keys(receivedFrequency).length > 0 + ? parseInt(Object.entries(receivedFrequency).sort((a, b) => b[1] - a[1])[0][0], 10) + : 0; + })(), + maxDamageReceivedByMonster, + avgDamageReceivedByMonster, + meanDamageReceivedByMonster, + modeDamageReceivedByMonster, + hpLostPerHour, + hpLostPer15Min, + }; + } catch { + return { + maxHit: 0, + avgHit: 0, + meanHit: 0, + modeHit: 0, + avgHitsToKill: 0, + maxHitByMonster: {}, + avgHitByMonster: {}, + meanHitByMonster: {}, + modeHitByMonster: {}, + avgHitsToKillByMonster: {}, + maxDamageReceived: 0, + avgDamageReceived: 0, + meanDamageReceived: 0, + modeDamageReceived: 0, + maxDamageReceivedByMonster: {}, + avgDamageReceivedByMonster: {}, + meanDamageReceivedByMonster: {}, + modeDamageReceivedByMonster: {}, + hpLostPerHour: 0, + hpLostPer15Min: 0, + }; + } + }, [filteredData]); + + // Calculate location-specific stats (only for specific locations, not 'all') + const locationStats = useMemo(() => { + if (selectedLocation === "all" || !Array.isArray(filteredData) || filteredData.length === 0) { + return { + totalFights: 0, + avgDamagePer15Min: 0, + maxDamagePer15Min: 0, + avgHitsToKill: 0, + }; + } + + // Deduplicate entries + const uniqueEntriesMap = new Map(); + filteredData.forEach(row => { + const skill = row.skill || ""; + const key = `${row.timestamp}-${skill}`; + const existing = uniqueEntriesMap.get(key); + + if (!existing) { + uniqueEntriesMap.set(key, row); + } else { + const existingGainedExp = parseInt(existing.gainedExp || "0", 10) || 0; + const currentGainedExp = parseInt(row.gainedExp || "0", 10) || 0; + if (currentGainedExp > existingGainedExp || (currentGainedExp === existingGainedExp && row.skillLevel)) { + uniqueEntriesMap.set(key, row); + } + } + }); + + const uniqueEntries = Array.from(uniqueEntriesMap.values()); + + // Count total fights (entries with damageDealt) + // IMPORTANT: Deduplicate by UUID since each fight creates multiple rows (one per combat skill) + // All rows from the same fight share the same UUID + const fightUUIDs = new Set(); + uniqueEntries.forEach(row => { + if (row.damageDealt && row.damageDealt.trim().length > 0 && row.uuid) { + fightUUIDs.add(row.uuid); + } + }); + const totalFights = fightUUIDs.size; + + // Calculate total damage received from all tracked entries + let totalDamageReceived = 0; + const damageBy15Min: Record = {}; + const timestamps = uniqueEntries.map(row => new Date(row.timestamp).getTime()).filter(ts => !isNaN(ts)); + + if (timestamps.length === 0) { + return { + totalFights, + avgDamagePer15Min: 0, + maxDamagePer15Min: 0, + avgHitsToKill: 0, + }; + } + + const earliestTime = Math.min(...timestamps); + const latestTime = Math.max(...timestamps); + const elapsedMinutes = Math.max(1, (latestTime - earliestTime) / (1000 * 60)); + + // Sum all damage received values and track per 15-minute interval + uniqueEntries.forEach(row => { + const damageReceivedStr = row.damageReceived || ""; + if (damageReceivedStr) { + const receivedValues = damageReceivedStr + .split(";") + .map(d => d.trim()) + .filter(d => d.length > 0) + .map(d => parseInt(String(d).replace(/,/g, ""), 10)) + .filter(d => !isNaN(d) && d >= 0); + + if (receivedValues.length > 0) { + const rowDamage = receivedValues.reduce((sum, d) => sum + d, 0); + totalDamageReceived += rowDamage; + + // Track damage per 15-minute interval for max calculation + const rowTime = new Date(row.timestamp).getTime(); + const minutesFromStart = (rowTime - earliestTime) / (1000 * 60); + const interval15Min = Math.floor(minutesFromStart / 15); + + if (!damageBy15Min[interval15Min]) { + damageBy15Min[interval15Min] = 0; + } + damageBy15Min[interval15Min] += rowDamage; + } + } + }); + + // Calculate average damage per 15 minutes: total damage / (elapsed minutes / 15) + // This gives the average rate of damage per 15-minute period + const avgDamagePer15Min = elapsedMinutes > 0 ? (totalDamageReceived / elapsedMinutes) * 15 : 0; + + // Max damage per 15 minutes: maximum damage in any single 15-minute interval + const damagePer15MinValues = Object.values(damageBy15Min); + const maxDamagePer15Min = damagePer15MinValues.length > 0 ? Math.max(...damagePer15MinValues) : 0; + + // Calculate average equipment stats + // Note: Equipment data needs to be stored in CSVRow for this to work + // For now, we'll try to parse equipment from a JSON string if it exists in CSVRow + // This assumes equipment is stored as a JSON string in a future CSVRow field + let avgEquipment: { items: Record; totals: EquipmentData["totals"] } | undefined = undefined; + + // Try to collect equipment data (if it exists in CSVRow) + const equipmentEntries: EquipmentData[] = []; + uniqueEntries.forEach(row => { + if (row.equipment && row.equipment.trim().length > 0) { + try { + const equipment = JSON.parse(row.equipment) as EquipmentData; + if (equipment && typeof equipment === "object") { + equipmentEntries.push(equipment); + } + } catch { + // Invalid JSON, skip this equipment entry + } + } + }); + + if (equipmentEntries.length > 0) { + // Calculate average equipment + const slotCounts: Record = {}; + const slotSums: Record = {}; + const totalsSum = { armour: 0, aim: 0, power: 0, travelTime: 0 }; + let totalsCount = 0; + + equipmentEntries.forEach(eq => { + // Count equipment items by slot (exclude 'totals' which is not an EquipmentItem) + const slots: Array> = [ + "helm", + "shield", + "body", + "weapon", + "legs", + "gloves", + "boots", + "horse", + "trophy", + ]; + slots.forEach(slot => { + const item = eq[slot]; + if (item && "name" in item) { + // Type guard: ensure item is EquipmentItem (has 'name' property) + if (!slotCounts[slot]) { + slotCounts[slot] = 0; + slotSums[slot] = { name: item.name, imageUrl: item.imageUrl, enchant: item.enchant, count: 0 }; + } + slotCounts[slot]++; + // Use the most common item name/image/enchant for each slot + if (slotSums[slot].name === item.name) { + slotSums[slot].count++; + // Update enchant if this item has one and we don't have one yet + if (item.enchant && !slotSums[slot].enchant) { + slotSums[slot].enchant = item.enchant; + } + } + } + }); + + // Sum totals + if (eq.totals) { + if (eq.totals.armour !== undefined) totalsSum.armour += eq.totals.armour; + if (eq.totals.aim !== undefined) totalsSum.aim += eq.totals.aim; + if (eq.totals.power !== undefined) totalsSum.power += eq.totals.power; + if (eq.totals.travelTime !== undefined) totalsSum.travelTime += eq.totals.travelTime; + totalsCount++; + } + }); + + // Build average equipment items (use most common item per slot) + const avgItems: Record = {}; + Object.entries(slotSums).forEach(([slot, data]) => { + if (data.count > 0) { + avgItems[slot] = { + slot, + name: data.name, + imageUrl: data.imageUrl, + enchant: data.enchant, + }; + } + }); + + // Calculate average totals + const avgTotals: EquipmentData["totals"] = {}; + if (totalsCount > 0) { + if (totalsSum.armour > 0) avgTotals.armour = totalsSum.armour / totalsCount; + if (totalsSum.aim > 0) avgTotals.aim = totalsSum.aim / totalsCount; + if (totalsSum.power > 0) avgTotals.power = totalsSum.power / totalsCount; + if (totalsSum.travelTime > 0) avgTotals.travelTime = totalsSum.travelTime / totalsCount; + } + + avgEquipment = { items: avgItems, totals: avgTotals }; + } + + // Calculate average hits to kill for this location + const hitsPerFightForLocation: number[] = []; + uniqueEntries.forEach(row => { + const damageDealtStr = row.damageDealt || ""; + if (damageDealtStr) { + const damageValues = damageDealtStr + .split(";") + .map(d => d.trim()) + .filter(d => d.length > 0) + .map(d => parseInt(String(d).replace(/,/g, ""), 10)) + .filter(d => !isNaN(d) && d >= 0); + + if (damageValues.length > 0) { + hitsPerFightForLocation.push(damageValues.length); + } + } + }); + + const avgHitsToKill = + hitsPerFightForLocation.length > 0 + ? hitsPerFightForLocation.reduce((sum, count) => sum + count, 0) / hitsPerFightForLocation.length + : 0; + + return { + totalFights, + avgDamagePer15Min, + maxDamagePer15Min, + avgEquipment, + avgHitsToKill, + }; + }, [selectedLocation, filteredData]); + + const handleClearCurrentHour = async () => { + if ( + confirm( + `Are you sure you want to clear all tracked data for hour ${currentHour}:00? This action cannot be undone.`, + ) + ) { + try { + await clearByHour(currentHour); + alert(`Data for hour ${currentHour}:00 cleared successfully!`); + } catch { + alert("Error clearing data for current hour"); + } + } + }; + + return { + // State + selectedLocation, + setSelectedLocation, + selectedMonster, + setSelectedMonster, + loading, + userStats, + + // Data + locations, + monsters, + totalGainedExp, + performanceStats, + locationStats, + currentHour, + + // Handlers + handleClearCurrentHour, + }; +}; diff --git a/pages/side-panel/src/components/Profile/index.tsx b/app/panel/components/Profile/index.tsx similarity index 85% rename from pages/side-panel/src/components/Profile/index.tsx rename to app/panel/components/Profile/index.tsx index 3c643e7..09e7c8a 100644 --- a/pages/side-panel/src/components/Profile/index.tsx +++ b/app/panel/components/Profile/index.tsx @@ -1,7 +1,7 @@ -import ProfileCard from '../ProfileCard'; -import { useTrackedDataQuery, useScreenData, useFormatting, useHourlyExp } from '@extension/shared'; -import { cn, Card, CardContent, Badge } from '@extension/ui'; -import { memo, useMemo, useState } from 'react'; +import ProfileCard from "../ProfileCard"; +import { cn, Card, CardContent, Badge } from "@app/components"; +import { useTrackedDataQuery, useScreenData, useFormatting, useHourlyExp } from "@app/hooks"; +import { memo, useMemo, useState } from "react"; /** * Normalize skill name for image URL @@ -34,22 +34,22 @@ const SkillCardWrapper = memo( const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); const skillInfo = getSkillInfo(skill); - const expForNextLevel = formatExp(skillInfo.expForNextLevel || '0'); + const expForNextLevel = formatExp(skillInfo.expForNextLevel || "0"); return ( - +
{`${skill}${level 0 ? ` - Level ${level}` : ''}`} + alt={`${skill}${level > 0 ? ` - Level ${level}` : ""}`} className="h-10 w-10 rounded object-cover" onLoad={() => setImageLoaded(true)} onError={e => { setImageError(true); // Hide image if it fails to load - (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).style.display = "none"; }} /> {level > 0 && {level}} @@ -58,9 +58,7 @@ const SkillCardWrapper = memo( {(!imageLoaded || imageError) && {skill}} +{formatExp(stats.gainedExp)} {isCurrentSkill && ( - + Current )} @@ -71,7 +69,7 @@ const SkillCardWrapper = memo( Current Exp: +{formatExp(stats.gainedExp)}
- {expForNextLevel !== '0' && ( + {expForNextLevel !== "0" && (
Exp Left: {expForNextLevel} @@ -83,7 +81,7 @@ const SkillCardWrapper = memo( ); }, ); -SkillCardWrapper.displayName = 'SkillCardWrapper'; +SkillCardWrapper.displayName = "SkillCardWrapper"; /** * Profile component - displays user profile information @@ -108,8 +106,7 @@ const Profile = memo(() => { return []; } return [...data].sort((a, b) => new Date(a?.timestamp || 0).getTime() - new Date(b?.timestamp || 0).getTime()); - } catch (error) { - console.error('[Profile] Error processing current hour data:', error); + } catch { return []; } }, [dataByHour, currentHour, now]); @@ -130,17 +127,17 @@ const Profile = memo(() => { Object.entries(hourlyExp?.expBySkill || {}).forEach(([skill, exp]) => { stats[skill] = { gainedExp: exp || 0, - lastUpdate: '', - skillLevel: '', - expForNextLevel: '', + lastUpdate: "", + skillLevel: "", + expForNextLevel: "", }; }); // Get level and expForNextLevel from current hour data currentHourData.forEach(row => { - const skill = row.skill || 'Unknown'; - const skillLevel = row.skillLevel || ''; - const expForNextLevel = row.expForNextLevel || ''; + const skill = row.skill || "Unknown"; + const skillLevel = row.skillLevel || ""; + const expForNextLevel = row.expForNextLevel || ""; if (!stats[skill]) { stats[skill] = { @@ -169,14 +166,14 @@ const Profile = memo(() => { // Determine the current skill const currentSkill = useMemo(() => { - const screenSkill = screenData?.actionText.currentActionText || ''; + const screenSkill = screenData?.actionText.currentActionText || ""; const mostRecentEntry = currentHourData .filter(row => { - const skill = row.skill || ''; + const skill = row.skill || ""; return (hourlyExp?.expBySkill[skill] || 0) > 0; }) .sort((a, b) => new Date(b?.timestamp || 0).getTime() - new Date(a?.timestamp || 0).getTime())[0]; - return screenSkill || mostRecentEntry?.skill || ''; + return screenSkill || mostRecentEntry?.skill || ""; }, [screenData, currentHourData, hourlyExp?.expBySkill]); // Get level and expForNextLevel from screen data if available, otherwise use tracked data @@ -186,14 +183,14 @@ const Profile = memo(() => { if (isCurrentSkill && screenData?.actionText.currentActionText === skill) { return { - level: screenData?.actionText.skillLevel || trackedInfo?.skillLevel || '', - expForNextLevel: screenData?.actionText.expForNextLevel || trackedInfo?.expForNextLevel || '', + level: screenData?.actionText.skillLevel || trackedInfo?.skillLevel || "", + expForNextLevel: screenData?.actionText.expForNextLevel || trackedInfo?.expForNextLevel || "", }; } return { - level: trackedInfo?.skillLevel || '', - expForNextLevel: trackedInfo?.expForNextLevel || '', + level: trackedInfo?.skillLevel || "", + expForNextLevel: trackedInfo?.expForNextLevel || "", }; }; @@ -205,7 +202,7 @@ const Profile = memo(() => { if (loading) { return (
-
Loading tracked data...
+
Loading tracked data...
); @@ -241,6 +238,6 @@ const Profile = memo(() => { ); }); -Profile.displayName = 'Profile'; +Profile.displayName = "Profile"; export default Profile; diff --git a/pages/side-panel/src/components/ProfileCard/index.tsx b/app/panel/components/ProfileCard/index.tsx similarity index 85% rename from pages/side-panel/src/components/ProfileCard/index.tsx rename to app/panel/components/ProfileCard/index.tsx index 4b539db..ce17869 100644 --- a/pages/side-panel/src/components/ProfileCard/index.tsx +++ b/app/panel/components/ProfileCard/index.tsx @@ -1,8 +1,8 @@ -import { useUserStatsQuery, useFormatting, useHourStats, useHourlyExp } from '@extension/shared'; -import { cn, Card, CardContent, Button, Progress } from '@extension/ui'; -import { useCallback, useMemo, useState } from 'react'; +import { cn, Card, CardContent, Button, Progress } from "@app/components"; +import { useUserStatsQuery, useFormatting, useHourStats, useHourlyExp } from "@app/hooks"; +import { useCallback, useMemo, useState } from "react"; -const STATS_PAGE_URL = 'https://www.syrnia.com/theGame/includes2/stats.php'; +const STATS_PAGE_URL = "https://www.syrnia.com/theGame/includes2/stats.php"; /** * Normalize skill name for image URL @@ -49,13 +49,13 @@ const SkillCard = ({
{`${skill}${level 0 ? ` - Level ${level}` : ''}`} + alt={`${skill}${level > 0 ? ` - Level ${level}` : ""}`} className="h-10 w-10 flex-shrink-0 rounded object-cover" onLoad={() => setImageLoaded(true)} onError={e => { setImageError(true); // Hide image if it fails to load - (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).style.display = "none"; }} /> {level > 0 && {level}} @@ -89,8 +89,8 @@ const SkillCard = ({
{isExpanded && (
@@ -133,13 +133,13 @@ const ProfileCard = () => { }, []); if (loading) { - return
Loading profile data...
; + return
Loading profile data...
; } if (!userStats) { return ( -
-

+

+

Open player stats in new tab, after data displays feel free to close.