Skip to content

Instantly share code, notes, and snippets.

@schl3ck
Forked from marco79cgn/vaccination-stats.js
Last active January 7, 2022 18:57
Show Gist options
  • Save schl3ck/570422d5f6ce4595c05fca951da067e5 to your computer and use it in GitHub Desktop.
Save schl3ck/570422d5f6ce4595c05fca951da067e5 to your computer and use it in GitHub Desktop.
A Scriptable widget that shows the amount of people who have received the corona vaccination in Austria
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: blue; icon-glyph: syringe;
/**************************
* Bitte ab hier kopieren *
**************************/
/**
* Scriptable Widget um die aktuellen Impfzahlen darzustellen
*
* Berechnet auch ein Datum ab dem eine Herdenimmunität gegeben wäre
* (Der Mensch hat keine lebenslange Immunität vor Coronaviren, deshalb wird es
* keine Herdenimmunität geben)
*
* Es gibt einen Konfigurator, um die am Widget agezeigten Elemente sowie auch
* die Bundesländer auszuwählen. Dazu einfach das Skript in der App starten und
* "Widgeteinstellungen konfigurieren" auswählen.
*
* Version: 2.1
* Source: https://gist.github.com/schl3ck/570422d5f6ce4595c05fca951da067e5
*
* Changelog
* v2.1 - 2021-12-25
* * Grenzen des Fortschrittbalkens angepasst, sodass die Werte nicht mehr zu nahe an den Grenzen liegen
* * Rechtschreibfehler korrigiert
*
* v2.0 - 2021-12-03
* + Widgetkonfigurator hinzugefügt
* * Widget kann nun auch die Zahlen für die Bundesländer anzeigen
* * Überprüft, ob ein Update des Skripts vorhanden ist
* + Changelog hinzugefügt
*
* v1.4 - 2021-11-21
* * Neue Datenquelle
* + Trendpfeil hinter der Zertifikatanzahl hinzugefügt
*
* v1.3 - 2021-04-18
* * Formatiert die Zahlen für bessere Lesbarkeit
* * Textgröße angepasst
*
* v1.2 - 2021-03-10
* * Neue Datenquelle
*
* v1.1 - 2021-01-30
* * Neue Datenquelle
* * Kleine Fehler behoben
*
* v1.0 - 2021-01-19
* * Widget für Österreich aktualisiert
*/
// Mit Caching und Fallback
const cacheMinutes = 60; // 60 min
const today = new Date();
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
// nur 80% werden für eine Herdenimmunität gebraucht
const herdImmunityFactor = 0.8;
const progressBarWidth = 100;
const progressBarHeight = 5;
const colors = [
new Color("#00ac00"),
new Color("#0000cc"),
new Color("#cc0000"),
new Color("#707070"),
new Color("#f4ac00"), // orange
new Color("#bd00a8"), // magenta
new Color("#7b5700"), // brown
new Color("#00d2d2"), // cyan
];
// maximum number of rows that can be shown on the widget
const maxRows = 8;
/**
* @typedef {object} WidgetSettings
* @property {number[]} stateIDs List of state IDs to show
* @property {boolean} showAverage if the 7 day average of each reagion should be shown
* @property {boolean} showStatusBar if the status bar should be shown
* @property {number} herdImmunityForStateID calculate the herd immunity for the state with this ID
*/
/** @type {WidgetSettings} */
const defaultWidgetSettings = {
stateIDs: [10], // show only of whole Austria
showAverage: true,
showStatusBar: true,
herdImmunityForStateID: 10,
};
const cacheFolder = (function () {
const name = Script.name().replace(/[/<>:"\\|?*]/g, "");
const fm = FileManager.local();
return `${fm.cacheDirectory()}/${name}`;
})();
const stateMap = [
"NoState",
"B",
"K",
"NÖ",
"OÖ",
"S",
"ST",
"T",
"V",
"W",
"Ö",
];
const stateMapFullNames = [
"NoState",
"Burgenland",
"Kärnten",
"Niederösterreich",
"Oberösterreich",
"Salzburg",
"Steiermark",
"Tirol",
"Vorarlberg",
"Wien",
"Österreich",
];
const changelogDateFormatter = new DateFormatter();
changelogDateFormatter.useShortDateStyle();
changelogDateFormatter.useNoTimeStyle();
const changelogRelativeFormatter = new RelativeDateTimeFormatter();
changelogRelativeFormatter.useNamedDateTimeStyle();
const htmlEscape = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
};
if (config.runsInWidget) {
/** @type {WidgetSettings} */
let settings;
let thrown = false;
try {
if (args.widgetParameter) settings = JSON.parse(args.widgetParameter);
} catch (err) {
const widget = new ListWidget();
const text = widget.addText("The passed parameter is not valid JSON");
text.textColor = Color.red();
Script.setWidget(widget);
Script.complete();
thrown = true;
}
if (!thrown) {
const widget = await createWidget(await getNumbers(), settings);
Script.setWidget(widget);
Script.complete();
}
} else {
await mainMenu();
}
/**
* @typedef { {
* nCitizens: any;
* states: {
* today: CSVItem;
* weekAgo: CSVItem;
* }[];
* daysBetweenLatest7DaysAgo: number;
* date: Date;
* } } CovidData
*/
/**
* @typedef {object} CSVItem
* @property {Date} date
* @property {number} state_id
* @property {string} state_name
* @property {string} age_group
* @property {"Female" | "Male" | "NonBinary"} gender
* @property {number} population
* @property {number} valid_certificates
* @property {number} valid_certificates_percent
*/
/**
* @typedef {T extends PromiseLike<infer U> ? Awaited<U> : T} Awaited
* @template T
*
*/
/**
* @param {Awaited<ReturnType<typeof getNumbers>>} data
* @param {WidgetSettings} settings
*/
async function createWidget(
{ data, update },
settings = defaultWidgetSettings,
) {
if (!settings) {
settings = defaultWidgetSettings;
}
const widget = new ListWidget();
widget.setPadding(4, 10, 0, 10);
const upperStack = widget.addStack();
upperStack.layoutHorizontally();
const upperTextStack = upperStack.addStack();
upperTextStack.layoutVertically();
let staticText1 = upperTextStack.addText("Aktuell gültige");
staticText1.font = Font.semiboldRoundedSystemFont(11);
let staticText2 = upperTextStack.addText("Impfzertifikate");
staticText2.font = Font.semiboldRoundedSystemFont(11);
upperStack.addSpacer();
let logoImage = upperStack.addImage(await getImage("vac-logo.png"));
logoImage.imageSize = new Size(30, 30);
widget.addSpacer(2);
for (let i = 0; i < settings.stateIDs.length; i++) {
const stateID = settings.stateIDs[i];
const color = colors[i % colors.length];
const state = data.states.find((r) => r.today.state_id === stateID);
if (!state) {
const text = widget.addText(`Unbekannte ID: ${stateID}`);
text.leftAlignText();
text.lineLimit = 1;
text.minimumScaleFactor = 0.7;
text.textColor = Color.red();
text.font = Font.boldSystemFont(11);
continue;
}
const titleDataStack = widget.addStack();
titleDataStack.layoutHorizontally();
const dataTitle = titleDataStack.addText(stateMap[stateID] + ":");
dataTitle.font = Font.boldSystemFont(10);
// dataTitle.minimumScaleFactor = 0.8;
titleDataStack.addSpacer();
const dataStack = titleDataStack.addStack();
dataStack.layoutVertically();
const percent = {
today: state.today.valid_certificates_percent,
weekAgo: state.weekAgo.valid_certificates_percent,
};
const trend =
percent.today > percent.weekAgo
? "↑"
: percent.today < percent.weekAgo
? "↓"
: "=";
let amountText = dataStack.addText(
`${state.today.valid_certificates.toLocaleString()} (${round(
state.today.valid_certificates_percent,
2,
).toLocaleString()}%) ${trend}`,
);
amountText.font = Font.boldSystemFont(11);
amountText.textColor = color;
amountText.minimumScaleFactor = 0.8;
amountText.lineLimit = 1;
if (settings.showAverage) {
const averageStack = widget.addStack();
averageStack.layoutHorizontally();
averageStack.addSpacer();
let description3 = averageStack.addText(
`(${data.daysBetweenLatest7DaysAgo}T. Ø: ${calculateDailyVac(
state,
data.daysBetweenLatest7DaysAgo,
).toLocaleString()} / Tag)`,
);
description3.font = Font.mediumSystemFont(9);
description3.lineLimit = 1;
}
}
if (settings.showStatusBar) {
widget.addSpacer(4);
const progressBar = createProgress(
settings.stateIDs.map((id) =>
data.states.find((s) => s.today.state_id === id),
),
);
let progressStack = widget.addStack();
progressStack.layoutVertically();
progressStack.addImage(progressBar.image);
let progressNumberStack = widget.addStack();
progressNumberStack.layoutHorizontally();
const progressText0 = progressNumberStack.addText(`${progressBar.min}%`);
progressText0.font = Font.mediumSystemFont(8);
progressNumberStack.addSpacer();
const progressText70 = progressNumberStack.addText(`${progressBar.max}%`);
progressText70.font = Font.mediumSystemFont(8);
}
if (
settings.herdImmunityForStateID >= 1
&& settings.herdImmunityForStateID <= 10
) {
widget.addSpacer(4);
const rowsLeft =
maxRows
- settings.stateIDs.length * (settings.showAverage ? 2 : 1)
- (settings.showStatusBar ? 1 : 0);
const enoughRowsLeft = rowsLeft >= 2;
let calendarStack = widget.addStack();
if (enoughRowsLeft) {
const calendarImage = calendarStack.addImage(
await getImage("calendar-icon.png"),
);
calendarImage.imageSize = new Size(23, 23);
calendarStack.addSpacer(6);
}
let calendarTextStack = calendarStack.addStack();
if (enoughRowsLeft) {
calendarTextStack.layoutVertically();
} else {
calendarTextStack.layoutHorizontally();
}
// calculate date
const state = data.states.find(
(s) => s.today.state_id === settings.herdImmunityForStateID,
);
if (state) {
const estimatedDate = new Date();
estimatedDate.setDate(
new Date().getDate()
+ calculateRemainingDays(state, data.daysBetweenLatest7DaysAgo),
);
let description = calendarTextStack.addText(
`"${enoughRowsLeft ? "Herdenimmunität" : "Herdenimm."}" ${
stateMap[settings.herdImmunityForStateID]
}:`,
);
description.font = Font.mediumSystemFont(9);
if (!enoughRowsLeft) {
calendarTextStack.addSpacer(4);
}
const description2 = calendarTextStack.addText(
estimatedDate > new Date() ? estimatedDate.toLocaleDateString() : "♾️",
);
description2.font = Font.boldSystemFont(9);
} else {
const text = calendarTextStack.addText(
`Unbekannte ID: ${settings.herdImmunityForStateID}`,
);
text.minimumScaleFactor = 0.7;
text.textColor = Color.red();
}
}
widget.addSpacer(2);
const lastUpdateDate = new Date(data.date);
lastUpdateDate.setSeconds(lastUpdateDate.getSeconds() + 1);
let lastUpdatedText = widget.addText(
"Stand: " + lastUpdateDate.toLocaleDateString(),
);
lastUpdatedText.font = Font.mediumMonospacedSystemFont(8);
lastUpdatedText.textOpacity = 0.7;
lastUpdatedText.centerAlignText();
if (update.updateAvailable) {
const updateText = widget.addText(
`Update: ${update.curVersion} ➞ ${update.newVersion}`,
);
updateText.font = Font.mediumMonospacedSystemFont(8);
updateText.textOpacity = 0.7;
updateText.centerAlignText();
}
return widget;
}
/** @typedef { {label: string, action: () => Promise<void>} } MenuOption */
async function mainMenu() {
const data = await getNumbers();
// log(JSON.stringify(data.update, null, 2));
/** @type {MenuOption[]} */
const options = [
{
label: "Standardwidget anzeigen",
async action() {
const widget = await createWidget(data);
await widget.presentSmall();
},
},
{
label: "Widgeteinstellungen konfigurieren",
action: configureWidget,
},
];
if (data.update.updateAvailable) {
options.push({
label: "Änderungen anzeigen",
action: showChanges.bind(undefined, data.update),
});
options.push({
label: "Aktualisieren",
action: updateScript.bind(undefined, data.update),
});
}
const a = new Alert();
a.addCancelAction("Abbrechen");
options.forEach((o) => a.addAction(o.label));
if (data.update.updateAvailable) {
const lines = data.update.changes
.map(
(change) =>
`v${change.version} - ${changelogDateFormatter.string(
change.date,
)}\n${change.changes}`,
)
.join("\n\n")
.split("\n");
a.title = "Update verfügbar!";
a.message = `${data.update.curVersion} ➞ ${data.update.newVersion}
${lines.length > 10 ? lines.slice(9).join("\n") + "\n..." : lines.join("\n")}`;
}
let res = await a.presentSheet();
if (res === -1) return;
return options[res].action();
}
function configureWidget() {
const ui = new UITable();
const settings = { ...defaultWidgetSettings };
let reorderState = -1;
/** counts down from 1 to 0 when the settings were copied */
let copied = 0;
refreshUI();
return ui.present();
function refreshUI() {
const backgroundColor = Color.dynamic(Color.white(), new Color("#1b1b1d"));
ui.removeAllRows();
let row, cell;
let rowsDisplayed = settings.stateIDs.length;
if (settings.showAverage) rowsDisplayed *= 2;
if (settings.showStatusBar) rowsDisplayed += 1;
if (settings.herdImmunityForStateID >= 0) rowsDisplayed += 1;
row = addRow();
row.height += 10;
if (rowsDisplayed > maxRows) {
row.backgroundColor = Color.orange();
cell = row.addText(
"Es wird am Widget zu viel dargestellt, sodass Text abgschnitten sein kann.",
);
cell.centerAligned();
} else {
row.isHeader = true;
cell = row.addText("Widget-Konfigurator");
cell.font = Font.boldSystemFont(20);
cell.centerAligned();
}
row = addRow();
row.height = 6;
row = addRow();
if (reorderState < 0) {
row.backgroundColor = adjustColor(
backgroundColor,
new Color("#00ac00"),
copied,
);
row.dismissOnSelect = false;
row.onSelect = () => {
Pasteboard.copyString(JSON.stringify(settings));
countDown();
};
cell = row.addText(copied > 0 ? "Kopiert!" : "Konfiguration kopieren", copied > 0 ? "" : "Kopiertes im Parameterfeld des Widgets einfügen");
if (copied > 0) {
cell.centerAligned();
}
}
row = addRow();
if (reorderState < 0) {
row.dismissOnSelect = false;
row.onSelect = async () => {
const widget = await createWidget(await getNumbers(), settings);
await widget.presentSmall();
};
cell = row.addText("Widgetvorschau");
}
row = addRow();
row.height = 2;
row.backgroundColor = Color.dynamic(Color.black(), Color.white());
row = addRow();
row.height = 6;
if (reorderState < 0) {
addSetting(
"7-Tages-Durchschnitt anzeigen",
settings.showAverage,
() => {
settings.showAverage = !settings.showAverage;
},
"für jedes Bundesland",
);
addSetting("Fortschrittsbalken anzeigen", settings.showStatusBar, () => {
settings.showStatusBar = !settings.showStatusBar;
});
} else {
row = addRow();
row.backgroundColor = Color.orange();
cell = row.addText(
"Sortiermodus",
"Tippe auf die Stelle, zu der das ausgewählte Bundesland verschoben werden soll. Zum Abbrechen tippe auf das ausgewählte Bundesland",
);
cell.centerAligned();
row.height *= 2;
}
row = addRow();
row.height += 10;
if (reorderState < 0) {
row.dismissOnSelect = false;
row.onSelect = async () => {
const a = new Alert();
a.addCancelAction("Abbrechen");
const options = stateMapFullNames.slice(1);
options.push("Nicht anzeigen");
options.forEach((o) => a.addAction(o));
const res = await a.presentSheet();
if (res === -1) return;
const choice = options[res];
if (choice === "Nicht anzeigen") {
settings.herdImmunityForStateID = -1;
} else {
settings.herdImmunityForStateID = stateMapFullNames.indexOf(choice);
}
refreshUI();
};
const herdImmunityState =
settings.herdImmunityForStateID >= 1
&& settings.herdImmunityForStateID <= 10
? stateMapFullNames[settings.herdImmunityForStateID]
: "";
cell = row.addText(
`"Herdenimmunität" ${herdImmunityState ? "" : "nicht "}anzeigen${
herdImmunityState ? " für" : ""
}`,
);
cell.widthWeight = 60;
cell = row.addText(herdImmunityState);
cell.widthWeight = 40;
cell.rightAligned();
}
const selectedStates = settings.stateIDs.map((s) => stateMapFullNames[s]);
const otherStates = [];
for (let i = 1; i < stateMapFullNames.length; i++) {
if (!settings.stateIDs.includes(i)) {
otherStates.push(stateMapFullNames[i]);
}
}
row = addRow();
row.height = 10;
row = addRow();
row.isHeader = true;
row.backgroundColor = Color.dynamic(new Color("#ddd"), new Color("#333"));
cell = row.addText(
"Ausgewählte Bundesländer",
"Tippe auf eines, um es zu entferen",
);
cell.centerAligned();
cell.subtitleFont = Font.systemFont(12);
for (const s of selectedStates) {
row = addRow();
row.dismissOnSelect = false;
row.onSelect = () => {
const id = stateMapFullNames.indexOf(s);
if (reorderState < 0) {
settings.stateIDs.splice(settings.stateIDs.indexOf(id), 1);
} else {
if (id !== reorderState) {
let old = settings.stateIDs.indexOf(reorderState);
let index = settings.stateIDs.indexOf(id);
if (index > old) {
index++;
}
settings.stateIDs.splice(index, 0, reorderState);
if (old > index) old++;
settings.stateIDs.splice(old, 1);
}
reorderState = -1;
}
refreshUI();
};
if (stateMapFullNames.indexOf(s) === reorderState) {
row.backgroundColor = Color.green();
}
cell = row.addText(s);
cell.widthWeight = 90;
if (reorderState < 0 && settings.stateIDs.length > 1) {
cell = row.addButton(" ↕️ ");
cell.widthWeight = 10;
cell.rightAligned();
cell.dismissOnTap = false;
cell.onTap = () => {
reorderState = stateMapFullNames.indexOf(s);
refreshUI();
};
}
}
row = addRow();
if (reorderState < 0) {
row.isHeader = true;
row.backgroundColor = Color.dynamic(new Color("#ddd"), new Color("#333"));
cell = row.addText(
"Verfügbare Bundesländer",
"Tippe auf eines, um es hinzuzufügen",
);
cell.centerAligned();
cell.subtitleFont = Font.systemFont(12);
}
for (const s of otherStates) {
row = addRow();
if (reorderState < 0) {
row.dismissOnSelect = false;
row.onSelect = () => {
settings.stateIDs.push(stateMapFullNames.indexOf(s));
refreshUI();
};
cell = row.addText(s);
}
}
ui.reload();
}
function addRow() {
const r = new UITableRow();
ui.addRow(r);
return r;
}
/**
* @param {string} title
* @param {boolean} currentState
* @param {() => void} onChange
* @param {string} [subtitle]
*/
function addSetting(title, currentState, onChange, subtitle = undefined) {
const row = addRow();
row.dismissOnSelect = false;
row.onSelect = () => {
onChange();
refreshUI();
};
let cell = row.addText(title, subtitle);
cell.widthWeight = 95;
cell = row.addText(currentState ? "✓" : "");
cell.widthWeight = 5;
cell.rightAligned();
}
async function countDown() {
copied = 1;
refreshUI();
const timer = Timer.schedule(10, true, () => {
copied -= 0.01;
if (copied <= 0) {
copied = 0;
timer.invalidate();
}
refreshUI();
});
}
}
/**
* @param {UpdateCheckResult} changes
*/
async function showChanges(changes) {
if (!changes.updateAvailable) return;
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<style>
:root {
font-family: -apple-system, sans-serif;
}
h2, h3 {
margin: 0;
}
p, ul {
margin-top: 0;
}
li {
margin-left: -1em;
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%220.6em%22%20height%3D%220.6em%22%3E%3Ccircle%20cx%3D%228%22%20cy%3D%228%22%20r%3D%224%22%20%2F%3E%3C%2Fsvg%3E%0D%0A");
}
li.plus {
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%220.6em%22%20height%3D%220.6em%22%3E%3Cpath%20d%3D%22M7%200h2v7h7v2h-7v7h-2v-7h-7v-2h7z%22%2F%3E%3C%2Fsvg%3E");
}
li.minus {
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%220.6em%22%20height%3D%220.6em%22%3E%3Cpath%20d%3D%22M0%207h16v2h-16z%22%2F%3E%3C%2Fsvg%3E%0D%0A");
}
pre, li {
font-family: inherit;
white-space: pre-line;
}
</style>
</head>
<body>
<h1>Änderungen</h1>
${formatChanges(changes.changes)}
<hr />
<h1>Alle Änderungen</h1>
${formatChanges(changes.fullChangelog)}
</body>
</html>`;
log(html);
const wv = new WebView();
wv.loadHTML(html);
await wv.present();
/** @type {MenuOption[]} */
const options = [
{
label: "Aktualisieren",
action: updateScript.bind(undefined, changes),
},
{
label: "Zurück",
action: mainMenu,
},
];
const a = new Alert();
a.addCancelAction("Abbrechen");
options.forEach((o) => a.addAction(o.label));
const res = await a.presentSheet();
if (res === -1) return;
return options[res].action();
/**
* @param {ChangelogItem[]} changes
* @returns {string}
*/
function formatChanges(changes) {
const isListRegex = /^([*:.<>|+-][ \t])/gim;
return changes
.map((change) => {
const isList = change.changes.match(isListRegex);
let notes = "";
if (isList) {
let lines = change.changes.replaceAll(
isListRegex,
(m, g1) =>
`</li><li${
g1.includes("+")
? ' class="plus"'
: g1.includes("-")
? ' class="minus"'
: ""
}>`,
);
if (lines.includes("<li")) lines += "</li>";
lines = lines.replace(/^\s*<\/li>/, "");
lines = lines.replace(/\n<\/li>/g, "</li>\n");
notes = `<ul>
${lines}
</ul>`;
} else {
notes = `<p><pre>${change.changes.replace(
/[<>&"]/g,
(m) => htmlEscape[m],
)}</pre></p>`;
}
return ` <h2>${change.version}</h2>
<h3>${changelogDateFormatter.string(change.date)},
${changelogRelativeFormatter.string(change.date, new Date())}</h3>
${notes}`;
})
.join("\n\n");
}
}
/**
* @param {UpdateCheckResult} changes
*/
async function updateScript(changes) {
if (!changes.updateAvailable) return;
const lang = {
updatedFiles: "Geänderte Dateien",
filesBeingImported:
"Diese Dateien werden im Scriptable-Ordner abgespeichert. Wähle die Dateien ab, die du nicht speichern möchtest.",
willOverwrite: "Überschreibt eine Datei",
update: "Aktualisieren",
cancel: "Abbrechen",
saved1File: "1 Datei wurde gespeichert",
savedNFiles: "$n Dateien wurden gespeichert",
};
/** @type {FileManager} */
let fm;
try {
fm = FileManager.iCloud();
} catch (err) {
fm = FileManager.local();
}
const files = changes.files;
// Check for files that are already there, but only with different content
const overridden = files.filter((f) => {
f.path = fm.joinPath(fm.documentsDirectory(), f.filename);
return fm.fileExists(f.path) && fm.readString(f.path) !== f.content;
});
const filesToSave = new Set(files);
const ui = new UITable();
refreshUI();
await ui.present();
function refreshUI() {
let row, cell;
ui.removeAllRows();
row = addRow();
row.isHeader = true;
cell = row.addText(lang.updatedFiles);
cell.centerAligned();
row = addRow();
row.height *= 2;
cell = row.addText(lang.filesBeingImported);
cell.centerAligned();
row = addRow();
for (const file of files) {
row = addRow();
row.dismissOnSelect = false;
row.onSelect = () => {
if (!filesToSave.delete(file)) filesToSave.add(file);
refreshUI();
};
cell = row.addText(
file.filename,
overridden.includes(file) ? lang.willOverwrite : "",
);
cell.widthWeight = 90;
cell = row.addText(filesToSave.has(file) ? "✅" : "");
cell.rightAligned();
cell.widthWeight = 10;
}
row = addRow();
row = addRow();
row.dismissOnSelect = true;
row.onSelect = saveFiles;
row.backgroundColor = new Color("#00ac00");
cell = row.addText(lang.update);
cell.centerAligned();
row = addRow();
row.dismissOnSelect = true;
// no-op
row.onSelect = () => {};
cell = row.addText(lang.cancel);
cell.centerAligned();
ui.reload();
}
function addRow() {
const row = new UITableRow();
ui.addRow(row);
return row;
}
/**
* @param {Set} filesToSave
*/
async function saveFiles(filesToSave) {
// save them to disk
filesToSave.forEach((file) => {
fm.writeString(file.path, file.content);
});
const cache = `${cacheFolder}/update.json`;
if (fm.fileExists(cache)) fm.remove(cache);
// notify the user
const n = new Notification();
n.title = Script.name();
n.body =
filesToSave.size === 1
? lang.saved1File
: lang.savedNFiles.replace("$n", filesToSave.size);
n.sound = null;
n.schedule();
// This is just to add some delay, before we remove the notification again
await new Promise((resolve) => {
Timer.schedule(500, false, resolve);
});
Notification.removeDelivered([n.identifier]);
}
}
/**
* get images from iCloud or download them once
* @param {string} image
* @returns {Image}
*/
async function getImage(image) {
let fm = FileManager.local();
let dir = fm.joinPath(fm.documentsDirectory(), "vaccination-stats-images");
fm.createDirectory(dir, true);
let path = fm.joinPath(dir, image);
if (fm.fileExists(path)) {
return fm.readImage(path);
} else {
// download once
let imageUrl;
switch (image) {
case "vac-logo.png":
imageUrl = "https://i.imgur.com/ZsBNT8E.png";
break;
case "calendar-icon.png":
imageUrl = "https://i.imgur.com/Qp8CEFf.png";
break;
default:
console.log(`Sorry, couldn't find ${image}.`);
}
let req = new Request(imageUrl);
let loadedImage = await req.loadImage();
fm.writeImage(path, loadedImage);
return loadedImage;
}
}
async function getNumbers() {
// Set up the file manager.
const fm = FileManager.local();
{
// delete old cache file if it exists
const oldCache = fm.joinPath(
fm.cacheDirectory(),
"vaccination-stats-data.csv",
);
if (fm.fileExists(oldCache)) {
fm.remove(oldCache);
}
}
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const formatter = new DateFormatter();
formatter.dateFormat = "yyyyMMdd";
/**
* @type { {
* debugName: string,
* cacheName: string,
* url?: string,
* method?: () => Promise<UpdateCheckResult>,
* type: "string" | "json",
* file: null | string | UpdateCheckResult | Promise<string | UpdateCheckResult>
* }[] }
*/
const files = [
{
debugName: "today",
cacheName: "today.csv",
url: "https://info.gesundheitsministerium.gv.at/data/COVID19_vaccination_certificates.csv",
type: "string",
file: null,
},
{
debugName: "week ago",
cacheName: "weeg-ago.csv",
url: `https://info.gesundheitsministerium.gv.at/data/archiv/COVID19_vaccination_certificates_${formatter.string(
weekAgo,
)}.csv`,
type: "string",
file: null,
},
{
debugName: "update check",
cacheName: "update.json",
method: checkForUpdate,
type: "json",
file: null,
},
];
log("checking folder at " + cacheFolder);
if (!fm.fileExists(cacheFolder)) fm.createDirectory(cacheFolder, true);
for (const file of files) {
// Set up cache
const cachePath = `${cacheFolder}/${file.cacheName}`;
const cacheExists = fm.fileExists(cachePath);
const cacheDate = cacheExists ? fm.modificationDate(cachePath) : 0;
// Get Data
try {
// If cache exists and it's been less than 60 minutes since last request, use cached data.
if (
cacheExists
&& today.getTime() - cacheDate.getTime() < cacheMinutes * 60 * 1000
) {
console.log(`Get data for ${file.debugName} from Cache`);
file.file = fm.readString(cachePath);
if (file.type === "json") {
file.file = JSON.parse(file.file, (key, value) => {
if (key === "date") return new Date(value);
return value;
});
}
} else {
console.log(`Get data for ${file.debugName} from API`);
if (file.url) {
const req = new Request(file.url);
file.file = req.loadString().then((content) => {
content = content.replace(/\r\n|\r/g, "\n");
console.log(`Write data for ${file.debugName} to Cache`);
try {
fm.writeString(cachePath, content);
} catch (e) {
console.error(
`Creating cache for data for ${file.debugName} failed!`,
);
console.error(e);
}
return content;
});
} else if (file.method) {
file.file = file.method().then((content) => {
if (!content) {
console.log(`Got no data for ${file.debugName}`);
return;
}
console.log(`Write data for ${file.debugName} to Cache`);
try {
fm.writeString(cachePath, JSON.stringify(content));
} catch (e) {
console.error(
`Creating cache for data for ${file.debugName} failed!`,
);
console.error(e);
}
return content;
});
}
}
} catch (e) {
console.error(`Error loading data for ${file.debugName}`);
console.error(e);
if (cacheExists) {
console.log(`Get data for ${file.debugName} from Cache`);
file.file = fm.readString(cachePath);
if (file.type === "json") {
file.file = JSON.parse(file.file, (key, value) => {
if (key === "date") return new Date(value);
return value;
});
}
} else {
console.log(
`No fallback to cache possible due to missing cache for data for ${file.debugName}.`,
);
}
}
}
await Promise.all(
files.map(async (file) => {
const content = await file.file;
file.file = content;
}),
);
return {
data: parseCSV(
...files.filter((file) => file.type === "string").map((file) => file.file),
),
/** @type {UpdateCheckResult} */
update: files.find((file) => file.type === "json")?.file || {
updateAvailable: false,
},
};
}
/**
* @param {string} todayCSV
* @param {string} weekAgoCSV */
function parseCSV(todayCSV, weekAgoCSV) {
const parse = (file) => {
const data = [];
let names = [];
for (const line of file.split("\n")) {
const tokens = line.split(";");
if (names.length === 0) {
names = tokens;
} else {
data.push(
Object.fromEntries(tokens.map((token, i) => [names[i], token])),
);
}
}
return data;
};
/**
* @type { {data: Record<keyof CSVItem, string>[], states: CSVItem[]}[] }
*/
const data = [
{
data: parse(todayCSV),
states: [],
},
{
data: parse(weekAgoCSV),
states: [],
},
];
for (const d of data) {
for (let i = 0; i < d.data.length; i++) {
const item = d.data[i];
if (
item.state_id === "0"
|| (item.age_group !== "All" && item.gender !== "All")
)
continue;
d.states.push({
date: new Date(item.date),
state_id: parseInt(item.state_id),
state_name: item.state_name,
age_group: item.age_group,
gender: item.gender,
population: parseInt(item.population),
valid_certificates: parseInt(item.valid_certificates),
valid_certificates_percent: parseFloat(item.valid_certificates_percent),
});
}
}
const latest = data[0].states[0].date;
const latestWeekAgo = data[1].states[0].date;
return {
nCitizens: latest.population,
states: data[0].states.map((state, index) => {
return {
today: state,
weekAgo: data[1].states[index],
};
}),
daysBetweenLatest7DaysAgo: Math.round(
(latest.getTime() - latestWeekAgo.getTime()) / 86400000, // 1000 * 3600 * 24 = 86400000
),
date: latest,
};
}
/**
* @param {(CovidData["states"][0] | undefined)[]} states
* @returns the rendered progress bar together with its minimum and maximum values
*/
function createProgress(states) {
const context = new DrawContext();
context.size = new Size(progressBarWidth, progressBarHeight);
context.opaque = false;
context.respectScreenScale = true;
context.setFillColor(new Color("#d2d2d7"));
const path = new Path();
path.addRoundedRect(
new Rect(0, 0, progressBarWidth, progressBarHeight),
3,
2,
);
context.addPath(path);
context.fillPath();
const minPercent = states.reduce(
(prev, cur) => Math.min(prev, cur.today.valid_certificates_percent),
100,
);
// calc lower boundary, but clamp it to 80 and below
let min = Math.floor(clamp(minPercent, 0, 80) / 10) * 10;
const maxPercent = states.reduce(
(prev, cur) => Math.max(prev, cur.today.valid_certificates_percent),
0,
);
// calc upper boundary, but clamp it to 80 and above
let max = Math.ceil(clamp(maxPercent, 80, 100) / 10) * 10;
// expand the boundaries if they're too close to the values
const threshold = 0.1;
if (min > 0 && (minPercent - min) / (max - min) < threshold) {
min -= 10;
}
if (max < 100 && (maxPercent - min) / (max - min) > 1 - threshold) {
max += 10;
}
const statesWithColor = states
.map((s, i) => {
return s
? {
...s,
color: colors[i % colors.length],
}
: undefined;
})
.filter((s) => s);
// sort descending => smallest gets drawn latest
statesWithColor.sort(
(a, b) =>
b.today.valid_certificates_percent - a.today.valid_certificates_percent,
);
for (let i = 0; i < statesWithColor.length; i++) {
const state = statesWithColor[i];
context.setFillColor(state.color);
const path2 = new Path();
const path2width = Math.max(
Math.min(
((state.today.valid_certificates_percent - min) / (max - min))
* progressBarWidth,
progressBarWidth,
),
2,
);
path2.addRoundedRect(new Rect(0, 0, path2width, progressBarHeight), 3, 2);
context.addPath(path2);
context.fillPath();
}
return {
image: context.getImage(),
min,
max,
};
}
/**
* @param {CovidData["states"][0]} data
* @param {number} daysBetween number of days between data from a week ago and latest data
*/
function calculateDailyVac(data, daysBetween) {
const dailyVacAmount = Math.round(
(data.today.valid_certificates - data.weekAgo.valid_certificates)
/ daysBetween,
);
return dailyVacAmount;
}
/**
* @param {CovidData["states"][0]} data
* @param {number} daysBetween number of days between data from a week ago and latest data
*/
function calculateRemainingDays(data, daysBetween) {
const daysRemaining = Math.round(
(data.today.population * herdImmunityFactor
- data.today.valid_certificates)
/ calculateDailyVac(data, daysBetween),
);
return daysRemaining;
}
/**
* @param {Color} color
* @param {Color} otherColor
* @param {number} amount
* @param {number | undefined} [alpha]
* @returns {Color}
*/
function adjustColor(color, otherColor, amount, alpha = undefined) {
let red = color.red * (1 - amount) + otherColor.red * amount;
let green = color.green * (1 - amount) + otherColor.green * amount;
let blue = color.blue * (1 - amount) + otherColor.blue * amount;
// let a = color.alpha * (1 - amount) + otherColor.alpha * amount;
if (red < 0) red = 0;
else if (red > 1) red = 1;
if (green < 0) green = 0;
else if (green > 1) green = 1;
if (blue < 0) blue = 0;
else if (blue > 1) blue = 1;
// if (a < 0) a = 0;
// else if (a > 1) a = 1;
red = Math.round(red * 255);
green = Math.round(green * 255);
blue = Math.round(blue * 255);
const hex =
red.toString(16).padStart(2, "0")
+ green.toString(16).padStart(2, "0")
+ blue.toString(16).padStart(2, "0");
return typeof alpha !== "undefined" ? new Color(hex, alpha) : new Color(hex);
}
/**
* Rounds `value` to a precision of `decimals`
* @param {number} value
* @param {number} decimals
* @returns {number}
*/
function round(value, decimals) {
return parseFloat(Math.round(value * 10 ** decimals) * 10 ** -decimals);
}
/**
* Clamp `value` so that `min <= value <= max`
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* @typedef { {version: string, date: Date, changes: string} } ChangelogItem
*/
/**
* @typedef { {updateAvailable: false}
* | {
* updateAvailable: true,
* curVersion: string,
* newVersion: string,
* changes: ChangelogItem[],
* fullChangelog: ChangelogItem[],
* files: { filename: string, content: string, path?: string }[]
* } } UpdateCheckResult
*/
/**
* @returns {Promise<UpdateCheckResult>}
*/
async function checkForUpdate() {
const regex = {
comments: /\/\*((?:(?!\*\/)[\s\S])+)\*\//g,
version: /^[ \t*+.:<>|-]*version\s*:?\s*(\d+(?:\.\d+)+)\s*$/im,
sourceUrl: /^[ *+.:<>|-]*source\s*:?\s*(https?:\/\/.+)\s*$/im,
gistUrl: /^https:\/\/gist.github.com\/[^/]+\/([0-9a-f]+)$/i,
changelog: /^[ \t*+.:<>|-]*changelog\s*:?\s*$/im,
changelogVersion:
/^(?<indent>[ \t*+.:<>|-]*)v(?<version>\d+(?:\.\d+)+)\s*-\s*(?<date>\d{4}-\d\d-\d\d)\s*$/gim,
escape: /[\\[\]+*?.|(){}^$]/g,
endsWhitespace: /[ \t]+$/,
};
let fm;
try {
fm = FileManager.iCloud();
} catch (err) {
fm = FileManager.local();
}
const self = fm.readString(module.filename);
const selfComments = self.matchAll(regex.comments);
let currentVersion = undefined;
let sourceUrl = undefined;
for (const comment of selfComments) {
const commentContent = comment[1];
if (!currentVersion) {
const v = commentContent.match(regex.version);
if (v) currentVersion = v[1];
}
if (!sourceUrl) {
const u = commentContent.match(regex.sourceUrl);
if (u) sourceUrl = u[1];
}
if (currentVersion && sourceUrl) break;
}
if (!currentVersion) {
console.error(
'[checkForUpdate] Could not find the current version. Make sure that it is written like "version: 1.0.1" in a multiline comment on its own line. Not checking for an update!',
);
return;
}
if (!sourceUrl) {
console.error(
'[checkForUpdate] Could not find a source url. Make sure that it is written like "source: https://myhost.com/my-script.js" in a multiline comment on its own line. It can also be a GitHub Gist URL. Not checking for an update!',
);
return;
}
const gist = sourceUrl.match(regex.gistUrl);
/** @type { {filename: string, content: string}[] } */
const files = [];
// files.push({
// filename: Script.name() + ".js",
// content: self.replace("Version: 1.3", "Version: 2.0"),
// });
if (gist) {
files.push(...(await downloadGist(gist[1])));
} else {
const req = new Request(sourceUrl);
const res = await req.loadString();
if ((req.response.statusCode < 200) | (req.response.statusCode >= 300)) {
console.error(`[checkForUpdate] There was an error while downloading the script source code. Status: ${
req.response.statusCode
}
${JSON.stringify(req.response, null, 2)}
Response: ${res}`);
} else {
files.push({
filename: sourceUrl.split("/").pop(),
content: res,
});
}
}
if (files.length === 0) {
console.log("[checkForUpdate] No files found");
return;
}
let remoteVersion = undefined;
/** @type { ChangelogItem[] } */
let remoteChangelog = undefined;
fileLoop: for (const file of files) {
const comments = Array.from(file.content.matchAll(regex.comments));
if (!comments || comments.length === 0) continue;
for (const comment of comments) {
const commentContent = comment[1];
if (!remoteVersion) {
const v = commentContent.match(regex.version);
if (v) {
remoteVersion = v[1];
const curParts = currentVersion.split(".").map((i) => parseInt(i));
const remoteParts = remoteVersion.split(".").map((i) => parseInt(i));
const length = Math.max(curParts.length, remoteParts.length);
for (let i = curParts.length; i < length; i++) {
curParts.push(0);
}
for (let i = remoteParts.length; i < length; i++) {
remoteParts.push(0);
}
let isNewer = false;
for (let i = 0; i < length; i++) {
if (remoteParts[i] > curParts[i]) {
isNewer = true;
break;
}
}
if (!isNewer) {
return { updateAvailable: false };
}
}
}
if (regex.changelog.test(commentContent)) {
const headings = Array.from(
commentContent.matchAll(regex.changelogVersion),
);
remoteChangelog = [];
for (let i = 0; i < headings.length; i++) {
const cur = headings[i];
const next =
i + 1 < headings.length
? headings[i + 1].index
: commentContent.length;
const start = cur.groups.indent;
const removeIndentRegex = new RegExp(
`^${start.replace(regex.escape, "\\$&")}${
regex.endsWhitespace.test(start) ? "?" : ""
}`,
"gm",
);
remoteChangelog.push({
version: cur.groups.version,
date: new Date(cur.groups.date),
changes: commentContent
.substring(cur.index + cur[0].length, next)
.replace(removeIndentRegex, "")
.trim()
.split("\n")
.map((s) => s.trim())
.join("\n"),
});
}
}
if (remoteVersion && remoteChangelog.length) break fileLoop;
}
}
if (!remoteVersion) return { updateAvailable: false };
return {
updateAvailable: true,
curVersion: currentVersion,
newVersion: remoteVersion,
changes: remoteChangelog.slice(
0,
remoteChangelog.findIndex((change) => change.version === currentVersion),
),
fullChangelog: remoteChangelog,
files,
};
}
/** @param {string} gistID */
async function downloadGist(gistID) {
// Get the data in the Gist
let req = new Request(`https://api.github.com/gists/${gistID}`);
req.headers = {
accept: "application/vnd.github.v3+json",
};
let res = await req.loadJSON();
if (req.response.statusCode === 403) {
console.error(
"[checkForUpdate] The source URL points to a private GitHub Gist. Cannot download from there.",
);
return [];
}
if (req.response.statusCode === 404) {
console.error(
"[checkForUpdate] The GitHub Gist was not found that is referenced by the source URL.",
);
return [];
}
let files = Object.values(res.files);
// await QuickLook.present(files);
if (files.length > 5) {
// more than 5 files? ask the user
let a = new Alert();
a.title = res.truncated ? "Zu viele Dateien" : `${files.length} Dateien`;
a.message = `Es werden ${
res.truncated ? "mehr als 300" : files.length
} Dateien heruntergeladen. ${
res.truncated ? "Nur die ersten 300 Dateien können geladen werden." : ""
}
Möchtest du fortfahren?`;
a.addCancelAction("Abbrechen");
a.addAction("Weiter");
if ((await a.presentAlert()) === -1) return [];
}
// Get the content of each file
let contents = await Promise.all(
files.map((file) => {
// if it is truncated, request its contents
if (file.truncated) {
return new Request(file.raw_url).loadString();
}
// otherwise just return them
return Promise.resolve(file.content);
}),
);
// update the content in the files array
contents.forEach((c, i) => (files[i].content = c));
return files.map((f) => {
return {
filename: f.filename,
content: f.content,
};
});
}
/***************************
* Bitte bis hier kopieren *
***************************/
@schl3ck
Copy link
Author

