feat: weapon stat columns + column visibility toggles

New columns: Max Damage, Attack Bonus, Material, Workmanship.
All columns now driven by RESULT_COLUMNS config array.
Column visibility toggles bar above results — checkboxes to
show/hide any column, persisted to localStorage.
Coverage column hidden by default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-08 18:22:13 +02:00
parent 7f7595b5b6
commit 0b91f111ad
2 changed files with 97 additions and 102 deletions

View file

@ -1138,6 +1138,10 @@
</div>
</div>
<div style="background:#f0f0f0;padding:3px 8px;border:1px solid #ccc;border-bottom:none;font-size:9px;display:flex;align-items:center;gap:4px;flex-wrap:wrap;">
<strong style="margin-right:4px;">Columns:</strong>
<span id="columnToggles"></span>
</div>
<div class="results-container" id="searchResults">
<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>
</div>

View file

@ -34,6 +34,7 @@ document.addEventListener('DOMContentLoaded', function() {
initializeEventListeners();
loadCharacterOptions();
renderColumnToggles();
});
/**
@ -442,6 +443,85 @@ function getSelectedSlots() {
return selectedSlots;
}
// Column definitions — single source of truth for headers, data, and visibility
const RESULT_COLUMNS = [
{ key: 'character_name', label: 'Character', sort: 'character_name', defaultVisible: true },
{ key: 'status', label: 'Status', sort: 'is_equipped', defaultVisible: true,
render: item => { const s = item.is_equipped; return `<td class="${s ? 'status-equipped' : 'status-inventory'}">${s ? '⚔️ Equipped' : '📦 Inventory'}</td>`; } },
{ key: 'name', label: 'Item Name', sort: 'name', defaultVisible: true, cls: 'item-name',
render: item => { let n = item.name; if (item.material_name && item.material_name !== '' && !n.toLowerCase().includes(item.material_name.toLowerCase())) n = `${item.material_name} ${n}`; return `<td class="item-name">${n}</td>`; } },
{ key: 'item_type_name', label: 'Type', sort: 'item_type_name', defaultVisible: true },
{ key: 'slot_name', label: 'Slot', sort: 'slot_name', defaultVisible: true, cls: 'text-right narrow-col',
render: item => `<td class="text-right narrow-col">${item.slot_name ? item.slot_name.replace(/,\s*/g, '<br>') : '-'}</td>` },
{ key: 'coverage', label: 'Coverage', sort: 'coverage', defaultVisible: false, cls: 'text-right narrow-col',
render: item => `<td class="text-right narrow-col">${item.coverage ? item.coverage.replace(/,\s*/g, '<br>') : '-'}</td>` },
{ key: 'armor_level', label: 'Armor', sort: 'armor', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.armor_level > 0 ? item.armor_level : '-'}</td>` },
{ key: 'max_damage', label: 'Max Dmg', sort: 'max_damage', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.max_damage > 0 ? item.max_damage : '-'}</td>` },
{ key: 'attack_bonus', label: 'Attack Bonus', sort: 'attack_bonus', defaultVisible: false, cls: 'text-right',
render: item => `<td class="text-right">${item.attack_bonus > 0 ? (item.attack_bonus * 100).toFixed(0) + '%' : '-'}</td>` },
{ key: 'material_name', label: 'Material', sort: 'material_name', defaultVisible: false },
{ key: 'workmanship', label: 'Wkm', sort: 'workmanship', defaultVisible: false, cls: 'text-right',
render: item => `<td class="text-right">${item.workmanship > 0 ? item.workmanship : '-'}</td>` },
{ key: 'item_set', label: 'Set', sort: 'item_set', defaultVisible: true, cls: 'set-col',
render: item => { let s = '-'; if (item.item_set_name) { s = item.item_set_name.replace(/\s+Set$/i, ''); } else if (item.item_set) { s = item.item_set.replace(/^Set\s+/i, '').replace(/\s+Set$/i, ''); } return `<td class="set-col" title="${s}">${s}</td>`; } },
{ key: 'spell_names', label: 'Spells/Cantrips', sort: 'spell_names', defaultVisible: true, cls: 'spells-cell medium-col',
render: item => { if (!item.spell_names || item.spell_names.length === 0) return '<td class="spells-cell medium-col">-</td>'; const f = item.spell_names.map(sp => sp.toLowerCase().includes('legendary') ? `<span class="legendary-cantrip">${sp}</span>` : `<span class="regular-spell">${sp}</span>`); return `<td class="spells-cell medium-col">${f.join('<br>')}</td>`; } },
{ key: 'crit_damage_rating', label: 'Crit Dmg', sort: 'crit_damage_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.crit_damage_rating > 0 ? item.crit_damage_rating : '-'}</td>` },
{ key: 'damage_rating', label: 'Dmg Rating', sort: 'damage_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.damage_rating > 0 ? item.damage_rating : '-'}</td>` },
{ key: 'heal_boost_rating', label: 'Heal Boost', sort: 'heal_boost_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.heal_boost_rating > 0 ? item.heal_boost_rating : '-'}</td>` },
{ key: 'vitality_rating', label: 'Vitality', sort: 'vitality_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.vitality_rating > 0 ? item.vitality_rating : '-'}</td>` },
{ key: 'damage_resist_rating', label: 'Dmg Resist', sort: 'damage_resist_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.damage_resist_rating > 0 ? item.damage_resist_rating : '-'}</td>` },
{ key: 'crit_damage_resist_rating', label: 'Crit Dmg Resist', sort: 'crit_damage_resist_rating', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.crit_damage_resist_rating > 0 ? item.crit_damage_resist_rating : '-'}</td>` },
{ key: 'last_updated', label: 'Last Updated', sort: 'last_updated', defaultVisible: true, cls: 'text-right',
render: item => `<td class="text-right">${item.last_updated ? new Date(item.last_updated).toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) : '-'}</td>` },
];
// Load column visibility from localStorage or use defaults
function getColumnVisibility() {
try {
const saved = localStorage.getItem('inventoryColumnVisibility');
if (saved) return JSON.parse(saved);
} catch (e) {}
const vis = {};
RESULT_COLUMNS.forEach(c => vis[c.key] = c.defaultVisible);
return vis;
}
let columnVisibility = getColumnVisibility();
function saveColumnVisibility() {
localStorage.setItem('inventoryColumnVisibility', JSON.stringify(columnVisibility));
}
function renderColumnToggles() {
const container = document.getElementById('columnToggles');
if (!container) return;
container.innerHTML = '';
RESULT_COLUMNS.forEach(col => {
const label = document.createElement('label');
label.style.cssText = 'display:inline-flex;align-items:center;gap:2px;font-size:9px;margin-right:6px;cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = columnVisibility[col.key] !== false;
cb.style.cssText = 'width:10px;height:10px;margin:0;';
cb.addEventListener('change', () => {
columnVisibility[col.key] = cb.checked;
saveColumnVisibility();
if (currentResultsData) displayResults(currentResultsData);
});
label.appendChild(cb);
label.appendChild(document.createTextNode(col.label));
container.appendChild(label);
});
}
/**
* Display search results in the UI
*/
@ -451,6 +531,8 @@ function displayResults(data) {
return;
}
const visibleCols = RESULT_COLUMNS.filter(c => columnVisibility[c.key] !== false);
const getSortIcon = (field) => {
if (currentSort.field === field) {
return currentSort.direction === 'asc' ? ' ▲' : ' ▼';
@ -465,114 +547,23 @@ function displayResults(data) {
<table class="results-table">
<thead>
<tr>
<th class="sortable" data-sort="character_name">Character${getSortIcon('character_name')}</th>
<th class="sortable" data-sort="is_equipped">Status${getSortIcon('is_equipped')}</th>
<th class="sortable" data-sort="name">Item Name${getSortIcon('name')}</th>
<th class="sortable" data-sort="item_type_name">Type${getSortIcon('item_type_name')}</th>
<th class="text-right narrow-col sortable" data-sort="slot_name">Slot${getSortIcon('slot_name')}</th>
<th class="text-right narrow-col sortable" data-sort="coverage">Coverage${getSortIcon('coverage')}</th>
<th class="text-right sortable" data-sort="armor">Armor${getSortIcon('armor')}</th>
<th class="set-col sortable" data-sort="item_set">Set${getSortIcon('item_set')}</th>
<th class="medium-col sortable" data-sort="spell_names">Spells/Cantrips${getSortIcon('spell_names')}</th>
<th class="text-right sortable" data-sort="crit_damage_rating">Crit Dmg${getSortIcon('crit_damage_rating')}</th>
<th class="text-right sortable" data-sort="damage_rating">Dmg Rating${getSortIcon('damage_rating')}</th>
<th class="text-right sortable" data-sort="heal_boost_rating">Heal Boost${getSortIcon('heal_boost_rating')}</th>
<th class="text-right sortable" data-sort="vitality_rating">Vitality${getSortIcon('vitality_rating')}</th>
<th class="text-right sortable" data-sort="damage_resist_rating">Dmg Resist${getSortIcon('damage_resist_rating')}</th>
<th class="text-right sortable" data-sort="crit_damage_resist_rating">Crit Dmg Resist${getSortIcon('crit_damage_resist_rating')}</th>
<th class="text-right sortable" data-sort="last_updated">Last Updated${getSortIcon('last_updated')}</th>
${visibleCols.map(c => `<th class="${c.cls || ''} sortable" data-sort="${c.sort}">${c.label}${getSortIcon(c.sort)}</th>`).join('\n ')}
</tr>
</thead>
<tbody>
`;
data.items.forEach((item) => {
const armor = item.armor_level > 0 ? item.armor_level : '-';
const critDmg = item.crit_damage_rating > 0 ? item.crit_damage_rating : '-';
const dmgRating = item.damage_rating > 0 ? item.damage_rating : '-';
const healBoostRating = item.heal_boost_rating > 0 ? item.heal_boost_rating : '-';
const vitalityRating = item.vitality_rating > 0 ? item.vitality_rating : '-';
const damageResistRating = item.damage_resist_rating > 0 ? item.damage_resist_rating : '-';
const critDamageResistRating = item.crit_damage_resist_rating > 0 ? item.crit_damage_resist_rating : '-';
const status = item.is_equipped ? '⚔️ Equipped' : '📦 Inventory';
const statusClass = item.is_equipped ? 'status-equipped' : 'status-inventory';
// Use the slot_name provided by the API instead of incorrect mapping
// Replace commas with line breaks for better display
const slot = item.slot_name ? item.slot_name.replace(/,\s*/g, '<br>') : 'Unknown';
// Coverage placeholder - will need to be added to backend later
// Replace commas with line breaks for better display
const coverage = item.coverage ? item.coverage.replace(/,\s*/g, '<br>') : '-';
// Format last updated timestamp
const lastUpdated = item.last_updated ?
new Date(item.last_updated).toLocaleString('sv-SE', {
timeZone: 'Europe/Stockholm',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}) : '-';
// Use the formatted name with material from the API
let displayName = item.name;
// The API should already include material in the name, but use material_name if available
if (item.material_name && item.material_name !== '' && !item.name.toLowerCase().includes(item.material_name.toLowerCase())) {
displayName = `${item.material_name} ${item.name}`;
}
// Format spells/cantrips list
let spellsDisplay = '-';
if (item.spell_names && item.spell_names.length > 0) {
// Highlight legendary cantrips in a different color
const formattedSpells = item.spell_names.map(spell => {
if (spell.toLowerCase().includes('legendary')) {
return `<span class="legendary-cantrip">${spell}</span>`;
} else {
return `<span class="regular-spell">${spell}</span>`;
}
});
spellsDisplay = formattedSpells.join('<br>');
}
// Get item type for display
const itemType = item.item_type_name || '-';
// Format equipment set name - prefer translated name
let setDisplay = '-';
if (item.item_set_name) {
// Use the translated set name from backend
setDisplay = item.item_set_name;
// Remove redundant "Set" suffix if present for cleaner display
setDisplay = setDisplay.replace(/\s+Set$/i, '');
} else if (item.item_set) {
// Fallback to raw set ID if name not available
setDisplay = item.item_set.replace(/^Set\s+/i, '');
setDisplay = setDisplay.replace(/\s+Set$/i, '');
}
html += `
<tr>
<td>${item.character_name}</td>
<td class="${statusClass}">${status}</td>
<td class="item-name">${displayName}</td>
<td>${itemType}</td>
<td class="text-right narrow-col">${slot}</td>
<td class="text-right narrow-col">${coverage}</td>
<td class="text-right">${armor}</td>
<td class="set-col" title="${setDisplay}">${setDisplay}</td>
<td class="spells-cell medium-col">${spellsDisplay}</td>
<td class="text-right">${critDmg}</td>
<td class="text-right">${dmgRating}</td>
<td class="text-right">${healBoostRating}</td>
<td class="text-right">${vitalityRating}</td>
<td class="text-right">${damageResistRating}</td>
<td class="text-right">${critDamageResistRating}</td>
<td class="text-right">${lastUpdated}</td>
</tr>
html += '<tr>';
visibleCols.forEach(col => {
if (col.render) {
html += col.render(item);
} else {
const val = item[col.key];
html += `<td class="${col.cls || ''}">${val != null && val !== '' && val !== -1 ? val : '-'}</td>`;
}
});
html += '</tr>
`;
});