├── .github └── workflows │ └── main.yml ├── README.md ├── screenshot.jpg ├── screenshot_extended.jpg ├── widget.js └── widget.min.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the main branch 7 | on: 8 | push: 9 | branches: [ main ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so auto-minify job can access it 21 | - uses: actions/checkout@v2 22 | 23 | - name: Auto Minify 24 | uses: nizarmah/auto-minify@master 25 | 26 | # Auto commits minified files to the repository 27 | # Ignore it if you don't want to commit the files to the repository 28 | - name: Auto committing minified files 29 | uses: stefanzweifel/git-auto-commit-action@v3.0.0 30 | with: 31 | commit_message: "Auto minify: ${{ github.event.head_commit.message }}" 32 | branch: ${{ github.ref }} 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # its-beds-widget 2 | 3 | Simple layout: 4 | ![Screenshot](screenshot.jpg "New Screenshot") 5 | Extended layout: 6 | ![Extended Screenshot](screenshot_extended.jpg "Extended Screenshot") 7 | 8 | This is a widget for [Scriptable](https://scriptable.app). To use this widget, add a new script to Scriptable, and insert this code: 9 | 10 | ``` 11 | // Licence: juliankern.com; CC BY 3.0 DE 12 | const C = { layout: 'simple' }; 13 | (async () => new Function(await new Request('https://cdn.jsdelivr.net/gh/Keyes/its-beds-widget/widget.min.js').loadString())(C))(); 14 | ``` 15 | 16 | This will load the current version, and keep it updated. 17 | 18 | ## Features 19 | - Shows the situation of ITS beds in your current state (germany only), as well as in the whole country 20 | - Add your state **short code** as parameter to change the displayed state (for short codes see below) 21 | - Shows the **timestamp** of the last update - official updates happen usually once per hour 22 | - Multiple **layouts** (see config) 23 | - Shows **trend arrows** how the free beds changed compared to yesterday 24 | - trend data is stored locally on your device or in your iCloud 25 | - additionally shows relative mount of beds added/removed in extended layout 26 | - trend arrows/numbers appear after the first day of usage 27 | 28 | ### Config 29 | The following options are possible 30 | - layout 31 | - 'simple': default layout (default option, see first screenshot) 32 | - 'extended': shows additionally absolute numbers of beds (see second screenshot) 33 | 34 | ### List of state short codes 35 | - Baden-Württemberg: BW 36 | - Bayern: BY 37 | - Berlin: BE 38 | - Brandenburg: BB 39 | - Bremen: HB 40 | - Hamburg: HH 41 | - Hessen: HE 42 | - Mecklenburg-Vorpommern: MV 43 | - Niedersachsen: NI 44 | - Nordrhein-Westfalen: NRW 45 | - Rheinland-Pfalz: RP 46 | - Saarland: SL 47 | - Sachsen: SN 48 | - Sachsen-Anhalt: ST 49 | - Schleswig-Holstein: SH 50 | - Thüringen: TH 51 | 52 | ## Development 53 | if you want to check out the development version of this widget, you can use this code: 54 | ``` 55 | // Licence: juliankern.com; CC BY 3.0 DE 56 | const C = { layout: 'simple' }; 57 | (async () => new Function(await new Request('https://raw.githubusercontent.com/Keyes/its-beds-widget/dev/widget.js').loadString())(C))(); 58 | ``` 59 | **Beware** Please note that this version might not always work as expected! -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Keyes/its-beds-widget/5e7ab01d15a514fdccdc26403a056aa40db2895e/screenshot.jpg -------------------------------------------------------------------------------- /screenshot_extended.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Keyes/its-beds-widget/5e7ab01d15a514fdccdc26403a056aa40db2895e/screenshot_extended.jpg -------------------------------------------------------------------------------- /widget.js: -------------------------------------------------------------------------------- 1 | /* Licence: juliankern.com; CC BY 3.0 DE */ 2 | const apiUrlBase = 'https://intensiv-widget.juliankern.com/beds'; 3 | const getApiUrl = (location, state) => { 4 | if (location) { return `${apiUrlBase}?lat=${location.latitude.toFixed(3)}&lng=${location.longitude.toFixed(3)}`; } 5 | if (state) { return `${apiUrlBase}?state=${state}`; } 6 | return apiUrlBase; 7 | } 8 | 9 | const defaultCfg = { 10 | layout: 'simple' 11 | }; 12 | 13 | const CONFIG = Object.assign({}, defaultCfg, arguments[0]); 14 | 15 | init(); 16 | 17 | async function init() { 18 | if (CONFIG.debug) console.log('init called'); 19 | const widget = await createWidget(); 20 | if (!config.runsInWidget) await widget.presentSmall(); 21 | 22 | Script.setWidget(widget); 23 | Script.complete(); 24 | 25 | if (CONFIG.debug) console.log('complete'); 26 | } 27 | 28 | async function createWidget(items) { 29 | if (CONFIG.debug) console.log('createWidget called'); 30 | 31 | const data = await getData(); 32 | const list = new ListWidget(); 33 | // list.setPadding(20,-20,20,-20); 34 | 35 | if (CONFIG.debug) console.log('data received'); 36 | 37 | const header = newStack(list, 4); 38 | 39 | let iconColor = Color.black(); 40 | if(Device.isUsingDarkAppearance()) iconColor = Color.white(); 41 | addIcon('stethoscope', header, 13, iconColor); 42 | 43 | const headerText = header.addText('Freie ITS-Betten'); 44 | headerText.font = Font.mediumSystemFont(12); 45 | 46 | if (CONFIG.debug) console.log('base constructed'); 47 | 48 | if (data) { 49 | list.addSpacer(); 50 | 51 | let weekData = { 52 | overall: saveLoadData(data.overall, 'DE') 53 | }; 54 | 55 | if (data.state) { 56 | weekData.state = saveLoadData(data.state, data.state.shortName); 57 | 58 | if (CONFIG.debug) { 59 | console.log('render state datablock'); 60 | console.log(data.state); 61 | console.log(weekData.state); 62 | } 63 | 64 | renderDatablock(list, data.state, weekData.state); 65 | 66 | list.addSpacer(4); 67 | } 68 | 69 | if (CONFIG.debug) { 70 | console.log('render overall datablock'); 71 | console.log(data.overall); 72 | console.log(weekData.overall); 73 | } 74 | 75 | renderDatablock(list, data.overall, weekData.overall); 76 | 77 | list.refreshAfterDate = new Date(Date.now() + (1000 * 60 * 30)); 78 | 79 | if (CONFIG.debug) console.log('render updated block'); 80 | 81 | list.addSpacer(6); 82 | const dateFormatter = new DateFormatter(); 83 | dateFormatter.useShortDateStyle(); 84 | dateFormatter.useShortTimeStyle(); 85 | 86 | const updatedLabel = list.addText(`↻ ${dateFormatter.string(new Date(data.overall.updated))}`); 87 | updatedLabel.font = Font.regularSystemFont(9); 88 | updatedLabel.textColor = Color.gray(); 89 | } else { 90 | list.addSpacer(); 91 | list.addText("Daten nicht verfügbar"); 92 | } 93 | 94 | return list; 95 | } 96 | 97 | function renderDatablock(list, data, weekData) { 98 | const percentLabel = newStack(list, 4); 99 | const datablockColor = getPercentageColor(data.used); 100 | 101 | if (CONFIG.debug) console.log('render percentLabel'); 102 | 103 | // const label = percentLabel.addText(`${data.used.toFixed(2)}% ${getBedsTrend(data, weekData)}`); 104 | const label = percentLabel.addText(`${data.used.toFixed(2)}%`); 105 | label.font = Font.mediumSystemFont(22); 106 | label.textColor = datablockColor; 107 | 108 | const trendIconName = getBedsTrendIcon(data, weekData); 109 | 110 | if (CONFIG.debug) { 111 | console.log('render trend icon'); 112 | console.log(trendIconName); 113 | } 114 | 115 | if (trendIconName) { 116 | addIcon(trendIconName, percentLabel, 15, datablockColor); 117 | } 118 | 119 | if (CONFIG.debug) console.log('render number stack'); 120 | 121 | const bedsLabel = newStack(list, 2); 122 | 123 | if (CONFIG.layout === 'extended') { 124 | if (CONFIG.debug) console.log('render extended datablock'); 125 | 126 | const location = bedsLabel.addText((data.shortName || 'DE')); 127 | location.font = Font.semiboldSystemFont(10); 128 | // location.textColor = Color.lightGray(); 129 | 130 | if (CONFIG.debug) console.log('absolute numbers'); 131 | 132 | const absoluteLabel = bedsLabel.addText(`${data.absolute.free}/${data.absolute.total}`); 133 | absoluteLabel.font = Font.mediumSystemFont(10); 134 | absoluteLabel.textColor = datablockColor; 135 | 136 | const bedTrendsAbsolute = getBedsTrendAbsolute(data, weekData); 137 | 138 | if (CONFIG.debug) { 139 | console.log('relative number'); 140 | console.log(bedTrendsAbsolute); 141 | } 142 | 143 | const relativeLabel = bedsLabel.addText(bedTrendsAbsolute); 144 | relativeLabel.font = Font.mediumSystemFont(10); 145 | relativeLabel.textColor = Color.gray(); 146 | } else { 147 | if (CONFIG.debug) console.log('render simple datablock'); 148 | 149 | const location = bedsLabel.addText(data.name || 'Deutschland'); 150 | location.font = Font.lightSystemFont(12); 151 | } 152 | 153 | if (CONFIG.debug) console.log('render datablock complete'); 154 | } 155 | 156 | function getPercentageColor(value) { 157 | return value <= 25 ? Color.red() : value <= 50 ? Color.orange() : Color.green(); 158 | } 159 | 160 | async function getData() { 161 | try { 162 | if (CONFIG.debug) { 163 | console.log('try getting data'); 164 | console.log(getApiUrl(null, args.widgetParameter)); 165 | } 166 | 167 | let foundData; 168 | 169 | if (args.widgetParameter) { 170 | foundData = await new Request(getApiUrl(null, args.widgetParameter)).loadJSON(); 171 | } else { 172 | const location = await getLocation(); 173 | foundData = await new Request(getApiUrl(location)).loadJSON(); 174 | } 175 | 176 | return foundData; 177 | } catch (e) { 178 | if (CONFIG.debug) { 179 | console.log('error getting data'); 180 | console.log(e); 181 | } 182 | 183 | return null; 184 | } 185 | } 186 | 187 | async function getLocation() { 188 | try { 189 | if (CONFIG.debug) console.log('try getting location'); 190 | 191 | Location.setAccuracyToThreeKilometers(); 192 | return await Location.current(); 193 | } catch (e) { 194 | if (CONFIG.debug) { 195 | console.log('error getting location'); 196 | console.log(e); 197 | } 198 | 199 | return null; 200 | } 201 | } 202 | 203 | function getBedsTrend(data, weekdata) { 204 | let bedsTrend = ' '; 205 | 206 | if (Object.keys(weekdata).length > 0) { 207 | const prevData = getDataForDate(weekdata); 208 | 209 | if (prevData) bedsTrend = (data.absolute.free === prevData.absolute.free) ? ' ' : ((data.absolute.free < prevData.absolute.free) ? '↓' : '↑'); 210 | } 211 | 212 | return bedsTrend; 213 | } 214 | 215 | function getBedsTrendIcon(data, weekdata) { 216 | if (Object.keys(weekdata).length > 0) { 217 | const prevData = getDataForDate(weekdata); 218 | 219 | if (prevData) { 220 | if (data.absolute.free === prevData.absolute.free) return; 221 | if (data.absolute.free < prevData.absolute.free) return 'chevron.down'; 222 | else return 'chevron.up'; 223 | } 224 | } 225 | } 226 | 227 | function getBedsTrendAbsolute(data, weekdata) { 228 | if (Object.keys(weekdata).length > 0) { 229 | const prevData = getDataForDate(weekdata); 230 | 231 | if (CONFIG.debug) { 232 | console.log('getBedsTrendAbsolute'); 233 | console.log(prevData); 234 | } 235 | 236 | if (prevData) { 237 | let bedsTrend = (data.absolute.free - prevData.absolute.free); 238 | 239 | if (CONFIG.debug) { 240 | console.log(bedsTrend); 241 | } 242 | 243 | if (bedsTrend === 0) return ''; 244 | if (bedsTrend > 0) bedsTrend = `+${bedsTrend}`; 245 | 246 | return ` (${bedsTrend})`; 247 | } 248 | } 249 | 250 | return ''; 251 | } 252 | 253 | function getDataForDate(weekdata, yesterday = true, datestr = '') { 254 | let dateKey = datestr; 255 | let dayOffset = 1; 256 | const today = new Date(); 257 | const todayDateKey = `${today.getFullYear()}-${("0" + (today.getMonth() + 1)).slice(-2)}-${("0" + today.getDate()).slice(-2)}`; 258 | 259 | if (CONFIG.debug) { 260 | console.log('getDataForDate'); 261 | console.log(todayDateKey); 262 | console.log(Object.keys(weekdata)); 263 | } 264 | 265 | if (typeof weekdata[todayDateKey] === 'undefined') dayOffset = 2; 266 | 267 | if (yesterday) { 268 | today.setDate(today.getDate() - dayOffset); 269 | dateKey = `${today.getFullYear()}-${("0" + (today.getMonth() + 1)).slice(-2)}-${("0" + today.getDate()).slice(-2)}`; 270 | } 271 | 272 | if (CONFIG.debug) { 273 | console.log(dateKey); 274 | console.log('getDataForDate result:'); 275 | console.log(weekdata[dateKey]); 276 | } 277 | 278 | if (typeof weekdata[dateKey] !== 'undefined') return weekdata[dateKey]; 279 | 280 | return false; 281 | } 282 | 283 | function saveLoadData(newData, suffix = '') { 284 | const updated = newData.updated.substr(0, 10); 285 | const loadedData = loadData(suffix); 286 | 287 | if (loadedData) { 288 | loadedData[updated] = newData; 289 | 290 | const loadedDataKeys = Object.keys(loadedData); 291 | const lastDaysKeys = loadedDataKeys.slice(Math.max(Object.keys(loadedData).length - 7, 0)); 292 | 293 | let loadedDataLimited = {}; 294 | lastDaysKeys.forEach(key => loadedDataLimited[key] = loadedData[key]); 295 | 296 | const { fm, path } = getFM(suffix); 297 | fm.writeString(path, JSON.stringify(loadedDataLimited)) 298 | 299 | return loadedData; 300 | } 301 | 302 | return {}; 303 | } 304 | 305 | function loadData(suffix) { 306 | const { fm, path } = getFM(suffix); 307 | 308 | if (fm.fileExists(path)) { 309 | const data = fm.readString(path); 310 | return JSON.parse(data); 311 | } 312 | 313 | return {}; 314 | } 315 | 316 | function getFM(suffix) { 317 | let fm, path; 318 | 319 | try { 320 | fm = FileManager.iCloud(); 321 | path = getFilePath(fm, suffix); 322 | } catch (e) { 323 | fm = FileManager.local(); 324 | path = getFilePath(fm, suffix); 325 | } 326 | 327 | return { fm, path }; 328 | } 329 | 330 | function addIcon(iconName, parent, size, color) { 331 | const widgetIcon = SFSymbol.named(iconName); 332 | widgetIcon.applyFont(Font.mediumSystemFont(22)); 333 | 334 | const widgetIconImage = parent.addImage(widgetIcon.image); 335 | if (color) widgetIconImage.tintColor = color; 336 | widgetIconImage.imageSize = new Size(size, size); 337 | widgetIconImage.resizeable = false; 338 | } 339 | 340 | function newStack(parent, spacing) { 341 | const createdStack = parent.addStack(); 342 | createdStack.layoutHorizontally(); 343 | createdStack.centerAlignContent(); 344 | createdStack.setPadding(0, 0, 0, 0); 345 | createdStack.spacing = spacing; 346 | 347 | return createdStack; 348 | } 349 | 350 | function getFilePath(fm, suffix) { 351 | return fm.joinPath(fm.documentsDirectory(), `its-beds-${suffix}.json`) 352 | } 353 | -------------------------------------------------------------------------------- /widget.min.js: -------------------------------------------------------------------------------- 1 | const apiUrlBase="https://intensiv-widget.juliankern.com/beds",getApiUrl=(a,b)=>a?`${apiUrlBase}?lat=${a.latitude.toFixed(3)}&lng=${a.longitude.toFixed(3)}`:b?`${apiUrlBase}?state=${b}`:apiUrlBase,defaultCfg={layout:"simple"},CONFIG=Object.assign({},defaultCfg,arguments[0]);init();async function init(){CONFIG.debug&&console.log("init called");const a=await createWidget();config.runsInWidget||(await a.presentSmall()),Script.setWidget(a),Script.complete(),CONFIG.debug&&console.log("complete")}async function createWidget(){CONFIG.debug&&console.log("createWidget called");const a=await getData(),b=new ListWidget;CONFIG.debug&&console.log("data received");const c=newStack(b,4);let d=Color.black();Device.isUsingDarkAppearance()&&(d=Color.white()),addIcon("stethoscope",c,13,d);const e=c.addText("Freie ITS-Betten");if(e.font=Font.mediumSystemFont(12),CONFIG.debug&&console.log("base constructed"),a){b.addSpacer();let c={overall:saveLoadData(a.overall,"DE")};a.state&&(c.state=saveLoadData(a.state,a.state.shortName),CONFIG.debug&&(console.log("render state datablock"),console.log(a.state),console.log(c.state)),renderDatablock(b,a.state,c.state),b.addSpacer(4)),CONFIG.debug&&(console.log("render overall datablock"),console.log(a.overall),console.log(c.overall)),renderDatablock(b,a.overall,c.overall),b.refreshAfterDate=new Date(Date.now()+1800000),CONFIG.debug&&console.log("render updated block"),b.addSpacer(6);const d=new DateFormatter;d.useShortDateStyle(),d.useShortTimeStyle();const e=b.addText(`↻ ${d.string(new Date(a.overall.updated))}`);e.font=Font.regularSystemFont(9),e.textColor=Color.gray()}else b.addSpacer(),b.addText("Daten nicht verf\xFCgbar");return b}function renderDatablock(a,b,c){const d=newStack(a,4),e=getPercentageColor(b.used);CONFIG.debug&&console.log("render percentLabel");const f=d.addText(`${b.used.toFixed(2)}%`);f.font=Font.mediumSystemFont(22),f.textColor=e;const g=getBedsTrendIcon(b,c);CONFIG.debug&&(console.log("render trend icon"),console.log(g)),g&&addIcon(g,d,15,e),CONFIG.debug&&console.log("render number stack");const h=newStack(a,2);if("extended"===CONFIG.layout){CONFIG.debug&&console.log("render extended datablock");const a=h.addText(b.shortName||"DE");a.font=Font.semiboldSystemFont(10),CONFIG.debug&&console.log("absolute numbers");const d=h.addText(`${b.absolute.free}/${b.absolute.total}`);d.font=Font.mediumSystemFont(10),d.textColor=e;const f=getBedsTrendAbsolute(b,c);CONFIG.debug&&(console.log("relative number"),console.log(f));const g=h.addText(f);g.font=Font.mediumSystemFont(10),g.textColor=Color.gray()}else{CONFIG.debug&&console.log("render simple datablock");const a=h.addText(b.name||"Deutschland");a.font=Font.lightSystemFont(12)}CONFIG.debug&&console.log("render datablock complete")}function getPercentageColor(a){return 25>=a?Color.red():50>=a?Color.orange():Color.green()}async function getData(){try{CONFIG.debug&&(console.log("try getting data"),console.log(getApiUrl(null,args.widgetParameter)));let a;if(args.widgetParameter)a=await new Request(getApiUrl(null,args.widgetParameter)).loadJSON();else{const b=await getLocation();a=await new Request(getApiUrl(b)).loadJSON()}return a}catch(a){return CONFIG.debug&&(console.log("error getting data"),console.log(a)),null}}async function getLocation(){try{return CONFIG.debug&&console.log("try getting location"),Location.setAccuracyToThreeKilometers(),await Location.current()}catch(a){return CONFIG.debug&&(console.log("error getting location"),console.log(a)),null}}function getBedsTrend(a,b){let c=" ";if(0h[a]=e[a]);const{fm:i,path:j}=getFM(b);return i.writeString(j,JSON.stringify(h)),e}return{}}function loadData(a){const{fm:b,path:c}=getFM(a);if(b.fileExists(c)){const a=b.readString(c);return JSON.parse(a)}return{}}function getFM(a){let b,c;try{b=FileManager.iCloud(),c=getFilePath(b,a)}catch(d){b=FileManager.local(),c=getFilePath(b,a)}return{fm:b,path:c}}function addIcon(a,b,c,d){const e=SFSymbol.named(a);e.applyFont(Font.mediumSystemFont(22));const f=b.addImage(e.image);d&&(f.tintColor=d),f.imageSize=new Size(c,c),f.resizeable=!1}function newStack(a,b){const c=a.addStack();return c.layoutHorizontally(),c.centerAlignContent(),c.setPadding(0,0,0,0),c.spacing=b,c}function getFilePath(a,b){return a.joinPath(a.documentsDirectory(),`its-beds-${b}.json`)} --------------------------------------------------------------------------------