Merge remote-tracking branch 'origin/currency-21' into release-0.3.21
This commit is contained in:
commit
ec0d14c7c9
20 changed files with 400 additions and 49 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) = ®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::<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;
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
5
docs/plans/currency-shots.txt
Normal file
5
docs/plans/currency-shots.txt
Normal file
|
|
@ -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
|
||||
BIN
docs/plans/currency-shots/01-earnings-eur-dark.png
Normal file
BIN
docs/plans/currency-shots/01-earnings-eur-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/plans/currency-shots/02-earnings-eur-light.png
Normal file
BIN
docs/plans/currency-shots/02-earnings-eur-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
BIN
docs/plans/currency-shots/03-settings-currency-dark.png
Normal file
BIN
docs/plans/currency-shots/03-settings-currency-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 313 KiB |
BIN
docs/plans/currency-shots/04-firstrun-region-dark.png
Normal file
BIN
docs/plans/currency-shots/04-firstrun-region-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/plans/currency-shots/05-overview-eur-dark.png
Normal file
BIN
docs/plans/currency-shots/05-overview-eur-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 520 KiB |
16
site/lib/money-regen.mjs
Normal file
16
site/lib/money-regen.mjs
Normal file
|
|
@ -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');
|
||||
46
site/lib/money.test.mjs
Normal file
46
site/lib/money.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
61
site/money.js
Normal file
61
site/money.js
Normal file
|
|
@ -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);
|
||||
|
|
@ -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 = '<b>Your card</b><select aria-label="Your card">' + CARDS.map(function (c) { return '<option value="' + c[1] + '"' + (chosen === c[0] ? ' selected' : '') + '>' + c[0] + ' \u00b7 ' + c[1] + ' MH/s</option>'; }).join('') + '</select><span>finds a block about <span class="eta">pending</span> at the network\u2019s hash rate right now.</span>';
|
||||
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 = '<b>Your card</b><select class="card" aria-label="Your card">' + CARDS.map(function (c) { return '<option value="' + c[1] + '"' + (chosen === c[0] ? ' selected' : '') + '>' + c[0] + ' \u00b7 ' + c[1] + ' MH/s</option>'; }).join('') + '</select><span>finds a block about <span class="eta">pending</span> at the network\u2019s hash rate right now.</span>' +
|
||||
(M ? ' <span class="cost"></span> <select class="cur" aria-label="Currency">' + M.CURRENCY_CODES.map(function (c) { return '<option value="' + c + '"' + (ch.code === c ? ' selected' : '') + '>' + c + '</option>'; }).join('') + '</select>' : '');
|
||||
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(); } } };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue