fix: fixed activity aggregation and new user tracking (#230)

This commit is contained in:
guarzo
2025-03-11 09:20:10 -06:00
committed by GitHub
parent 2bb45b312c
commit 1590c848c9
18 changed files with 820 additions and 822 deletions

View File

@@ -1,208 +1,82 @@
/* Character Activity Component Styles */
.characterActivity {
&Dialog {
background-color: var(--surface-card);
color: var(--text-color);
width: 80vw;
max-width: 650px;
}
/* Set a fixed height for the container so scrolling works properly */
&Container {
width: 100%;
height: 400px; // fixed height to match the DataTable's scrollHeight
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
margin: 0;
}
}
:global {
.p-dialog-content {
padding: 0;
background-color: var(--surface-card);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
max-height: 60vh;
overflow: hidden;
.p-datatable .p-datatable-thead > tr > th {
background-color: var(--surface-ground);
padding: 0.5rem;
text-align: center;
white-space: normal;
overflow: visible;
height: auto;
}
.p-datatable .p-datatable-tbody > tr > td {
padding: 0.25rem 0.5rem;
}
.p-datatable {
width: 100%;
border: none;
}
.p-datatable-wrapper {
border-radius: 0;
overflow: auto !important;
padding: 0;
margin: 0;
height: 100%;
max-height: 100%;
border: none;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--surface-section);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--surface-border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--text-color-secondary);
}
}
.p-datatable-table {
width: 100% !important;
table-layout: fixed !important;
border-collapse: collapse;
margin: 0;
padding: 0;
}
/* Empty message styling */
.p-datatable-emptymessage td {
text-align: center;
padding: 2rem;
color: var(--text-color-secondary);
font-style: italic;
border: none !important;
}
}
/* Character info styles */
.characterInfo {
.spinnerContainer {
width: 50px;
height: 50px;
}
.columnHeader {
text-align: center;
font-weight: 600;
font-size: 0.75rem; /* text-xs */
white-space: normal !important;
overflow: visible !important;
}
.numericColumnHeader {
padding: 0.5rem !important;
}
.dataTable {
width: 100%;
border: none;
}
.cellContent {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
overflow: hidden;
padding: 0.25rem 0;
}
.characterPortrait {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.characterNameContainer {
.characterInfo {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
min-width: 0;
width: 100%;
}
.characterName {
display: flex;
font-weight: 500;
width: 100%;
overflow: hidden;
font-size: 12px;
line-height: 1.333;
white-space: nowrap;
text-overflow: ellipsis;
gap: 0.5rem;
}
.nameText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
display: inline;
max-width: 180px;
width: 100%;
}
.corporationTicker {
color: var(--text-color-secondary);
opacity: 0.8;
display: inline;
font-size: 0.75rem;
.characterTicker {
white-space: nowrap;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
}
.allianceTicker {
color: var(--text-color-secondary);
font-size: 0.75rem;
}
/* Cell styling */
.characterNameCell {
display: flex;
align-items: center;
padding: 2px;
width: 100%;
overflow: hidden;
}
.activityValueCell {
.numericValueCell {
text-align: center;
font-size: 0.75rem; /* text-xs */
font-weight: 500;
font-size: 12px;
line-height: 1.333;
font-variant-numeric: tabular-nums;
}
/* Column styles */
.characterColumn {
min-width: 200px;
width: 40%;
}
.numericColumn {
width: 20%;
text-align: center;
white-space: nowrap;
}
/* Empty state message */
.emptyMessage {
padding: 2rem;
text-align: center;
color: var(--text-color-secondary);
font-style: italic;
}
/* Loading container */
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
width: 100%;
}
.loadingText {
margin-top: 1rem;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
/* Error message styling */
.errorMessage {
padding: 2rem;
text-align: center;
color: var(--red-500);
}
/* Table row compact style */
.tableRowCompact {
font-size: 12px !important;
line-height: 1.333 !important;
}

View File

@@ -2,13 +2,10 @@ import { useState, useEffect, useMemo } from 'react';
import { Dialog } from 'primereact/dialog';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import classes from './CharacterActivity.module.scss';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { ProgressSpinner } from 'primereact/progressspinner';
import classes from './CharacterActivity.module.scss';
/**
* Summary of a character's activity
*/
export interface ActivitySummary {
character_id: string;
character_name: string;
@@ -28,29 +25,26 @@ interface CharacterActivityProps {
onHide: () => void;
}
const getRowClassName = () => [classes.tableRowCompact, 'p-selectable-row'];
const getRowClassName = () => ['text-xs leading-tight', 'p-selectable-row'];
const renderCharacterTemplate = (rowData: ActivitySummary) => {
const displayName = rowData.is_user ? rowData.user_name : rowData.character_name;
const ticker = rowData.corporation_ticker;
const allianceTicker = rowData.alliance_ticker ? `[${rowData.alliance_ticker}]` : '';
return (
<div className={classes.characterNameCell}>
<div className={classes.cellContent}>
<div className="w-6 h-6 rounded-full overflow-hidden flex-shrink-0 mr-3">
<img src={rowData.portrait_url} alt={displayName} className="w-full h-full object-cover" />
</div>
<div className={classes.characterInfo}>
<div className={classes.characterPortrait}>
<img src={rowData.portrait_url} alt={rowData.character_name} className="w-full h-full object-cover" />
<div className={classes.characterName}>
<span className="font-medium text-text-color">{displayName}</span>
</div>
<div className={classes.characterNameContainer}>
<div className={classes.characterName}>
{rowData.is_user ? (
<>
<span className={classes.nameText}>{rowData.user_name}</span>
<span className={classes.corporationTicker}>[{rowData.corporation_ticker}]</span>
</>
) : (
<>
<span className={classes.nameText}>{rowData.character_name}</span>
<span className={classes.corporationTicker}>[{rowData.corporation_ticker}]</span>
</>
)}
</div>
<div className={classes.characterTicker}>
<span className="text-text-color-secondary text-xs">
[{ticker}] {allianceTicker}
</span>
</div>
</div>
</div>
@@ -58,19 +52,15 @@ const renderCharacterTemplate = (rowData: ActivitySummary) => {
};
const renderValueTemplate = (rowData: ActivitySummary, field: keyof ActivitySummary) => {
return <div className={classes.activityValueCell}>{rowData[field] as number}</div>;
return <div className={`${classes.numericValueCell} tabular-nums`}>{rowData[field] as number}</div>;
};
/**
* Component that displays character activity in a dialog.
*/
export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) => {
const { data } = useMapRootState();
const { characterActivityData } = data;
const [localActivity, setLocalActivity] = useState<ActivitySummary[]>([]);
const [loading, setLoading] = useState(true);
// Use the new structure directly
const activity = useMemo(() => {
return characterActivityData?.activity || [];
}, [characterActivityData]);
@@ -80,77 +70,93 @@ export const CharacterActivity = ({ visible, onHide }: CharacterActivityProps) =
setLoading(characterActivityData?.loading !== false);
}, [activity, characterActivityData]);
const renderContent = () => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-[400px] w-full">
<ProgressSpinner className={classes.spinnerContainer} strokeWidth="4" />
<div className="mt-4 text-text-color-secondary text-sm">Loading character activity data...</div>
</div>
);
}
if (localActivity.length === 0) {
return (
<div className="p-8 text-center text-text-color-secondary italic">No character activity data available</div>
);
}
return (
<DataTable
value={localActivity}
scrollable
scrollHeight="400px"
resizableColumns
columnResizeMode="fit"
className="w-full"
tableClassName={classes.dataTable}
emptyMessage="No character activity data available"
sortField="passages"
sortOrder={-1}
responsiveLayout="scroll"
size="small"
rowClassName={getRowClassName}
rowHover
>
<Column
field="character_name"
header="Character"
body={renderCharacterTemplate}
sortable
headerStyle={{ minWidth: '75px', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ minWidth: '75px' }}
className={classes.characterColumn}
headerClassName={`${classes.columnHeader} ${classes.characterHeader}`}
/>
<Column
field="passages"
header="Passages"
body={rowData => renderValueTemplate(rowData, 'passages')}
sortable
headerStyle={{ width: '120px', textAlign: 'center', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ width: '120px', textAlign: 'center' }}
className={classes.numericColumn}
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
/>
<Column
field="connections"
header="Connections"
body={rowData => renderValueTemplate(rowData, 'connections')}
sortable
headerStyle={{ width: '120px', textAlign: 'center', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ width: '120px', textAlign: 'center' }}
className={classes.numericColumn}
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
/>
<Column
field="signatures"
header="Signatures"
body={rowData => renderValueTemplate(rowData, 'signatures')}
sortable
headerStyle={{ width: '120px', textAlign: 'center', padding: '0.5rem', height: 'auto', overflow: 'visible' }}
bodyStyle={{ width: '120px', textAlign: 'center' }}
className={classes.numericColumn}
headerClassName={`${classes.columnHeader} ${classes.numericColumnHeader}`}
/>
</DataTable>
);
};
return (
<Dialog
header="Character Activity"
visible={visible}
className={classes.characterActivityDialog}
className="bg-surface-card text-text-color w-11/12 max-w-[600px]"
onHide={onHide}
dismissableMask
draggable={false}
resizable={false}
closable
modal={true}
>
<div className={classes.characterActivityContainer}>
{loading && (
<div className={classes.loadingContainer}>
<ProgressSpinner style={{ width: '50px', height: '50px' }} strokeWidth="4" />
<div className={classes.loadingText}>Loading character activity data...</div>
</div>
)}
{!loading && localActivity.length === 0 && (
<div className={classes.emptyMessage}>No character activity data available</div>
)}
{!loading && localActivity.length > 0 && (
<DataTable
value={localActivity}
className={classes.characterActivityDatatable}
scrollable
scrollHeight="400px"
emptyMessage="No character activity data available"
sortField="passages"
sortOrder={-1}
responsiveLayout="scroll"
size="small"
rowClassName={getRowClassName}
rowHover
>
<Column
field="character_name"
header="Character"
body={renderCharacterTemplate}
sortable
className={classes.characterColumn}
headerClassName={`${classes.headerCharacter} text-xs`}
/>
<Column
field="passages"
header="Passages"
body={rowData => renderValueTemplate(rowData, 'passages')}
sortable
className={classes.numericColumn}
headerClassName={`${classes.headerStandard} text-xs`}
/>
<Column
field="connections"
header="Connections"
body={rowData => renderValueTemplate(rowData, 'connections')}
sortable
className={classes.numericColumn}
headerClassName={`${classes.headerStandard} text-xs`}
/>
<Column
field="signatures"
header="Signatures"
body={rowData => renderValueTemplate(rowData, 'signatures')}
sortable
className={classes.numericColumn}
headerClassName={`${classes.headerStandard} text-xs`}
/>
</DataTable>
)}
</div>
<div className="w-full h-[400px] flex flex-col overflow-hidden p-0 m-0">{renderContent()}</div>
</Dialog>
);
};

View File

@@ -1,40 +1,3 @@
/* TrackAndFollow Dialog Styles */
.trackFollowDialog {
width: 500px;
:global {
/* Dialog content */
.p-dialog-content {
padding: 0;
max-height: 70vh;
overflow: hidden;
}
}
}
/* Character grid styles */
.characterGrid {
display: grid;
grid-template-columns: 1fr;
width: 100%;
border: 1px solid var(--surface-border);
overflow: hidden;
}
.characterGridHeader {
display: grid;
grid-template-columns: 80px 80px 1fr;
background-color: var(--surface-section);
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color);
padding: 2px;
text-align: center;
}
.characterGridBody {
display: grid;
grid-template-columns: 1fr;
width: 100%;
}
.trackFollowHeader {
background-color: var(--surface-ground);
}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { Dialog } from 'primereact/dialog';
import { VirtualScroller } from 'primereact/virtualscroller';
import classes from './TrackAndFollow.module.scss';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
import { TrackingCharacterWrapper } from './TrackingCharacterWrapper';
import { TrackingCharacter } from './types';
import classes from './TrackAndFollow.module.scss';
interface TrackAndFollowProps {
visible: boolean;
@@ -28,30 +28,30 @@ export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
const characters = useMemo(() => trackingCharactersData || [], [trackingCharactersData]);
useEffect(() => {
if (!visible || characters.length === 0) {
return;
}
const tracked = characters.filter(char => char.tracked).map(char => char.id);
setTrackedCharacters(tracked);
if (trackingCharactersData) {
const newTrackedCharacters = trackingCharactersData
.filter(character => character.tracked)
.map(character => character.id);
const followed = characters.find(char => char.followed);
setFollowedCharacter(followed ? followed.id : null);
}, [visible, characters]);
setTrackedCharacters(newTrackedCharacters);
const followedChar = trackingCharactersData.find(character => character.followed);
if (followedChar?.id !== followedCharacter) {
setFollowedCharacter(followedChar?.id || null);
}
}
}, [followedCharacter, trackingCharactersData]);
const handleTrackToggle = (characterId: string) => {
setTrackedCharacters(prev => {
if (!prev.includes(characterId)) {
return [...prev, characterId];
}
if (followedCharacter === characterId) {
setFollowedCharacter(null);
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterId },
});
}
return prev.filter(id => id !== characterId);
});
const isCurrentlyTracked = trackedCharacters.includes(characterId);
if (isCurrentlyTracked) {
setTrackedCharacters(prev => prev.filter(id => id !== characterId));
} else {
setTrackedCharacters(prev => [...prev, characterId]);
}
outCommand({
type: OutCommand.toggleTrack,
data: { 'character-id': characterId },
@@ -59,14 +59,31 @@ export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
};
const handleFollowToggle = (characterId: string) => {
if (followedCharacter !== characterId && !trackedCharacters.includes(characterId)) {
const isCurrentlyFollowed = followedCharacter === characterId;
const isCurrentlyTracked = trackedCharacters.includes(characterId);
// If not followed and not tracked, we need to track it first
if (!isCurrentlyFollowed && !isCurrentlyTracked) {
setTrackedCharacters(prev => [...prev, characterId]);
// Send track command first
outCommand({
type: OutCommand.toggleTrack,
data: { 'character-id': characterId },
});
// Then send follow command after a short delay
setTimeout(() => {
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterId },
});
}, 100);
return;
}
setFollowedCharacter(prev => (prev === characterId ? null : characterId));
// Otherwise just toggle follow
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterId },
@@ -91,24 +108,23 @@ export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
header={renderHeader()}
visible={visible}
onHide={onHide}
modal
className={classes.trackFollowDialog}
closeOnEscape
showHeader={true}
closable={true}
className="w-[500px] bg-surface-card text-text-color"
>
<div className={classes.characterGrid}>
<div className={classes.characterGridHeader}>
<div className="w-full overflow-hidden">
<div
className={`
grid grid-cols-[80px_80px_1fr]
${classes.trackFollowHeader}
border-b border-surface-border
font-normal text-sm text-text-color
p-0.5 text-center
`}
>
<div>Track</div>
<div>Follow</div>
<div>Character</div>
<div className="text-center">Character</div>
</div>
<VirtualScroller
items={characters}
itemSize={48}
itemTemplate={rowTemplate}
className={`${classes.characterGridBody} h-72 w-full`}
/>
<VirtualScroller items={characters} itemSize={48} itemTemplate={rowTemplate} className="h-72 w-full" />
</div>
</Dialog>
);

