Den Tages-Check aufs iPhone holen
Es gibt zwei Wege, und du kannst beide kombinieren. Ein echtes Widget auf dem Sperrbildschirm geht unter iOS nur über eine kleine Helfer-App, weil Apple Websites dort nicht zulässt. Ein App-Icon auf dem Home-Bildschirm bekommst du direkt aus Safari. Beides bleibt werbefrei und ohne Tracking.
Weg 1: Sperrbildschirm-Widget mit Scriptable
Scriptable ist eine kostenlose App, mit der sich kleine Skripte als Widget anzeigen lassen. Das Skript unten holt die Wetterdaten direkt auf deinem Gerät von Open-Meteo und rechnet lokal. Es sendet nichts an meaningful.dev, es gibt keinen Account.
- Lade Scriptable aus dem App Store.
- Öffne Scriptable, tippe auf das Plus oben rechts, füge den Skript-Text von unten ein (oder nutze die Datei) und benenne das Skript „Tages-Check“.
-
Optional: Trage im Skript oben bei
CONFIGdeine Stadt ein, dann braucht das Widget keinen Standortzugriff. Lässt du es leer, fragt es einmalig nach deinem Standort. - Halte den Sperrbildschirm gedrückt, tippe auf „Anpassen“, dann auf einen Widget-Bereich, wähle „Scriptable“ und darin das Skript „Tages-Check“.
Das Widget passt sich der gewählten Größe an: Der schmale Streifen direkt über der Uhr zeigt eine Zeile (Temperatur plus der wichtigste Punkt), das rechteckige Feld darunter zwei Zeilen mit Empfehlung und Werten. Für die einzeilige Variante wählst du beim Anpassen also den Platz oberhalb der Uhr.
Auf dem iPhone am einfachsten: „Skript kopieren“ tippen, dann in Scriptable ein neues Skript anlegen und einfügen.
// Tages-Check fuer Scriptable (iOS Sperrbildschirm- und Home-Widget)
// Quelle: meaningful.dev · Daten: Open-Meteo (Modelle DWD ICON, CAMS)
// Kein Tracking, kein Account. Das Skript holt die Wetterdaten direkt auf
// deinem Geraet und rechnet lokal. Nichts wird an meaningful.dev gesendet.
//
// Einrichtung:
// 1. Diese Datei komplett in ein neues Scriptable-Skript einfuegen.
// 2. Optional unten bei CONFIG eine feste Stadt eintragen. Sonst nutzt das
// Widget deinen Standort (einmalig Erlaubnis erforderlich).
// 3. Sperrbildschirm bearbeiten, Widget hinzufuegen, Scriptable waehlen,
// dieses Skript zuweisen.
//
// Robustheit: Schlaegt der Datenabruf fehl (z. B. nachts ohne Funk), zeigt
// das Widget die zuletzt geladenen Werte mit Stand-Hinweis statt eines
// Fehlers. Der letzte gute Stand wird lokal auf dem Geraet gecacht.
// Nachts (23 bis 6 Uhr) ruht das Widget und aktualisiert erst wieder am
// fruehen Morgen: schont Akku und Funkmodul.
//
// Warnung: Bei Gewitter plus mindestens mittlerem Pollenflug plant das
// Skript eine lokale Mitteilung (Gewitterasthma-Risiko), maximal eine pro
// Tag, nur zwischen 6 und 22 Uhr. Alles bleibt auf dem Geraet, kein Server.
// Fuer die Mitteilung einmal das Skript manuell in Scriptable starten,
// damit iOS nach der Mitteilungs-Erlaubnis fragt.
// ===== CONFIG =====
const CONFIG = {
// Feste Koordinaten eintragen, um den Standortzugriff zu vermeiden.
// Beispiel Freiburg: lat: 47.99, lon: 7.84, ort: "Freiburg"
lat: null,
lon: null,
ort: null,
// Lokale Gewitterasthma-Warnung (true/false)
warnungen: true,
};
// ===== Lokaler Cache (Library-Ordner, unsichtbar, nur dieses Geraet) =====
const CACHE_MAX_ALTER_MS = 12 * 60 * 60 * 1000; // Daten maximal 12 h wiederverwenden
function cachePfad() {
const fm = FileManager.local();
return { fm, pfad: fm.joinPath(fm.libraryDirectory(), "tages-check-cache.json") };
}
function ladeCache() {
try {
const { fm, pfad } = cachePfad();
if (!fm.fileExists(pfad)) return {};
return JSON.parse(fm.readString(pfad)) || {};
} catch (e) {
return {};
}
}
function speichereCache(patch) {
try {
const { fm, pfad } = cachePfad();
const c = ladeCache();
for (const k of Object.keys(patch)) c[k] = patch[k];
fm.writeString(pfad, JSON.stringify(c));
} catch (e) {
/* Cache ist optional, still weiter */
}
}
// ===== Standort =====
async function standort() {
if (CONFIG.lat != null && CONFIG.lon != null) {
return { lat: CONFIG.lat, lon: CONFIG.lon, name: CONFIG.ort || "Dein Ort" };
}
try {
Location.setAccuracyToThreeKilometers();
const p = await Location.current();
let name = "Dein Ort";
try {
const g = await Location.reverseGeocode(p.latitude, p.longitude);
name = (g[0] && (g[0].locality || g[0].administrativeArea)) || name;
} catch (e) {}
const o = { lat: p.latitude, lon: p.longitude, name };
speichereCache({ ort: o });
return o;
} catch (e) {
// Erst der zuletzt bekannte Ort, dann die geografische Mitte Deutschlands
const c = ladeCache();
if (c.ort && typeof c.ort.lat === "number" && typeof c.ort.lon === "number") return c.ort;
return { lat: 51.16, lon: 10.45, name: "Deutschland" };
}
}
// ===== Daten =====
async function hole(url) {
const r = new Request(url);
r.timeoutInterval = 15; // nicht minutenlang haengen, lieber in den Fallback
return await r.loadJSON();
}
// Ein kurzer zweiter Versuch faengt kurze Funklöcher ab
async function holeMitRetry(url) {
try {
return await hole(url);
} catch (e) {
return await hole(url);
}
}
// Open-Meteo liefert bei Fehlern JSON ohne hourly/daily: vor Zugriff pruefen
function gueltig(d) {
return !!(d && d.hourly && Array.isArray(d.hourly.time) && d.hourly.time.length);
}
function stundeJetzt(times) {
const jetzt = new Date();
const p = (n) => String(n).padStart(2, "0");
const key = `${jetzt.getFullYear()}-${p(jetzt.getMonth() + 1)}-${p(jetzt.getDate())}T${p(jetzt.getHours())}:00`;
const i = times.indexOf(key);
return i >= 0 ? i : 0;
}
// Naechster Aktualisierungszeitpunkt. Tagsueber: 30 Minuten (nach Fehlern 15).
// Nachts (23 bis 6 Uhr) ruht das Widget und meldet sich gegen 5:45 zurueck:
// weniger Funkversuche im Standby, frische Daten zum Aufwachen.
function naechsteAktualisierung(fehlgeschlagen) {
const jetzt = new Date();
const h = jetzt.getHours();
if (h >= 23 || h < 6) {
const morgen = new Date(jetzt);
if (h >= 23) morgen.setDate(morgen.getDate() + 1);
morgen.setHours(5, 45, 0, 0);
if (morgen.getTime() > jetzt.getTime()) return morgen;
}
return new Date(Date.now() + (fehlgeschlagen ? 15 : 30) * 60 * 1000);
}
async function lage() {
const o = await standort();
const basis = `latitude=${o.lat}&longitude=${o.lon}&timezone=auto&forecast_days=1`;
const [w, a] = await Promise.all([
holeMitRetry(`https://api.open-meteo.com/v1/forecast?${basis}&hourly=precipitation,weather_code&daily=uv_index_max,apparent_temperature_max,temperature_2m_max`),
holeMitRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${basis}&hourly=grass_pollen,birch_pollen,ozone`),
]);
if (!gueltig(w) || !gueltig(a) || !w.daily) {
throw new Error("Antwort unvollstaendig");
}
const wh = w.hourly, ah = a.hourly;
const jw = stundeJetzt(wh.time), ja = stundeJetzt(ah.time);
const restW = wh.time.map((_, i) => i).filter((i) => i >= jw);
const restA = ah.time.map((_, i) => i).filter((i) => i >= ja);
// Regen: erste Stunde mit >= 0.2 mm
let regen = null;
for (const i of restW) {
if ((wh.precipitation[i] ?? 0) >= 0.2) { regen = parseInt(wh.time[i].slice(11, 13), 10); break; }
}
// Gewitter: erste Stunde mit Gewitter-Wettercode (95, 96, 99)
const GEWITTER_CODES = [95, 96, 99];
let gewitter = null;
for (const i of restW) {
if (GEWITTER_CODES.indexOf(wh.weather_code?.[i]) !== -1) { gewitter = parseInt(wh.time[i].slice(11, 13), 10); break; }
}
// UV (Tageshoechstwert)
const uv = Math.round(w.daily.uv_index_max[0] ?? 0);
// Pollen: Gräser und Birke, hoechste Stufe (0..3)
const stufe = (v, s) => (v >= s[2] ? 3 : v >= s[1] ? 2 : v >= s[0] ? 1 : 0);
const gras = Math.max(...restA.map((i) => ah.grass_pollen?.[i] ?? 0), 0);
const birke = Math.max(...restA.map((i) => ah.birch_pollen?.[i] ?? 0), 0);
const pStufe = Math.max(stufe(gras, [1, 30, 50]), stufe(birke, [1, 11, 50]));
const pTxt = ["keine", "gering", "mittel", "hoch"][pStufe];
// Ozon
const o3 = Math.max(...restA.map((i) => ah.ozone?.[i] ?? 0), 0);
// Hitze (gefuehlt, Tageshoechstwert)
const gefuehlt = Math.round(w.daily.apparent_temperature_max[0] ?? 0);
const real = Math.round(w.daily.temperature_2m_max[0] ?? 0);
return { ort: o.name, regen, gewitter, uv, pStufe, pTxt, o3, gefuehlt, real };
}
// ===== Lokale Warnung: Gewitterasthma (Gewitter + Pollen ab Stufe mittel) =====
// Alles auf dem Geraet: keine Server, keine Push-Dienste, kein Datenabfluss.
// Maximal eine Mitteilung pro Tag, nur zwischen 6 und 22 Uhr.
async function pruefeWarnung(l) {
try {
if (!CONFIG.warnungen) return;
if (l.gewitter == null || l.pStufe < 2) return;
const jetzt = new Date();
const h = jetzt.getHours();
if (h < 6 || h >= 22) return; // niemanden nachts wecken
const p = (n) => String(n).padStart(2, "0");
const heute = `${jetzt.getFullYear()}-${p(jetzt.getMonth() + 1)}-${p(jetzt.getDate())}`;
const c = ladeCache();
if (c.warnungTag === heute) return; // heute schon gewarnt
const flug = l.pTxt === "hoch" ? "hohem" : "mittlerem";
const n = new Notification();
n.title = "Gewitterasthma-Risiko heute";
n.subtitle = l.ort;
n.body = `Gewitter ab etwa ${l.gewitter} Uhr bei ${flug} Pollenflug. Ab dann besser drinnen bleiben, Fenster zu, Asthmaspray griffbereit. Kein medizinischer Rat.`;
n.threadIdentifier = "tages-check-warnung";
await n.schedule();
speichereCache({ warnungTag: heute });
} catch (e) {
/* Mitteilung ist optional: fehlende Erlaubnis darf das Widget nie brechen */
}
}
// Frische Daten holen; bei Fehlern die letzte gute Antwort verwenden.
// Rueckgabe: { l, stand } — stand ist null bei frischen Daten, sonst "HH:MM".
async function lageMitFallback() {
try {
const l = await lage();
speichereCache({ daten: { zeit: Date.now(), lage: l } });
return { l, stand: null };
} catch (e) {
const c = ladeCache();
const d = c.daten;
if (d && typeof d.zeit === "number" && d.lage && Date.now() - d.zeit <= CACHE_MAX_ALTER_MS) {
const t = new Date(d.zeit);
const p = (n) => String(n).padStart(2, "0");
return { l: d.lage, stand: `${p(t.getHours())}:${p(t.getMinutes())}` };
}
return { l: null, stand: null };
}
}
// ===== Bewertung: kurze Handlungszeile + Faktoren =====
function auswerten(l) {
const faktoren = [];
if (l.gewitter != null && l.pStufe >= 2) faktoren.push(`Gewitterasthma-Risiko ab ${l.gewitter} Uhr`);
if (l.pStufe >= 2) faktoren.push(`Pollen ${l.pTxt}`);
if (l.uv >= 3) faktoren.push(`UV ${l.uv}`);
if (l.regen != null) faktoren.push(`Regen ab ${l.regen} Uhr`);
if (l.gefuehlt >= 27) faktoren.push(`gefühlt ${l.gefuehlt}°`);
if (l.o3 >= 180) faktoren.push(`Ozon hoch`);
// Wichtigste Handlung (Gesundheitsgefahr zuerst)
let aktion = "Heute wenig zu beachten.";
if (l.gewitter != null && l.pStufe >= 2) aktion = `Gewitter + Pollen: ab ${l.gewitter} Uhr besser drinnen.`;
else if (l.gefuehlt >= 32) aktion = "Viel trinken, Mittagssonne meiden.";
else if (l.pStufe >= 2) aktion = "Vor 8 Uhr lüften, tagsüber Fenster zu.";
else if (l.uv >= 6) aktion = "Sonnencreme ab 15 Min draußen.";
else if (l.regen != null) aktion = `Regen ab etwa ${l.regen} Uhr.`;
else if (l.uv >= 3) aktion = "Bei längerem Aufenthalt Sonnenschutz.";
// Kurzform der Aktion fuer das kleine Sperrbildschirm-Rechteck:
// eine Zeile, dafuer groessere Schrift
let aktionKurz = "Wenig zu beachten";
if (l.gewitter != null && l.pStufe >= 2) aktionKurz = `Ab ${l.gewitter} Uhr drinnen bleiben`;
else if (l.gefuehlt >= 32) aktionKurz = "Viel trinken, Sonne meiden";
else if (l.pStufe >= 2) aktionKurz = "Vor 8 Uhr lüften";
else if (l.uv >= 6) aktionKurz = "Sonnencreme ab 15 Min";
else if (l.regen != null) aktionKurz = `Regen ab ${l.regen} Uhr`;
else if (l.uv >= 3) aktionKurz = "Mittags Sonnenschutz";
const stats = `${l.real}° · UV ${l.uv} · Pollen ${l.pTxt}`;
// Kürzester Ein-Zeiler für das schmale Inline-Widget neben der Uhr
let kurz;
if (l.gefuehlt >= 32) kurz = `${l.real}° · gefühlt ${l.gefuehlt}°`;
else if (l.pStufe >= 2) kurz = `${l.real}° · Pollen ${l.pTxt}`;
else if (l.regen != null) kurz = `${l.real}° · Regen ${l.regen} Uhr`;
else kurz = `${l.real}° · UV ${l.uv}`;
return { aktion, aktionKurz, stats, faktoren, kurz };
}
// ===== Widget =====
function zeile(w, text, opts) {
const t = w.addText(text);
t.font = opts.font;
if (opts.color) t.textColor = opts.color;
if (opts.max) t.lineLimit = opts.max;
// Lieber leicht schrumpfen als abschneiden (z. B. "Stand 06:30"-Zusatz)
if (opts.scale) t.minimumScaleFactor = opts.scale;
return t;
}
// Freundlicher Zustand ohne Daten: kein Skriptfehler auf dem Sperrbildschirm,
// dafuer ein baldiger neuer Versuch.
function baueLeer(family) {
const w = new ListWidget();
w.refreshAfterDate = naechsteAktualisierung(true);
if (family === "accessoryInline") {
zeile(w, "Tages-Check: gleich wieder da", { font: Font.systemFont(13), max: 1 });
return w;
}
if (family === "accessoryCircular") {
w.addSpacer();
zeile(w, "–", { font: Font.semiboldSystemFont(18), max: 1 });
w.addSpacer();
return w;
}
if (family === "accessoryRectangular") {
zeile(w, "Gerade keine Wetterdaten.", { font: Font.semiboldSystemFont(13), max: 2 });
zeile(w, "Neuer Versuch in Kürze.", { font: Font.systemFont(12), max: 1 });
return w;
}
w.backgroundColor = new Color("#17656b");
zeile(w, "Tages-Check", { font: Font.semiboldSystemFont(14), color: new Color("#bfe0e2"), max: 1 });
w.addSpacer(4);
zeile(w, "Gerade keine Wetterdaten. Neuer Versuch in Kürze.", { font: Font.semiboldSystemFont(16), color: new Color("#ffffff"), max: 3 });
w.addSpacer();
return w;
}
async function baue() {
const family = config.widgetFamily || "accessoryRectangular";
const { l, stand } = await lageMitFallback();
if (!l) return baueLeer(family);
const a = auswerten(l);
// Warnung nur bei frischen Daten pruefen (nicht auf Basis alter Cache-Werte)
if (!stand) await pruefeWarnung(l);
const w = new ListWidget();
const gleichAktualisieren = () => (w.refreshAfterDate = naechsteAktualisierung(!!stand));
// Bei aelteren Daten den Stand ehrlich ausweisen
const statsMitStand = stand ? `${a.stats} · Stand ${stand}` : a.stats;
// Schmales Inline-Widget: eine Zeile neben der Uhr, vom System eingefärbt
if (family === "accessoryInline") {
zeile(w, a.kurz, { font: Font.systemFont(13), max: 1 });
gleichAktualisieren();
return w;
}
// Kreis-Widget: sehr klein, Temperatur plus UV
if (family === "accessoryCircular") {
w.addSpacer();
zeile(w, `${l.real}°`, { font: Font.semiboldSystemFont(18), max: 1 });
zeile(w, `UV ${l.uv}`, { font: Font.systemFont(10), max: 1 });
w.addSpacer();
gleichAktualisieren();
return w;
}
// Rechteckiges Sperrbildschirm-Widget: zwei Zeilen, dafuer gut lesbar.
// Kurze Aktion gross und fett, Werte kleiner darunter. minimumScaleFactor
// laesst lange Zeilen leicht schrumpfen statt sie abzuschneiden.
if (family === "accessoryRectangular") {
w.setPadding(0, 0, 0, 0);
w.addSpacer();
zeile(w, a.aktionKurz, { font: Font.semiboldSystemFont(16), max: 1, scale: 0.75 });
w.addSpacer(2);
zeile(w, statsMitStand, { font: Font.systemFont(12), max: 1, scale: 0.75 });
w.addSpacer();
gleichAktualisieren();
return w;
}
// Home-Bildschirm: eigene Farben in der Markenwelt
w.backgroundColor = new Color("#17656b");
zeile(w, l.ort, { font: Font.semiboldSystemFont(14), color: new Color("#bfe0e2"), max: 1 });
w.addSpacer(4);
zeile(w, a.aktion, { font: Font.semiboldSystemFont(17), color: new Color("#ffffff"), max: 3 });
w.addSpacer(6);
zeile(w, statsMitStand, { font: Font.systemFont(12), color: new Color("#cfe6e7"), max: 1 });
if (family === "systemMedium" && a.faktoren.length) {
w.addSpacer(4);
zeile(w, a.faktoren.join(" · "), { font: Font.systemFont(12), color: new Color("#cfe6e7"), max: 2 });
}
w.addSpacer();
const q = zeile(w, "meaningful.dev", { font: Font.systemFont(9), color: new Color("#9ec7c9"), max: 1 });
q.textOpacity = 0.7;
gleichAktualisieren();
return w;
}
const widget = await baue();
if (config.runsInWidget) {
Script.setWidget(widget);
} else {
await widget.presentMedium(); // Vorschau beim manuellen Start
}
Script.complete();
Weg 2: App-Icon auf dem Home-Bildschirm
Öffne meaningful.dev in Safari, tippe unten auf das Teilen-Symbol und wähle „Zum Home-Bildschirm“. Danach startet der Tages-Check als eigenes Icon im Vollbild, ohne Browserleiste. Das ist kein Sperrbildschirm-Widget, aber der schnellste Weg zum täglichen Blick.