From d8386dccf462681be8604b0cb802f7e28ae97348 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 5 Mar 2026 02:48:33 -0500 Subject: [PATCH] improve the bill of production --- src/components/table/ProfitRow.vue | 139 ++++++++++++++++++-------- src/components/table/TableHeader.vue | 84 +++++++++++----- src/composables/useCraftingProfit.ts | 9 +- src/composables/useProductionOrder.ts | 29 ++++-- src/data/cityBonuses.ts | 1 + src/pages/PricesPage.vue | 17 +--- src/pages/ProductionPage.vue | 99 +++++++++++++++--- src/types/crafting.ts | 6 +- 8 files changed, 279 insertions(+), 105 deletions(-) diff --git a/src/components/table/ProfitRow.vue b/src/components/table/ProfitRow.vue index 41f23af..ed8014c 100644 --- a/src/components/table/ProfitRow.vue +++ b/src/components/table/ProfitRow.vue @@ -41,36 +41,26 @@ - {{ formatSilver(result.effectiveMaterialCost) }} - - + {{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.15)) : '—' }} - - + {{ result.effectiveMaterialCost > 0 ? formatSilver(Math.round(result.effectiveMaterialCost * 1.30)) : '—' }} - - - - - - + - -
+
- -
+
+ @click="startBill('nofocus')" + >×{{ currentQtyNoFocus }}
- + + + + {{ formatSilver(result.effectiveMaterialCostFocus) }} + + + + + {{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.15)) : '—' }} + + + + + {{ result.effectiveMaterialCostFocus > 0 ? formatSilver(Math.round(result.effectiveMaterialCostFocus * 1.30)) : '—' }} + + + + +
+ + + +
+
+ + +
+ + + + + + + - +
@@ -184,13 +233,13 @@ Raw Materials {{ formatSilver(result.materialCost) }}
-
- RRR ({{ result.rrr.toFixed(1) }}%) - −{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }} -
- Craft Cost - {{ formatSilver(result.effectiveMaterialCost) }} + RRR ({{ result.rrr.toFixed(1) }}%) + {{ formatSilver(result.effectiveMaterialCost) }} +
+
+ RRR + Focus ({{ result.rrrFocus.toFixed(1) }}%) + {{ formatSilver(result.effectiveMaterialCostFocus) }}
@@ -241,24 +290,30 @@ const inputValue = ref('') // ─── Bill of Production ─────────────────────────────────────────────────────── -const addingToBill = ref(false) -const billQty = ref(1) +const addingMode = ref(null) +const billQty = ref(1) -const isInOrder = computed(() => inOrder(props.result.recipe.outputItemId)) -const currentQty = computed(() => getQty(props.result.recipe.outputItemId)) +const isInOrderNoFocus = computed(() => inOrder(props.result.recipe.outputItemId, false)) +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() { - billQty.value = isInOrder.value ? currentQty.value : 1 - addingToBill.value = true +function startBill(mode: 'nofocus' | 'focus') { + const already = mode === 'nofocus' ? isInOrderNoFocus.value : isInOrderFocus.value + const qty = mode === 'nofocus' ? currentQtyNoFocus.value : currentQtyFocus.value + billQty.value = already ? qty : 1 + addingMode.value = mode } function confirmBill() { - if (billQty.value > 0) upsert(props.result.recipe, billQty.value) - addingToBill.value = false + if (billQty.value > 0 && addingMode.value) { + upsert(props.result.recipe, billQty.value, addingMode.value === 'focus') + } + addingMode.value = null } function cancelBill() { - addingToBill.value = false + addingMode.value = null } // ─── Price age display ──────────────────────────────────────────────────────── diff --git a/src/components/table/TableHeader.vue b/src/components/table/TableHeader.vue index b6dec9b..cfe48ff 100644 --- a/src/components/table/TableHeader.vue +++ b/src/components/table/TableHeader.vue @@ -1,21 +1,63 @@ @@ -23,23 +65,19 @@ diff --git a/src/composables/useCraftingProfit.ts b/src/composables/useCraftingProfit.ts index 3e7ea7a..6b4e12a 100644 --- a/src/composables/useCraftingProfit.ts +++ b/src/composables/useCraftingProfit.ts @@ -4,7 +4,7 @@ import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } fro import type { FilterState, VariantType } from '../types/filters' import { useAlbionPrices } from './useAlbionPrices' import { formatItemId } from '../utils/formatting' -import { cityRrr, isRrrExempt } from '../data/cityBonuses' +import { localProductionBonus, rrrFromBonus, isRrrExempt, FOCUS_LPB } from '../data/cityBonuses' function variantOf(outputItemId: string): VariantType { const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix @@ -84,15 +84,20 @@ export function useCraftingProfit( } const materialCost = basicCost + artefactCost - const rrr = cityRrr(city, recipe.outputItemId) + 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) results.push({ recipe, materialCost, effectiveMaterialCost, + effectiveMaterialCostFocus, rrr, + rrrFocus, priceAgeMs, missingPrices, ingredientBreakdown, diff --git a/src/composables/useProductionOrder.ts b/src/composables/useProductionOrder.ts index 9ed2fa8..f4e1f80 100644 --- a/src/composables/useProductionOrder.ts +++ b/src/composables/useProductionOrder.ts @@ -5,12 +5,17 @@ import { ALL_RECIPES } from '../data/recipes' export interface OrderItem { recipe: CraftingRecipe qty: number + focus: boolean } const STORAGE_KEY = 'albion-production-order' 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 { try { const raw = localStorage.getItem(STORAGE_KEY) @@ -19,7 +24,10 @@ function load(): Map { const result: [string, OrderItem][] = [] for (const i of items) { 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) } @@ -39,17 +47,18 @@ const order = ref>(load()) // ─── Mutations ──────────────────────────────────────────────────────────────── -function upsert(recipe: CraftingRecipe, qty: number): void { - if (qty <= 0) { remove(recipe.outputItemId); return } +function upsert(recipe: CraftingRecipe, qty: number, focus: boolean): void { + const key = orderKey(recipe.outputItemId, focus) + if (qty <= 0) { remove(recipe.outputItemId, focus); return } const next = new Map(order.value) - next.set(recipe.outputItemId, { recipe, qty }) + next.set(key, { recipe, qty, focus }) order.value = next save(next) } -function remove(outputItemId: string): void { +function remove(outputItemId: string, focus: boolean): void { const next = new Map(order.value) - next.delete(outputItemId) + next.delete(orderKey(outputItemId, focus)) order.value = next save(next) } @@ -64,12 +73,12 @@ function clear(): void { const orderItems = computed(() => [...order.value.values()]) const orderCount = computed(() => order.value.size) -function getQty(outputItemId: string): number { - return order.value.get(outputItemId)?.qty ?? 0 +function getQty(outputItemId: string, focus: boolean): number { + return order.value.get(orderKey(outputItemId, focus))?.qty ?? 0 } -function inOrder(outputItemId: string): boolean { - return order.value.has(outputItemId) +function inOrder(outputItemId: string, focus: boolean): boolean { + return order.value.has(orderKey(outputItemId, focus)) } // ─── Public API ─────────────────────────────────────────────────────────────── diff --git a/src/data/cityBonuses.ts b/src/data/cityBonuses.ts index cc06246..871e159 100644 --- a/src/data/cityBonuses.ts +++ b/src/data/cityBonuses.ts @@ -9,6 +9,7 @@ import type { AlbionCity } from '../types/api' 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([ 'Fort Sterling', 'Lymhurst', 'Bridgewatch', 'Martlock', 'Thetford', 'Caerleon', diff --git a/src/pages/PricesPage.vue b/src/pages/PricesPage.vue index 418db86..cc3b60e 100644 --- a/src/pages/PricesPage.vue +++ b/src/pages/PricesPage.vue @@ -5,17 +5,7 @@

