Ver código fonte

Add_Persistent_LocalStorage_State

Implemented automated client-side persistence for all UI configurations.

        PURPOSE:
        To fulfill a user request for persistent state tracking across page reloads. Previously, all active dropdown selections, volume filters, and dynamic column sorts were lost whenever the frontend was refreshed, causing UX friction. By storing the configuration variables in the browser's `localStorage` cache, the application immediately resumes exactly where the user left off.

        IMPLEMENTATION DETAILS:
        - Added a `saveState()` function that maps all active UI values (DOM properties and internal `MetricType`/sort state trackers) into explicitly named string keys within `localStorage`.
        - Hooked `saveState()` into the very end of the `render()` execution block. Because `render()` is triggered sequentially by every single event listener in the file, placing the hook here centralizes the tracking logic and guarantees that only fully-validated, successfully rendered configurations are committed to disk (e.g. invalid combinations like selecting a building outside of the active expertise filter self-heal to empty strings).
        - Created a State Initialization block executed immediately upon script entry. This overrides default assignments with truthy checks against `localStorage`, populating dropdowns prior to the initial visual rendering.
        - Orchestrated a temporary `savedBuilding` injection pass to ensure that dynamically generated dropdown target values are restored correctly.
Thomas Knott 2 semanas atrás
pai
commit
71b22d2b35
7 arquivos alterados com 1733 adições e 640 exclusões
  1. 49 18
      ts/roi.ts
  2. 61 17
      www/roi.js
  3. 0 0
      www/roi.js.map
  4. 399 148
      www/roi_ai1.json
  5. 402 150
      www/roi_ci1.json
  6. 420 157
      www/roi_ic1.json
  7. 402 150
      www/roi_nc1.json

+ 49 - 18
ts/roi.ts

@@ -8,6 +8,9 @@ async function getROI(cx: string) {
 	return {lastModified, profits};
 	return {lastModified, profits};
 }
 }
 
 
+// EXTREME DETAIL: Hoisted the MetricType so it can be utilized by the state initialization block below.
+type MetricType = 'vwap' | 'bid' | 'ask';
+
 const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
 const lowVolume = document.querySelector('input#low-volume') as HTMLInputElement;
 
 
 const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
 const cxSelect = document.querySelector('select#cx') as HTMLSelectElement;