View File

@@ -1,118 +1,10 @@
/* Character grid row styles */
.characterGridRow {
display: grid;
grid-template-columns: 80px 80px 1fr;
border-bottom: 1px solid var(--surface-border);
padding: 2px;
align-items: center;
transition: background-color 0.2s;
min-height: 32px;
&:hover {
background-color: var(--surface-hover);
}
.characterRow {
border-color: var(--surface-border);
border-width: 0 0 1px 0;
border-style: solid;
opacity: 0.5;
&:last-child {
border-bottom: none;
}
}
.gridCellTrack,
.gridCellFollow {
display: flex;
justify-content: center;
align-items: center;
padding: 2px;
text-align: center;
}
.gridCellCharacter {
padding: 2px;
display: flex;
align-items: center;
}
/* Character info styles */
.characterInfo {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
overflow: hidden;
min-height: 32px;
}
.characterPortrait {
width: 2rem;
height: 2rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.characterDetails {
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
white-space: nowrap;
}
.characterName {
font-weight: 500;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.checkboxContainer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.radioContainer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.portraitImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.corporationTicker {
margin-left: 0.5rem;
color: var(--text-color-secondary, #9ca3af);
font-size: 0.875rem;
}
.allianceTicker {
margin-left: 0.25rem;
color: var(--text-color-secondary, #9ca3af);
font-size: 0.875rem;
}
/* Responsive styles */
@media (max-width: 640px) {
.characterGridRow {
grid-template-columns: 60px 60px 1fr;
}
.characterPortrait {
width: 2rem;
height: 2rem;
}
}
}

View File

@@ -1,8 +1,8 @@
import { TrackingCharacter } from './types';
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit/WdCheckbox/WdCheckbox';
import WdRadioButton from '@/hooks/Mapper/components/ui-kit/WdRadioButton';
import classes from './TrackingCharacterWrapper.module.scss';
import { TooltipPosition, WdTooltipWrapper } from '../../../ui-kit';
import classes from './TrackingCharacterWrapper.module.scss';
interface TrackingCharacterWrapperProps {
character: TrackingCharacter;
@@ -23,35 +23,46 @@ export const TrackingCharacterWrapper = ({
const followRadioId = `follow-${character.id}`;
return (
<div className={classes.characterGridRow}>
<div className={classes.gridCellTrack}>
<div
className={`
grid grid-cols-[80px_80px_1fr]
${classes.characterRow}
p-0.5 items-center transition-colors duration-200 min-h-8 hover:bg-surface-hover
`}
>
<div className="flex justify-center items-center p-0.5 text-center">
<WdTooltipWrapper content="Track this character on the map" position={TooltipPosition.top}>
<div className={classes.checkboxContainer}>
<div className="flex justify-center items-center w-full">
<WdCheckbox id={trackCheckboxId} label="" value={isTracked} onChange={() => onTrackToggle()} />
</div>
</WdTooltipWrapper>
</div>
<div className={classes.gridCellFollow}>
<div className="flex justify-center items-center p-0.5 text-center">
<WdTooltipWrapper content="Follow this character's movements on the map" position={TooltipPosition.top}>
<div className={classes.radioContainer}>
<WdRadioButton
id={followRadioId}
name="followed_character"
checked={isFollowed}
onChange={() => onFollowToggle()}
/>
<div className="flex justify-center items-center w-full">
<div onClick={onFollowToggle} className="cursor-pointer">
<WdRadioButton id={followRadioId} name="followed_character" checked={isFollowed} onChange={() => {}} />
</div>
</div>
</WdTooltipWrapper>
</div>
<div className={classes.gridCellCharacter}>
<div className={classes.characterInfo}>
<div className={classes.characterPortrait}>
<img src={character.portrait_url} alt={character.name} className={classes.portraitImage} />
<div className="p-0.5 flex items-center justify-center">
<div className="flex items-center gap-3 w-full overflow-hidden min-h-8 justify-center">
<div className="w-8 h-8 rounded-full overflow-hidden flex-shrink-0">
<img src={character.portrait_url} alt={character.name} className="w-full h-full object-cover" />
</div>
<div className={classes.characterDetails}>
<span className={classes.characterName}>{character.name}</span>
<span className={classes.corporationTicker}>[{character.corporation_ticker}]</span>
{character.alliance_ticker && <span className={classes.allianceTicker}>[{character.alliance_ticker}]</span>}
<div className="flex items-center overflow-hidden flex-nowrap whitespace-nowrap">
<span
className={`
text-sm text-color-color whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]
`}
>
{character.name}
</span>
<span className="ml-2 text-text-color-secondary text-sm">[{character.corporation_ticker}]</span>
{character.alliance_ticker && (
<span className="ml-1 text-text-color-secondary text-sm">[{character.alliance_ticker}]</span>
)}
</div>
</div>
</div>

View File

@@ -13,17 +13,17 @@ export const useTrackAndFollowHandlers = () => {
* Handle hiding the track and follow dialog
*/
const handleHideTracking = useCallback(() => {
// Update local state to hide the dialog
update(state => ({
...state,
showTrackAndFollow: false,
}));
// Send the command to the server
// Send the command to the server first
outCommand({
type: OutCommand.hideTracking,
data: {},
});
// Then update local state to hide the dialog
update(state => ({
...state,
showTrackAndFollow: false,
}));
}, [outCommand, update]);
/**