Price Editor

-
- City - -
- + Amber = manual override · Click any price to edit · Empty field to clear override
@@ -80,11 +70,10 @@ import { ref, computed } from 'vue' import { tierStyle, itemImageUrl } from '../utils/formatting' import PriceCell from '../components/PriceCell.vue' import { useFilters } from '../composables/useFilters' -import { CITIES, ENCHANTMENTS } from '../data/constants' -import type { AlbionCity, } from '../types/api' +import { ENCHANTMENTS } from '../data/constants' import type { Enchantment } from '../types/crafting' -const { filters, setCity } = useFilters() +const { filters } = useFilters() function enchantedId(baseId: string, enchant: Enchantment): string { return enchant === 0 ? baseId : `${baseId}_LEVEL${enchant}@${enchant}` diff --git a/src/pages/ProductionPage.vue b/src/pages/ProductionPage.vue index 3a64d57..0ba172f 100644 --- a/src/pages/ProductionPage.vue +++ b/src/pages/ProductionPage.vue @@ -23,16 +23,33 @@ - - - + + + + + + + @@ -209,7 +232,7 @@ import { useAlbionPrices } from '../composables/useAlbionPrices' import { useFilters } from '../composables/useFilters' import { formatSilver, formatItemId, formatLastUpdated, tierStyle, itemImageUrl } from '../utils/formatting' import { fameTypeOf, craftsPerJournal } from '../data/constants' -import { cityRrr, isRrrExempt } from '../data/cityBonuses' +import { localProductionBonus, rrrFromBonus, FOCUS_LPB, isRrrExempt } from '../data/cityBonuses' import type { Tier, JournalType } from '../types/crafting' const vFocus = { mounted: (el: HTMLElement) => el.focus() } @@ -261,6 +284,43 @@ function priceTitle(itemId: string, currentPrice: number): string { return exact ? `${exact} — click to set price` : 'Click to set price' } +// ─── Order sort ─────────────────────────────────────────────────────────────── + +type OrderSortField = 'name' | 'station' | 'focus' | 'qty' | 'cost' +const orderSortField = ref('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 = { + warrior: "Warrior's Forge", + hunter: "Hunter's Lodge", + mage: "Mage's Tower", + toolmaker: "Toolmaker's Workshop", +} + +const STATION_CLASS: Record = { + 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 = { warrior: "Blacksmith's", hunter: "Fletcher's", @@ -280,7 +340,7 @@ const JOURNAL_ITEM_ID: Record = { const computedItems = computed(() => { const city = filters.value.city - return orderItems.value.map(({ recipe, qty }) => { + return orderItems.value.map(({ recipe, qty, focus }) => { let basicCost = 0 let artefactCost = 0 let missingPrices = false @@ -296,9 +356,24 @@ const computedItems = computed(() => { } const rawCost = basicCost + artefactCost - const rrr = cityRrr(city, recipe.outputItemId) + const lpb = localProductionBonus(city, recipe.outputItemId) + const rrr = rrrFromBonus(focus ? lpb + FOCUS_LPB : lpb) const craftCostPerUnit = basicCost * (1 - rrr / 100) + artefactCost - return { recipe, qty, rawCostPerUnit: rawCost, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, rrr, missingPrices } + 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 + } }) }) diff --git a/src/types/crafting.ts b/src/types/crafting.ts index 7813e94..2cff13f 100644 --- a/src/types/crafting.ts +++ b/src/types/crafting.ts @@ -43,8 +43,10 @@ export interface IngredientBreakdown { export interface ProfitResult { recipe: CraftingRecipe materialCost: number - effectiveMaterialCost: number - rrr: number // effective RRR % for this item in the selected city + 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 missingPrices: boolean ingredientBreakdown: IngredientBreakdown[]
ItemQtyCost / unitItem {{ sortIndicator('name') }}Station {{ sortIndicator('station') }}Focus {{ sortIndicator('focus') }}Qty {{ sortIndicator('qty') }}Cost / unit {{ sortIndicator('cost') }}
@@ -43,16 +60,22 @@ class="w-12 h-12 -my-2 rounded flex-shrink-0" /> {{ item.recipe.displayName }} - {{ item.recipe.category }} + {{ stationLabel(item.recipe.journalType) }} + + Focus + No Focus + @@ -63,7 +86,7 @@