From e971163f71e71625f4004deb108d638b70e45a13 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:48:11 +0200 Subject: [PATCH] feat: PWA manifest, app icons, service worker with tests Co-Authored-By: Claude Fable 5.1 --- public/app.js | 15 ++++++ public/icons/icon-180.png | Bin 0 -> 11521 bytes public/icons/icon-192.png | Bin 0 -> 12216 bytes public/icons/icon-512.png | Bin 0 -> 24786 bytes public/icons/icon.svg | 7 +++ public/manifest.webmanifest | 16 ++++++ public/sw.js | 82 ++++++++++++++++++++++++++++++ tests/js/sw.test.js | 98 ++++++++++++++++++++++++++++++++++++ 8 files changed, 218 insertions(+) create mode 100644 public/icons/icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 tests/js/sw.test.js diff --git a/public/app.js b/public/app.js index a151310..034a30a 100644 --- a/public/app.js +++ b/public/app.js @@ -216,6 +216,19 @@ async function requestWakeLock() { } } +/* ---------- service worker ---------- */ + +function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('weerscherm: service worker', err)); + let reloading = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (reloading) return; + reloading = true; + location.reload(); + }); +} + /* ---------- start ---------- */ window.addEventListener('resize', fit); @@ -225,6 +238,7 @@ document.addEventListener('visibilitychange', () => { fit(); refresh(); requestWakeLock(); +registerServiceWorker(); } }); @@ -236,3 +250,4 @@ scheduleClock(); scheduleMidnight(); scheduleReload(); requestWakeLock(); +registerServiceWorker(); diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..b7a89963ef192200a896349930e92ce6b5911e15 GIT binary patch literal 11521 zcmd72byOTtkS{y~g9ex2Fi3!b;O-D0xI>U&!CeOT0fM``1P$))!QCZTa0nXQ$s@b_ z?e00>dH=sY=k~3x`c+j|_3iH4w<}CpQ3@TE7!?2jpvy>0sJ`~~{~9vl>t1Lak^Qv; znTsii0RS~IZ=Sv&ydHy%rBxLG08d%~0QMCCcz9*O4gdfbRsi785CGs$1po*fGFw#y zUj-7TS~6w|3IK*z8W{i#u>^o#Dd1}(er*5%d@c|G|Jnop@p9q*TUwtB|3CCU;HUXy zk5^chrMi~0mV!LLvAr$x7ZZCUQ)YKthkpQopgaF7X>02I1?+BXW9P*0E=2Vo2>w_4 zADe{={2vf!YauEv1!b_fJ=7G;&CJToN+pa627?8mCT9Gq5+DCd{q;zQ%EH;%fuDuN z&CQM3jf2@9YRh&h(04a`Lcq{^HJL=S2OVM*crL5~fbZP)i4AOM5%; zKf7Oy>|LCNsHpxq`tR*O&(qZ1@_#(pIsI2yuK}|Bld!Ncv$FiR?N?R7e_VcLOLtQn zEeT6oQ#+?uAHrO0?1KM+|Nlw;$K(If)c(I}{x8XYX$rFZ3*dhR^q<-K5AJKagi!@q z{yX);sLd?Wng9Spf{cWix;yYh4=Gc9{#v-MW7dl$0Gta)m1l(4k0Y1&@h^AYzsxn4{&Q zBW{JmAx=4RWfpNDP9efON&?{znu!%>iQp{){hRYdRdufik|yr@Qc|h5s_;dGJ3+Cb zUDjo~C2Q(4?XNy6l?)8>kYS%YZ){7J1T~+Mg5p!xq-H!R=!VIH;pdXBtGK&_q=7lw z0UA6EouG5g6q)IOl|t_|FW#Xf%h;hOuK3t^>NF(zZIM)}noU+_$6eV$*p$ zZRUvrk2q?St_39rn{u*9MWco8(;ccUw%8J}I}cYJT|ys$@D>=z%}xrxxk~`%w5cTc zp;RxWMhF5`T>Wi$togk)KxymICOO-F#b+|-=I$Qr@WsH-nty_GQ&IyflwO1FCKUaI zp>24Q+uw_KrrHfyZZkI4d5iFxqI__A%ReKqo71J#WJ$4CFny?*-8u8CtV4ji$W%r7 zHUFr2X`kEWfJy^U3(;5>lpZmzt$b=@dsK`IN?rzFkBW20>|^wcHCh-OLGblk540%< zt!{sn2B#Kvn@sexwYM_TN8pk*%#K=P*-e5b>D>jETf&xFjVjrRr4<^Y4es3aK+dR_^GLLD!krO?9GP<-%+T(Rv0N}Z z&JP}-ZtD{SqW_dT@6Q`iZ1~GMoJYFHe33D=qMII3N;~H;fTG~f=x>Md{rtb#M719c zFMa?gl?P*fH`rVsbu$}96Q$^O0lG=AX7DF@NWOE?51&i=fA8&nH*{l{yk?l~7N~xr zQwg@y67kpTIP&VV@C(1BU9O_^IsAKuE~<9!f2^fekHH_i2GeGgk=1;H&qX9KcLe3S z$Y}WyU1m;e;M94F+f`urzKN!5q9|f{TNJsmDB-T&qYIi4BrptK$Ksq}QKmC1Iebb= z9>V7}>m*U>CV|n4fhT~DXyU5 zEDQ;!+SSd8YO}c?=_;(Z57m4eDZz#4yc$r9^iOmr&D0kldLpG?*+9-PSym0(zlec8 z`E$v;k{DL_nt7x)>F{5~l%+6^dw|;OllQouic#Y-c#(dau*erZkvEAT~Iz_{dpI>?sLHT(dc~ExU&hRHDtiOaKM2}XxmpKL& z@xdABa4w3#n4&cwK>axCrt?cT4erkYmkd#S?DE8eVb6Po@b|t9vFJOtE3T^N{R|gR zN|pYzCSfBAKLP> z{mpG-I|_f4>KfqqxuBFImR<491GKo_zi%*IslI3GhLO z2`=4O6rdiU7V^ewB<-j20sBpsiGS^;sA?qO5Atd_I)$|4sZ9y9R*<#a9j6dd!8V;F zW^y-=-(nac7SH?Xz)!KD=G_%J=pa0&y_9T|xFN)!T}Hr@x(4PlFZ*&ldWC=*%2Cdz zr82R4NjS`xYJ^B8=cS_;ux1>sjpwi^w?U=RLdb<+XvuL(jxSHAd`55f(qZfb)7 zAPOcR`=29zGWWsSBVi?9+3ot+gHAe^_oT?0iFgYd?d84{BCEtb#tmfAG!W7JhF40c z7pAiy$FycUQJPl~Ea@K&B=+Se+(~(71{h=D2;_LQJ>C*KHlG$T=`+aTjzSfXd#k9)s zUHsfCkl7nW^rfcrJ+HPKWZce@_3;`I(u8zCZm39W-q_{pMlu#7M89DvT36U6=0h*G z_A_4N_8`DTK{dmNrf_tr^>QN+PutLQG{-3|@x70HU>4rc$QKgzje)L_6Sd8}7exgq z$j#PNc)3Se8JRT|d*&hQD@7_ENf-^5DLseU7})$%+Rsp;X|xD)(}UnFO40cTHox>a zycY{#Ac23xJG1K#oXc79a9%Y7%Ens|y=zpwYALpHg0i9M>dw9A96&{E!HxL5Yq`fc zp#ws+Ui*^_MDGTV6`c{)fEl4lNiykp+K-jp2^Wdh3^49n+Scrh8<6_)M&MKx*7Wuq zU%cmDF^&eNQ|v^_82rxv)G^7;P3cjgKpn4Xc4dfn@0hJg+A4JEBVjVK7JT^Ib?Qip#-ayg zQxmA1_g&t`ppuEIDb_`GI3xA~is=-R0Jm7y!agrP(~{X?Fh~$zuhadZ?S;e7v-KVy zh#+XaTYUmAdQ|nz+Y%TNj^~Gq7Zmz~@$)rHQV#sS?GZ|*p-(mCBY5SZn+cbr?T7yi zc~_kZ!tp2;{sCNZRBMq<*++!K%kS!^+~)`kZ!XX)kE$S&f|nHlSK?2ONh9nj)8K^)VUQKLC05BLiv;!qG2sF^OM-eN@rPo&HtA)4^JCHZd))-lw{7R@|1#IilL;qW-6}S2&wI1QS3!?IL(Wb`U-y&sjBM_Z=r2N}`9ZUZf ztbrkh9|vAHEmxkxq16PfL)l;c^vzd+^|!|byC;`DoXuFW2fd*ap9zy!6)$xkHtOD) z?~Feffy!1$BeLPLo5TfNq8it5%DKvmh?L0QVQ8u;euSw^ED3B-brC_?KdEroQKPb6 zLK54COVs#d)T1-l4oF#D+X*R~D_I~KBYxg`42@$IUO>`i8%0TZ|5OA{Q%&gL1=n8$ z)&Lqvl@w|K3uRq#z0fhR;Z}7g^zG1f>3j|z_4i}bs7xwtYG3OtS5SkJ!Pj_GKQ$IJ ztqb4Yko3Q{5Ue^FEzkSs7Y~P@?9LPVkbK=tWK81>N4GMPwbu1y^64xv?0m)qqeYoL z#Ng-B@nY_$&PZpRqjO8$Z5AYrK#^Rb<0^wo^iDfkcOth)gW1Daa&-1Iz7%s zR<)zmoFH_r94W_Eqg^HeqrNL>Od7-1TpTAySAbnJd?Xp=8T%?|aF%-ilLobqG`EE& ze8doak@VEn7nJX>6!_dSXh=UQUfq@fjg&RFN(nsL;yEUxXW+MeCN3JZJ&+qimsFFU zv$-7r<3AIxZX>Hg_ir9$WSe>&iQW@esCvg1`USZ~w{Y5MRZnnxEF9V+(x%Orpw?sM z!e=)kcLwS(dxx?y2c+1gc^)5oD)DhQ+_JSIR@W>}o-(_|lnu@LHV4P*kV|JgJZrRC zybX5Fcr&&sO+VFcoMY~rc| zTUPs(Ro-LSr-UXGd2`(PP#^crbHIHo)a|eY^aaRa7h?@Pn+m%*c)nM~6Bj+WU$) zqu}Dcy&p4I@nX@tuMJ3>twdaLV$uUqEcCD{2DG{l8^0w4An#rQ>earSqIF7C=>JiL z7RgATSxkEPJ9fIilzt%TZt`R?Dg}-MEczJ0uFXcz?;UoFlmO|@W~1gqNKM*eR=d;H zHN^D4eKz(^U^vCrI(CtF?6`Bj!G+8Cp)$Hzif7*-3i+@{7%f5n20fiipj@6XhO6y8 zMp<#`0Uw%Ru?u~osPioS_Q;wAyrn2oteVr^MMEjvk*v9pO4uW8V2JHuEaij8ls{w8 zijU&T4m4K0;XrN+hLN3T+K0OueF3rnU>q-zcIMc%Kgs5HJjOi25CQWx3n#2e=ozy5 z=}++j z6m%)=X_Fo0k&>Z`AJ3wd5*8Llt`uKQ^bs(Ei?;Nc4bknG?kv02*Y})s+U5vSj(J(l z@ETgmBG$j`S)O$E4P9nbHXt0)1CF-?ro_(@X3nyInDIE(K#4u}sWwD+wB+HFTYmLh z1?TT8;KZFW=4T+B8cz1JDGX^f6SKhQ9dQchU5ox`oCABbo$5>e6t<`dNK5^hm}0?< zty}C-a^!>5F;8kE;lBsuq)=-b;Ulr$XP=)4_d)7q+R*{jr?)x81f>1iXq7j}061kQ zrBEYF8xrsAiE|ZZROq@{NR{COba1x}i#AJ%=QXEyK;D1sN-2w4TR+CvwMK}riI0qq z6ckMmB_g-^K%uoXbnj%~eCcRtw}f-%z(L{)OnnUAaJlvkFCKq-#*r*sllSPM*7C(+ zz(QslD!@E?k8~Wo42jS0tiBkZ=Kuy(LT7$92JHT1AX0>Z4)&BTSvHTRY64iScS$9< zyR983$7bYK=2S7qhFLzgB1lG6xkG;>yHmcDff279ObW=MABGIaC#6BiR+>^O_1dH- zPuB|UbM$A*q%eQa)dUt4Q-7oO($dg#Gg#`2T6vQ}Fa@k_c{iCyZeT?sVr?nlSVyE20LhTzSC!Pv)Yqq;NRocMSu+uFK9%}H{0qpokzsqgTnebtmR zg-+(XI~p1qBOMj|H=SK-kla9_uXAU=d@nyCK83SqO05@9bqp#cb?o7Uy=I#QvIyP>~ z!24YMZo)qS9y}#s6FQMrN=^a%B`07T>~pE+of;Q}OW6>}ae#sw!$`(E49tdR0S10a<8p91`!S*R0xd`O=lD@0DpQaU(l$|a zD>9FYZ1mPANbZjS3VFcq;jS)ljL>y?K*V~RG^)SJ&yeT%v^2`KHkTxyhuZ|;^P_Bz zen){?F-Cks61tR3Izw7{8)|YGyE{fic)00N<4NsXj@}sSg^|+vGSkaFK?sCN&10>$ zdg%a*JIdtq-G`T(a5C+$ zrOxpcHb`cER78ykPi&Ji(x!x!eS4GXwO6$>@Jk;ebMRSEOU#IT3Qq`m=fi15fDf{G z7&#D2RomnOda9nZ>;yNn6+OL#IS}h`Phh&87m&q%y&$mjjA<@s#b8v6QqGFHs}YX> z{CFLQOH0l)sCY-z>?m@5Q6di7N7=GKt)K@ni{Mdw`f>A2zI^+FL?PBWIlsL*a?KDR zy2HD>KR8;^)g?C5(|~fJ#q0>Mh|MVy2*pLn`AZ&7#o-yu^2=%icg(9bhKK5&urfXV zr{svY?M}=jb3)8MbF4FrFY`z4H!J!5M>JCKex^q&DbJ?#@1^OB<&rT{vS3?^ae~M? zX)68B_KKC=wKR$$xvEa)vc*cZ&+g5%eYF=*KJ1Y#vf1L&_Y|X<{K9~7$`GV!0}Yy> z%i_`~ejMe`p}=LZ>z((XR*m_QlQWvpzn_X^iRGc2cu={3+o!t8M1w5PaA#3bZ2ZdP zcm*zBP~&;UO5?DI!E;RepNq2EvRu)V-Oc(g(%hGR8M>j9S-Vfnzf?~!if!X`aYbNe zRhwd0)OD(xntlPh@Da{j*q-i2aQ4L#J!NNq!P@J(i8Li&47E*FLb0UC9Gm5X`E33O z!+*vbvT=LP`%>(VlEQ32c`J00>do)wyHxAv;Sr?ku&xcA-LY2BE|SKves}%=;Zcfd z__)$Y5RT91q!UA(yQ3j}9c}Ljsdu}kU0!c^lwgP?VDXNlVeyHJgsi~bo+gO-PT`2P zB#36S-&-|j2-LR{MS=_1mm8eu_-+pJ4acP=VRKDZrYP?Ml$i~##+zmu6-el-9tR5q z$qY#O0sMf}GRcvJ4(fNm1j~C~zBP08JPnV4(q4Y4gRZ_thvxH6*O|NB&(1;EeOD<} zG2(iyNtu8uDv)8w#dN09ZnD5xTzh*(X)59n-f;#8hPXOK+h02(9@j{Q?4HWY!#oHB8C19j|aonJEYlwc>rNrmCqNC0+&ACSha; zcK0dKP4$!A56m6T_qWjAxzDD%F=g{c+jIKMC6Bj9jFZAIvCe(vxN@>a-eIKZQYbfj zpkwc(g-d#w!_deFe1uQreC{aoASp$Cw@IHmRTCLzxJ&Unv+qwQBHpQjPbmV06kF8D zc2xx7;I&rQBu69)nm2Eml9@g?mALd(uHgw?I4DrstV>bw30vXj>phQq2zCM$M#L)j z=pe;a1vlx)UYGRvQmM$rZ$HJ&^$~m6YlpY9Qb3)Mk-+_tlz~XY;D?Ca89`- zAQ$s;J4#V=KXy%7Sy|g&J-$HfzF$>ZCDtUCXG3E{up{BwS+fq_pu-3WM&x7uxBk;Um}BlA%$`mx>BA;SQZC5DNkGiIjsx{*Um0`ko^fMx=sSSU z6NdT#S7aaIspq>#MTykC2nXD=)wDBYGX|d>>4XV>afWYL19D$14&M=E(ism%E)a05 zcg>As(27Q&Bz@G8J80&6{2_P#+-%eP!!X-v>*ibb=D>Xi&J^1Ez2mh`+I`PptIbGs zEFRZ#lRaZXRjY3P&pdSc+OjULm^ynr>en|YetcFEeoaI+2dTJn)7cV)BisAqM8^TD z$NevRixevKDwhNdFS=aZgq@08v*AWE0n!$Z3c`JV4$Pl!v7XDTH0=b3dGszDpA8y%qgRJs@$}u6%pU1#2uClh|cI2&FJwOB!b)57nr#EH<{?JLr%py%UgPUD2C zcKIYBsMQURP6o&C4iC9(UAI|IkC$CP$gG8o@?cg{VBhw#U}R_!(Vf+%QQF^mxck#b z3KJ*tWagfm5#X$~jq-@tslTZzi_qiOdixdEsv#07&#{*)4v&Wx0@Les7gb%=Jbh)j z41T0Eahh(LsQZ?O+K8QtUzt_@iqoAm+`V{L4#>Z>ro03_Vui7xM$pujKVLh-IN>il zwQ?<6oRwL@q>0^wCAgWMF+yemdl3;X8FgwRM>)n%11j5?DdL#q`J}D_R}n5GP%cE1 z^LSIhJu!rdQIlDbIGDkIs56AZFWx$Q=OSWa5ya~*lHSmV5K{Cs@WC-4%%D@ZTDe7z zGt2uy;MDZcp#sU7QawZnn#ete2)QJQSQDs{Q(~hD zgNI#ve)wBgwX0|UUCC#6{1G=IimX-qqh_nMf914ODM1U-Oug7EPlzA^R)i*1x4eca zNX7FDDzLLdW)*FX8Y?5pub681#0qL0d^p^<0Ae2sGmL?_71CcH5*hMh(f~=$*1jy% zs?E}?E!@%<9s?dOz;2@$0i=p$2nLIPR5nR67!{GXLGAFSl>IOsmcvca^kzYdF^&bjZg zT6U=v1|_irNI$l)Aod#J?BMwhi(2eJL_`!TyY^6BP(so_=2N=1uZt^fX0}}JxOL@3 z%PUD*$bn-A zQH`1mZEEV>kl-5}S4Ql)k1c(JSh1EKW!;J(lZ-`bfDMUyy+}WaQV4n`UL8*>wA3Wk zVc4`zFw0ITY-He&A&eO23;S+Orw5+-M&kOwxxPc#EUxOfPtNol32VK_wmkb3h9as~ zpNqfULSW#3Y^wAfppx1XD#3r3s{-BhFdwBvj-w&K=sU(aC~8&$rf)Oz-oDokRY$I? znOS8P4a?g`+?n^qJm>66bB-=^oV&RUczlj3{RmxZsOI=}`)8Zj083V&XfPrdxZ8jGFy)YT1?=L7Y z(FUGv#}|&0=95#~W)%Y!_+B4a1VdhR!nIkQaAam^gMYq>?^I!~F(QEnQrHL)hk%Fm z$2_((=9!+g{9lfnIho|;CpeHwMl$h+q(7hPiRKS_ujKe_nnsN}q$j(t32WK??bd6u z)U~hgIgG=Z)TPLT!#jES!O623dcHApQ$n7~uvu0`Kv`Qg$5qo?2P-=B+uV9b{4flr zZu_tsv)8=x*P-qT5j*OKxl~EA5QZah%XKU>oNW7={O}1^Ge^(Bc!pt{D^mFB+>CsE zo@t=nC%RL4Q3J>GAa>6=;5hNsjJ#gtid7n&SJ$`4A7L|U+r|Mdi2}IF5Zx9lF2G52 z&-m;8C*QAu8EIX#V57;K6-Dlmo9VbbTuE|pl5H6sA_W}iXN~y%nu*P{hNd&Mb^RcO z(d0$$_SUS*3z}S%t)O4;r%7#G)v$}%%qqLE79u0B&{izROD^|xFI8b+GHw%t!4OvQ z!`E-|%Aa{cC>FVDop)B0IEO12r2o9=a zOORr>S&Z7^6IIh^hA<`Br!cuCmK~sH@@|?RO4O+FuYzMss69~~%&TrrmUVjcx#u0) zAyR5-ZZkjeNl&W=W1=N@ey_j5>U z31P`>7@z3u zNsjfnKAa|d%j8CwxDObgFgG=Qjj!EI)3-BU>w{6+j3-{M){tPpuh-ASI@X^JUx%$9 zZjX!^nQqZoHEuc9)D;c;@zm)FrfUx00y|!=i}B+`Rx9#? zrS}1zfZ?6*45Et1CL!Dp%Z`139z4c4I#mh%96zYsA#6T#a*xmJ+mCOsmH`)3hu@?r zv`~{YNi3YCqlV<{AP%WHy>`h9hWPF`ipfTtS}l%T^g8%#;$aHaH_R0@ z=2NK(d+{&M_7YPg6$m4_B3JAd-pb-{ZQBi@1CDE;^~D<+r71415)(@D`MMxj#ILmJeP)FZ%M>KN;gD)I9cjD+f$f>XGmf+gGc3GFKAo zxA}I^3KTDWDONZ$ZMI4}g|Z#rGWy1@oV6GXj#<7>W!zmtks8?$d*o#=x8UEB5v`-% znOSvh@@aF8CT2CZ{rkMPGL&@FII=#C)w4RwiK@)0+hiB0E8@~xiQt06HB|mOd!>6_ zpHh2bPCGnoTJMEbeyogok**DWf37udsF{e{jpDTUjQNG2xCOg-siAC<6f*{Hb-;GK z)#c)|P4lpiR^}D?!cdLiqh|4NXCNZ~MCYbZ$GJu}0%yhuPpUmM1cQw95_#51n&&8L z)=qN$K#5bJWHxaz6>U$t0B>XK)5zFm=aVdR$DXJ5%`@tF&!#%2P+M;ZdeY@2pIS$| z;^IfIiCI|DlOVFL4o@@^TPA98OTZ5*d9Nn$%8ZChAW3nSQPv^*{xL-KqZnJvhWAs6 z`R>S3k>NadT}(yn21Ps}Ru$@n^Mowu4qmDI5A0OT8zh;1o`4wi5Fyd@D?(&2t>npU#Vp z+-5spJb3&DLAgJ_Fi=!=T}R7zTuRY!nrqUtORZBTTI)L@ zk3_9}$m=DeRj@LU7RnD>EuX*edAprD)zY1Jo|LXN%eni*CB5@1%~>mxbmUBaoi4tsk z?CEy0%(v7KJPtT$BZa@R*%u@DLzTJjxIEG;4rw2sb>1VYzAMi5&Xdnz*RD`xO0uIW z0x0A`zV~S4JP(TO`V%RA|FLf}RHbBb*>(>R-Obo`{2i3==e{94n5icXUMPZ3v~Cl` zg^y#$?=&ccNyW4LY5N^oqy?jo(_X$@8_PxED#&`^YYwFia7#{(ah(n2pgUDXMYjf; zWEb}|+0l&r7*qUYl6Z|2e@7eBI~JrPy)pRotUTloO7pZd zGJYpfO*>IdzveT0liRcKlEYXC9mi^fAfi~a(UYJ(`D8vby!i1P&u~)LAuSg#f&SHF zZMDm10s)Aldz~SsAeKN+47jA@ z>t7;#85R}DT3@B}-#1sju7JD&P?Ue`5J$vR^-NG}sxPEm5hx7!761B)hTcfFkfyzVO2mO+0rQ8^wHk>Z)b-~Pd_r~E&9Qt z=4H_oRLt%KAB^OQodR25<3qz}VIP;K5k!AR2nsqBW}&B_lL%;)1GLmbl4bDfNb6en zc1+~Bp#mHshMqd5v@azpj=f&sN)cyA^n(YoU3(}+1pK)Gf3a9E|Mr<5<3Nxio!yi8 z#|X#gZQw~M~YSRw-~1Fc`-u2G3aR4lI*fXi?4 g4VVAlFm>lgi4_@~ literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b17fb96142ced6712f6df5401b6c781650888eef GIT binary patch literal 12216 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 W^3gOVgTm_p02v8I@fuO1;Qs?(2W?#d literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..e2025c955f88a497f751b1881d9a752a2be17b3a GIT binary patch literal 24786 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 Q4GjG0Xy~g~sM?17AEfkS9RL6T literal 0 HcmV?d00001 diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..aadb695 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..ec27a07 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Weerscherm", + "short_name": "Weer", + "description": "Netatmo-weerstation en verwachting voor in de woonkamer", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "landscape", + "background_color": "#E8EDF1", + "theme_color": "#E8EDF1", + "lang": "nl", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..952e016 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,82 @@ +/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */ + +const VERSION = 'weerscherm-v1'; +const SHELL = [ + './', + './index.html', + './styles.css', + './app.js', + './format.js', + './icons.js', + './manifest.webmanifest', + './icons/icon.svg', + './icons/icon-180.png', + './icons/icon-192.png', + './icons/icon-512.png', +]; +const API_PATH = '/api/data.php'; +const API_TIMEOUT_MS = 8000; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(VERSION).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.endsWith(API_PATH)) { + event.respondWith(networkFirst(req)); + } else { + event.respondWith(cacheFirst(req)); + } +}); + +async function networkFirst(req) { + const cache = await caches.open(VERSION); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + const res = await fetch(req, { signal: controller.signal, cache: 'no-store' }); + if (res.ok) { + cache.put(req, res.clone()); + } + return res; + } catch (err) { + const cached = await cache.match(req); + if (cached) { + const headers = new Headers(cached.headers); + headers.set('X-From-Cache', '1'); + return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers }); + } + return new Response(JSON.stringify({ errors: ['offline'] }), { + status: 503, + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }, + }); + } finally { + clearTimeout(timer); + } +} + +async function cacheFirst(req) { + const cache = await caches.open(VERSION); + const cached = await cache.match(req, { ignoreSearch: true }); + if (cached) return cached; + const res = await fetch(req); + if (res.ok) { + cache.put(req, res.clone()); + } + return res; +} diff --git a/tests/js/sw.test.js b/tests/js/sw.test.js new file mode 100644 index 0000000..89d4b28 --- /dev/null +++ b/tests/js/sw.test.js @@ -0,0 +1,98 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +/** Laadt sw.js in een minimale nep-serviceworker-omgeving en geeft de geregistreerde handlers terug. */ +function loadServiceWorker({ fetchImpl }) { + const listeners = {}; + const store = new Map(); + const cache = { + async addAll(urls) { urls.forEach((u) => store.set(new URL(u, 'http://x/').href, new Response('shell'))); }, + async put(req, res) { store.set(req.url, res); }, + async match(req) { return store.get(typeof req === 'string' ? req : req.url) || undefined; }, + async keys() { return [...store.keys()].map((u) => new Request(u)); }, + }; + const sandbox = { + self: { + addEventListener: (type, fn) => { listeners[type] = fn; }, + skipWaiting: async () => {}, + clients: { claim: async () => {} }, + location: new URL('http://x/'), + }, + caches: { open: async () => cache, keys: async () => ['weerscherm-v0', 'weerscherm-v1'], delete: async () => true }, + fetch: fetchImpl, + Response, Request, Headers, URL, AbortController, setTimeout, clearTimeout, console, + }; + vm.createContext(sandbox); + vm.runInContext(readFileSync(new URL('../../public/sw.js', import.meta.url), 'utf8'), sandbox); + return { listeners, store }; +} + +function dispatchFetch(listeners, url) { + let promise = null; + listeners.fetch({ + request: new Request(url), + respondWith: (p) => { promise = p; }, + }); + return promise; +} + +test('install caches the app shell', async () => { + const { listeners, store } = loadServiceWorker({ fetchImpl: async () => new Response('x') }); + let done = null; + listeners.install({ waitUntil: (p) => { done = p; } }); + await done; + assert.ok(store.has('http://x/app.js')); + assert.ok(store.has('http://x/index.html')); +}); + +test('API request goes to the network first and is cached', async () => { + const { listeners, store } = loadServiceWorker({ + fetchImpl: async () => new Response('{"station":{}}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + const res = await dispatchFetch(listeners, 'http://x/api/data.php'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('X-From-Cache'), null); + assert.ok(store.has('http://x/api/data.php')); +}); + +test('API request falls back to the cached response with X-From-Cache when offline', async () => { + let online = true; + const { listeners } = loadServiceWorker({ + fetchImpl: async () => { + if (!online) throw new TypeError('Failed to fetch'); + return new Response('{"station":{"stale":false}}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + }); + await dispatchFetch(listeners, 'http://x/api/data.php'); + online = false; + const res = await dispatchFetch(listeners, 'http://x/api/data.php'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('X-From-Cache'), '1'); + assert.equal(await res.text(), '{"station":{"stale":false}}'); +}); + +test('API request without any cache gives a 503 offline JSON', async () => { + const { listeners } = loadServiceWorker({ fetchImpl: async () => { throw new TypeError('offline'); } }); + const res = await dispatchFetch(listeners, 'http://x/api/data.php'); + assert.equal(res.status, 503); + assert.deepEqual(await res.json(), { errors: ['offline'] }); +}); + +test('shell request is served from cache without hitting the network', async () => { + let calls = 0; + const { listeners, store } = loadServiceWorker({ fetchImpl: async () => { calls++; return new Response('net'); } }); + store.set('http://x/styles.css', new Response('cached css')); + const res = await dispatchFetch(listeners, 'http://x/styles.css'); + assert.equal(await res.text(), 'cached css'); + assert.equal(calls, 0); +}); + +test('cross-origin and non-GET requests are left alone', () => { + const { listeners } = loadServiceWorker({ fetchImpl: async () => new Response('x') }); + assert.equal(dispatchFetch(listeners, 'http://elders/api/data.php'), null); + let handled = false; + listeners.fetch({ request: new Request('http://x/api/data.php', { method: 'POST' }), respondWith: () => { handled = true; } }); + assert.equal(handled, false); +});