/* settings.jsx — HEMS Inställningar Loads existing settings from /settings/api/{api_key}, lets the user edit, POSTs the FULL settings object back. No partial updates (per backend spec). */ const SETTINGS_BASE_URL = window.HEMS_BASE(); // Säkerhetsfix 2026-05-29: API-nyckel läses från localStorage (session-token // sparas där efter login). Tidigare hårdkodning av Andrés Homey-nyckel gjorde // att alla inloggade staging-användare läste/skrev mot Andrés settings när de // öppnade /v2/settings.html. STRIKT sess_*-validering: gamla hems_*-spöktokens // (från tidigare cutover-fönster) rensas → tvingar fram login. Saknad token → // redirect till login.html. // Master-gaten (user_mode_enabled) persisteras av backend sedan 2026-08-01 // (SPEC_DRIFTLAGE_UI_V2, rev 01259-kuc). localStorage-speglingen // `hems_user_mode_enabled_` är RIVEN — backend är sanningskälla. // Bygg aldrig tillbaka den: ny inloggning/enhet = ny token = tom nyckel. function _readSessionToken() { if (typeof localStorage === 'undefined') return ''; let tok = localStorage.getItem('hems_session_token') || ''; if (tok && !tok.startsWith('sess_')) { // Spöke från tidigare cutover — rensa och tvinga login localStorage.removeItem('hems_session_token'); tok = ''; } if (!tok) { // Ej inloggad — skicka till login (sparar request_url så vi kan komma tillbaka) window.location.href = 'login.html'; return ''; } return tok; } const SETTINGS_API_KEY = _readSessionToken(); // Babel-scripts delar inte scope: settings-ev.jsx behöver bas-URL + nyckel för // den frivilliga live-data-koll som driver "HEMS ser en laddbox"-hinten // (SPEC_EV_SETTINGS_UX §3). Ingen ny endpoint — /api/live-data finns redan. window.HEMS_SETTINGS_API = { base: SETTINGS_BASE_URL, key: SETTINGS_API_KEY }; // Defaults derived from clean_settings_api.json + the live screenshots. // Used while the real GET is in flight, and as a fallback if it fails. // Säkerhetsfix 2026-05-29: tidigare hade defaults Andrés exakta personliga data // (Kinnekulle-koord, 19.78 kWp sol, 25.6 kWh batteri, peak-config). När annan // användares settings saknade ett fält visade UI Andrés värden istället. Nu är // alla personberoende fält neutrala/0 — bara svenska standarder (SE3, moms 25%) // och tekniska defaults (verkningsgrader). UI ska enbart visa användarens // faktiska sparade data, aldrig "någon annans" som fallback. const SETTINGS_DEFAULTS = { name: '', email: '', area: 'SE3', latitude: 0, longitude: 0, // Solar — up to 3 arrays (tomt = inte konfigurerat) solar_array_1_kwp: 0, solar_array_1_azimuth: 180, solar_array_1_tilt: 30, solar_array_2_kwp: 0, solar_array_2_azimuth: 180, solar_array_2_tilt: 30, solar_array_3_kwp: 0, solar_array_3_azimuth: 180, solar_array_3_tilt: 30, // Battery (0 = inte konfigurerat) battery_capacity_kwh: 0, battery_charge_rate_kw: 0, battery_discharge_rate_kw: 0, battery_soc_min_pct: 5, battery_soc_max_pct: 100, battery_efficiency_pct: 98, inverter_efficiency_pct: 96, multi_battery_enabled: false, // Installation main_fuse_a: 16, // Homey / inverter format homey_power_in_kw: false, ev_charger_power_in_kw: false, pause_battery_on_ev_charging: false, // Elbil & skjutbara laster (SPEC_LASTER_EV 2026-06-11) — nested objekt + // array, backend-namnen exakt enligt spec. Defaults speglar EV_CHARGING_DEFAULTS // i settings-ev.jsx. ev_charging: { enabled: false, battery_kwh: 0, max_current_a: 16, min_current_a: 6, phases: 3, variable_current: true, departure_time: '07:00', target_pct: 80, }, // Multi-EV (§5): lista av laddboxar (max 2). Normaliseras från backend vid // GET (migrerar gammalt ev_charging-objekt vid behov). Skickas som // ev_chargers vid save; gamla ev_charging strippas ur payloaden. ev_chargers: [], deferrable_loads: [], negate_charge: false, negate_chargesolar: false, negate_selfconsumption: false, negate_peakshaving: false, negate_export: false, // Buy economy (öre/kWh except moms) — svenska standardvärden grid_fee_ore: 0, energy_tax_ore: 0, retailer_margin_ore: 0, vat_pct: 25, // Sell economy grid_benefit_ore: 0, sale_margin_ore: 0, cycle_cost_ore: 10, // Peak shaving (av som default) peak_shaving_enabled: false, peak_shaving_mode: 'simple', peak_max_grid_kw: 5, peak_cost_kr_per_kw: 50, peak_reserve_kwh: '', // tomt = automatiskt (null i API:t, ALDRIG 0) peak_day_max_kw: 0, peak_night_max_kw: 0, peak_day_start_h: 6, peak_day_end_h: 22, peak_winter_high_start_h: 6, peak_winter_high_end_h: 21, peak_winter_high_max_kw: 0, peak_winter_high_cost_kr_per_kw: 0, peak_winter_low_max_kw: 0, peak_winter_low_cost_kr_per_kw: 0, peak_winter_months: [11,12,1,2,3], // Nov-Mar (kalendermånad 1-12) peak_summer_max_kw: 0, peak_summer_cost_kr_per_kw: 0, peak_summer_months: [4,5,6,7,8,9,10], // Apr-Okt (kalendermånad 1-12) // Driftläge (2026-07-30, SPEC_GRAVIO_USER_MODE_UI): self_sufficiency-läget är // rivet — operating_mode finns inte längre i UI:t eller i payloaden. // user_mode_enabled = master-gate. AV → flottans defaults gäller, men // rattvärdena raderas inte (backend ignorerar dem, UI:t minns dem). user_mode_enabled: false, // Rattar: null = OJUSTERAD (flottans standard, visas som ghost, skickas // aldrig). 0 = av och är ett giltigt sparat värde. Blanda aldrig ihop dem. traj_premie_ore_per_kwh_h: null, sellsolar_min_price_ore: null, maxprofit_import_aversion_kr: null, soc_planning_buffer_pct: null, }; // ── Section colour accents (matches the gradient borders in the original) ── /* Kategorier för SNav (2026-06-11) — id:n matchar
-wrapparna i main. Färgprickarna speglar respektive korts accent. */ const SETTINGS_SECTIONS = [ { id: 'grp-hem', label: 'Hem & plats', color: '#3FCC8B' }, { id: 'grp-drift', label: 'Driftläge', color: '#00BFA5' }, { id: 'grp-sol', label: 'Sol & batteri', color: '#F4C84B' }, { id: 'grp-install', label: 'Elinstallation', color: '#5BA8FF' }, { id: 'grp-ev', label: 'Elbil & laster', color: '#7AB8FF' }, { id: 'grp-data', label: 'Datafält & enheter', color: '#8C97AE' }, { id: 'grp-ekonomi', label: 'Ekonomi', color: '#FF8A47' }, { id: 'grp-peak', label: 'Effektavgift', color: '#9D7BFF' }, { id: 'grp-konto', label: 'Konto & säkerhet', color: '#FF5A65' }, ]; /* ---- Reserv för toppar (kontrakt 1/9 punkt A) -------------------------- Tomt fält och 0 betyder BÅDA "automatiskt" och skickas som null. 0 skrevs tidigare om till 1 kWh — den som skriver 0 vill inte reservera något själv, så klampet gav honom motsatsen. Utanför 1–50 klampas inget tyst: felet visas rött vid fältet och sparningen stoppas, för ett tyst klamp sparar ett annat värde än det användaren lämnade. */ function peakReserveOut(v){ if (v === '' || v == null) return null; const n = Number(v); if (!isFinite(n) || n <= 0) return null; return n; } function peakReserveErr(v){ if (v === '' || v == null) return null; const n = Number(v); if (!isFinite(n)) return 'Ange ett tal i kWh, eller lämna fältet tomt för automatiskt.'; if (n === 0) return null; // 0 = automatiskt, giltigt /* Negativt är inte "automatiskt" — det är ett skrivfel. Utan den här raden föll −5 igenom båda gränskontrollerna och sparades TYST som null, så användaren trodde att han satt en reserv. */ if (n < 0) return 'Reserven kan inte vara negativ. Lämna fältet tomt eller skriv 0 för automatiskt.'; if (n < 1) return 'Reserven måste vara minst 1 kWh. Lämna fältet tomt eller skriv 0 för automatiskt.'; if (n > 50) return 'Reserven kan högst vara 50 kWh.'; return null; } /* Backendens 422 i läsbar form. Utan detta blev ett fältfel "HTTP 422 — {"detail":[{"type":"greater_than"…" i statusraden, och användaren fick gissa vilket fält som stoppade sparningen. Högst tre fel, som backend. */ const SB_FIELD_NAME = { deferrable_loads: 'skjutbara laster', power_kw: 'effekt', windows: 'körfönster', peak_reserve_kwh: 'reserv för toppar', duration_hours: 'körtid', earliest: 'tidigast start', latest: 'senast klar', days: 'veckodagar', name: 'namn', max_parts: 'max antal delar', }; function settings422(body){ const det = (body && Array.isArray(body.detail)) ? body.detail : []; if (!det.length) return null; const lines = det.slice(0, 3).map(d => { const loc = Array.isArray(d && d.loc) ? d.loc.filter(x => x !== 'body') : []; /* Numret hör till f\u00e4ltet FÖRE det, inte till slutet: loc ['deferrable_loads',0,'power_kw'] är "skjutbara laster 1 → effekt", inte "skjutbara laster → effekt 1". */ const parts = []; loc.forEach(x => { if (typeof x === 'number'){ if (parts.length) parts[parts.length - 1] += ' ' + (x + 1); } else parts.push(SB_FIELD_NAME[x] || String(x)); }); const msg = String((d && d.msg) || '').replace(/^Value error,\s*/i, ''); return parts.length ? `${parts.join(' → ')}: ${msg}` : msg; }); return lines.join(' · '); } const ACCENTS = { contact: 'linear-gradient(90deg, #3FCC8B, #FF8A47)', area: 'linear-gradient(90deg, #5BA8FF, #00BFA5)', position: 'linear-gradient(90deg, #F4C84B, #FF8A47)', solar: 'linear-gradient(90deg, #F4C84B, #3FCC8B)', battery: 'linear-gradient(90deg, #9D7BFF, #5BA8FF)', install: 'linear-gradient(90deg, #5BA8FF, #00BFA5)', homey: 'linear-gradient(90deg, #5BA8FF, #9D7BFF)', econBuy: 'linear-gradient(90deg, #FF8A47, #FF5A65)', econSell: 'linear-gradient(90deg, #3FCC8B, #00BFA5)', peak: 'linear-gradient(90deg, #FF5A65, #9D7BFF)', selfSuf: 'linear-gradient(90deg, #F4C84B, #FF8A47)', password: 'linear-gradient(90deg, #3FCC8B, #5BA8FF)', api: 'linear-gradient(90deg, #9D7BFF, #5BA8FF)', }; // ── Helper: deep-equal for dirty detection ────────────────────────────── const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); // ── Timme-väljare för höglastfönstret (2026-07-30) ───────────────────── // "0" och "24" i ett nummerfält var obegripligt för villaägaren. Samma // värden (heltal 0–24) skickas till backend, men UI:t säger "kl 07:00". const HOUR_LABEL = (h) => String(h).padStart(2, '0') + ':00'; const hourOpts = (isEnd) => Array.from({length: 24}, (_, i) => { const h = isEnd ? i + 1 : i; return { value: String(h), label: 'kl ' + HOUR_LABEL(h === 24 ? 0 : h) + (h === 24 ? ' (midnatt)' : '') }; }); function PeakWindow({ start, end, onStart, onEnd, startLabel, endLabel }){ const a = Number(start) || 0, b = Number(end) || 0; const wraps = b <= a; const hours = wraps ? (24 - a) + b : b - a; const allDay = hours === 0 || hours >= 24; return (
onStart(Number(v))} options={hourOpts(false)}/> onEnd(Number(v))} options={hourOpts(true)}/>
{allDay ? 'Höglast gäller dygnet runt — låglastgränsen används aldrig.' : <>Höglast gäller kl {HOUR_LABEL(a)}–{HOUR_LABEL(b === 24 ? 0 : b)}{wraps ? ' (över midnatt)' : ''} — {hours} timmar per dygn. Övrig tid räknas som låglast.}
); } // ── Multi-batteri-editor ─────────────────────────────────────────── // Renderar varje batteri i `batteries`-arrayen som en redigerbar rad + // Lägg till/Ta bort-knappar. Vid save POSTas hela arrayen tillbaka. const MULTI_BATT_DEFAULT = { capacity_kwh: 0, charge_rate_kw: 0, discharge_rate_kw: 0, soc_min: 5, soc_max: 100, ai_controlled: false, }; function MultiBatteryEditor({ batteries, onChange }){ const upd = (i, patch) => { const next = batteries.map((b, j) => j === i ? { ...b, ...patch } : b); onChange(next); }; const add = () => { if (batteries.length >= 3) return; onChange([...batteries, { ...MULTI_BATT_DEFAULT }]); }; const remove = (i) => onChange(batteries.filter((_, j) => j !== i)); if (batteries.length === 0){ return (
Inga batterier konfigurerade än. Klicka på knappen nedan för att lägga till.
); } return (
{batteries.map((b, i) => (
Batteri {i+1}
upd(i, {capacity_kwh: parseFloat(v) || 0})}/> upd(i, {charge_rate_kw: parseFloat(v) || 0})}/> upd(i, {discharge_rate_kw: parseFloat(v) || 0})}/> upd(i, {soc_min: parseFloat(v) || 0})}/> upd(i, {soc_max: parseFloat(v) || 100})}/>
upd(i, {ai_controlled: v})}/>
))} {batteries.length < 3 && ( )}
); } function SettingsApp(){ const [original, setOriginal] = React.useState(SETTINGS_DEFAULTS); const [s, setS] = React.useState(SETTINGS_DEFAULTS); const [loading, setLoading] = React.useState(true); const [loadErr, setLoadErr] = React.useState(null); const [saving, setSaving] = React.useState(false); const [status, setStatus] = React.useState(null); // {kind:'ok'|'err'|'info', msg} // Load real settings on mount // Engångsstädning av den rivna bil-skuggan (2026-07-30). React.useEffect(() => { window.evModel?.purgeLegacyCarShadow?.(); }, []); /* Automatiska reservvärdet till placeholder (kontrakt 1/9): visar vad "tomt" betyder i praktiken. Tyst vid fel — placeholdern blir generisk. RAW FETCH, inte window.hems: fasaden konfigureras aldrig med en sessionsnyckel på den här sidan (settings.html använder SETTINGS_API_KEY överallt), så getPeak() kastade "ingen sessionsnyckel satt än" och catch:en svalde det tyst — placeholdern kunde ALDRIG bli annat än fallbacken. Samma mönster som den frivilliga live-data-hinten i settings-ev.jsx:552. */ const [autoReserve, setAutoReserve] = React.useState(null); React.useEffect(() => { (async () => { try { const r = await fetch(`${SETTINGS_BASE_URL}/api/peak-insights?api_key=${encodeURIComponent(SETTINGS_API_KEY)}`, { headers: { 'Accept': 'application/json' } }); if (!r.ok) return; const p = await r.json(); const sr = p?.peak_shaving?.soc_reserve; if (sr?.kwh != null) setAutoReserve(sr); } catch(e){} })(); }, []); React.useEffect(() => { (async () => { try { const url = `${SETTINGS_BASE_URL}/settings/api/${SETTINGS_API_KEY}`; const r = await fetch(url); // #294: död session → rensa token + tillbaka till login (aldrig felloop) if (r.status === 401) { localStorage.removeItem('hems_session_token'); window.location.href = 'login.html?expired=1'; return; } if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); // Säkerhetsfix 2026-05-29: backend skickar solar_arrays: [{kwp, azimuth, // tilt}, ...] som array, men UI har flata fält solar_array_1_kwp etc. // Mappar arrayen → flata fält så Lennies riktiga sol-config visas // istället för defaults (= Andrés värden i föregående bug). const arr = Array.isArray(data.solar_arrays) ? data.solar_arrays : []; const flatSolar = {}; for (let i = 0; i < 3; i++) { const a = arr[i] || {}; flatSolar[`solar_array_${i+1}_kwp`] = a.kwp ?? 0; flatSolar[`solar_array_${i+1}_azimuth`] = a.azimuth ?? 180; flatSolar[`solar_array_${i+1}_tilt`] = a.tilt ?? 30; } // Backend-namn → frontend-namn-mapping för fält som heter olika. // Bygger på fullständig audit 2026-05-29 av staging-users Lennie + André. // Använder ?? istället för || så 0 (giltigt värde) inte ersätts av default. const renamed = { // Position latitude: data.lat ?? data.latitude ?? 0, longitude: data.lon ?? data.longitude ?? 0, // SOC + moms battery_soc_min_pct: data.soc_min ?? data.battery_soc_min_pct ?? 5, battery_soc_max_pct: data.soc_max ?? data.battery_soc_max_pct ?? 100, vat_pct: data.vat_percent ?? data.vat_pct ?? 25, // Versionsnumret följer med orört genom hela rundgången (se toBackend). settings_rev: data.settings_rev ?? null, // Batteri-verkningsgrader battery_efficiency_pct: data.battery_efficiency ?? data.battery_efficiency_pct ?? 98, inverter_efficiency_pct: data.inverter_efficiency ?? data.inverter_efficiency_pct ?? 96, // EV-pause pause_battery_on_ev_charging: data.ev_pause_battery ?? data.pause_battery_on_ev_charging ?? false, // Elbil & skjutbara laster — nested objekt + array rakt från backend; // saknade nycklar får form från defaults så UI:t aldrig kraschar. ev_charging: { ...SETTINGS_DEFAULTS.ev_charging, ...(data.ev_charging || {}) }, // Multi-EV (§5): normalisera till lista (migrerar singular vid behov). ev_chargers: (window.evModel ? window.evModel.normalize(data) : []), deferrable_loads: Array.isArray(data.deferrable_loads) ? data.deferrable_loads : [], // Ekonomi köp (öre/kWh — backend skickar samma siffra med _kwh-suffix) grid_fee_ore: data.grid_fee_kwh ?? data.grid_fee_ore ?? 0, energy_tax_ore: data.energy_tax_kwh ?? data.energy_tax_ore ?? 0, retailer_margin_ore: data.elhandel_margin_kwh ?? data.retailer_margin_ore ?? 0, // Ekonomi sälj grid_benefit_ore: data.grid_benefit_kwh ?? data.grid_benefit_ore ?? 0, sale_margin_ore: data.sell_margin_kwh ?? data.sale_margin_ore ?? 0, cycle_cost_ore: data.cycle_cost_kwh ?? data.cycle_cost_ore ?? 10, // Multi-batteri (backend skickar batteries-array när multi) multi_battery_enabled: Array.isArray(data.batteries) && data.batteries.length > 1, /* Peak shaving. enabled/mode lästes ALDRIG in (kritiskt, backend 31/8): kortet visade alltid av + "Enkel" och aktivering sparades aldrig. mode BEVARAS ordagrant även när värdet är okänt — klubbas det till default skriver nästa spara sönder användarens läge (punkt 5). */ peak_shaving_enabled: data.peak_shaving_enabled === true, peak_shaving_mode: data.peak_shaving_mode || 'simple', peak_max_grid_kw: data.peak_shaving_max_grid_kw ?? 5, peak_cost_kr_per_kw: data.peak_shaving_cost_kr_per_kw ?? 50, peak_reserve_kwh: data.peak_reserve_kwh ?? '', peak_day_max_kw: data.peak_shaving_day_max_kw ?? 0, peak_night_max_kw: data.peak_shaving_night_max_kw ?? 0, peak_day_start_h: data.peak_shaving_day_start ?? 6, peak_day_end_h: data.peak_shaving_day_end ?? 22, // period1 = vinter (high/low), period2 = sommar peak_winter_high_start_h: data.peak_shaving_period1_day_start ?? 6, peak_winter_high_end_h: data.peak_shaving_period1_day_end ?? 21, peak_winter_high_max_kw: data.peak_shaving_period1_day_max_kw ?? 0, peak_winter_high_cost_kr_per_kw: data.peak_shaving_period1_day_cost_kr ?? 0, peak_winter_low_max_kw: data.peak_shaving_period1_night_max_kw ?? 0, peak_winter_low_cost_kr_per_kw: data.peak_shaving_period1_night_cost_kr ?? 0, peak_winter_months: Array.isArray(data.peak_shaving_period1_months) ? data.peak_shaving_period1_months : [], peak_summer_max_kw: data.peak_shaving_period2_max_kw ?? 0, peak_summer_cost_kr_per_kw: data.peak_shaving_period2_cost_kr ?? 0, peak_summer_months: Array.isArray(data.peak_shaving_period2_months) ? data.peak_shaving_period2_months : [], // Driftläge (2026-07-30): operating_mode läses INTE längre. // Master-gate + rattar: fältet saknas i svaret ⇒ ojusterat (null), // inte 0 — därför `in`-koll och inte ?? mot flottans default. // Master-gate = TRESTAT (SPEC_DRIFTLAGE_UI_V2 §2.2). Läs ALDRIG // `!!data.user_mode_enabled`: null ⇒ false vore fel för legacy-users // med satta rattar. true/false används rakt av; null/saknas = // användaren har aldrig sparat sedan backend-fixen — motorn kör då // rattarna, så UI:t härleds ur dem (någon ratt satt ⇒ Egna inställningar). user_mode_enabled: (data.user_mode_enabled === true || data.user_mode_enabled === false) ? data.user_mode_enabled : (window.MODE_KNOB_FIELDS || []).some(f => data[f] !== null && data[f] !== undefined && data[f] !== ''), traj_premie_ore_per_kwh_h: ('traj_premie_ore_per_kwh_h' in data) ? data.traj_premie_ore_per_kwh_h : null, sellsolar_min_price_ore: ('sellsolar_min_price_ore' in data) ? data.sellsolar_min_price_ore : null, maxprofit_import_aversion_kr: ('maxprofit_import_aversion_kr' in data) ? data.maxprofit_import_aversion_kr : null, soc_planning_buffer_pct: ('soc_planning_buffer_pct' in data) ? data.soc_planning_buffer_pct : null, }; // Filtrera bort password_hash och api_key_hash — ingen frontend ska se dem const safeData = { ...data }; delete safeData.password_hash; delete safeData.password_set_at; delete safeData.api_key_hash; // Merge defaults så saknade nycklar har form const merged = { ...SETTINGS_DEFAULTS, ...safeData, ...flatSolar, ...renamed }; setOriginal(merged); setS(merged); } catch (e){ console.warn('Settings load failed, using defaults', e); setLoadErr(String(e.message || e)); } setLoading(false); })(); }, []); // Update a single field const u = (key, val) => setS(prev => ({...prev, [key]: val})); const isDirty = !eq(s, original); // Reverse-mapping UI-fält → backend-namn. Speglar GET-mappingen ovan så // Lennies sparade ändringar når rätt fält i Firestore. Skickar BÅDA namnen // (UI + backend) för felräddande — backend väljer det den känner igen. const toBackend = (ui) => { const arrs = [0,1,2].map(i => ({ kwp: ui[`solar_array_${i+1}_kwp`] ?? 0, azimuth: ui[`solar_array_${i+1}_azimuth`] ?? 180, tilt: ui[`solar_array_${i+1}_tilt`] ?? 30, })).filter(a => a.kwp > 0); // Driftläge (2026-07-30): self_sufficiency är rivet. operating_mode och de // gamla MSC/självförsörjnings-fälten destruktureras ut så de ALDRIG följer // med via ...ui (backend tolererar men skriver aldrig operating_mode). // Rattfälten plockas också ut här och läggs tillbaka villkorligt nedan — // ett ojusterat fält får inte skickas alls. const { operating_mode: _deadMode, maximize_self_consumption, maximize_self_consumption_enabled, self_sufficiency_mode, self_sufficiency_threshold_pct, self_sufficiency_penalty_ore, minimum_export_profit_ore, msc_export_min_profit_ore, traj_premie_ore_per_kwh_h: _k1, sellsolar_min_price_ore: _k2, maxprofit_import_aversion_kr: _k3, soc_planning_buffer_pct: _k4, ev_charging: _legacyEvCharging, settings_rev: _rev, ...uiClean } = ui; // Rattar (§3.3): satt värde skickas; ojusterat utelämnas; en ratt som VAR // satt och nu är återställd skickas som explicit null (backend har // None-pop-skydd — nollställningsvägen är koordinerad OCH driftsatt 1/8: // `null` når databasen på riktigt, ghost-läget efter omladdning är korrekt). const knobs = {}; for (const f of (window.MODE_KNOB_FIELDS || [])){ const v = ui[f]; const wasSet = original[f] !== null && original[f] !== undefined && original[f] !== ''; if (v !== null && v !== undefined && v !== '') knobs[f] = Number(v); else if (wasSet) knobs[f] = null; } return { ...uiClean, ...knobs, // Master-gate: skickas alltid (bool, inget ghost-begrepp) user_mode_enabled: !!ui.user_mode_enabled, // Position lat: ui.latitude, lon: ui.longitude, // SOC + moms soc_min: ui.battery_soc_min_pct, soc_max: ui.battery_soc_max_pct, vat_percent: ui.vat_pct, // Skickas tillbaka så backend kan se om någon annan hann skriva emellan. // Utelämnas när dokumentet inte bar något (äldre backend). ...(_rev != null ? { settings_rev: _rev } : {}), // Batteri-verkningsgrader battery_efficiency: ui.battery_efficiency_pct, inverter_efficiency: ui.inverter_efficiency_pct, // EV-pause ev_pause_battery: ui.pause_battery_on_ev_charging, // Multi-EV (§5): skicka ev_chargers-array (backend läser den när den finns); // gamla singular ev_charging strippades ur payloaden ovan. id skickas // ALLTID när det finns — även för nya boxar: UI:t genererar ett id som är // unikt mot listan (evModel.nextChargerId) och det är kontraktsnyckeln. // Id-lösa rader skulle tvinga backend att koppla på positionsordning, // vilket går sönder vid borttagning/omordning. ev_chargers: (Array.isArray(ui.ev_chargers) ? ui.ev_chargers : []) .map(c => { const rest = { ...(c || {}) }; const id = rest.id; delete rest.id; delete rest._new; return (id != null && id !== '') ? { id, ...rest } : rest; }), // Bilprofiler (SPEC_BILPROFILER_PER_BOX): bilar ≠ boxar. Skickas bara när // användaren faktiskt har rört dem — härledda rader (som bara speglar // boxarnas battery_kwh) ska inte skapa profiler i backend i förtid. // SPEC_EV_FASER_PER_BIL §5.4: `phases` bärs bara när användaren valt // 1 eller 3 — vid "Som laddboxen" utelämnas nyckeln HELT (inte null, // inte 0, inte ""). Vakten här är sista ledet; UI:t deletar redan. ...(Array.isArray(ui.ev_cars) ? { ev_cars: ui.ev_cars .map(c => { const rest = { ...(c || {}) }; delete rest._derived; delete rest._new; if (Number(rest.phases) !== 1 && Number(rest.phases) !== 3) delete rest.phases; else rest.phases = Number(rest.phases); return rest; }) .filter(c => c.id != null && c.id !== '') } : {}), /* Skjutbara laster: id sätts av backend — skicka ALDRIG id för nya laster (tomt/saknat), behåll orört för befintliga. Radering = borta ur arrayen. lwToPayload är OBLIGATORISK här, inte en finess: en legacy-last utan `windows` visas som en fönsterrad i editorn, och spec §2 kräver att raden SKRIVS som `windows` vid nästa spar. Ändrade användaren bara namnet gick lasten tidigare tillbaka i legacy-form härifrån — tyst, eftersom V5 uppfylls av toppnivåns duration_hours. Rörde han ett fönster på en last men bara namnet på en annan migrerade den ena och inte den andra, i samma POST, och båda såg likadana ut i UI:t. Samma normalisering som gc-last.jsx och ev-loads.jsx. */ deferrable_loads: (Array.isArray(ui.deferrable_loads) ? ui.deferrable_loads : []) .map(l => { const rest = window.lwToPayload ? window.lwToPayload(l || {}) : { ...(l || {}) }; const id = rest.id; delete rest.id; return id != null && id !== '' ? { id, ...rest } : rest; }), // Ekonomi grid_fee_kwh: ui.grid_fee_ore, energy_tax_kwh: ui.energy_tax_ore, elhandel_margin_kwh: ui.retailer_margin_ore, grid_benefit_kwh: ui.grid_benefit_ore, sell_margin_kwh: ui.sale_margin_ore, cycle_cost_kwh: ui.cycle_cost_ore, // Sol — flata UI-fält → array solar_arrays: arrs, // Peak shaving — enabled/mode ingick inte i payloaden (kritiskt 31/8). peak_shaving_enabled: ui.peak_shaving_enabled, peak_shaving_mode: ui.peak_shaving_mode, /* SANERING vid varje Enkel-sparning (backend 1/9): fyra dolda legacy- fält (months/weekdays/start/end) gate:ar NÄR gränsen gäller. Två testanvändare hade gamla vinter/vardag/dagtid-värden kvar i dokumentet — "en gräns hela året" var i praktiken avstängd hela september utan att något syntes. simple ⇒ hela året/alla dagar/hela dygnet, exakt vad etiketten lovar (månader 1–12 som build 189, veckodagar 0=mån). Övriga lägen skickar dem INTE — de har egna periodfält, och medvetet sparade filter från gamla systemet får inte skrivas över. */ ...(ui.peak_shaving_mode === 'simple' ? { peak_shaving_months: [1,2,3,4,5,6,7,8,9,10,11,12], peak_shaving_weekdays: [0,1,2,3,4,5,6], peak_shaving_start_hour: 0, peak_shaving_end_hour: 24, } : {}), /* Dag/Natt sanerar samma months/weekdays (backend 1/9): SOC-reserven grindas på dem, så gamla vinter/vardag-värden tog bort beredskaps- bufferten sommartid och helger. start/end skickas INTE — Dag/Natt har egna dag-/nattfönster. */ ...(ui.peak_shaving_mode === 'day_night' ? { peak_shaving_months: [1,2,3,4,5,6,7,8,9,10,11,12], peak_shaving_weekdays: [0,1,2,3,4,5,6], } : {}), peak_shaving_max_grid_kw: ui.peak_max_grid_kw, peak_shaving_cost_kr_per_kw: ui.peak_cost_kr_per_kw, /* Reserv för toppar (kontrakt 1/9 punkt A): tomt fält ELLER 0 ⇒ null (automatik), ALDRIG 0 i payloaden. "0" skrevs tidigare om till 1 kWh, alltså raka motsatsen till vad användaren menade — den som skriver 0 vill inte reservera något själv och låter HEMS räkna. Talet klampas INTE längre tyst: ligger det utanför 1–50 står det rött vid fältet och sparningen stoppas innan den går i vägen, för ett tyst klamp gav användaren ett annat värde än det han lämnade. */ peak_reserve_kwh: peakReserveOut(ui.peak_reserve_kwh), peak_shaving_day_max_kw: ui.peak_day_max_kw, peak_shaving_night_max_kw: ui.peak_night_max_kw, peak_shaving_day_start: ui.peak_day_start_h, peak_shaving_day_end: ui.peak_day_end_h, // UI-namn → period1/period2 peak_shaving_period1_day_start: ui.peak_winter_high_start_h, peak_shaving_period1_day_end: ui.peak_winter_high_end_h, peak_shaving_period1_day_max_kw: ui.peak_winter_high_max_kw, peak_shaving_period1_day_cost_kr: ui.peak_winter_high_cost_kr_per_kw, peak_shaving_period1_night_max_kw: ui.peak_winter_low_max_kw, peak_shaving_period1_night_cost_kr: ui.peak_winter_low_cost_kr_per_kw, peak_shaving_period1_months: ui.peak_winter_months, peak_shaving_period2_max_kw: ui.peak_summer_max_kw, peak_shaving_period2_cost_kr: ui.peak_summer_cost_kr_per_kw, peak_shaving_period2_months: ui.peak_summer_months, }; }; // Save (POST full object back) const save = async () => { // Spärr: dubblett-id bland laddboxar skulle låta två bilar dela // sessionsnyckel i backend (SOC hoppar mellan bilarna). Blockera POST. const dupIds = window.evModel?.duplicateChargerIds ? window.evModel.duplicateChargerIds(s.ev_chargers) : []; if (dupIds.length) { setStatus({kind:'err', msg:`Kunde inte spara: två laddboxar har samma id (${dupIds.join(', ')}). Ta bort och lägg till bilen igen.`}); return; } // Samma spärr för bilprofiler — car_id är nyckeln sessionen pekar på. const dupCars = window.evModel?.duplicateChargerIds ? window.evModel.duplicateChargerIds(s.ev_cars) : []; if (dupCars.length) { setStatus({kind:'err', msg:`Kunde inte spara: två bilar har samma id (${dupCars.join(', ')}). Ta bort och lägg till bilen igen.`}); return; } /* Reserven stoppas HÄR i stället för att klampas tyst (kontrakt 1/9 A). Backend 422:ar utanför 1–50, och ett tyst klamp hade sparat ett annat värde än det som står i fältet. */ const resErr = peakReserveErr(s.peak_reserve_kwh); if (resErr) { setStatus({kind:'err', msg:`Kunde inte spara: ${resErr}`}); return; } /* Effekten på en skjutbar last måste vara > 0 — backend avvisar hela dokumentet annars, och då ser det ut som att inget alls fastnade (Andrés fall 2/9). Namnet på lasten pekas ut, inte fältnumret. */ const badLoad = (Array.isArray(s.deferrable_loads) ? s.deferrable_loads : []) .find(l => l && l.enabled !== false && !(Number(l.power_kw) > 0)); if (badLoad) { setStatus({kind:'err', msg:`Kunde inte spara: lasten "${badLoad.name || 'utan namn'}" har ingen effekt. Ange effekt i kW större än 0.`}); return; } /* Fönsterreglerna speglas också här: ett överlapp byggt på inställningssidan gick annars till backend och kom tillbaka som 422 — läsbart sedan settings422, men en onödig rundtur. */ if (window.lwValidate && window.lwFromLoad) { const loadsArr = Array.isArray(s.deferrable_loads) ? s.deferrable_loads : []; for (let i = 0; i < loadsArr.length; i++) { const e = window.lwValidate(window.lwFromLoad(loadsArr[i])); const k = Object.keys(e)[0]; if (k != null) { const nm = (loadsArr[i] && loadsArr[i].name) || `Last ${i + 1}`; setStatus({kind:'err', msg:`Kunde inte spara: ${nm}, fönster ${Number(k) + 1} — ${e[k]}`}); return; } } } setSaving(true); setStatus({kind:'info', msg:'Sparar…'}); try { const url = `${SETTINGS_BASE_URL}/settings/api/${SETTINGS_API_KEY}`; const payload = toBackend(s); const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); // #294: död session → rensa token + tillbaka till login if (r.status === 401) { localStorage.removeItem('hems_session_token'); window.location.href = 'login.html?expired=1'; return; } if (r.status === 409){ setStatus({kind:'err', msg:'Inställningarna ändrades på annat håll medan du redigerade. Läs in sidan igen så du inte skriver över den ändringen.'}); setSaving(false); return; } if (!r.ok){ /* 422 är ett fältsvar, inte ett tekniskt fel: läs body:n och peka ut fältet i klartext i stället för att klistra in rå JSON. */ if (r.status === 422){ const b = await r.json().catch(()=>null); const txt = settings422(b); setStatus({kind:'err', msg: txt ? `Kunde inte spara — ${txt}` : 'Kunde inte spara: ett fält avvisades av HEMS.'}); setSaving(false); return; } const t = await r.text().catch(()=>String(r.status)); throw new Error(`HTTP ${r.status} — ${t.slice(0, 80)}`); } const result = await r.json(); // Nytt versionsnummer ur svaret — annars spar man bara en gång per // sidladdning innan numret är inaktuellt. const next = result && result.settings_rev != null ? { ...s, settings_rev: result.settings_rev } : s; if (next !== s) setS(next); setOriginal(next); setStatus({kind:'ok', msg: result.status === 'saved' ? 'Sparat' : 'OK'}); setTimeout(()=>setStatus(null), 4000); } catch(e){ setStatus({kind:'err', msg: `Kunde inte spara: ${e.message}`}); } setSaving(false); }; const reset = () => setS(original); // Flik-läge (2026-06-11): vald kategori i SNav visar BARA den sektionen. // Hash i URL:en så en sidladdning/delning landar på samma kategori. const [activeSec, setActiveSec] = React.useState(() => { const h = (window.location.hash || '').slice(1); return SETTINGS_SECTIONS.some(sec => sec.id === h) ? h : 'grp-hem'; }); const selectSec = (id) => { setActiveSec(id); try { history.replaceState(null, '', '#' + id); } catch(e){} window.scrollTo(0, 0); }; if (loading){ return (
{ try { localStorage.setItem('gc.page', 'oversikt'); } catch(e){} }}> Gravio

Inställningar

Systemkonfiguration
Laddar inställningar…
); } return (
{/* Logotypen går hem till Översikt (André 1/9): gc.page sätts före navigeringen, annars öppnar index senast besökta fliken. */} { try { localStorage.setItem('gc.page', 'oversikt'); } catch(e){} }}> Gravio

Inställningar

Systemkonfiguration
{status && ( {status.msg} )} {loadErr && !status && ( Mockdata · ej anslutet )} {isDirty && !status && ( Osparade ändringar )}
{/* ── Kontaktuppgifter ─────────────────────────────────────── */}
u('name', v)} help="Vad anläggningen ska kallas i appen"/> u('email', v)} help="Används för aviseringar och framtida inloggning"/>
{/* ── Elområde ─────────────────────────────────────────────── */} u('area', v)} help="Styr vilka spotpriser HEMS planerar mot" options={[ {value:'SE1', label:'SE1 — Luleå'}, {value:'SE2', label:'SE2 — Sundsvall'}, {value:'SE3', label:'SE3 — Södra Mellansverige'}, {value:'SE4', label:'SE4 — Malmö'}, ]}/> {/* ── Position ─────────────────────────────────────────────── */} { u('latitude', +lat.toFixed(4)); u('longitude', +lng.toFixed(4)); }}/>
u('latitude', v)}/> u('longitude', v)}/>
Positionen används för att hämta väder och beräkna solvinklar.
{/* ── Solcellsanläggning ───────────────────────────────────── */}
{/* Driftläge (2026-07-30): automatisk drift vs egna inställningar */}
{[1, 2, 3].map((i) => (
Fält {i}
u(`solar_array_${i}_kwp`, v)}/> u(`solar_array_${i}_azimuth`, v)} help="0=N, 90=Ö, 180=S, 270=V"/> u(`solar_array_${i}_tilt`, v)}/>
))}
Riktning: Söder (180°) är optimalt i Sverige. Lutning: 30–40° är optimalt för årsproduktion.
{/* ── Batteri ──────────────────────────────────────────────── */} u('multi_battery_enabled', v)}/>}> u('battery_capacity_kwh', v)} help="Total kapacitet (brutto, innan SOC-gränser)"/>
u('battery_charge_rate_kw', v)}/> u('battery_discharge_rate_kw', v)}/> u('battery_soc_min_pct', v)}/> u('battery_soc_max_pct', v)}/> u('battery_efficiency_pct', v)} help="Förlust vid laddning + urladdning (typiskt 85–95%)"/> u('inverter_efficiency_pct', v)} help="Hur mycket av elen som blir kvar efter växelriktning"/>
{/* Multi-batteri: visar varje batteri som en redigerbar rad. Backend skickar batteries: [{capacity_kwh, charge_rate_kw, discharge_rate_kw, soc_min, soc_max, ai_controlled}, ...] när användaren har flera. */} {s.multi_battery_enabled && (
Per batteri — kapacitet, laddhastigheter och AI-styrning. Lägg till eller ta bort batterier nedan (max 3 stycken).
u('batteries', arr)}/>
)}
{/* ── Elinstallation ───────────────────────────────────────── */}
u('main_fuse_a', v)} help="16A ≈ 11 kW · 20A ≈ 14 kW · 25A ≈ 17 kW · 35A ≈ 24 kW · 50A ≈ 35 kW · 63A ≈ 44 kW"/>
Sätter taket för effekt vid elmätaren (import + export). Påverkar inte sol-laddning av batteriet — det går direkt i huset.
{/* ── Effektformat (Homey) ─────────────────────────────────── */} {/* ── Elbil & skjutbara laster (SPEC_LASTER_EV 2026-06-11) ── */}
Avancerat. Rör bara dessa om något beter sig fel — fel effekt-enhet, eller att batteriet gör tvärtom mot vad HEMS säger. Annars: lämna allt som det är.
Enheter & laddbox
u('homey_power_in_kw', v)}/> {/* EV-kryssrutorna (ev_charger_power_in_kw, pause_battery_on_ev_charging) har flyttat till "Elbil & skjutbara laster" (spec §1) — mappningen oförändrad. */}
Teckenvändning
Vissa växelriktare vill ha effekten som ett minus-tal. Bocka i ett kommando bara om din växelriktare gör tvärtom när HEMS skickar det — t.ex. laddar när HEMS säger "ladda ur", eller att effekten visas med fel tecken. Vet du inte? Lämna alla tomma. De allra flesta behöver inte detta.
u('negate_charge', v)}/> u('negate_chargesolar', v)}/> u('negate_selfconsumption', v)}/> u('negate_peakshaving', v)}/> u('negate_export', v)}/>
{/* ── Ekonomi - Köp ────────────────────────────────────────── */}
Köppris = (Spotpris + alla avgifter) × moms
Ange alla avgifter utan moms — momsen läggs på automatiskt.
u('grid_fee_ore', v)} help="Vad nätbolaget tar för att leverera elen"/> u('energy_tax_ore', v)} help="Statlig skatt"/> u('retailer_margin_ore', v)} help="Påslag från elhandlaren"/> u('vat_pct', v)} help="Normalt 25 %"/>
{/* ── Ekonomi - Försäljning ────────────────────────────────── */}
Säljpris = Spotpris + nätnytta − marginal
Ingen moms vid försäljning av el.
u('grid_benefit_ore', v)} help="Ersättning från nätbolaget för el du matar ut"/> u('sale_margin_ore', v)} help="Vad elhandlaren tar för att förmedla din försäljning"/> {/* Cykelkostnad (batterislitage) flyttad till Driftläge → "Gäller alltid" (SPEC_GRAVIO_USER_MODE_UI §3.2). Samma fält cycle_cost_ore → cycle_cost_kwh, mappningen oförändrad — inga värden nollställs. */}
{/* ── Effektavgift — EGEN meny (André 31/8): eget ämne med eget kort på Översikt, inte en prisparameter under Ekonomi. ── */}
} subtitle="Begränsa nätimporten under valda timmar för att hålla ner effekttariffen. Batteriet täcker det som överstiger gränsen."> Effektavgiftshanteringen är ny och ännu inte färdigtestad av oss. Skyddet kan missa en topp, och våra tal kan avvika från nätfakturan. Sätt gränsen med egen marginal till abonnemangets, och följ upp mot fakturan de första månaderna.
u('peak_shaving_enabled', v)}/>
{s.peak_shaving_enabled && (<> {/* Lägesvärdena är backendens EXAKTA strängar — seasonal_daynight gav 422 (rätt: seasonal_day_night). day_night tillagt: 4 prod- användare kör det. Okänt mode-värde från GET bevaras som eget val i listan i stället för att klubbas — annars skriver nästa spara sönder läget (punkt 5, backend 31/8). */} u('peak_shaving_mode', v)} options={[ {value:'simple', label:'Enkel — en gräns hela året'}, {value:'day_night', label:'Dag/Natt — olika gräns dag och natt'}, {value:'seasonal', label:'Säsong — vinter/sommar'}, {value:'seasonal_day_night',label:'Säsong + Dag/Natt (t.ex. Bjäre)'}, ...(['simple','day_night','seasonal','seasonal_day_night'].includes(s.peak_shaving_mode) ? [] : [{value: s.peak_shaving_mode, label:`Okänt läge (${s.peak_shaving_mode}) — bevaras`}]), ]}/> {/* Fältsektionerna följer VALT läge (punkt 4, backend 31/8) — tidigare visades Bjäre-layouten oavsett val. */} {s.peak_shaving_mode === 'simple' && (
u('peak_max_grid_kw', v)}/> u('peak_cost_kr_per_kw', v)}/>
)} {s.peak_shaving_mode === 'day_night' && (
{/* Egna etiketter: "höglast" är säsongslägenas begrepp och finns inte i Dag/Natt. */} u('peak_day_start_h', v)} onEnd={(v)=>u('peak_day_end_h', v)} startLabel="Dag börjar" endLabel="Dag slutar"/> u('peak_day_max_kw', v)}/> u('peak_night_max_kw', v)}/> {/* En gemensam avgift för båda lägena (peak_shaving_cost_kr_per_kw) — bara besparingsvisning, styr inget. */} u('peak_cost_kr_per_kw', v)}/>
)} {s.peak_shaving_mode === 'seasonal' && (
Vinter
u('peak_winter_high_max_kw', v)}/> u('peak_winter_high_cost_kr_per_kw', v)}/>
u('peak_winter_months', v)}/>
Sommar
u('peak_summer_max_kw', v)}/> u('peak_summer_cost_kr_per_kw', v)}/>
u('peak_summer_months', v)}/>
)} {s.peak_shaving_mode === 'seasonal_day_night' && (
Aktiv gräns just nu
{s.peak_winter_high_max_kw} kW
Period 1 dag (månad {window.hemsDate().getMonth()+1}, kl {window.hemsDate().getHours()})
För nätbolag där vintern har olika höglast/låglast-tariff och sommaren har en gräns dygnet runt.
{/* Winter */}
Vinter — höglast & låglast
u('peak_winter_high_start_h', v)} onEnd={(v)=>u('peak_winter_high_end_h', v)}/> u('peak_winter_high_max_kw', v)}/> u('peak_winter_low_max_kw', v)}/> u('peak_winter_high_cost_kr_per_kw', v)}/> u('peak_winter_low_cost_kr_per_kw', v)}/>
u('peak_winter_months', v)}/>
{/* Summer */}
Sommar — en gräns dygnet runt
u('peak_summer_max_kw', v)}/> u('peak_summer_cost_kr_per_kw', v)}/>
u('peak_summer_months', v)}/>
)} {/* Reserv för toppar — ALLA lägen, INNANFÖR enabled-gaten (kontrakt 1/9 punkt A). Tomt = automatiskt (null, aldrig 0); placeholdern visar det faktiska automatiska värdet. */}
u('peak_reserve_kwh', v)} placeholder={autoReserve ? `Automatiskt: ${(window.lwNum || window.gcNum) ? (window.lwNum || window.gcNum)(autoReserve.kwh, 1) : autoReserve.kwh} kWh (${Math.round(autoReserve.percent || 0)} %)` : 'Tomt = automatiskt'} help={peakReserveErr(s.peak_reserve_kwh) ? {peakReserveErr(s.peak_reserve_kwh)} : 'Minst 1 kWh. Tomt eller 0 = HEMS räknar själv utifrån gräns och erfarenhet.'}/>
)}
{/* ── Byt lösenord ─────────────────────────────────────────── */}
{/* ── Homey API-nyckel ─────────────────────────────────────── */} {/* Säkerhetsfix 2026-05-29: visa data.api_key (Homey-nyckeln per user) */} {/* från backend, INTE SETTINGS_API_KEY (sess_*-token för dashboard-auth). */} {/* Innan: hårdkodad Andrés hems_3694... → alla såg Andrés nyckel. */} {/* ── Logga ut ─────────────────────────────────────────────── */}
Det kan ta upp till 5 minuter innan ändrade inställningar slår igenom i systemets beräkningar och dashboard.
); } // ── Live Leaflet map with draggable pin + click-to-set ───────────────── function PositionMap({ lat, lng, onChange }){ const containerRef = React.useRef(null); const mapRef = React.useRef(null); const markerRef = React.useRef(null); // Init once React.useEffect(() => { if (mapRef.current || !containerRef.current || !window.L) return; const L = window.L; const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, }).setView([lat || 59.33, lng || 18.06], 11); /* OSM-tiles i stället för CARTO (31/8): CARTO:s fria basemap kräver numera API-nyckel och vattenstämplar hela kartan "API KEY REQUIRED". OSM är nyckelfri; mörkret görs med CSS-filter på tile-panelen (settings.css .sc-map .leaflet-tile-pane) så paletten fortfarande matchar. */ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, }).addTo(map); // Custom HTML pin so it picks up theme colour const pinIcon = L.divIcon({ className: 'sc-pin-icon', html: ` `, iconSize: [32, 32], iconAnchor: [16, 30], }); const marker = L.marker([lat || 59.33, lng || 18.06], { icon: pinIcon, draggable: true, }).addTo(map); marker.on('dragend', () => { const p = marker.getLatLng(); onChange(p.lat, p.lng); }); map.on('click', (e) => { marker.setLatLng(e.latlng); onChange(e.latlng.lat, e.latlng.lng); }); mapRef.current = map; markerRef.current = marker; // Help Leaflet pick up its real size after layout setTimeout(() => map.invalidateSize(), 60); return () => { map.remove(); mapRef.current = null; markerRef.current = null; }; }, []); // External lat/lng changes (typed into fields, geolocation) → sync map React.useEffect(() => { if (!mapRef.current || !markerRef.current) return; if (typeof lat !== 'number' || typeof lng !== 'number') return; const p = markerRef.current.getLatLng(); if (Math.abs(p.lat - lat) > 1e-4 || Math.abs(p.lng - lng) > 1e-4){ markerRef.current.setLatLng([lat, lng]); mapRef.current.panTo([lat, lng]); } }, [lat, lng]); return (
{typeof lat === 'number' ? lat.toFixed(4) : '—'}, {typeof lng === 'number' ? lng.toFixed(4) : '—'}
); } // Driftläge-kortet bor nu i settings-mode.jsx (RunModeCard, 2026-07-30). // OperatingModeCard + self_sufficiency-valet är rivet enligt spec §2. // ── Password change card (separate endpoint — placeholder for now) ───── function PasswordCard({ apiKey, initialEmail }){ // Förifyll med inloggad användares email (skickas in från settings-fetch). // Fältet är fortfarande redigerbart om någon byter konto. const [email, setEmail] = React.useState(initialEmail || ''); React.useEffect(() => { if (initialEmail) setEmail(initialEmail); }, [initialEmail]); const [pw1, setPw1] = React.useState(''); const [pw2, setPw2] = React.useState(''); const [status, setStatus] = React.useState(null); const onSave = () => { if (pw1.length < 6){ setStatus({kind:'err', msg:'Minst 6 tecken'}); return; } if (pw1 !== pw2){ setStatus({kind:'err', msg:'Lösenorden matchar inte'}); return; } setStatus({kind:'info', msg:'Lösenordsändring kräver separat endpoint — placeholder'}); setTimeout(()=>setStatus(null), 3500); }; return (
{status && {status.msg}}
); } ReactDOM.createRoot(document.getElementById('root')).render();