schl3ck commented Jul 7, 2021

Kein Problem!

Ganz oben steht Forked from... Dieses ist für Deutschland.

@GitAxMan
Copy link

Script ist top - habe allerdings ein Phänomen: funktioniert nur in der App (config.runsInApp) aber als Widget auf dem Homescreen tut sich gar nichts...

@GitAxMan
Copy link

Script ist top - habe allerdings ein Phänomen: funktioniert nur in der App (config.runsInApp) aber als Widget auf dem Homescreen tut sich gar nichts...

Habe nur ich dieses Problem??

@schl3ck
Copy link
Author

schl3ck commented Nov 19, 2021

Nein, hast du nicht. Auch bei mir funktioniert es am Homescreen nicht, aber in der App schon. Ich hatte aber bis jetzt leider keine Zeit, mir das anzuschauen, hoffentlich in den nächsten Tagen. Ich melde mich, wenn ich die Ursache gefunden hab!

Es gibt auch ein neues Datenformat. Ich werde übers Wochenende versuchen, auf das neue Format umzusteigen.

@GitAxMan
Copy link

Nein, hast du nicht. Auch bei mir funktioniert es am Homescreen nicht, aber in der App schon. Ich hatte aber bis jetzt leider keine Zeit, mir das anzuschauen, hoffentlich in den nächsten Tagen. Ich melde mich, wenn ich die Ursache gefunden hab!

