Currency by locale (the project lead, 7 October 2026: "£/day is for UK; we need other currencies"): no IP lookup; the web view's language (the OS locale) picks the region, its currency and its default tariff at first run (GBP, EUR, USD, CAD, AUD and the long tail by ISO region through ISO_CURRENCY, names from Intl.DisplayNames, an EU member on the euro takes the EU average), a currency picker in Settings and on the first-run step whose override lives in the engine's settings file (config.rs currency, state.rs, Cmd::Region's third field, api/region currency; the Rust round-trip test) and wins over the locale; a region with no currency row falls back to USD with the prompt visible; every money line goes through one formatter (fmtMoney: Intl.NumberFormat with the code, narrowSymbol, the symbol placed as the locale places it); the user's own tariff is the number, so no exchange rate anywhere (the rent line says so for a currency without a USD rate); zero-decimal currencies typed in the unit (priceFactor), the price field's ceiling 100,000; three view tests known-failed first (de-DE reads EUR with the Eurostat tariff; the override wins and survives restart; a locale with no row asks); the mock's euro scenario

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 16:22:37 +00:00
parent b7734b733e
commit 74630524f5
9 changed files with 236 additions and 39 deletions

View file

@ -121,6 +121,10 @@ pub struct Settings {
/// idle offset of the card sensor above the room (0 = the default).
#[serde(default)]
pub region: String,
/// currency-21 (7 October 2026): the ISO 4217 code the miner chose in Settings ("" = follow the region, which
/// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here.
#[serde(default)]
pub currency: String,
#[serde(default)]
pub heat_on: bool,
#[serde(default = "nineteen")]
@ -182,7 +186,7 @@ fn yes() -> bool {
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false }
}
}
@ -441,6 +445,20 @@ impl Runtime {
#[cfg(test)]
mod tests {
#[test]
fn currency_round_trip() {
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""
let mut s = super::Settings::default();
s.currency = "EUR".into();
s.region = "de".into();
let text = serde_json::to_string(&s).unwrap();
let back: super::Settings = serde_json::from_str(&text).unwrap();
assert_eq!(back.currency, "EUR");
assert_eq!(back.region, "de");
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
assert_eq!(old.currency, "");
}
use super::*;
fn tmp(name: &str, content: &str) -> PathBuf {

View file

@ -102,7 +102,7 @@ pub enum Cmd {
/// one card's goal override (key, goal; "" = back to the global goal)
TuneCardGoal(String, String),
/// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step)
Region(Option<String>, Option<f64>),
Region(Option<String>, Option<f64>, Option<String>),
/// Ember Heat: the switch, the set point, the schedule text with the window's clock offset, a typed room reading
Heat(HeatPatch),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
@ -189,7 +189,7 @@ impl Shared {
st.mining.accepted_total = settings.accepted_total;
st.mining.fee_total = settings.fee_total;
st.address = address_state(&settings, &wallet_path);
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin, prove_instead: settings.prove_instead };
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), currency: settings.currency.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin, prove_instead: settings.prove_instead };
st.heat = heat_state_off(settings.heat_on, settings.heat_set_c);
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
st.live_page = packaged.live_page.clone();
@ -1531,8 +1531,8 @@ impl Engine {
drop(st);
self.shared.event("info", &format!("tune goal: {}{}{}", s.tune_goal, if s.power_price_pence > 0.0 { format!(", electricity {:.1} p/kWh", s.power_price_pence) } else { String::new() }, if s.tune_climb { ", hill-climb on" } else { "" }));
}
Cmd::Region(region, price) => {
let (r, p) = {
Cmd::Region(region, price, currency) => {
let (r, p, c) = {
let mut s = self.shared.settings.lock().unwrap();
if let Some(r) = &region {
s.region = r.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(16).collect();
@ -1540,15 +1540,21 @@ impl Engine {
if let Some(p) = price {
s.power_price_pence = p;
}
(s.region.clone(), s.power_price_pence)
if let Some(c) = &currency {
// an ISO 4217 code: three ASCII letters, upper-cased; anything else means "follow the region"
let code: String = c.chars().filter(|ch| ch.is_ascii_alphabetic()).take(3).collect::<String>().to_ascii_uppercase();
s.currency = if code.len() == 3 { code } else { String::new() };
}
(s.region.clone(), s.power_price_pence, s.currency.clone())
};
self.shared.save_settings();
{
let mut st = self.st();
st.settings.region = r.clone();
st.settings.power_price_pence = p;
st.settings.currency = c.clone();
}
self.shared.event("info", &format!("electricity: region {}{}", if r.is_empty() { "not chosen".to_string() } else { r }, if p > 0.0 { format!(", {p:.2} per kWh (typed, never fetched)") } else { String::new() }));
self.shared.event("info", &format!("electricity: region {}{}{}", if r.is_empty() { "not chosen".to_string() } else { r }, if p > 0.0 { format!(", {p:.2} per kWh (typed, never fetched)") } else { String::new() }, if c.is_empty() { String::new() } else { format!(", currency {c}") }));
}
Cmd::Heat(patch) => {
let mut bad: Option<String> = None;

View file

@ -316,8 +316,10 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
"/api/region" => {
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched)
let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
shared.send(Cmd::Region(region, price));
// hundredths of the chosen unit per kWh: up to 100,000 so a zero-decimal currency (yen, won) fits
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=100_000.0).contains(p));
let currency = body.get("currency").and_then(|v| v.as_str()).map(|c| c.to_string());
shared.send(Cmd::Region(region, price, currency));
Ok(json!({ "ok": true }))
}
"/api/heat" => {

View file

@ -363,6 +363,8 @@ pub struct SettingsState {
/// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as
/// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
pub region: String,
/// currency-21: the ISO 4217 override from the settings file ("" = follow the region)
pub currency: String,
pub heat_on: bool,
pub heat_set_c: f64,
pub heat_schedule: String,

View file

@ -853,3 +853,4 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.gpu-line .who .gen{margin:-1px 0 1px}
#bc-card{display:flex;align-items:center;min-width:0}
#bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
.help.ask-cur{color:var(--ember)}

View file

@ -442,8 +442,15 @@ var View = (function () {
// ---- money (miner-ui-3): £ a day from watts at pence per kWh; null when either is unknown ----
function poundsPerDay(watts, pence) { return watts > 0 && pence > 0 ? watts * 24 / 1000 * pence / 100 : null; }
// the currency symbol follows the region (Ember Heat, setRegion); £ until a region is chosen
var currency = { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 };
function money(v) { if (v === null || v === undefined || isNaN(v)) return ''; return currency.symbol + (v >= 100 ? String(Math.round(v)) : v.toFixed(2)); }
var currency = { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 }, locale = 'en-GB', override = '', regionCode = '';
// the ONE money formatter (currency-21, 7 October 2026): Intl places the symbol as the locale does; whole units from 100
function fmtMoney(v, cur, loc) {
if (v === null || v === undefined || isNaN(v)) return '';
var d = v >= 100 ? 0 : minorOf(cur).digits;
try { return new Intl.NumberFormat(loc || locale, { style: 'currency', currency: cur, currencyDisplay: 'narrowSymbol', minimumFractionDigits: d, maximumFractionDigits: d }).format(v).replace(/[\u00a0\u202f]/g, ' '); }
catch (e) { return (CURRENCIES[cur] || CURRENCIES.GBP).symbol + (d ? v.toFixed(d) : String(Math.round(v))); }
}
function money(v) { return fmtMoney(v, currency.code, locale); }
// MH per watt: the engine's live figure, else rate over draw
function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; }
function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; }
@ -872,17 +879,83 @@ var View = (function () {
{ code: 'cn', name: 'China', cur: 'USD', price: 0, restricted: 'Mining is illegal in China. The joint Notice of 6 February 2026 reaffirmed it.' },
{ code: 'other', name: 'Somewhere else', cur: 'USD', price: 0 }
];
function regionOf(code) { for (var i = 0; i < REGIONS.length; i++) if (REGIONS[i].code === code) return REGIONS[i]; return null; }
function currencyOf(code) { var r = regionOf(code); return CURRENCIES[r ? r.cur : 'GBP'] || CURRENCIES.GBP; }
// the money symbol follows the region from here on (every £ figure on every page)
function setRegion(code) { currency = currencyOf(code); return currency; }
var synthRegions = {};
function regionOf(code) {
for (var i = 0; i < REGIONS.length; i++) if (REGIONS[i].code === code) return REGIONS[i];
if (!/^[a-z]{2}$/.test(code || '')) return null;
if (synthRegions[code]) return synthRegions[code];
var cur = ISO_CURRENCY[code] || '', eu = EU_MEMBERS.indexOf(code) >= 0 && cur === 'EUR', avg = null;
for (var j = 0; j < REGIONS.length; j++) if (REGIONS[j].code === 'eu') avg = REGIONS[j];
return (synthRegions[code] = { code: code, name: regionName(code), cur: cur || 'USD', unknown: !cur, price: eu && avg ? avg.price : 0, source: eu && avg ? avg.source : '' });
}
function currencyOf(code) { var r = regionOf(code); return currencyInfo(r ? r.cur : 'GBP') || CURRENCIES.GBP; }
// the money symbol follows the region, unless an override is set (every money figure on every page)
function setRegion(code) { regionCode = code || ''; currency = (override && currencyInfo(override)) || currencyOf(regionCode); return currency; }
function setCurrency(code) { override = code && currencyInfo(code) ? String(code).toUpperCase() : ''; return setRegion(regionCode); }
function setLocale(l) { locale = l || 'en-GB'; return locale; }
function currencyNow() { return currency; }
// ---------- currency by locale (currency-21, the project lead, 7 October 2026: "£/day is for UK; we need other currencies") ----------
// No IP lookups: the OS locale's region (navigator.language in the app's web view) picks the region row, its currency
// and its default tariff at first run; a Settings override (settings.currency, in the engine's settings file) wins
// and survives restart; the user's own tariff is the number, so no exchange rate exists anywhere. The long tail of
// regions is synthesised from ISO_CURRENCY with Intl.DisplayNames for the name; an EU member without its own row
// takes the EU average (euro cents) when it uses the euro; a region with no currency row falls back to USD and asks.
var ISO_CURRENCY = { at: 'EUR', be: 'EUR', cy: 'EUR', de: 'EUR', ee: 'EUR', es: 'EUR', fi: 'EUR', fr: 'EUR', gr: 'EUR', hr: 'EUR', ie: 'EUR', it: 'EUR', lt: 'EUR', lu: 'EUR', lv: 'EUR', mt: 'EUR', nl: 'EUR', pt: 'EUR', si: 'EUR', sk: 'EUR',
gb: 'GBP', us: 'USD', ca: 'CAD', au: 'AUD', nz: 'NZD', ch: 'CHF', li: 'CHF', se: 'SEK', no: 'NOK', dk: 'DKK', is: 'ISK', pl: 'PLN', cz: 'CZK', hu: 'HUF', ro: 'RON', bg: 'BGN', rs: 'RSD', tr: 'TRY', ua: 'UAH', ge: 'GEL', kz: 'KZT', ru: 'RUB',
jp: 'JPY', kr: 'KRW', cn: 'CNY', hk: 'HKD', tw: 'TWD', sg: 'SGD', my: 'MYR', th: 'THB', vn: 'VND', ph: 'PHP', id: 'IDR', in: 'INR', pk: 'PKR', bd: 'BDT', ae: 'AED', sa: 'SAR', il: 'ILS', ir: 'IRR',
za: 'ZAR', ng: 'NGN', ke: 'KES', eg: 'EGP', ma: 'MAD', et: 'ETB', br: 'BRL', mx: 'MXN', ar: 'ARS', cl: 'CLP', co: 'COP', pe: 'PEN', py: 'PYG' };
var EU_MEMBERS = ['at', 'be', 'bg', 'hr', 'cy', 'cz', 'dk', 'ee', 'fi', 'fr', 'de', 'gr', 'hu', 'ie', 'it', 'lv', 'lt', 'lu', 'mt', 'nl', 'pl', 'pt', 'ro', 'sk', 'si', 'es', 'se'];
var CURRENCY_CODES = ['GBP', 'EUR', 'USD', 'CAD', 'AUD'];
(function () { var seen = {}; CURRENCY_CODES.forEach(function (c) { seen[c] = 1; }); var rest = []; for (var k in ISO_CURRENCY) if (!seen[ISO_CURRENCY[k]]) { seen[ISO_CURRENCY[k]] = 1; rest.push(ISO_CURRENCY[k]); } rest.sort(); CURRENCY_CODES = CURRENCY_CODES.concat(rest); })();
// the minor unit's word and short form, and the currency's decimal digits (0 for yen or won: the price is typed in the unit itself)
var MINOR = { GBP: ['pence', 'p'], EUR: ['euro cents', 'c'], USD: ['cents', 'c'], CAD: ['cents', 'c'], AUD: ['cents', 'c'], NZD: ['cents', 'c'], CHF: ['rappen', 'rp'], SEK: ['öre', 'öre'], NOK: ['øre', 'øre'], DKK: ['øre', 'øre'], PLN: ['grosze', 'gr'], CZK: ['haléře', 'h'], INR: ['paise', 'p'], BRL: ['centavos', 'c'], MXN: ['centavos', 'c'], ZAR: ['cents', 'c'], JPY: ['yen', '¥'], KRW: ['won', '₩'] };
function minorOf(cur) {
cur = String(cur || '').toUpperCase(); var digits = 2;
try { digits = new Intl.NumberFormat('en', { style: 'currency', currency: cur }).resolvedOptions().maximumFractionDigits; } catch (e) { }
var m = MINOR[cur] || (digits === 0 ? [cur.toLowerCase(), cur] : ['cents', 'c']);
return { word: m[0], abbr: m[1], digits: digits };
}
// the engine stores the price in hundredths of the unit; a zero-decimal currency is typed in the unit, so x100 on the way in
function priceFactor(cur) { return minorOf(cur).digits === 0 ? 100 : 1; }
function symbolOf(cur) { try { var parts = new Intl.NumberFormat('en', { style: 'currency', currency: cur, currencyDisplay: 'narrowSymbol' }).formatToParts(1); for (var i = 0; i < parts.length; i++) if (parts[i].type === 'currency') return parts[i].value; } catch (e) { } return cur; }
// a currency's row: the three the heat lane carried (with their approximate USD rates for the rent comparison), CAD and
// AUD (approximate rates, October 2026), the long tail without a rate (the rent line says so)
function currencyInfo(cur) {
cur = String(cur || '').toUpperCase(); if (CURRENCIES[cur]) return CURRENCIES[cur];
if (CURRENCY_CODES.indexOf(cur) < 0) return null;
return (CURRENCIES[cur] = { code: cur, symbol: symbolOf(cur), minor: minorOf(cur).abbr, usd: cur === 'CAD' ? 0.72 : cur === 'AUD' ? 0.65 : 0 });
}
function regionName(code) { try { var n = new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase()); if (n && n !== code.toUpperCase()) return n; } catch (e) { } return code.toUpperCase(); }
// the region of a BCP 47 tag: "de-DE" and "fr-CA" carry it, "de" alone is filled in by Intl.Locale; a language with no region ("eo") gives ''
function regionFromLocale(lang) {
lang = String(lang || '').trim(); if (!lang) return ''; var reg = '';
try { var l = new Intl.Locale(lang); reg = l.region || (l.maximize ? l.maximize().region : '') || ''; } catch (e) { var m = /^[A-Za-z]{2,3}[-_]([A-Za-z]{2})\b/.exec(lang); reg = m ? m[1] : ''; }
return /^[A-Za-z]{2}$/.test(reg) ? reg.toLowerCase() : '';
}
// what the money shows as: the override, else the chosen region, else the locale's region, else USD with the prompt
function currencyChoice(settings, lang) {
settings = settings || {}; var ov = String(settings.currency || '').toUpperCase(), region = settings.region || '', loc = regionFromLocale(lang);
if (ov && currencyInfo(ov)) return { code: ov, source: 'override', region: region || loc, prompt: false };
var r = region ? regionOf(region) : null;
if (r && !(r.unknown || r.code === 'other')) return { code: r.cur, source: 'region', region: region, prompt: false };
if (r) return { code: 'USD', source: 'fallback', region: region, prompt: true };
var lr = loc ? regionOf(loc) : null;
if (lr && !lr.unknown) return { code: lr.cur, source: 'locale', region: loc, prompt: false };
return { code: 'USD', source: 'fallback', region: loc, prompt: true };
}
function currencyAsk(ch) { return ch && ch.prompt ? 'This app could not tell your currency from this machine (' + (ch.region ? 'region ' + ch.region.toUpperCase() : 'no region in its locale') + '). Choose one and type what you pay per kWh. Figures show in US dollars until you do.' : ''; }
// the render path: set the module's currency and locale from the engine's settings and the web view's language
function applyMoney(settings, lang) { var ch = currencyChoice(settings, lang); override = ch.source === 'override' ? ch.code : ''; regionCode = ch.region || ''; currency = currencyInfo(ch.code) || CURRENCIES.USD; return ch; }
// the words for the price field: the minor unit of the currency in force (the override, else the region's)
function unitWords(code) { var c = (override && currencyInfo(override)) || currencyOf(code); return minorOf(c.code).word + ' per kWh'; }
// the restricted line for a region: the standing sentence plus the entry, or '' for a region with no entry
function restrictedLine(code) { var r = regionOf(code); return r && r.restricted ? RESTRICTED_SENTENCE + ' ' + r.restricted : ''; }
// the price note under the field: where the default came from, or that the miner types their own
function priceNote(code) {
var r = regionOf(code), c = currencyOf(code);
if (!r) return 'Choose a region to see a typical price. Every ' + c.symbol + ' figure uses what you type here. Nothing is fetched.';
return (r.price > 0 ? 'Typical: ' + r.price + ' ' + c.minor + ' per kWh (' + r.source + '). ' : 'No table price for this region. ') + 'Type what you pay, in ' + (c.code === 'GBP' ? 'pence' : c.code === 'EUR' ? 'euro cents' : 'US cents') + ' per kWh. Nothing is fetched.';
return (r.price > 0 ? 'Typical: ' + r.price + ' ' + c.minor + ' per kWh (' + r.source + '). ' : 'No table price for this region. ') + 'Type what you pay, in ' + minorOf(((override && currencyInfo(override)) || c).code).word + ' per kWh. Nothing is fetched.';
}
// the measured rental rate of hash (docs/bench-log.md, "Rental cost of hash, 6 October 2026"); the test
// heat-region.test.mjs reads the bench log and fails when this number drifts from it
@ -890,7 +963,7 @@ var View = (function () {
// the miner's own cost of hash: watts per MH/s at the price, in the region's minor unit per MH/s-hour, against the rent.
// Live numbers while mining; the last tune's point otherwise; nothing without a draw reading or a price.
function rentLine(cards, price, code) {
var c = currencyOf(code), w = 0, h = 0, live = false;
var c = (override && currencyInfo(override)) || currencyOf(code), w = 0, h = 0, live = false;
(cards || []).filter(present).forEach(function (cd) {
if (cd.state === 'mining' && cd.power_w > 0 && cd.hash_now > 0) { w += cd.power_w; h += cd.hash_now; live = true; }
});
@ -899,6 +972,7 @@ var View = (function () {
if (!(h > 0 && w > 0)) { out.text = 'Your cost of hash: no reading yet'; out.sub = 'needs a card mining with a power reading, or a finished tune. Rented hash costs USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + ').'; return out; }
if (!(price > 0)) { out.kind = 'price'; out.text = 'Your cost of hash: set a price'; out.sub = Math.round(w) + ' W for ' + (h >= 100 ? h.toFixed(0) : h.toFixed(1)) + ' MH/s is ' + (w / h).toFixed(2) + ' W per MH/s. Set your electricity price to compare it with rented hash at USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour.'; return out; }
var cost = w / h / 1000 * price; // minor units per MH/s-hour
if (!(c.usd > 0)) { out.kind = 'nocur'; out.cost = cost; out.text = 'Your cost of hash: ' + fmtMoney(cost / 100, c.code, locale) + ' per MH/s-hour'; out.sub = 'no USD rate is carried for ' + c.code + ', so no comparison with rented hash (USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour). Nothing is fetched.'; return out; }
var usd = cost / 100 * c.usd, ratio = RENT.usd_per_mhs_hour / usd;
out.kind = 'line'; out.cost = cost; out.usd = usd; out.ratio = ratio;
var costText = cost < 0.1 ? cost.toFixed(3) : cost.toFixed(2);
@ -1139,7 +1213,7 @@ var View = (function () {
var watts = fleetWatts(m, m.cards), kwhDay = watts > 0 ? watts * 24 / 1000 : 0, ppd = fleetPounds(m, m.cards, pence);
var perKwh = dayIgn !== null && kwhDay > 0 ? fmtIgn(dayIgn / kwhDay) + ' per kWh' : '';
out.cost = ppd === null ? { text: watts > 0 ? Math.round(watts) + ' W' : 'no draw', sub: watts > 0 ? 'set a price in Settings' + (perKwh ? ' · ' + perKwh : '') : 'no card reports its draw', field: FIELDS.watts }
: { text: money(ppd) + ' a day', sub: 'at ' + pence + (minor || 'p') + '/kWh for ' + Math.round(watts) + ' W' + (perKwh ? ' · ' + perKwh : ''), field: FIELDS.watts + ' × 24 × the price in Settings' };
: { text: money(ppd) + ' a day', sub: 'at ' + (pence / priceFactor(currency.code)) + (minor || minorOf(currency.code).abbr) + '/kWh for ' + Math.round(watts) + ' W' + (perKwh ? ' · ' + perKwh : ''), field: FIELDS.watts + ' × 24 × the price in Settings' };
var life = m.accepted_total || 0;
out.lifetime = { text: withCommas(life) + ' block' + (life === 1 ? '' : 's'), sub: c.minerIgn ? fmtIgn(life * c.minerIgn) + ' at today’s reward' : '', field: FIELDS.found };
out.devFee = function (on, feeTotal) { return on ? 'Dev fee: 1 block in 100 pays the people who make this app' + (feeTotal ? ' (' + withCommas(feeTotal) + ' block' + (feeTotal === 1 ? '' : 's') + ' so far)' : '') + '.' : 'Dev fee off. Every block pays your address.'; };
@ -1197,7 +1271,7 @@ var View = (function () {
}
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
return { ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -1240,6 +1314,8 @@ if (typeof document !== 'undefined') (function () {
var dpr = Math.min(2, window.devicePixelRatio || 1);
var state = null, phase = 'welcome', page = 'mine', forced = null, cardsSeen = false, keyShownThisRun = false;
var params = new URLSearchParams(location.search);
// the web view's language is the OS locale; ?lang=de-DE overrides it for screenshots and tests
var lang = params.get('lang') || navigator.language || 'en-GB'; View.setLocale(lang);
if (params.get('screen')) forced = params.get('screen');
if (params.get('page')) page = params.get('page'); // ?page=overview|cards|earnings|prove|settings (screenshots)
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
@ -1316,7 +1392,7 @@ if (typeof document !== 'undefined') (function () {
if (!dash && drawerOpen()) setDrawer(false);
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
// the region step; ?region=<code> preselects one for screenshots
if (p === 'region') { fillRegions($('region-select')); var rc = params.get('region') || regionNow(); $('region-select').value = rc; regionWords(rc, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), !(priceNow() > 0)); if (priceNow() > 0) $('region-price').value = String(priceNow()); }
if (p === 'region') { fillRegions($('region-select')); fillCurrencies($('region-currency'), regionNow() || View.regionFromLocale(lang)); var rc = params.get('region') || regionNow() || View.regionFromLocale(lang); $('region-select').value = rc; $('region-currency').value = currencyNowCode(); var ask0 = View.currencyAsk(View.currencyChoice({ region: rc, currency: currencyNowCode() }, lang)); setText('region-currency-ask', ask0); setHidden('region-currency-ask', !ask0); regionWords(rc, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), !(priceNow() > 0)); if (priceNow() > 0) $('region-price').value = String(priceNow()); }
if (dash) { showPage(page); mountDag(); }
api('api/phase', { phase: p });
}
@ -1350,32 +1426,53 @@ if (typeof document !== 'undefined') (function () {
if (choices.length) api('api/cards', { cards: choices }).then(function () { show('region'); }); else show('region');
});
// ---------- the region and price step (Ember Heat): the list, a typed price never fetched, the restricted line ----------
// the currency picker: "Follow the region" first, then the five, then the long tail, each with its English name
function fillCurrencies(sel, regionCodeNow) {
var follow = View.currencyOf(regionCodeNow || '').code, names = null; try { names = new Intl.DisplayNames(['en'], { type: 'currency' }); } catch (e) { }
var want = 'follow:' + follow; if (sel.dataset.filled === want) return; sel.dataset.filled = want;
var v = sel.value;
sel.innerHTML = '<option value="">Follow the region (' + esc(follow) + ')</option>' + View.CURRENCY_CODES.map(function (c) { var n = names ? names.of(c) : c; return '<option value="' + c + '">' + esc(c + (n && n !== c ? ' · ' + n : '')) + '</option>'; }).join('');
sel.value = v;
}
function currencyNowCode() { return (state && state.settings && state.settings.currency) || stored('igneum.currency') || ''; }
// the region list: the table, plus the locale's region when it is not in the table (synthesised from ISO_CURRENCY)
function fillRegions(sel) {
if (sel.options.length) return;
sel.innerHTML = '<option value="">Choose a region</option>' + View.REGIONS.map(function (r) { return '<option value="' + esc(r.code) + '">' + esc(r.name) + '</option>'; }).join('');
var extra = View.regionFromLocale(lang), rows = View.REGIONS.slice();
if (extra && !View.REGIONS.some(function (r) { return r.code === extra; })) rows.splice(1, 0, View.regionOf(extra));
if (sel.dataset.filled === String(rows.length)) return; sel.dataset.filled = String(rows.length);
sel.innerHTML = '<option value="">Choose a region</option>' + rows.map(function (r) { return '<option value="' + esc(r.code) + '">' + esc(r.name) + '</option>'; }).join('');
}
// the price field, unit, note and restricted line follow the chosen region; a typed price is kept unless `prefill`
function regionWords(code, priceEl, unitEl, noteEl, restrictedEl, prefill) {
var r = View.regionOf(code), c = View.currencyOf(code);
var r = View.regionOf(code);
if (prefill && priceEl) priceEl.value = r && r.price > 0 ? String(r.price) : '';
if (priceEl) priceEl.placeholder = r && r.price > 0 ? String(r.price) : '';
if (unitEl) setText(unitEl.id, (c.code === 'GBP' ? 'pence' : c.code === 'EUR' ? 'euro cents' : 'US cents') + ' per kWh');
if (unitEl) setText(unitEl.id, View.unitWords(code));
if (noteEl) setText(noteEl.id, View.priceNote(code));
var line = View.restrictedLine(code);
if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); }
}
function regionNow() { return (state && state.settings && state.settings.region) || stored('igneum.region') || ''; }
function saveRegion(code, price) {
store('igneum.region', code || null); store('igneum.price', price > 0 ? price : null);
return api('api/region', { region: code, price_pence: price }).catch(function () { return api('api/tune/goal', { price_pence: price }); });
function saveRegion(code, price, cur) {
store('igneum.region', code || null); store('igneum.price', price > 0 ? price : null); if (cur !== undefined) store('igneum.currency', cur || null);
var body = { region: code, price_pence: price }; if (cur !== undefined) body.currency = cur || '';
return api('api/region', body).catch(function () { return api('api/tune/goal', { price_pence: price }); });
}
// the override: picked, the price field's unit follows at once; "" follows the region again
function saveCurrency(sel, priceEl, unitEl, noteEl, regionSel) {
var cur = sel.value; View.setCurrency(cur); store('igneum.currency', cur || null);
api('api/region', { currency: cur }).catch(function () { });
regionWords(regionSel.value, null, unitEl, noteEl, null, false);
if (state) renderPage(state);
}
$('region-select').addEventListener('change', function () { regionWords(this.value, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), true); });
$('region-currency').addEventListener('change', function () { saveCurrency(this, $('region-price'), $('region-unit'), $('region-note'), $('region-select')); var a = View.currencyAsk(View.currencyChoice({ region: $('region-select').value, currency: this.value }, lang)); setText('region-currency-ask', a); setHidden('region-currency-ask', !a); });
$('btn-region-back').addEventListener('click', function () { show('cards'); });
$('btn-region-skip').addEventListener('click', function () { show('address'); });
$('btn-region-next').addEventListener('click', function () {
var code = $('region-select').value, p = parseFloat($('region-price').value);
var code = $('region-select').value, p = parseFloat($('region-price').value) * View.priceFactor(View.currencyNow().code);
if (!(p >= 0)) p = 0;
saveRegion(code, p).then(function () { show('address'); }).catch(function () { show('address'); });
saveRegion(code, p, $('region-currency').value).then(function () { show('address'); }).catch(function () { show('address'); });
});
$('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); });
$('btn-address-back').addEventListener('click', function () { show('cards'); });
@ -1541,13 +1638,14 @@ if (typeof document !== 'undefined') (function () {
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on. Once after install, then every 7 days' : 'Ember Tune off'); }); });
$('s-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (!r || !r.ok) { toast('Needs the Ember Tune update'); $('s-climb').checked = !on; } }); });
function savePrice() {
var p = parseFloat($('s-price').value), code = $('s-region').value, c = View.currencyOf(code);
if (!(p >= 0)) { toast('Type a price in ' + c.minor + ' per kWh, for example ' + (View.regionOf(code) && View.regionOf(code).price || 26.32)); return; }
saveRegion(code, p).then(function () { toast(p > 0 ? 'Price saved: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); });
var typed = parseFloat($('s-price').value), code = $('s-region').value, c = View.currencyNow(), f = View.priceFactor(c.code), p = typed * f;
if (!(p >= 0)) { toast('Type a price in ' + View.minorOf(c.code).word + ' per kWh, for example ' + (View.regionOf(code) && View.regionOf(code).price || 26.32)); return; }
saveRegion(code, p, $('s-currency').value).then(function () { toast(p > 0 ? 'Price saved: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); });
}
$('s-price-save').addEventListener('click', savePrice);
$('s-price').addEventListener('keydown', function (e) { if (e.key === 'Enter') savePrice(); });
$('s-region').addEventListener('change', function () { regionWords(this.value, $('s-price'), $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), true); });
$('s-currency').addEventListener('change', function () { saveCurrency(this, $('s-price'), $('s-price-unit'), $('s-price-note'), $('s-region')); });
// ---------- Heat mode (Ember Heat) ----------
$('s-heat').addEventListener('change', function () { var on = this.checked; api('api/heat', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Heat mode on. The cards heat for a share of every 10 minutes' : 'Heat mode off'); else { toast(r.error || 'could not change'); $('s-heat').checked = !on; } }); });
function saveHeat() {
@ -2256,7 +2354,7 @@ if (typeof document !== 'undefined') (function () {
var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now');
var ppd = View.fleetPounds(m, m.cards, price);
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, per kWh' : 'electricity at ' + price + View.currencyOf(regionNow()).minor + '/kWh');
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, per kWh' : 'electricity at ' + (price / View.priceFactor(View.currencyNow().code)) + View.minorOf(View.currencyNow().code).abbr + '/kWh');
setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
var rows = shown.map(View.cardRow), now = Date.now();
@ -2336,7 +2434,7 @@ if (typeof document !== 'undefined') (function () {
// ---------- Earnings ----------
function renderEarnings(s) {
var a = s.address || {}, m = s.mining;
var el = View.earningsLines(s, chain, priceNow(), s.now, View.currencyOf(regionNow()).minor);
var el = View.earningsLines(s, chain, priceNow(), s.now);
setText('e-day', el.day.text); setText('e-day-sub', el.day.sub); $('e-day').title = el.day.field;
setText('e-run', el.run.text); setText('e-run-sub', el.run.sub); $('e-run').title = el.run.field;
setText('e-rung', el.weight.text); setText('e-rung-sub', el.weight.sub); $('e-rung').title = el.weight.field;
@ -2394,7 +2492,9 @@ if (typeof document !== 'undefined') (function () {
fillRegions($('s-region'));
var rc = regionNow();
if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; }
if (document.activeElement !== $('s-price')) { var p = priceNow(); $('s-price').value = p > 0 ? String(p) : ''; }
fillCurrencies($('s-currency'), rc); if (document.activeElement !== $('s-currency')) $('s-currency').value = currencyNowCode();
var askS = View.currencyAsk(View.currencyChoice({ region: rc, currency: currencyNowCode() }, lang)); setText('s-currency-ask', askS); setHidden('s-currency-ask', !askS);
if (document.activeElement !== $('s-price')) { var p = priceNow() / View.priceFactor(View.currencyNow().code); $('s-price').value = p > 0 ? String(p) : ''; }
if (document.activeElement !== $('s-region')) regionWords($('s-region').value, null, $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), false);
var h = s.heat || {};
sw('s-heat', st.heat_on);
@ -2618,7 +2718,7 @@ if (typeof document !== 'undefined') (function () {
}
function render(s) {
state = s; stateAt = performance.now();
View.setRegion((s.settings && s.settings.region) || stored('igneum.region') || '');
View.applyMoney({ region: (s.settings && s.settings.region) || stored('igneum.region') || '', currency: (s.settings && s.settings.currency) || stored('igneum.currency') || '' }, lang);
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.14'; s.uptime_s = 20; } }
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
if (params.get('tune')) sampleTune(s, params.get('tune'));

View file

@ -131,15 +131,20 @@
<div class="step">
<div class="eyebrow">step 2 of 3</div>
<h2>Your electricity</h2>
<p class="sub">Every £ figure in the app comes from the price you type here. Nothing is fetched.</p>
<p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p>
<div class="field">
<div class="k">region</div>
<select class="addr-input select" id="region-select" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">currency</div>
<select class="addr-input select" id="region-currency" aria-label="Currency"></select>
<p class="help ask-cur" id="region-currency-ask" hidden></p>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="region-price" min="0" max="500" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
<input type="number" class="addr-input mono short" id="region-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
</div>
<p class="help" id="region-note">Choose a region to see a typical price.</p>
</div>
@ -484,10 +489,15 @@
<div class="k">region</div>
<select class="addr-input select" id="s-region" aria-label="Region"></select>
</div>
<div class="field">
<div class="k">currency</div>
<select class="addr-input select" id="s-currency" aria-label="Currency"></select>
<p class="help ask-cur" id="s-currency-ask" hidden></p>
</div>
<div class="field">
<div class="k">price per kWh</div>
<div class="row">
<input type="number" class="addr-input mono short" id="s-price" min="0" max="500" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
<input type="number" class="addr-input mono short" id="s-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
<button class="btn small" id="s-price-save">Save</button>
</div>
<p class="help" id="s-price-note"></p>

View file

@ -978,3 +978,55 @@ test('the "Prove instead of mining" row shows only on an under-12 GB machine and
const on = V.proveInsteadWords({ under_12gb: true }, { prove_instead: true });
assert.equal(on.show, true); assert.equal(on.on, true); assert.match(on.help, /held off/);
});
// ---------- currency (currency-21, the project lead, 7 October 2026: "£/day is for UK; we need other currencies") ----------
// No IP lookups: the OS locale's region picks the currency and the default tariff at first run; a Settings override
// (currency plus the price per kWh in it) wins and lives in the engine's settings file; every money line goes through
// one Intl formatter; the user's own tariff is the number, so no exchange rate exists anywhere.
const uiDir = dirname(fileURLToPath(import.meta.url));
const pageHtml = readFileSync(join(uiDir, 'index.html'), 'utf8');
const rustSrc = (f) => readFileSync(join(uiDir, '../src/' + f), 'utf8');
test('a de-DE machine reads EUR with the EU tariff from its locale, before any choice is made (known-failed on 0.3.21)', () => {
const ch = V.currencyChoice({ region: '', currency: '' }, 'de-DE');
assert.equal(ch.code, 'EUR'); assert.equal(ch.region, 'de'); assert.equal(ch.source, 'locale'); assert.equal(ch.prompt, false);
assert.equal(V.regionOf('de').price, 38.69); assert.match(V.regionOf('de').source, /Eurostat/);
assert.equal(V.regionFromLocale('de-DE'), 'de'); assert.equal(V.regionFromLocale('en-GB'), 'gb'); assert.equal(V.regionFromLocale('de'), 'de'); assert.equal(V.regionFromLocale('fr-CA'), 'ca'); assert.equal(V.regionFromLocale(''), '');
const ca = V.currencyChoice({}, 'fr-CA'); assert.equal(ca.code, 'CAD'); assert.equal(V.regionOf('ca').name, 'Canada'); assert.equal(V.regionOf('ca').cur, 'CAD');
assert.equal(V.currencyChoice({}, 'en-AU').code, 'AUD');
assert.equal(V.currencyChoice({}, 'fr-FR').code, 'EUR'); assert.ok(V.regionOf('fr').price > 0, 'an EU member without its own row takes the EU average'); assert.match(V.regionOf('fr').source, /EU average/);
assert.equal(V.currencyChoice({}, 'sv-SE').code, 'SEK'); assert.equal(V.regionOf('se').price, 0, 'no table price outside the euro: the miner types one');
// one formatter, the symbol placed as the locale places it, no hand-built money string left
assert.equal(V.fmtMoney(1.5, 'EUR', 'de-DE'), '1,50 €'); assert.equal(V.fmtMoney(1.5, 'EUR', 'en-IE'), '€1.50');
assert.equal(V.fmtMoney(1234.5, 'USD', 'en-US'), '$1,235'); assert.equal(V.fmtMoney(1.5, 'CAD', 'en-CA'), '$1.50'); assert.equal(V.fmtMoney(1.5, 'JPY', 'en-US'), '¥2'); assert.equal(V.fmtMoney(null, 'GBP', 'en-GB'), '');
V.setLocale('de-DE'); V.setRegion('de'); assert.equal(V.money(1.5), '1,50 €'); V.setLocale('en-GB'); V.setRegion('');
assert.equal((src.match(/currency\.symbol \+ \(/g) || []).length, 0, 'money() no longer concatenates a symbol');
});
test('a user override wins over the locale and the region, and survives restart through the engine settings file', () => {
const ch = V.currencyChoice({ region: 'gb', currency: 'USD' }, 'de-DE');
assert.equal(ch.code, 'USD'); assert.equal(ch.source, 'override'); assert.equal(ch.prompt, false);
assert.equal(V.currencyChoice({ region: 'gb', currency: '' }, 'de-DE').code, 'GBP', 'a chosen region beats the locale');
assert.equal(V.currencyChoice({ region: 'gb', currency: 'zzz' }, 'en-GB').code, 'GBP', 'an unknown override is ignored');
V.setLocale('en-GB'); V.setCurrency('USD'); assert.equal(V.money(1.5), '$1.50'); assert.equal(V.currencyNow().code, 'USD'); V.setCurrency(''); V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50'); V.setRegion('');
// survives restart: a field of the engine's settings file, carried in the state the UI reads, set through api/region
assert.match(rustSrc('config.rs'), /pub currency: String/); assert.match(rustSrc('state.rs'), /pub currency: String/);
assert.match(rustSrc('server.rs'), /body\.get\("currency"\)/); assert.match(rustSrc('engine.rs'), /Cmd::Region\(region, price, currency\)/);
assert.match(rustSrc('config.rs'), /fn currency_survives_a_round_trip|currency_round_trip/, 'the Rust round-trip test exists');
// the Settings card: a currency picker with "follow the region" first; the price unit follows the pick
assert.ok(pageHtml.includes('id="s-currency"'), 'the picker');
assert.deepEqual(V.minorOf('GBP'), { word: 'pence', abbr: 'p', digits: 2 }); assert.equal(V.minorOf('EUR').word, 'euro cents'); assert.equal(V.minorOf('CAD').abbr, 'c'); assert.equal(V.minorOf('JPY').digits, 0); assert.equal(V.minorOf('JPY').word, 'yen');
assert.ok(V.CURRENCY_CODES.indexOf('GBP') === 0 && V.CURRENCY_CODES.slice(0, 5).join() === 'GBP,EUR,USD,CAD,AUD' && V.CURRENCY_CODES.length > 20, 'the five first, then the long tail');
});
test('a locale with no currency row falls back to USD with the override prompt visible', () => {
const eo = V.currencyChoice({}, 'eo');
assert.equal(eo.code, 'USD'); assert.equal(eo.prompt, true); assert.equal(eo.source, 'fallback');
const aq = V.currencyChoice({}, 'en-AQ'); assert.equal(aq.code, 'USD'); assert.equal(aq.prompt, true);
assert.equal(V.currencyChoice({}, 'en-GB').prompt, false);
assert.equal(V.currencyChoice({ region: 'other', currency: '' }, 'eo').prompt, true, '"Somewhere else" with no currency still asks');
assert.equal(V.currencyChoice({ region: 'other', currency: 'USD' }, 'eo').prompt, false, 'an answer closes the prompt');
assert.match(V.currencyAsk(eo), /could not tell your currency/);
assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), '');
assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step');
});

View file

@ -27,6 +27,7 @@
// drivers-reboot, drivers-error, drivers-mac)
// GET /api/ladder answers the chain facts in src/chainfacts.rs's shape (the devnet's params, this key ranked);
// POST /api/card accepts the block card's PNG and answers a path.
// euro currency-21: region Germany, 38.69 c/kWh, every money line in euro (add &lang=de-DE for German placement)
// rig gpu-logos: one card per vendor (RTX 5090, RX 9070 XT, Arc B580, a UHD 770 iGPU off), every mark on one page
// heat Ember Heat (mission item 7): a 5090 and a 4070 in heat mode holding 20 °C (heating, 60% duty), the
// region United Kingdom at the Ofgem price; ?page=cards|earnings|settings for the three lines
@ -234,6 +235,11 @@ function stateNow() {
if (scenario === 'drivers-error') s.drivers = { ...s.drivers, status: 'error', vendor: 'amd', version: '26.9.2', progress: 1, message: 'download: sha256 mismatch: the file is not the one the table names (page https://www.amd.com/en/support/download/drivers.html)', error: 'download: sha256 mismatch: the file is not the one the table names (page https://www.amd.com/en/support/download/drivers.html)', started_at: t - 120, finished_at: t - 5 };
if (scenario === 'drivers-mac') { s.mining.cards = [{ ...recState.mining.cards[0], driver_offer: { vendor: 'apple', status: 'none', installed: '', wanted: '', min_version: '', size: 0, url: '', hash_source: '', text: 'Apple silicon: no driver step, macOS carries it.', installable: false } }]; s.mining.hash_total = recState.mining.hash_total; s.display_name = recState.display_name; s.drivers.platform = 'macos'; }
}
if (scenario === 'euro') {
// currency-21: a German machine, the Eurostat tariff typed, no override: every money line in euro
s.settings = { ...s.settings, region: 'de', power_price_pence: 38.69, currency: '' };
s.mining.cards = s.mining.cards.map((c, i) => i === 0 ? { ...c, power_w: 92, power_default_w: 0, telemetry_at: t } : c);
}
if (scenario === 'rig') {
// gpu-logos (7 October 2026): a mixed rig, one card per vendor, every mark on one page; the Intel numbers are the
// intel scenario's expectation, the others the heat and nvidia scenarios' readings