feat(v2): Quest Status + Player Dashboard as React windows

Quest Status window (📜 Quests in sidebar):
- Fetches GET /quest-status API (polls every 30s)
- Grid: characters as rows × all unique quests as columns
- "READY" shown in green, countdowns in yellow, missing as dash
- Quest names shortened (removes "Timer", "Pickup" suffixes)
- Sticky header row, scrollable body
- Replaces broken quest-status.html link

Player Dashboard window (👥 Dashboard in sidebar):
- Sortable table of all online characters
- Columns: Character, State, KPH, Session kills, Total kills,
  Rares (total + session), Deaths, Uptime, HP%, Tapers
- Click column headers to sort (ascending/descending toggle)
- State badges: green=combat/hunt, red=other, gray=idle
- KPH in green, rares in gold, deaths in red (if > 0)
- HP% color-coded: green >80%, yellow >40%, red below

Sidebar changes:
- Removed broken /quest-status.html external link
- Added 👥 Dashboard + 📜 Quests as window opener buttons
- Both lazy-loaded (only fetched when first opened)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 14:02:00 +02:00
parent 27caa21a56
commit 938421999a
31 changed files with 345 additions and 21 deletions

View file

@ -85,7 +85,6 @@ export const Sidebar: React.FC<Props> = ({
<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
<a href="/suitbuilder.html" target="_blank" className="ml-tool-link">🛡 Suitbuilder</a>
<a href="/debug.html" target="_blank" className="ml-tool-link">🐛 Debug</a>
<a href="/quest-status.html" target="_blank" className="ml-tool-link">📜 Quests</a>
</div>
<SidebarWindowButtons />

View file

@ -6,6 +6,10 @@ export const SidebarWindowButtons: React.FC = () => {
return (
<div className="ml-tool-links">
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('playerdash', 'Player Dashboard')}>👥 Dashboard</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('queststatus', 'Quest Status')}>📜 Quests</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('issues', 'Issues Board')}>📋 Issues</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}

View file