Es gibt auch ein neues Datenformat. Ich werde übers Wochenede versuchen, auf das neue Format umzusteigen.

OK - dachte schon, ich bin ein Einzel-Problem :-)
Super, danke schon mal im Voraus für's Ansehen - vielleicht finde ich auch was.....

@schl3ck
Copy link
Author

schl3ck commented Nov 19, 2021

OK - dachte schon, ich bin ein Einzel-Problem :-) Super, danke schon mal im Voraus für's Ansehen - vielleicht finde ich auch was.....

Ich vermute stark, dass die Datenquelle zu groß ist fürs Widget. Sind immerhin 1,23 MB. Mit den neuen sollte es dann aber wieder funktionieren.

@GitAxMan
Copy link

OK - dachte schon, ich bin ein Einzel-Problem :-) Super, danke schon mal im Voraus für's Ansehen - vielleicht finde ich auch was.....

Ich vermute stark, dass die Datenquelle zu groß ist fürs Widget. Sind immerhin 1,23 MB. Mit den neuen sollte es dann aber wieder funktionieren.

@schl3ck - Du hast richtig vermutet, die Datenquelle ist/war zu groß....
Ich habe es einmal quick&dirty gelöst, indem ich nur die letzten 200 Zeilen des CSV-Files verwende:

