Compare commits

...

10 Commits

18 changed files with 510 additions and 370 deletions

View File

@@ -18,9 +18,10 @@
<!-- Calculator page --> <!-- Calculator page -->
<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" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments" @toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments"
@toggle-variant="toggleVariant" @reset-variants="resetVariants" @set-rrr="setRrr" /> @toggle-variant="toggleVariant" @reset-variants="resetVariants"
@toggle-station="toggleStation" @reset-stations="resetStations" />
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" /> <ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
</div> </div>
@@ -66,15 +67,15 @@ const sortState = ref<SortState>({ field: 'materialCost', direction: 'asc' })
// Filters // Filters
const { const {
filters, filters,
setCity,
toggleTier, toggleTier,
setSelectedItemTypes, setSelectedItemTypes,
setRrr,
setNameFilter, setNameFilter,
toggleEnchantment, toggleEnchantment,
resetEnchantments, resetEnchantments,
toggleVariant, toggleVariant,
resetVariants, resetVariants,
toggleStation,
resetStations,
} = useFilters() } = useFilters()
// Profit calculation // Profit calculation

View File

@@ -35,7 +35,7 @@ const vFocus = { mounted: (el: HTMLElement) => el.focus() }
const props = defineProps<{ itemId: string }>() const props = defineProps<{ itemId: string }>()
const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices() const { getPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
const { filters } = useFilters() const { filters } = useFilters()
const editing = ref(false) const editing = ref(false)
@@ -46,25 +46,22 @@ const city = computed(() => filters.value.city)
const entry = computed(() => getPrice(props.itemId, city.value)) const entry = computed(() => getPrice(props.itemId, city.value))
const currentPrice = computed(() => entry.value?.sell_price_min ?? 0) const currentPrice = computed(() => entry.value?.sell_price_min ?? 0)
const isManual = computed(() => isManualPrice(props.itemId, city.value))
const displayText = computed(() => const displayText = computed(() =>
currentPrice.value > 0 ? formatSilver(currentPrice.value) : '—' currentPrice.value > 0 ? formatSilver(currentPrice.value) : '—'
) )
const cellClass = computed(() => { const cellClass = computed(() =>
if (isManual.value) return 'text-amber-400 hover:text-amber-200' currentPrice.value > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-600 hover:text-amber-400'
if (currentPrice.value === 0) return 'text-gray-600 hover:text-amber-400' )
return 'text-gray-300 hover:text-gray-100'
})
const tooltip = computed(() => { const tooltip = computed(() => {
const exact = currentPrice.value > 0 ? currentPrice.value.toLocaleString() : null
const e = getManualEntry(props.itemId, city.value) const e = getManualEntry(props.itemId, city.value)
if (e && isManual.value) { if (e) {
return exact ? `${exact} — set ${formatLastUpdated(new Date(e.editedAt))} — click to edit` : `Set ${formatLastUpdated(new Date(e.editedAt))} — click to edit` const exact = currentPrice.value.toLocaleString()
return `${exact} — set ${formatLastUpdated(new Date(e.editedAt))} — click to edit`
} }
return exact ? `${exact} — click to edit` : 'Click to set price' return 'Click to set price'
}) })
function startEdit(initial?: number) { function startEdit(initial?: number) {
@@ -73,7 +70,7 @@ function startEdit(initial?: number) {
} }
function save() { function save() {
const v = Math.round(Number(inputValue.value)) const v = Math.ceil(Number(inputValue.value))
if (!inputValue.value && inputValue.value !== 0) { if (!inputValue.value && inputValue.value !== 0) {
clearManualPrice(props.itemId, city.value) clearManualPrice(props.itemId, city.value)
} else if (v > 0) { } else if (v > 0) {

View File

@@ -131,29 +131,20 @@
</div> </div>
</div> </div>
<!-- City select --> <!-- Station toggles -->
<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">Station</span>
<select <div class="flex gap-0.5">
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer" <button class="px-2 py-1 rounded text-xs font-semibold transition-colors" :class="filters.stations === null
:value="filters.city" @change="$emit('set-city', ($event.target as HTMLSelectElement).value as AlbionCity)"> ? 'bg-amber-600 text-amber-100'
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option> : 'bg-gray-800 text-gray-500 hover:bg-gray-700'" @click="$emit('reset-stations')">All</button>
</select> <button v-for="s in ALL_STATIONS" :key="s"
class="px-2 py-1 rounded text-xs font-semibold transition-colors"
:class="isStationActive(s) ? STATION_ACTIVE_CLASS[s] : 'bg-gray-800 text-gray-600 hover:bg-gray-700'"
@click="$emit('toggle-station', s)">{{ STATION_LABEL[s] }}</button>
</div>
</div> </div>
<div class="h-5 w-px bg-gray-600" />
<!-- RRR -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-400 whitespace-nowrap">RRR</span>
<input type="number"
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 w-16 focus:outline-none focus:border-amber-500 font-mono"
:value="filters.rrr" @input="$emit('set-rrr', Number(($event.target as HTMLInputElement).value))" min="0"
max="100" step="0.5" />
<span class="text-xs text-gray-500">%</span>
</div>
<!-- Result count --> <!-- Result count -->
<span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span> <span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span>
@@ -165,10 +156,9 @@
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, VariantType } from '../../types/filters' import type { FilterState, VariantType } from '../../types/filters'
import { ALL_VARIANTS } from '../../types/filters' import { ALL_VARIANTS, ALL_STATIONS } from '../../types/filters'
import type { AlbionCity } from '../../types/api' import type { Tier, Enchantment, JournalType } from '../../types/crafting'
import type { Tier, Enchantment } from '../../types/crafting' import { TIERS, ENCHANTMENTS } from '../../data/constants'
import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants'
import { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree' import { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree'
import type { TreeNode } from '../../data/itemTree' import type { TreeNode } from '../../data/itemTree'
@@ -179,14 +169,14 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'set-name-filter': [value: string] 'set-name-filter': [value: string]
'set-city': [city: AlbionCity]
'toggle-tier': [tier: Tier] 'toggle-tier': [tier: Tier]
'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] 'toggle-variant': [v: VariantType]
'reset-variants': [] 'reset-variants': []
'set-rrr': [rate: number] 'toggle-station': [s: JournalType]
'reset-stations': []
}>() }>()
// ─── Category tree ──────────────────────────────────────────────────────────── // ─── Category tree ────────────────────────────────────────────────────────────
@@ -313,6 +303,23 @@ function isVariantActive(v: VariantType): boolean {
return s === null || s.has(v) return s === null || s.has(v)
} }
// ─── Station helpers ──────────────────────────────────────────────────────────
const STATION_LABEL: Record<JournalType, string> = {
warrior: 'Warrior', hunter: 'Hunter', mage: 'Mage', toolmaker: 'Toolmaker',
}
const STATION_ACTIVE_CLASS: Record<JournalType, string> = {
warrior: 'bg-red-900/60 text-red-300',
hunter: 'bg-green-900/60 text-green-300',
mage: 'bg-blue-900/60 text-blue-300',
toolmaker: 'bg-orange-900/60 text-orange-300',
}
function isStationActive(s: JournalType): boolean {
return props.filters.stations === null || props.filters.stations.has(s)
}
// ─── Dropdown open states ───────────────────────────────────────────────────── // ─── Dropdown open states ─────────────────────────────────────────────────────

View File

@@ -1,90 +0,0 @@
<template>
<aside class="w-64 flex-shrink-0">
<div class="sticky top-20 bg-gray-800 rounded-xl border border-gray-700 p-4 space-y-5">
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider">Filters</h2>
<CityFilter :city="filters.city" @change="$emit('set-city', $event)" />
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Quality</label>
<select
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
:value="filters.quality"
@change="$emit('set-quality', Number(($event.target as HTMLSelectElement).value) as AlbionQuality)"
>
<option v-for="q in QUALITIES" :key="q.value" :value="q.value">{{ q.label }}</option>
</select>
</div>
<CategoryFilter :categories="filters.categories" @toggle="$emit('toggle-category', $event)" />
<TierFilter :tiers="filters.tiers" @toggle="$emit('toggle-tier', $event)" />
<TaxInput :tax-rate="filters.taxRate" @change="$emit('set-tax-rate', $event)" />
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Min Profit (silver)</label>
<input
type="number"
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
:value="filters.minProfit"
@input="$emit('set-min-profit', Number(($event.target as HTMLInputElement).value))"
min="0"
step="1000"
/>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
class="w-4 h-4 rounded accent-amber-500"
:checked="filters.hideStale"
@change="$emit('set-hide-stale', ($event.target as HTMLInputElement).checked)"
/>
<span class="text-xs text-gray-300">Hide stale prices</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
class="w-4 h-4 rounded accent-amber-500"
:checked="filters.hideMissing"
@change="$emit('set-hide-missing', ($event.target as HTMLInputElement).checked)"
/>
<span class="text-xs text-gray-300">Hide missing prices</span>
</label>
</div>
<div class="pt-1 border-t border-gray-700 text-xs text-gray-500">
{{ resultCount }} results shown
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import CityFilter from './CityFilter.vue'
import CategoryFilter from './CategoryFilter.vue'
import TierFilter from './TierFilter.vue'
import TaxInput from './TaxInput.vue'
import type { FilterState } from '../../types/filters'
import type { AlbionCity, AlbionQuality } from '../../types/api'
import type { Tier, ItemCategory } from '../../types/crafting'
import { QUALITIES } from '../../data/constants'
defineProps<{
filters: FilterState
resultCount: number
}>()
defineEmits<{
'set-city': [city: AlbionCity]
'set-quality': [quality: AlbionQuality]
'toggle-tier': [tier: Tier]
'toggle-category': [category: ItemCategory]
'set-tax-rate': [rate: number]
'set-min-profit': [value: number]
'set-hide-stale': [value: boolean]
'set-hide-missing': [value: boolean]
}>()
</script>

View File

@@ -29,6 +29,8 @@ defineEmits<{
function tierActiveClass(tier: Tier): string { function tierActiveClass(tier: Tier): string {
const classes: Record<Tier, string> = { const classes: Record<Tier, string> = {
2: 'bg-stone-600 border-stone-500 text-white',
3: 'bg-stone-500 border-stone-400 text-white',
4: 'bg-blue-600 border-blue-500 text-white', 4: 'bg-blue-600 border-blue-500 text-white',
5: 'bg-green-600 border-green-500 text-white', 5: 'bg-green-600 border-green-500 text-white',
6: 'bg-yellow-600 border-yellow-500 text-white', 6: 'bg-yellow-600 border-yellow-500 text-white',

View File

@@ -9,12 +9,28 @@
{{ totalRecipes }} recipes {{ totalRecipes }} recipes
</span> </span>
</div> </div>
<!-- City selector -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-400">City</span>
<select
class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
:value="filters.city"
@change="setCity(($event.target as HTMLSelectElement).value as AlbionCity)"
>
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ import { useFilters } from '../../composables/useFilters'
totalRecipes: number import { CITIES } from '../../data/constants'
}>() import type { AlbionCity } from '../../types/api'
defineProps<{ totalRecipes: number }>()
const { filters, setCity } = useFilters()
</script> </script>

View File

@@ -25,11 +25,18 @@
:alt="result.recipe.displayName" :alt="result.recipe.displayName"
class="w-12 h-12 -my-2 rounded flex-shrink-0" class="w-12 h-12 -my-2 rounded flex-shrink-0"
/> />
<span class="text-sm font-medium text-gray-200">{{ result.recipe.displayName }}</span> <span class="text-sm font-medium text-gray-200">{{ result.recipe.displayName.replace(/^T\d+\.\d+\s/, '') }}</span>
<span class="text-xs text-gray-500">{{ result.recipe.category }}</span>
</div> </div>
</td> </td>
<!-- Station 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="STATION[result.recipe.journalType].cls">
{{ STATION[result.recipe.journalType].label }}
</span>
</td>
<!-- Tier badge --> <!-- Tier badge -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<span <span
@@ -41,36 +48,26 @@
</td> </td>
<!-- Effective material cost (after RRR) --> <!-- Effective material cost (after RRR) -->
<td class="px-4 py-3 text-sm font-mono text-gray-300" <td class="px-4 py-3 text-right text-sm font-mono text-gray-300"
:title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined"> :title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined">
{{ formatSilver(result.effectiveMaterialCost) }} {{ formatSilver(result.effectiveMaterialCost) }}
</td> </td>
<!-- +15% markup --> <!-- +15% markup (no focus) -->
<td class="px-4 py-3 text-sm font-mono text-blue-300" <td class="px-4 py-3 text-right text-sm font-mono text-blue-300"
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.15).toLocaleString() : undefined"> :title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.15).toLocaleString() : undefined">
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }} {{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }}
</td> </td>
<!-- +30% markup --> <!-- +30% markup (no focus) -->
<td class="px-4 py-3 text-sm font-mono text-emerald-300" <td class="px-4 py-3 text-right text-sm font-mono text-emerald-300"
:title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.30).toLocaleString() : undefined"> :title="result.effectiveMaterialCost > 0 ? Math.round(result.effectiveMaterialCost * 1.30).toLocaleString() : undefined">
{{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }} {{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }}
</td> </td>
<!-- Price age --> <!-- Add to bill (no focus) -->
<td class="px-4 py-3">
<span
class="inline-block w-2.5 h-2.5 rounded-full"
:class="ageDotClass(result)"
:title="ageDotTitle(result)"
/>
</td>
<!-- Add to bill -->
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop> <td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
<!-- Inline qty form --> <div v-if="addingMode === 'nofocus'" class="inline-flex items-center gap-1">
<div v-if="addingToBill" class="inline-flex items-center gap-1">
<input <input
v-focus v-focus
type="number" type="number"
@@ -83,31 +80,90 @@
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none" @click="confirmBill"></button> <button class="text-amber-500 hover:text-amber-300 text-xs leading-none" @click="confirmBill"></button>
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill"></button> <button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill"></button>
</div> </div>
<!-- In-order badge + remove --> <div v-else-if="isInOrderNoFocus" class="inline-flex items-center gap-1">
<div v-else-if="isInOrder" class="inline-flex items-center gap-1">
<button <button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-600/20 border border-amber-600/40 text-amber-400 text-xs font-mono hover:border-amber-500 transition-colors" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-600/20 border border-amber-600/40 text-amber-400 text-xs font-mono hover:border-amber-500 transition-colors"
@click="startBill" @click="startBill('nofocus')"
>×{{ currentQty }}</button> >×{{ currentQtyNoFocus }}</button>
<button <button
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none" class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
title="Remove from order" title="Remove from order"
@click="remove(result.recipe.outputItemId)" @click="remove(result.recipe.outputItemId, false)"
></button> ></button>
</div> </div>
<!-- Add button -->
<button <button
v-else v-else
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-amber-600/30 hover:text-amber-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center" class="w-6 h-6 rounded-full bg-gray-700 hover:bg-amber-600/30 hover:text-amber-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
title="Add to Bill of Production" title="Add to Bill of Production (no focus)"
@click="startBill" @click="startBill('nofocus')"
>+</button> >+</button>
</td> </td>
<!-- Cost (with focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-violet-300 border-l border-gray-700/40"
:title="result.effectiveMaterialCostFocus > 0 ? result.effectiveMaterialCostFocus.toLocaleString() : undefined">
{{ formatSilver(result.effectiveMaterialCostFocus) }}
</td>
<!-- +15% markup (with focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-blue-300/70"
:title="result.effectiveMaterialCostFocus > 0 ? Math.round(result.effectiveMaterialCostFocus * 1.15).toLocaleString() : undefined">
{{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.15)) : '—' }}
</td>
<!-- +30% markup (with focus) -->
<td class="px-4 py-3 text-right text-sm font-mono text-emerald-300/70"
:title="result.effectiveMaterialCostFocus > 0 ? Math.round(result.effectiveMaterialCostFocus * 1.30).toLocaleString() : undefined">
{{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.30)) : '—' }}
</td>
<!-- Add to bill (with focus) -->
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
<div v-if="addingMode === 'focus'" class="inline-flex items-center gap-1">
<input
v-focus
type="number"
min="1"
v-model.number="billQty"
class="w-12 bg-gray-900 border border-violet-500 rounded px-1.5 py-0.5 text-center text-xs font-mono text-gray-200 focus:outline-none"
@keydown.enter="confirmBill"
@keydown.escape="cancelBill"
/>
<button class="text-violet-400 hover:text-violet-200 text-xs leading-none" @click="confirmBill"></button>
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill"></button>
</div>
<div v-else-if="isInOrderFocus" class="inline-flex items-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-600/20 border border-violet-600/40 text-violet-400 text-xs font-mono hover:border-violet-500 transition-colors"
@click="startBill('focus')"
>×{{ currentQtyFocus }}</button>
<button
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
title="Remove from order"
@click="remove(result.recipe.outputItemId, true)"
></button>
</div>
<button
v-else
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-violet-600/30 hover:text-violet-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
title="Add to Bill of Production (with focus)"
@click="startBill('focus')"
>+</button>
</td>
<!-- Status -->
<td class="px-4 py-3 text-center">
<span
class="inline-block w-2.5 h-2.5 rounded-full"
:class="ageDotClass(result)"
:title="ageDotTitle(result)"
/>
</td>
</tr> </tr>
<!-- 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="9" class="px-6 py-4"> <td colspan="14" class="px-6 py-4">
<div class="flex gap-8"> <div class="flex gap-8">
<!-- Ingredients breakdown --> <!-- Ingredients breakdown -->
@@ -184,13 +240,13 @@
<span class="text-gray-400">Raw Materials</span> <span class="text-gray-400">Raw Materials</span>
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span> <span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
</div> </div>
<div class="flex justify-between gap-4">
<span class="text-gray-400">RRR ({{ filters.rrr }}%)</span>
<span class="text-green-400">{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}</span>
</div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5"> <div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">
<span class="text-gray-300 font-semibold">Craft Cost</span> <span class="text-gray-400">RRR ({{ result.rrr.toFixed(1) }}%)</span>
<span class="font-semibold text-gray-100">{{ formatSilver(result.effectiveMaterialCost) }}</span> <span class="text-gray-200">{{ formatSilver(result.effectiveMaterialCost) }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-violet-400/80">RRR + Focus ({{ result.rrrFocus.toFixed(1) }}%)</span>
<span class="text-violet-300">{{ formatSilver(result.effectiveMaterialCostFocus) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -202,7 +258,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ProfitResult, Tier } from '../../types/crafting' import type { ProfitResult, JournalType } from '../../types/crafting'
import { formatSilver, formatLastUpdated, tierEnchantStyle, itemImageUrl } from '../../utils/formatting' import { formatSilver, formatLastUpdated, tierEnchantStyle, itemImageUrl } from '../../utils/formatting'
import { useAlbionPrices } from '../../composables/useAlbionPrices' import { useAlbionPrices } from '../../composables/useAlbionPrices'
import { useFilters } from '../../composables/useFilters' import { useFilters } from '../../composables/useFilters'
@@ -214,11 +270,18 @@ const props = defineProps<{
result: ProfitResult result: ProfitResult
}>() }>()
const { isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices() const { getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
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 }> = { const STATION: Record<JournalType, { label: string; cls: string }> = {
warrior: { label: 'Warrior', cls: 'bg-red-900/30 border border-red-700/40 text-red-400' },
hunter: { label: 'Hunter', cls: 'bg-green-900/30 border border-green-700/40 text-green-400' },
mage: { label: 'Mage', cls: 'bg-blue-900/30 border border-blue-700/40 text-blue-400' },
toolmaker: { label: 'Toolmaker', cls: 'bg-orange-900/30 border border-orange-700/40 text-orange-400' },
}
const VARIANT_INFO = {
avalon: { label: 'Avalon', cls: 'bg-violet-600/30 text-violet-300 border border-violet-600/40' }, 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' }, 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' }, artifact: { label: 'Artifact', cls: 'bg-amber-600/30 text-amber-300 border border-amber-600/40' },
@@ -241,24 +304,30 @@ const inputValue = ref('')
// ─── Bill of Production ─────────────────────────────────────────────────────── // ─── Bill of Production ───────────────────────────────────────────────────────
const addingToBill = ref(false) const addingMode = ref<null | 'nofocus' | 'focus'>(null)
const billQty = ref(1) const billQty = ref(1)
const isInOrder = computed(() => inOrder(props.result.recipe.outputItemId)) const isInOrderNoFocus = computed(() => inOrder(props.result.recipe.outputItemId, false))
const currentQty = computed(() => getQty(props.result.recipe.outputItemId)) const currentQtyNoFocus = computed(() => getQty(props.result.recipe.outputItemId, false))
const isInOrderFocus = computed(() => inOrder(props.result.recipe.outputItemId, true))
const currentQtyFocus = computed(() => getQty(props.result.recipe.outputItemId, true))
function startBill() { function startBill(mode: 'nofocus' | 'focus') {
billQty.value = isInOrder.value ? currentQty.value : 1 const already = mode === 'nofocus' ? isInOrderNoFocus.value : isInOrderFocus.value
addingToBill.value = true const qty = mode === 'nofocus' ? currentQtyNoFocus.value : currentQtyFocus.value
billQty.value = already ? qty : 1
addingMode.value = mode
} }
function confirmBill() { function confirmBill() {
if (billQty.value > 0) upsert(props.result.recipe, billQty.value) if (billQty.value > 0 && addingMode.value) {
addingToBill.value = false upsert(props.result.recipe, billQty.value, addingMode.value === 'focus')
}
addingMode.value = null
} }
function cancelBill() { function cancelBill() {
addingToBill.value = false addingMode.value = null
} }
// ─── Price age display ──────────────────────────────────────────────────────── // ─── Price age display ────────────────────────────────────────────────────────
@@ -290,23 +359,16 @@ function ageDotTitle(result: ProfitResult): string {
// ─── Price cell helpers ─────────────────────────────────────────────────────── // ─── Price cell helpers ───────────────────────────────────────────────────────
function priceButtonClass(itemId: string, currentPrice: number): string { function priceButtonClass(_itemId: string, currentPrice: number): string {
if (isManualPrice(itemId, filters.value.city)) { return currentPrice > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-500 hover:text-amber-400'
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 { function priceTitle(itemId: string, currentPrice: number): string {
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
const entry = getManualEntry(itemId, filters.value.city) const entry = getManualEntry(itemId, filters.value.city)
if (entry && isManualPrice(itemId, filters.value.city)) { if (entry) {
return exact ? `${exact} — set ${formatLastUpdated(new Date(entry.editedAt))}click to edit` : `Set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit` return `${currentPrice.toLocaleString()}set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
} }
return exact ? `${exact} — click to set price` : 'Click to set price' return 'Click to set price'
} }
// ─── Edit state ─────────────────────────────────────────────────────────────── // ─── Edit state ───────────────────────────────────────────────────────────────
@@ -317,8 +379,8 @@ function startEdit(itemId: string, current: number) {
} }
function saveEdit(itemId: string) { function saveEdit(itemId: string) {
const v = Math.round(Number(inputValue.value)) const v = Math.ceil(Number(inputValue.value))
if (!inputValue.value && inputValue.value !== 0) { if (!inputValue.value) {
clearManualPrice(itemId, filters.value.city) clearManualPrice(itemId, filters.value.city)
} else if (v > 0) { } else if (v > 0) {
setManualPrice(itemId, filters.value.city, v) setManualPrice(itemId, filters.value.city, v)

View File

@@ -1,21 +1,63 @@
<template> <template>
<thead class="sticky top-0 z-10"> <thead class="sticky top-0 z-10">
<!-- Group row -->
<tr class="bg-gray-800 border-b border-gray-700/50">
<th colspan="5" class="bg-gray-800" />
<th colspan="4" class="px-4 py-1.5 text-center text-[10px] font-semibold text-gray-500 uppercase tracking-wider border-l border-gray-700/60">
No Focus
</th>
<th colspan="4" class="px-4 py-1.5 text-center text-[10px] font-semibold text-violet-400/70 uppercase tracking-wider border-l border-gray-700/60">
With Focus
</th>
<th colspan="1" class="bg-gray-800" />
</tr>
<!-- Column row -->
<tr class="bg-gray-800 border-b border-gray-700"> <tr class="bg-gray-800 border-b border-gray-700">
<!-- Fixed left cols -->
<th class="px-3 py-2 w-4" />
<th <th
v-for="col in columns" v-for="col in leftCols" :key="col.field"
:key="col.field" class="px-4 py-2 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none"
class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none"
:class="col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : ''" :class="col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : ''"
@click="col.sortable && col.field !== 'status' ? $emit('sort', col.field as SortField) : undefined" @click="col.sortable ? $emit('sort', col.field as SortField) : undefined"
> >
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
{{ col.label }} {{ col.label }}
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400"> <span v-if="sortState.field === col.field" class="text-amber-400">{{ sortState.direction === 'asc' ? '' : '' }}</span>
{{ sortState.direction === 'asc' ? '' : '' }} <span v-else class="text-gray-600"></span>
</span> </span>
</th>
<!-- No-focus group -->
<th
v-for="col in priceCols" :key="'nf-' + col.field"
class="px-4 py-2 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none border-l border-gray-700/60"
:class="[col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : '', col.first ? 'border-l border-gray-700/60' : '']"
@click="col.sortable ? $emit('sort', col.field as SortField) : undefined"
>
<span class="flex items-center gap-1">
{{ col.label }}
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400">{{ sortState.direction === 'asc' ? '' : '' }}</span>
<span v-else-if="col.sortable" class="text-gray-600"></span> <span v-else-if="col.sortable" class="text-gray-600"></span>
</span> </span>
</th> </th>
<th class="px-3 py-2 w-8" />
<!-- With-focus group -->
<th
v-for="col in priceCols" :key="'f-' + col.field"
class="px-4 py-2 text-right text-xs font-semibold text-violet-400/70 uppercase tracking-wider whitespace-nowrap select-none border-l border-gray-700/60"
:class="col.first ? 'border-l border-gray-700/60' : ''"
>
{{ col.label }}
</th>
<th class="px-3 py-2 w-8" />
<!-- Status -->
<th class="px-4 py-2 text-center text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none">
Status
</th>
</tr> </tr>
</thead> </thead>
</template> </template>
@@ -23,23 +65,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SortField, SortState } from '../../types/crafting' import type { SortField, SortState } from '../../types/crafting'
const columns: { field: SortField | 'status' | 'bill' | 'markup15' | 'markup30' | 'expand'; label: string; sortable: boolean }[] = [ const leftCols = [
{ field: 'expand', label: '', sortable: false }, { field: 'variantType' as SortField, label: 'Variant', sortable: true },
{ field: 'variantType', label: 'Variant', sortable: true }, { field: 'displayName' as SortField, label: 'Item', sortable: true },
{ field: 'displayName', label: 'Item', sortable: true }, { field: 'station' as SortField, label: 'Station', sortable: true },
{ field: 'tier', label: 'Tier', sortable: true }, { field: 'tier' as SortField, label: 'Tier', sortable: true },
{ field: 'materialCost', label: 'Cost', sortable: true },
{ field: 'markup15', label: '+15%', sortable: false },
{ field: 'markup30', label: '+30%', sortable: false },
{ field: 'status', label: 'Price Age', sortable: false },
{ field: 'bill', label: '', sortable: false },
] ]
defineProps<{ const priceCols = [
sortState: SortState { field: 'materialCost' as SortField, label: 'Cost', sortable: true, first: true },
}>() { field: 'markup15' as const, label: '+15%', sortable: false, first: false },
{ field: 'markup30' as const, label: '+30%', sortable: false, first: false },
]
defineEmits<{
sort: [field: SortField] defineProps<{ sortState: SortState }>()
}>() defineEmits<{ sort: [field: SortField] }>()
</script> </script>

View File

@@ -1,55 +0,0 @@
import { ref, watch, onUnmounted } from 'vue'
import type { Ref } from 'vue'
import { AUTO_REFRESH_INTERVAL_S } from '../data/constants'
export function useAutoRefresh(fetchFn: () => Promise<void>, enabled: Ref<boolean>) {
const countdown = ref(AUTO_REFRESH_INTERVAL_S)
let intervalId: ReturnType<typeof setInterval> | null = null
let tickId: ReturnType<typeof setInterval> | null = null
function startRefresh() {
stopRefresh()
countdown.value = AUTO_REFRESH_INTERVAL_S
// Countdown tick every second
tickId = setInterval(() => {
countdown.value = Math.max(0, countdown.value - 1)
}, 1000)
// Fetch every interval
intervalId = setInterval(async () => {
await fetchFn()
countdown.value = AUTO_REFRESH_INTERVAL_S
}, AUTO_REFRESH_INTERVAL_S * 1000)
}
function stopRefresh() {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (tickId !== null) {
clearInterval(tickId)
tickId = null
}
}
watch(
enabled,
(val) => {
if (val) {
startRefresh()
} else {
stopRefresh()
}
},
{ immediate: true }
)
onUnmounted(() => {
stopRefresh()
})
return { countdown }
}

View File

@@ -4,6 +4,7 @@ import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } fro
import type { FilterState, VariantType } 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'
import { localProductionBonus, rrrFromBonus, isRrrExempt, FOCUS_LPB } from '../data/cityBonuses'
function variantOf(outputItemId: string): VariantType { function variantOf(outputItemId: string): VariantType {
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
@@ -30,7 +31,6 @@ export function useCraftingProfit(
const profitResults = computed<ProfitResult[]>(() => { const profitResults = computed<ProfitResult[]>(() => {
const f = filters.value const f = filters.value
const city = f.city const city = f.city
const rrrFactor = 1 - f.rrr / 100
const results: ProfitResult[] = [] const results: ProfitResult[] = []
const nameLower = f.nameFilter.trim().toLowerCase() const nameLower = f.nameFilter.trim().toLowerCase()
@@ -39,12 +39,14 @@ export function useCraftingProfit(
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 if (f.variants !== null && !f.variants.has(variantOf(recipe.outputItemId))) continue
if (f.stations !== null && !f.stations.has(recipe.journalType)) 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
// Calculate material cost from ingredients only // Calculate material cost from ingredients only
let materialCost = 0 let basicCost = 0 // resources subject to RRR
let artefactCost = 0 // artefacts / ALCHEMY_RARE — not subject to RRR
let missingPrices = false let missingPrices = false
// Track oldest price date (for status column) // Track oldest price date (for status column)
@@ -70,7 +72,8 @@ export function useCraftingProfit(
} else { } else {
trackDate(ingEntry.sell_price_min_date) trackDate(ingEntry.sell_price_min_date)
const totalCost = unitPrice * ing.quantity const totalCost = unitPrice * ing.quantity
materialCost += totalCost if (isRrrExempt(ing.itemId)) artefactCost += totalCost
else basicCost += totalCost
ingredientBreakdown.push({ ingredientBreakdown.push({
itemId: ing.itemId, itemId: ing.itemId,
displayName: formatItemId(ing.itemId), displayName: formatItemId(ing.itemId),
@@ -81,13 +84,21 @@ export function useCraftingProfit(
} }
} }
const effectiveMaterialCost = materialCost * rrrFactor const materialCost = basicCost + artefactCost
const lpb = localProductionBonus(city, recipe.outputItemId)
const rrr = rrrFromBonus(lpb)
const rrrFocus = rrrFromBonus(lpb + FOCUS_LPB)
const effectiveMaterialCost = basicCost * (1 - rrr / 100) + artefactCost
const effectiveMaterialCostFocus = basicCost * (1 - rrrFocus / 100) + artefactCost
const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null) const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null)
results.push({ results.push({
recipe, recipe,
materialCost, materialCost,
effectiveMaterialCost, effectiveMaterialCost,
effectiveMaterialCostFocus,
rrr,
rrrFocus,
priceAgeMs, priceAgeMs,
missingPrices, missingPrices,
ingredientBreakdown, ingredientBreakdown,
@@ -117,6 +128,10 @@ export function useCraftingProfit(
aVal = variantRank(a.recipe.outputItemId) aVal = variantRank(a.recipe.outputItemId)
bVal = variantRank(b.recipe.outputItemId) bVal = variantRank(b.recipe.outputItemId)
break break
case 'station':
aVal = a.recipe.journalType
bVal = b.recipe.journalType
break
default: default:
aVal = a.effectiveMaterialCost aVal = a.effectiveMaterialCost
bVal = b.effectiveMaterialCost bVal = b.effectiveMaterialCost

View File

@@ -1,7 +1,7 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import type { FilterState, VariantType } from '../types/filters' import type { FilterState, VariantType } from '../types/filters'
import { ALL_VARIANTS } from '../types/filters' import { ALL_VARIANTS, ALL_STATIONS } from '../types/filters'
import type { Tier, Enchantment } from '../types/crafting' import type { Tier, Enchantment, JournalType } from '../types/crafting'
import type { AlbionCity } from '../types/api' import type { AlbionCity } from '../types/api'
import { ENCHANTMENTS, TIERS } from '../data/constants' import { ENCHANTMENTS, TIERS } from '../data/constants'
@@ -11,10 +11,10 @@ const STORAGE_KEY = 'albion-filters'
interface StoredFilters { interface StoredFilters {
city?: AlbionCity city?: AlbionCity
rrr?: number
tiers?: Tier[] tiers?: Tier[]
enchantments?: Enchantment[] | null enchantments?: Enchantment[] | null
variants?: VariantType[] | null variants?: VariantType[] | null
stations?: JournalType[] | null
selectedItemTypes?: string[] | null selectedItemTypes?: string[] | null
} }
@@ -31,10 +31,10 @@ function buildInitialState(): FilterState {
const s = loadStored() const s = loadStored()
return { return {
city: s.city ?? 'Caerleon', city: s.city ?? 'Caerleon',
rrr: (s.rrr != null && s.rrr >= 0 && s.rrr <= 100) ? s.rrr : 15,
tiers: s.tiers ? new Set(s.tiers) : new Set(TIERS), tiers: s.tiers ? new Set(s.tiers) : new Set(TIERS),
enchantments: s.enchantments === undefined ? null : (s.enchantments === null ? null : new Set(s.enchantments)), enchantments: s.enchantments === undefined ? null : (s.enchantments === null ? null : new Set(s.enchantments)),
variants: s.variants === undefined ? null : (s.variants === null ? null : new Set(s.variants)), variants: s.variants === undefined ? null : (s.variants === null ? null : new Set(s.variants)),
stations: s.stations === undefined ? null : (s.stations === null ? null : new Set(s.stations)),
selectedItemTypes: s.selectedItemTypes === undefined ? null : (s.selectedItemTypes === null ? null : new Set(s.selectedItemTypes)), selectedItemTypes: s.selectedItemTypes === undefined ? null : (s.selectedItemTypes === null ? null : new Set(s.selectedItemTypes)),
nameFilter: '', nameFilter: '',
} }
@@ -48,10 +48,10 @@ watch(filters, () => {
const f = filters.value const f = filters.value
const toStore: StoredFilters = { const toStore: StoredFilters = {
city: f.city, city: f.city,
rrr: f.rrr,
tiers: [...f.tiers], tiers: [...f.tiers],
enchantments: f.enchantments === null ? null : [...f.enchantments], enchantments: f.enchantments === null ? null : [...f.enchantments],
variants: f.variants === null ? null : [...f.variants], variants: f.variants === null ? null : [...f.variants],
stations: f.stations === null ? null : [...f.stations],
selectedItemTypes: f.selectedItemTypes === null ? null : [...f.selectedItemTypes], selectedItemTypes: f.selectedItemTypes === null ? null : [...f.selectedItemTypes],
} }
try { try {
@@ -79,10 +79,6 @@ function setSelectedItemTypes(value: Set<string> | null) {
filters.value.selectedItemTypes = value filters.value.selectedItemTypes = value
} }
function setRrr(rate: number) {
filters.value.rrr = Math.max(0, Math.min(100, rate))
}
function setNameFilter(value: string) { function setNameFilter(value: string) {
filters.value.nameFilter = value filters.value.nameFilter = value
} }
@@ -117,17 +113,33 @@ function resetVariants() {
filters.value.variants = null filters.value.variants = null
} }
function toggleStation(station: JournalType) {
const current = filters.value.stations
const next = current === null ? new Set(ALL_STATIONS) : new Set(current)
if (next.has(station)) {
next.delete(station)
} else {
next.add(station)
}
filters.value.stations = next.size === ALL_STATIONS.length ? null : next
}
function resetStations() {
filters.value.stations = null
}
export function useFilters() { export function useFilters() {
return { return {
filters, filters,
setCity, setCity,
toggleTier, toggleTier,
setSelectedItemTypes, setSelectedItemTypes,
setRrr,
setNameFilter, setNameFilter,
toggleEnchantment, toggleEnchantment,
resetEnchantments, resetEnchantments,
toggleVariant, toggleVariant,
resetVariants, resetVariants,
toggleStation,
resetStations,
} }
} }

View File

@@ -5,12 +5,17 @@ import { ALL_RECIPES } from '../data/recipes'
export interface OrderItem { export interface OrderItem {
recipe: CraftingRecipe recipe: CraftingRecipe
qty: number qty: number
focus: boolean
} }
const STORAGE_KEY = 'albion-production-order' const STORAGE_KEY = 'albion-production-order'
const recipeIndex = new Map(ALL_RECIPES.map(r => [r.outputItemId, r])) const recipeIndex = new Map(ALL_RECIPES.map(r => [r.outputItemId, r]))
function orderKey(outputItemId: string, focus: boolean): string {
return `${outputItemId}|${focus ? 'f' : 'b'}`
}
function load(): Map<string, OrderItem> { function load(): Map<string, OrderItem> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@@ -19,7 +24,10 @@ function load(): Map<string, OrderItem> {
const result: [string, OrderItem][] = [] const result: [string, OrderItem][] = []
for (const i of items) { for (const i of items) {
const recipe = recipeIndex.get(i.recipe.outputItemId) const recipe = recipeIndex.get(i.recipe.outputItemId)
if (recipe) result.push([recipe.outputItemId, { recipe, qty: i.qty }]) if (recipe) {
const focus = i.focus ?? false
result.push([orderKey(recipe.outputItemId, focus), { recipe, qty: i.qty, focus }])
}
} }
return new Map(result) return new Map(result)
} }
@@ -39,17 +47,18 @@ const order = ref<Map<string, OrderItem>>(load())
// ─── Mutations ──────────────────────────────────────────────────────────────── // ─── Mutations ────────────────────────────────────────────────────────────────
function upsert(recipe: CraftingRecipe, qty: number): void { function upsert(recipe: CraftingRecipe, qty: number, focus: boolean): void {
if (qty <= 0) { remove(recipe.outputItemId); return } const key = orderKey(recipe.outputItemId, focus)
if (qty <= 0) { remove(recipe.outputItemId, focus); return }
const next = new Map(order.value) const next = new Map(order.value)
next.set(recipe.outputItemId, { recipe, qty }) next.set(key, { recipe, qty, focus })
order.value = next order.value = next
save(next) save(next)
} }
function remove(outputItemId: string): void { function remove(outputItemId: string, focus: boolean): void {
const next = new Map(order.value) const next = new Map(order.value)
next.delete(outputItemId) next.delete(orderKey(outputItemId, focus))
order.value = next order.value = next
save(next) save(next)
} }
@@ -64,12 +73,12 @@ function clear(): void {
const orderItems = computed(() => [...order.value.values()]) const orderItems = computed(() => [...order.value.values()])
const orderCount = computed(() => order.value.size) const orderCount = computed(() => order.value.size)
function getQty(outputItemId: string): number { function getQty(outputItemId: string, focus: boolean): number {
return order.value.get(outputItemId)?.qty ?? 0 return order.value.get(orderKey(outputItemId, focus))?.qty ?? 0
} }
function inOrder(outputItemId: string): boolean { function inOrder(outputItemId: string, focus: boolean): boolean {
return order.value.has(outputItemId) return order.value.has(orderKey(outputItemId, focus))
} }
// ─── Public API ─────────────────────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────────────────────

56
src/data/cityBonuses.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { AlbionCity } from '../types/api'
// Source: Albion Online Wiki — Production Bonuses
// Formula: RRR% = (1 - 1 / (1 + LPB/100)) × 100
//
// Royal cities base LPB: 18% → RRR ≈ 15.3%
// Specialization bonus: +15% → total 33% LPB → RRR ≈ 24.8%
// Black Market (not a crafting location): 0% LPB → RRR = 0%
const BASE_ROYAL_LPB = 18
const SPECIALIZATION_BONUS = 15
export const FOCUS_LPB = 59 // flat bonus added when using focus
const ROYAL_CITIES = new Set<AlbionCity>([
'Fort Sterling', 'Lymhurst', 'Bridgewatch', 'Martlock', 'Thetford', 'Caerleon',
])
// Each regex matches all output item IDs that receive the +15% specialization bonus
// in that city, including artifact / avalon / crystal variants.
const SPECIALIZATION_PATTERNS: Partial<Record<AlbionCity, RegExp>> = {
'Fort Sterling': /_MAIN_HAMMER|_2H_HAMMER|_2H_POLEHAMMER|_2H_DUALHAMMER|_2H_RAM_|_MAIN_SPEAR|_2H_SPEAR|_2H_GLAIVE|_2H_HARPOON|_2H_TRIDENT|_HOLYSTAFF|_DIVINESTAFF|_HEAD_PLATE_|_ARMOR_CLOTH_/,
'Lymhurst': /_MAIN_SWORD|_2H_CLAYMORE|_2H_DUALSWORD|_SCIMITAR|_2H_CLEAVER|_2H_BOW|_LONGBOW|_WARBOW|_ARCANESTAFF|_ENIGMATICSTAFF|_ENIGMATICORB|_ARCANE_RINGPAIR|_HEAD_LEATHER_|_SHOES_LEATHER_/,
'Bridgewatch': /_CROSSBOW|_1HCROSSBOW|_DAGGER|_CLAWPAIR|_RAPIER|_DUALSICKLE|_CURSEDSTAFF|_DEMONICSTAFF|_SKULLORB|_ARMOR_PLATE_|_SHOES_CLOTH_/,
'Martlock': /_MAIN_AXE|_2H_AXE|_2H_HALBERD|_2H_SCYTHE|_2H_DUALAXE|_QUARTERSTAFF|_IRONCLADEDSTAFF|_DOUBLEBLADEDSTAFF|_COMBATSTAFF|_TWINSCYTHE|_ROCKSTAFF|_FROSTSTAFF|_GLACIALSTAFF|_ICEGAUNTLETS|_ICECRYSTAL|_SHOES_PLATE_|_OFF_/,
'Thetford': /_MAIN_MACE|_2H_MACE|_2H_FLAIL|_ROCKMACE|_2H_DUALMACE|_NATURESTAFF|_WILDSTAFF|_FIRESTAFF|_INFERNOSTAFF|_FIRE_RINGPAIR|_ARMOR_LEATHER_|_HEAD_CLOTH_/,
'Caerleon': /_2H_KNUCKLES|_SHAPESHIFTER|_2H_TOOL_/,
}
// Returns the Local Production Bonus (%) for crafting this item in the given city.
export function localProductionBonus(city: AlbionCity, outputItemId: string): number {
if (!ROYAL_CITIES.has(city)) return 0 // Black Market: no bonus
const pattern = SPECIALIZATION_PATTERNS[city]
const baseId = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
const isSpecialized = pattern ? pattern.test(baseId) : false
return BASE_ROYAL_LPB + (isSpecialized ? SPECIALIZATION_BONUS : 0)
}
// Converts a Local Production Bonus (%) to the resulting Resource Return Rate (%).
// Formula: RRR = 1 - 1/(1 + LPB/100)
export function rrrFromBonus(lpb: number): number {
if (lpb === 0) return 0
return (1 - 1 / (1 + lpb / 100)) * 100
}
// Returns the effective RRR (%) for crafting this item in the given city.
export function cityRrr(city: AlbionCity, outputItemId: string): number {
return rrrFromBonus(localProductionBonus(city, outputItemId))
}
// Returns true for ingredients that are NOT subject to RRR (artefacts, ALCHEMY_RARE).
// Only basic resources (bars, planks, leather, cloth) are returned by the city bonus.
export function isRrrExempt(itemId: string): boolean {
return itemId.includes('_ARTEFACT_') || itemId.includes('_ALCHEMY_RARE_')
}

View File

@@ -5,18 +5,8 @@
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<h2 class="text-lg font-semibold text-gray-200">Price Editor</h2> <h2 class="text-lg font-semibold text-gray-200">Price Editor</h2>
<div class="h-5 w-px bg-gray-600" /> <div class="h-5 w-px bg-gray-600" />
<div class="flex items-center gap-1.5"> <span class="text-xs text-gray-500">
<span class="text-xs text-gray-400">City</span> Amber = price set · Click any price to edit · Leave empty to clear
<select
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
:value="filters.city"
@change="setCity(($event.target as HTMLSelectElement).value as AlbionCity)"
>
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<span class="text-xs text-gray-500 ml-2">
Amber = manual override · Click any price to edit · Empty field to clear override
</span> </span>
</div> </div>
@@ -26,12 +16,15 @@
<button <button
v-for="mat in MATERIALS" v-for="mat in MATERIALS"
:key="mat.name" :key="mat.name"
class="px-3 py-1 rounded-lg text-xs font-medium transition-colors border" class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border"
:class="activeMaterial === mat.name :class="activeMaterial === mat.name
? 'bg-amber-500/15 border-amber-500/60 text-amber-300' ? 'bg-amber-500/15 border-amber-500/60 text-amber-300'
: 'bg-transparent border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'" : 'bg-transparent border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'"
@click="setActiveMaterial(mat.name)" @click="setActiveMaterial(mat.name)"
>{{ mat.name }}</button> >
<img :src="itemImageUrl(mat.baseId(4))" :alt="mat.name" class="w-6 h-6 rounded" />
{{ mat.name }}
</button>
</div> </div>
</div> </div>
@@ -56,11 +49,13 @@
<div <div
v-for="enc in (tier >= 4 ? ENCHANTMENTS : [0])" v-for="enc in (tier >= 4 ? ENCHANTMENTS : [0])"
:key="enc" :key="enc"
class="px-4 py-3" class="px-4 py-3 flex items-center gap-2"
> >
<div class="text-[11px] font-semibold mb-2" :style="enchantTextStyle(enc)"> <img
.{{ enc }} :src="itemImageUrl(enchantedId(activeMaterialDef.baseId(tier), enc))"
</div> :alt="`${activeMaterialDef.name} .${enc}`"
class="w-8 h-8 rounded flex-shrink-0"
/>
<PriceCell :item-id="enchantedId(activeMaterialDef.baseId(tier), enc)" /> <PriceCell :item-id="enchantedId(activeMaterialDef.baseId(tier), enc)" />
</div> </div>
</div> </div>
@@ -72,17 +67,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { tierStyle, enchantTextStyle } from '../utils/formatting' import { tierStyle, itemImageUrl } from '../utils/formatting'
import PriceCell from '../components/PriceCell.vue' import PriceCell from '../components/PriceCell.vue'
import { useFilters } from '../composables/useFilters' import { ENCHANTMENTS } from '../data/constants'
import { CITIES, ENCHANTMENTS } from '../data/constants'
import type { AlbionCity, } from '../types/api'
import type { Enchantment } from '../types/crafting'
const { filters, setCity } = useFilters() function enchantedId(baseId: string, enchant: number): string {
return enchant === 0 ? baseId : `${baseId}_LEVEL${enchant}`
function enchantedId(baseId: string, enchant: Enchantment): string {
return enchant === 0 ? baseId : `${baseId}@${enchant}`
} }
interface MaterialDef { interface MaterialDef {

View File

@@ -23,16 +23,33 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<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-xs font-semibold text-gray-400 uppercase tracking-wider">Item</th> <th
<th class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28">Qty</th> class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
<th class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Cost / unit</th> @click="toggleSort('name')"
><span class="flex items-center gap-1">Item <span class="text-amber-400">{{ sortIndicator('name') }}</span></span></th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('station')"
><span class="flex items-center gap-1">Station <span class="text-amber-400">{{ sortIndicator('station') }}</span></span></th>
<th
class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('focus')"
><span class="flex items-center gap-1">Focus <span class="text-amber-400">{{ sortIndicator('focus') }}</span></span></th>
<th
class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28 cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('qty')"
><span class="flex items-center justify-center gap-1">Qty <span class="text-amber-400">{{ sortIndicator('qty') }}</span></span></th>
<th
class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors select-none"
@click="toggleSort('cost')"
><span class="flex items-center justify-end gap-1">Cost / unit <span class="text-amber-400">{{ sortIndicator('cost') }}</span></span></th>
<th class="w-8"></th> <th class="w-8"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="item in computedItems" v-for="item in sortedItems"
:key="item.recipe.outputItemId" :key="`${item.recipe.outputItemId}|${item.focus ? 'f' : 'b'}`"
class="border-t border-gray-700/40 hover:bg-gray-700/20" class="border-t border-gray-700/40 hover:bg-gray-700/20"
> >
<td class="px-4 py-2"> <td class="px-4 py-2">
@@ -43,16 +60,22 @@
class="w-12 h-12 -my-2 rounded flex-shrink-0" class="w-12 h-12 -my-2 rounded flex-shrink-0"
/> />
<span class="text-gray-200 font-medium">{{ item.recipe.displayName }}</span> <span class="text-gray-200 font-medium">{{ item.recipe.displayName }}</span>
<span class="text-xs text-gray-500">{{ item.recipe.category }}</span>
</div> </div>
</td> </td>
<td class="px-4 py-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold" :class="stationClass(item.recipe.journalType)">{{ stationLabel(item.recipe.journalType) }}</span>
</td>
<td class="px-4 py-2">
<span v-if="item.focus" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-violet-600/20 border border-violet-600/40 text-violet-400">Focus</span>
<span v-else class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-gray-700/50 border border-gray-600/40 text-gray-400">No Focus</span>
</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<input <input
type="number" type="number"
min="1" min="1"
:value="item.qty" :value="item.qty"
class="w-20 bg-gray-900 border border-gray-700 rounded px-2 py-0.5 text-center text-sm font-mono text-gray-200 focus:outline-none focus:border-amber-500" class="w-20 bg-gray-900 border border-gray-700 rounded px-2 py-0.5 text-center text-sm font-mono text-gray-200 focus:outline-none focus:border-amber-500"
@change="upsert(item.recipe, Number(($event.target as HTMLInputElement).value))" @change="upsert(item.recipe, Number(($event.target as HTMLInputElement).value), item.focus)"
/> />
</td> </td>
<td class="px-4 py-2 text-right font-mono text-gray-300"> <td class="px-4 py-2 text-right font-mono text-gray-300">
@@ -63,7 +86,7 @@
<button <button
class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none" class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none"
title="Remove" title="Remove"
@click="remove(item.recipe.outputItemId)" @click="remove(item.recipe.outputItemId, item.focus)"
></button> ></button>
</td> </td>
</tr> </tr>
@@ -179,7 +202,7 @@
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span> <span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
</div> </div>
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<span class="text-gray-400">RRR savings ({{ filters.rrr }}%)</span> <span class="text-gray-400">RRR savings</span>
<span class="text-green-400">{{ formatSilver(totals.rrrSavings) }}</span> <span class="text-green-400">{{ formatSilver(totals.rrrSavings) }}</span>
</div> </div>
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2"> <div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
@@ -207,14 +230,15 @@ 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, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting' import { formatSilver, formatItemId, formatLastUpdated, itemImageUrl } from '../utils/formatting'
import { fameTypeOf, craftsPerJournal } from '../data/constants' import { fameTypeOf, craftsPerJournal } from '../data/constants'
import { localProductionBonus, rrrFromBonus, FOCUS_LPB, isRrrExempt } from '../data/cityBonuses'
import type { Tier, JournalType } from '../types/crafting' import type { Tier, JournalType } from '../types/crafting'
const vFocus = { mounted: (el: HTMLElement) => el.focus() } const vFocus = { mounted: (el: HTMLElement) => el.focus() }
const { orderItems, upsert, remove, clear } = useProductionOrder() const { orderItems, upsert, remove, clear } = useProductionOrder()
const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices() const { getPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
const { filters } = useFilters() const { filters } = useFilters()
// ─── Inline price editing ───────────────────────────────────────────────────── // ─── Inline price editing ─────────────────────────────────────────────────────
@@ -243,23 +267,55 @@ function cancelEdit() {
inputValue.value = '' inputValue.value = ''
} }
function priceButtonClass(itemId: string, currentPrice: number): string { function priceButtonClass(_itemId: string, currentPrice: number): string {
if (isManualPrice(itemId, filters.value.city)) return 'text-amber-400 hover:text-amber-200' return currentPrice > 0 ? 'text-amber-400 hover:text-amber-200' : 'text-gray-500 hover:text-amber-400'
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 { function priceTitle(itemId: string, currentPrice: number): string {
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
const entry = getManualEntry(itemId, filters.value.city) const entry = getManualEntry(itemId, filters.value.city)
if (entry && isManualPrice(itemId, filters.value.city)) { if (entry) {
return exact return `${currentPrice.toLocaleString()} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
? `${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' return 'Click to set price'
} }
// ─── Order sort ───────────────────────────────────────────────────────────────
type OrderSortField = 'name' | 'station' | 'focus' | 'qty' | 'cost'
const orderSortField = ref<OrderSortField>('name')
const orderSortDir = ref<'asc' | 'desc'>('asc')
function toggleSort(field: OrderSortField) {
if (orderSortField.value === field) {
orderSortDir.value = orderSortDir.value === 'asc' ? 'desc' : 'asc'
} else {
orderSortField.value = field
orderSortDir.value = 'asc'
}
}
function sortIndicator(field: OrderSortField): string {
if (orderSortField.value !== field) return ''
return orderSortDir.value === 'asc' ? '↑' : '↓'
}
const STATION_LABEL: Record<JournalType, string> = {
warrior: "Warrior's Forge",
hunter: "Hunter's Lodge",
mage: "Mage's Tower",
toolmaker: "Toolmaker's Workshop",
}
const STATION_CLASS: Record<JournalType, string> = {
warrior: 'bg-red-900/30 border border-red-700/40 text-red-400',
hunter: 'bg-green-900/30 border border-green-700/40 text-green-400',
mage: 'bg-blue-900/30 border border-blue-700/40 text-blue-400',
toolmaker: 'bg-orange-900/30 border border-orange-700/40 text-orange-400',
}
function stationLabel(type: JournalType): string { return STATION_LABEL[type] }
function stationClass(type: JournalType): string { return STATION_CLASS[type] }
const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = { const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = {
warrior: "Blacksmith's", warrior: "Blacksmith's",
hunter: "Fletcher's", hunter: "Fletcher's",
@@ -277,11 +333,11 @@ const JOURNAL_ITEM_ID: Record<JournalType, string> = {
// ─── Per-item computed values ───────────────────────────────────────────────── // ─── Per-item computed values ─────────────────────────────────────────────────
const computedItems = computed(() => { const computedItems = computed(() => {
const city = filters.value.city const city = filters.value.city
const rrrFactor = 1 - filters.value.rrr / 100
return orderItems.value.map(({ recipe, qty }) => { return orderItems.value.map(({ recipe, qty, focus }) => {
let rawCost = 0 let basicCost = 0
let artefactCost = 0
let missingPrices = false let missingPrices = false
for (const ing of recipe.ingredients) { for (const ing of recipe.ingredients) {
@@ -289,12 +345,30 @@ const computedItems = computed(() => {
if (!entry || entry.sell_price_min === 0) { if (!entry || entry.sell_price_min === 0) {
missingPrices = true missingPrices = true
} else { } else {
rawCost += entry.sell_price_min * ing.quantity if (isRrrExempt(ing.itemId)) artefactCost += entry.sell_price_min * ing.quantity
else basicCost += entry.sell_price_min * ing.quantity
} }
} }
const craftCostPerUnit = rawCost * rrrFactor const rawCost = basicCost + artefactCost
return { recipe, qty, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, missingPrices } const lpb = localProductionBonus(city, recipe.outputItemId)
const rrr = rrrFromBonus(focus ? lpb + FOCUS_LPB : lpb)
const craftCostPerUnit = basicCost * (1 - rrr / 100) + artefactCost
return { recipe, qty, focus, rawCostPerUnit: rawCost, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, rrr, missingPrices }
})
})
const sortedItems = computed(() => {
const dir = orderSortDir.value === 'asc' ? 1 : -1
return [...computedItems.value].sort((a, b) => {
switch (orderSortField.value) {
case 'name': return dir * a.recipe.displayName.localeCompare(b.recipe.displayName)
case 'station': return dir * a.recipe.journalType.localeCompare(b.recipe.journalType)
case 'focus': return dir * (Number(a.focus) - Number(b.focus))
case 'qty': return dir * (a.qty - b.qty)
case 'cost': return dir * (a.craftCostPerUnit - b.craftCostPerUnit)
default: return 0
}
}) })
}) })
@@ -323,24 +397,23 @@ const aggregatedMaterials = computed(() => {
// ─── Totals + journals ──────────────────────────────────────────────────────── // ─── Totals + journals ────────────────────────────────────────────────────────
const totals = computed(() => { const totals = computed(() => {
const rrrFactor = 1 - filters.value.rrr / 100
let rawCost = 0 let rawCost = 0
let effectiveCost = 0
let totalCrafts = 0 let totalCrafts = 0
let totalItems = 0 let totalItems = 0
for (const item of computedItems.value) { for (const item of computedItems.value) {
if (!item.missingPrices) { if (!item.missingPrices) {
rawCost += item.craftCostPerUnit / rrrFactor * item.qty // back to raw rawCost += item.rawCostPerUnit * item.qty
effectiveCost += item.craftCostPerUnit * item.qty
} }
totalCrafts += item.qty totalCrafts += item.qty
totalItems += item.qty totalItems += item.qty
} }
const effectiveCost = rawCost * rrrFactor const rrrSavings = rawCost - effectiveCost
const rrrSavings = rawCost - effectiveCost
const totalJournals = Math.ceil(totalCrafts / CRAFTS_PER_JOURNAL)
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems, totalJournals } return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems }
}) })
const journalRows = computed(() => { const journalRows = computed(() => {

View File

@@ -7,7 +7,7 @@ export type JournalType = 'warrior' | 'mage' | 'hunter' | 'toolmaker'
export type FameType = 'twoHanded' | 'oneHanded' | 'armorChest' | 'small' export type FameType = 'twoHanded' | 'oneHanded' | 'armorChest' | 'small'
export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType' export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType' | 'station'
export type SortDirection = 'asc' | 'desc' export type SortDirection = 'asc' | 'desc'
@@ -43,7 +43,10 @@ export interface IngredientBreakdown {
export interface ProfitResult { export interface ProfitResult {
recipe: CraftingRecipe recipe: CraftingRecipe
materialCost: number materialCost: number
effectiveMaterialCost: number effectiveMaterialCost: number // without focus
effectiveMaterialCostFocus: number // with focus
rrr: number // RRR % without focus
rrrFocus: number // RRR % with focus
priceAgeMs: number | null // null = missing prices; ms since oldest manual entry priceAgeMs: number | null // null = missing prices; ms since oldest manual entry
missingPrices: boolean missingPrices: boolean
ingredientBreakdown: IngredientBreakdown[] ingredientBreakdown: IngredientBreakdown[]

View File

@@ -1,16 +1,18 @@
import type { AlbionCity } from './api' import type { AlbionCity } from './api'
import type { Tier, Enchantment } from './crafting' import type { Tier, Enchantment, JournalType } from './crafting'
export type VariantType = 'basic' | 'artifact' | 'avalon' | 'crystal' export type VariantType = 'basic' | 'artifact' | 'avalon' | 'crystal'
export const ALL_VARIANTS: VariantType[] = ['basic', 'artifact', 'avalon', 'crystal'] export const ALL_VARIANTS: VariantType[] = ['basic', 'artifact', 'avalon', 'crystal']
export const ALL_STATIONS: JournalType[] = ['warrior', 'hunter', 'mage', 'toolmaker']
export interface FilterState { export interface FilterState {
city: AlbionCity city: AlbionCity
tiers: Set<Tier> tiers: Set<Tier>
selectedItemTypes: Set<string> | null selectedItemTypes: Set<string> | null
rrr: number
nameFilter: string nameFilter: string
enchantments: Set<Enchantment> | null enchantments: Set<Enchantment> | null
variants: Set<VariantType> | null variants: Set<VariantType> | null
stations: Set<JournalType> | null
} }

View File

@@ -219,7 +219,7 @@ export function enchantTextStyle(enc: number): { color: string } {
return { color: map[enc] ?? '#9ca3af' } return { color: map[enc] ?? '#9ca3af' }
} }
export function tierEnchantStyle(tier: number, enchantment: number): object { export function tierEnchantStyle(tier: number, enchantment: number): Record<string, string> {
const base = tierStyle(tier) const base = tierStyle(tier)
if (enchantment === 0) return base if (enchantment === 0) return base
return { return {
@@ -244,14 +244,15 @@ export function tierStyle(tier: number): { backgroundColor: string; color: strin
} }
export function formatSilver(value: number): string { export function formatSilver(value: number): string {
if (value === 0) return '0' const v = Math.ceil(value)
if (Math.abs(value) >= 1_000_000) { if (v === 0) return '0'
return (value / 1_000_000).toFixed(2) + 'M' if (Math.abs(v) >= 1_000_000) {
return (v / 1_000_000).toFixed(2) + 'M'
} }
if (Math.abs(value) >= 1_000) { if (Math.abs(v) >= 1_000) {
return (value / 1_000).toFixed(1) + 'k' return (v / 1_000).toFixed(1) + 'k'
} }
return value.toLocaleString() return v.toLocaleString()
} }
export function formatRoi(roi: number): string { export function formatRoi(roi: number): string {