diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index a8093f77a..f3235e8a8 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -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 { diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index ff6469bc0..4d4d0878b 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -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, Option), + Region(Option, Option, Option), /// 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) = ®ion { 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) = ¤cy { + // 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::().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 = None; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 0c068d9a2..28baeee3f 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -316,8 +316,10 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { // 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" => { diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index dafcce49d..0a368dabd 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -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, diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 3599e2949..52283eaf3 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -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)} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index dd049d141..b0eb6bbe8 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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= 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 = '' + View.CURRENCY_CODES.map(function (c) { var n = names ? names.of(c) : c; return ''; }).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 = '' + View.REGIONS.map(function (r) { return ''; }).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 = '' + rows.map(function (r) { return ''; }).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')); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index e29c988e8..242c5280e 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -131,15 +131,20 @@
step 2 of 3

Your electricity

-

Every £ figure in the app comes from the price you type here. Nothing is fetched.

+

Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.

region
+
+
currency
+ + +
price per kWh
- pence per kWh + pence per kWh

Choose a region to see a typical price.

@@ -484,10 +489,15 @@
region
+
+
currency
+ + +
price per kWh
- pence per kWh + pence per kWh

diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index c056cb7a9..df71c2040 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -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'); +}); diff --git a/docs/plans/currency-shots.txt b/docs/plans/currency-shots.txt new file mode 100644 index 000000000..0da010c7c --- /dev/null +++ b/docs/plans/currency-shots.txt @@ -0,0 +1,5 @@ +01-earnings-eur-dark.png scenario=euro&page=earnings&theme=dark&lang=de-DE 1100 +02-earnings-eur-light.png scenario=euro&page=earnings&theme=light&lang=de-DE 1100 +03-settings-currency-dark.png scenario=euro&page=settings&theme=dark&lang=de-DE 1400 +04-firstrun-region-dark.png scenario=fresh&screen=region&theme=dark&lang=fr-CA 900 +05-overview-eur-dark.png scenario=euro&page=overview&theme=dark&lang=de-DE 900 diff --git a/docs/plans/currency-shots/01-earnings-eur-dark.png b/docs/plans/currency-shots/01-earnings-eur-dark.png new file mode 100644 index 000000000..5e4247661 Binary files /dev/null and b/docs/plans/currency-shots/01-earnings-eur-dark.png differ diff --git a/docs/plans/currency-shots/02-earnings-eur-light.png b/docs/plans/currency-shots/02-earnings-eur-light.png new file mode 100644 index 000000000..008120ed3 Binary files /dev/null and b/docs/plans/currency-shots/02-earnings-eur-light.png differ diff --git a/docs/plans/currency-shots/03-settings-currency-dark.png b/docs/plans/currency-shots/03-settings-currency-dark.png new file mode 100644 index 000000000..3f74f4575 Binary files /dev/null and b/docs/plans/currency-shots/03-settings-currency-dark.png differ diff --git a/docs/plans/currency-shots/04-firstrun-region-dark.png b/docs/plans/currency-shots/04-firstrun-region-dark.png new file mode 100644 index 000000000..8c52eb502 Binary files /dev/null and b/docs/plans/currency-shots/04-firstrun-region-dark.png differ diff --git a/docs/plans/currency-shots/05-overview-eur-dark.png b/docs/plans/currency-shots/05-overview-eur-dark.png new file mode 100644 index 000000000..68c42ae4a Binary files /dev/null and b/docs/plans/currency-shots/05-overview-eur-dark.png differ diff --git a/site/lib/money-regen.mjs b/site/lib/money-regen.mjs new file mode 100644 index 000000000..eb12337f5 --- /dev/null +++ b/site/lib/money-regen.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// Rewrites the four tables in site/money.js from app/igneum-app/ui/app.js (View.REGIONS, ISO_CURRENCY, EU_MEMBERS, +// CURRENCY_CODES). Run after a change to the app's tables, then commit both; site/lib/money.test.mjs fails on drift. +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +const here = dirname(fileURLToPath(import.meta.url)); +const m = { exports: {} }; new Function('module', readFileSync(join(here, '../../app/igneum-app/ui/app.js'), 'utf8'))(m); +const V = m.exports.View, p = join(here, '../money.js'); +const regions = V.REGIONS.map(r => ({ code: r.code, name: r.name, cur: r.cur, price: r.price, source: r.source || '', restricted: !!r.restricted })); +let s = readFileSync(p, 'utf8'); +s = s.replace(/ M\.REGIONS = .*\n/, ' M.REGIONS = ' + JSON.stringify(regions) + ';\n'); +s = s.replace(/ M\.ISO_CURRENCY = .*\n/, ' M.ISO_CURRENCY = ' + JSON.stringify(V.ISO_CURRENCY) + ';\n'); +s = s.replace(/ M\.EU_MEMBERS = .*\n/, ' M.EU_MEMBERS = ' + JSON.stringify(V.EU_MEMBERS) + ';\n'); +s = s.replace(/ M\.CURRENCY_CODES = .*\n/, ' M.CURRENCY_CODES = ' + JSON.stringify(V.CURRENCY_CODES) + ';\n'); +writeFileSync(p, s); console.log('site/money.js tables regenerated'); diff --git a/site/lib/money.test.mjs b/site/lib/money.test.mjs new file mode 100644 index 000000000..0dd85c1a1 --- /dev/null +++ b/site/lib/money.test.mjs @@ -0,0 +1,46 @@ +// node --test site/lib/money.test.mjs (the pre-push gate runs it beside the explorer and emission tests) +// site/money.js is the app's currency rules for the site: its tables must equal the app's, its formatter must place the +// symbol as the locale does, and the picker's choice must follow navigator.language with a saved override winning. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +const here = dirname(fileURLToPath(import.meta.url)); +const load = (p) => { const mod = { exports: {} }; new Function('module', 'window', readFileSync(p, 'utf8'))(mod, undefined); return mod.exports; }; +const M = load(join(here, '../money.js')); +const V = load(join(here, '../../app/igneum-app/ui/app.js')).View; + +test('the site tables equal the app tables (regenerate with node site/lib/money-regen.mjs)', () => { + assert.deepEqual(M.REGIONS, V.REGIONS.map(r => ({ code: r.code, name: r.name, cur: r.cur, price: r.price, source: r.source || '', restricted: !!r.restricted }))); + assert.deepEqual(M.ISO_CURRENCY, V.ISO_CURRENCY); assert.deepEqual(M.EU_MEMBERS, V.EU_MEMBERS); assert.deepEqual(M.CURRENCY_CODES, V.CURRENCY_CODES); + for (const c of ['GBP', 'EUR', 'JPY', 'CHF']) assert.deepEqual(M.minorOf(c), V.minorOf(c), c); +}); + +test('the picker follows navigator.language, a saved choice wins, no row falls back to USD and asks', () => { + assert.equal(M.currencyChoice('', 'de-DE').code, 'EUR'); assert.equal(M.currencyChoice('', 'en-GB').code, 'GBP'); assert.equal(M.currencyChoice('', 'fr-CA').code, 'CAD'); assert.equal(M.currencyChoice('', 'en-AU').code, 'AUD'); + assert.equal(M.currencyChoice('USD', 'de-DE').code, 'USD'); assert.equal(M.currencyChoice('USD', 'de-DE').source, 'override'); + const eo = M.currencyChoice('', 'eo'); assert.equal(eo.code, 'USD'); assert.equal(eo.prompt, true); + assert.equal(M.currencyChoice('zzz', 'eo').prompt, true, 'an unknown saved code is ignored'); +}); + +test('the default tariff is the region row, else the first table row in the currency, and the cost line uses one formatter', () => { + const de = M.tariffFor(M.currencyChoice('', 'de-DE')); assert.equal(de.price, 38.69); assert.match(de.source, /Eurostat/); assert.equal(de.region, 'Germany'); + const gbp = M.tariffFor(M.currencyChoice('GBP', 'de-DE')); assert.equal(gbp.price, 26.32); assert.equal(gbp.region, 'United Kingdom'); + const fr = M.tariffFor(M.currencyChoice('', 'fr-FR')); assert.equal(fr.price, 28.96, 'an EU member on the euro takes the EU average'); + assert.equal(M.tariffFor(M.currencyChoice('', 'sv-SE')), null, 'no table tariff in kronor: the picker says so'); + assert.equal(M.fmtMoney(M.costPerDay(227, 38.69), 'EUR', 'de-DE'), '2,11 €'); + assert.equal(M.fmtMoney(M.costPerDay(227, 26.32), 'GBP', 'en-GB'), '£1.43'); + assert.equal(M.fmtMoney(M.costPerDay(227, 17.7), 'USD', 'en-US'), '$0.96'); + assert.equal(M.costPerDay(null, 26.32), null, 'no watts, no cost'); + assert.equal(M.fmtMoney(1234.5, 'USD', 'en-US'), '$1,235'); assert.equal(M.fmtMoney(1.5, 'JPY', 'en-US'), '¥2'); +}); + +test('yourcard.js loads money.js and shows the electricity line with the currency switch; no hand-built money string', () => { + const yc = readFileSync(join(here, '../yourcard.js'), 'utf8'); + assert.ok(yc.includes("'/money.js'"), 'loads the module when the page did not'); + assert.ok(yc.includes('window.IgneumMoney') && yc.includes('.fmtMoney(') && yc.includes('.currencyChoice(') && yc.includes('.tariffFor('), 'the module is the only money path'); + assert.ok(yc.includes('igneum-currency'), 'the saved choice'); + assert.ok(yc.includes('class="cur"'), 'the switch'); + assert.equal(/'£'|"£"/.test(yc), false); +}); diff --git a/site/money.js b/site/money.js new file mode 100644 index 000000000..82bdf4f90 --- /dev/null +++ b/site/money.js @@ -0,0 +1,61 @@ +/* Igneum money (currency-21, 7 October 2026): the site's copy of the miner app's currency rules, for the card picker + (yourcard.js) and any page that shows a money figure. No IP lookup: navigator.language's region picks the currency and + the default tariff; a saved choice (localStorage igneum-currency) wins; every figure goes through one Intl.NumberFormat + call with the currency code and the narrow symbol, placed as the locale places it; the user's own tariff is the + number, so no exchange rate exists anywhere. The tables below are generated from app/igneum-app/ui/app.js (View.REGIONS, + View.ISO_CURRENCY, View.EU_MEMBERS, View.CURRENCY_CODES); site/lib/money.test.mjs fails when they drift, and + `node site/lib/money-regen.mjs` rewrites them. A classic script (window.IgneumMoney) so yourcard.js can load it on + pages that carry no module tag; under node it also exports through module.exports for the test. */ +(function (root) { + var M = {}; + M.REGIONS = [{"code":"gb","name":"United Kingdom","cur":"GBP","price":26.32,"source":"Ofgem cap, 1 October to 31 December 2026","restricted":false},{"code":"ie","name":"Ireland","cur":"EUR","price":40.42,"source":"Eurostat, second half of 2025","restricted":false},{"code":"de","name":"Germany","cur":"EUR","price":38.69,"source":"Eurostat, second half of 2025","restricted":false},{"code":"be","name":"Belgium","cur":"EUR","price":34.99,"source":"Eurostat, second half of 2025","restricted":false},{"code":"eu","name":"European Union, other","cur":"EUR","price":28.96,"source":"Eurostat, second half of 2025, the EU average","restricted":false},{"code":"hu","name":"Hungary","cur":"EUR","price":10.82,"source":"Eurostat, second half of 2025, in euro cents","restricted":false},{"code":"mt","name":"Malta","cur":"EUR","price":12.82,"source":"Eurostat, second half of 2025","restricted":false},{"code":"bg","name":"Bulgaria","cur":"EUR","price":13.55,"source":"Eurostat, second half of 2025, in euro cents","restricted":false},{"code":"us","name":"United States","cur":"USD","price":17.7,"source":"EIA average, second half of 2025","restricted":false},{"code":"kz","name":"Kazakhstan","cur":"USD","price":4,"source":"about 4 c, a secondary source, approximate","restricted":false},{"code":"py","name":"Paraguay","cur":"USD","price":4.4,"source":"ANDE crypto tariff, 4.4 to 6 c; every crypto contract ends 31 December 2027","restricted":false},{"code":"ir","name":"Iran","cur":"USD","price":1,"source":"licensed mining tariff, about 1 c, a secondary source, approximate","restricted":false},{"code":"et","name":"Ethiopia","cur":"USD","price":2,"source":"2 to 5.3 c, a secondary source","restricted":false},{"code":"ng","name":"Nigeria","cur":"USD","price":4.8,"source":"hosted, 4.8 c, a secondary source","restricted":false},{"code":"ru-mow","name":"Russia, Moscow region","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru-irk","name":"Russia, Irkutsk","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru-bur","name":"Russia, Buryatia","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru-zab","name":"Russia, Zabaikalsky","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru","name":"Russia, other region","cur":"USD","price":0,"source":"","restricted":true},{"code":"cn","name":"China","cur":"USD","price":0,"source":"","restricted":true},{"code":"other","name":"Somewhere else","cur":"USD","price":0,"source":"","restricted":false}]; + M.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"}; + M.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"]; + M.CURRENCY_CODES = ["GBP","EUR","USD","CAD","AUD","AED","ARS","BDT","BGN","BRL","CHF","CLP","CNY","COP","CZK","DKK","EGP","ETB","GEL","HKD","HUF","IDR","ILS","INR","IRR","ISK","JPY","KES","KRW","KZT","MAD","MXN","MYR","NGN","NOK","NZD","PEN","PHP","PKR","PLN","PYG","RON","RSD","RUB","SAR","SEK","SGD","THB","TRY","TWD","UAH","VND","ZAR"]; + 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', '₩'] }; + M.minorOf = function (cur) { + cur = String(cur || '').toUpperCase(); var digits = 2; + try { digits = new Intl.NumberFormat('en', { style: 'currency', currency: cur }).resolvedOptions().maximumFractionDigits; } catch (e) { } + var mm = MINOR[cur] || (digits === 0 ? [cur.toLowerCase(), cur] : ['cents', 'c']); + return { word: mm[0], abbr: mm[1], digits: digits }; + }; + M.fmtMoney = function (v, cur, loc) { + if (v === null || v === undefined || isNaN(v)) return ''; + var d = v >= 100 ? 0 : M.minorOf(cur).digits; + try { return new Intl.NumberFormat(loc || 'en-GB', { style: 'currency', currency: cur, currencyDisplay: 'narrowSymbol', minimumFractionDigits: d, maximumFractionDigits: d }).format(v).replace(/[\u00a0\u202f]/g, ' '); } + catch (e) { return cur + ' ' + (d ? v.toFixed(d) : String(Math.round(v))); } + }; + M.regionFromLocale = function (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 mm = /^[A-Za-z]{2,3}[-_]([A-Za-z]{2})\b/.exec(lang); reg = mm ? mm[1] : ''; } + return /^[A-Za-z]{2}$/.test(reg) ? reg.toLowerCase() : ''; + }; + var synth = {}; + 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(); } + M.regionOf = function (code) { + for (var i = 0; i < M.REGIONS.length; i++) if (M.REGIONS[i].code === code) return M.REGIONS[i]; + if (!/^[a-z]{2}$/.test(code || '')) return null; + if (synth[code]) return synth[code]; + var cur = M.ISO_CURRENCY[code] || '', eu = M.EU_MEMBERS.indexOf(code) >= 0 && cur === 'EUR', avg = M.regionOf('eu'); + return (synth[code] = { code: code, name: regionName(code), cur: cur || 'USD', unknown: !cur, price: eu && avg ? avg.price : 0, source: eu && avg ? avg.source : '' }); + }; + // the currency in force: a saved choice, else the locale's region, else USD with the prompt + M.currencyChoice = function (saved, lang) { + var ov = String(saved || '').toUpperCase(), loc = M.regionFromLocale(lang); + if (ov && M.CURRENCY_CODES.indexOf(ov) >= 0) return { code: ov, source: 'override', region: loc, prompt: false }; + var r = loc ? M.regionOf(loc) : null; + if (r && !r.unknown) return { code: r.cur, source: 'locale', region: loc, prompt: false }; + return { code: 'USD', source: 'fallback', region: loc, prompt: true }; + }; + // the default tariff for a choice: the locale's region row when it carries the currency, else the first table row in that currency + M.tariffFor = function (choice) { + var r = choice.region ? M.regionOf(choice.region) : null; + if (r && r.cur === choice.code && r.price > 0) return { price: r.price, source: r.source, region: r.name, cur: choice.code }; + for (var i = 0; i < M.REGIONS.length; i++) if (M.REGIONS[i].cur === choice.code && M.REGIONS[i].price > 0 && !M.REGIONS[i].restricted) return { price: M.REGIONS[i].price, source: M.REGIONS[i].source, region: M.REGIONS[i].name, cur: choice.code }; + return null; + }; + // watts over a day at a tariff in hundredths of the unit per kWh: the unit amount + M.costPerDay = function (watts, pricePerKwh) { return watts > 0 && pricePerKwh > 0 ? watts * 24 / 1000 * pricePerKwh / 100 : null; }; + root.IgneumMoney = M; + if (typeof module === 'object' && module && module.exports) module.exports = M; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/site/yourcard.js b/site/yourcard.js index 7c843070d..ecabb75e2 100644 --- a/site/yourcard.js +++ b/site/yourcard.js @@ -1,16 +1,42 @@ -/* The "your card" line: a measured card, and how often it finds a block at the network's hash rate right now. One block a - second is the target, so the wait is network hashes per second over the card's rate. The rates are the bench table's - measured numbers (site/miners.html); nothing here is a promise. Reads /api/live every 15 s. */ +/* The "your card" line: a measured card, how often it finds a block at the network's hash rate right now, and what its + electricity costs a day in your currency. One block a second is the target, so the wait is network hashes per second + over the card's rate. The rates are the bench table's measured numbers (site/miners.html); the watts are the tuned + draws from the same runs (app.html's Ember table: 5090 226.8 W, 4070 75.6 W; the 9070 XT 202 W from docs/bench-log.md, + 6 October 2026; Apple silicon reports no draw). The currency follows navigator.language (no IP lookup); the switch saves + a choice in localStorage igneum-currency; the tariff is the public table's default for that region or currency, so the + cost is arithmetic from a typical price, never a promise (currency-21, 7 October 2026). Reads /api/live every 15 s. */ (function () { - var CARDS = [['NVIDIA RTX 5090', 127.7], ['NVIDIA RTX 4070', 28.8], ['Apple M5 Max', 26.7], ['AMD RX 9070 XT', 17.8]]; + var CARDS = [['NVIDIA RTX 5090', 127.7, 226.8], ['NVIDIA RTX 4070', 28.8, 75.6], ['Apple M5 Max', 26.7, null], ['AMD RX 9070 XT', 17.8, 202]]; var els = document.querySelectorAll('[data-yourcard]'); if (!els.length) return; var net = null, chosen = (function () { try { return localStorage.getItem('igneum-card'); } catch (e) { return null; } })(); + var savedCur = (function () { try { return localStorage.getItem('igneum-currency') || ''; } catch (e) { return ''; } })(); function dur(sec) { if (!(sec > 0)) return 'pending'; if (sec < 90) return Math.round(sec) + ' s'; if (sec < 5400) return Math.round(sec / 60) + ' min'; if (sec < 172800) { var h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60); return h + ' h' + (m ? ' ' + m + ' min' : ''); } return Math.round(sec / 86400) + ' days'; } - function render() { els.forEach(function (el) { var r = parseFloat(el.querySelector('select').value) * 1e6, eta = el.querySelector('.eta'); eta.textContent = net && r ? 'every ' + dur(net / r) : 'pending'; }); } - els.forEach(function (el) { - el.innerHTML = 'Your cardfinds a block about pending at the network\u2019s hash rate right now.'; - el.querySelector('select').addEventListener('change', function (e) { var name = e.target.options[e.target.selectedIndex].text.split(' \u00b7 ')[0]; try { localStorage.setItem('igneum-card', name); } catch (x) {} document.querySelectorAll('[data-yourcard] select').forEach(function (s) { s.value = e.target.value; }); render(); }); - }); + function costWords(watts) { + var M = window.IgneumMoney; if (!M) return ''; + var ch = M.currencyChoice(savedCur, navigator.language), t = M.tariffFor(ch); + if (watts === null) return 'no power reading on this card'; + if (!t) return 'no typical ' + ch.code + ' tariff in the table; the app uses the price you type'; + var c = M.costPerDay(watts, t.price); + return 'about ' + M.fmtMoney(c, ch.code, navigator.language) + ' a day in electricity at ' + t.price + ' ' + M.minorOf(ch.code).abbr + '/kWh (' + t.region + ', typical)'; + } + function render() { + els.forEach(function (el) { + var sel = el.querySelector('select.card'), r = parseFloat(sel.value) * 1e6, eta = el.querySelector('.eta'), cost = el.querySelector('.cost'); + eta.textContent = net && r ? 'every ' + dur(net / r) : 'pending'; + var row = CARDS[sel.selectedIndex]; if (cost) cost.textContent = costWords(row ? row[2] : null); + }); + } + function init() { + var M = window.IgneumMoney, ch = M ? M.currencyChoice(savedCur, navigator.language) : null; + els.forEach(function (el) { + el.innerHTML = 'Your cardfinds a block about pending at the network\u2019s hash rate right now.' + + (M ? ' ' : ''); + el.querySelector('select.card').addEventListener('change', function (e) { var name = e.target.options[e.target.selectedIndex].text.split(' \u00b7 ')[0]; try { localStorage.setItem('igneum-card', name); } catch (x) {} document.querySelectorAll('[data-yourcard] select.card').forEach(function (s) { s.value = e.target.value; }); render(); }); + var cur = el.querySelector('select.cur'); if (cur) cur.addEventListener('change', function (e) { savedCur = e.target.value; try { localStorage.setItem('igneum-currency', savedCur); } catch (x) {} document.querySelectorAll('[data-yourcard] select.cur').forEach(function (s) { s.value = savedCur; }); render(); }); + }); + render(); + } + if (window.IgneumMoney) init(); else { var sc = document.createElement('script'); sc.src = '/money.js'; sc.onload = init; sc.onerror = init; document.head.appendChild(sc); } function poll() { fetch('/api/live?window=30', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) { if (d && d.ok && d.state && d.state.hashes_per_second_estimate > 0) { net = d.state.hashes_per_second_estimate; render(); } }).catch(function () {}); } poll(); setInterval(function () { if (!document.hidden) poll(); }, 15000); window.IgneumYourCard = { update: function (h) { if (h > 0) { net = h; render(); } } }; diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 25e3c4e95..ffc3057e2 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -101,7 +101,7 @@ tree_checks() { run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh - run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs + run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test run "heat gate reader self-test (a known hold passes, a known drift fails)" node tools/heat-gate.mjs --self-test diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index a708a7046..5b2b16cf8 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -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