function parseCSV(csv) {
const data = [];
let names = [];
const lines = csv.split("\n");
for (const line of csv.split("\n")) {
const tokens = line.split(";");
if (names.length === 0) {
names = tokens;
} else if (lines.indexOf(line) > (lines.length - 200)){
data.push(
Object.fromEntries(tokens.map((token, i) => [names[i], token])),
);
}
}

@schl3ck
Copy link
Author

schl3ck commented Nov 22, 2021

@GitAxMan Ich habe das Skript schon aktualisiert, sodass es die neue Datenquelle verwendet, die die aktuell gültigen Impfzertifikate liefert. Die alte Datenquelle wird im Dezember eingestellt und dadurch, dass bald Impfungen "auslaufen", dachte ich mir, dass die neue Quelle die aktuelle Situation besser reflektiert.

Ein weiteres Update ist bereits in Arbeit, womit dann auch die Zahlen für die Bundesländer dargestellt bzw. die am Widget angezeigten Elemente konfiguriert werden können.

@GitAxMan
Copy link

@GitAxMan Ich habe das Skript schon aktualisiert, sodass es die neue Datenquelle verwendet, die die aktuell gültigen Impfzertifikate liefert. Die alte Datenquelle wird im Dezember eingestellt und dadurch, dass bald Impfungen "auslaufen", dachte ich mir, dass die neue Quelle die aktuelle Situation besser reflektiert.

Ein weiteres Update ist bereits in Arbeit, womit dann auch die Zahlen für die Bundesländer dargestellt bzw. die am Widget angezeigten Elemente konfiguriert werden können.

@schl3ck Super - danke!

@schl3ck
Copy link
Author

schl3ck commented Dec 2, 2021

Ich habe das Skript aktualisiert und einen Updater sowie einen Widget-Konfigurator hinzugefügt. Mit dem Konfigurator kann man auswählen, welche Bundesländer angezeigt werden sollen. Ebenso kann man festlegen, ob der Fortschrittsbalken, die Herdenimmunität und der 7-Tages-Durchschnitt angezeigt werden sollen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment