feat: rewrite to stateless web app with IndexedDB frontend
Replace CLI + SQLite architecture with a Go web server + vanilla JS frontend using IndexedDB for all client-side data storage. - Remove: cli, store, report, static packages - Add: compute engine (BuildDashboard), server package, web UI - Add: setup page with CRUD for profiles, rooms, devices, occupants, AC - Add: dashboard with SVG temperature timeline, risk analysis, care checklist - Add: i18n support (English/German) with server-side Go templates - Add: LLM provider selection UI with client-side API key storage - Add: per-room indoor temperature, edit buttons, language-aware AI summary
This commit is contained in:
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:templates all:js all:css all:i18n
|
||||
var FS embed.FS
|
||||
249
web/i18n/de.json
Normal file
249
web/i18n/de.json
Normal file
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "HeatGuard",
|
||||
"tagline": "Personalisierte Hitzevorsorge"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"setup": "Einrichtung",
|
||||
"guide": "Anleitung",
|
||||
"language": "Sprache"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Einrichtung",
|
||||
"profiles": {
|
||||
"title": "Profile",
|
||||
"help": "Ein Profil repr\u00e4sentiert einen Standort, den Sie \u00fcberwachen m\u00f6chten.",
|
||||
"name": { "label": "Name", "tooltip": "Ein beschreibender Name f\u00fcr diesen Standort" },
|
||||
"latitude": { "label": "Breitengrad", "tooltip": "Dezimalgrad (z.B. 52.52)" },
|
||||
"longitude": { "label": "L\u00e4ngengrad", "tooltip": "Dezimalgrad (z.B. 13.405)" },
|
||||
"timezone": { "label": "Zeitzone", "tooltip": "IANA-Zeitzone (z.B. Europe/Berlin)" },
|
||||
"geolocate": {
|
||||
"button": "Meinen Standort verwenden",
|
||||
"loading": "Standort wird erkannt\u2026",
|
||||
"denied": "Standortberechtigung verweigert.",
|
||||
"unavailable": "Standort nicht verf\u00fcgbar.",
|
||||
"timeout": "Zeit\u00fcberschreitung bei Standortabfrage."
|
||||
},
|
||||
"add": "Profil hinzuf\u00fcgen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "L\u00f6schen",
|
||||
"noItems": "Noch keine Profile. Erstellen Sie eines, um zu beginnen."
|
||||
},
|
||||
"rooms": {
|
||||
"title": "R\u00e4ume",
|
||||
"help": "F\u00fcgen Sie R\u00e4ume zur \u00dcberwachung hinzu. Die Eigenschaften beeinflussen die W\u00e4rmeberechnung.",
|
||||
"name": { "label": "Name", "tooltip": "Raumname (z.B. B\u00fcro, Schlafzimmer)" },
|
||||
"area": { "label": "Fl\u00e4che (m\u00b2)", "tooltip": "Bodenfl\u00e4che. Beeinflusst den Solargewinn durch Fenster." },
|
||||
"ceilingHeight": { "label": "Deckenh\u00f6he (m)", "tooltip": "Raumh\u00f6he. Beeinflusst das Luftvolumen f\u00fcr L\u00fcftungsberechnungen." },
|
||||
"floor": { "label": "Stockwerk", "tooltip": "Stockwerknummer (0 = Erdgeschoss). H\u00f6here Stockwerke sind tendenziell w\u00e4rmer." },
|
||||
"orientation": {
|
||||
"label": "Ausrichtung",
|
||||
"tooltip": "Hauptfensterrichtung. Beeinflusst den zeitlichen Verlauf des Solargewinns.",
|
||||
"options": { "N": "Nord", "NE": "Nordost", "E": "Ost", "SE": "S\u00fcdost", "S": "S\u00fcd", "SW": "S\u00fcdwest", "W": "West", "NW": "Nordwest" }
|
||||
},
|
||||
"shadingType": {
|
||||
"label": "Verschattung",
|
||||
"tooltip": "Art der Au\u00dfenverschattung (keine, Jalousien, Rolll\u00e4den, Markise).",
|
||||
"options": { "none": "Keine", "blinds": "Jalousien", "shutters": "Rolll\u00e4den", "awning": "Markise" }
|
||||
},
|
||||
"shadingFactor": { "label": "Verschattungsfaktor", "tooltip": "0 = vollst\u00e4ndig verschattet, 1 = keine Verschattung. Rolll\u00e4den \u2248 0,2, Innenjalousien \u2248 0,6." },
|
||||
"ventilation": { "label": "L\u00fcftung", "tooltip": "L\u00fcftungsart (nat\u00fcrlich, mechanisch, dicht)." },
|
||||
"ventilationAch": { "label": "Luftwechselrate", "tooltip": "Luftwechsel pro Stunde. Dicht \u2248 0,3, nat\u00fcrlich \u2248 1,5, offene Fenster \u2248 5,0." },
|
||||
"windowFraction": { "label": "Fensteranteil", "tooltip": "Anteil der Wandfl\u00e4che, die Fenster ist (0\u20131). Typisch: 0,15." },
|
||||
"shgc": { "label": "SHGC", "tooltip": "Gesamtenergiedurchlassgrad (0\u20131). W\u00e4rmeschutzglas \u2248 0,3, Klarglas \u2248 0,8." },
|
||||
"insulation": {
|
||||
"label": "D\u00e4mmung",
|
||||
"tooltip": "Wandd\u00e4mmqualit\u00e4t (schlecht, mittel, gut, ausgezeichnet).",
|
||||
"options": { "poor": "Schlecht", "average": "Mittel", "good": "Gut", "excellent": "Ausgezeichnet" }
|
||||
},
|
||||
"indoorTemp": {
|
||||
"label": "Raumtemperatur (\u00b0C)",
|
||||
"tooltip": "Aktuelle oder gew\u00fcnschte Raumtemperatur. Standard 25\u00b0C."
|
||||
},
|
||||
"add": "Raum hinzuf\u00fcgen",
|
||||
"noItems": "Noch keine R\u00e4ume. F\u00fcgen Sie R\u00e4ume zu Ihrem Profil hinzu."
|
||||
},
|
||||
"devices": {
|
||||
"title": "Ger\u00e4te",
|
||||
"help": "W\u00e4rmeproduzierende Ger\u00e4te in jedem Raum.",
|
||||
"name": { "label": "Name", "tooltip": "Ger\u00e4tename (z.B. Desktop-PC, TV)" },
|
||||
"room": { "label": "Raum", "tooltip": "In welchem Raum sich das Ger\u00e4t befindet" },
|
||||
"type": { "label": "Typ", "tooltip": "Ger\u00e4tekategorie" },
|
||||
"wattsIdle": { "label": "Watt (Leerlauf)", "tooltip": "Leistungsaufnahme im Leerlauf/Standby" },
|
||||
"wattsTypical": { "label": "Watt (Typisch)", "tooltip": "Leistungsaufnahme bei normaler Nutzung" },
|
||||
"wattsPeak": { "label": "Watt (Spitze)", "tooltip": "Leistungsaufnahme bei Maximallast (z.B. Gaming)" },
|
||||
"dutyCycle": { "label": "Einschaltdauer", "tooltip": "Anteil der aktiven Zeit (0\u20131). K\u00fchlschrank \u2248 0,3, PC \u2248 1,0." },
|
||||
"add": "Ger\u00e4t hinzuf\u00fcgen",
|
||||
"noItems": "Noch keine Ger\u00e4te."
|
||||
},
|
||||
"occupants": {
|
||||
"title": "Bewohner",
|
||||
"help": "Personen in jedem Raum. K\u00f6rperw\u00e4rme tr\u00e4gt zur Raumtemperatur bei.",
|
||||
"room": { "label": "Raum", "tooltip": "Welcher Raum" },
|
||||
"count": { "label": "Anzahl", "tooltip": "Anzahl der Personen" },
|
||||
"activity": {
|
||||
"label": "Aktivit\u00e4tsniveau",
|
||||
"tooltip": "Schlafend \u2248 70W, Sitzend \u2248 100W, Leicht \u2248 130W, Mittel \u2248 200W, Schwer \u2248 300W pro Person.",
|
||||
"options": { "sleeping": "Schlafend", "sedentary": "Sitzend", "light": "Leicht", "moderate": "Mittel", "heavy": "Schwer" }
|
||||
},
|
||||
"vulnerable": { "label": "Schutzbed\u00fcrftig", "tooltip": "Ankreuzen bei \u00e4lteren Menschen, Kleinkindern oder gesundheitlich eingeschr\u00e4nkten Personen. F\u00fcgt Pflegeerinnerungen hinzu." },
|
||||
"add": "Bewohner hinzuf\u00fcgen",
|
||||
"noItems": "Noch keine Bewohner."
|
||||
},
|
||||
"ac": {
|
||||
"title": "Klimaanlagen",
|
||||
"help": "Klimager\u00e4te und deren Raumzuordnungen.",
|
||||
"name": { "label": "Name", "tooltip": "Name des Klimager\u00e4ts" },
|
||||
"type": {
|
||||
"label": "Typ",
|
||||
"tooltip": "Mobil, Fenster, Split, Zentral",
|
||||
"options": { "portable": "Mobil", "window": "Fenster", "split": "Split", "central": "Zentral" }
|
||||
},
|
||||
"capacity": { "label": "Leistung (BTU)", "tooltip": "K\u00fchlleistung in BTU/h. Typisch mobil: 8.000\u201314.000 BTU." },
|
||||
"eer": { "label": "EER", "tooltip": "Energieeffizienzwert. H\u00f6her = effizienter. Typisch: 8\u201312." },
|
||||
"dehumidify": { "label": "Entfeuchtung", "tooltip": "Ob das Ger\u00e4t einen Entfeuchtungsmodus hat" },
|
||||
"rooms": { "label": "Zugewiesene R\u00e4ume", "tooltip": "Welche R\u00e4ume dieses Klimager\u00e4t versorgt" },
|
||||
"add": "Klimager\u00e4t hinzuf\u00fcgen",
|
||||
"noItems": "Noch keine Klimager\u00e4te."
|
||||
},
|
||||
"toggles": {
|
||||
"title": "Schalter",
|
||||
"help": "Aktivit\u00e4tsschalter beeinflussen die W\u00e4rmeberechnung f\u00fcr den aktuellen Tag.",
|
||||
"gaming": "Gaming (Ger\u00e4te auf Spitzenlast)",
|
||||
"cooking": "Kochen (zus\u00e4tzliche K\u00fcchenw\u00e4rme)",
|
||||
"laundry": "W\u00e4sche / Trockner l\u00e4uft",
|
||||
"guests": "Zus\u00e4tzliche G\u00e4ste anwesend"
|
||||
},
|
||||
"forecast": {
|
||||
"title": "Wettervorhersage",
|
||||
"help": "Wetterdaten f\u00fcr Ihren Profilstandort abrufen.",
|
||||
"fetch": "Vorhersage abrufen",
|
||||
"lastFetched": "Zuletzt abgerufen",
|
||||
"never": "Nie",
|
||||
"fetching": "Vorhersage wird abgerufen\u2026"
|
||||
},
|
||||
"llm": {
|
||||
"title": "KI-Zusammenfassung",
|
||||
"help": "Konfigurieren Sie einen KI-Anbieter f\u00fcr personalisierte Hitzezusammenfassungen.",
|
||||
"provider": "Anbieter",
|
||||
"model": "Modell",
|
||||
"apiKey": "API-Schl\u00fcssel",
|
||||
"apiKeyPlaceholder": "API-Schl\u00fcssel eingeben",
|
||||
"modelPlaceholder": "Modellname (leer lassen f\u00fcr Standard)",
|
||||
"save": "Einstellungen speichern",
|
||||
"serverProvider": "Server-Anbieter",
|
||||
"configured": "Auf Server konfiguriert",
|
||||
"notConfigured": "Kein KI-Anbieter auf dem Server konfiguriert.",
|
||||
"providerOptions": { "anthropic": "Anthropic", "openai": "OpenAI", "gemini": "Google Gemini" }
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Hitzebericht",
|
||||
"riskLevel": "Risikostufe",
|
||||
"peakTemp": "H\u00f6chsttemperatur",
|
||||
"minNightTemp": "Min. Nachttemperatur",
|
||||
"poorNightCool": "Schlechte Nachtk\u00fchlung",
|
||||
"timeline": "24h-Zeitverlauf",
|
||||
"roomBudgets": "Raum-W\u00e4rmebilanzen",
|
||||
"actions": "Ma\u00dfnahmen",
|
||||
"careChecklist": "Pflege-Checkliste",
|
||||
"warnings": "Wetterwarnungen",
|
||||
"riskWindows": "Risikozeitr\u00e4ume",
|
||||
"llmSummary": "KI-Zusammenfassung",
|
||||
"noData": "Noch keine Daten. Richten Sie Ihr Profil ein und rufen Sie eine Vorhersage ab.",
|
||||
"getStarted": "Loslegen",
|
||||
"goToSetup": "Zur Einrichtung",
|
||||
"goToGuide": "Anleitung lesen",
|
||||
"loading": "Laden\u2026",
|
||||
"computing": "Hitzeanalyse wird berechnet\u2026",
|
||||
"error": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
|
||||
"internalGains": "Interne Gewinne",
|
||||
"solarGain": "Solargewinn",
|
||||
"ventGain": "L\u00fcftungsgewinn",
|
||||
"totalGain": "Gesamtgewinn",
|
||||
"acCapacity": "Klimaleistung",
|
||||
"headroom": "Reserve",
|
||||
"comfortable": "Komfortabel",
|
||||
"marginal": "Grenzwertig",
|
||||
"overloaded": "\u00dcberlastet",
|
||||
"fetchForecastFirst": "Keine Vorhersagedaten. Rufen Sie zuerst eine Vorhersage in der Einrichtung ab.",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"riskLow": "Niedrig",
|
||||
"riskModerate": "Mittel",
|
||||
"riskHigh": "Hoch",
|
||||
"riskExtreme": "Extrem"
|
||||
},
|
||||
"guide": {
|
||||
"title": "Erste Schritte",
|
||||
"intro": "HeatGuard hilft Ihnen, sich auf Hitzeereignisse vorzubereiten, indem es Ihre Wohnr\u00e4ume, Wettervorhersagen analysiert und personalisierte st\u00fcndliche Aktionspl\u00e4ne erstellt.",
|
||||
"step1": {
|
||||
"title": "1. Profil erstellen",
|
||||
"text": "Gehen Sie zur Einrichtung und erstellen Sie ein Profil f\u00fcr Ihren Standort. Geben Sie Ihre Koordinaten ein (nutzen Sie 'Meinen Standort verwenden' f\u00fcr automatische Erkennung) und die Zeitzone."
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. R\u00e4ume hinzuf\u00fcgen",
|
||||
"text": "F\u00fcgen Sie die R\u00e4ume hinzu, die Sie \u00fcberwachen m\u00f6chten. Stellen Sie Fl\u00e4che, Ausrichtung (Fensterrichtung), Deckenh\u00f6he und Fenstereigenschaften ein. Diese beeinflussen die Solar- und L\u00fcftungsberechnungen."
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Ger\u00e4te & Bewohner hinzuf\u00fcgen",
|
||||
"text": "F\u00fcgen Sie w\u00e4rmeproduzierende Ger\u00e4te (Computer, Fernseher, Haushaltsger\u00e4te) und Bewohner zu jedem Raum hinzu. Jede Person und jedes Ger\u00e4t tr\u00e4gt zur internen W\u00e4rmelast bei."
|
||||
},
|
||||
"step4": {
|
||||
"title": "4. Klimaanlagen konfigurieren",
|
||||
"text": "Wenn Sie eine Klimaanlage haben, f\u00fcgen Sie Ihre Ger\u00e4te hinzu und weisen Sie sie R\u00e4umen zu. So kann HeatGuard berechnen, ob Ihre K\u00fchlleistung ausreicht."
|
||||
},
|
||||
"step5": {
|
||||
"title": "5. Vorhersage abrufen",
|
||||
"text": "Klicken Sie auf der Einrichtungsseite auf 'Vorhersage abrufen', um aktuelle Wetterdaten f\u00fcr Ihren Standort zu erhalten. Vorhersagen decken die n\u00e4chsten 3 Tage ab."
|
||||
},
|
||||
"step6": {
|
||||
"title": "6. Dashboard ansehen",
|
||||
"text": "Kehren Sie zum Dashboard zur\u00fcck, um Ihre personalisierte Hitzeanalyse zu sehen: Risikostufe, Temperaturverlauf, Raum-W\u00e4rmebilanzen und empfohlene Ma\u00dfnahmen."
|
||||
},
|
||||
"params": {
|
||||
"title": "Raumparameter verstehen",
|
||||
"shgc": "SHGC (Gesamtenergiedurchlassgrad): Wie viel Solarenergie durch Fenster gelangt. W\u00e4rmeschutzglas \u2248 0,3, einfaches Klarglas \u2248 0,8.",
|
||||
"ventilation": "Luftwechselrate (ACH): Wie oft das Luftvolumen des Raums pro Stunde ausgetauscht wird. Dicht \u2248 0,3, offene Fenster \u2248 5,0.",
|
||||
"shading": "Verschattungsfaktor: 0 = vollst\u00e4ndig verschattet (keine Sonne), 1 = keine Verschattung. Rolll\u00e4den \u2248 0,2, Innenjalousien \u2248 0,6.",
|
||||
"orientation": "Ausrichtung: S\u00fcdr\u00e4ume bekommen die meiste Mittagssonne. Osten = Morgensonne, Westen = Nachmittagssonne."
|
||||
},
|
||||
"risk": {
|
||||
"title": "Risikostufen verstehen",
|
||||
"low": "Niedrig: Temperaturen unter 30\u00b0C. Normale Bedingungen.",
|
||||
"moderate": "Mittel: Temperaturen 30\u201335\u00b0C. Grundlegende Vorsichtsma\u00dfnahmen treffen.",
|
||||
"high": "Hoch: Temperaturen 35\u201340\u00b0C. Erhebliches Hitzestressrisiko.",
|
||||
"extreme": "Extrem: Temperaturen \u00fcber 40\u00b0C. Gef\u00e4hrliche Bedingungen."
|
||||
},
|
||||
"budget": {
|
||||
"title": "W\u00e4rmebilanzen verstehen",
|
||||
"text": "Die W\u00e4rmebilanz jedes Raums vergleicht die gesamten W\u00e4rmegewinne (Ger\u00e4te + Personen + Solar + L\u00fcftung) mit der Klimak\u00fchlleistung. Status: Komfortabel (>20% Reserve), Grenzwertig (0\u201320% Reserve), \u00dcberlastet (Gewinne \u00fcbersteigen Kapazit\u00e4t)."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Tipps f\u00fcr Genauigkeit",
|
||||
"tip1": "Messen Sie Ihre Raumfl\u00e4che und Fenstergr\u00f6\u00dfen f\u00fcr bessere Solargewinnsch\u00e4tzungen.",
|
||||
"tip2": "Pr\u00fcfen Sie Ger\u00e4teleistungen auf Typenschildern oder Herstellerangaben.",
|
||||
"tip3": "Schlie\u00dfen Sie Rolll\u00e4den/Jalousien w\u00e4hrend der Spitzensonnenstunden f\u00fcr maximale Wirkung.",
|
||||
"tip4": "Nutzen Sie den Gaming-Schalter an Tagen, an denen Sie Ger\u00e4te unter Volllast betreiben."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"source": "Quellcode",
|
||||
"license": "GPL-3.0-Lizenz"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "L\u00f6schen",
|
||||
"edit": "Bearbeiten",
|
||||
"saving": "Speichern\u2026",
|
||||
"saved": "Gespeichert",
|
||||
"error": "Etwas ist schiefgelaufen.",
|
||||
"confirm": "Sind Sie sicher?",
|
||||
"loading": "Laden\u2026",
|
||||
"noProfile": "Kein Profil ausgew\u00e4hlt.",
|
||||
"watts": "W",
|
||||
"btuh": "BTU/h"
|
||||
}
|
||||
}
|
||||
249
web/i18n/en.json
Normal file
249
web/i18n/en.json
Normal file
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "HeatGuard",
|
||||
"tagline": "Personalized heat preparedness"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"setup": "Setup",
|
||||
"guide": "Guide",
|
||||
"language": "Language"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Setup",
|
||||
"profiles": {
|
||||
"title": "Profiles",
|
||||
"help": "A profile represents a location you want to monitor.",
|
||||
"name": { "label": "Name", "tooltip": "A descriptive name for this location" },
|
||||
"latitude": { "label": "Latitude", "tooltip": "Decimal degrees (e.g. 52.52)" },
|
||||
"longitude": { "label": "Longitude", "tooltip": "Decimal degrees (e.g. 13.405)" },
|
||||
"timezone": { "label": "Timezone", "tooltip": "IANA timezone (e.g. Europe/Berlin)" },
|
||||
"geolocate": {
|
||||
"button": "Use my location",
|
||||
"loading": "Detecting location\u2026",
|
||||
"denied": "Location permission denied.",
|
||||
"unavailable": "Location unavailable.",
|
||||
"timeout": "Location request timed out."
|
||||
},
|
||||
"add": "Add Profile",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"noItems": "No profiles yet. Create one to get started."
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Rooms",
|
||||
"help": "Add rooms to monitor. Properties affect heat calculations.",
|
||||
"name": { "label": "Name", "tooltip": "Room name (e.g. Office, Bedroom)" },
|
||||
"area": { "label": "Area (m\u00b2)", "tooltip": "Floor area. Affects solar gain through windows." },
|
||||
"ceilingHeight": { "label": "Ceiling Height (m)", "tooltip": "Room height. Affects air volume for ventilation calculations." },
|
||||
"floor": { "label": "Floor", "tooltip": "Floor number (0 = ground). Higher floors tend to be warmer." },
|
||||
"orientation": {
|
||||
"label": "Orientation",
|
||||
"tooltip": "Primary window direction. Affects solar gain timing.",
|
||||
"options": { "N": "North", "NE": "Northeast", "E": "East", "SE": "Southeast", "S": "South", "SW": "Southwest", "W": "West", "NW": "Northwest" }
|
||||
},
|
||||
"shadingType": {
|
||||
"label": "Shading",
|
||||
"tooltip": "Type of exterior shading (none, blinds, shutters, awning).",
|
||||
"options": { "none": "None", "blinds": "Blinds", "shutters": "Shutters", "awning": "Awning" }
|
||||
},
|
||||
"shadingFactor": { "label": "Shading Factor", "tooltip": "0 = fully shaded, 1 = no shading. External shutters \u2248 0.2, internal blinds \u2248 0.6." },
|
||||
"ventilation": { "label": "Ventilation", "tooltip": "Ventilation type (natural, mechanical, sealed)." },
|
||||
"ventilationAch": { "label": "Ventilation ACH", "tooltip": "Air Changes per Hour. Sealed \u2248 0.3, natural \u2248 1.5, open windows \u2248 5.0." },
|
||||
"windowFraction": { "label": "Window Fraction", "tooltip": "Fraction of wall area that is window (0\u20131). Typical: 0.15." },
|
||||
"shgc": { "label": "SHGC", "tooltip": "Solar Heat Gain Coefficient (0\u20131). Low-E glass \u2248 0.3, clear glass \u2248 0.8." },
|
||||
"insulation": {
|
||||
"label": "Insulation",
|
||||
"tooltip": "Wall insulation quality (poor, average, good, excellent).",
|
||||
"options": { "poor": "Poor", "average": "Average", "good": "Good", "excellent": "Excellent" }
|
||||
},
|
||||
"indoorTemp": {
|
||||
"label": "Indoor Temp (\u00b0C)",
|
||||
"tooltip": "Current or target indoor temperature. Default 25\u00b0C."
|
||||
},
|
||||
"add": "Add Room",
|
||||
"noItems": "No rooms yet. Add rooms to your profile."
|
||||
},
|
||||
"devices": {
|
||||
"title": "Devices",
|
||||
"help": "Heat-producing devices in each room.",
|
||||
"name": { "label": "Name", "tooltip": "Device name (e.g. Desktop PC, TV)" },
|
||||
"room": { "label": "Room", "tooltip": "Which room this device is in" },
|
||||
"type": { "label": "Type", "tooltip": "Device category" },
|
||||
"wattsIdle": { "label": "Watts (Idle)", "tooltip": "Power draw when idle/standby" },
|
||||
"wattsTypical": { "label": "Watts (Typical)", "tooltip": "Power draw during normal use" },
|
||||
"wattsPeak": { "label": "Watts (Peak)", "tooltip": "Power draw at maximum load (e.g. gaming)" },
|
||||
"dutyCycle": { "label": "Duty Cycle", "tooltip": "Fraction of time active (0\u20131). Fridge \u2248 0.3, PC \u2248 1.0." },
|
||||
"add": "Add Device",
|
||||
"noItems": "No devices yet."
|
||||
},
|
||||
"occupants": {
|
||||
"title": "Occupants",
|
||||
"help": "People in each room. Metabolic heat contributes to room temperature.",
|
||||
"room": { "label": "Room", "tooltip": "Which room" },
|
||||
"count": { "label": "Count", "tooltip": "Number of people" },
|
||||
"activity": {
|
||||
"label": "Activity Level",
|
||||
"tooltip": "Sleeping \u2248 70W, Sedentary \u2248 100W, Light \u2248 130W, Moderate \u2248 200W, Heavy \u2248 300W per person.",
|
||||
"options": { "sleeping": "Sleeping", "sedentary": "Sedentary", "light": "Light", "moderate": "Moderate", "heavy": "Heavy" }
|
||||
},
|
||||
"vulnerable": { "label": "Vulnerable", "tooltip": "Check if elderly, young children, or health-compromised. Adds care reminders." },
|
||||
"add": "Add Occupants",
|
||||
"noItems": "No occupants yet."
|
||||
},
|
||||
"ac": {
|
||||
"title": "AC Units",
|
||||
"help": "Air conditioning units and their room assignments.",
|
||||
"name": { "label": "Name", "tooltip": "AC unit name" },
|
||||
"type": {
|
||||
"label": "Type",
|
||||
"tooltip": "portable, window, split, central",
|
||||
"options": { "portable": "Portable", "window": "Window", "split": "Split", "central": "Central" }
|
||||
},
|
||||
"capacity": { "label": "Capacity (BTU)", "tooltip": "Cooling capacity in BTU/h. Typical portable: 8,000\u201314,000 BTU." },
|
||||
"eer": { "label": "EER", "tooltip": "Energy Efficiency Ratio. Higher = more efficient. Typical: 8\u201312." },
|
||||
"dehumidify": { "label": "Dehumidify", "tooltip": "Whether the unit has a dehumidify mode" },
|
||||
"rooms": { "label": "Assigned Rooms", "tooltip": "Which rooms this AC unit serves" },
|
||||
"add": "Add AC Unit",
|
||||
"noItems": "No AC units yet."
|
||||
},
|
||||
"toggles": {
|
||||
"title": "Toggles",
|
||||
"help": "Activity toggles affect heat calculations for the current day.",
|
||||
"gaming": "Gaming (devices at peak power)",
|
||||
"cooking": "Cooking (additional kitchen heat)",
|
||||
"laundry": "Laundry / Dryer running",
|
||||
"guests": "Extra guests present"
|
||||
},
|
||||
"forecast": {
|
||||
"title": "Forecast",
|
||||
"help": "Fetch weather forecast data for your profile location.",
|
||||
"fetch": "Fetch Forecast",
|
||||
"lastFetched": "Last fetched",
|
||||
"never": "Never",
|
||||
"fetching": "Fetching forecast\u2026"
|
||||
},
|
||||
"llm": {
|
||||
"title": "AI Summary",
|
||||
"help": "Configure an AI provider for personalized heat summaries.",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "Enter API key",
|
||||
"modelPlaceholder": "Model name (leave blank for default)",
|
||||
"save": "Save Settings",
|
||||
"serverProvider": "Server provider",
|
||||
"configured": "Configured on server",
|
||||
"notConfigured": "No AI provider configured on the server.",
|
||||
"providerOptions": { "anthropic": "Anthropic", "openai": "OpenAI", "gemini": "Google Gemini" }
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Heat Report",
|
||||
"riskLevel": "Risk Level",
|
||||
"peakTemp": "Peak Temperature",
|
||||
"minNightTemp": "Min Night Temp",
|
||||
"poorNightCool": "Poor Night Cooling",
|
||||
"timeline": "24h Timeline",
|
||||
"roomBudgets": "Room Heat Budgets",
|
||||
"actions": "Actions",
|
||||
"careChecklist": "Care Checklist",
|
||||
"warnings": "Weather Warnings",
|
||||
"riskWindows": "Risk Windows",
|
||||
"llmSummary": "AI Summary",
|
||||
"noData": "No data yet. Set up your profile and fetch a forecast.",
|
||||
"getStarted": "Get Started",
|
||||
"goToSetup": "Go to Setup",
|
||||
"goToGuide": "Read the Guide",
|
||||
"loading": "Loading\u2026",
|
||||
"computing": "Computing heat analysis\u2026",
|
||||
"error": "Failed to load data. Please try again.",
|
||||
"internalGains": "Internal Gains",
|
||||
"solarGain": "Solar Gain",
|
||||
"ventGain": "Ventilation Gain",
|
||||
"totalGain": "Total Gain",
|
||||
"acCapacity": "AC Capacity",
|
||||
"headroom": "Headroom",
|
||||
"comfortable": "Comfortable",
|
||||
"marginal": "Marginal",
|
||||
"overloaded": "Overloaded",
|
||||
"fetchForecastFirst": "No forecast data. Fetch a forecast in Setup first.",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"riskLow": "Low",
|
||||
"riskModerate": "Moderate",
|
||||
"riskHigh": "High",
|
||||
"riskExtreme": "Extreme"
|
||||
},
|
||||
"guide": {
|
||||
"title": "Getting Started",
|
||||
"intro": "HeatGuard helps you prepare for heat events by analyzing your living space, weather forecasts, and providing personalized hour-by-hour action plans.",
|
||||
"step1": {
|
||||
"title": "1. Create a Profile",
|
||||
"text": "Go to Setup and create a profile for your location. Enter your coordinates (use the 'Use my location' button for auto-detection) and timezone."
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. Add Rooms",
|
||||
"text": "Add the rooms you want to monitor. Set the area, orientation (which direction windows face), ceiling height, and window properties. These affect solar gain and ventilation calculations."
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Add Devices & Occupants",
|
||||
"text": "Add heat-producing devices (computers, TVs, appliances) and occupants to each room. Each person and device contributes to the internal heat load."
|
||||
},
|
||||
"step4": {
|
||||
"title": "4. Configure AC Units",
|
||||
"text": "If you have air conditioning, add your units and assign them to rooms. This allows HeatGuard to calculate whether your cooling capacity is sufficient."
|
||||
},
|
||||
"step5": {
|
||||
"title": "5. Fetch a Forecast",
|
||||
"text": "On the Setup page, click 'Fetch Forecast' to get the latest weather data for your location. Forecasts cover the next 3 days."
|
||||
},
|
||||
"step6": {
|
||||
"title": "6. View the Dashboard",
|
||||
"text": "Return to the Dashboard to see your personalized heat analysis: risk level, temperature timeline, room heat budgets, and recommended actions."
|
||||
},
|
||||
"params": {
|
||||
"title": "Understanding Room Parameters",
|
||||
"shgc": "SHGC (Solar Heat Gain Coefficient): How much solar energy passes through windows. Low-E glass \u2248 0.3, clear single pane \u2248 0.8.",
|
||||
"ventilation": "Ventilation ACH (Air Changes per Hour): How often the room's air volume is replaced. Sealed room \u2248 0.3, open windows \u2248 5.0.",
|
||||
"shading": "Shading Factor: 0 = fully shaded (no sun enters), 1 = no shading. External shutters \u2248 0.2, internal blinds \u2248 0.6.",
|
||||
"orientation": "Orientation: South-facing rooms get the most midday sun. East faces morning sun, West faces afternoon sun."
|
||||
},
|
||||
"risk": {
|
||||
"title": "Understanding Risk Levels",
|
||||
"low": "Low: Temperatures below 30\u00b0C. Normal conditions.",
|
||||
"moderate": "Moderate: Temperatures 30\u201335\u00b0C. Take basic precautions.",
|
||||
"high": "High: Temperatures 35\u201340\u00b0C. Significant heat stress risk.",
|
||||
"extreme": "Extreme: Temperatures above 40\u00b0C. Dangerous conditions."
|
||||
},
|
||||
"budget": {
|
||||
"title": "Understanding Heat Budgets",
|
||||
"text": "Each room's heat budget compares total heat gains (devices + people + solar + ventilation) against AC cooling capacity. Status: Comfortable (>20% headroom), Marginal (0\u201320% headroom), Overloaded (gains exceed capacity)."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Tips for Accuracy",
|
||||
"tip1": "Measure your room area and window sizes for better solar gain estimates.",
|
||||
"tip2": "Check device power ratings on labels or manufacturer specs.",
|
||||
"tip3": "Close external shutters/blinds during peak sun hours for maximum effect.",
|
||||
"tip4": "Use the gaming toggle on days when you'll be running devices at full load."
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"source": "Source Code",
|
||||
"license": "GPL-3.0 License"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"saving": "Saving\u2026",
|
||||
"saved": "Saved",
|
||||
"error": "Something went wrong.",
|
||||
"confirm": "Are you sure?",
|
||||
"loading": "Loading\u2026",
|
||||
"noProfile": "No profile selected.",
|
||||
"watts": "W",
|
||||
"btuh": "BTU/h"
|
||||
}
|
||||
}
|
||||
354
web/js/dashboard.js
Normal file
354
web/js/dashboard.js
Normal file
@@ -0,0 +1,354 @@
|
||||
// Dashboard page logic
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function show(id) { $(id).classList.remove("hidden"); }
|
||||
function hide(id) { $(id).classList.add("hidden"); }
|
||||
|
||||
const riskColors = {
|
||||
low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" },
|
||||
moderate: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300", border: "border-yellow-500" },
|
||||
high: { bg: "bg-orange-100 dark:bg-orange-900", text: "text-orange-700 dark:text-orange-300", border: "border-orange-500" },
|
||||
extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" },
|
||||
};
|
||||
|
||||
const budgetColors = {
|
||||
comfortable: "bg-green-500",
|
||||
marginal: "bg-yellow-500",
|
||||
overloaded: "bg-red-500",
|
||||
};
|
||||
|
||||
const budgetHexColors = {
|
||||
comfortable: "#22c55e",
|
||||
marginal: "#eab308",
|
||||
overloaded: "#ef4444",
|
||||
};
|
||||
|
||||
function tempColorHex(temp) {
|
||||
if (temp >= 40) return "#dc2626";
|
||||
if (temp >= 35) return "#f97316";
|
||||
if (temp >= 30) return "#facc15";
|
||||
if (temp >= 25) return "#fde68a";
|
||||
if (temp >= 20) return "#bbf7d0";
|
||||
return "#bfdbfe";
|
||||
}
|
||||
|
||||
window.loadDashboard = async function() {
|
||||
hide("no-data");
|
||||
hide("no-forecast");
|
||||
hide("error-state");
|
||||
hide("data-display");
|
||||
show("loading");
|
||||
|
||||
try {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) {
|
||||
hide("loading");
|
||||
show("no-data");
|
||||
return;
|
||||
}
|
||||
|
||||
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
||||
if (forecasts.length === 0) {
|
||||
hide("loading");
|
||||
show("no-forecast");
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const payload = await getComputePayload(profileId, today);
|
||||
if (!payload) {
|
||||
hide("loading");
|
||||
show("no-data");
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch("/api/compute/dashboard", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.error || "Compute failed");
|
||||
}
|
||||
const data = await resp.json();
|
||||
|
||||
hide("loading");
|
||||
show("data-display");
|
||||
renderDashboard(data);
|
||||
|
||||
// LLM summary (async)
|
||||
try {
|
||||
const llmBody = {
|
||||
date: data.date,
|
||||
peakTempC: data.peakTempC,
|
||||
minNightTempC: data.minNightTempC,
|
||||
riskLevel: data.riskLevel,
|
||||
acHeadroomBTUH: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].headroomBtuh : 0,
|
||||
budgetStatus: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].status : "comfortable",
|
||||
language: window.HG && window.HG.lang === "de" ? "German" : "English",
|
||||
};
|
||||
|
||||
// Include client-side LLM settings if available
|
||||
const llmProvider = await getSetting("llmProvider");
|
||||
const llmApiKey = await getSetting("llmApiKey");
|
||||
const llmModel = await getSetting("llmModel");
|
||||
if (llmProvider && llmApiKey) {
|
||||
llmBody.provider = llmProvider;
|
||||
llmBody.apiKey = llmApiKey;
|
||||
if (llmModel) llmBody.model = llmModel;
|
||||
}
|
||||
|
||||
const llmResp = await fetch("/api/llm/summarize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(llmBody),
|
||||
});
|
||||
if (llmResp.ok) {
|
||||
const llmData = await llmResp.json();
|
||||
if (llmData.summary) {
|
||||
$("llm-summary").innerHTML = `<p class="text-sm whitespace-pre-line">${esc(llmData.summary)}</p>
|
||||
<p class="text-xs text-gray-400 mt-2">AI-generated summary. Not a substitute for professional advice.</p>`;
|
||||
} else {
|
||||
$("llm-section").classList.add("hidden");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
$("llm-section").classList.add("hidden");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
hide("loading");
|
||||
show("error-state");
|
||||
console.error("Dashboard error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
function renderDashboard(data) {
|
||||
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
|
||||
|
||||
// Risk card
|
||||
const rc = riskColors[data.riskLevel] || riskColors.low;
|
||||
$("risk-card").className = `rounded-xl p-4 text-center shadow-sm ${rc.bg}`;
|
||||
$("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`;
|
||||
$("risk-level").textContent = data.riskLevel;
|
||||
|
||||
// Peak temp
|
||||
$("peak-temp").textContent = data.peakTempC.toFixed(1) + "\u00b0C";
|
||||
|
||||
// Min night temp
|
||||
$("min-night-temp").textContent = data.minNightTempC.toFixed(1) + "\u00b0C";
|
||||
if (data.poorNightCool) {
|
||||
$("poor-night-cool").classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
show("warnings-section");
|
||||
$("warnings-section").innerHTML = '<h2 class="text-lg font-semibold mb-2">Warnings</h2>' +
|
||||
data.warnings.map(w => `
|
||||
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<div class="font-medium text-red-700 dark:text-red-300">${esc(w.headline)}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
|
||||
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
|
||||
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// Risk windows
|
||||
if (data.riskWindows && data.riskWindows.length > 0) {
|
||||
show("risk-windows-section");
|
||||
$("risk-windows").innerHTML = data.riskWindows.map(w => {
|
||||
const wc = riskColors[w.level] || riskColors.low;
|
||||
return `
|
||||
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2">
|
||||
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
|
||||
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Timeline SVG chart + budget strip
|
||||
if (data.timeline && data.timeline.length > 0) {
|
||||
renderTimelineChart(data.timeline);
|
||||
renderBudgetStrip(data.timeline);
|
||||
}
|
||||
|
||||
// Room budgets
|
||||
if (data.roomBudgets && data.roomBudgets.length > 0) {
|
||||
show("budgets-section");
|
||||
$("room-budgets").innerHTML = data.roomBudgets.map(rb => {
|
||||
const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1);
|
||||
const gainPct = Math.min((rb.totalGainBtuh / maxVal) * 100, 100);
|
||||
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
|
||||
return `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">${esc(rb.roomName)}</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between"><span>Internal</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>Solar</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>Ventilation</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between font-medium"><span>Total</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between"><span>AC Capacity</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between font-medium"><span>Headroom</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-red-400" style="width: ${gainPct}%"></div>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct}%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-400">
|
||||
<span>Gain</span><span>AC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Care checklist
|
||||
if (data.careChecklist && data.careChecklist.length > 0) {
|
||||
show("care-section");
|
||||
$("care-checklist").innerHTML = data.careChecklist.map(item => `
|
||||
<li class="flex items-start gap-2">
|
||||
<input type="checkbox" class="mt-1 rounded">
|
||||
<span class="text-sm">${esc(item)}</span>
|
||||
</li>
|
||||
`).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SVG Timeline Chart ==========
|
||||
function renderTimelineChart(timeline) {
|
||||
const container = $("timeline-chart");
|
||||
const width = 720;
|
||||
const height = 200;
|
||||
const padLeft = 40;
|
||||
const padRight = 10;
|
||||
const padTop = 15;
|
||||
const padBottom = 25;
|
||||
const chartW = width - padLeft - padRight;
|
||||
const chartH = height - padTop - padBottom;
|
||||
|
||||
const temps = timeline.map(s => s.tempC);
|
||||
const minTemp = Math.floor(Math.min(...temps) / 5) * 5;
|
||||
const maxTemp = Math.ceil(Math.max(...temps) / 5) * 5;
|
||||
const tempRange = maxTemp - minTemp || 10;
|
||||
|
||||
function x(i) { return padLeft + (i / (timeline.length - 1)) * chartW; }
|
||||
function y(t) { return padTop + (1 - (t - minTemp) / tempRange) * chartH; }
|
||||
|
||||
// Build area path (temperature curve filled to bottom)
|
||||
const points = timeline.map((s, i) => `${x(i).toFixed(1)},${y(s.tempC).toFixed(1)}`);
|
||||
const linePath = `M${points.join(" L")}`;
|
||||
const areaPath = `${linePath} L${x(timeline.length - 1).toFixed(1)},${(padTop + chartH).toFixed(1)} L${padLeft},${(padTop + chartH).toFixed(1)} Z`;
|
||||
|
||||
// Build gradient stops based on temperature
|
||||
const gradientStops = timeline.map((s, i) => {
|
||||
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
|
||||
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}" stop-opacity="0.4"/>`;
|
||||
}).join("");
|
||||
|
||||
const gradientStopsLine = timeline.map((s, i) => {
|
||||
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
|
||||
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}"/>`;
|
||||
}).join("");
|
||||
|
||||
// Threshold lines
|
||||
const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp);
|
||||
const thresholdLines = thresholds.map(t =>
|
||||
`<line x1="${padLeft}" y1="${y(t).toFixed(1)}" x2="${padLeft + chartW}" y2="${y(t).toFixed(1)}" stroke="#9ca3af" stroke-width="0.5" stroke-dasharray="4,3"/>
|
||||
<text x="${padLeft - 3}" y="${(y(t) + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${t}\u00b0</text>`
|
||||
).join("");
|
||||
|
||||
// Y-axis labels (min, max)
|
||||
const yLabels = `
|
||||
<text x="${padLeft - 3}" y="${(padTop + 4).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${maxTemp}\u00b0</text>
|
||||
<text x="${padLeft - 3}" y="${(padTop + chartH + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${minTemp}\u00b0</text>
|
||||
`;
|
||||
|
||||
// X-axis labels (every 3 hours)
|
||||
const xLabels = timeline
|
||||
.filter((s, i) => s.hour % 3 === 0)
|
||||
.map((s, i) => {
|
||||
const idx = timeline.findIndex(t => t.hour === s.hour);
|
||||
return `<text x="${x(idx).toFixed(1)}" y="${(height - 3).toFixed(1)}" text-anchor="middle" fill="#9ca3af" font-size="9">${String(s.hour).padStart(2, '0')}</text>`;
|
||||
}).join("");
|
||||
|
||||
// Data points (circles)
|
||||
const circles = timeline.map((s, i) =>
|
||||
`<circle cx="${x(i).toFixed(1)}" cy="${y(s.tempC).toFixed(1)}" r="4" fill="${tempColorHex(s.tempC)}" stroke="white" stroke-width="1.5" class="timeline-dot" data-idx="${i}" style="cursor:pointer"/>`
|
||||
).join("");
|
||||
|
||||
const svg = `<svg viewBox="0 0 ${width} ${height}" class="w-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="1" y2="0">${gradientStops}</linearGradient>
|
||||
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">${gradientStopsLine}</linearGradient>
|
||||
</defs>
|
||||
<path d="${areaPath}" fill="url(#areaGrad)"/>
|
||||
<path d="${linePath}" fill="none" stroke="url(#lineGrad)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
${thresholdLines}
|
||||
${yLabels}
|
||||
${xLabels}
|
||||
${circles}
|
||||
</svg>`;
|
||||
|
||||
container.innerHTML = svg;
|
||||
|
||||
// Tooltip on hover/click
|
||||
const tooltip = $("timeline-tooltip");
|
||||
container.querySelectorAll(".timeline-dot").forEach(dot => {
|
||||
const handler = (e) => {
|
||||
const idx = parseInt(dot.dataset.idx);
|
||||
const slot = timeline[idx];
|
||||
const actions = slot.actions && slot.actions.length > 0
|
||||
? slot.actions.map(a => `\u2022 ${esc(a.name)}`).join("<br>")
|
||||
: "No actions";
|
||||
tooltip.innerHTML = `
|
||||
<div class="font-medium mb-1">${slot.hourStr}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
|
||||
<div class="capitalize">${slot.budgetStatus}</div>
|
||||
<div class="mt-1 text-gray-300">${actions}</div>
|
||||
`;
|
||||
const rect = dot.getBoundingClientRect();
|
||||
tooltip.style.left = (rect.left + window.scrollX - 60) + "px";
|
||||
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 8) + "px";
|
||||
tooltip.classList.remove("hidden");
|
||||
};
|
||||
dot.addEventListener("mouseenter", handler);
|
||||
dot.addEventListener("click", handler);
|
||||
});
|
||||
|
||||
container.addEventListener("mouseleave", () => tooltip.classList.add("hidden"));
|
||||
}
|
||||
|
||||
// ========== Budget Status Strip ==========
|
||||
function renderBudgetStrip(timeline) {
|
||||
const strip = $("timeline-strip");
|
||||
strip.innerHTML = timeline.map(slot => {
|
||||
const color = budgetHexColors[slot.budgetStatus] || "#d1d5db";
|
||||
return `<div class="flex-1 h-3 rounded-sm" style="background:${color}" title="${slot.hourStr}: ${slot.budgetStatus}"></div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Init
|
||||
loadDashboard();
|
||||
})();
|
||||
290
web/js/db.js
Normal file
290
web/js/db.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// IndexedDB wrapper for HeatGuard
|
||||
const DB_NAME = "heatguard";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
const STORES = {
|
||||
profiles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "name", keyPath: "name", unique: true }] },
|
||||
rooms: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
||||
devices: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
|
||||
occupants: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
|
||||
ac_units: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
||||
ac_assignments: { keyPath: ["acId", "roomId"] },
|
||||
forecasts: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
||||
warnings: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
||||
toggles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId_name", keyPath: ["profileId", "name"] }] },
|
||||
settings: { keyPath: "key" },
|
||||
};
|
||||
|
||||
let dbPromise = null;
|
||||
|
||||
function openDB() {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
for (const [name, cfg] of Object.entries(STORES)) {
|
||||
if (!db.objectStoreNames.contains(name)) {
|
||||
const opts = { keyPath: cfg.keyPath };
|
||||
if (cfg.autoIncrement) opts.autoIncrement = true;
|
||||
const store = db.createObjectStore(name, opts);
|
||||
if (cfg.indexes) {
|
||||
for (const idx of cfg.indexes) {
|
||||
store.createIndex(idx.name, idx.keyPath, { unique: !!idx.unique });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function dbPut(storeName, item) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = store.put(item);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dbAdd(storeName, item) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = store.add(item);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dbGet(storeName, key) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const req = tx.objectStore(storeName).get(key);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dbGetAll(storeName) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const req = tx.objectStore(storeName).getAll();
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dbGetByIndex(storeName, indexName, key) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const store = tx.objectStore(storeName);
|
||||
const index = store.index(indexName);
|
||||
const req = index.getAll(key);
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dbDelete(storeName, key) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const req = tx.objectStore(storeName).delete(key);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dbClear(storeName) {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const req = tx.objectStore(storeName).clear();
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Cascade delete: delete a profile and all related data
|
||||
async function deleteProfile(profileId) {
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
for (const room of rooms) {
|
||||
await deleteRoomData(room.id);
|
||||
}
|
||||
await deleteByIndex("ac_units", "profileId", profileId);
|
||||
await deleteByIndex("forecasts", "profileId", profileId);
|
||||
await deleteByIndex("warnings", "profileId", profileId);
|
||||
|
||||
// Delete toggles for this profile
|
||||
const toggles = await dbGetAll("toggles");
|
||||
for (const t of toggles) {
|
||||
if (t.profileId === profileId) await dbDelete("toggles", t.id);
|
||||
}
|
||||
|
||||
// Delete ac_assignments for ac_units that belonged to this profile
|
||||
const allAssignments = await dbGetAll("ac_assignments");
|
||||
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
|
||||
const acIds = new Set(acUnits.map(u => u.id));
|
||||
for (const a of allAssignments) {
|
||||
if (acIds.has(a.acId)) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
||||
}
|
||||
|
||||
await dbDelete("profiles", profileId);
|
||||
}
|
||||
|
||||
async function deleteRoomData(roomId) {
|
||||
await deleteByIndex("devices", "roomId", roomId);
|
||||
await deleteByIndex("occupants", "roomId", roomId);
|
||||
// Delete ac_assignments for this room
|
||||
const assignments = await dbGetAll("ac_assignments");
|
||||
for (const a of assignments) {
|
||||
if (a.roomId === roomId) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
||||
}
|
||||
await dbDelete("rooms", roomId);
|
||||
}
|
||||
|
||||
async function deleteByIndex(storeName, indexName, key) {
|
||||
const items = await dbGetByIndex(storeName, indexName, key);
|
||||
for (const item of items) {
|
||||
const pk = item.id !== undefined ? item.id : null;
|
||||
if (pk !== null) await dbDelete(storeName, pk);
|
||||
}
|
||||
}
|
||||
|
||||
// Settings helpers
|
||||
async function getSetting(key) {
|
||||
const item = await dbGet("settings", key);
|
||||
return item ? item.value : null;
|
||||
}
|
||||
|
||||
async function setSetting(key, value) {
|
||||
await dbPut("settings", { key, value });
|
||||
}
|
||||
|
||||
async function getActiveProfileId() {
|
||||
return await getSetting("activeProfileId");
|
||||
}
|
||||
|
||||
async function setActiveProfileId(id) {
|
||||
await setSetting("activeProfileId", id);
|
||||
}
|
||||
|
||||
// Build full compute payload from IndexedDB
|
||||
async function getComputePayload(profileId, dateStr) {
|
||||
const profiles = await dbGetAll("profiles");
|
||||
const profile = profiles.find(p => p.id === profileId);
|
||||
if (!profile) return null;
|
||||
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const allDevices = [];
|
||||
const allOccupants = [];
|
||||
for (const room of rooms) {
|
||||
const devices = await dbGetByIndex("devices", "roomId", room.id);
|
||||
allDevices.push(...devices);
|
||||
const occupants = await dbGetByIndex("occupants", "roomId", room.id);
|
||||
allOccupants.push(...occupants);
|
||||
}
|
||||
|
||||
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
|
||||
const allAssignments = await dbGetAll("ac_assignments");
|
||||
const acIds = new Set(acUnits.map(u => u.id));
|
||||
const acAssignments = allAssignments.filter(a => acIds.has(a.acId));
|
||||
|
||||
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
||||
const warnings = await dbGetByIndex("warnings", "profileId", profileId);
|
||||
|
||||
// Toggles
|
||||
const allToggles = await dbGetAll("toggles");
|
||||
const toggles = {};
|
||||
for (const t of allToggles) {
|
||||
if (t.profileId === profileId && t.active) {
|
||||
toggles[t.name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profile: {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
latitude: profile.latitude,
|
||||
longitude: profile.longitude,
|
||||
timezone: profile.timezone || "Europe/Berlin",
|
||||
},
|
||||
rooms: rooms.map(r => ({
|
||||
id: r.id,
|
||||
profileId: r.profileId,
|
||||
name: r.name,
|
||||
areaSqm: r.areaSqm || 0,
|
||||
ceilingHeightM: r.ceilingHeightM || 2.5,
|
||||
floor: r.floor || 0,
|
||||
orientation: r.orientation || "S",
|
||||
shadingType: r.shadingType || "none",
|
||||
shadingFactor: r.shadingFactor ?? 1.0,
|
||||
ventilation: r.ventilation || "natural",
|
||||
ventilationAch: r.ventilationAch || 0.5,
|
||||
windowFraction: r.windowFraction || 0.15,
|
||||
shgc: r.shgc || 0.6,
|
||||
insulation: r.insulation || "average",
|
||||
indoorTempC: r.indoorTempC || 0,
|
||||
})),
|
||||
devices: allDevices.map(d => ({
|
||||
id: d.id,
|
||||
roomId: d.roomId,
|
||||
name: d.name,
|
||||
deviceType: d.deviceType || "electronics",
|
||||
wattsIdle: d.wattsIdle || 0,
|
||||
wattsTypical: d.wattsTypical || 0,
|
||||
wattsPeak: d.wattsPeak || 0,
|
||||
dutyCycle: d.dutyCycle ?? 1.0,
|
||||
})),
|
||||
occupants: allOccupants.map(o => ({
|
||||
id: o.id,
|
||||
roomId: o.roomId,
|
||||
count: o.count || 1,
|
||||
activityLevel: o.activityLevel || "sedentary",
|
||||
vulnerable: !!o.vulnerable,
|
||||
})),
|
||||
acUnits: acUnits.map(a => ({
|
||||
id: a.id,
|
||||
profileId: a.profileId,
|
||||
name: a.name,
|
||||
acType: a.acType || "portable",
|
||||
capacityBtu: a.capacityBtu || 0,
|
||||
hasDehumidify: !!a.hasDehumidify,
|
||||
efficiencyEer: a.efficiencyEer || 10,
|
||||
})),
|
||||
acAssignments: acAssignments.map(a => ({
|
||||
acId: a.acId,
|
||||
roomId: a.roomId,
|
||||
})),
|
||||
toggles,
|
||||
forecasts: forecasts.map(f => ({
|
||||
timestamp: f.timestamp,
|
||||
temperatureC: f.temperatureC ?? null,
|
||||
humidityPct: f.humidityPct ?? null,
|
||||
cloudCoverPct: f.cloudCoverPct ?? null,
|
||||
sunshineMin: f.sunshineMin ?? null,
|
||||
apparentTempC: f.apparentTempC ?? null,
|
||||
})),
|
||||
warnings: warnings.map(w => ({
|
||||
headline: w.headline || "",
|
||||
severity: w.severity || "",
|
||||
description: w.description || "",
|
||||
instruction: w.instruction || "",
|
||||
onset: w.onset || "",
|
||||
expires: w.expires || "",
|
||||
})),
|
||||
date: dateStr,
|
||||
};
|
||||
}
|
||||
685
web/js/setup.js
Normal file
685
web/js/setup.js
Normal file
@@ -0,0 +1,685 @@
|
||||
// Setup page logic
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// Tab switching
|
||||
const tabBtns = document.querySelectorAll(".tab-btn");
|
||||
const tabPanels = document.querySelectorAll(".tab-panel");
|
||||
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab;
|
||||
tabBtns.forEach(b => {
|
||||
b.classList.remove("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400");
|
||||
b.classList.add("border-transparent", "text-gray-500");
|
||||
});
|
||||
btn.classList.add("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400");
|
||||
btn.classList.remove("border-transparent", "text-gray-500");
|
||||
tabPanels.forEach(p => p.classList.add("hidden"));
|
||||
document.getElementById("tab-" + tab).classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
// Hash-based tab navigation
|
||||
if (location.hash) {
|
||||
const tab = location.hash.slice(1);
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
|
||||
// Toast
|
||||
function showToast(msg, isError) {
|
||||
const toast = document.getElementById("toast");
|
||||
toast.textContent = msg;
|
||||
toast.className = "fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity";
|
||||
toast.classList.add(...(isError ? ["bg-red-600", "text-white"] : ["bg-green-600", "text-white"]));
|
||||
toast.classList.remove("hidden");
|
||||
setTimeout(() => toast.classList.add("hidden"), 3000);
|
||||
}
|
||||
|
||||
// Tooltip handling
|
||||
document.addEventListener("click", (e) => {
|
||||
const trigger = e.target.closest(".tooltip-trigger");
|
||||
if (!trigger) {
|
||||
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
|
||||
const tip = document.createElement("div");
|
||||
tip.className = "tooltip-popup absolute z-50 p-2 bg-gray-800 text-white text-xs rounded-lg shadow-lg max-w-xs";
|
||||
tip.textContent = trigger.dataset.tooltip;
|
||||
trigger.parentElement.style.position = "relative";
|
||||
trigger.parentElement.appendChild(tip);
|
||||
setTimeout(() => tip.remove(), 5000);
|
||||
});
|
||||
|
||||
// Form helpers
|
||||
function formData(form) {
|
||||
const data = {};
|
||||
const fd = new FormData(form);
|
||||
for (const [key, val] of fd.entries()) {
|
||||
data[key] = val;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function resetForm(form) {
|
||||
form.reset();
|
||||
const hidden = form.querySelector('input[name="id"]');
|
||||
if (hidden) hidden.value = "";
|
||||
}
|
||||
|
||||
function numOrDefault(val, def) {
|
||||
const n = parseFloat(val);
|
||||
return isNaN(n) ? def : n;
|
||||
}
|
||||
|
||||
// ========== Profiles ==========
|
||||
async function loadProfiles() {
|
||||
const profiles = await dbGetAll("profiles");
|
||||
const list = document.getElementById("profiles-list");
|
||||
if (profiles.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500">No profiles yet.</p>';
|
||||
return;
|
||||
}
|
||||
const activeId = await getActiveProfileId();
|
||||
list.innerHTML = profiles.map(p => {
|
||||
const isActive = activeId === p.id;
|
||||
return `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(p.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="activateProfile(${p.id})" class="text-xs px-2 py-1 rounded ${isActive ? 'bg-orange-600 text-white' : 'bg-gray-100 dark:bg-gray-700'}">
|
||||
${isActive ? '● Active' : 'Set Active'}
|
||||
</button>
|
||||
<button onclick="editProfileUI(${p.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteProfileUI(${p.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
window.activateProfile = async function(id) {
|
||||
await setActiveProfileId(id);
|
||||
await loadProfiles();
|
||||
await refreshRoomSelects();
|
||||
showToast("Profile activated", false);
|
||||
};
|
||||
|
||||
window.editProfileUI = async function(id) {
|
||||
const p = await dbGet("profiles", id);
|
||||
if (!p) return;
|
||||
const form = document.getElementById("profile-form");
|
||||
form.querySelector('input[name="id"]').value = p.id;
|
||||
form.querySelector('input[name="name"]').value = p.name;
|
||||
form.querySelector('input[name="latitude"]').value = p.latitude;
|
||||
form.querySelector('input[name="longitude"]').value = p.longitude;
|
||||
form.querySelector('input[name="timezone"]').value = p.timezone || "Europe/Berlin";
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
|
||||
window.deleteProfileUI = async function(id) {
|
||||
if (!confirm("Delete this profile and all its data?")) return;
|
||||
const activeId = await getActiveProfileId();
|
||||
await deleteProfile(id);
|
||||
if (activeId === id) await setSetting("activeProfileId", null);
|
||||
await loadProfiles();
|
||||
showToast("Profile deleted", false);
|
||||
};
|
||||
|
||||
document.getElementById("profile-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const data = formData(e.target);
|
||||
const profile = {
|
||||
name: data.name,
|
||||
latitude: numOrDefault(data.latitude, 0),
|
||||
longitude: numOrDefault(data.longitude, 0),
|
||||
timezone: data.timezone || "Europe/Berlin",
|
||||
};
|
||||
if (data.id) {
|
||||
profile.id = parseInt(data.id);
|
||||
await dbPut("profiles", profile);
|
||||
} else {
|
||||
const id = await dbAdd("profiles", profile);
|
||||
// Auto-activate if first profile
|
||||
const profiles = await dbGetAll("profiles");
|
||||
if (profiles.length === 1) await setActiveProfileId(id);
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadProfiles();
|
||||
showToast("Profile saved", false);
|
||||
});
|
||||
|
||||
// Geolocation
|
||||
document.getElementById("geolocate-btn").addEventListener("click", () => {
|
||||
const btn = document.getElementById("geolocate-btn");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "⟳ Detecting…";
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
document.querySelector('#profile-form input[name="latitude"]').value = pos.coords.latitude.toFixed(4);
|
||||
document.querySelector('#profile-form input[name="longitude"]').value = pos.coords.longitude.toFixed(4);
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz) document.querySelector('#profile-form input[name="timezone"]').value = tz;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "📍 Use my location";
|
||||
},
|
||||
(err) => {
|
||||
const msgs = { 1: "Permission denied.", 2: "Location unavailable.", 3: "Timed out." };
|
||||
showToast(msgs[err.code] || "Location error.", true);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "📍 Use my location";
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
// ========== Rooms ==========
|
||||
async function loadRooms() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const list = document.getElementById("rooms-list");
|
||||
if (rooms.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No rooms yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rooms.map(r => `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(r.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C</span>
|
||||
</div>
|
||||
<button onclick="editRoomUI(${r.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteRoomUI(${r.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
window.editRoomUI = async function(id) {
|
||||
const r = await dbGet("rooms", id);
|
||||
if (!r) return;
|
||||
const form = document.getElementById("room-form");
|
||||
form.querySelector('input[name="id"]').value = r.id;
|
||||
form.querySelector('input[name="name"]').value = r.name;
|
||||
form.querySelector('input[name="areaSqm"]').value = r.areaSqm || "";
|
||||
form.querySelector('input[name="ceilingHeightM"]').value = r.ceilingHeightM || 2.5;
|
||||
form.querySelector('input[name="floor"]').value = r.floor || 0;
|
||||
form.querySelector('select[name="orientation"]').value = r.orientation || "S";
|
||||
form.querySelector('select[name="shadingType"]').value = r.shadingType || "none";
|
||||
form.querySelector('input[name="shadingFactor"]').value = r.shadingFactor ?? 1.0;
|
||||
form.querySelector('input[name="ventilationAch"]').value = r.ventilationAch || 0.5;
|
||||
form.querySelector('input[name="windowFraction"]').value = r.windowFraction || 0.15;
|
||||
form.querySelector('input[name="shgc"]').value = r.shgc || 0.6;
|
||||
form.querySelector('select[name="insulation"]').value = r.insulation || "average";
|
||||
form.querySelector('input[name="indoorTempC"]').value = r.indoorTempC || 25;
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
|
||||
window.deleteRoomUI = async function(id) {
|
||||
if (!confirm("Delete this room and its devices/occupants?")) return;
|
||||
await deleteRoomData(id);
|
||||
await loadRooms();
|
||||
await refreshRoomSelects();
|
||||
showToast("Room deleted", false);
|
||||
};
|
||||
|
||||
document.getElementById("room-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) { showToast("Select a profile first", true); return; }
|
||||
const data = formData(e.target);
|
||||
const room = {
|
||||
profileId,
|
||||
name: data.name,
|
||||
areaSqm: numOrDefault(data.areaSqm, 15),
|
||||
ceilingHeightM: numOrDefault(data.ceilingHeightM, 2.5),
|
||||
floor: parseInt(data.floor) || 0,
|
||||
orientation: data.orientation || "S",
|
||||
shadingType: data.shadingType || "none",
|
||||
shadingFactor: numOrDefault(data.shadingFactor, 1.0),
|
||||
ventilation: data.ventilation || "natural",
|
||||
ventilationAch: numOrDefault(data.ventilationAch, 0.5),
|
||||
windowFraction: numOrDefault(data.windowFraction, 0.15),
|
||||
shgc: numOrDefault(data.shgc, 0.6),
|
||||
insulation: data.insulation || "average",
|
||||
indoorTempC: numOrDefault(data.indoorTempC, 25),
|
||||
};
|
||||
if (data.id) {
|
||||
room.id = parseInt(data.id);
|
||||
await dbPut("rooms", room);
|
||||
} else {
|
||||
await dbAdd("rooms", room);
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadRooms();
|
||||
await refreshRoomSelects();
|
||||
showToast("Room saved", false);
|
||||
});
|
||||
|
||||
// ========== Devices ==========
|
||||
async function loadDevices() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const allDevices = [];
|
||||
for (const room of rooms) {
|
||||
const devices = await dbGetByIndex("devices", "roomId", room.id);
|
||||
for (const d of devices) { d._roomName = room.name; allDevices.push(d); }
|
||||
}
|
||||
const list = document.getElementById("devices-list");
|
||||
if (allDevices.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No devices yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = allDevices.map(d => `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(d.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${esc(d._roomName)} · ${d.wattsTypical}W typical</span>
|
||||
</div>
|
||||
<button onclick="editDeviceUI(${d.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteDeviceUI(${d.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
window.editDeviceUI = async function(id) {
|
||||
const d = await dbGet("devices", id);
|
||||
if (!d) return;
|
||||
const form = document.getElementById("device-form");
|
||||
form.querySelector('input[name="id"]').value = d.id;
|
||||
form.querySelector('select[name="roomId"]').value = d.roomId;
|
||||
form.querySelector('input[name="name"]').value = d.name;
|
||||
form.querySelector('input[name="deviceType"]').value = d.deviceType || "electronics";
|
||||
form.querySelector('input[name="wattsIdle"]').value = d.wattsIdle || 0;
|
||||
form.querySelector('input[name="wattsTypical"]').value = d.wattsTypical || 0;
|
||||
form.querySelector('input[name="wattsPeak"]').value = d.wattsPeak || 0;
|
||||
form.querySelector('input[name="dutyCycle"]').value = d.dutyCycle ?? 1.0;
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
|
||||
window.deleteDeviceUI = async function(id) {
|
||||
await dbDelete("devices", id);
|
||||
await loadDevices();
|
||||
showToast("Device deleted", false);
|
||||
};
|
||||
|
||||
document.getElementById("device-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const data = formData(e.target);
|
||||
const device = {
|
||||
roomId: parseInt(data.roomId),
|
||||
name: data.name,
|
||||
deviceType: data.deviceType || "electronics",
|
||||
wattsIdle: numOrDefault(data.wattsIdle, 0),
|
||||
wattsTypical: numOrDefault(data.wattsTypical, 0),
|
||||
wattsPeak: numOrDefault(data.wattsPeak, 0),
|
||||
dutyCycle: numOrDefault(data.dutyCycle, 1.0),
|
||||
};
|
||||
if (data.id) {
|
||||
device.id = parseInt(data.id);
|
||||
await dbPut("devices", device);
|
||||
} else {
|
||||
await dbAdd("devices", device);
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadDevices();
|
||||
showToast("Device saved", false);
|
||||
});
|
||||
|
||||
// ========== Occupants ==========
|
||||
async function loadOccupants() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const allOccupants = [];
|
||||
for (const room of rooms) {
|
||||
const occupants = await dbGetByIndex("occupants", "roomId", room.id);
|
||||
for (const o of occupants) { o._roomName = room.name; allOccupants.push(o); }
|
||||
}
|
||||
const list = document.getElementById("occupants-list");
|
||||
if (allOccupants.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No occupants yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = allOccupants.map(o => `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${o.count}x ${esc(o.activityLevel)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''}</span>
|
||||
</div>
|
||||
<button onclick="editOccupantUI(${o.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteOccupantUI(${o.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
window.editOccupantUI = async function(id) {
|
||||
const o = await dbGet("occupants", id);
|
||||
if (!o) return;
|
||||
const form = document.getElementById("occupant-form");
|
||||
form.querySelector('input[name="id"]').value = o.id;
|
||||
form.querySelector('select[name="roomId"]').value = o.roomId;
|
||||
form.querySelector('input[name="count"]').value = o.count || 1;
|
||||
form.querySelector('select[name="activityLevel"]').value = o.activityLevel || "sedentary";
|
||||
form.querySelector('input[name="vulnerable"]').checked = !!o.vulnerable;
|
||||
form.querySelector('input[name="count"]').focus();
|
||||
};
|
||||
|
||||
window.deleteOccupantUI = async function(id) {
|
||||
await dbDelete("occupants", id);
|
||||
await loadOccupants();
|
||||
showToast("Occupant deleted", false);
|
||||
};
|
||||
|
||||
document.getElementById("occupant-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const data = formData(e.target);
|
||||
const occupant = {
|
||||
roomId: parseInt(data.roomId),
|
||||
count: parseInt(data.count) || 1,
|
||||
activityLevel: data.activityLevel || "sedentary",
|
||||
vulnerable: !!data.vulnerable,
|
||||
};
|
||||
if (data.id) {
|
||||
occupant.id = parseInt(data.id);
|
||||
await dbPut("occupants", occupant);
|
||||
} else {
|
||||
await dbAdd("occupants", occupant);
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadOccupants();
|
||||
showToast("Occupant saved", false);
|
||||
});
|
||||
|
||||
// ========== AC Units ==========
|
||||
async function loadACUnits() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const units = await dbGetByIndex("ac_units", "profileId", profileId);
|
||||
const list = document.getElementById("ac-list");
|
||||
if (units.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No AC units yet.</p>';
|
||||
return;
|
||||
}
|
||||
const assignments = await dbGetAll("ac_assignments");
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const roomMap = Object.fromEntries(rooms.map(r => [r.id, r.name]));
|
||||
|
||||
list.innerHTML = units.map(u => {
|
||||
const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId);
|
||||
const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", ");
|
||||
return `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">${esc(u.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}</span>
|
||||
</div>
|
||||
<button onclick="editACUI(${u.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
|
||||
<button onclick="deleteACUI(${u.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
window.editACUI = async function(id) {
|
||||
const u = await dbGet("ac_units", id);
|
||||
if (!u) return;
|
||||
const form = document.getElementById("ac-form");
|
||||
form.querySelector('input[name="id"]').value = u.id;
|
||||
form.querySelector('input[name="name"]').value = u.name;
|
||||
form.querySelector('select[name="acType"]').value = u.acType || "portable";
|
||||
form.querySelector('input[name="capacityBtu"]').value = u.capacityBtu || 0;
|
||||
form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10;
|
||||
form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify;
|
||||
// Check assigned rooms
|
||||
const assignments = await dbGetAll("ac_assignments");
|
||||
const assignedRoomIds = new Set(assignments.filter(a => a.acId === id).map(a => a.roomId));
|
||||
document.querySelectorAll('#ac-room-checkboxes input').forEach(cb => {
|
||||
cb.checked = assignedRoomIds.has(parseInt(cb.value));
|
||||
});
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
|
||||
window.deleteACUI = async function(id) {
|
||||
await dbDelete("ac_units", id);
|
||||
const assignments = await dbGetAll("ac_assignments");
|
||||
for (const a of assignments) {
|
||||
if (a.acId === id) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
||||
}
|
||||
await loadACUnits();
|
||||
showToast("AC unit deleted", false);
|
||||
};
|
||||
|
||||
document.getElementById("ac-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) { showToast("Select a profile first", true); return; }
|
||||
const data = formData(e.target);
|
||||
const unit = {
|
||||
profileId,
|
||||
name: data.name,
|
||||
acType: data.acType || "portable",
|
||||
capacityBtu: numOrDefault(data.capacityBtu, 0),
|
||||
efficiencyEer: numOrDefault(data.efficiencyEer, 10),
|
||||
hasDehumidify: !!data.hasDehumidify,
|
||||
};
|
||||
let acId;
|
||||
if (data.id) {
|
||||
unit.id = parseInt(data.id);
|
||||
await dbPut("ac_units", unit);
|
||||
acId = unit.id;
|
||||
} else {
|
||||
acId = await dbAdd("ac_units", unit);
|
||||
}
|
||||
// Save room assignments
|
||||
const oldAssignments = await dbGetAll("ac_assignments");
|
||||
for (const a of oldAssignments) {
|
||||
if (a.acId === acId) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
||||
}
|
||||
const checkboxes = document.querySelectorAll('#ac-room-checkboxes input:checked');
|
||||
for (const cb of checkboxes) {
|
||||
await dbPut("ac_assignments", { acId, roomId: parseInt(cb.value) });
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadACUnits();
|
||||
showToast("AC unit saved", false);
|
||||
});
|
||||
|
||||
// ========== Toggles ==========
|
||||
async function loadToggles() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const toggles = await dbGetAll("toggles");
|
||||
document.querySelectorAll(".toggle-switch").forEach(el => {
|
||||
const name = el.dataset.toggle;
|
||||
const t = toggles.find(t => t.profileId === profileId && t.name === name);
|
||||
el.checked = t ? t.active : false;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".toggle-switch").forEach(el => {
|
||||
el.addEventListener("change", async () => {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const name = el.dataset.toggle;
|
||||
const toggles = await dbGetAll("toggles");
|
||||
const existing = toggles.find(t => t.profileId === profileId && t.name === name);
|
||||
if (existing) {
|
||||
existing.active = el.checked;
|
||||
await dbPut("toggles", existing);
|
||||
} else {
|
||||
await dbAdd("toggles", { profileId, name, active: el.checked });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Forecast ==========
|
||||
document.getElementById("fetch-forecast-btn").addEventListener("click", async () => {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) { showToast("Select a profile first", true); return; }
|
||||
const profiles = await dbGetAll("profiles");
|
||||
const profile = profiles.find(p => p.id === profileId);
|
||||
if (!profile) return;
|
||||
|
||||
const btn = document.getElementById("fetch-forecast-btn");
|
||||
const spinner = document.getElementById("forecast-spinner");
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove("hidden");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/weather/forecast", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lat: profile.latitude,
|
||||
lon: profile.longitude,
|
||||
timezone: profile.timezone || "Europe/Berlin",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const data = await resp.json();
|
||||
|
||||
// Clear old forecasts for this profile
|
||||
await deleteByIndex("forecasts", "profileId", profileId);
|
||||
|
||||
// Store hourly forecasts
|
||||
for (const h of (data.Hourly || data.hourly || [])) {
|
||||
await dbAdd("forecasts", {
|
||||
profileId,
|
||||
timestamp: h.Timestamp || h.timestamp,
|
||||
temperatureC: h.TemperatureC ?? h.temperatureC ?? null,
|
||||
humidityPct: h.HumidityPct ?? h.humidityPct ?? null,
|
||||
cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null,
|
||||
sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null,
|
||||
apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Also fetch warnings
|
||||
try {
|
||||
const wResp = await fetch("/api/weather/warnings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }),
|
||||
});
|
||||
if (wResp.ok) {
|
||||
const wData = await wResp.json();
|
||||
await deleteByIndex("warnings", "profileId", profileId);
|
||||
for (const w of (wData.warnings || [])) {
|
||||
await dbAdd("warnings", {
|
||||
profileId,
|
||||
headline: w.Headline || w.headline || "",
|
||||
severity: w.Severity || w.severity || "",
|
||||
description: w.Description || w.description || "",
|
||||
instruction: w.Instruction || w.instruction || "",
|
||||
onset: w.Onset || w.onset || "",
|
||||
expires: w.Expires || w.expires || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { /* warnings are optional */ }
|
||||
|
||||
await setSetting("lastFetched", new Date().toISOString());
|
||||
document.getElementById("last-fetched").textContent = new Date().toLocaleString();
|
||||
showToast("Forecast fetched", false);
|
||||
} catch (err) {
|
||||
showToast("Fetch failed: " + err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
async function loadForecastStatus() {
|
||||
const lastFetched = await getSetting("lastFetched");
|
||||
if (lastFetched) {
|
||||
document.getElementById("last-fetched").textContent = new Date(lastFetched).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LLM Config ==========
|
||||
async function loadLLMConfig() {
|
||||
// Show server-side info
|
||||
try {
|
||||
const resp = await fetch("/api/llm/config");
|
||||
const data = await resp.json();
|
||||
const infoEl = document.getElementById("llm-server-info");
|
||||
if (data.available) {
|
||||
infoEl.textContent = `Server: ${data.provider} (${data.model || "default"})`;
|
||||
infoEl.classList.remove("hidden");
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Load saved client-side LLM settings
|
||||
const provider = await getSetting("llmProvider");
|
||||
const apiKey = await getSetting("llmApiKey");
|
||||
const model = await getSetting("llmModel");
|
||||
|
||||
if (provider) document.getElementById("llm-provider-select").value = provider;
|
||||
if (apiKey) document.getElementById("llm-api-key").value = apiKey;
|
||||
if (model) document.getElementById("llm-model").value = model;
|
||||
}
|
||||
|
||||
document.getElementById("llm-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const provider = document.getElementById("llm-provider-select").value;
|
||||
const apiKey = document.getElementById("llm-api-key").value;
|
||||
const model = document.getElementById("llm-model").value;
|
||||
|
||||
await setSetting("llmProvider", provider);
|
||||
await setSetting("llmApiKey", apiKey);
|
||||
await setSetting("llmModel", model);
|
||||
|
||||
showToast("LLM settings saved", false);
|
||||
});
|
||||
|
||||
// ========== Room selects for device/occupant/AC forms ==========
|
||||
async function refreshRoomSelects() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const options = rooms.map(r => `<option value="${r.id}">${esc(r.name)}</option>`).join("");
|
||||
|
||||
["device-room-select", "occupant-room-select"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.innerHTML = options;
|
||||
});
|
||||
|
||||
// AC room checkboxes
|
||||
const acCheckboxes = document.getElementById("ac-room-checkboxes");
|
||||
if (acCheckboxes) {
|
||||
acCheckboxes.innerHTML = rooms.map(r => `
|
||||
<label class="flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" value="${r.id}" class="rounded"> ${esc(r.name)}
|
||||
</label>
|
||||
`).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
function esc(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
async function init() {
|
||||
await loadProfiles();
|
||||
await loadRooms();
|
||||
await loadDevices();
|
||||
await loadOccupants();
|
||||
await loadACUnits();
|
||||
await loadToggles();
|
||||
await loadForecastStatus();
|
||||
await loadLLMConfig();
|
||||
await refreshRoomSelects();
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
109
web/templates/dashboard.html
Normal file
109
web/templates/dashboard.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{{define "content"}}
|
||||
<div id="dashboard">
|
||||
<!-- No data state -->
|
||||
<div id="no-data" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🌡️</div>
|
||||
<h1 class="text-2xl font-bold mb-2">{{t "dashboard.title"}}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">{{t "dashboard.noData"}}</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/guide" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.goToGuide"}}</a>
|
||||
<a href="/setup" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">{{t "dashboard.goToSetup"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No forecast state -->
|
||||
<div id="no-forecast" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<h1 class="text-2xl font-bold mb-2">{{t "dashboard.title"}}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">{{t "dashboard.fetchForecastFirst"}}</p>
|
||||
<a href="/setup#forecast" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.goToSetup"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="loading" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-2 border-orange-600 border-t-transparent mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{t "dashboard.computing"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div id="error-state" class="hidden">
|
||||
<div class="text-center py-16">
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{{t "dashboard.error"}}</p>
|
||||
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data display -->
|
||||
<div id="data-display" class="hidden space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{{t "dashboard.title"}}</h1>
|
||||
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div id="warnings-section" class="hidden space-y-2"></div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div id="risk-card" class="rounded-xl p-4 text-center">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.riskLevel"}}</div>
|
||||
<div id="risk-level" class="text-2xl font-bold"></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.peakTemp"}}</div>
|
||||
<div id="peak-temp" class="text-2xl font-bold"></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 text-center shadow-sm">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{t "dashboard.minNightTemp"}}</div>
|
||||
<div id="min-night-temp" class="text-2xl font-bold"></div>
|
||||
<div id="poor-night-cool" class="hidden text-xs text-orange-600 dark:text-orange-400 mt-1">{{t "dashboard.poorNightCool"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk windows -->
|
||||
<div id="risk-windows-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.riskWindows"}}</h2>
|
||||
<div id="risk-windows" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.timeline"}}</h2>
|
||||
<div id="timeline-chart" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"></div>
|
||||
<div id="timeline-strip" class="flex gap-0.5 mt-2"></div>
|
||||
<div id="timeline-tooltip" class="hidden absolute z-50 bg-gray-800 text-white text-xs rounded-lg p-3 shadow-lg max-w-xs pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Room budgets -->
|
||||
<div id="budgets-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.roomBudgets"}}</h2>
|
||||
<div id="room-budgets" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Care checklist -->
|
||||
<div id="care-section" class="hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.careChecklist"}}</h2>
|
||||
<ul id="care-checklist" class="space-y-1"></ul>
|
||||
</div>
|
||||
|
||||
<!-- LLM Summary -->
|
||||
<div id="llm-section">
|
||||
<h2 class="text-lg font-semibold mb-3">{{t "dashboard.llmSummary"}}</h2>
|
||||
<div id="llm-summary" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div class="animate-pulse h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/assets/js/db.js"></script>
|
||||
<script src="/assets/js/dashboard.js"></script>
|
||||
{{end}}
|
||||
87
web/templates/guide.html
Normal file
87
web/templates/guide.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<h1 class="text-2xl font-bold">{{t "guide.title"}}</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{t "guide.intro"}}</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step1.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step1.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step2.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step2.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step3.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step3.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step4.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step4.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step5.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step5.text"}}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "guide.step6.title"}}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.step6.text"}}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.params.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.shgc"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.ventilation"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.shading"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.params.orientation"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.risk.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.low"}}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.moderate"}}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-orange-500"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.high"}}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-red-600"></span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.risk.extreme"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.budget.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.budget.text"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-bold">{{t "guide.tips.title"}}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm space-y-2">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip1"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip2"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip3"}}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{t "guide.tips.tip4"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
48
web/templates/layout.html
Normal file
48
web/templates/layout.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Lang}}" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{t "app.name"}} — {{.Title}}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌡</text></svg>">
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<script>
|
||||
window.HG = { lang: "{{.Lang}}" };
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-14">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/" class="font-bold text-lg text-orange-600 dark:text-orange-400">{{t "app.name"}}</a>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="hover:text-orange-600 dark:hover:text-orange-400 {{if eq .Page "dashboard"}}text-orange-600 dark:text-orange-400 font-medium{{end}}">{{t "nav.dashboard"}}</a>
|
||||
<a href="/setup" class="hover:text-orange-600 dark:hover:text-orange-400 {{if eq .Page "setup"}}text-orange-600 dark:text-orange-400 font-medium{{end}}">{{t "nav.setup"}}</a>
|
||||
<a href="/guide" class="hover:text-orange-600 dark:hover:text-orange-400 {{if eq .Page "guide"}}text-orange-600 dark:text-orange-400 font-medium{{end}}">{{t "nav.guide"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{t "nav.language"}}:</span>
|
||||
<a href="?lang=en" class="{{if eq .Lang "en"}}font-bold text-orange-600 dark:text-orange-400{{else}}text-gray-500 dark:text-gray-400 hover:text-orange-600{{end}}">EN</a>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<a href="?lang=de" class="{{if eq .Lang "de"}}font-bold text-orange-600 dark:text-orange-400{{else}}text-gray-500 dark:text-gray-400 hover:text-orange-600{{end}}">DE</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-gray-200 dark:border-gray-700 mt-12 py-4">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
|
||||
<div>{{t "app.name"}} v1.0.0 — {{t "app.tagline"}}</div>
|
||||
<div><a href="https://somegit.dev/vikingowl/HeatGuard" class="hover:text-orange-600 dark:hover:text-orange-400" target="_blank" rel="noopener">{{t "footer.source"}}</a> · {{t "footer.license"}}</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
326
web/templates/setup.html
Normal file
326
web/templates/setup.html
Normal file
@@ -0,0 +1,326 @@
|
||||
{{define "content"}}
|
||||
<div id="setup">
|
||||
<h1 class="text-2xl font-bold mb-6">{{t "setup.title"}}</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-6 overflow-x-auto border-b border-gray-200 dark:border-gray-700">
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-orange-600 text-orange-600 dark:text-orange-400 dark:border-orange-400" data-tab="profiles">{{t "setup.profiles.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="rooms">{{t "setup.rooms.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="devices">{{t "setup.devices.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="occupants">{{t "setup.occupants.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="ac">{{t "setup.ac.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="toggles">{{t "setup.toggles.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="forecast">{{t "setup.forecast.title"}}</button>
|
||||
<button class="tab-btn px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400" data-tab="llm">{{t "setup.llm.title"}}</button>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="hidden fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity"></div>
|
||||
|
||||
<!-- Profiles tab -->
|
||||
<section id="tab-profiles" class="tab-panel">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.profiles.help"}}</p>
|
||||
<form id="profile-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.timezone.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.timezone.tooltip"}}">?</span></label>
|
||||
<input type="text" name="timezone" value="Europe/Berlin" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.latitude.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.latitude.tooltip"}}">?</span></label>
|
||||
<input type="number" name="latitude" step="0.0001" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.profiles.longitude.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.profiles.longitude.tooltip"}}">?</span></label>
|
||||
<input type="number" name="longitude" step="0.0001" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" id="geolocate-btn" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
📍 {{t "setup.profiles.geolocate.button"}}
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.profiles.add"}}</button>
|
||||
</div>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="profiles-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.profiles.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rooms tab -->
|
||||
<section id="tab-rooms" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.rooms.help"}}</p>
|
||||
<form id="room-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.area.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.area.tooltip"}}">?</span></label>
|
||||
<input type="number" name="areaSqm" step="0.1" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.ceilingHeight.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.ceilingHeight.tooltip"}}">?</span></label>
|
||||
<input type="number" name="ceilingHeightM" step="0.1" value="2.5" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.floor.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.floor.tooltip"}}">?</span></label>
|
||||
<input type="number" name="floor" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.orientation.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.orientation.tooltip"}}">?</span></label>
|
||||
<select name="orientation" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="N">{{t "setup.rooms.orientation.options.N"}}</option><option value="NE">{{t "setup.rooms.orientation.options.NE"}}</option><option value="E">{{t "setup.rooms.orientation.options.E"}}</option>
|
||||
<option value="SE">{{t "setup.rooms.orientation.options.SE"}}</option><option value="S" selected>{{t "setup.rooms.orientation.options.S"}}</option><option value="SW">{{t "setup.rooms.orientation.options.SW"}}</option>
|
||||
<option value="W">{{t "setup.rooms.orientation.options.W"}}</option><option value="NW">{{t "setup.rooms.orientation.options.NW"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.shadingType.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.shadingType.tooltip"}}">?</span></label>
|
||||
<select name="shadingType" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="none">{{t "setup.rooms.shadingType.options.none"}}</option><option value="blinds">{{t "setup.rooms.shadingType.options.blinds"}}</option>
|
||||
<option value="shutters">{{t "setup.rooms.shadingType.options.shutters"}}</option><option value="awning">{{t "setup.rooms.shadingType.options.awning"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.shadingFactor.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.shadingFactor.tooltip"}}">?</span></label>
|
||||
<input type="number" name="shadingFactor" step="0.1" min="0" max="1" value="1.0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.ventilationAch.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.ventilationAch.tooltip"}}">?</span></label>
|
||||
<input type="number" name="ventilationAch" step="0.1" value="0.5" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.windowFraction.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.windowFraction.tooltip"}}">?</span></label>
|
||||
<input type="number" name="windowFraction" step="0.01" min="0" max="1" value="0.15" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.shgc.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.shgc.tooltip"}}">?</span></label>
|
||||
<input type="number" name="shgc" step="0.1" min="0" max="1" value="0.6" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.insulation.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.insulation.tooltip"}}">?</span></label>
|
||||
<select name="insulation" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="poor">{{t "setup.rooms.insulation.options.poor"}}</option><option value="average" selected>{{t "setup.rooms.insulation.options.average"}}</option>
|
||||
<option value="good">{{t "setup.rooms.insulation.options.good"}}</option><option value="excellent">{{t "setup.rooms.insulation.options.excellent"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.rooms.indoorTemp.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.rooms.indoorTemp.tooltip"}}">?</span></label>
|
||||
<input type="number" name="indoorTempC" step="0.5" min="15" max="35" value="25" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.rooms.add"}}</button>
|
||||
</div>
|
||||
<input type="hidden" name="id" value="">
|
||||
<input type="hidden" name="profileId" value="">
|
||||
</form>
|
||||
<div id="rooms-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.rooms.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Devices tab -->
|
||||
<section id="tab-devices" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.devices.help"}}</p>
|
||||
<form id="device-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.room.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.room.tooltip"}}">?</span></label>
|
||||
<select name="roomId" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm" id="device-room-select"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.type.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.type.tooltip"}}">?</span></label>
|
||||
<input type="text" name="deviceType" value="electronics" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.wattsIdle.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.wattsIdle.tooltip"}}">?</span></label>
|
||||
<input type="number" name="wattsIdle" step="1" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.wattsTypical.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.wattsTypical.tooltip"}}">?</span></label>
|
||||
<input type="number" name="wattsTypical" step="1" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.wattsPeak.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.wattsPeak.tooltip"}}">?</span></label>
|
||||
<input type="number" name="wattsPeak" step="1" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.devices.dutyCycle.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.devices.dutyCycle.tooltip"}}">?</span></label>
|
||||
<input type="number" name="dutyCycle" step="0.1" min="0" max="1" value="1.0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.devices.add"}}</button>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="devices-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.devices.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Occupants tab -->
|
||||
<section id="tab-occupants" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.occupants.help"}}</p>
|
||||
<form id="occupant-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.occupants.room.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.room.tooltip"}}">?</span></label>
|
||||
<select name="roomId" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm" id="occupant-room-select"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.occupants.count.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.count.tooltip"}}">?</span></label>
|
||||
<input type="number" name="count" min="1" value="1" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.occupants.activity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.activity.tooltip"}}">?</span></label>
|
||||
<select name="activityLevel" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="sleeping">{{t "setup.occupants.activity.options.sleeping"}}</option><option value="sedentary" selected>{{t "setup.occupants.activity.options.sedentary"}}</option>
|
||||
<option value="light">{{t "setup.occupants.activity.options.light"}}</option><option value="moderate">{{t "setup.occupants.activity.options.moderate"}}</option><option value="heavy">{{t "setup.occupants.activity.options.heavy"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end pb-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="vulnerable" class="rounded">
|
||||
{{t "setup.occupants.vulnerable.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.occupants.vulnerable.tooltip"}}">?</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.occupants.add"}}</button>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="occupants-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.occupants.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AC Units tab -->
|
||||
<section id="tab-ac" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.ac.help"}}</p>
|
||||
<form id="ac-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.name.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.name.tooltip"}}">?</span></label>
|
||||
<input type="text" name="name" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.type.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.type.tooltip"}}">?</span></label>
|
||||
<select name="acType" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="portable">{{t "setup.ac.type.options.portable"}}</option><option value="window">{{t "setup.ac.type.options.window"}}</option>
|
||||
<option value="split">{{t "setup.ac.type.options.split"}}</option><option value="central">{{t "setup.ac.type.options.central"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.capacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.capacity.tooltip"}}">?</span></label>
|
||||
<input type="number" name="capacityBtu" step="100" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.eer.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.eer.tooltip"}}">?</span></label>
|
||||
<input type="number" name="efficiencyEer" step="0.1" value="10" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div class="flex items-end pb-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="hasDehumidify" class="rounded">
|
||||
{{t "setup.ac.dehumidify.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.dehumidify.tooltip"}}">?</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.rooms.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.rooms.tooltip"}}">?</span></label>
|
||||
<div id="ac-room-checkboxes" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.ac.add"}}</button>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
<div id="ac-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.ac.noItems"}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toggles tab -->
|
||||
<section id="tab-toggles" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.toggles.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-gaming" class="toggle-switch rounded" data-toggle="gaming">
|
||||
<span class="text-sm">{{t "setup.toggles.gaming"}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-cooking" class="toggle-switch rounded" data-toggle="cooking">
|
||||
<span class="text-sm">{{t "setup.toggles.cooking"}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-laundry" class="toggle-switch rounded" data-toggle="laundry">
|
||||
<span class="text-sm">{{t "setup.toggles.laundry"}}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-guests" class="toggle-switch rounded" data-toggle="guests">
|
||||
<span class="text-sm">{{t "setup.toggles.guests"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Forecast tab -->
|
||||
<section id="tab-forecast" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.forecast.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<button id="fetch-forecast-btn" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition text-sm">
|
||||
{{t "setup.forecast.fetch"}}
|
||||
</button>
|
||||
<span id="forecast-status" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{t "setup.forecast.lastFetched"}}: <span id="last-fetched">{{t "setup.forecast.never"}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="forecast-spinner" class="hidden text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-block animate-spin mr-2">↻</span> {{t "setup.forecast.fetching"}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LLM tab -->
|
||||
<section id="tab-llm" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.llm.help"}}</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||
<div id="llm-server-info" class="text-sm text-gray-400 hidden"></div>
|
||||
<form id="llm-form" class="space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.llm.provider"}}</label>
|
||||
<select name="llmProvider" id="llm-provider-select" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="">-- {{t "setup.llm.provider"}} --</option>
|
||||
<option value="anthropic">{{t "setup.llm.providerOptions.anthropic"}}</option>
|
||||
<option value="openai">{{t "setup.llm.providerOptions.openai"}}</option>
|
||||
<option value="gemini">{{t "setup.llm.providerOptions.gemini"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.llm.apiKey"}}</label>
|
||||
<input type="password" name="llmApiKey" id="llm-api-key" placeholder="{{t "setup.llm.apiKeyPlaceholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.llm.model"}}</label>
|
||||
<input type="text" name="llmModel" id="llm-model" placeholder="{{t "setup.llm.modelPlaceholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.llm.save"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/assets/js/db.js"></script>
|
||||
<script src="/assets/js/setup.js"></script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user