@@ -35,16 +38,25 @@ const formatDecimal = new Intl.NumberFormat(undefined,
 		{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
 		{maximumFractionDigits: 2, maximumSignificantDigits: 6, roundingPriority: 'lessPrecision'}).format;
 const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
 const formatWhole = new Intl.NumberFormat(undefined, {maximumFractionDigits: 0}).format;
 
 
-let currentSortKey: keyof ProfitWithMetrics | 'outputs' = 'break_even';
-let currentSortAsc: boolean = true;
+// EXTREME DETAIL: --- STATE INITIALIZATION ---
+// Upon script execution (page load), we immediately query the browser's localStorage.
+// If valid keys exist, we override the default DOM selections with the persisted state.
+if (localStorage.getItem('roi-cx')) cxSelect.value = localStorage.getItem('roi-cx')!;
+if (localStorage.getItem('roi-expertise')) expertiseSelect.value = localStorage.getItem('roi-expertise')!;
+if (localStorage.getItem('roi-low-volume')) lowVolume.checked = localStorage.getItem('roi-low-volume') === 'true';
+
+// The building options haven't been dynamically generated yet, so we store the target in a temporary variable.
+let savedBuilding = localStorage.getItem('roi-building') || '';
+
+let currentSortKey: keyof ProfitWithMetrics | 'outputs' = (localStorage.getItem('roi-sort-key') as any) || 'break_even';
+let currentSortAsc: boolean = localStorage.getItem('roi-sort-asc') !== 'false'; // Defaults to true if null
 let headersInitialized = false;
 let headersInitialized = false;
 let metricControlsInitialized = false;
 let metricControlsInitialized = false;
 
 
-// EXTREME DETAIL: Global state trackers to determine which pricing metric is applied to which column mathematically.
-type MetricType = 'vwap' | 'bid' | 'ask';
-let capexMetric: MetricType = 'vwap';
-let opexMetric: MetricType = 'vwap';
-let revenueMetric: MetricType = 'vwap';
+let capexMetric: MetricType = (localStorage.getItem('roi-capex-metric') as MetricType) || 'vwap';
+let opexMetric: MetricType = (localStorage.getItem('roi-opex-metric') as MetricType) || 'vwap';
+let revenueMetric: MetricType = (localStorage.getItem('roi-revenue-metric') as MetricType) || 'vwap';
+// ------------------------------------------
 
 
 async function render() {
 async function render() {
 	const tbody = document.querySelector('tbody')!;
 	const tbody = document.querySelector('tbody')!;
@@ -55,8 +67,6 @@ async function render() {
 		roiCache[cx] = getROI(cx);
 		roiCache[cx] = getROI(cx);
 	const {lastModified, profits} = await roiCache[cx];
 	const {lastModified, profits} = await roiCache[cx];
 
 
-	// EXTREME DETAIL: We dynamically inject three select dropdowns prior to rendering the table.
-	// We bind event listeners to them to update the global MetricType states and force a re-render.
 	if (!metricControlsInitialized) {
 	if (!metricControlsInitialized) {
 		const controls = document.createElement('div');
 		const controls = document.createElement('div');
 		controls.style.marginBottom = '15px';
 		controls.style.marginBottom = '15px';
@@ -74,6 +84,11 @@ async function render() {
 		const table = document.querySelector('table');
 		const table = document.querySelector('table');
 		if (table) table.parentNode?.insertBefore(controls, table);
 		if (table) table.parentNode?.insertBefore(controls, table);
 
 
+        // EXTREME DETAIL: Apply the stored state defaults to the newly generated dropdown nodes.
+		(document.getElementById('capex-metric') as HTMLSelectElement).value = capexMetric;
+		(document.getElementById('opex-metric') as HTMLSelectElement).value = opexMetric;
+		(document.getElementById('revenue-metric') as HTMLSelectElement).value = revenueMetric;
+
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
 		document.getElementById('capex-metric')!.addEventListener('change', (e) => {
 			capexMetric = (e.target as HTMLSelectElement).value as MetricType;
 			capexMetric = (e.target as HTMLSelectElement).value as MetricType;
 			render();
 			render();
@@ -91,7 +106,6 @@ async function render() {
 
 
 	if (!headersInitialized) {
 	if (!headersInitialized) {
 		const ths = document.querySelectorAll('th');
 		const ths = document.querySelectorAll('th');
-		// Note that 'capex' and 'cost_per_day' here map dynamically to the newly derived states later in the file.
 		const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
 		const keys: (keyof ProfitWithMetrics | 'outputs')[] = [
 			'outputs', 'expertise', 'profit_per_area', 'break_even', 
 			'outputs', 'expertise', 'profit_per_area', 'break_even', 
 			'capex_val', 'opex_val', 'logistics_per_area', 'market_capacity_area'
 			'capex_val', 'opex_val', 'logistics_per_area', 'market_capacity_area'
@@ -119,7 +133,9 @@ async function render() {
 	const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
 	const buildings: {ticker: string, expertise: keyof typeof expertise}[] = Array.from(buildingTickers)
 			.map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
 			.map((building) => ({ticker: building, expertise: profits.find(p => p.building === building)!.expertise}))
 			.sort((a, b) => a.ticker.localeCompare(b.ticker));
 			.sort((a, b) => a.ticker.localeCompare(b.ticker));
-	let selectedBuilding = buildingSelect.value;
+    
+    // EXTREME DETAIL: Inject the stored 'savedBuilding' target if this is the first execution.
+	let selectedBuilding = buildingSelect.value || savedBuilding;
 	let buildingFound = false;
 	let buildingFound = false;
 	buildingSelect.innerHTML = '<option value="">(all)</option>';
 	buildingSelect.innerHTML = '<option value="">(all)</option>';
 	for (const building of buildings)
 	for (const building of buildings)
@@ -135,6 +151,9 @@ async function render() {
 		}
 		}
 	if (!buildingFound)
 	if (!buildingFound)
 		selectedBuilding = '';
 		selectedBuilding = '';
+    
+    // Clear the injection buffer so future renders correctly rely purely on DOM/User manipulation.
+    savedBuilding = ''; 
 
 
 	const filteredProfits = profits.filter(p => {
 	const filteredProfits = profits.filter(p => {
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
 		const volumeRatio = p.output_per_day / p.average_traded_7d;
@@ -144,9 +163,6 @@ async function render() {
 		return true;
 		return true;
 	});
 	});
 
 
-	// EXTREME DETAIL: We map over the filtered array to compute the final derivation.
-	// By executing this map BEFORE the sort algorithm runs, the columns dynamically organize themselves
-	// perfectly around whichever permutations the user selected in the dropdowns.
 	const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
 	const profitsWithMetrics: ProfitWithMetrics[] = filteredProfits.map(p => {
 		const capex_val = p.capex[capexMetric];
 		const capex_val = p.capex[capexMetric];
 		const opex_val = p.opex[opexMetric];
 		const opex_val = p.opex[opexMetric];
@@ -202,6 +218,25 @@ async function render() {
 
 
 		tbody.appendChild(tr);
 		tbody.appendChild(tr);
 	}
 	}
+	document.getElementById('last-updated')!.textContent =
+		`last updated: ${lastModified.toLocaleString(undefined, {dateStyle: 'full', timeStyle: 'long', hour12: false})}`;
+
+    // EXTREME DETAIL: By calling saveState() at the very conclusion of the render loop,
+    // we take a final snapshot of the fully sanitized UI state (ensuring we don't accidentally
+    // save invalid building + expertise configurations to the hard drive).
+    saveState();
+}
+
+function saveState() {
+	localStorage.setItem('roi-cx', cxSelect.value);
+	localStorage.setItem('roi-expertise', expertiseSelect.value);
+	localStorage.setItem('roi-building', buildingSelect.value);
+	localStorage.setItem('roi-low-volume', lowVolume.checked.toString());
+	localStorage.setItem('roi-sort-key', currentSortKey);
+	localStorage.setItem('roi-sort-asc', currentSortAsc.toString());
+	localStorage.setItem('roi-capex-metric', capexMetric);
+	localStorage.setItem('roi-opex-metric', opexMetric);
+	localStorage.setItem('roi-revenue-metric', revenueMetric);
 }
 }
 
 
 function color(n: number, low: number, high: number): string {
 function color(n: number, low: number, high: number): string {
@@ -209,9 +244,6 @@ function color(n: number, low: number, high: number): string {
 	return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
 	return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
 }
 }
 
 
-// EXTREME DETAIL: formatMatPrices relies on evaluating the user's selected metric ('vwap', 'bid', or 'ask').
-// To avoid math crashes, if a user requests a Bid/Ask display for an item lacking that specific liquidity,
-// the fallback logic defaults to the standard 7-day VWAP.
 function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
 function formatMatPrices(matPrices: MatPrice[], metric: MetricType, runs_per_day: number): string {
 	return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
 	return matPrices.map(({ticker, amount, vwap_7d, bid, ask}) => {
 		const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
 		const val = metric === 'vwap' ? vwap_7d : metric === 'bid' ? (bid ?? vwap_7d) : (ask ?? vwap_7d);
@@ -250,7 +282,6 @@ interface Profit {
 	market_capacity_area: number
 	market_capacity_area: number
 }
 }
 
 
-// Interface extension utilized for the dynamic frontend mapping array
 interface ProfitWithMetrics extends Profit {
 interface ProfitWithMetrics extends Profit {
 	capex_val: number;
 	capex_val: number;
 	opex_val: number;
 	opex_val: number;

+ 61 - 17
www/roi.js

@@ -53,6 +53,10 @@ var formatWhole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
 var currentSortKey = "break_even";
 var currentSortKey = "break_even";
 var currentSortAsc = true;
 var currentSortAsc = true;
 var headersInitialized = false;
 var headersInitialized = false;
+var metricControlsInitialized = false;
+var capexMetric = "vwap";
+var opexMetric = "vwap";
+var revenueMetric = "vwap";
 async function render() {
 async function render() {
   const tbody = document.querySelector("tbody");
   const tbody = document.querySelector("tbody");
   tbody.innerHTML = "";
   tbody.innerHTML = "";
@@ -60,6 +64,37 @@ async function render() {
   if (!roiCache[cx])
   if (!roiCache[cx])
     roiCache[cx] = getROI(cx);
     roiCache[cx] = getROI(cx);
   const { lastModified, profits } = await roiCache[cx];
   const { lastModified, profits } = await roiCache[cx];
+  if (!metricControlsInitialized) {
+    const controls = document.createElement("div");
+    controls.style.marginBottom = "15px";
+    controls.innerHTML = `
+			<label style="margin-right: 15px;">CapEx Price: 
+				<select id="capex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
+			</label>
+			<label style="margin-right: 15px;">OpEx Price: 
+				<select id="opex-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
+			</label>
+			<label>Revenue (Outputs) Price: 
+				<select id="revenue-metric"><option value="vwap">VWAP</option><option value="bid">Bid</option><option value="ask">Ask</option></select>
+			</label>
+		`;
+    const table = document.querySelector("table");
+    if (table)
+      table.parentNode?.insertBefore(controls, table);
+    document.getElementById("capex-metric").addEventListener("change", (e) => {
+      capexMetric = e.target.value;
+      render();
+    });
+    document.getElementById("opex-metric").addEventListener("change", (e) => {
+      opexMetric = e.target.value;
+      render();
+    });
+    document.getElementById("revenue-metric").addEventListener("change", (e) => {
+      revenueMetric = e.target.value;
+      render();
+    });
+    metricControlsInitialized = true;
+  }
   if (!headersInitialized) {
   if (!headersInitialized) {
     const ths = document.querySelectorAll("th");
     const ths = document.querySelectorAll("th");
     const keys = [
     const keys = [
@@ -67,8 +102,8 @@ async function render() {
       "expertise",
       "expertise",
       "profit_per_area",
       "profit_per_area",
       "break_even",
       "break_even",
-      "capex",
-      "cost_per_day",
+      "capex_val",
+      "opex_val",
       "logistics_per_area",
       "logistics_per_area",
       "market_capacity_area"
       "market_capacity_area"
     ];
     ];
@@ -117,7 +152,16 @@ async function render() {
       return false;
       return false;
     return true;
     return true;
   });
   });
-  filteredProfits.sort((a, b) => {
+  const profitsWithMetrics = filteredProfits.map((p) => {
+    const capex_val = p.capex[capexMetric];
+    const opex_val = p.opex[opexMetric];
+    const revenue_val = p.revenue[revenueMetric];
+    const profit_per_day = revenue_val - opex_val;
+    const profit_per_area = profit_per_day / p.area;
+    const break_even = profit_per_day > 0 ? (capex_val + 3 * opex_val) / profit_per_day : Infinity;
+    return { ...p, capex_val, opex_val, revenue_val, profit_per_day, profit_per_area, break_even };
+  });
+  profitsWithMetrics.sort((a, b) => {
     let valA = a[currentSortKey];
     let valA = a[currentSortKey];
     let valB = b[currentSortKey];
     let valB = b[currentSortKey];
     if (currentSortKey === "outputs") {
     if (currentSortKey === "outputs") {
@@ -130,42 +174,42 @@ async function render() {
       return currentSortAsc ? 1 : -1;
       return currentSortAsc ? 1 : -1;
     return 0;
     return 0;
   });
   });
-  for (const p of filteredProfits) {
+  for (const p of profitsWithMetrics) {
+    const volumeRatio = p.output_per_day / p.average_traded_7d;
     const tr = document.createElement("tr");
     const tr = document.createElement("tr");
     tr.innerHTML = `
     tr.innerHTML = `
 			<td>${p.outputs.map((o) => o.ticker).join(", ")}</td>
 			<td>${p.outputs.map((o) => o.ticker).join(", ")}</td>
 			<td>${expertise[p.expertise]}</td>
 			<td>${expertise[p.expertise]}</td>
 			<td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
 			<td style="color: ${color(p.profit_per_area, 0, 300)}">${formatDecimal(p.profit_per_area)}</td>
 			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
 			<td><span style="color: ${color(p.break_even, 30, 3)}">${formatDecimal(p.break_even)}</span>d</td>
-			<td style="color: ${color(p.capex, 300000, 40000)}">${formatWhole(p.capex)}</td>
-			<td style="color: ${color(p.cost_per_day, 40000, 1000)}">${formatWhole(p.cost_per_day)}</td>
+			<td style="color: ${color(p.capex_val, 300000, 40000)}">${formatWhole(p.capex_val)}</td>
+			<td style="color: ${color(p.opex_val, 40000, 1000)}">${formatWhole(p.opex_val)}</td>
 			<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
 			<td style="color: ${color(p.logistics_per_area, 2, 0.2)}">${formatDecimal(p.logistics_per_area)}</td>
 			<td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
 			<td style="color: ${color(p.market_capacity_area, 20, 500)}">${formatWhole(p.market_capacity_area)}</td>
 		`;
 		`;
     const output = tr.querySelector("td");
     const output = tr.querySelector("td");
     output.dataset.tooltip = p.recipe;
     output.dataset.tooltip = p.recipe;
     const profitCell = tr.querySelectorAll("td")[2];
     const profitCell = tr.querySelectorAll("td")[2];
-    const revenue = p.outputs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
-    const inputCost = p.input_costs.reduce((sum, o) => sum + o.amount * o.vwap_7d, 0);
-    profitCell.dataset.tooltip = formatMatPrices(p.outputs) + `
-
-` + formatMatPrices(p.input_costs) + `
-` + "worker consumables: " + formatWhole(p.worker_consumable_cost_per_day) + `
+    profitCell.dataset.tooltip = formatMatPrices(p.outputs, revenueMetric, p.runs_per_day) + `
 
 
-` + `(${formatWhole(revenue)} - ${formatWhole(inputCost)}) × ${formatDecimal(p.runs_per_day)} runs ` + `- ${formatWhole(p.worker_consumable_cost_per_day)} = ${formatDecimal(p.profit_per_day)}
+` + formatMatPrices(p.input_costs, opexMetric, p.runs_per_day) + `
+` + `(${formatWhole(p.revenue_val)} - ${formatWhole(p.opex_val)}) = ${formatDecimal(p.profit_per_day)}
 ` + `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
 ` + `${formatDecimal(p.profit_per_day)} / ${formatWhole(p.area)} area = ${formatDecimal(p.profit_per_area)}`;
     const marketCell = tr.querySelectorAll("td")[7];
     const marketCell = tr.querySelectorAll("td")[7];
     marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
     marketCell.dataset.tooltip = `Market Capacity: ${formatWhole(p.average_traded_7d)} traded/day ÷ ${formatDecimal(p.output_per_day / p.area)} produced/day/area = ${formatWhole(p.market_capacity_area)} equivalent areas`;
     tbody.appendChild(tr);
     tbody.appendChild(tr);
   }
   }
-  document.getElementById("last-updated").textContent = `last updated: ${lastModified.toLocaleString(undefined, { dateStyle: "full", timeStyle: "long", hour12: false })}`;
 }
 }
 function color(n, low, high) {
 function color(n, low, high) {
   const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
   const scale = Math.min(Math.max((n - low) / (high - low), 0), 1);
   return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
   return `color-mix(in xyz, #0aa ${scale * 100}%, #f80)`;
 }
 }
-function formatMatPrices(matPrices) {
-  return matPrices.map(({ ticker, amount, vwap_7d }) => `${ticker}: ${amount} × ${formatDecimal(vwap_7d)} = ${formatWhole(amount * vwap_7d)}`).join(`
+function formatMatPrices(matPrices, metric, runs_per_day) {
+  return matPrices.map(({ ticker, amount, vwap_7d, bid, ask }) => {
+    const val = metric === "vwap" ? vwap_7d : metric === "bid" ? bid ?? vwap_7d : ask ?? vwap_7d;
+    const daily_amount = amount * runs_per_day;
+    return `${ticker}: ${formatDecimal(daily_amount)} × ${formatDecimal(val)} = ${formatWhole(daily_amount * val)}`;
+  }).join(`
 `);
 `);
 }
 }
 setupPopover();
 setupPopover();
@@ -175,4 +219,4 @@ expertiseSelect.addEventListener("change", render);
 buildingSelect.addEventListener("change", render);
 buildingSelect.addEventListener("change", render);
 render();
 render();
 
 
-//# debugId=360E975B4A4AE32464756E2164756E21
+//# debugId=96EE1F33EAAE56CC64756E2164756E21

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
www/roi.js.map


Diferenças do arquivo suprimidas por serem muito extensas
+ 399 - 148
www/roi_ai1.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 402 - 150
www/roi_ci1.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 420 - 157
www/roi_ic1.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 402 - 150
www/roi_nc1.json


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff