From 31d5322b38f26ccddf297b6d78cd09dc984d14d6 Mon Sep 17 00:00:00 2001 From: lockeshor Date: Fri, 18 Sep 2026 16:47:15 -0400 Subject: [PATCH] favicon and some TODOs --- index.html | 3 + public/apple-touch-icon.png | Bin 0 -> 8356 bytes public/favicon.ico | Bin 0 -> 15086 bytes public/favicon.svg | 38 +++++ src/engine.test.ts | 49 ++++++ src/engine.ts | 62 +++++++- src/main.ts | 290 +++++++++++++++++++++++++++++++----- src/sanity.test.ts | 58 +++++--- src/store.ts | 14 ++ src/style.css | 264 +++++++++++++++++++++++++++----- src/types.ts | 30 ++++ 11 files changed, 708 insertions(+), 100 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg diff --git a/index.html b/index.html index 22437e1..8b312d9 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ CMU Meal Estimator + + + fK$Hbkf~PItdSX1QddaBDfI27tHt= z6=g)tjNa?LYsAN8?$x^x5J8lYtD@+->h*z|K}DW35=7??sN)q7P(uPq0Aq(v(vNg^ z`cdyX=j{7O(%MN^Rd-kI^QcgtwR)|js?XWGtG@NC-~R32{`~~OVJ=&-dc8!fQ>asg zQYS0KS^-@^^6*b8!1=YQO%})jIR#?^wMQwjOGvTHg{MacpWh1DK}<~eIaqJ+n$umO z-XN5^P$6Fhs>9Fm+xUf;R=B?uLOdjucwlJg`KNsw+3NxN`udhu9C?Xy)g}b5^m@ED zq2y_W_&$#O-hl)CLtYN#nV@@n*PQMuu0*Iy0n@8-+O!llO8iI)ZuH7(YZvtLRjbwu zTYeJ3TL7v1{%wv4T;N_@_xh2MeOq= zef#!}>A0a5;dQU*d$(b$pMZD`ZL8d7;Rq0Cx=L(HB|8Umxv6a$ZrC_|eSPtgBX1E( z{pV&U(xwfl&B`f#@W6os*+%csu%LT;*PP~}ZUs;EXZ{3ifdwSNq zMhWg|$C2B3L{6dJabRfgPZxXPqJ8L|p1#YJ;7%aE*lXLgSs)RCxHOSU@6BhY{;-$} z7bWQBy{j)rh;J?Sx;AYZKni$MGL;_9Wv3on=tT<_W=~JwWeD+2+EI)){vm+BN~ALV z`RwF^eCh@H&^4E5T|p~heNDhRz4L0*reRK&R{R_F zexc@~uzN+{yFpy1e@<=MG{s9&$@DY1+*DQ9qpAO_D4#L-VwbvevP>9e%O+$OyAL`!y6?_rLGUcPG8df~{&X>Xpi2{#U|yW!}Zk2w}v z*z#K1Vr~d(LD zAea^|(?T(+Y^IgVgljXQ0y!kz>L{T&V|Hx%h_ikU?$xx#T$e5*!l@$4DaxYH6?D0< z)P}U<*176v&u-=={>v9(MF<8h!GH+^(q*@>+2fWNbjrH_gkuSzg!(wJ>CX?${G+@3 z_#ULam-d0yKO=w>MUgodCn~cJuTlb zm3t9zNYDaxf%3P^T_Bt)W4zZ%^5VRUpf%iz#bPSOIR%GT5NB&F$&X~7r`#~$aHw%C&+Ljzr6*^1R0 zrDDNnwpxh}4BjIRk zGsn!jLcl3TGS&G%Y4P0WE@5(|%J9a5z-(LP1!Dkbl?7**NuHogvBh080R>5mM>o!t zac(JhvcA;XZ$^-uUUa$HPIHQp2zn|_p`cR&EnA=Njb?^x3o$xcWUD&NblHl}=A*pc zOb0!cX6O*zy{mITOMNsYg8ynP;o@9d$X@W%q~!lN<2bG7f@DQ^?`jvHyJ0m$0?CzX z31^0E;a3qbYDmUR7?p}Kp(x>kg50cEH-#Xr1RaNeGD@(r><|f=cAkn#t{3AJT1(VQ zboZ`qnHet;1~=FlR{NZjd^9B?=#NOAF&%bNW`|p5R5hLuB!LyekBb0sY;7iWppNOfcOdyNfL$=(I4o(Mi)+_V-OaXh?n$bNMv|o((t;;&>y*MJmpD zCE}rwIA14_xE_IV^EV#a$Y<}Jn ze9Sl)>V;xx1#KX>K`rHUAEFt%qLS|zd3LmD-tdV=ocG%aUgE=R^@o_`Q;dZ+u_?5I zzFaQl4S8>;HfR}aj+XefJ?-f@esQ*$;sd29-5xI>?@vj-Zfoh2shv;?`XVdC2dBOB z4wZl{3Bj$-1eMU*n#PDAd6$vlZ}JMm!*}-UDVO{0N$&<|kkASG6dC8Ug_vilpCgv! zOIDukE$Ut@lv8Ak|5izQ7Ur}CTo*60HDs@2s05wH;7fL9RxJ#@Jd~7t!<``Cz)HQR zx0j1Ax`<`Vx_I)*C;7L3`@g7pHa=FZTE*ob`Ug&5cRGW^L;T>jAMmSR{YTwvI@4)B zbomE)=}XUIdU~4s?z^A6@4kE9v8oZ123M#I=M=noJ*N!8N28OBx#5X?ghtR3_>AiE zWD)2H_-@kWUmE<1+_-Tgx7_k&QmMam=Jap3L-d^sy>kc|Qj?DeK z`s!==*MI#<)oWt07(c$_c8*)S_K3%Cz4caZzWK`y%^l#KGQ(vBf%M>={(VexE#dK} zLsQ4@tw!1tG3N}y4e?ULVirPh!wuITA!dMAo_j8rT=Mps*L~mv|7nhx0j{{>ikjEF z{q1j^BW8e2n>NwawR91)K9V216MQ;eB5UY>yU!}RTpXedy-)~xtr6q0y#A>STau5* z@;u=-9#`(`>tp50l~tcR`xR%`yzcaMr&WEtt7|Dey}ebRTX)*&RUbDDgLS7i*Xrg8 zH_yjoS;kELuk+vM4OST8==c}7pe69pN>ckGaM+Su8Obux7AM8t>9j##3v%tjA|Bj+*N$k=?F#2X5fOp$mrxnnzywNn37MJlZ5d(=k(URdkvIni6RuW^F;o6SYMat0T^6r22D_Iv;{Wov{|nu(G+*n&yCJQNOET zk_x!_L!g3wA8DPdd$Mp#s;DB4^ z`bdtv)aNK9UBzWaunWY%1ijXXaY0d^m|X#1j278z>rL~lYoX({U5-ekwzaKgiR-y3 zx&;le$0_ppSP@sF^ZSN8tT1YSFuf))LBG#UYK@EjbHe5ECZZX&sAr)gkqD+a*JUy- z3&W`W)fEzaPRE

);m&1&|X;R8Sb_x`DH?%6wb-JoFs#>(t!y2Qdgg~*=tE| z76-N6?`X_At^-sTT0Kh(wSDDy02X?N|E(spEfOX09*ytuV#oHC6J?b3ntW$hz%5pu zqKCNz^?Yc6L=``}-hvQn`_T0*w6_+opd4{^+w3{L%e7;f^SK;zg%*v}x2DzN(3TZJ%Gz&7y@-lVZgnOY*5rDeS5$Nu^}peC zI!jvrJH&&6W(KR7x!R_am4Wph+d)1jEFc)j%3 zO8YA43rl*2+ZL5PYG<|FYd)%l1CATXnti+@oYqo>v2(0 z`YJNt#yZ_dXgNKh1boNLX_(Q%u&ntk=UfY2Z^!S@^K->J-$F9(gAYBMTP;0jyL&1w zXG#C&?wg?Bp!BTLe;b$l(Z`}M^SK<=EN88)?HQ_B@cG;nUCBucWxG@0iMXD;jPvj> zChd!$qe#xt5R6d3kEOpgGpstUT}w|*Lp)qVEB|< z>WiQ^8c8jM2e-#0&)WVT%A8NoRX0}VTNG9a&&_uY_!GLD%?>BeA0y36B<;Ao*zjj@ z=!>8?C_SecelDBOZ(c0(EefkfIp*12tmb3Sw~(wgme*{t^t|ThDt|Uvd=d1S8lpg1 zL+~5dzs2AT)_fZ)RkyzD`;67ZJKsX`&{+QGNBqLhGNHj=czMa6Ud4K|>#NWwqbx5r zAKmy>3@QP%p0MWG7+BV|jK03UBOhDVRri{XRL7iaR`kx_w<+Oc=xzpE9D42F+@vk( zvK7Z$5q2wmx$4#MT+lBQdaTL=_-~y7T(|y*@zocc&#Nyue^K{5;lvZT|Ni?HJr~EL zdL{GmYnA8CH2+~bL&n*y@$f}8Ja;+PJI#lF_F9sD4d(+Jq2h$6h)TuQvxB!-cDY5y z%;p`2Q(VvOr?-L@z$qF&kbelUCG5VvdjlJLHm>V3I5-%{*b4ZqhIRdme5jZ1si1q! znO@t?@dUwD&)>0gM_^;mW>5bf^7-KERzED7|Ay?YxTHO<-{GmCj~C5{YDz+|)d{X3 z;P0OJJ>{~ue&0fP_>o5f9e=x%r_!X?A%Jz#+wh&If<8_)?=$U)3TzRa%P}=I#X}E1 z6xjGP+B+9QyPcj2dbQj9?VmM*uiyN=&70?qc6iC7kN%dOyLJXP z{tVBU&A$eHI**uF!COIVSOoMF{MY^VZQE;B4D}kvak%NG{}ssCN3)COqb$o?RnP)h zp&?y#cR=B~qxt+7zQ~?E&-*^^SHAL9o_+RN-^Z=SP7TJT%l6iN^i0rcVK!ZWu`FPa zaP-ETnw;drAN~lFlinZun&F;%?&Uk*xz)Gv=3~SyV>fAFI~^BGcvmjWGeKu$^9#a# zmJzBBDEF@9Ne5~7U`ytm|cfIH1EC!cD%}AR;0zJ~Zg`sDH?x=I%bD>On zQF6XX1_lOr&wKxlyYIT&lZE2Y&=CLqAO8oR``qUOU5M6VRD*F{?qNYk&-NnI8YVp@ z6k+V~I5j!Rr>?u6JMO%bcfaQ{UiaG9&3h(rKKlFnx%=*Wx&8LrDHOuhh-WsV8cblO z6$Ra?n*YnELn`PQo_gwOK5^yM-1yml;??J$&xNmj4X-}`{JM4RW-~lI%$6|Wg`eEFg|%zfaP22Q$;Ov$ ztoi%4ZS(ohe}Nz0aVK^qn1rK7DdIwtna!q$nbDs4&=C!j&W71M*I>_{=lS5}A7bm) ztu=q=nrp7*2S4~>%ZV94Luw8h68CE2nV`eBobiuhvB*s~eZJ~*fB3^zwrttr^NjRS z*6^)q>MEdD1{;*Fwrv~x_U)VV_?G)xSInU4ef98**E2yko0$dPevF-T5v~2^r_*yA=%nlPFKh)mG)TtF=#^)RDQggPSBtwGoz(#A30k+u`YSYr7mvHE40l z!wgE#1U>9D?{sw8ZYcUkH$p8-obGI03oQ|>s5I|$OnBH6$1_2fl*4qBP0}L@x;#uZ z^paYF*3pDMDEg#@)ntUCEFh<;ZFMy~4I_+cNUAzb!^!a==&a#dw6cXhf#ypP?6;(+ z!B|fPts~X&6k5|lXKDyH)4~?|I1TT`UM~|l@L14$W%DbDlkL`wMW-{Fs*iWJu!TNJ zLrU=T8Z=Q4Pu22_Xx>*kuBxJ&t%Vg7so&lcoxGaUIf*yCujB2R1bFS-cJv1+< zwy82hE5@QXYVe=4hG5j=vJ{?wmp`ZB+jV2P^<0j6;ki~^7@kpS{yo^~!}{KHrR?id1`Uj)m;)55x`6}E%cv(P7MsJuU9N%p(m*D3T| z&tRj(1>aa{jcxEqBvQBV zOe+aGmCBIUkizhU@aKfPFM{4`=a|;e>+VHmhf~e)`_fD5xEs(qr&g>;j7Tib^U>zQ z69oy6+tZq6RUcmj?I^_)nvCl^obYe5%;zJwO2%EPa|r-jlP1l09S_HZZ&kj13-JC* zQ{$hF1((w_t-8HpK9?esu5ZD^fMrFA#SVQNj++LLr5dwHwh-JY{A+gPo1lN^<~g8Y z*H8fOv%~Fn)LRfjYwQ$uWR@QB|2vl@m~6mpyf#I8D zR`_*X@S1${G0EPF%loVh-*Cq@%uFxyxfDVOQmGCmCJy>EqlJ^|T!LZLmc_U!`SQvR zPAJ-J%$7O6VAJ6$9AGdic{(29w;d6RQjpJ0;W+-b6!G0m+)b7XNoYCzdU0OyNFvTw zH(Uk67vdvlGU?C>IuePJOf77|x;tjFJ62mhN-46_{{MZxe`gQ4C7z7x8CC)xsU%29 z?<&GK!a{uHux+jgDM>Hw)=|0+fI=aQWBcFmDG(=gvys(SZg+Xbohs-B;7c&iZ}iq#AFf}oXQo(lD34E*VC+-y6VtS{syyEgt zl{BlQ|EI+(6`P$qcg_2ITLbD#XqYBl-77UlG!9WJx5gQHsS*(gRbmHt%F zlgKKETr|qYvfeX;aaVD^8)Z*Kvfty1J+s-pdpAM|Hf-2{I4Y7eG&soRAN~mAV`IL} z2M$FumMmMb;4Zg|VRB-Waw#0UwW7Osb!a;CTr0ZRP|)v#DFHu>yWH+h`r3x*to7@8 z`z3GZ_~VbGkT3GcqYrcY?LTIE+TVnoWGa0)G^hWNaUnm=#Q2Ez`!-6b1ie%k+)_@{ z>3B1xN8*y3nV_JdT}CS)r6iqMu65I7Hg=`L*nzzZS_Jz=;6C(h#+AcO(E_$d)e2rw zaJkveaH7}HXbbI12&oDVLN~!4I(W-?KIzaz3$@COFxnb({p&Rp8Yk!En@K zpX=|b3a^O96D(b}f>)#QBr~tn)%Dha(>2$q)5qm2RSQh)bmJ*G`aa{-3=i@^Q zK?p%KmLQW^PDiE-%kpP8v{EiHJ{oQ@L&z=k3>jf^gOg@W+1nL$SqQFLn`Y{8aY@H< zC>Ey4=d#!VyCYU4N<5Jw7Vq$Mu~!Y-t}r&T57)!~g1Tr4K?8IOlTSMtjxT%rqTbb& z;O9%`ZkOAY62)SkQn5g}T!be3_05YAL?SVwu{g0<64NJ3d^9tbxfR3=EhK1w4h*h$ zGo0w5rG6@5am&j3H9wS6R7yokr2>_53EM6=eY)8&OiU|+Wkrd`;zS}bJPlPWfKn;X z!O`KsGw2qB$swnxB?>Q3qIxDi_DLaqJ3? zW24leG*Wfc#)Fg+Ath28NW;J|ObpY~nwPADVm`~k@e#uAGarRAfdbGnTW19n*Km;k zlFV?i2ib~7gRX=W5@|(zTcmkOE<4H8L^!hN7D-M5{?s|uh=by5&OyG~;i7v(al#0f zrSui0m^^r(RmBW2-I9Wy;pde}u8bEL)!1%#cRZXWy1d4=%Z!ffBcGcJdMeE@Ed@*l zJ&R^|)-7^{HO01=9#KEi(HdO>bW$v285`M0rA2p2XHYOH73xnxPo!BUmCe?@jTB&7O(1YQ}To5 z{%j$KfpV$9=j`_owTy=&247g!xv1L5t%~E= zOpK2(c3^*?^)tLcD0i1wwqnf&snp{^PsR&6G1w$Jxu8hZ#_J^^_(dkhmZdRmb*saQ zQjpJ0GCeguCpLbpprm}M0L<>*)nh>LrF=Z1(~NRXq?5InSV~Y4us34zbkaQLg$#;9 zK1X(H{Ft-IM=?1vG|*)Nwm|)dLi}~mGxCZlyTs4z0~n@7sv|=@(J|*V_OXYeP{@;= z96Q#9d*POm+sIFQK2q7I}IcyYd1M@?nH4 z9)iO;9KF43PIFOD1v@4GF$|M~I4j6W1&L`zh$lKoBs!2j=NmVQavchV9EIHUp_JAaC<-1sFf{OTnAKxN z2(eiyUa*3;D`lo9$H-2N5s4*;#gas$ag2~zZo5*UTrN;5!Q zcm$5hgYNEG^UnzN?qKHC8W^U9WknAs<69Vpbts=%HkYO6x-O1W!Le;Bl`?jvOr=u7 zc_Bn7nnGBdc}&h>m227Cu9|sS^*&q48u5-xG#-EiTpy$2@fizP)gyt z?xBOYu7l$^s1`Zm6f#_)ICFS-VEfDib7EHAy{qp6-sbDH+O%l^0e1`!4ZL&qV{s}AE%|)BAW24lkkA84Yd@7fl9!{mw%Ym2sGOadk8bAs4m64&n-#_~Cs=jii9K8zI z@A<6Sv}uf?O4R&RwI`|ydVKu3DWTNIyqr;+HjN?_S4{NxS8Y{Wb4x6jo7$F4rB49P z^lWBr+AN;$jtmccrq+{n!*fEFKd4}b{yDX2(-6gmy^vpCaGqo?H?=*PN{<2;(-w6be{g{hjSLOc?_@fQMJ4{` zvQv+xlAX^X#G4j-ZJRcWq%0JhMurD&UCc!dS=yeSwHGR(?gdr9?rfVjjgeDAU2 z?*ogyu%WEF;o&_G8bX{6Y-{BDHf?GlP&;wexs8Y!nn)8J9Ne=bVyd%I;+u`$qfMJ5 zQJYbfjUyv_pKSCFS{8RbJ*(fP1YZPtb=g={s;S(IT%|4)@G2nf=lC&8!Sw(D08L3m zK~!ygV-onKP~t(yupb!Rzu&i#%|UKq8_QO%JyR-YopQygQgE_@i-8pSd>vaLV=I~00002g+hr${`*nRzn}ALd*Hh<6gnXs z3MH9_i?~KUpOa9C=~Ta(Xw}Kp(a0`-{=*LiJfPVXi-xCx*FfE%I?8hJsE%s8-SXm^ zL7Bwy)ShXfNn^6&!F#%)mg2?OJecc^(PT$9PQz; zyRrOR%7$D!{1|1Ad68OTrwW=WU6ZJ5Lc4fxuC1m?tr@{XfrB#UrIa1<8l_@09sbu` zc?Df^;RVh&cuYvmrA*BfCr`?^we^hs5l{a4=YEgQK4Xq2FBiSPhz>lmBX^KxF7(jO znK^?_nRh?c2n& z`1sikK zW&2UsuX&lMV~u=;b^VHX&&1{z$_1ZYaZNkL#m~(<_}z%i#2C!;2*2)biw{q3Z}@eL zThv{A6#fqFhq{<&68uc&2lJEoVdC-mMWfL$CrP^t9eS+#@2G!`Ac<_$A@w^_pF?6w>&|d)9-Ba4nM)N>x*Q7cF*qDXxr9jm1V$yaUuHyHYxf0rS(RN_Y6|+{ggDP zmaO;U3tMT^hMtn;=!;`Bv&bIxJms{FRA)4oq?MR5=H-KJ*B}{nlH2Y0~CAho7yl_N4_Ti|t(6U8B+AOMmBW ztH`~FQnjhUXm|M8RZsi!6Y_bCOZf>s?n}R7SvOsK&DFj%3ASCy&pKwiFRhS&OrJxa z(A9GX#{AsbvuN)dyJ_c+mk4_~RojtQqbD4G*^w`oq?7fnQCl4P2BS?$f7dVXpnrV+ zndiA_$s#3fqg(&sCzUi(Hqu=5E=PVkc3u&Ey19Y=_Sq*M*}(GYM;|KZ7?1t2ZM$Wu z-ZREkj(l5tsEEFOOisw2f5G|mO5b*RtY@9DL11W16x&NqTjTI6XZ&IA&GOz@+DxlNCI52LlzF;SAAIca zW|Do;mdgrWxy)!vH8xOfwMm_AV?|z)`^!CtVGSQ`&aFtc#D}wydk=uUh|TBRbnINh z+=%%p5v?PPQ_R({SJ~!#+D{qY!$^K5Qr~k%$~>FT{)2Opmw$eLpvpu4VeeK#rpOn2 zKRvS7j|OE}lkF()3D|$3lI6+6xsvS{d)6Sn*njf=dsz^jEN|ekiS^lz-ktB`k+1ST zX*=8M7BO#Sj4K6JNnb|VeHhOlU*Nona~Pk$PG_vcdj7(huiy-LdeB**41EUnIb;;d zmN?KCu!n&&(N~8;-T;rU25fC9WJq~P_k}{=>j*b_#KvVF~&4)x~dI%-}bQD$WV1~Af|_>y4w&7hZ|Yv z`;Sz;7vW+XaoZBx%%&omBh-clacUdT2c{Xe2lIw*_q8!y_-e+any7>09uwI&0Urq- zydw`~@aKT`5Xd};d8raVpGszu&lYZ+HF`B!k#%b9 z94l*zjSuAEZU^3*YO1Njhl*{ zF;vev7Cr~odyphg}g5@X5q3 zEa1a;MT|4c=7ag??|=Q2-u>;M|4-`1FKvmPTqHNmSp4d!%4~DFmk-Ae;=B&1XiMeo zd;dM*$1hc`%Ew&|+*fgrJH*XYw0?OxZLNog9eK239&hdW4Q+WU*tcJZ!{+#w8$a%m z6w(BJ+oN9a@UZk-E9SA|#ckBHZf(W&EaPV$+4d-PEYhzHg?*ScZmbvwbRQH4RAI36 z!Pp@B!D3yi==}2F|8)0ZR`0He$C;Vt!2q}w?o{`bLjx_0e89T zeZu&0^p|~qD#9+?@ZRs<5pfMwU3Z?iQ|OYNi>?Wj?XHh^Tff7j1H>BQ0sDqKR;?_F zUAE!cAN|l5OAVjza5vDcf7@8@Tc;2^zVk7f!Tl!qwHVWV`SkXz7x)oB+_3&(r!Du~ zwVK}EyGMK@wylyU^IFWaJlG*OV-DV_lypI9y^$;86BMW4)bpsoxM;z3G&y4&PL9=0EGfU|k+lLN&Mckd#)x-}2k=8Y z75H(#Qt~fM5eM}81^8a19voXPSXRfsm+%GU*cJYOTJu~qB z@x9**yokr*d*O9IRpaNeWrur<s{*#rr|@F~)Zx52<6^rETEzIq<)^dzYZY z{a$=udQtIM%*OI}dA;~ytCNdl6v`~;+tR)Jk-VIKzOQ?|XHGrs)amrZrpKH)9^>KW zrAq{cS<|P9dkr09TOCX|``hLM*1PwdA#~4sh;p!%Lj(Vb9qB3RUm=Ijlg(wG$=tFq z56FAUUcK{vM~tz`_Udlls`mu^SA18XP*(n%6?^?;&9SOr`Hq zdBPVb{8`05A*T&IcU1b0G3M8ICGa3dIVAsilKonR`>aYagC6s9lIOS*_=58LtM%|r zL#)gC;+PBHXBB>y0$)W?Td=m{4$9Y@-@9v!aBish-SyyafzQG=Z|1Sg{|muqCfK{U z2g3Vz?8hzM)27Xf*&mV%(rY>82N7dO42*F+;l}bm^4<&V_?c!^6Jz$hbA5!hX+Q1> zmY(Y|jCXwV&k1F5eN?W?V;X9GiW-Ush|{42f5j3R}`6n-8OY)nk`q z?6@bO!mP>@aqaxviuD08%Hgyv$b!sWxc4OI85Pb0$m|M*_9Ok4xVOZ&heDw~d>_sK jFNKtEFv%33|NoWNz+x1+^wo!N*5f{;tXHXLsrUZ^7Tt#( literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..813c7b2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/src/engine.test.ts b/src/engine.test.ts index 9a1c71c..38ed87a 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -27,6 +27,8 @@ function sel(overrides: Partial = {}): Selections { targetDate: d("2026-04-10"), activeMissingPeriodIds: new Set(), extraExcludedDays: 0, + actualMealBlocks: null, + actualFlexDollars: null, ...overrides, }; } @@ -95,4 +97,51 @@ describe("estimate", () => { expect(r.consumedMealBlocks).toBe(0); expect(Number.isFinite(r.remainingMealBlocks)).toBe(true); }); + + it("reports activeDaysRemaining as total minus consumed", () => { + // Halfway: Jan 1 .. Feb 19 is 50 inclusive days of a 100-day window. + const r = estimate(config, sel({ targetDate: d("2026-02-19") })); + expect(r.activeDaysConsumed).toBe(50); + expect(r.activeDaysRemaining).toBe(50); + }); + + it("returns null comparisons when no actual balance is entered", () => { + const r = estimate(config, sel()); + expect(r.blockComparison).toBeNull(); + expect(r.flexComparison).toBeNull(); + }); +}); + +describe("balance comparison", () => { + // Halfway through the 100-day window: expected remaining = 100 blocks / 250 flex. + const halfway = () => sel({ targetDate: d("2026-02-19") }); + + it("flags a higher-than-expected balance as ahead", () => { + const r = estimate(config, { ...halfway(), actualMealBlocks: 130 }); + expect(r.blockComparison?.status).toBe("ahead"); + expect(r.blockComparison?.difference).toBeCloseTo(30); + }); + + it("flags a lower-than-expected balance as behind", () => { + const r = estimate(config, { ...halfway(), actualMealBlocks: 70 }); + expect(r.blockComparison?.status).toBe("behind"); + expect(r.blockComparison?.difference).toBeCloseTo(-30); + }); + + it("flags a matching balance as on track", () => { + const r = estimate(config, { ...halfway(), actualMealBlocks: 100 }); + expect(r.blockComparison?.status).toBe("onTrack"); + }); + + it("suggests a go-forward per-week rate from the actual balance", () => { + // 130 blocks over 50 remaining days => 2.6/day => 18.2/week. + const r = estimate(config, { ...halfway(), actualMealBlocks: 130 }); + expect(r.blockComparison?.suggestedPerWeek).toBeCloseTo(18.2, 1); + }); + + it("suggests null per-week when no active days remain", () => { + const r = estimate(config, { ...sel(), actualMealBlocks: 0 }); + expect(r.activeDaysRemaining).toBe(0); + expect(r.blockComparison?.suggestedPerWeek).toBeNull(); + }); }); diff --git a/src/engine.ts b/src/engine.ts index 665a04e..4b8f284 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,16 @@ -import type { Config, EstimationResult, MissingPeriod, Plan, Selections } from "./types"; +import type { + BalanceComparison, + Config, + EstimationResult, + MissingPeriod, + Plan, + Selections, +} from "./types"; import { clampDate, daysBetween, inclusiveDayCount, overlapDays } from "./dates"; +/** A balance is treated as "on track" when within this fraction of expected. */ +const ON_TRACK_TOLERANCE = 0.02; + /** The active missing periods for a set of ids, in config order. */ function activePeriods(config: Config, ids: Set): MissingPeriod[] { return config.missingPeriods.filter((p) => ids.has(p.id)); @@ -76,18 +86,64 @@ export function estimate(config: Config, selections: Selections): EstimationResu const consumedMealBlocks = clamp(blockRate * consumedDays, 0, plan.totalMealBlocks); const consumedFlexDollars = clamp(flexRate * consumedDays, 0, plan.totalFlexDollars); + const remainingMealBlocks = plan.totalMealBlocks - consumedMealBlocks; + const remainingFlexDollars = plan.totalFlexDollars - consumedFlexDollars; + + // Active days still ahead of the target date (for catch-up guidance). + const activeDaysRemaining = Math.max(0, total - consumedDays); + return { perWeekMealBlocks: blockRate * 7, perWeekFlexDollars: flexRate * 7, consumedMealBlocks, consumedFlexDollars, - remainingMealBlocks: plan.totalMealBlocks - consumedMealBlocks, - remainingFlexDollars: plan.totalFlexDollars - consumedFlexDollars, + remainingMealBlocks, + remainingFlexDollars, totalActiveDays: total, activeDaysConsumed: consumedDays, + activeDaysRemaining, + blockComparison: compareBalance( + selections.actualMealBlocks, + remainingMealBlocks, + activeDaysRemaining + ), + flexComparison: compareBalance( + selections.actualFlexDollars, + remainingFlexDollars, + activeDaysRemaining + ), }; } +/** + * Compare an actual balance the user entered against the on-pace expected + * remaining amount. Returns null when no actual balance was entered. + * + * A higher actual balance than expected means the student is spending slower + * than the even pace (ahead / not running low). The suggested per-week rate is + * the pace that would use the actual balance up exactly over the days left. + */ +export function compareBalance( + actual: number | null, + expectedRemaining: number, + activeDaysRemaining: number +): BalanceComparison | null { + if (actual === null) return null; + + const difference = actual - expectedRemaining; + + let status: BalanceComparison["status"]; + const tolerance = Math.max(ON_TRACK_TOLERANCE * expectedRemaining, 0.5); + if (Math.abs(difference) <= tolerance) status = "onTrack"; + else if (difference > 0) status = "ahead"; + else status = "behind"; + + const suggestedPerWeek = + activeDaysRemaining > 0 ? (actual / activeDaysRemaining) * 7 : null; + + return { actual, expected: expectedRemaining, difference, status, suggestedPerWeek }; +} + function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); } diff --git a/src/main.ts b/src/main.ts index 2b03c24..b6adf35 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,12 @@ import { estimate } from "./engine"; import { Store } from "./store"; import { toISODate, parseISODate, atMidnight } from "./dates"; import { loadPersisted, savePersisted } from "./persistence"; -import type { Config, EstimationResult, Selections } from "./types"; +import type { + BalanceComparison, + Config, + EstimationResult, + Selections, +} from "./types"; const appEl = document.getElementById("app")!; @@ -22,8 +27,9 @@ async function main(): Promise { /** * Build the initial selections: defaults, overlaid with any persisted values - * that are still valid for this config. The target date is never persisted — - * it always starts at today. + * that are still valid for this config. The target date and actual balances are + * never persisted — the date always starts at today, and balances are + * session-only since they change constantly. */ function initialSelections(config: Config): Selections { const defaults: Selections = { @@ -31,6 +37,8 @@ function initialSelections(config: Config): Selections { targetDate: atMidnight(new Date()), activeMissingPeriodIds: new Set(), extraExcludedDays: 0, + actualMealBlocks: null, + actualFlexDollars: null, }; const persisted = loadPersisted(); @@ -62,7 +70,7 @@ function bootstrap(config: Config): void { const rerender = () => { const sel = store.getSelections(); - // Persist everything except the date. + // Persist everything except the date and the actual balances. savePersisted({ selectedPlanId: sel.selectedPlanId, activeMissingPeriodIds: [...sel.activeMissingPeriodIds], @@ -100,8 +108,15 @@ function buildLayout(store: Store, config: Config): HTMLElement { grid.append(buildControls(store, config), buildResultsPanel()); const footer = el("footer", "footer"); - footer.textContent = - "Estimates spread each plan evenly across on-campus days. Informational only, not official balances."; + footer.append( + footerLine( + "Estimates spread each plan evenly across on-campus days. Informational only, not official balances." + ), + footerLine( + "Not affiliated with, endorsed by, or officially connected to Carnegie Mellon University or CMU Dining. An independent student tool.", + "footer-disclaimer" + ) + ); wrap.append(header, grid, footer); return wrap; @@ -111,7 +126,9 @@ function buildControls(store: Store, config: Config): HTMLElement { const panel = el("section", "panel controls"); panel.append(sectionTitle("Your plan")); - // Plan selector + // Plan + date, side by side to shorten the column. + const pair = el("div", "field-pair"); + const planField = field("Meal plan"); const select = document.createElement("select"); select.className = "select"; @@ -119,15 +136,14 @@ function buildControls(store: Store, config: Config): HTMLElement { for (const plan of config.plans) { const opt = document.createElement("option"); opt.value = plan.id; - opt.textContent = `${plan.displayName} — ${plan.totalMealBlocks} blocks / $${plan.totalFlexDollars} FLEX`; + // Keep the label short so it fits in a half-width control. + opt.textContent = plan.displayName; select.appendChild(opt); } select.value = store.getSelections().selectedPlanId; select.addEventListener("change", () => store.setSelectedPlan(select.value)); planField.appendChild(select); - panel.appendChild(planField); - // Date picker const dateField = field("As of date"); const date = document.createElement("input"); date.type = "date"; @@ -141,15 +157,30 @@ function buildControls(store: Store, config: Config): HTMLElement { if (parsed) store.setTargetDate(parsed); }); dateField.appendChild(date); - const range = el("p", "hint"); - range.textContent = `Meal period: ${toISODate(config.mealStartDate)} → ${toISODate( - config.mealEndDate - )}`; - dateField.appendChild(range); - panel.appendChild(dateField); - // Missing periods + pair.append(planField, dateField); + panel.appendChild(pair); + + // Selected plan totals + meal-period range, on one compact line. + const planInfo = el("p", "hint"); + const sel0 = config.plans.find((p) => p.id === store.getSelections().selectedPlanId)!; + const setPlanInfo = (planId: string) => { + const p = config.plans.find((pl) => pl.id === planId) ?? sel0; + planInfo.textContent = `${p.totalMealBlocks} blocks · $${p.totalFlexDollars} FLEX · meal period ${toISODate( + config.mealStartDate + )} → ${toISODate(config.mealEndDate)}`; + }; + setPlanInfo(store.getSelections().selectedPlanId); + select.addEventListener("change", () => setPlanInfo(select.value)); + panel.appendChild(planInfo); + + // Time away (breaks + extra days) panel.append(sectionTitle("Time away")); + const awayIntro = el("p", "hint"); + awayIntro.textContent = + "Days you weren't (or won't be) on campus — past or planned — so they don't count toward your expected usage."; + panel.appendChild(awayIntro); + if (config.missingPeriods.length === 0) { const none = el("p", "hint"); none.textContent = "No breaks configured."; @@ -178,7 +209,7 @@ function buildControls(store: Store, config: Config): HTMLElement { } panel.appendChild(list); const hint = el("p", "hint"); - hint.textContent = "Check any breaks you spent off campus."; + hint.textContent = "Check any breaks you spent (or plan to spend) off campus."; panel.appendChild(hint); } @@ -196,13 +227,60 @@ function buildControls(store: Store, config: Config): HTMLElement { ); extraField.appendChild(extra); const extraHint = el("p", "hint"); - extraHint.textContent = "Other days you weren't eating on campus."; + extraHint.textContent = + "Any other days, past or future, you weren't or won't be eating on campus."; extraField.appendChild(extraHint); panel.appendChild(extraField); + // Current balance (optional) — drives the catch-up comparison. + panel.append(sectionTitle("Current balance (optional)")); + const balanceIntro = el("p", "hint"); + balanceIntro.textContent = + "Enter what GrubHub or SIO shows you have left to see whether you're ahead or behind pace."; + panel.appendChild(balanceIntro); + + const balanceRow = el("div", "balance-inputs"); + balanceRow.append( + balanceInput( + "Meal blocks left", + store.getSelections().actualMealBlocks, + "1", + (v) => store.setActualMealBlocks(v) + ), + balanceInput( + "FLEX dollars left", + store.getSelections().actualFlexDollars, + "0.01", + (v) => store.setActualFlexDollars(v) + ) + ); + panel.appendChild(balanceRow); + return panel; } +function balanceInput( + label: string, + current: number | null, + step: string, + onChange: (value: number | null) => void +): HTMLElement { + const f = field(label); + const input = document.createElement("input"); + input.type = "number"; + input.min = "0"; + input.step = step; + input.className = "input"; + input.placeholder = "—"; + input.value = current === null ? "" : String(current); + input.addEventListener("input", () => { + const trimmed = input.value.trim(); + onChange(trimmed === "" ? null : Number(trimmed)); + }); + f.appendChild(input); + return f; +} + function buildResultsPanel(): HTMLElement { const panel = el("section", "panel results-panel"); panel.append(sectionTitle("Estimate")); @@ -219,39 +297,165 @@ function buildResultsPanel(): HTMLElement { function renderResults(r: EstimationResult): HTMLElement { const container = el("div", "result-groups"); - container.append( - resultGroup("Per week", r.perWeekMealBlocks, r.perWeekFlexDollars), - resultGroup("Consumed so far", r.consumedMealBlocks, r.consumedFlexDollars), - resultGroup("Remaining", r.remainingMealBlocks, r.remainingFlexDollars) + + // Primary: Remaining — the number that matters most. + container.appendChild(renderRemaining(r)); + + // Secondary: per-week pace and consumed-so-far, visually de-emphasized. + const secondary = el("div", "result-secondary"); + secondary.append( + // Per-week is a pace, so fractional blocks are meaningful here. + miniStat("Suggested pace / week", r.perWeekMealBlocks, r.perWeekFlexDollars, fmtBlocks), + // Consumed is a count, so show whole blocks. + miniStat("Used so far", r.consumedMealBlocks, r.consumedFlexDollars, fmtBlocksWhole) ); + container.appendChild(secondary); + return container; } -function resultGroup(label: string, blocks: number, flex: number): HTMLElement { - const group = el("div", "result-group"); - const h = el("h3", "result-label"); - h.textContent = label; - group.appendChild(h); +function renderRemaining(r: EstimationResult): HTMLElement { + const card = el("div", "remaining-card"); - const row = el("div", "result-row"); + const label = el("h3", "remaining-label"); + label.textContent = "Should have remaining"; + card.appendChild(label); + + const row = el("div", "remaining-row"); row.append( - metric(fmtBlocks(blocks), "meal blocks"), - metric(fmtMoney(flex), "FLEX dollars") + bigMetric(fmtBlocksWhole(r.remainingMealBlocks), "meal blocks", r.blockComparison), + bigMetric(fmtMoney(r.remainingFlexDollars), "FLEX dollars", r.flexComparison) ); - group.appendChild(row); - return group; + card.appendChild(row); + + // Clarify how to read this against GrubHub / SIO. + const note = el("p", "remaining-note"); + note.textContent = + "If GrubHub or SIO shows more than this, you're doing well — you're not running low. Less than this means you're spending faster than the even pace."; + card.appendChild(note); + + // Per-resource ahead/behind guidance, only when a balance was entered. + // `fmt` formats balance amounts (whole blocks); `fmtPace` formats the + // suggested per-week pace (fractional blocks are useful there). + const comparisons = [ + { + label: "Meal blocks", + cmp: r.blockComparison, + fmt: fmtBlocksWhole, + fmtPace: fmtBlocks, + unit: "blocks/week", + }, + { + label: "FLEX", + cmp: r.flexComparison, + fmt: fmtMoney, + fmtPace: fmtMoney, + unit: "/week", + }, + ] as const; + + const active = comparisons.filter((c) => c.cmp !== null); + if (active.length > 0) { + const guide = el("div", "catchup"); + for (const c of active) { + guide.appendChild(renderCatchUp(c.label, c.cmp!, c.fmt, c.fmtPace, c.unit)); + } + card.appendChild(guide); + } + + return card; } -function metric(value: string, unit: string): HTMLElement { - const m = el("div", "metric"); - const v = el("span", "metric-value"); +function renderCatchUp( + label: string, + cmp: BalanceComparison, + fmt: (n: number) => string, + fmtPace: (n: number) => string, + unit: string +): HTMLElement { + const row = el("div", `catchup-row catchup-${cmp.status}`); + + const head = el("div", "catchup-head"); + const tag = el("span", "catchup-tag"); + tag.textContent = + cmp.status === "ahead" + ? "Ahead of pace" + : cmp.status === "behind" + ? "Behind pace" + : "On track"; + const name = el("span", "catchup-name"); + name.textContent = label; + head.append(name, tag); + row.appendChild(head); + + const detail = el("p", "catchup-detail"); + const diff = Math.abs(cmp.difference); + if (cmp.status === "onTrack") { + detail.textContent = `You're right about where you should be (${fmt( + cmp.actual + )} vs. an expected ${fmt(cmp.expected)}).`; + } else if (cmp.status === "ahead") { + detail.textContent = `You have ${fmt(diff)} more than the ${fmt( + cmp.expected + )} expected — you can afford to spend a bit more freely.`; + } else { + detail.textContent = `You have ${fmt(diff)} less than the ${fmt( + cmp.expected + )} expected — ease off to make it last.`; + } + row.appendChild(detail); + + if (cmp.suggestedPerWeek !== null) { + const pace = el("p", "catchup-pace"); + pace.textContent = `To use your balance evenly from here: about ${fmtPace( + cmp.suggestedPerWeek + )} ${unit}.`; + row.appendChild(pace); + } + + return row; +} + +function bigMetric( + value: string, + unit: string, + cmp: BalanceComparison | null +): HTMLElement { + const m = el("div", "big-metric"); + const v = el("span", "big-metric-value"); v.textContent = value; - const u = el("span", "metric-unit"); + const u = el("span", "big-metric-unit"); u.textContent = unit; m.append(v, u); + if (cmp) { + const badge = el("span", `metric-badge badge-${cmp.status}`); + badge.textContent = + cmp.status === "ahead" ? "▲ ahead" : cmp.status === "behind" ? "▼ behind" : "● on track"; + m.appendChild(badge); + } return m; } +function miniStat( + label: string, + blocks: number, + flex: number, + fmtBlk: (n: number) => string +): HTMLElement { + const box = el("div", "mini-stat"); + const h = el("span", "mini-label"); + h.textContent = label; + box.appendChild(h); + const vals = el("div", "mini-values"); + const b = el("span", "mini-value"); + b.textContent = `${fmtBlk(blocks)} blocks`; + const f = el("span", "mini-value"); + f.textContent = `${fmtMoney(flex)} FLEX`; + vals.append(b, f); + box.appendChild(vals); + return box; +} + /* ---------- loading + config error ---------- */ function renderLoading(): void { @@ -280,6 +484,12 @@ function renderConfigError(errors: string[]): void { /* ---------- formatting + DOM helpers ---------- */ +/** Whole meal blocks — used everywhere except the suggested go-forward pace. */ +function fmtBlocksWhole(n: number): string { + return Math.round(n).toLocaleString(); +} + +/** Fractional meal blocks — used only for the suggested pace, where precision helps. */ function fmtBlocks(n: number): string { return (Math.round(n * 10) / 10).toLocaleString(undefined, { minimumFractionDigits: 0, @@ -318,6 +528,12 @@ function field(label: string): HTMLElement { return f; } +function footerLine(text: string, extraClass = ""): HTMLElement { + const p = el("p", `footer-line${extraClass ? ` ${extraClass}` : ""}`); + p.textContent = text; + return p; +} + function spanTartan(text: string): HTMLElement { const s = el("span", "tartan"); s.textContent = text; diff --git a/src/sanity.test.ts b/src/sanity.test.ts index 65d1860..8cb44d3 100644 --- a/src/sanity.test.ts +++ b/src/sanity.test.ts @@ -3,7 +3,6 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { loadConfig } from "./configLoader"; import { estimate } from "./engine"; -import { parseISODate } from "./dates"; // Read the actual shipped config that gets served at runtime. const configPath = fileURLToPath(new URL("../public/config.json", import.meta.url)); @@ -16,45 +15,66 @@ describe("real config sanity", () => { expect(result.ok).toBe(true); }); - it("produces sensible Green-plan numbers at the halfway point", () => { + it("produces sensible first-plan numbers partway through the term", () => { if (!result.ok) throw new Error("config failed to load"); const config = result.config; + const plan = config.plans[0]; + + // Target a date roughly in the middle of the meal window. + const midMs = + (config.mealStartDate.getTime() + config.mealEndDate.getTime()) / 2; + const midDate = new Date(midMs); - // Roughly mid-semester target. const r = estimate(config, { - selectedPlanId: "green", - targetDate: parseISODate("2026-10-18")!, + selectedPlanId: plan.id, + targetDate: midDate, activeMissingPeriodIds: new Set(), extraExcludedDays: 0, + actualMealBlocks: null, + actualFlexDollars: null, }); - // Green = 210 blocks / 500 flex over ~112 days (Aug 24 -> Dec 13). - expect(r.totalActiveDays).toBe(112); - // per-week block rate ~ 210/112*7 = 13.125 - expect(r.perWeekMealBlocks).toBeCloseTo(13.125, 2); - // consumed + remaining conserves the total - expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(210); - expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(500); - // partway through, consumption is strictly between 0 and total + // Per-week rate matches the plan total spread over the active days. + expect(r.perWeekMealBlocks).toBeCloseTo( + (plan.totalMealBlocks / r.totalActiveDays) * 7, + 2 + ); + // Consumed + remaining conserves the plan totals. + expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo( + plan.totalMealBlocks + ); + expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo( + plan.totalFlexDollars + ); + // Partway through, consumption is strictly between 0 and the total. expect(r.consumedMealBlocks).toBeGreaterThan(0); - expect(r.consumedMealBlocks).toBeLessThan(210); + expect(r.consumedMealBlocks).toBeLessThan(plan.totalMealBlocks); }); it("checking a break reduces total active days", () => { if (!result.ok) throw new Error("config failed to load"); const config = result.config; + if (config.missingPeriods.length === 0) return; // nothing to check + + const plan = config.plans[0]; + const period = config.missingPeriods[0]; + const endDate = config.mealEndDate; const base = estimate(config, { - selectedPlanId: "green", - targetDate: parseISODate("2026-12-13")!, + selectedPlanId: plan.id, + targetDate: endDate, activeMissingPeriodIds: new Set(), extraExcludedDays: 0, + actualMealBlocks: null, + actualFlexDollars: null, }); const withBreak = estimate(config, { - selectedPlanId: "green", - targetDate: parseISODate("2026-12-13")!, - activeMissingPeriodIds: new Set(["thanksgiving"]), + selectedPlanId: plan.id, + targetDate: endDate, + activeMissingPeriodIds: new Set([period.id]), extraExcludedDays: 0, + actualMealBlocks: null, + actualFlexDollars: null, }); expect(withBreak.totalActiveDays).toBeLessThan(base.totalActiveDays); diff --git a/src/store.ts b/src/store.ts index 8148ac0..9b6fee8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -46,7 +46,21 @@ export class Store { this.update({ extraExcludedDays: Math.max(0, Math.floor(count) || 0) }); } + setActualMealBlocks(value: number | null): void { + this.update({ actualMealBlocks: normalizeBalance(value) }); + } + + setActualFlexDollars(value: number | null): void { + this.update({ actualFlexDollars: normalizeBalance(value) }); + } + private emit(): void { for (const l of this.listeners) l(); } } + +/** Clamp an entered balance to a non-negative number, or null if blank/invalid. */ +function normalizeBalance(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) return null; + return Math.max(0, value); +} diff --git a/src/style.css b/src/style.css index 9bab462..3120dd0 100644 --- a/src/style.css +++ b/src/style.css @@ -15,6 +15,12 @@ --tartan: #c8102e; --tartan-soft: #e0384f; + /* Status */ + --ahead: #3fb27f; + --ahead-dim: #2b8560; + --behind: #e0a83a; + --behind-dim: #a87d28; + --radius: 10px; --radius-sm: 7px; --gap: 20px; @@ -39,22 +45,22 @@ body { } .wrap { - max-width: 940px; + max-width: 960px; margin: 0 auto; - padding: 48px 24px 40px; + padding: 30px 24px 28px; } /* Header */ .header { - margin-bottom: 32px; + margin-bottom: 22px; border-bottom: 1px solid var(--border); - padding-bottom: 24px; + padding-bottom: 16px; } .title { - font-size: 30px; + font-size: 26px; font-weight: 700; - margin: 0 0 6px; + margin: 0 0 4px; letter-spacing: -0.02em; } @@ -65,7 +71,7 @@ body { .subtitle { margin: 0; color: var(--text-dim); - font-size: 15px; + font-size: 14px; } /* Grid */ @@ -87,27 +93,38 @@ body { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); - padding: 22px; + padding: 18px; } .section-title { - font-size: 12px; + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); - margin: 0 0 14px; + margin: 0 0 11px; } .controls .section-title:not(:first-child) { - margin-top: 26px; - padding-top: 22px; + margin-top: 18px; + padding-top: 16px; border-top: 1px solid var(--border); } /* Fields */ .field { - margin-bottom: 16px; + margin-bottom: 12px; +} + +/* Put plan + date side by side to shorten the controls column. */ +.field-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.field-pair .field { + margin-bottom: 0; } .field:last-child { @@ -129,7 +146,7 @@ body { color: var(--text); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); - padding: 10px 12px; + padding: 8px 11px; font-size: 14px; font-family: inherit; transition: border-color 0.12s ease; @@ -163,11 +180,11 @@ body { .checkbox-row { display: flex; align-items: center; - gap: 12px; + gap: 11px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 10px 12px; + padding: 7px 11px; cursor: pointer; transition: border-color 0.12s ease; } @@ -201,6 +218,17 @@ body { font-family: "IBM Plex Mono", ui-monospace, monospace; } +/* Balance inputs */ +.balance-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.balance-inputs .field { + margin-bottom: 0; +} + /* Results */ .results-panel { position: sticky; @@ -208,7 +236,7 @@ body { } .days-note { - margin: -4px 0 18px; + margin: -2px 0 14px; font-size: 13px; color: var(--text-dim); font-family: "IBM Plex Mono", ui-monospace, monospace; @@ -220,49 +248,188 @@ body { gap: 12px; } -.result-group { +/* Primary: Remaining */ +.remaining-card { background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 16px 18px; -} - -.result-group:first-child { + border: 1px solid var(--border-strong); border-left: 3px solid var(--tartan); + border-radius: var(--radius); + padding: 16px 18px 15px; } -.result-label { - margin: 0 0 12px; +.remaining-label { + margin: 0 0 10px; font-size: 13px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-dim); } -.result-row { +.remaining-row { display: grid; grid-template-columns: 1fr 1fr; - gap: 16px; + gap: 18px; } -.metric { +.big-metric { + display: flex; + flex-direction: column; + gap: 3px; +} + +.big-metric-value { + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 36px; + font-weight: 600; + line-height: 1.05; + letter-spacing: -0.02em; + color: var(--text); +} + +.big-metric-unit { + font-size: 12px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.metric-badge { + margin-top: 6px; + align-self: flex-start; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-strong); + color: var(--text-dim); +} + +.badge-ahead { + color: var(--ahead); + border-color: var(--ahead-dim); +} + +.badge-behind { + color: var(--behind); + border-color: var(--behind-dim); +} + +.remaining-note { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.45; + color: var(--text-faint); + padding-top: 11px; + border-top: 1px solid var(--border); +} + +/* Catch-up guidance */ +.catchup { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 14px; +} + +.catchup-row { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 11px 13px; +} + +.catchup-ahead { + border-left-color: var(--ahead); +} + +.catchup-behind { + border-left-color: var(--behind); +} + +.catchup-onTrack { + border-left-color: var(--text-faint); +} + +.catchup-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} + +.catchup-name { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.catchup-tag { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.catchup-ahead .catchup-tag { + color: var(--ahead); +} + +.catchup-behind .catchup-tag { + color: var(--behind); +} + +.catchup-detail { + margin: 0; + font-size: 13px; + color: var(--text-dim); +} + +.catchup-pace { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-faint); + font-family: "IBM Plex Mono", ui-monospace, monospace; +} + +/* Secondary stats: per-week + consumed */ +.result-secondary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.mini-stat { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; +} + +.mini-label { + display: block; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-faint); + margin-bottom: 6px; +} + +.mini-values { display: flex; flex-direction: column; gap: 2px; } -.metric-value { +.mini-value { font-family: "IBM Plex Mono", ui-monospace, monospace; - font-size: 26px; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--text); -} - -.metric-unit { - font-size: 12px; - color: var(--text-faint); - text-transform: uppercase; - letter-spacing: 0.05em; + font-size: 15px; + font-weight: 500; + color: var(--text-dim); } /* Footer */ @@ -270,8 +437,23 @@ body { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); +} + +.footer-line { + margin: 0 0 8px; font-size: 12px; color: var(--text-faint); + line-height: 1.5; +} + +.footer-line:last-child { + margin-bottom: 0; +} + +.footer-disclaimer { + font-style: italic; + color: var(--text-faint); + opacity: 0.85; } /* Config error */ diff --git a/src/types.ts b/src/types.ts index 6bf9f76..b66ab8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,30 @@ export interface Selections { targetDate: Date; activeMissingPeriodIds: Set; extraExcludedDays: number; + /** The user's actual current meal-block balance, or null if not entered. */ + actualMealBlocks: number | null; + /** The user's actual current FLEX-dollar balance, or null if not entered. */ + actualFlexDollars: number | null; +} + +/** + * Compares an actual balance the user entered against the on-pace estimate for + * a single resource (blocks or FLEX). + */ +export interface BalanceComparison { + /** What the user actually has left (as entered). */ + actual: number; + /** What an on-pace student would have left right now. */ + expected: number; + /** actual - expected. Positive = ahead (has more than expected). */ + difference: number; + /** "ahead" | "behind" | "onTrack" (within a small tolerance). */ + status: "ahead" | "behind" | "onTrack"; + /** + * Go-forward rate that would exactly use up the actual balance over the + * remaining active days, expressed per week. Null if no active days remain. + */ + suggestedPerWeek: number | null; } /** Output of the estimation engine. */ @@ -43,6 +67,12 @@ export interface EstimationResult { remainingFlexDollars: number; totalActiveDays: number; activeDaysConsumed: number; + /** Active days remaining after the target date (used for catch-up math). */ + activeDaysRemaining: number; + /** Present only when the user entered an actual meal-block balance. */ + blockComparison: BalanceComparison | null; + /** Present only when the user entered an actual FLEX-dollar balance. */ + flexComparison: BalanceComparison | null; } /** Result of loading + validating the raw config. */