add variant fileter

This commit is contained in:
2026-03-05 00:00:38 -05:00
parent f44f1acb92
commit 6ec5b95982
8 changed files with 186 additions and 17 deletions

View File

@@ -19,7 +19,8 @@
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4"> <div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
<FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter" <FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter"
@set-city="setCity" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes" @set-city="setCity" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments" @set-rrr="setRrr" /> @toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments"
@toggle-variant="toggleVariant" @reset-variants="resetVariants" @set-rrr="setRrr" />
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" /> <ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
</div> </div>
@@ -72,6 +73,8 @@ const {
setNameFilter, setNameFilter,
toggleEnchantment, toggleEnchantment,
resetEnchantments, resetEnchantments,
toggleVariant,
resetVariants,
} = useFilters() } = useFilters()
// Profit calculation // Profit calculation

View File

@@ -117,6 +117,20 @@
</div> </div>
</div> </div>
<!-- Variant toggles -->
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">Variant</span>
<div class="flex gap-0.5">
<button class="px-2 py-1 rounded text-xs font-semibold transition-colors" :class="filters.variants === null
? 'bg-amber-600 text-amber-100'
: 'bg-gray-800 text-gray-500 hover:bg-gray-700'" @click="$emit('reset-variants')">All</button>
<button v-for="v in ALL_VARIANTS" :key="v"
class="px-2 py-1 rounded text-xs font-semibold transition-colors"
:class="isVariantActive(v) ? VARIANT_ACTIVE_CLASS[v] : 'bg-gray-800 text-gray-600 hover:bg-gray-700'"
@click="$emit('toggle-variant', v)">{{ VARIANT_LABEL[v] }}</button>
</div>
</div>
<!-- City select --> <!-- City select -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-xs text-gray-400">City</span> <span class="text-xs text-gray-400">City</span>
@@ -150,7 +164,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { tierStyle, enchantStyle } from '../../utils/formatting' import { tierStyle, enchantStyle } from '../../utils/formatting'
import type { FilterState } from '../../types/filters' import type { FilterState, VariantType } from '../../types/filters'
import { ALL_VARIANTS } from '../../types/filters'
import type { AlbionCity } from '../../types/api' import type { AlbionCity } from '../../types/api'
import type { Tier, Enchantment } from '../../types/crafting' import type { Tier, Enchantment } from '../../types/crafting'
import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants' import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants'
@@ -169,6 +184,8 @@ const emit = defineEmits<{
'set-selected-item-types': [v: Set<string> | null] 'set-selected-item-types': [v: Set<string> | null]
'toggle-enchantment': [enc: Enchantment] 'toggle-enchantment': [enc: Enchantment]
'reset-enchantments': [] 'reset-enchantments': []
'toggle-variant': [v: VariantType]
'reset-variants': []
'set-rrr': [rate: number] 'set-rrr': [rate: number]
}>() }>()
@@ -278,6 +295,24 @@ function isEnchantActive(enc: Enchantment): boolean {
return s === null || s.has(enc) return s === null || s.has(enc)
} }
// ─── Variant helpers ──────────────────────────────────────────────────────────
const VARIANT_LABEL: Record<VariantType, string> = {
basic: 'Basic', artifact: 'Artifact', avalon: 'Avalon', crystal: 'Crystal',
}
const VARIANT_ACTIVE_CLASS: Record<VariantType, string> = {
basic: 'bg-gray-600 text-gray-200',
artifact: 'bg-amber-600/70 text-amber-100',
avalon: 'bg-violet-600/70 text-violet-100',
crystal: 'bg-cyan-600/70 text-cyan-100',
}
function isVariantActive(v: VariantType): boolean {
const s = props.filters.variants
return s === null || s.has(v)
}
// ─── Dropdown open states ───────────────────────────────────────────────────── // ─── Dropdown open states ─────────────────────────────────────────────────────

View File