@ -0,0 +1,113 @@
import React, { useState, useMemo } from 'react';
import { DraggableWindow } from './DraggableWindow';
import type { CharacterState } from '../../types';
interface Props { id: string; zIndex: number; characters: Map<string, CharacterState>; }
type SortCol = 'name' | 'kills' | 'kph' | 'rares' | 'deaths' | 'uptime' | 'state';
export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters }) => {
const [sortCol, setSortCol] = useState<SortCol>('kph');
const [sortAsc, setSortAsc] = useState(false);
const players = useMemo(() => {
const list = Array.from(characters.values()).filter(c => c.telemetry).map(c => {
const t = c.telemetry!;
return {
name: c.name,
kills: t.kills ?? 0,
kph: parseInt(t.kills_per_hour) || 0,
totalKills: t.total_kills ?? 0,
rares: t.total_rares ?? 0,
sessionRares: t.session_rares ?? 0,
deaths: parseInt(t.deaths as string) || 0,
totalDeaths: parseInt(t.total_deaths as string) || 0,
uptime: t.onlinetime?.replace(/^00\./, '') ?? '',
state: t.vt_state ?? 'idle',
tapers: parseInt(t.prismatic_taper_count as string) || 0,
hp: c.vitals?.health_percentage ?? 0,
};
});
list.sort((a, b) => {
let cmp = 0;
switch (sortCol) {
case 'name': cmp = a.name.localeCompare(b.name); break;
case 'kills': cmp = a.kills - b.kills; break;
case 'kph': cmp = a.kph - b.kph; break;
case 'rares': cmp = a.rares - b.rares; break;
case 'deaths': cmp = a.totalDeaths - b.totalDeaths; break;
case 'uptime': cmp = a.uptime.localeCompare(b.uptime); break;
case 'state': cmp = a.state.localeCompare(b.state); break;
}
return sortAsc ? cmp : -cmp;
});
return list;
}, [characters, sortCol, sortAsc]);
const toggleSort = (col: SortCol) => {
if (sortCol === col) setSortAsc(!sortAsc);
else { setSortCol(col); setSortAsc(false); }
};
const thStyle = (col: SortCol): React.CSSProperties => ({
padding: '4px 6px', cursor: 'pointer', userSelect: 'none',
color: sortCol === col ? '#6af' : '#888',
fontSize: '0.65rem', fontWeight: 600, whiteSpace: 'nowrap',
borderBottom: '1px solid #444',
});
const arrow = (col: SortCol) => sortCol === col ? (sortAsc ? ' ▲' : ' ▼') : '';
return (
<DraggableWindow id={id} title="Player Dashboard" zIndex={zIndex} width={850} height={500}>
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.73rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<th style={{ ...thStyle('name'), textAlign: 'left' }} onClick={() => toggleSort('name')}>Character{arrow('name')}</th>
<th style={{ ...thStyle('state'), textAlign: 'center' }} onClick={() => toggleSort('state')}>State{arrow('state')}</th>
<th style={{ ...thStyle('kph'), textAlign: 'right' }} onClick={() => toggleSort('kph')}>KPH{arrow('kph')}</th>
<th style={{ ...thStyle('kills'), textAlign: 'right' }} onClick={() => toggleSort('kills')}>Session{arrow('kills')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Total</th>
<th style={{ ...thStyle('rares'), textAlign: 'right' }} onClick={() => toggleSort('rares')}>Rares{arrow('rares')}</th>
<th style={{ ...thStyle('deaths'), textAlign: 'right' }} onClick={() => toggleSort('deaths')}>Deaths{arrow('deaths')}</th>
<th style={{ ...thStyle('uptime'), textAlign: 'right' }} onClick={() => toggleSort('uptime')}>Uptime{arrow('uptime')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>HP%</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Tapers</th>
</tr>
</thead>
<tbody>
{players.map(p => {
const stateLC = p.state.toLowerCase();
const isActive = stateLC === 'combat' || stateLC === 'hunt';
return (
<tr key={p.name} style={{ borderBottom: '1px solid #1a1a1a' }}>
<td style={{ padding: '3px 6px', color: '#ccc', fontWeight: 500, maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ textAlign: 'center', padding: '3px 6px' }}>
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
background: isActive ? 'rgba(68,204,68,0.15)' : stateLC === 'idle' || stateLC === 'default' ? 'rgba(100,100,100,0.2)' : 'rgba(204,68,68,0.15)',
color: isActive ? '#4c4' : stateLC === 'idle' || stateLC === 'default' ? '#888' : '#c44',
}}>{p.state}</span>
</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#4c4', fontVariantNumeric: 'tabular-nums' }}>{p.kph.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#ccc', fontVariantNumeric: 'tabular-nums' }}>{p.kills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.totalKills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#fc0', fontVariantNumeric: 'tabular-nums' }}>{p.rares}{p.sessionRares > 0 ? ` (${p.sessionRares})` : ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: p.totalDeaths > 0 ? '#c66' : '#555', fontVariantNumeric: 'tabular-nums' }}>{p.totalDeaths}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.uptime}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums',
color: p.hp > 80 ? '#4c4' : p.hp > 40 ? '#ca0' : '#c44' }}>{p.hp.toFixed(0)}%</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.tapers.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
{players.length === 0 && (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No characters online</div>
)}
</div>
</DraggableWindow>
);
};

View file

@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; zIndex: number; }
interface QuestData {
quest_data: Record<string, Record<string, string>>;
tracked_quests: string[];
player_count: number;
}
export const QuestStatusWindow: React.FC<Props> = ({ id, zIndex }) => {
const [data, setData] = useState<QuestData | null>(null);
useEffect(() => {
const fetch = async () => {
try { setData(await apiFetch<QuestData>('/quest-status')); } catch {}
};
fetch();
const iv = setInterval(fetch, 30000);
return () => clearInterval(iv);
}, []);
const characters = data ? Object.keys(data.quest_data).sort() : [];
// Collect ALL unique quest names across all characters
const allQuests = new Set<string>();
if (data) {
for (const quests of Object.values(data.quest_data)) {
for (const q of Object.keys(quests)) allQuests.add(q);
}
}
const questNames = Array.from(allQuests).sort();
return (
<DraggableWindow id={id} title="Quest Status" zIndex={zIndex} width={780} height={500}>
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.72rem' }}>
{!data ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>Loading quest data...</div>
) : characters.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No quest data available</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<th style={{ textAlign: 'left', padding: '4px 8px', borderBottom: '1px solid #444', color: '#888', fontSize: '0.65rem', fontWeight: 600, minWidth: 140 }}>Character</th>
{questNames.map(q => (
<th key={q} style={{ textAlign: 'center', padding: '4px 6px', borderBottom: '1px solid #444', color: '#888', fontSize: '0.6rem', fontWeight: 600, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={q}>
{q.replace(' Timer', '').replace(' Pickup', '')}
</th>
))}
</tr>
</thead>
<tbody>
{characters.map(char => {
const quests = data.quest_data[char] || {};
return (
<tr key={char} style={{ borderBottom: '1px solid #222' }}>
<td style={{ padding: '3px 8px', color: '#ccc', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }}>{char}</td>
{questNames.map(q => {
const val = quests[q];
const isReady = val === 'READY';
return (
<td key={q} style={{
textAlign: 'center', padding: '3px 6px',
color: isReady ? '#4c4' : val ? '#ca0' : '#333',
fontWeight: isReady ? 600 : 400,
fontSize: isReady ? '0.7rem' : '0.68rem',
}}>
{val || '\u2014'}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</DraggableWindow>
);
};

View file

@ -9,6 +9,8 @@ const CombatStatsWindow = lazy(() => import('./CombatStatsWindow').then(m => ({
const CombatPickerWindow = lazy(() => import('./CombatPickerWindow').then(m => ({ default: m.CombatPickerWindow })));
const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m.IssuesWindow })));
const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
import type { CharacterState } from '../../types';
interface Props {
@ -52,6 +54,10 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
return <IssuesWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'vitalsharing':
return <VitalSharingWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'queststatus':
return <QuestStatusWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'playerdash':
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
default:
return null;
}