@@ -4,6 +4,14 @@
:class="result.missingPrices ? 'opacity-60' : ''" :class="result.missingPrices ? 'opacity-60' : ''"
@click="expanded = !expanded" @click="expanded = !expanded"
> >
<!-- Variant badge -->
<td class="px-4 py-3">
<span
class="inline-flex items-center justify-center px-2 h-6 rounded text-xs font-semibold whitespace-nowrap"
:class="variantClass"
>{{ variantLabel }}</span>
</td>
<!-- Item name --> <!-- Item name -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -83,7 +91,7 @@
<!-- Expanded detail row --> <!-- Expanded detail row -->
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60"> <tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
<td colspan="5" class="px-6 py-4"> <td colspan="6" class="px-6 py-4">
<div class="flex gap-8"> <div class="flex gap-8">
<!-- Ingredients breakdown --> <!-- Ingredients breakdown -->
@@ -194,6 +202,23 @@ const { isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useA
const { filters } = useFilters() const { filters } = useFilters()
const { upsert, remove, getQty, inOrder } = useProductionOrder() const { upsert, remove, getQty, inOrder } = useProductionOrder()
const VARIANT_INFO: Record<string, { label: string; cls: string }> = {
avalon: { label: 'Avalon', cls: 'bg-violet-600/30 text-violet-300 border border-violet-600/40' },
crystal: { label: 'Crystal', cls: 'bg-cyan-600/30 text-cyan-300 border border-cyan-600/40' },
artifact: { label: 'Artifact', cls: 'bg-amber-600/30 text-amber-300 border border-amber-600/40' },
basic: { label: 'Basic', cls: 'bg-gray-700/50 text-gray-400 border border-gray-600/40' },
}
const variantInfo = computed(() => {
const id = props.result.recipe.outputItemId.replace(/@\d$/, '')
if (id.endsWith('_AVALON')) return VARIANT_INFO.avalon
if (id.endsWith('_CRYSTAL')) return VARIANT_INFO.crystal
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return VARIANT_INFO.artifact
return VARIANT_INFO.basic
})
const variantLabel = computed(() => variantInfo.value.label)
const variantClass = computed(() => variantInfo.value.cls)
const expanded = ref(false) const expanded = ref(false)
const editingItemId = ref<string | null>(null) const editingItemId = ref<string | null>(null)
const inputValue = ref('') const inputValue = ref('')

View File

@@ -24,9 +24,9 @@
import type { SortField, SortState } from '../../types/crafting' import type { SortField, SortState } from '../../types/crafting'
const columns: { field: SortField | 'status' | 'bill'; label: string; sortable: boolean }[] = [ const columns: { field: SortField | 'status' | 'bill'; label: string; sortable: boolean }[] = [
{ field: 'variantType', label: 'Variant', sortable: true },
{ field: 'displayName', label: 'Item', sortable: true }, { field: 'displayName', label: 'Item', sortable: true },
{ field: 'tier', label: 'Tier', sortable: true }, { field: 'tier', label: 'Tier', sortable: true },
{ field: 'variantType', label: 'Variant', sortable: true },
{ field: 'materialCost', label: 'Craft Cost', sortable: true }, { field: 'materialCost', label: 'Craft Cost', sortable: true },
{ field: 'status', label: 'Price Age', sortable: false }, { field: 'status', label: 'Price Age', sortable: false },
{ field: 'bill', label: '', sortable: false }, { field: 'bill', label: '', sortable: false },

View File

@@ -1,19 +1,23 @@
import { computed } from 'vue' import { computed } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } from '../types/crafting' import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } from '../types/crafting'
import type { FilterState } from '../types/filters' import type { FilterState, VariantType } from '../types/filters'
import { useAlbionPrices } from './useAlbionPrices' import { useAlbionPrices } from './useAlbionPrices'
import { formatItemId } from '../utils/formatting' import { formatItemId } from '../utils/formatting'
// Returns 0=basic, 1=artifact, 2=avalon, 3=crystal function variantOf(outputItemId: string): VariantType {
function variantRank(outputItemId: string): number {
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
if (id.endsWith('_AVALON')) return 2 if (id.endsWith('_AVALON')) return 'avalon'
if (id.endsWith('_CRYSTAL')) return 3 if (id.endsWith('_CRYSTAL')) return 'crystal'
if (/_SET[123]$/.test(id)) return 0 if (/_SET[123]$/.test(id)) return 'basic'
// Artifact suffixes: UNDEAD, HELL, MORGANA, KEEPER, and unique named artifacts if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return 'artifact'
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return 1 return 'basic'
return 0 }
const VARIANT_ORDER: Record<VariantType, number> = { basic: 0, artifact: 1, avalon: 2, crystal: 3 }
function variantRank(outputItemId: string): number {
return VARIANT_ORDER[variantOf(outputItemId)]
} }
export function useCraftingProfit( export function useCraftingProfit(
@@ -34,6 +38,7 @@ export function useCraftingProfit(
for (const recipe of recipes) { for (const recipe of recipes) {
if (!f.tiers.has(recipe.tier)) continue if (!f.tiers.has(recipe.tier)) continue
if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue
if (f.variants !== null && !f.variants.has(variantOf(recipe.outputItemId))) continue
const baseName = recipe.displayName.replace(/^T\d+\.\d /, '') const baseName = recipe.displayName.replace(/^T\d+\.\d /, '')
if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue

View File

@@ -1,5 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { FilterState } from '../types/filters' import type { FilterState, VariantType } from '../types/filters'
import { ALL_VARIANTS } from '../types/filters'
import type { Tier, Enchantment } from '../types/crafting' import type { Tier, Enchantment } from '../types/crafting'
import type { AlbionCity } from '../types/api' import type { AlbionCity } from '../types/api'
import { ENCHANTMENTS } from '../data/constants' import { ENCHANTMENTS } from '../data/constants'
@@ -20,6 +21,7 @@ const filters = ref<FilterState>({
rrr: loadRrr(), rrr: loadRrr(),
nameFilter: '', nameFilter: '',
enchantments: null, enchantments: null,
variants: null,
}) })
function setCity(city: AlbionCity) { function setCity(city: AlbionCity) {
@@ -65,6 +67,21 @@ function resetEnchantments() {
filters.value.enchantments = null filters.value.enchantments = null
} }
function toggleVariant(variant: VariantType) {
const current = filters.value.variants
const next = current === null ? new Set(ALL_VARIANTS) : new Set(current)
if (next.has(variant)) {
next.delete(variant)
} else {
next.add(variant)
}
filters.value.variants = next.size === ALL_VARIANTS.length ? null : next
}
function resetVariants() {
filters.value.variants = null
}
export function useFilters() { export function useFilters() {
return { return {
filters, filters,
@@ -75,5 +92,7 @@ export function useFilters() {
setNameFilter, setNameFilter,
toggleEnchantment, toggleEnchantment,
resetEnchantments, resetEnchantments,
toggleVariant,
resetVariants,
} }
} }

View File

@@ -85,6 +85,8 @@
<tr class="border-b border-gray-700 bg-gray-700/30"> <tr class="border-b border-gray-700 bg-gray-700/30">
<th class="text-left px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Material</th> <th class="text-left px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Material</th>
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Qty</th> <th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Qty</th>
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Unit Price</th>
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -100,6 +102,36 @@
</div> </div>
</td> </td>
<td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ mat.qty.toLocaleString() }}</td> <td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ mat.qty.toLocaleString() }}</td>
<td class="px-4 py-1.5 text-right font-mono">
<div v-if="editingItemId === mat.itemId" class="flex items-center justify-end gap-1">
<input
v-focus
type="number"
min="1"
:placeholder="mat.unitPrice > 0 ? 'empty to clear' : 'silver'"
class="w-28 bg-gray-900 border border-amber-500 rounded px-1.5 py-0.5 text-right text-xs font-mono text-gray-200 focus:outline-none placeholder-gray-600"
v-model="inputValue"
@keydown.enter="saveEdit(mat.itemId)"
@keydown.escape="cancelEdit"
/>
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none px-0.5" @click="saveEdit(mat.itemId)"></button>
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none px-0.5" @click="cancelEdit"></button>
</div>
<button
v-else
class="group flex items-center gap-0.5 ml-auto"
:class="priceButtonClass(mat.itemId, mat.unitPrice)"
:title="priceTitle(mat.itemId, mat.unitPrice)"
@click="startEdit(mat.itemId, mat.unitPrice)"
>
{{ mat.unitPrice > 0 ? formatSilver(mat.unitPrice) : '—' }}
<span class="opacity-0 group-hover:opacity-40 text-[10px] ml-0.5"></span>
</button>
</td>
<td class="px-4 py-1.5 text-right font-mono text-gray-200"
:title="mat.totalCost > 0 ? mat.totalCost.toLocaleString() : undefined">
{{ mat.totalCost > 0 ? formatSilver(mat.totalCost) : '—' }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -169,17 +201,62 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useProductionOrder } from '../composables/useProductionOrder' import { useProductionOrder } from '../composables/useProductionOrder'
import { useAlbionPrices } from '../composables/useAlbionPrices' import { useAlbionPrices } from '../composables/useAlbionPrices'
import { useFilters } from '../composables/useFilters' import { useFilters } from '../composables/useFilters'
import { formatSilver, formatItemId, tierStyle, itemImageUrl } from '../utils/formatting' import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting'
import type { Tier, JournalType } from '../types/crafting' import type { Tier, JournalType } from '../types/crafting'
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
const { orderItems, upsert, remove, clear } = useProductionOrder() const { orderItems, upsert, remove, clear } = useProductionOrder()
const { getPrice } = useAlbionPrices() const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
const { filters } = useFilters() const { filters } = useFilters()
// ─── Inline price editing ─────────────────────────────────────────────────────
const editingItemId = ref<string | null>(null)
const inputValue = ref('')
function startEdit(itemId: string, current: number) {
editingItemId.value = itemId
inputValue.value = current > 0 ? String(current) : ''
}
function saveEdit(itemId: string) {
const v = Math.round(Number(inputValue.value))
if (!inputValue.value) {
clearManualPrice(itemId, filters.value.city)
} else if (v > 0) {
setManualPrice(itemId, filters.value.city, v)
}
editingItemId.value = null
inputValue.value = ''
}
function cancelEdit() {
editingItemId.value = null
inputValue.value = ''
}
function priceButtonClass(itemId: string, currentPrice: number): string {
if (isManualPrice(itemId, filters.value.city)) return 'text-amber-400 hover:text-amber-200'
if (currentPrice === 0) return 'text-gray-500 hover:text-amber-400'
return 'text-gray-300 hover:text-gray-100'
}
function priceTitle(itemId: string, currentPrice: number): string {
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
const entry = getManualEntry(itemId, filters.value.city)
if (entry && isManualPrice(itemId, filters.value.city)) {
return exact
? `${exact} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
: `Set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
}
return exact ? `${exact} — click to set price` : 'Click to set price'
}
// Laborer journal fills ≈ 13 crafts per book (ratio is constant across tiers) // Laborer journal fills ≈ 13 crafts per book (ratio is constant across tiers)
const CRAFTS_PER_JOURNAL = 13 const CRAFTS_PER_JOURNAL = 13

View File

@@ -1,6 +1,10 @@
import type { AlbionCity } from './api' import type { AlbionCity } from './api'
import type { Tier, Enchantment } from './crafting' import type { Tier, Enchantment } from './crafting'
export type VariantType = 'basic' | 'artifact' | 'avalon' | 'crystal'
export const ALL_VARIANTS: VariantType[] = ['basic', 'artifact', 'avalon', 'crystal']
export interface FilterState { export interface FilterState {
city: AlbionCity city: AlbionCity
tiers: Set<Tier> tiers: Set<Tier>
@@ -8,4 +12,5 @@ export interface FilterState {
rrr: number rrr: number
nameFilter: string nameFilter: string
enchantments: Set<Enchantment> | null enchantments: Set<Enchantment> | null
variants: Set<VariantType> | null
} }