From d9f1cbfc23220273df508675b8effccb5bea78d3 Mon Sep 17 00:00:00 2001 From: ImgBotApp Date: Wed, 2 Aug 2023 00:00:06 +0000 Subject: [PATCH 01/73] [ImgBot] Optimize images *Total -- 137.06kb -> 101.09kb (26.24%) /public/images/browsers/aol.png -- 3.08kb -> 0.41kb (86.68%) /public/images/os/ios.png -- 3.37kb -> 0.64kb (80.96%) /public/images/browsers/curl.png -- 4.11kb -> 1.17kb (71.6%) /public/images/os/os-2.png -- 4.15kb -> 1.20kb (71.23%) /public/images/browsers/ios.png -- 4.30kb -> 1.25kb (71.03%) /public/images/browsers/ios-webview.png -- 4.30kb -> 1.25kb (71.03%) /public/images/browsers/beaker.png -- 3.41kb -> 1.03kb (69.74%) /public/images/os/mac-os.png -- 4.62kb -> 1.43kb (69.07%) /public/images/os/windows-8-1.png -- 3.36kb -> 1.38kb (58.92%) /public/images/os/windows-8.png -- 3.36kb -> 1.38kb (58.92%) /public/images/os/blackberry-os.png -- 3.07kb -> 1.89kb (38.37%) /public/images/browsers/blackberry.png -- 2.06kb -> 1.40kb (31.85%) /public/images/browsers/searchbot.png -- 1.56kb -> 1.23kb (21.37%) /public/images/os/sun-os.png -- 2.66kb -> 2.24kb (15.79%) /public/images/os/amazon-os.png -- 2.33kb -> 1.98kb (15%) /public/images/os/windows-3-11.png -- 3.18kb -> 2.75kb (13.49%) /public/images/os/windows-98.png -- 3.18kb -> 2.75kb (13.49%) /public/images/os/windows-2000.png -- 3.18kb -> 2.75kb (13.49%) /public/images/os/windows-95.png -- 3.18kb -> 2.75kb (13.49%) /public/mstile-150x150.png -- 2.93kb -> 2.54kb (13.35%) /public/images/os/windows-server-2003.png -- 2.66kb -> 2.37kb (10.87%) /public/images/os/windows-me.png -- 2.66kb -> 2.37kb (10.87%) /public/images/os/windows-7.png -- 2.66kb -> 2.37kb (10.87%) /public/images/os/windows-vista.png -- 2.66kb -> 2.37kb (10.87%) /public/images/os/windows-xp.png -- 2.66kb -> 2.37kb (10.87%) /public/images/os/beos.png -- 2.86kb -> 2.57kb (10.24%) /public/images/os/open-bsd.png -- 3.33kb -> 3.03kb (8.99%) /public/images/browsers/android.png -- 1.61kb -> 1.47kb (8.26%) /public/images/os/linux.png -- 1.90kb -> 1.74kb (8.18%) /public/images/browsers/miui.png -- 1.77kb -> 1.65kb (6.89%) /public/images/os/android-os.png -- 1.86kb -> 1.76kb (5.67%) /public/images/browsers/edge.png -- 2.10kb -> 2.00kb (4.92%) /public/images/os/qnx.png -- 2.39kb -> 2.28kb (4.81%) /public/favicon-32x32.png -- 0.87kb -> 0.83kb (4.17%) /public/apple-touch-icon.png -- 2.03kb -> 1.95kb (4%) /public/safari-pinned-tab.svg -- 5.02kb -> 4.84kb (3.62%) /public/android-chrome-512x512.png -- 22.16kb -> 21.47kb (3.09%) /public/favicon-16x16.png -- 0.58kb -> 0.57kb (2.85%) /public/android-chrome-192x192.png -- 7.71kb -> 7.53kb (2.38%) /public/images/browsers/instagram.png -- 2.15kb -> 2.13kb (1.14%) Signed-off-by: ImgBotApp --- public/android-chrome-192x192.png | Bin 7895 -> 7707 bytes public/android-chrome-512x512.png | Bin 22690 -> 21989 bytes public/apple-touch-icon.png | Bin 2075 -> 1992 bytes public/favicon-16x16.png | Bin 597 -> 580 bytes public/favicon-32x32.png | Bin 888 -> 851 bytes public/images/browsers/android.png | Bin 1646 -> 1510 bytes public/images/browsers/aol.png | Bin 3154 -> 420 bytes public/images/browsers/beaker.png | Bin 3496 -> 1058 bytes public/images/browsers/blackberry.png | Bin 2110 -> 1438 bytes public/images/browsers/curl.png | Bin 4208 -> 1195 bytes public/images/browsers/edge.png | Bin 2154 -> 2048 bytes public/images/browsers/instagram.png | Bin 2202 -> 2177 bytes public/images/browsers/ios-webview.png | Bin 4405 -> 1276 bytes public/images/browsers/ios.png | Bin 4405 -> 1276 bytes public/images/browsers/miui.png | Bin 1813 -> 1688 bytes public/images/browsers/searchbot.png | Bin 1596 -> 1255 bytes public/images/os/amazon-os.png | Bin 2386 -> 2028 bytes public/images/os/android-os.png | Bin 1906 -> 1798 bytes public/images/os/beos.png | Bin 2930 -> 2630 bytes public/images/os/blackberry-os.png | Bin 3146 -> 1939 bytes public/images/os/ios.png | Bin 3450 -> 657 bytes public/images/os/linux.png | Bin 1943 -> 1784 bytes public/images/os/mac-os.png | Bin 4736 -> 1465 bytes public/images/os/open-bsd.png | Bin 3414 -> 3107 bytes public/images/os/os-2.png | Bin 4254 -> 1224 bytes public/images/os/qnx.png | Bin 2452 -> 2334 bytes public/images/os/sun-os.png | Bin 2723 -> 2293 bytes public/images/os/windows-2000.png | Bin 3261 -> 2821 bytes public/images/os/windows-3-11.png | Bin 3261 -> 2821 bytes public/images/os/windows-7.png | Bin 2723 -> 2427 bytes public/images/os/windows-8-1.png | Bin 3437 -> 1412 bytes public/images/os/windows-8.png | Bin 3437 -> 1412 bytes public/images/os/windows-95.png | Bin 3261 -> 2821 bytes public/images/os/windows-98.png | Bin 3261 -> 2821 bytes public/images/os/windows-me.png | Bin 2723 -> 2427 bytes public/images/os/windows-server-2003.png | Bin 2723 -> 2427 bytes public/images/os/windows-vista.png | Bin 2723 -> 2427 bytes public/images/os/windows-xp.png | Bin 2723 -> 2427 bytes public/mstile-150x150.png | Bin 3003 -> 2602 bytes public/safari-pinned-tab.svg | 76 +---------------------- 40 files changed, 1 insertion(+), 75 deletions(-) diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 36a3bb8c6456a01992cf90568574fb55cda04eb1..29d134eef86f26171befbbd80aadf4fa8c54f48b 100644 GIT binary patch delta 7638 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 delta 7810 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 U@p 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 literal 22690 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 Y&k4T3Eq%2NunOR+k%i$$ednkD19u^)=l}o! diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 16842d3c183298ee7d763d39b139da041c0c68b9..575bba77b63eef82217cebfb77039f01256014d5 100644 GIT binary patch delta 1897 zcmaJ?dog&j5e`I^`(c^LP>_?o=!_;$$d>o*Wm7iHM^I1KACf#r$pfJ+g#7L_E=g7=k0N z`@f888jQo6g{~6>5}s&AG~cNIZg1fFq-;>3RWSfts805_hfninpwTn#hrP5B^K-_F z-qId2joYnLwJ9O5UU&?KI1IXU#v@fYdL1b1JGfkr{l9x2*p-!XjZk6193oakXyPjj zhg8$^6>U&RKV?(>qhk$U#GN$`hlFERIsPASLBp1lZS2W0TJPMgaoAM)W@Rk*KLh$N zY7NDzsw&!Yzp&=)+VC&pyqA3jz0N+5VpdBBjzrCo2|PdAXZ|0iy4L>lVetzJTBeiB z+QCl_m2vXWCYQzB_voy!w}dm9Be&!wt8cQ}{1##<5A?K~OUX*ShNyd}Z2^8e0(app zWq1vw=M_xIb&|^re4%10=ycexAU*$TZNjb9#2;@7Tj`zcQZVj=6Xdhy zmmg!IeFC@xXrO$iv_{^U$KTbn$d%{!_^vHra&c(=SDFC; zJ>S(lfHm`r`(hUv`$;XJr&{NHOKm@2Dh@KLd_5FKGalwS(&JG&sm3=>L~Ff2^O58? z#5l=X&eLWb*t0Tf_$j*Bw|rTq;#lC=uO^$AOU&JB2x`PyN{d^_@~m%jSr!v3Y}jszKFrzfd|frgPNT`FcM(<)%339vRV1&UjGDCF&8xeG z$s|?uH(_=$NG*`n4PR34VHUXt8)E8IK^#BI?tR5>qjt287IWLJbAs8Ssr#Xyk!+~QA^}IoB zla$SEyB1M21877B5i=g#gn`pFc{_PX1yHsGza~hnaDiu9`U6aDHflvE0Q2E6XTsfW5Dc&vlS>sFVK#U{ z*UShR9PN?e=L29p{*aWr7zZ@GIf%0s(%^%Z2Ghw1G@ylPWB1`DBkeqwQ)gj88QpNc zx*}-R|DC%m0GP5u{gqjcXIg8JVUHXv^=+9aT;N$O1;!9P@>|kMSyg$k6`ow08pT6Z z-$oJmt3kpFwck>Y=5hapvZm@Q$!X3)`PKE}SQ48;nXNfrE1yP8P z7?$*&%BT8>lm2|p-FDl(PpMsTNeYq;=ep*mGnrKp-29Tf{;T~5v&T$Q$SoJFkJ4Hy zMOXHlSvAG5g6N`zEw#;Fy+h^Ew&-Q#Nc}Wbi6lw*r#t=KX7z9q#gM^}lOulXb}l*g z^dFo9&t^QNxbPkgY+$o4lI<6oZmghWZnd{9veeOEcdJ$14Yk7W8W^Kn=b&r+tA4B7 zZAEwGr~CB+UFi3!UCnvktZx)u(Xi`oZn>V{i@l$AAI~&6=Kq!Xra+S!wUcJ9_?Q&c z`FXR1&^+EAV>ajeSm!=9E(Y@>?=915W^0X*`$h&qZ0w?$AV*sh<|LcTML8 zxNR!U_;y-PWhoV&Sd_2?|!>yS6DqH(fg{1OyI^pF!oIBc&` zaT{g}ii~2vGYy}ht~?)W42Ih2ow9!8gMBr{263;|3L|&**|}#8uA;;;VC&cN>~dq0fQ$!N9+rfAMH9 z03g|eb$0YgoLrrzdHMXV0$2~qZ*^ce8E0hdwaMy9%LLZ)n9{d$`{7)@B9+r$5b3v= zwjnpYPxz;K^NZ)10b@!J!iI`X9~#OW(Pn&wPRkA!CmJ@3)M=LyYAx|EQ49BItv2uJ zb}O9=&qeWTsOGg=x&&CJWEzl*i;G2K;-BnIVcV3mVNi`1LXAeo2V(9#5hQ_$M`DcO z*5z!QfIYp!n|YQhGRmozl)%3hNBX8MR7Y#7s+*_VHa^rfH2yq!V)^XLxe?@k{Fs->}^yH^xid)qjXY3a4VwU7|~I?cd?F z9|qqEP6_k0a@oAZ9HBJOQxW$&2IQAmP@I%U>{(Husk8kpQW$8QmCbH=}d2XNDMZ|%Hft+C( z=wqAYrdBJK*`*3H@sPxh#JN5LeqI!oysCUJX*D_*U>jaR+S4X!%6h8cQW2Hv@M!|W zJ1l)X_3Aa_nQVE*PSwD&0h36g&H-esBK`Bp8(nJW590|oD2_lW(!M&rjv}^?t?>2I z^qK~7-MnMD{6_+-`^bB{7ZId8nCCf$+#)K@HT{@$09dsc6Z0O}LQQoR2d__F`uDXa ze#M}$e~o;v6}sLWjltp?dlW8EWrl7wS94$TrJ5CD|re%KeCoR zbwR~bgBg4rD)?!&94S@VH7$qa)%O^b3ju1m9#U#d?Ip^(udT+F)#}V+@MjL3&^Ea( zl~st=t`@tuhM^?ZgJs2(H-=~`zRa(*3Mep+4TTs|jcA-7sg;u>8i4UO-1bV7N$^Ol#_BSdi3=}6M9M(R6utlWQ_;1u2~Wg zE~7V0PdTgl>?((mkd2RwuBUnlT5Ax_dCfJc$}=Gu!RcmODQQjLLc=fH<$Q2-QpX)d z8?X-RJKh%U3}zR+TQu7*B`YfBNuv%#gu^asJx+Nco`|gi=VXm{^Cdc^NsarLm?U>7 zkaHl~1fqC^t|X&}=?y`J%s|XLEIi`kts}=FJ86GM)UCN(n7gzrCZg{Rzz7y!&3|K7 zD>j1lEig_r{}4f+iy;j-qq0xm58MpN$>*u|zV3+feU<21H~omr3H~u1>Y&}}ne7^Ur9r{o z_w;VPuJivQfttvg#!K#Oe_DE#UtcFw=8esEv^(ygzlp>boa#<_W3M&Ywc*S1%c2Kl zuZiLT*3`z~rH9>7Zum|G%Yz86DC_NoHgrSgse$Wb+xuDyE2JtrLl*krHJCol9}AyK zTD^_3=a0Ev=}-jS@|0?ulC5qaiGHw4=jf#tv(*~rX!U+UH7bEJntP{Yv5r@F3iqu1 z`Q!k-0eKkRBqDDpCR}!krn&egaUB;{zX=cs(@0AJ7<(8ezDyPMQFg4#-o!I$4zb>HuI}Je-@H H&ZqnX%MhI1 diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index 9d08d9963a0571252bdf0cfcf0141f4532d3af70..549642460f3f8c1d7989938a389eb4e4c42cb0d9 100644 GIT binary patch delta 455 zcmcc0a)f0ty$tFnMYjYLWauvlV4d#A$|~;IQU7td_(Xvcow-5NpDpLBw0h#b zFX7U6p8LP&elB=m+f>l#eX4$Gg+#{%^#?PYd=6gyAJ0^??a8jMibq<#D^d>0e)oyo zq}}M#^}WCHYF}lN+cFN;H5DuL(;CCs<2Gz4+!6TH;J|}OX1+^7*QIvkvbIT8JYQn? z?$fG9mfegoD}A);kMd71S(V5>d4tzPk+j1z9nWkNdTZc#3XGWk722WQ%mvv4FO#nWQsXzb# delta 491 zcmX@Ya+PI5N&ORUPEl^H%<4ay3=9mvJY5_^G|rb!*z3idDByZtTtrbZfot*H^9o^H zUP&7^3b3qQqoZ5PeDGHJ2i+zCS5A&?W^D;tf`LawvaY$F*m%QN>`uk?dZtf z*7K+Pgm-q+>#{%mv9E8OdbLmXeQo)S!0?0mT?~T0RUsT-B2((Sg&2-FGS*onn=NIZ z^Z5{?F@wmYW6{fQ`Y!K!6Yxw;a0XLG(J7{Kp5RRfr2A_&@GhuQ5q=bKmFa-YL7=A diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 3f2e4c1f71c55068edac3e9703fc988ce1354517..ec5ecedae5d5af5b796a96cffa4fa6539c2004ea 100644 GIT binary patch delta 728 zcmeytcA0HLiNJFXMsaKP^Bqnz7#JAhN?a%NGfUKSWfqhqb18sZH7T8lz!U%fguW{{o4P ztk*i9yY+DHFL^CkfA2zrxV+sSn3Ii-CJvg}<(R^!1E!V8%eEh4sAwL$F*XK3GhRsZ#Cc5as?oFq_4>A@|Wl-%W>E)=pXS?cS_01Me6ltBm(s%NeRU3tT7fn=( z?U2@-w&=wnC9b+_3lhw)&Gr7v*!16Fmu75)>%^uggNRK*E&{I)`3v0DC{5&U3&@@& zqU@R5*S*AaX1SVYahma+%JY@)pT{t?+$srqJS))g>xLzfwioIPD_K*GZY@hZdwpq` z(ptgRR*j2~wbpgUIXMY7+DPR!#T;6(=f#XVGu;W-W`?!hXi~15oFaF_X9n*%ccz$4 zU*^tzHa%1BQxlI|LMI=e;fXw*&k3i382z{Ep0~}r%(GJR&KVPQo8Jb+h)(0hsp_DIjQc4nG)-rmN_*1 z<>me&WWt(oAop3$2jSIALj4Z1UOZ476uCXDZhdU&%iE@**zuw6POGyzGwDp^uJa3m zH+C)YoaC6J+at=#EmvZ~c|;%2Gf zkU#e?*v$nxoNZc8VDYRFpMOGscps|0DN$F5`bqh0TSI>M_$B1(c1 z%M}WW^3yVNQWZ)n3sMy_3rdn17%JvG{=~yk7^b0d%K!8k&!<5Q%*xz)$=t%q!rqfb zn1vNw8cYtSFe`5kQ8<0$%84Uqj>sHgKi%N5z)O$emAGKZCnwXX3=CWh44$rjF6*2U FngEDwN+bXP diff --git a/public/images/browsers/android.png b/public/images/browsers/android.png index 6e28498d2c1a737eb1cd4554a5e8ec631562d584..393001a017e08b8e378bc3ec8ef5acd6c404b2e4 100644 GIT binary patch literal 1510 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>lbcxo13z8 z&b&SIe>XONU&0fY%IkRQ>G5NyXY1+c=ZOdwURdQ^0yK*;$=lt9p@UV{1IXbl@Q5sC zU=XwfVMghjlih%V>?NMQuI$e_n1G74UK;_0m}RO$B1(c1%M}WW^3yVNQWZ)n3sMy- zatjz3JUWktd6(Tb;JNcy#I}iLLPLej%}HgI>rcz=H(pRb>8(nxcyFA>EEe`=ebxPT zb*K3M^hU95+?;E8^uglU^Y^%2Ja~0=yLo(WVC?qGFSo2r+FSY1i+6SE#)UeDBF%4} z_?{9eevq(r#_6c;$Gi^4GC^CnS9pJlJA#Os@96~rDm>9*4JH7~Ok>pd`j&=|(O?q`N@kJgXSvtN2jP5;f# zt0)dR$bC(!#OZ|LtX95ucYQXk3WsG`z@R^NGIcVWU*HEGv%?E?Klxv85V4q8?C?3G z*WW16=->_k=R1ZQ9a>&Yo{=D7Qc!=MNkl-W#cA)-^-?t~ov-&DxX4vopc`3w-OjC9 zukP9GQ`f2|l!&IBf5z=I^FX>nrA5sSx7U1~%HIw#GHa}u&snl@UXz42Ul`l`Wo@wy z$I}y1uC#1UU}-j-{9bVXM?0DQw-xkDmd2lIQu@IxTJ(jNVY>JOv87i&$W01=@aa+m zvqiRvK*@=mP-EV0B}QD+K73iR;MUt!^R2jymojzKUGP$!J%9eI*C)d#l?E@6)Lb8Y z_Sr#ZkL=6e++1~+?q9j!`rWP9l#YjScQM#9%;r_*+WJ1J&*nAC-(BR;uD)Zw z;SF2ywV%-uFKdrmU;Q}qQ~AQDU2|0pcBe1XZ#nH?e(qvfw%X$^DTRLZaORF&-kVL2 z#aCO;ZudybI@kL5;H1B^65G=?Zl=y~*Ny(w<69!apx7pJ(N838!)u)va%{(ouNgT1 zOE5cl#6-5}gtdyw5%oV-MhWL4?r{A$p`iTD!QtPugA*I>NL)C%_I{dx^?!D^y{f0r z>F+Q!HcAkGTTrYJWccp7z+1M%n~XiSwapa&wsm3pKbtF}`FB?TaQ)?~b>iH+dJoo0 z=}vYw!4vmy?cAp=^K;QP`G*nAGZ@R3JXG~=7hKBpb5CsT&9!>*+HY>^FPe1r+vSK? zWqTS^>sjTG{(62$_TP`GSIU2^RBO-Q>h(&}^X>e|Ptpgrx_b8gsFMd~ohhC!jv*44 zW6xg~YBCUExxjL?FSaRmQNsHhxBvYQuRN<_vCF^w+?&I($1IL|sl1&sby>F|t7J;? ziO&*Teu5fPwb^nkuJSKfw(wp->vPWSH-j#`E2*xX+Nj^oqReuEQ-OoUiCOU&*Am8+ z57!&cJjZiJ+`CCMUi!v|wZD?L_ZL_wS^fMt%~&tI<-RV<_V^u-KOXTuD1AXy_SncptHiCrBkVIHPy>UftDnm{r-UW|zXqUO literal 1646 zcmbVN3uxSA98c+NYs-mK!P+VdsoGVWOD?&)USe;vy=&J~)Aez6SBJ3glJD+f?~*i0 z@6kyWb;zup_&_JrQe+|gPQ^~=4SQXMXkm){SWmh(plC!!Rlf5x&pgSulonULt0i z8c$KFRLY)W?W!K79300{G(#~Ai4dfbQB05~6{Df3!4C~lmo-yX72MJY!s>|WC6KFm z4hgNKRxt{3LJ_0VK%*RX+TxT0N@5A8jp*^5aY>|L944S*8VKtsVYQfQszyxx7uBWX zzZpQW6@-$FReeb$N+t}mBZ+7f0E0l=BXZeyNkXM_EL8SCQ z(w&DHYQ#(d9kxYLv@Tjs6mp)j`Y_Lf`X|NQ*#$jYgrh-VElVQ^ZAwE7Drj=)XhgX^ z)@&@hIpA*#dL|}5-F)jKz1Mv3)6psj=Gynq9-M6-46VPdzP|pZCwK2yoPX-|Hyhpk z`lE z+%uEEx4w6_EcAZW$>GZJ>|uOr>A||O`F%UGY{ks@ck#-a-E-G|f6sxbg@$M8$6h$_ zaQ2UvosEn8rWcMJx$mQ$Wp7`B30%|kE!+bymCqiMW=;;?-p0@^H(;laJ^DuTS7f%@ ze&f-KsK+*Dd+sgU@?tYSu=X2I)$+hAYgW2`e&YIQ&1c@NpWivLiCY+$`Sskcu<+Yu XL8X1KGWG4a^+yPFbo*ax-}ca-;utKH diff --git a/public/images/browsers/aol.png b/public/images/browsers/aol.png index 66dc4288e69dfe25867c511f168d82755333efe9..3b6f962f792ca91c8b64202ca7e1fb76034aa787 100644 GIT binary patch delta 363 zcmca4v4nYogd__y0|Ud`yN`l^6id3JuOkD)#(wTUiL8^`xb*8)0(?ST|NsC0?Afzb zrylLQ{_@e=Z`ErrPuX+ZqwiqSyi;1$+b=wN&y%uh($mwaKob~~yxm;O zkH}&M25w;xW@MN(M*=9wUgGKN%Kn^#3CNCp!*l^iWq7(chHzX@PDn@)5D{USCov0b8+ZlSI=aO`Vr9ZB0B)w>L*zn(5QfGuQXn#GI23Z6%o* z=I#p=nkRfY_lcL&q1LJC(r(9X1_ct%yea&>4YO=ywU~<87^-vxURf+KGzD6!TH+c} zl9E`GYL#4+3Zxi}42+C*4J>pG3`2~JtxPSgj7_xN3=FIc3@o|5_oC>?%}>cptHi1U VtidDfGb2y~gQu&X%Q~loCIAfedj9|b delta 3118 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 IM6N<$f)?-j4gdfE diff --git a/public/images/browsers/beaker.png b/public/images/browsers/beaker.png index fbc997cd886840a25d6b85d4c7803263a7704cd7..8d4e61d6905db4b6ee1b784603d4e56835efcc4e 100644 GIT binary patch literal 1058 zcmZ`%Yf#j66#Xp=!tzK7WeGu-Hv$XGo81L*mwmFk?6T~WKo;0#(ZBML)u1$yWeOY2 zGTJa9QG%K}v;0PvffP@?+9Hk-M^t25+ZGBO>JF!GNV|ADVM&6?zRJ&&Ctd-bu(~T56vs zWG9c11sfrPUP9PSedkK^31QsPsSQ#9^bBDzI8PbnIWrc?A66+Wl0OxB7h~2|_;?4) zb7z#fm;8Jqv<2UpB@0X@D=V>Iz#R)@MXd@<+rVi}jqT#63~l6{%}*T@XUt?L9u}kw zN3u<^Yn@&UAA{(TH!~#_Gl+hSi>RkJM~14>V)texzQT*^ zSRbaPBW*21bunaV*q8umDykH4YSCmt5f|bVG*#inHn?gb<3W^!8U>oI5XQo$!M8W* zG`KsDFE8TNP8bESsZg8=a}oY}j5-7MG+^;QP7fn19Ci)rb>K(g=GQ2fBPkf^VR*2J z@8_^72^B?nT8`;)a96`BLmC@Ld*IN+QV3H4oJRB?LPj|Ly%hw}*tY{MH7LzPwE~(v z-1-J7At)_CxeO*T5`th9W3v=_9K6#9&r9I00gsL68!tndu4OWygy;F*&%eIu0({!1Y>uAUQ3 zzus#LsOTB;R0j`Dy|=LZ88s@ogmn2tDrmxLQdnCE2?$v(26?kDj23-eN{!U_7e5AD zD4g5B?glO0S4u>Nu-RJMdD6Y<{N<0o`1I-pR!xWZ#6-}6ZjJiN<+j!fUh3VU9@F)l z@m-IeSn}d9MDheD+iW)HKC@CAHPAlx#)k(#7&&lsZ#!kvjrlR%^@{pls2`^ZQKikwE!MV4N;(+G zbZ=7_&r+K0mCaLBQSECHO!uUCup?-We!8T~sXzT}i{g|O1G z*fTskbnUaleASwt*s|=0w_1L_dgT3MtcMXXCp_aXkG?g{=wgw5X9V;1*ITS~_BfZ+ z;<8t}9D>>%4jM=XFC&x7%j5FW4H=n&Y`!2fE1Aa=@Obe~L_pfEjP P&<3KEmPkg$wvK-Q-V(PE delta 3445 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 X-qUVcJWZ#?00000NkvXXu0mjf3UsQ! diff --git a/public/images/browsers/blackberry.png b/public/images/browsers/blackberry.png index 74f255cb90060cf8a4350dd395283f4d5b4e5625..11ccd8364634ec1bbe54c659ee7c7c566c44335c 100644 GIT binary patch delta 1411 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 DJ+aiG literal 4208 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 Moh_AAV#A307dgCg^#A|> literal 2154 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 Q3H{k&Ywch~I*v{JC!%pf>i_@% literal 2202 zcmbVO2~ZPP7>>XwB4`1xp%vE^14Wa~K_FR)77}7m0!)aCc(Lp*u##lg-35Y(2&gS~ zq!la(*0z90X|00TQpKZ#inP;=)T?T#x78}50!^)obign-F1vxr4tW+o3ZsBs3R35q31Ghk;#lE`#c z3396T+hC#`v}R_&Pn^df3qnC+p~!Ai9}q(wI4Yes_6f&O2sh#;+{`i@R_wr1sU%A> zspKoD9mg*S;C!poIb^(47n8{$f?*>vI5P$u@=`Qo$f9sak27RCjp7j*oSKvE)=;pD z#u1jJ4J2tCj8yEP%77$D7z_l=C(Ia`$-MpC1ze4=xDvESO&|&ph!h5~43^5^AThUv zMWTMFj>L%MtXDu~!7o6$oWT$lc`X=2;bf9FA)LX42}!{r%A5iM4mZLo(n!*rU`{(} zpB6eD9BF1)#EjyRY9+|oEF=gF4n{C>h$KiVkc!0;flL&n5Gc@OOn^y(g5=^5ksK4F zgZI@Wnr@Hm;C<{r-jAUPuCx&2|K_n5oINFAEx~ZX${MJMIDGlQ%18ixSpXxbJ-d}4 zY7Z-pfdik3|LuW(+RRj(BmI|f_rn-6naxCKJS>It)@#!V;*^K%b=YqQ^iLD}&Yr8; zK{(e4>`O-r;x>*ZhMT$0MRP4`YTLIpJf5>YQXOWHZ| z{K5tNqMD1XhGnO#1ZTWtN^R+~fX@TG!rsW<7(Okq`&2>r=mb}{%3*bSvN(T=cFy*s zaka4x33@ANJvH9Pe`_>fdin7u!*a_$y{(jgP%>iF_uG7Av5Nx@!+)RUchuE?Jry1y zG5Dd>KEU(3zmqa7sc91P8PFMEE6=t6FVW<-0frlT-)-MZYJy@@C4JoC^0 zcIie?W3#PpRmU8!%7XIFz%L$7D!jS5t;bw3vQfiiufMnR(xgAz=e<*JvqFy=N)vyo z-gl?uZuaN~E59>scCd@gNBv$WTD)olTdO%)?pZO?h}y2myPyrHJDYt^#iUDeS32$V zZLL@{208AVJdeDR;Hy7&CSoevzT3b4$ot3V&uD72IOkcfwGMZSyZ_VS^kHpK!Myk; zRhoDG*+X-LcPDKJZ;ll&EE}TTpVo42L6v^!FBeCy-tO!^#>pq{@iDO3_+yVMhac`5 zu{WY}-Vy%;SzDV6m;1JO8f1CdkL6!aB)zRYMY0Eeykb|?vCTxonvz}Mv=P(qR!_7& zj5$~o+Nrp3GCFVd?VWQBMMa2z(zZ&^Z`M8tbtlks`{y40;%T8RFh1tU>cib{H$B>t zXyldWzV$WX)wcPldqu5befNWCLquii&mS&?x0O?>U2xt3%jmANd+KJLtSZ}Gsakt@ zsm2&stn4|qN?Eft=lr3@_?&xwO{2BL`TM(*_I_1AUARQGSef7UWJXovs7*s3^#+}`vbyvw==Lde-=hi+0z3hJvkrC1A-QkJ(e***pKSlrm diff --git a/public/images/browsers/ios-webview.png b/public/images/browsers/ios-webview.png index 5f2dd40196c7b37b43ac91b9604f7ba3097c585b..bf7c362e534dbf87c6585df9bf9c1333afdf2744 100644 GIT binary patch delta 1225 zcmV;)1UCD%BK!%E85jfr006c6H|hWY00d`2O+f$vv5yPGVoOIv0RM-N z%)bBt010qNS#tmY4c7nw4c7reD4Tcy000McNliru=Li88J3HHn9@hW>0~>l&Saech zcOY*wjN*-ehY%CU2UP^X+0ceIzsaq7NCG&Ar$Jh`KrQ4khrZ29Iem?*OFCt z?Q=Bf;xpf-Y{_q0OuFr7CD#j9+Fq!^3s3< zIBPTzbjYklTNl+m%=l-RFUU#=zi}tQ1qX<}c0B_Z|tzwKa-oeh!l&S7f2`hWt6D((^4a&g$ zEv)pu8nOl3H>*3Fr%1Nc zoHF}^HTHi?3p+eR$u7e@s;#9@a4x7@c76-{B#0NlCXA3NSZ9kFct~&#U6U;u{mE%8 zP7zpxDL*k_&RE+|>%Hw@7NgX31}x%tblVL0s+t0078IL_t(2&xMgO3c^4TML(zrDi&5+*ob-! zdvkvXYax)%8`xQS06SY7J3BpvIe=J%C`JMz`)94pW)i`jW`}vqXZ{C(03i~@g-Q@2 z001AhSQYp8fk+QKvNsdS`UprBASsQ&0VxoFn;yrka%H9*IV8@28K1Or;F4FdaxzU( zz>tR!g4{NSAG8^^dTi!uk{U2kRp+OgJLOG5`PoC3HntbYx+4WjbSWWnpw>05UK# zGc7PUEif@uGBY|gIXW{mD=;uRFfcg_OT7R903~!qSaf7zbY(hiZ)9m^c>ppnGBYJD nFgPtRF;p@$IyE^uGc+qOFgh?WNLKR!00000NkvXXu0mjfUvU!* literal 4405 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 diff --git a/public/images/browsers/ios.png b/public/images/browsers/ios.png index 5f2dd40196c7b37b43ac91b9604f7ba3097c585b..114bf554c5560ac60ca7fe1ab8a2e52e631fe65b 100644 GIT binary patch delta 1225 zcmV;)1UCD%BK!%E85jfr006c6H|hWY00d`2O+f$vv5yPGVoOIv0RM-N z%)bBt010qNS#tmY4c7nw4c7reD4Tcy000McNliru=Li88J2%olQv3h_0~>l&Saech zcOY*wjN*-ehY%CU2UP^X+0ceIzsaq7NCG&Ar$Jh`KrQ4khrZ29Iem?*OFCt z?Q=Bf;xpf-Y{_q0OuFr7CD#j9+Fq!^3s3< zIBPTzbjYklTNl+m%=l-RFUU#=zi}tQ1qX<}c0B_Z|tzwKa-oeh!l&S7f2`hWt6D((^4a&g$ zEv)pu8nOl3H>*3Fr%1Nc zoHF}^HTHi?3p+eR$u7e@s;#9@a4x7@c76-{B#0NlCXA3NSZ9kFct~&#U6U;u{mE%8 zP7zpxDL*k_&RE+|>%Hw@7NgX31}x%tblVL0s+t0078IL_t(2&xMgO3c^4TML(zrDi&5+*ob-! zdvkvXYax)%8`xQS06SY7J3BpvIe=J%C`JMz`)94pW)i`jW`}vqXZ{C(03i~@g-Q@2 z001AhSQYp8fk+QKvNsdS`UprBASsQ&0VxoFn;yrka%H9*IV8@28K1Or;F4FdaxzU( zz>tR!g4{NSAG8^^dTi!uk{U2kRp+OgJLOG5`PoC3HntbYx+4WjbSWWnpw>05UK# zGc7PUEif@uGBY|gIXW{mD=;uRFfcg_OT7R903~!qSaf7zbY(hiZ)9m^c>ppnGBYJD nFgPtRF;p@$IyE^uGc+qOFgh?WNLKR!00000NkvXXu0mjfXUr20 literal 4405 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 diff --git a/public/images/browsers/miui.png b/public/images/browsers/miui.png index 5f929510f987d9293ae617a31a017b2c3d9f8487..1534a0ca129902c559bd231ee72607857df9f3b4 100644 GIT binary patch literal 1688 zcmcIjdo$WT<)vSlqo1Xh3##~otO{}|E#dOQ}vE(ALf@DRi!zy%@-BEfR#*DB)alhU+2H8Jl6}|C;Ce!YwNhmAnzM9QBq=LOuWDh zOBD&s#6l55>>PQJ_w`eBa(SG=&aA@t?p@R&M*0Z$Xn6XoLrZlhpT)UZm7y!cH8q!g z+t=@VpcAS-!gA=2?JJ^Mrzs*%6xT0YEo`}SPgdLbR4C{YD@HBkRljosf3Ou~#`k$N zKZy}k?(A~ei#PtuWZ@S# zEuw}aS zQGJW5Ipbb_CRG3Qt0n~@=7v=E=?+(yUn5&dFSvDe;_*RFF-0sUdU$NBC0ZCAS*0?W za2r!h9;RTGHnT-h`Zr9^pKeG*&S4pO!=XOu>`NW5A7tn?^`HMzS->TE8rKf-^=ofR zJ?Hn?N%!>^uG^}eF`yF~+I>Ocy|?va?wJG!M~mU+l4z4Q6k|K^?7WrBbfb+jrLq5{ zyy4gp^Nl(iGdmd{AFj_!luQu54)Q-y9s3LKb@t4PmEL=Gyj#bj&$-G1KiN6Hd9+3C z$5_5_zviFayh5V!iS^pw$ijtnF zEZpFqGVJ!&uFWf~N^QyLr^B4njfLWf@I$k<<$p>$D_6zNE$WFwVHryX#uuM`NlPJ4 zrAk%xJA~&9#vU7P$%&kaS(&W!Fd8qe5JQKULz+NZ>VZ2Z=lch8l&w-fe1 z^H^87laPgfoL62fCdWwO+Y`JwD*zoD^4HrDC@G>edYa`Pk*J>j8TJpK=yK_#BlIz zZryF#<+QstON09#I<0BEFkgA-_M7s-*1aZ+>mz+O9y7^5>hISp-p34ER{2%_@nBgS zZ?2@xXVY<$F>Bd(zrEDk`;$e20(QoRijti>6geC+TtBsV(uXrpN9!t zcP9yOKn`>VhArLMmTu2+V7RV!ab-B#(CMypx(ms>^&bXdB0nZF^}h}5pmzk^fV|xO KSeG`1XZ;Q6cLF~E literal 1813 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 diff --git a/public/images/os/beos.png b/public/images/os/beos.png index 6bc4a8a5c58eb439ac01191b0ca8df4c0f1839ed..8e852b0fc82b3096588447ea7b8d2c3671e9ea58 100644 GIT binary patch delta 2601 zcmV+^3fA@V7RD5iBufNmK}|sb0I`n?{9y$E001CkNK#Dz0D2|>0Dy!50Qvv`0D$NK z0Cg|`0P0`>06Lfe02gqax=}m;000JJOGiWi{{a60|De66laV18e*+wPR9JLUVRs;K za&Km7Y-J#Hd2nSQcx`Y107!|&R!O#;Fbv%56gh&CWC*CyN@#YXgFwdD6@e}JlowCm)N7w05)oC z@Q|r*JjIjA3kGYqIHAuIT^R7LoXb(Z)G zueis^gd40C=!B4A6mc$xi#G!qJRnn9Rc&t^ZJS}>=!&bze*}Y8&mS)iDR2tT4$XtK zxRMD66O40UkQpZ8h*0>%M;HbTvA<^l3J4ZL!Pe4O4L*XzW1rw?hCaA9SasLFMo6Q!0SM4_rgZYWGupoqR=EHww*2c^+#vk2rv|7H%J? zsH*)Q3VPi=YRz+ORZ#>r>rOGEAo?!mDB~UMY>_g}f6QCJ(jNB$Hb5BlHNAGPV5#>V z>r{s`<==&-2X>agWR{se2V4fa#T;$7W->0Qf#7j+HGrb6AzN^Kv%0f+i)5zeq|qNF z*)JJ(cqSyfOyp6^mX4wnbQJJ|DE>A2Btk8M#)9rKM1RC=gh_1ZL6EIbihb->^dn5u zM<)i6e>39G0zNXxh`kY@zh!VFKu-qYet0#K$u3fXc6R?mD$s|Kcw^v>{H>kqM}?Y5xj7iuKQh7yR)nsqe(6 z8XdmXYEaKea{tVtUq0hS#kJ+TB>(^hAW1|)R9M69ms^ZoRUO8E-+!-tE^}svPN$_a zQz*1rKns#+h)LC|DG8>1AR_qW11T7Z5+6uVhzVXIKB#RZF-8s7sEJrjxEM5%0#PAo ze>7k$N)<~BRa)A)bUN)hXYak%--mNLog!_AXwnx~a$ffNXRU92-}k>PxjScb8E1K& zbpRUsz<0lrEWSskjU*CK_5Sf896$@>BkoUn($7H;WsTb2=|3Hyb8rsG`yma+nC9j2 z$7xteT(>%IIhq*BUt+Wn8I@ zE8TsYEN*q|bCAuGDU4D9N5Gl=6_8`*fd5pm6S%a8fV+kN?8Fv=s4rDTTBmy2kx47O zDsLTJJv5w;58XuH%Y;mjs9Oirj3 z1S_9ONiQSEt4J2ZN3x<$ESk{Gx#7@GAd+NnIScfrPIY14lPU`ZSzaLc8!p@(G8?s8 zU~0+vym#i$e0cw_p|N~XSr8VNe=Ck8Vp+#!;E;vRn5-wVs$!<}98$^F-SUF*_zn2(h2}gn0QJJkF{`Vb*mmPcfdTO=<4Cg!3)^(;{Cku$SW{3 zMzZjInOy^Z6xWE0A>^R!UeKq3?r95=I|rCgB=x_E5UaPu(ZRc$6lT)rjc%|DW_sS(zkh5>P{_ozE=%!c6N z+Hm*dNS30$r%t|oW}v&{?dcCZy=OZ#L~4!P?Rj&3Xitr}X(nMvU@3bnNnh|(-RU7; z7ne_Lvr0FgPJ+#X%|4$Me<15=;YoXOo~ZJ)0~=?<N^{xJ*XW=RjG5bvs|W4Arp zT>HROZ3Roc{aI*&{PL*k0lD?%b6kM3X8$VSo_UP#KOF<@%1A&0e-7guPKli&yRRzk zI;+K>$f~gs&ECJiv3&HAuy)(^?Poqj-T-Ayp$Wl)a%?yhf^YRfh^)5nlmH|e?kAUX zSt6AgvEA67d1qVRDsLeHHEsP}>%7OWZ(FbQ#Nu{jPhz!G6d6G@2ydw!J9q|WguyP# z(ho?6yMYx<4mNItfBMl32g}#@Xig`WHUX)3xEdu`xp)AQkOCnu$U=#QIPKowJvm2B zgJjn29cmk}{J7Q&n>nf_Y_!0Zbs_j70eH_i$so#zG8{D*?tG+bJx^@xLJ7w=%F7|C zLaD;lGoecm+h}?k_O%DRt2@k3)+e1rmrkUh3#2xx+NkKDe^|V0o?a7O@gT&faBwVh zs(TcV^<-O6(!I*@xr|uUo+9S!4IFp+!_b0DN1yd{9C;s0G9VFC6ZiU;rPQm@(O$%B z2ax>&2`S;KU6*KVJ;?*^U`8B1=SMzVyCP(VKN<{3?o_1+xh|Hxhct$@Ae2?;gp75` zHsy`(SHUlFe-~LJyvp;lR}Jld0UEPM7#P0Q2g5fMD?HS1agSF458?irVB9mdxQFrZ z+R96*^0vk{d}ouxx)`}J3%Y1w-U4%wUhng_419S0Mv2!Y{gAr8c%-mOEcpNoUjg#O%Fe}JkW7FSGW$k zYZv%Vq9z?6C0Z9Xd9|^ey)Ug|qWUMGn^mQ)c=ZV^yR1JN?IZD+qJEh|iLd-So_7ci z|H9erPWhixq~4ef>NO`{N#>UUOK{8+3>=$dpe&ui2|Q~RRK*^oM{s`MZ)lU4roFNc zdU15Ae;PTenpKc^Zt+7rZ;nyD!NPEeC8z>B(Ff*NS;ELUXmk}EoPyaS|GS5YFXn4e zLcNCGu@)^`3Y~%gyaEjV*DsvjPp1tU7=VM5_@lpq5MXFvPXB+Lo^1gC26>*@@hQ`F z5dZ)HC3HntbYx+4WjbSWWnpw>05UK#Gc7PUQ!OwtR5CLS4=kqz|{Qu|w{Vm_$_BpAZ?oR4I8UBPq zq10WRS*wvZT7D}lAx}6q_9OCA5jp$9D3q$U{GNe2oUM;S%^DJT`$&A;Rx-FyI1b=J z91s^BE<(^Kl(kK?2;hc-5;O-476_S`w>9+`w1CINthI0>xQQGp-haO+#uS=%@geai9s|S zN5pao1R|PB#{o1Rg=#@wjwTUEWITb4Cy}uP8pFbpL8hX=Ul_!km>0xY&2s#14B0U; ze2GNFz~iH$qHs}U93&3L6X|q1oU2NNnBQrfvMGFX7?SCSb{mjR=kJ0r^uj>>VQl@vA`?iWGCf z6&n#ZP33Gv3e#Eq0^_J=(0|CIL-3y{_V!v3}%dBe#|g5fNH zkz~bu?+7n2;`=I0fS#%X2Edh9Hxt8^rxipPeETf;+Y$ICAH@d|rT-({-(WBlB#8pV zpj|K$t-mZMJmNfF-iP0K;QyWC)Y%_;_6Hmp1oGwd2tqc~LktumlS_<@DD86KJ_@B| z?!vP3_DOzo&_6AJt+V}TR8D-|(b1so?!|>jnM2`u;=#ajF!;A^ttDsC>+VT7SKD}*7K|t zN3vtI(zo^zb}`EOc{^&u+BJ?&+s<1yb(!~>gX{Z;YIqUF+@pzUbW-vv^T*0QJsW1w zgEVA&6gMTdUQ|B6PUfMzS~rNCp!so@jS%7m{Cfj=`u+DvkFh3>WkP1m(q zSvpk2PBYv2An`i2^xCHdK%em|J`QA;e{$s@)Qmc4AuSINnq zAJD7H%E%g#m1r{j_pUz_RoG&0m!+pXu~RiF<;M0?x`%usOx_+I(_TpXvW#CEP(>5z z8bxZNQ++xk3}`%HBdb%#mrZ2HYLxI^Ol&%qy~J<9)7Om04h`mkHt#~;ecg5Qf;B4g zO8?%D2j@JhM1uH}#^s_0>w%1WKxM%ddrXzJ-sDTer9C509~MnqW;$)2S*h-Q%EF_B_BrC} zIdzF9-;-ULqaQJvo=Kgwtvlv;U!sMHHm7~dSHwG2j1HC5wdGd- z+WX;dV)w1NB^HwE7dLMnV-lYgl_Z%Y7QVi{D89`rvN$EqYdy27)x0}yiAt4fReMSQ zmeP~S|5(r%BW?N3I4l#}H}0#cR-Q9iw6r_#X`{cfpI5W3@ul7KQ26@%%-&QjVFk5N zI`(@0Op&l%djpoNcKerAdKclvEjFBEk8p#R<~{ZQBza%uba?*4L&r}6h1E9dZhJRq zvYF`W+_-dA&(7Ca#Yf~l!^~h#p1*;La|1D(Yj$QWZ-iawA6zLj^|c`VIygRux@L}z zk>$|Ey00I{o9E}1no){khEQrL_V0EZEAQ*js5L>QZntu+f_*Uu49;}l&l!DPCS~{7 zD}+24eb}kE$G^8~s8f5_!+p%ej;!bFzsys=Tpwv<6|EGMG@;RQXT*7XO`=ZP2vwLg zmZ~?&)mGg2LhZ z^GvBhc6#^;DAB;V{F7{*;?K1D{Kn9XIuHM&He)Z^Zs?P(;rA(NZbs-b!>>Uh>IuKF zo9|5Od)jphYaPnV6D&V_XvM|{8;FPCk$OClx}UT z$bK`o`1)}n7oat2c_(7CXMQMH4h&dRq#Eh&4IhUmd#*nFs4g?BSfTRCF!q(>xsb&- zbZ0#V^CoQ+Z<#(l$L#+y@M>E@pUkvAX>MR1+i*pxZnT) diff --git a/public/images/os/blackberry-os.png b/public/images/os/blackberry-os.png index c77db52541db458d831df2b13afbe0113a31c0d8..093a8a40032757fe7f8693921fa47a2d87fa2299 100644 GIT binary patch delta 1916 zcmV-?2ZQ*^7?Tf>8BzoQ007x@vVQ;o00d`2O+f$vv5yP zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u00Lr5M??VshmXv^ks%j<00(qQ zO+^Ri2mu#6I;@T^K>z>)8hTV%bW&k=AaHVTW@&6?Aar?fWgvKMZ~y>EiN#h+mYgsQ z-0KuMf{|}|cHs}mK1rM}ZaLxxCVN}JhW~NuntMT7-#harFUa;1n6GD#BEVLYd9=#c;-~pMY zwN~F7C$~i!I7Y=&Y=S{+xZ}eD5u#6GgkjLY^*swv zK(G)B^`5@!@DU{L>jFnB^ue`cwYl~+8g%hH-&D3dgQSZfdsd%e$_PG%)5h+a0AlXw11$Y)w;c@V5_@(twoN1y;T$eD-NZIQE&5+ zGi3)mJ5#2+M<=Z8aZg|)0a?_j|Bqp1>pQ+t9o{MbF0|aSvjish%=DRX8R*V$wB44; zxS$5eXmV9r=P3i!w%Blbc-{+7X!fIJw`$$dM` z;D_sfV6?Sl+4%)0W=00=q90BUL@I{;9HQM}Sat@<)-dtFzBlyDpO?DgiF+5nNuQ*G z?Ck!BRFDrN@xmYi`P(+G9~F*#QR}a8tO0xhcA()kONSAEr2_f9U47ZDZnqWo?31Wu zv;8!dx*ktp*&o42vF*C>gg@SrwiRC0hQZ?2m;vuZc&mff-&W;K|%*ZqE9o_tw38MUkWP<=H4N@l_C zTAIb-u-IL@P%Iq*@a4(QIP9U~u$Y-c0midymSf0&1Lqw8NWrtE9mlZ%L_4Oe2B1S7 zIz1Hr9Ujx%;s_u245gG(N=U;RmRpaoSPTIHrnYdu)+{06`iMp~Dx~w8xa%;KD3T;e zl9O~3QRYldZ)1@1K7hedx;vprNMXJjd!7=Xu*@3&)zjy5feIBWlrG2uLVOi#k4SQ!54guY-s38nMse-d z00!|zY_Lt9fhOD%Sx%vvbP*A@0s{J(V}%vIW0498F7X2^tndwY808sW<~Fx^hnwVo znk!-xshW@yLuAR4AxV~VjKn)87^R)BV}EApte-&j5F2SMQlyB%F~&KraD^9{Aj25L zY{Y>9`C`bnRin`zprr>4gj72h|Ma?7-_|M0000bbVXQn zWMOn=I%9HWVRU5xGB7eTEigDOFfmjzGdeXnIx{pYFfckWFgXiLy#N3JC3HntbYx+4 zWjbwdWNBu305UK#Gc7PUEif@3R5CL0000 C>3d%Q literal 3146 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 G%D(_=H5YpT diff --git a/public/images/os/ios.png b/public/images/os/ios.png index 1c129ae84c1753b45092a525cecdd49d225c41bf..7e803a6906ee9785aec3970ad60a8f677cbe67b0 100644 GIT binary patch delta 601 zcmew*HIa3Kqy!5C1H;YYP4z&ECEd~2k%3`jKlh(R*2!&L`t?jn-tI08|J(b|><7wo z7I;J!GcfR82Vq7hjoB4ILG}_)Usv|$983%hOq(Ai|6yQYZ1Hq)4DmR=cB;KMv!lS# zUOJLn%fucm_=o#UT@eNZytYPV`xU#8U@3p0fiB#cw;}a}v z$|il&UHozVp(VQ-0&BD`r;5rpb8BRu?~L6QW@yi{ySdnInbG79arO^wH+~i#SUtJ% zyB))~7i|W!W7)&+NpF}o)8*Z+^$x3B1dACfFK{wWKDOtjN*jLC3P+BaEq@-&6N5%Yk;w&TH+c}l9E`G zYL#4+3Zxi}42+C*4J>pG3`2~JtxPSgj7_u+46F!x!WTsW(*5MKM RnGvXi!PC{xWt~$(69A)n^+W&w delta 3415 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 t6PT#2fxLz#e5Qy$0Dy!50Qvv`0D$NK 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 diff --git a/public/images/os/open-bsd.png b/public/images/os/open-bsd.png index 806887e83f48b210c54c554ca86914ec5a2d2917..b3423ccee06b18b427fdb7db3c1b43381521d9eb 100644 GIT binary patch delta 3082 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 diff --git a/public/images/os/os-2.png b/public/images/os/os-2.png index 5f88105dacb2e27b9b4fb36837699a7ea14bece3..8f51e6183f738c2d0541caa225b46081714958e2 100644 GIT binary patch delta 1173 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 AO8@`> diff --git a/public/images/os/qnx.png b/public/images/os/qnx.png index 59d9a44c4363de57e873cbee62ddd0018c8d207a..1cf10fe57dbd525b4534fee128dec06e0603c10a 100644 GIT binary patch literal 2334 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 m0-9*4^;CY#Od0ngjqLHT;r<)TD-Twi2Fu$Sz diff --git a/public/images/os/sun-os.png b/public/images/os/sun-os.png index c19f0eb39924ae56743784b3c5efe675667456da..648eb24175e8e8ff4680421f02e8241ecedc42af 100644 GIT binary patch delta 2261 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 diff --git a/public/images/os/windows-2000.png b/public/images/os/windows-2000.png index 3bccae3fb1731eb95029719ac7eb8b166a2f2e54..8ec7db1876bfc3fe0bcb0662f93271403d30e76c 100644 GIT binary patch delta 2794 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 h(5F)u$Cnb96lO;4@UL7c0Dy!50Qvv`0D$NK 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 h(5F)u$Cnb96lO;4@UL7c0Dy!50Qvv`0D$NK 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 P0000<2SrXqu0mjfW!hFm literal 2723 zcmbVO3pkYN9v_#nC?b*MGDa7-nQtzPnPFTqVq|jJskBbzo0)IMFgG*9jLX&#$7-Xb zQj$Y?4Z~rxdAByOpRcdc^rgcy{;coPExI&+~ro`+o25{r&I%=gW;) z8*E~1V~ju`Oc){bD0oI`hoJ%dmZzqEfF~nq$VNE=G0#FfbP&ZQ)(C|Dpnw&vh-QXS zIT8^T*3`|bVm_z1R{VV0t6xkN2Yp`s6;Q+%!7u-$+$df6n*uK zFnC8p$0-z2DgY>zO01HImB{!2o7twN7iWCB(Ah{$- z#({#8VKtq!)<~&=G6+;iWGsn9I2);mS(Q-)4=f4gyg?x5N|bV!-&}y`paPE8wMk35q9DJ;=X-hURF)3USdhp9O!}1D|P?aS%-UKf?VPCYSION>By` z@L_NLX*vP0@_@DuKidKP(ZuPq-_-0Z9Bu^KUvEjbt z>&v|O1056mf%XzPecd>4ed0uq4rSfMVCVeSb&HM03rutCgFu(7+5UF8`L?Ua+pT+a zDbM()$fUI0CZ`s#X$N>Huc#fk&OJ`g_lY#UDY5#eWA2fc+>gj=C_59_{F0dXUE8vK z@nuWOAG(A)UNr4bj2V(u;Ib!Ou3JeC_@&%8Ty*s-$azuw!73M@5a+bC7kxdpCv(C~ zhb(l53=G^(yI2?ISBV(UY|BnM)9;20CwPn_jsr-G_r|_csO56YSRLn>4r}xedptX# zO471-V*h{Co~i?1-fH~&P#3F|Z#&^>J4|ld*=D96uYPs-UE-pe){9+T3|E`Q-nZlC zzxX*s=fb1>cKhSeH``cN>~fOvM$Y^MHW@XvG?S`vkJVX~bSD6Hx^BgxLvdkE_s1~t z?R^u>ed=h`rkAfvi&`z?fqx#t=>|t`IwN? z_*(4l@l&?Di$?m5>#IKc13`=PQFr4ELF)nfq-BP#_eY?u;AVBGaqbQSbp}8qi;a$+ zQ?nw^*^O;pqDK7_&o9jL?;}n19B4N3S|PMyT_L7p)%O)6l}))GOPRbwPq*&Xw%vlPoibC{Mj( zDXAS9jY)us?~Su{jV71UeQDu`YWN1dYS(6_Rqfu3Fx3947`M|OFcB!Gab`}LERIJ$ZPOY4O*3|E5>8G4C zWyKRIF7}hYeYsoveJ#68N_MeX)VADdv7gX8W7z!H z2$qfL2hNOet&fg;pqqE9iaSviHTN^%>MHR12 zo^Um6C`xO1&m4?fia&VgG{r{UU_Mw%D7>n4-qgZbW98N>NYlM_biGt48dYXi?-_2M z3LB%g~>lSSw*hof_HmYmAnqTVR3se zFpO5~to*_AZ*KQ4m*BsBvn{NlkACTfX*uIsbhq$Ji2d-zjEy!JPe5wwswF Fe*iRDIduR4 diff --git a/public/images/os/windows-8-1.png b/public/images/os/windows-8-1.png index 3ce98aaaf9152cdc6117aa39e222038d2d79f063..f6605f4c53eaa36ec6325def7a6d9bc639b4f22d 100644 GIT binary patch literal 1412 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 h(5F)u$Cnb96lO;4@UL7c0Dy!50Qvv`0D$NK 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 h(5F)u$Cnb96lO;4@UL7c0Dy!50Qvv`0D$NK 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 P0000<2SrXqu0mjfW!hFm literal 2723 zcmbVO3pkYN9v_#nC?b*MGDa7-nQtzPnPFTqVq|jJskBbzo0)IMFgG*9jLX&#$7-Xb zQj$Y?4Z~rxdAByOpRcdc^rgcy{;coPExI&+~ro`+o25{r&I%=gW;) z8*E~1V~ju`Oc){bD0oI`hoJ%dmZzqEfF~nq$VNE=G0#FfbP&ZQ)(C|Dpnw&vh-QXS zIT8^T*3`|bVm_z1R{VV0t6xkN2Yp`s6;Q+%!7u-$+$df6n*uK zFnC8p$0-z2DgY>zO01HImB{!2o7twN7iWCB(Ah{$- z#({#8VKtq!)<~&=G6+;iWGsn9I2);mS(Q-)4=f4gyg?x5N|bV!-&}y`paPE8wMk35q9DJ;=X-hURF)3USdhp9O!}1D|P?aS%-UKf?VPCYSION>By` z@L_NLX*vP0@_@DuKidKP(ZuPq-_-0Z9Bu^KUvEjbt z>&v|O1056mf%XzPecd>4ed0uq4rSfMVCVeSb&HM03rutCgFu(7+5UF8`L?Ua+pT+a zDbM()$fUI0CZ`s#X$N>Huc#fk&OJ`g_lY#UDY5#eWA2fc+>gj=C_59_{F0dXUE8vK z@nuWOAG(A)UNr4bj2V(u;Ib!Ou3JeC_@&%8Ty*s-$azuw!73M@5a+bC7kxdpCv(C~ zhb(l53=G^(yI2?ISBV(UY|BnM)9;20CwPn_jsr-G_r|_csO56YSRLn>4r}xedptX# zO471-V*h{Co~i?1-fH~&P#3F|Z#&^>J4|ld*=D96uYPs-UE-pe){9+T3|E`Q-nZlC zzxX*s=fb1>cKhSeH``cN>~fOvM$Y^MHW@XvG?S`vkJVX~bSD6Hx^BgxLvdkE_s1~t z?R^u>ed=h`rkAfvi&`z?fqx#t=>|t`IwN? z_*(4l@l&?Di$?m5>#IKc13`=PQFr4ELF)nfq-BP#_eY?u;AVBGaqbQSbp}8qi;a$+ zQ?nw^*^O;pqDK7_&o9jL?;}n19B4N3S|PMyT_L7p)%O)6l}))GOPRbwPq*&Xw%vlPoibC{Mj( zDXAS9jY)us?~Su{jV71UeQDu`YWN1dYS(6_Rqfu3Fx3947`M|OFcB!Gab`}LERIJ$ZPOY4O*3|E5>8G4C zWyKRIF7}hYeYsoveJ#68N_MeX)VADdv7gX8W7z!H z2$qfL2hNOet&fg;pqqE9iaSviHTN^%>MHR12 zo^Um6C`xO1&m4?fia&VgG{r{UU_Mw%D7>n4-qgZbW98N>NYlM_biGt48dYXi?-_2M z3LB%g~>lSSw*hof_HmYmAnqTVR3se zFpO5~to*_AZ*KQ4m*BsBvn{NlkACTfX*uIsbhq$Ji2d-zjEy!JPe5wwswF Fe*iRDIduR4 diff --git a/public/images/os/windows-server-2003.png b/public/images/os/windows-server-2003.png index cd2db79e40aa9d3434ca2bac727d692aee997142..4a899a30f8ae88d3cca51b71c3d7d82887c14882 100644 GIT binary patch delta 2397 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 P0000<2SrXqu0mjfW!hFm literal 2723 zcmbVO3pkYN9v_#nC?b*MGDa7-nQtzPnPFTqVq|jJskBbzo0)IMFgG*9jLX&#$7-Xb zQj$Y?4Z~rxdAByOpRcdc^rgcy{;coPExI&+~ro`+o25{r&I%=gW;) z8*E~1V~ju`Oc){bD0oI`hoJ%dmZzqEfF~nq$VNE=G0#FfbP&ZQ)(C|Dpnw&vh-QXS zIT8^T*3`|bVm_z1R{VV0t6xkN2Yp`s6;Q+%!7u-$+$df6n*uK zFnC8p$0-z2DgY>zO01HImB{!2o7twN7iWCB(Ah{$- z#({#8VKtq!)<~&=G6+;iWGsn9I2);mS(Q-)4=f4gyg?x5N|bV!-&}y`paPE8wMk35q9DJ;=X-hURF)3USdhp9O!}1D|P?aS%-UKf?VPCYSION>By` z@L_NLX*vP0@_@DuKidKP(ZuPq-_-0Z9Bu^KUvEjbt z>&v|O1056mf%XzPecd>4ed0uq4rSfMVCVeSb&HM03rutCgFu(7+5UF8`L?Ua+pT+a zDbM()$fUI0CZ`s#X$N>Huc#fk&OJ`g_lY#UDY5#eWA2fc+>gj=C_59_{F0dXUE8vK z@nuWOAG(A)UNr4bj2V(u;Ib!Ou3JeC_@&%8Ty*s-$azuw!73M@5a+bC7kxdpCv(C~ zhb(l53=G^(yI2?ISBV(UY|BnM)9;20CwPn_jsr-G_r|_csO56YSRLn>4r}xedptX# zO471-V*h{Co~i?1-fH~&P#3F|Z#&^>J4|ld*=D96uYPs-UE-pe){9+T3|E`Q-nZlC zzxX*s=fb1>cKhSeH``cN>~fOvM$Y^MHW@XvG?S`vkJVX~bSD6Hx^BgxLvdkE_s1~t z?R^u>ed=h`rkAfvi&`z?fqx#t=>|t`IwN? z_*(4l@l&?Di$?m5>#IKc13`=PQFr4ELF)nfq-BP#_eY?u;AVBGaqbQSbp}8qi;a$+ zQ?nw^*^O;pqDK7_&o9jL?;}n19B4N3S|PMyT_L7p)%O)6l}))GOPRbwPq*&Xw%vlPoibC{Mj( zDXAS9jY)us?~Su{jV71UeQDu`YWN1dYS(6_Rqfu3Fx3947`M|OFcB!Gab`}LERIJ$ZPOY4O*3|E5>8G4C zWyKRIF7}hYeYsoveJ#68N_MeX)VADdv7gX8W7z!H z2$qfL2hNOet&fg;pqqE9iaSviHTN^%>MHR12 zo^Um6C`xO1&m4?fia&VgG{r{UU_Mw%D7>n4-qgZbW98N>NYlM_biGt48dYXi?-_2M z3LB%g~>lSSw*hof_HmYmAnqTVR3se zFpO5~to*_AZ*KQ4m*BsBvn{NlkACTfX*uIsbhq$Ji2d-zjEy!JPe5wwswF Fe*iRDIduR4 diff --git a/public/images/os/windows-vista.png b/public/images/os/windows-vista.png index cd2db79e40aa9d3434ca2bac727d692aee997142..4a899a30f8ae88d3cca51b71c3d7d82887c14882 100644 GIT binary patch delta 2397 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 P0000<2SrXqu0mjfW!hFm literal 2723 zcmbVO3pkYN9v_#nC?b*MGDa7-nQtzPnPFTqVq|jJskBbzo0)IMFgG*9jLX&#$7-Xb zQj$Y?4Z~rxdAByOpRcdc^rgcy{;coPExI&+~ro`+o25{r&I%=gW;) z8*E~1V~ju`Oc){bD0oI`hoJ%dmZzqEfF~nq$VNE=G0#FfbP&ZQ)(C|Dpnw&vh-QXS zIT8^T*3`|bVm_z1R{VV0t6xkN2Yp`s6;Q+%!7u-$+$df6n*uK zFnC8p$0-z2DgY>zO01HImB{!2o7twN7iWCB(Ah{$- z#({#8VKtq!)<~&=G6+;iWGsn9I2);mS(Q-)4=f4gyg?x5N|bV!-&}y`paPE8wMk35q9DJ;=X-hURF)3USdhp9O!}1D|P?aS%-UKf?VPCYSION>By` z@L_NLX*vP0@_@DuKidKP(ZuPq-_-0Z9Bu^KUvEjbt z>&v|O1056mf%XzPecd>4ed0uq4rSfMVCVeSb&HM03rutCgFu(7+5UF8`L?Ua+pT+a zDbM()$fUI0CZ`s#X$N>Huc#fk&OJ`g_lY#UDY5#eWA2fc+>gj=C_59_{F0dXUE8vK z@nuWOAG(A)UNr4bj2V(u;Ib!Ou3JeC_@&%8Ty*s-$azuw!73M@5a+bC7kxdpCv(C~ zhb(l53=G^(yI2?ISBV(UY|BnM)9;20CwPn_jsr-G_r|_csO56YSRLn>4r}xedptX# zO471-V*h{Co~i?1-fH~&P#3F|Z#&^>J4|ld*=D96uYPs-UE-pe){9+T3|E`Q-nZlC zzxX*s=fb1>cKhSeH``cN>~fOvM$Y^MHW@XvG?S`vkJVX~bSD6Hx^BgxLvdkE_s1~t z?R^u>ed=h`rkAfvi&`z?fqx#t=>|t`IwN? z_*(4l@l&?Di$?m5>#IKc13`=PQFr4ELF)nfq-BP#_eY?u;AVBGaqbQSbp}8qi;a$+ zQ?nw^*^O;pqDK7_&o9jL?;}n19B4N3S|PMyT_L7p)%O)6l}))GOPRbwPq*&Xw%vlPoibC{Mj( zDXAS9jY)us?~Su{jV71UeQDu`YWN1dYS(6_Rqfu3Fx3947`M|OFcB!Gab`}LERIJ$ZPOY4O*3|E5>8G4C zWyKRIF7}hYeYsoveJ#68N_MeX)VADdv7gX8W7z!H z2$qfL2hNOet&fg;pqqE9iaSviHTN^%>MHR12 zo^Um6C`xO1&m4?fia&VgG{r{UU_Mw%D7>n4-qgZbW98N>NYlM_biGt48dYXi?-_2M z3LB%g~>lSSw*hof_HmYmAnqTVR3se zFpO5~to*_AZ*KQ4m*BsBvn{NlkACTfX*uIsbhq$Ji2d-zjEy!JPe5wwswF Fe*iRDIduR4 diff --git a/public/images/os/windows-xp.png b/public/images/os/windows-xp.png index cd2db79e40aa9d3434ca2bac727d692aee997142..4a899a30f8ae88d3cca51b71c3d7d82887c14882 100644 GIT binary patch delta 2397 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 P0000<2SrXqu0mjfW!hFm literal 2723 zcmbVO3pkYN9v_#nC?b*MGDa7-nQtzPnPFTqVq|jJskBbzo0)IMFgG*9jLX&#$7-Xb zQj$Y?4Z~rxdAByOpRcdc^rgcy{;coPExI&+~ro`+o25{r&I%=gW;) z8*E~1V~ju`Oc){bD0oI`hoJ%dmZzqEfF~nq$VNE=G0#FfbP&ZQ)(C|Dpnw&vh-QXS zIT8^T*3`|bVm_z1R{VV0t6xkN2Yp`s6;Q+%!7u-$+$df6n*uK zFnC8p$0-z2DgY>zO01HImB{!2o7twN7iWCB(Ah{$- z#({#8VKtq!)<~&=G6+;iWGsn9I2);mS(Q-)4=f4gyg?x5N|bV!-&}y`paPE8wMk35q9DJ;=X-hURF)3USdhp9O!}1D|P?aS%-UKf?VPCYSION>By` z@L_NLX*vP0@_@DuKidKP(ZuPq-_-0Z9Bu^KUvEjbt z>&v|O1056mf%XzPecd>4ed0uq4rSfMVCVeSb&HM03rutCgFu(7+5UF8`L?Ua+pT+a zDbM()$fUI0CZ`s#X$N>Huc#fk&OJ`g_lY#UDY5#eWA2fc+>gj=C_59_{F0dXUE8vK z@nuWOAG(A)UNr4bj2V(u;Ib!Ou3JeC_@&%8Ty*s-$azuw!73M@5a+bC7kxdpCv(C~ zhb(l53=G^(yI2?ISBV(UY|BnM)9;20CwPn_jsr-G_r|_csO56YSRLn>4r}xedptX# zO471-V*h{Co~i?1-fH~&P#3F|Z#&^>J4|ld*=D96uYPs-UE-pe){9+T3|E`Q-nZlC zzxX*s=fb1>cKhSeH``cN>~fOvM$Y^MHW@XvG?S`vkJVX~bSD6Hx^BgxLvdkE_s1~t z?R^u>ed=h`rkAfvi&`z?fqx#t=>|t`IwN? z_*(4l@l&?Di$?m5>#IKc13`=PQFr4ELF)nfq-BP#_eY?u;AVBGaqbQSbp}8qi;a$+ zQ?nw^*^O;pqDK7_&o9jL?;}n19B4N3S|PMyT_L7p)%O)6l}))GOPRbwPq*&Xw%vlPoibC{Mj( zDXAS9jY)us?~Su{jV71UeQDu`YWN1dYS(6_Rqfu3Fx3947`M|OFcB!Gab`}LERIJ$ZPOY4O*3|E5>8G4C zWyKRIF7}hYeYsoveJ#68N_MeX)VADdv7gX8W7z!H z2$qfL2hNOet&fg;pqqE9iaSviHTN^%>MHR12 zo^Um6C`xO1&m4?fia&VgG{r{UU_Mw%D7>n4-qgZbW98N>NYlM_biGt48dYXi?-_2M z3LB%g~>lSSw*hof_HmYmAnqTVR3se zFpO5~to*_AZ*KQ4m*BsBvn{NlkACTfX*uIsbhq$Ji2d-zjEy!JPe5wwswF Fe*iRDIduR4 diff --git a/public/mstile-150x150.png b/public/mstile-150x150.png index 7f73bcd84d4b9eb920a14f92d1eaea37a6480de4..ae4fd79c1c12b638413ad613966d7a7e26663198 100644 GIT binary patch literal 2602 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 diff --git a/public/safari-pinned-tab.svg b/public/safari-pinned-tab.svg index 179f3e69..2d116eb8 100644 --- a/public/safari-pinned-tab.svg +++ b/public/safari-pinned-tab.svg @@ -1,75 +1 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file From 7b9c29e039cae638475047386d814138237cf3b4 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 2 Aug 2023 11:56:42 -0700 Subject: [PATCH 02/73] Check for DISABLE_LOGIN on api route. --- pages/api/auth/login.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pages/api/auth/login.ts b/pages/api/auth/login.ts index af206938..b9a2be00 100644 --- a/pages/api/auth/login.ts +++ b/pages/api/auth/login.ts @@ -7,6 +7,7 @@ import { checkPassword, createSecureToken, methodNotAllowed, + forbidden, } from 'next-basics'; import redis from '@umami/redis-client'; import { getUserByUsername } from 'queries'; @@ -30,6 +31,10 @@ export default async ( req: NextApiRequestQueryBody, res: NextApiResponse, ) => { + if (process.env.DISABLE_LOGIN) { + return forbidden(res); + } + if (req.method === 'POST') { const { username, password } = req.body; From 4497951000065e9cc57ac8556bcc5a1bf04087ea Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 2 Aug 2023 14:21:13 -0700 Subject: [PATCH 03/73] Split out session query. --- components/layout/AppLayout.js | 6 +- lib/clickhouse.ts | 7 +- lib/prisma.ts | 22 ++--- pages/api/websites/[id]/metrics.ts | 4 +- pages/api/websites/[id]/pageviews.ts | 6 +- .../analytics/pageviews/getPageviewMetrics.ts | 3 +- .../analytics/pageviews/getPageviewStats.ts | 41 ++++---- .../analytics/sessions/getSessionMetrics.ts | 8 +- queries/analytics/sessions/getSessionStats.ts | 98 +++++++++++++++++++ 9 files changed, 139 insertions(+), 56 deletions(-) create mode 100644 queries/analytics/sessions/getSessionStats.ts diff --git a/components/layout/AppLayout.js b/components/layout/AppLayout.js index 989128f9..7ab74351 100644 --- a/components/layout/AppLayout.js +++ b/components/layout/AppLayout.js @@ -2,9 +2,7 @@ import { Container } from 'react-basics'; import Head from 'next/head'; import NavBar from 'components/layout/NavBar'; import UpdateNotice from 'components/common/UpdateNotice'; -import useRequireLogin from 'hooks/useRequireLogin'; -import useConfig from 'hooks/useConfig'; -import { CURRENT_VERSION } from 'lib/constants'; +import { useRequireLogin, useConfig } from 'hooks'; import styles from './AppLayout.module.css'; export function AppLayout({ title, children }) { @@ -16,7 +14,7 @@ export function AppLayout({ title, children }) { } return ( -

+
{title ? `${title} | umami` : 'umami'} diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index b3dc2c48..d294110c 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -61,14 +61,13 @@ function getDateFormat(date) { return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`; } -function getFilterQuery(filters = {}, params = {}) { +function getFilterQuery(filters = {}) { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; if (filter !== undefined) { const column = FILTER_COLUMNS[key] || key; arr.push(`and ${column} = {${key}:String}`); - params[key] = decodeURIComponent(filter); } return arr; @@ -77,9 +76,9 @@ function getFilterQuery(filters = {}, params = {}) { return query.join('\n'); } -function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) { +function parseFilters(filters: WebsiteMetricFilter = {}) { return { - filterQuery: getFilterQuery(filters, params), + filterQuery: getFilterQuery(filters), }; } diff --git a/lib/prisma.ts b/lib/prisma.ts index 08309e31..0a52dd7e 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -1,7 +1,7 @@ import prisma from '@umami/prisma-client'; import moment from 'moment-timezone'; import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; -import { FILTER_COLUMNS } from './constants'; +import { FILTER_COLUMNS, SESSION_COLUMNS } from './constants'; const MYSQL_DATE_FORMATS = { minute: '%Y-%m-%d %H:%i:00', @@ -64,14 +64,13 @@ function getTimestampIntervalQuery(field: string): string { } } -function getFilterQuery(filters = {}, params = []): string { +function getFilterQuery(filters = {}): string { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; if (filter !== undefined) { const column = FILTER_COLUMNS[key] || key; arr.push(`and ${column}={{${key}}}`); - params.push(decodeURIComponent(filter)); } return arr; @@ -80,19 +79,12 @@ function getFilterQuery(filters = {}, params = []): string { return query.join('\n'); } -function parseFilters( - filters: { [key: string]: any } = {}, - params = [], - sessionKey = 'session_id', -) { - const { os, browser, device, country, region, city } = filters; - +function parseFilters(filters: { [key: string]: any } = {}) { return { - joinSession: - os || browser || device || country || region || city - ? `inner join session on website_event.${sessionKey} = session.${sessionKey}` - : '', - filterQuery: getFilterQuery(filters, params), + joinSession: Object.keys(filters).find(key => SESSION_COLUMNS[key]) + ? `inner join session on website_event.session_id = session.session_id` + : '', + filterQuery: getFilterQuery(filters), }; } diff --git a/pages/api/websites/[id]/metrics.ts b/pages/api/websites/[id]/metrics.ts index 37a04691..fa0b7554 100644 --- a/pages/api/websites/[id]/metrics.ts +++ b/pages/api/websites/[id]/metrics.ts @@ -68,7 +68,7 @@ export default async ( filters[type] = undefined; - let data = await getSessionMetrics(websiteId, { + const data = await getSessionMetrics(websiteId, { startDate, endDate, column, @@ -88,7 +88,7 @@ export default async ( } } - data = Object.values(combined); + return ok(res, Object.values(combined)); } return ok(res, data); diff --git a/pages/api/websites/[id]/pageviews.ts b/pages/api/websites/[id]/pageviews.ts index 453c6733..810854a7 100644 --- a/pages/api/websites/[id]/pageviews.ts +++ b/pages/api/websites/[id]/pageviews.ts @@ -6,6 +6,7 @@ import { canViewWebsite } from 'lib/auth'; import { useAuth, useCors } from 'lib/middleware'; import { getPageviewStats } from 'queries'; import { parseDateRangeQuery } from 'lib/query'; +import { getSessionStats } from '../../../../queries/analytics/sessions/getSessionStats'; export interface WebsitePageviewRequestQuery { id: string; @@ -62,7 +63,6 @@ export default async ( endDate, timezone, unit, - count: '*', filters: { url, referrer, @@ -75,14 +75,14 @@ export default async ( city, }, }), - getPageviewStats(websiteId, { + getSessionStats(websiteId, { startDate, endDate, timezone, unit, - count: 'distinct website_event.', filters: { url, + referrer, title, os, browser, diff --git a/queries/analytics/pageviews/getPageviewMetrics.ts b/queries/analytics/pageviews/getPageviewMetrics.ts index 1032540b..a5da178a 100644 --- a/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/queries/analytics/pageviews/getPageviewMetrics.ts @@ -84,6 +84,7 @@ async function clickhouseQuery( const { rawQuery, parseFilters } = clickhouse; const website = await loadWebsite(websiteId); const params = { + ...filters, websiteId, startDate: maxDate(startDate, website.resetAt), endDate, @@ -98,7 +99,7 @@ async function clickhouseQuery( params.domain = website.domain; } - const { filterQuery } = parseFilters(filters, params); + const { filterQuery } = parseFilters(filters); return rawQuery( ` diff --git a/queries/analytics/pageviews/getPageviewStats.ts b/queries/analytics/pageviews/getPageviewStats.ts index f6d4158c..6d702993 100644 --- a/queries/analytics/pageviews/getPageviewStats.ts +++ b/queries/analytics/pageviews/getPageviewStats.ts @@ -10,9 +10,19 @@ export interface PageviewStatsCriteria { endDate: Date; timezone?: string; unit?: string; - count?: string; - filters: object; - sessionKey?: string; + filters: { + url?: string; + referrer?: string; + title?: string; + browser?: string; + os?: string; + device?: string; + screen?: string; + language?: string; + country?: string; + region?: string; + city?: string; + }; } export async function getPageviewStats( @@ -25,15 +35,7 @@ export async function getPageviewStats( } async function relationalQuery(websiteId: string, criteria: PageviewStatsCriteria) { - const { - startDate, - endDate, - timezone = 'utc', - unit = 'day', - count = '*', - filters = {}, - sessionKey = 'session_id', - } = criteria; + const { startDate, endDate, timezone = 'utc', unit = 'day', filters = {} } = criteria; const { getDateQuery, parseFilters, rawQuery } = prisma; const website = await loadWebsite(websiteId); const { filterQuery, joinSession } = parseFilters(filters); @@ -42,7 +44,7 @@ async function relationalQuery(websiteId: string, criteria: PageviewStatsCriteri ` select ${getDateQuery('website_event.created_at', unit, timezone)} x, - count(${count !== '*' ? `${count}${sessionKey}` : count}) y + count(*) y from website_event ${joinSession} where website_event.website_id = {{websiteId::uuid}} @@ -52,24 +54,17 @@ async function relationalQuery(websiteId: string, criteria: PageviewStatsCriteri group by 1 `, { + ...filters, websiteId, startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, - ...filters, }, ); } async function clickhouseQuery(websiteId: string, criteria: PageviewStatsCriteria) { - const { - startDate, - endDate, - timezone = 'UTC', - unit = 'day', - count = '*', - filters = {}, - } = criteria; + const { startDate, endDate, timezone = 'UTC', unit = 'day', filters = {} } = criteria; const { parseFilters, rawQuery, getDateStringQuery, getDateQuery } = clickhouse; const website = await loadWebsite(websiteId); const { filterQuery } = parseFilters(filters); @@ -82,7 +77,7 @@ async function clickhouseQuery(websiteId: string, criteria: PageviewStatsCriteri from ( select ${getDateQuery('created_at', unit, timezone)} as t, - count(${count !== '*' ? 'distinct session_id' : count}) as y + count(*) as y from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} diff --git a/queries/analytics/sessions/getSessionMetrics.ts b/queries/analytics/sessions/getSessionMetrics.ts index aec2d8f1..2512b06c 100644 --- a/queries/analytics/sessions/getSessionMetrics.ts +++ b/queries/analytics/sessions/getSessionMetrics.ts @@ -28,8 +28,8 @@ async function relationalQuery( return rawQuery( `select ${column} x, count(*) y - from session as x - where x.session_id in ( + from session as s + where s.session_id in ( select website_event.session_id from website_event join website @@ -38,7 +38,7 @@ async function relationalQuery( where website.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} - ) + ) as t group by 1 order by 2 desc limit 100`, @@ -64,7 +64,7 @@ async function clickhouseQuery( ` select ${column} x, count(distinct session_id) y - from website_event as x + from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} and event_type = {eventType:UInt32} diff --git a/queries/analytics/sessions/getSessionStats.ts b/queries/analytics/sessions/getSessionStats.ts new file mode 100644 index 00000000..966fd91f --- /dev/null +++ b/queries/analytics/sessions/getSessionStats.ts @@ -0,0 +1,98 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; +import { EVENT_TYPE } from 'lib/constants'; +import { loadWebsite } from 'lib/load'; +import { maxDate } from 'lib/date'; + +export interface SessionStatsCriteria { + startDate: Date; + endDate: Date; + timezone?: string; + unit?: string; + filters: { + url?: string; + referrer?: string; + title?: string; + browser?: string; + os?: string; + device?: string; + screen?: string; + language?: string; + country?: string; + region?: string; + city?: string; + }; +} + +export async function getSessionStats( + ...args: [websiteId: string, criteria: SessionStatsCriteria] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, criteria: SessionStatsCriteria) { + const { startDate, endDate, timezone = 'utc', unit = 'day', filters = {} } = criteria; + const { getDateQuery, parseFilters, rawQuery } = prisma; + const website = await loadWebsite(websiteId); + const { filterQuery, joinSession } = parseFilters(filters); + + return rawQuery( + ` + select + ${getDateQuery('website_event.created_at', unit, timezone)} x, + count(distinct website_event.session_id) y + from website_event + ${joinSession} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and event_type = {{eventType}} + ${filterQuery} + group by 1 + `, + { + ...filters, + websiteId, + startDate: maxDate(startDate, website.resetAt), + endDate, + eventType: EVENT_TYPE.pageView, + }, + ); +} + +async function clickhouseQuery(websiteId: string, criteria: SessionStatsCriteria) { + const { startDate, endDate, timezone = 'UTC', unit = 'day', filters = {} } = criteria; + const { parseFilters, rawQuery, getDateStringQuery, getDateQuery } = clickhouse; + const website = await loadWebsite(websiteId); + const { filterQuery } = parseFilters(filters); + + return rawQuery( + ` + select + ${getDateStringQuery('g.t', unit)} as x, + g.y as y + from ( + select + ${getDateQuery('created_at', unit, timezone)} as t, + count(distinct session_id) as y + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime} and {endDate:DateTime} + and event_type = {eventType:UInt32} + ${filterQuery} + group by t + ) as g + order by t + `, + { + ...filters, + websiteId, + startDate: maxDate(startDate, website.resetAt), + endDate, + eventType: EVENT_TYPE.pageView, + }, + ); +} From 2d04e46dedbaac8c968c9829a6386f69f35f4206 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 3 Aug 2023 10:44:35 -0700 Subject: [PATCH 04/73] Insights progress update. --- components/pages/reports/ReportTemplates.js | 2 - .../pages/reports/insights/InsightsTable.js | 6 +- queries/analytics/reports/getInsights.ts | 84 ++++++++++++++++++- 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js index 60ae11e7..c1e0acdf 100644 --- a/components/pages/reports/ReportTemplates.js +++ b/components/pages/reports/ReportTemplates.js @@ -33,14 +33,12 @@ export function ReportTemplates() { const { formatMessage, labels } = useMessages(); const reports = [ - /* { title: formatMessage(labels.insights), description: 'Dive deeper into your data by using segments and filters.', url: '/reports/insights', icon: , }, - */ { title: formatMessage(labels.funnel), description: 'Understand the conversion and drop-off rate of users.', diff --git a/components/pages/reports/insights/InsightsTable.js b/components/pages/reports/insights/InsightsTable.js index a767468e..d751445b 100644 --- a/components/pages/reports/insights/InsightsTable.js +++ b/components/pages/reports/insights/InsightsTable.js @@ -6,11 +6,13 @@ import { ReportContext } from '../Report'; export function InsightsTable() { const { report } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); + const { fields = [] } = report?.parameters || {}; return ( - - + {fields.map(({ name }) => { + return ; + })} ); diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index 1d8970ed..68f06e21 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -1,11 +1,14 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; +import { maxDate } from 'lib/date'; +import { EVENT_TYPE } from 'lib/constants'; +import { loadWebsite } from 'lib/load'; export interface GetInsightsCriteria { startDate: Date; endDate: Date; - fields: string[]; + fields: { name: string; type: string; value: string }[]; filters: string[]; groups: string[]; } @@ -26,7 +29,33 @@ async function relationalQuery( y: number; }[] > { - return null; + const { startDate, endDate, fields = [], filters = [], groups = [] } = criteria; + const { parseFilters, rawQuery } = prisma; + const website = await loadWebsite(websiteId); + const params = {}; + const { filterQuery, joinSession } = parseFilters(params); + + return rawQuery( + ` + select + url_path, + count(*) y + from website_event + ${joinSession} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type = {{eventType}} + ${filterQuery} + group by 1 + `, + { + ...filters, + websiteId, + startDate: maxDate(startDate, website.resetAt), + endDate, + eventType: EVENT_TYPE.pageView, + }, + ); } async function clickhouseQuery( @@ -38,5 +67,54 @@ async function clickhouseQuery( y: number; }[] > { - return null; + const { startDate, endDate, fields = [], filters = [], groups = [] } = criteria; + const { parseFilters, rawQuery } = clickhouse; + const website = await loadWebsite(websiteId); + const params = {}; + const { filterQuery } = parseFilters(params); + + const fieldsQuery = parseFields(fields); + + return rawQuery( + ` + select + ${fieldsQuery} + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime} and {endDate:DateTime} + and event_type = {eventType:UInt32} + ${filterQuery} + group by ${fields.map(({ name }) => name).join(',')} + order by total desc + limit 500 + `, + { + ...filters, + websiteId, + startDate: maxDate(startDate, website.resetAt), + endDate, + eventType: EVENT_TYPE.pageView, + }, + ); +} + +function parseFields(fields) { + let count = false; + let distinct = false; + + const query = fields.reduce((arr, field) => { + const { name, value } = field; + + if (!count && value === 'total') { + count = true; + arr = arr.concat(`count(*) as total`); + } else if (!distinct && value === 'unique') { + distinct = true; + //arr = arr.concat(`count(distinct ${name})`); + } + + return arr.concat(name); + }, []); + + return query.join(',\n'); } From 157862834d810e7036913e2c7e2710beab88e72b Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 00:51:52 -0700 Subject: [PATCH 05/73] Refactored query filters. --- components/pages/reports/FieldSelectForm.js | 2 +- .../pages/reports/funnel/FunnelParameters.js | 7 ++- components/pages/reports/funnel/UrlAddForm.js | 40 +++++++-------- .../reports/insights/InsightsParameters.js | 36 +++++++++---- lib/clickhouse.ts | 4 ++ lib/constants.ts | 41 ++------------- lib/prisma.ts | 6 +++ pages/api/reports/insights.ts | 2 +- pages/api/websites/[id]/metrics.ts | 49 ++++++++---------- queries/analytics/events/getEventMetrics.ts | 4 +- .../analytics/pageviews/getPageviewMetrics.ts | 51 ++++++------------- .../analytics/pageviews/getPageviewStats.ts | 2 + queries/analytics/reports/getInsights.ts | 4 +- .../analytics/sessions/getSessionMetrics.ts | 11 ++-- queries/analytics/sessions/getSessionStats.ts | 2 + queries/analytics/stats/getWebsiteStats.ts | 4 +- 16 files changed, 118 insertions(+), 147 deletions(-) diff --git a/components/pages/reports/FieldSelectForm.js b/components/pages/reports/FieldSelectForm.js index 0e41ea1f..69f399bb 100644 --- a/components/pages/reports/FieldSelectForm.js +++ b/components/pages/reports/FieldSelectForm.js @@ -13,7 +13,7 @@ export default function FieldSelectForm({ fields, onSelect }) { return (
{label || name}
-
{type}
+ {type &&
{type}
}
); })} diff --git a/components/pages/reports/funnel/FunnelParameters.js b/components/pages/reports/funnel/FunnelParameters.js index ae498176..03898db3 100644 --- a/components/pages/reports/funnel/FunnelParameters.js +++ b/components/pages/reports/funnel/FunnelParameters.js @@ -16,6 +16,7 @@ import UrlAddForm from './UrlAddForm'; import { ReportContext } from 'components/pages/reports/Report'; import BaseParameters from '../BaseParameters'; import ParameterList from '../ParameterList'; +import PopupForm from '../PopupForm'; export function FunnelParameters() { const { report, runReport, updateReport, isRunning } = useContext(ReportContext); @@ -53,7 +54,11 @@ export function FunnelParameters() { {(close, element) => { - return ; + return ( + + + + ); }} diff --git a/components/pages/reports/funnel/UrlAddForm.js b/components/pages/reports/funnel/UrlAddForm.js index 0fb78b3d..ce202116 100644 --- a/components/pages/reports/funnel/UrlAddForm.js +++ b/components/pages/reports/funnel/UrlAddForm.js @@ -2,16 +2,14 @@ import { useState } from 'react'; import { useMessages } from 'hooks'; import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics'; import styles from './UrlAddForm.module.css'; -import PopupForm from '../PopupForm'; -export function UrlAddForm({ defaultValue = '', element, onAdd, onClose }) { +export function UrlAddForm({ defaultValue = '', onAdd }) { const [url, setUrl] = useState(defaultValue); const { formatMessage, labels } = useMessages(); const handleSave = () => { onAdd(url); setUrl(''); - onClose(); }; const handleChange = e => { @@ -26,25 +24,23 @@ export function UrlAddForm({ defaultValue = '', element, onAdd, onClose }) { }; return ( - -
- - - - - - -
-
+
+ + + + + + +
); } diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index b87a566d..692c5ead 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -2,12 +2,28 @@ import { useContext, useRef } from 'react'; import { useMessages } from 'hooks'; import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics'; import { ReportContext } from 'components/pages/reports/Report'; -import { REPORT_PARAMETERS, WEBSITE_EVENT_FIELDS } from 'lib/constants'; +import { REPORT_PARAMETERS } from 'lib/constants'; import Icons from 'components/icons'; import BaseParameters from '../BaseParameters'; -import FieldAddForm from '../FieldAddForm'; import ParameterList from '../ParameterList'; import styles from './InsightsParameters.module.css'; +import FieldSelectForm from '../FieldSelectForm'; +import PopupForm from '../PopupForm'; +import FieldFilterForm from '../FieldFilterForm'; + +const fieldOptions = [ + { name: 'url', type: 'string' }, + { name: 'title', type: 'string' }, + { name: 'referrer', type: 'string' }, + { name: 'query', type: 'string' }, + { name: 'browser', type: 'string' }, + { name: 'os', type: 'string' }, + { name: 'device', type: 'string' }, + { name: 'country', type: 'string' }, + { name: 'region', type: 'string' }, + { name: 'city', type: 'string' }, + { name: 'language', type: 'string' }, +]; export function InsightsParameters() { const { report, runReport, updateReport, isRunning } = useContext(ReportContext); @@ -16,7 +32,6 @@ export function InsightsParameters() { const { parameters } = report || {}; const { websiteId, dateRange, fields, filters, groups } = parameters || {}; const queryEnabled = websiteId && dateRange && fields?.length; - const fieldOptions = Object.keys(WEBSITE_EVENT_FIELDS).map(key => WEBSITE_EVENT_FIELDS[key]); const parameterGroups = [ { label: formatMessage(labels.fields), group: REPORT_PARAMETERS.fields }, @@ -57,13 +72,14 @@ export function InsightsParameters() { {(close, element) => { return ( - + + {group === REPORT_PARAMETERS.fields && ( + + )} + {group === REPORT_PARAMETERS.filters && ( + + )} + ); }} diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index d294110c..19d09405 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -70,6 +70,10 @@ function getFilterQuery(filters = {}) { arr.push(`and ${column} = {${key}:String}`); } + if (key === 'referrer') { + arr.push('and referrer_domain != {domain:String}'); + } + return arr; }, []); diff --git a/lib/constants.ts b/lib/constants.ts index c275ed8d..2b3da875 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -43,11 +43,6 @@ export const SESSION_COLUMNS = [ 'city', ]; -export const COLLECTION_TYPE = { - event: 'event', - identify: 'identify', -}; - export const FILTER_COLUMNS = { url: 'url_path', referrer: 'referrer_domain', @@ -57,6 +52,11 @@ export const FILTER_COLUMNS = { region: 'subdivision1', }; +export const COLLECTION_TYPE = { + event: 'event', + identify: 'identify', +}; + export const EVENT_TYPE = { pageView: 1, customEvent: 2, @@ -120,37 +120,6 @@ export const ROLE_PERMISSIONS = { [ROLES.teamMember]: [], } as const; -export const WEBSITE_EVENT_FIELDS = { - eventId: { name: 'event_id', type: 'uuid', label: 'Event ID' }, - websiteId: { name: 'website_id', type: 'uuid', label: 'Website ID' }, - sessionId: { name: 'session_id', type: 'uuid', label: 'Session ID' }, - createdAt: { name: 'created_at', type: 'date', label: 'Created date' }, - urlPath: { name: 'url_path', type: 'string', label: 'URL path' }, - urlQuery: { name: 'url_query', type: 'string', label: 'URL query' }, - referrerPath: { name: 'referrer_path', type: 'string', label: 'Referrer path' }, - referrerQuery: { name: 'referrer_query', type: 'string', label: 'Referrer query' }, - referrerDomain: { name: 'referrer_domain', type: 'string', label: 'Referrer domain' }, - pageTitle: { name: 'page_title', type: 'string', label: 'Page title' }, - eventType: { name: 'event_type', type: 'string', label: 'Event type' }, - eventName: { name: 'event_name', type: 'string', label: 'Event name' }, -}; - -export const SESSION_FIELDS = { - sessionId: { name: 'session_id', type: 'uuid' }, - websiteId: { name: 'website_id', type: 'uuid' }, - hostname: { name: 'hostname', type: 'string' }, - browser: { name: 'browser', type: 'string' }, - os: { name: 'os', type: 'string' }, - device: { name: 'device', type: 'string' }, - screen: { name: 'screen', type: 'string' }, - language: { name: 'language', type: 'string' }, - country: { name: 'country', type: 'string' }, - subdivision1: { name: 'subdivision1', type: 'string' }, - subdivision2: { name: 'subdivision2', type: 'string' }, - city: { name: 'city', type: 'string' }, - createdAt: { name: 'created_at', type: 'date' }, -}; - export const THEME_COLORS = { light: { primary: '#2680eb', diff --git a/lib/prisma.ts b/lib/prisma.ts index 0a52dd7e..a6f1ff88 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -73,6 +73,12 @@ function getFilterQuery(filters = {}): string { arr.push(`and ${column}={{${key}}}`); } + if (key === 'referrer') { + arr.push( + 'and (website_event.referrer_domain != {{domain}} or website_event.referrer_domain is null)', + ); + } + return arr; }, []); diff --git a/pages/api/reports/insights.ts b/pages/api/reports/insights.ts index dba11953..a40c2124 100644 --- a/pages/api/reports/insights.ts +++ b/pages/api/reports/insights.ts @@ -11,7 +11,7 @@ export interface InsightsRequestBody { startDate: string; endDate: string; }; - fields: string[]; + fields: { name: string; type: string; value: string }[]; filters: string[]; groups: string[]; } diff --git a/pages/api/websites/[id]/metrics.ts b/pages/api/websites/[id]/metrics.ts index fa0b7554..15389e3e 100644 --- a/pages/api/websites/[id]/metrics.ts +++ b/pages/api/websites/[id]/metrics.ts @@ -46,6 +46,7 @@ export default async ( country, region, city, + language, } = req.query; if (req.method === 'GET') { @@ -55,19 +56,26 @@ export default async ( const { startDate, endDate } = await parseDateRangeQuery(req); + const filters = { + url, + referrer, + title, + query, + event, + os, + browser, + device, + country, + region, + city, + language, + }; + + filters[type] = undefined; + + const column = FILTER_COLUMNS[type] || type; + if (SESSION_COLUMNS.includes(type)) { - const column = FILTER_COLUMNS[type] || type; - const filters = { - os, - browser, - device, - country, - region, - city, - }; - - filters[type] = undefined; - const data = await getSessionMetrics(websiteId, { startDate, endDate, @@ -95,23 +103,6 @@ export default async ( } if (EVENT_COLUMNS.includes(type)) { - const column = FILTER_COLUMNS[type] || type; - const filters = { - url, - referrer, - title, - query, - event, - os, - browser, - device, - country, - region, - city, - }; - - filters[type] = undefined; - const data = await getPageviewMetrics(websiteId, { startDate, endDate, diff --git a/queries/analytics/events/getEventMetrics.ts b/queries/analytics/events/getEventMetrics.ts index e9754036..03b252b7 100644 --- a/queries/analytics/events/getEventMetrics.ts +++ b/queries/analytics/events/getEventMetrics.ts @@ -47,11 +47,12 @@ async function relationalQuery(websiteId: string, criteria: GetEventMetricsCrite order by 2 `, { + ...filters, websiteId, startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.customEvent, - ...filters, + domain: website.domain, }, ); } @@ -82,6 +83,7 @@ async function clickhouseQuery(websiteId: string, criteria: GetEventMetricsCrite startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.customEvent, + domain: website.domain, }, ); } diff --git a/queries/analytics/pageviews/getPageviewMetrics.ts b/queries/analytics/pageviews/getPageviewMetrics.ts index a5da178a..8e4460e6 100644 --- a/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/queries/analytics/pageviews/getPageviewMetrics.ts @@ -34,22 +34,6 @@ async function relationalQuery( const { startDate, endDate, filters = {}, column } = criteria; const { rawQuery, parseFilters } = prisma; const website = await loadWebsite(websiteId); - const params: any = { - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, - ...filters, - }; - - let excludeDomain = ''; - - if (column === 'referrer_domain') { - excludeDomain = - 'and (website_event.referrer_domain != {{domain}} or website_event.referrer_domain is null)'; - - params.domain = website.domain; - } const { filterQuery, joinSession } = parseFilters(filters); @@ -61,13 +45,19 @@ async function relationalQuery( where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} - ${excludeDomain} ${filterQuery} group by 1 order by 2 desc limit 100 `, - params, + { + ...filters, + websiteId, + startDate: maxDate(startDate, website.resetAt), + endDate, + eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, + domain: website.domain, + }, ); } @@ -83,21 +73,6 @@ async function clickhouseQuery( const { startDate, endDate, filters = {}, column } = criteria; const { rawQuery, parseFilters } = clickhouse; const website = await loadWebsite(websiteId); - const params = { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, - domain: undefined, - }; - - let excludeDomain = ''; - - if (column === 'referrer_domain') { - excludeDomain = 'and referrer_domain != {domain:String}'; - params.domain = website.domain; - } const { filterQuery } = parseFilters(filters); @@ -108,12 +83,18 @@ async function clickhouseQuery( where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} and event_type = {eventType:UInt32} - ${excludeDomain} ${filterQuery} group by x order by y desc limit 100 `, - params, + { + ...filters, + websiteId, + startDate: maxDate(startDate, website.resetAt), + endDate, + eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, + domain: website.domain, + }, ); } diff --git a/queries/analytics/pageviews/getPageviewStats.ts b/queries/analytics/pageviews/getPageviewStats.ts index 6d702993..cdbd6442 100644 --- a/queries/analytics/pageviews/getPageviewStats.ts +++ b/queries/analytics/pageviews/getPageviewStats.ts @@ -59,6 +59,7 @@ async function relationalQuery(websiteId: string, criteria: PageviewStatsCriteri startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, + domain: website.domain, }, ); } @@ -93,6 +94,7 @@ async function clickhouseQuery(websiteId: string, criteria: PageviewStatsCriteri startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, + domain: website.domain, }, ); } diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index 68f06e21..ff139931 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -29,7 +29,7 @@ async function relationalQuery( y: number; }[] > { - const { startDate, endDate, fields = [], filters = [], groups = [] } = criteria; + const { startDate, endDate, filters = [] } = criteria; const { parseFilters, rawQuery } = prisma; const website = await loadWebsite(websiteId); const params = {}; @@ -107,7 +107,7 @@ function parseFields(fields) { if (!count && value === 'total') { count = true; - arr = arr.concat(`count(*) as total`); + arr = arr.concat(`count(*) as views`); } else if (!distinct && value === 'unique') { distinct = true; //arr = arr.concat(`count(distinct ${name})`); diff --git a/queries/analytics/sessions/getSessionMetrics.ts b/queries/analytics/sessions/getSessionMetrics.ts index 2512b06c..a9b49ec8 100644 --- a/queries/analytics/sessions/getSessionMetrics.ts +++ b/queries/analytics/sessions/getSessionMetrics.ts @@ -1,7 +1,7 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; -import { DEFAULT_RESET_DATE, EVENT_TYPE } from 'lib/constants'; +import { EVENT_TYPE } from 'lib/constants'; import { loadWebsite } from 'lib/load'; import { maxDate } from 'lib/date'; @@ -28,14 +28,9 @@ async function relationalQuery( return rawQuery( `select ${column} x, count(*) y - from session as s - where s.session_id in ( - select website_event.session_id from website_event - join website - on website_event.website_id = website.website_id - ${joinSession} - where website.website_id = {{websiteId::uuid}} + ${joinSession} + where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} ) as t diff --git a/queries/analytics/sessions/getSessionStats.ts b/queries/analytics/sessions/getSessionStats.ts index 966fd91f..7633f242 100644 --- a/queries/analytics/sessions/getSessionStats.ts +++ b/queries/analytics/sessions/getSessionStats.ts @@ -59,6 +59,7 @@ async function relationalQuery(websiteId: string, criteria: SessionStatsCriteria startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, + domain: website.domain, }, ); } @@ -93,6 +94,7 @@ async function clickhouseQuery(websiteId: string, criteria: SessionStatsCriteria startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, + domain: website.domain, }, ); } diff --git a/queries/analytics/stats/getWebsiteStats.ts b/queries/analytics/stats/getWebsiteStats.ts index 4d3730ee..e048fc8f 100644 --- a/queries/analytics/stats/getWebsiteStats.ts +++ b/queries/analytics/stats/getWebsiteStats.ts @@ -51,11 +51,12 @@ async function relationalQuery( ) as t `, { + ...filters, websiteId, startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, - ...filters, + domain: website.domain, }, ); } @@ -97,6 +98,7 @@ async function clickhouseQuery( startDate: maxDate(startDate, website.resetAt), endDate, eventType: EVENT_TYPE.pageView, + domain: website.domain, }, ); } From 9cde107ddfa857a65431d54be44d1c79f311d24d Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 4 Aug 2023 13:10:03 -0700 Subject: [PATCH 06/73] build out retention reports --- components/messages.js | 1 + components/pages/reports/ReportDetails.js | 2 + components/pages/reports/ReportTemplates.js | 6 + .../pages/reports/funnel/FunnelChart.js | 17 +- .../pages/reports/retention/RetentionChart.js | 74 +++++++ .../retention/RetentionChart.module.css | 3 + .../reports/retention/RetentionParameters.js | 44 ++++ .../reports/retention/RetentionReport.js | 28 +++ .../retention/RetentionReport.module.css | 10 + .../pages/reports/retention/RetentionTable.js | 19 ++ pages/api/reports/retention.ts | 55 +++++ pages/reports/retention.js | 13 ++ queries/analytics/reports/getRetention.ts | 209 ++++++++++++++++++ queries/index.js | 1 + 14 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 components/pages/reports/retention/RetentionChart.js create mode 100644 components/pages/reports/retention/RetentionChart.module.css create mode 100644 components/pages/reports/retention/RetentionParameters.js create mode 100644 components/pages/reports/retention/RetentionReport.js create mode 100644 components/pages/reports/retention/RetentionReport.module.css create mode 100644 components/pages/reports/retention/RetentionTable.js create mode 100644 pages/api/reports/retention.ts create mode 100644 pages/reports/retention.js create mode 100644 queries/analytics/reports/getRetention.ts diff --git a/components/messages.js b/components/messages.js index a31e2875..68e3b3d5 100644 --- a/components/messages.js +++ b/components/messages.js @@ -161,6 +161,7 @@ export const labels = defineMessages({ overview: { id: 'labels.overview', defaultMessage: 'Overview' }, totalRecords: { id: 'labels.total-records', defaultMessage: 'Total records' }, insights: { id: 'label.insights', defaultMessage: 'Insights' }, + retention: { id: 'label.retention', defaultMessage: 'Retention' }, dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' }, }); diff --git a/components/pages/reports/ReportDetails.js b/components/pages/reports/ReportDetails.js index c41d12f6..39cd285d 100644 --- a/components/pages/reports/ReportDetails.js +++ b/components/pages/reports/ReportDetails.js @@ -1,9 +1,11 @@ import FunnelReport from './funnel/FunnelReport'; import EventDataReport from './event-data/EventDataReport'; +import RetentionReport from './retention/RetentionReport'; const reports = { funnel: FunnelReport, 'event-data': EventDataReport, + retention: RetentionReport, }; export default function ReportDetails({ reportId, reportType }) { diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js index 60ae11e7..29c193a8 100644 --- a/components/pages/reports/ReportTemplates.js +++ b/components/pages/reports/ReportTemplates.js @@ -47,6 +47,12 @@ export function ReportTemplates() { url: '/reports/funnel', icon: , }, + { + title: formatMessage(labels.retention), + description: 'Track your websites user retention', + url: '/reports/retention', + icon: , + }, ]; return ( diff --git a/components/pages/reports/funnel/FunnelChart.js b/components/pages/reports/funnel/FunnelChart.js index 7253c3fa..c35afe4e 100644 --- a/components/pages/reports/funnel/FunnelChart.js +++ b/components/pages/reports/funnel/FunnelChart.js @@ -1,5 +1,5 @@ import { useCallback, useContext, useMemo } from 'react'; -import { Loading } from 'react-basics'; +import { Loading, StatusLight } from 'react-basics'; import useMessages from 'hooks/useMessages'; import useTheme from 'hooks/useTheme'; import BarChart from 'components/metrics/BarChart'; @@ -22,14 +22,25 @@ export function FunnelChart({ className, loading }) { ); const renderTooltipPopup = useCallback((setTooltipPopup, model) => { - const { opacity, dataPoints } = model.tooltip; + const { opacity, labelColors, dataPoints } = model.tooltip; if (!dataPoints?.length || !opacity) { setTooltipPopup(null); return; } - setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`); + setTooltipPopup( + <> +
+ {formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)} +
+
+ + {formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)} + +
+ , + ); }, []); const datasets = useMemo(() => { diff --git a/components/pages/reports/retention/RetentionChart.js b/components/pages/reports/retention/RetentionChart.js new file mode 100644 index 00000000..5f7361fd --- /dev/null +++ b/components/pages/reports/retention/RetentionChart.js @@ -0,0 +1,74 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { Loading, StatusLight } from 'react-basics'; +import useMessages from 'hooks/useMessages'; +import useTheme from 'hooks/useTheme'; +import BarChart from 'components/metrics/BarChart'; +import { formatLongNumber } from 'lib/format'; +import styles from './RetentionChart.module.css'; +import { ReportContext } from '../Report'; + +export function RetentionChart({ className, loading }) { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const { colors } = useTheme(); + + const { parameters, data } = report || {}; + + const renderXLabel = useCallback( + (label, index) => { + return parameters.urls[index]; + }, + [parameters], + ); + + const renderTooltipPopup = useCallback((setTooltipPopup, model) => { + const { opacity, labelColors, dataPoints } = model.tooltip; + + if (!dataPoints?.length || !opacity) { + setTooltipPopup(null); + return; + } + + setTooltipPopup( + <> +
+ {formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)} +
+
+ + {formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)} + +
+ , + ); + }, []); + + const datasets = useMemo(() => { + return [ + { + label: formatMessage(labels.uniqueVisitors), + data: data, + borderWidth: 1, + ...colors.chart.visitors, + }, + ]; + }, [data]); + + if (loading) { + return ; + } + + return ( + + ); +} + +export default RetentionChart; diff --git a/components/pages/reports/retention/RetentionChart.module.css b/components/pages/reports/retention/RetentionChart.module.css new file mode 100644 index 00000000..9e1690b3 --- /dev/null +++ b/components/pages/reports/retention/RetentionChart.module.css @@ -0,0 +1,3 @@ +.loading { + height: 300px; +} diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js new file mode 100644 index 00000000..29c0eff2 --- /dev/null +++ b/components/pages/reports/retention/RetentionParameters.js @@ -0,0 +1,44 @@ +import { useContext, useRef } from 'react'; +import { useMessages } from 'hooks'; +import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics'; +import { ReportContext } from 'components/pages/reports/Report'; +import BaseParameters from '../BaseParameters'; + +export function RetentionParameters() { + const { report, runReport, isRunning } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const ref = useRef(null); + + const { parameters } = report || {}; + const { websiteId, dateRange } = parameters || {}; + const queryDisabled = !websiteId || !dateRange; + + const handleSubmit = (data, e) => { + e.stopPropagation(); + e.preventDefault(); + if (!queryDisabled) { + runReport(data); + } + }; + + return ( +
+ + + + + + + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default RetentionParameters; diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js new file mode 100644 index 00000000..31d085f7 --- /dev/null +++ b/components/pages/reports/retention/RetentionReport.js @@ -0,0 +1,28 @@ +import RetentionChart from './RetentionChart'; +import RetentionTable from './RetentionTable'; +import RetentionParameters from './RetentionParameters'; +import Report from '../Report'; +import ReportHeader from '../ReportHeader'; +import ReportMenu from '../ReportMenu'; +import ReportBody from '../ReportBody'; +import Funnel from 'assets/funnel.svg'; + +const defaultParameters = { + type: 'Retention', + parameters: { window: 60, urls: [] }, +}; + +export default function RetentionReport({ reportId }) { + return ( + + } /> + + + + + + + + + ); +} diff --git a/components/pages/reports/retention/RetentionReport.module.css b/components/pages/reports/retention/RetentionReport.module.css new file mode 100644 index 00000000..aed66b74 --- /dev/null +++ b/components/pages/reports/retention/RetentionReport.module.css @@ -0,0 +1,10 @@ +.filters { + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--base400); + border-radius: var(--border-radius); + line-height: 32px; + padding: 10px; + overflow: hidden; +} diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js new file mode 100644 index 00000000..4ef87986 --- /dev/null +++ b/components/pages/reports/retention/RetentionTable.js @@ -0,0 +1,19 @@ +import { useContext } from 'react'; +import DataTable from 'components/metrics/DataTable'; +import { useMessages } from 'hooks'; +import { ReportContext } from '../Report'; + +export function RetentionTable() { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + return ( + + ); +} + +export default RetentionTable; diff --git a/pages/api/reports/retention.ts b/pages/api/reports/retention.ts new file mode 100644 index 00000000..6b8aebcc --- /dev/null +++ b/pages/api/reports/retention.ts @@ -0,0 +1,55 @@ +import { canViewWebsite } from 'lib/auth'; +import { useCors, useAuth } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { ok, methodNotAllowed, unauthorized } from 'next-basics'; +import { getRetention } from 'queries'; + +export interface RetentionRequestBody { + websiteId: string; + urls: string[]; + window: number; + dateRange: { + startDate: string; + endDate: string; + }; +} + +export interface RetentionResponse { + urls: string[]; + window: number; + startAt: number; + endAt: number; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + if (req.method === 'POST') { + const { + websiteId, + urls, + window, + dateRange: { startDate, endDate }, + } = req.body; + + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const data = await getRetention(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + urls, + windowMinutes: +window, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/pages/reports/retention.js b/pages/reports/retention.js new file mode 100644 index 00000000..b7f0bd0f --- /dev/null +++ b/pages/reports/retention.js @@ -0,0 +1,13 @@ +import AppLayout from 'components/layout/AppLayout'; +import RetentionReport from 'components/pages/reports/retention/RetentionReport'; +import useMessages from 'hooks/useMessages'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + + + + ); +} diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts new file mode 100644 index 00000000..b2c47882 --- /dev/null +++ b/queries/analytics/reports/getRetention.ts @@ -0,0 +1,209 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; + +export async function getRetention( + ...args: [ + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +): Promise< + { + x: string; + y: number; + z: number; + }[] +> { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery, getAddMinutesQuery } = prisma; + const { levelQuery, sumQuery } = getRetentionQuery(urls, windowMinutes); + + function getRetentionQuery( + urls: string[], + windowMinutes: number, + ): { + levelQuery: string; + sumQuery: string; + } { + return urls.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union ' : ''; + + if (levelNumber >= 2) { + pv.levelQuery += ` + , level${levelNumber} AS ( + select distinct we.session_id, we.created_at + from level${i} l + join website_event we + on l.session_id = we.session_id + where we.created_at between l.created_at + and ${getAddMinutesQuery(`l.created_at `, windowMinutes)} + and we.referrer_path = {{${i - 1}}} + and we.url_path = {{${i}}} + and we.created_at <= {{endDate}} + and we.website_id = {{websiteId::uuid}} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + + return pv; + }, + { + levelQuery: '', + sumQuery: '', + }, + ); + } + + return rawQuery( + ` + WITH level1 AS ( + select distinct session_id, created_at + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and url_path = {{0}} + ) + ${levelQuery} + ${sumQuery} + ORDER BY level; + `, + { + websiteId, + startDate, + endDate, + ...urls, + }, + ).then(results => { + return urls.map((a, i) => ({ + x: a, + y: results[i]?.count || 0, + z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off + })); + }); +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +): Promise< + { + x: string; + y: number; + z: number; + }[] +> { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery } = clickhouse; + const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getRetentionQuery( + urls, + windowMinutes, + ); + + function getRetentionQuery( + urls: string[], + windowMinutes: number, + ): { + levelQuery: string; + sumQuery: string; + urlFilterQuery: string; + urlParams: { [key: string]: string }; + } { + return urls.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union all ' : ''; + const startFilter = i > 0 ? ', ' : ''; + + if (levelNumber >= 2) { + pv.levelQuery += `\n + , level${levelNumber} AS ( + select distinct y.session_id as session_id, + y.url_path as url_path, + y.referrer_path as referrer_path, + y.created_at as created_at + from level${i} x + join level0 y + on x.session_id = y.session_id + where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute + and y.referrer_path = {url${i - 1}:String} + and y.url_path = {url${i}:String} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + pv.urlFilterQuery += `${startFilter}{url${i}:String} `; + pv.urlParams[`url${i}`] = cv; + + return pv; + }, + { + levelQuery: '', + sumQuery: '', + urlFilterQuery: '', + urlParams: {}, + }, + ); + } + + return rawQuery<{ level: number; count: number }[]>( + ` + WITH level0 AS ( + select distinct session_id, url_path, referrer_path, created_at + from umami.website_event + where url_path in (${urlFilterQuery}) + and website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ), + level1 AS ( + select * + from level0 + where url_path = {url0:String} + ) + ${levelQuery} + select * + from ( + ${sumQuery} + ) ORDER BY level; + `, + { + websiteId, + startDate, + endDate, + ...urlParams, + }, + ).then(results => { + return urls.map((a, i) => ({ + x: a, + y: results[i]?.count || 0, + z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off + })); + }); +} diff --git a/queries/index.js b/queries/index.js index f509e039..0fb2bf2c 100644 --- a/queries/index.js +++ b/queries/index.js @@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataFields'; export * from './analytics/eventData/getEventDataUsage'; export * from './analytics/events/saveEvent'; export * from './analytics/reports/getFunnel'; +export * from './analytics/reports/getRetention'; export * from './analytics/reports/getInsights'; export * from './analytics/pageviews/getPageviewMetrics'; export * from './analytics/pageviews/getPageviewStats'; From 7148f66d1af901b7d55e93b005261484461a9ec1 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 13:18:30 -0700 Subject: [PATCH 07/73] Refactored query parameter handling. --- lib/clickhouse.ts | 23 ++++-- lib/constants.ts | 3 + lib/prisma.ts | 19 ++++- lib/types.ts | 36 +++++---- pages/api/websites/[id]/metrics.ts | 17 ++-- pages/api/websites/[id]/pageviews.ts | 52 +++++-------- pages/api/websites/[id]/stats.ts | 46 ++++------- .../analytics/eventData/getEventDataEvents.ts | 77 ++++++++----------- .../analytics/eventData/getEventDataFields.ts | 28 +++---- queries/analytics/events/getEventMetrics.ts | 59 +++++--------- .../analytics/pageviews/getPageviewMetrics.ts | 67 ++++------------ .../analytics/pageviews/getPageviewStats.ts | 65 ++++------------ queries/analytics/reports/getInsights.ts | 54 ++++--------- .../analytics/sessions/getSessionMetrics.ts | 48 ++++-------- queries/analytics/sessions/getSessionStats.ts | 65 ++++------------ .../analytics/stats/getWebsiteDateRange.ts | 14 ++-- queries/analytics/stats/getWebsiteStats.ts | 56 ++++---------- 17 files changed, 260 insertions(+), 469 deletions(-) diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 19d09405..6d5bcf42 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -2,8 +2,10 @@ import { ClickHouse } from 'clickhouse'; import dateFormat from 'dateformat'; import debug from 'debug'; import { CLICKHOUSE } from 'lib/db'; -import { WebsiteMetricFilter } from './types'; -import { FILTER_COLUMNS } from './constants'; +import { QueryFilters } from './types'; +import { FILTER_COLUMNS, IGNORED_FILTERS } from './constants'; +import { loadWebsite } from './load'; +import { maxDate } from './date'; export const CLICKHOUSE_DATE_FORMATS = { minute: '%Y-%m-%d %H:%M:00', @@ -65,13 +67,13 @@ function getFilterQuery(filters = {}) { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; - if (filter !== undefined) { + if (filter !== undefined && !IGNORED_FILTERS.includes(key)) { const column = FILTER_COLUMNS[key] || key; arr.push(`and ${column} = {${key}:String}`); } if (key === 'referrer') { - arr.push('and referrer_domain != {domain:String}'); + arr.push('and referrer_domain != {websiteDomain:String}'); } return arr; @@ -80,9 +82,20 @@ function getFilterQuery(filters = {}) { return query.join('\n'); } -function parseFilters(filters: WebsiteMetricFilter = {}) { +async function parseFilters( + websiteId: string, + filters: QueryFilters & { [key: string]: any } = {}, +) { + const website = await loadWebsite(websiteId); + return { filterQuery: getFilterQuery(filters), + params: { + ...filters, + websiteId, + startDate: maxDate(filters.startDate, website.resetAt), + websiteDomain: website.domain, + }, }; } diff --git a/lib/constants.ts b/lib/constants.ts index 2b3da875..9362b456 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -50,8 +50,11 @@ export const FILTER_COLUMNS = { query: 'url_query', event: 'event_name', region: 'subdivision1', + type: 'event_type', }; +export const IGNORED_FILTERS = ['startDate', 'endDate', 'timezone', 'unit', 'eventType']; + export const COLLECTION_TYPE = { event: 'event', identify: 'identify', diff --git a/lib/prisma.ts b/lib/prisma.ts index a6f1ff88..d1b9d0e5 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -1,7 +1,10 @@ import prisma from '@umami/prisma-client'; import moment from 'moment-timezone'; import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; -import { FILTER_COLUMNS, SESSION_COLUMNS } from './constants'; +import { FILTER_COLUMNS, IGNORED_FILTERS, SESSION_COLUMNS } from './constants'; +import { loadWebsite } from './load'; +import { maxDate } from './date'; +import { QueryFilters } from './types'; const MYSQL_DATE_FORMATS = { minute: '%Y-%m-%d %H:%i:00', @@ -68,14 +71,14 @@ function getFilterQuery(filters = {}): string { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; - if (filter !== undefined) { + if (filter !== undefined && !IGNORED_FILTERS.includes(key)) { const column = FILTER_COLUMNS[key] || key; arr.push(`and ${column}={{${key}}}`); } if (key === 'referrer') { arr.push( - 'and (website_event.referrer_domain != {{domain}} or website_event.referrer_domain is null)', + 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)', ); } @@ -85,12 +88,20 @@ function getFilterQuery(filters = {}): string { return query.join('\n'); } -function parseFilters(filters: { [key: string]: any } = {}) { +async function parseFilters(websiteId, filters: QueryFilters & { [key: string]: any } = {}) { + const website = await loadWebsite(websiteId); + return { joinSession: Object.keys(filters).find(key => SESSION_COLUMNS[key]) ? `inner join session on website_event.session_id = session.session_id` : '', filterQuery: getFilterQuery(filters), + params: { + ...filters, + websiteId, + startDate: maxDate(filters.startDate, website.resetAt), + websiteDomain: website.domain, + }, }; } diff --git a/lib/types.ts b/lib/types.ts index 7c91ec4f..131740f8 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -73,21 +73,6 @@ export interface WebsiteMetric { y: number; } -export interface WebsiteMetricFilter { - domain?: string; - url?: string; - referrer?: string; - title?: string; - query?: string; - event?: string; - os?: string; - browser?: string; - device?: string; - country?: string; - region?: string; - city?: string; -} - export interface WebsiteEventMetric { x: string; t: string; @@ -144,3 +129,24 @@ export interface DateRange { unit: string; value: string; } + +export interface QueryFilters { + startDate?: Date; + endDate?: Date; + timezone?: string; + unit?: string; + domain?: string; + eventType?: number; + url?: string; + referrer?: string; + title?: string; + query?: string; + event?: string; + os?: string; + browser?: string; + device?: string; + country?: string; + region?: string; + city?: string; + language?: string; +} diff --git a/pages/api/websites/[id]/metrics.ts b/pages/api/websites/[id]/metrics.ts index 15389e3e..7c84583c 100644 --- a/pages/api/websites/[id]/metrics.ts +++ b/pages/api/websites/[id]/metrics.ts @@ -23,6 +23,7 @@ export interface WebsiteMetricsRequestQuery { country: string; region: string; city: string; + language: string; } export default async ( @@ -57,6 +58,8 @@ export default async ( const { startDate, endDate } = await parseDateRangeQuery(req); const filters = { + startDate, + endDate, url, referrer, title, @@ -76,12 +79,7 @@ export default async ( const column = FILTER_COLUMNS[type] || type; if (SESSION_COLUMNS.includes(type)) { - const data = await getSessionMetrics(websiteId, { - startDate, - endDate, - column, - filters, - }); + const data = await getSessionMetrics(websiteId, column, filters); if (type === 'language') { const combined = {}; @@ -103,12 +101,7 @@ export default async ( } if (EVENT_COLUMNS.includes(type)) { - const data = await getPageviewMetrics(websiteId, { - startDate, - endDate, - column, - filters, - }); + const data = await getPageviewMetrics(websiteId, column, filters); return ok(res, data); } diff --git a/pages/api/websites/[id]/pageviews.ts b/pages/api/websites/[id]/pageviews.ts index 810854a7..87c60d58 100644 --- a/pages/api/websites/[id]/pageviews.ts +++ b/pages/api/websites/[id]/pageviews.ts @@ -57,41 +57,25 @@ export default async ( return badRequest(res); } + const filters = { + startDate, + endDate, + timezone, + unit, + url, + referrer, + title, + os, + browser, + device, + country, + region, + city, + }; + const [pageviews, sessions] = await Promise.all([ - getPageviewStats(websiteId, { - startDate, - endDate, - timezone, - unit, - filters: { - url, - referrer, - title, - os, - browser, - device, - country, - region, - city, - }, - }), - getSessionStats(websiteId, { - startDate, - endDate, - timezone, - unit, - filters: { - url, - referrer, - title, - os, - browser, - device, - country, - region, - city, - }, - }), + getPageviewStats(websiteId, filters), + getSessionStats(websiteId, filters), ]); return ok(res, { pageviews, sessions }); diff --git a/pages/api/websites/[id]/stats.ts b/pages/api/websites/[id]/stats.ts index 3164913d..a77c7eaf 100644 --- a/pages/api/websites/[id]/stats.ts +++ b/pages/api/websites/[id]/stats.ts @@ -56,40 +56,26 @@ export default async ( const prevStartDate = subMinutes(startDate, diff); const prevEndDate = subMinutes(endDate, diff); - const metrics = await getWebsiteStats(websiteId, { - startDate, - endDate, - filters: { - url, - referrer, - title, - query, - event, - os, - browser, - device, - country, - region, - city, - }, - }); + const filters = { + url, + referrer, + title, + query, + event, + os, + browser, + device, + country, + region, + city, + }; + + const metrics = await getWebsiteStats(websiteId, { ...filters, startDate, endDate }); const prevPeriod = await getWebsiteStats(websiteId, { + ...filters, startDate: prevStartDate, endDate: prevEndDate, - filters: { - url, - referrer, - title, - query, - event, - os, - browser, - device, - country, - region, - city, - }, }); const stats = Object.keys(metrics[0]).reduce((obj, key) => { diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index 634a28a2..fae46db1 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -1,17 +1,10 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; -import { WebsiteEventDataFields } from 'lib/types'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters, WebsiteEventDataFields } from 'lib/types'; export async function getEventDataEvents( - ...args: [ - websiteId: string, - startDate: Date, - endDate: Date, - filters: { field?: string; event?: string }, - ] + ...args: [websiteId: string, filters: QueryFilters & { field?: string; event?: string }] ): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -21,64 +14,60 @@ export async function getEventDataEvents( async function relationalQuery( websiteId: string, - startDate: Date, - endDate: Date, - filters: { field?: string; event?: string }, + filters: QueryFilters & { field?: string; event?: string }, ) { - const { rawQuery } = prisma; - const website = await loadWebsite(websiteId); - const { event } = filters; + const { rawQuery, parseFilters } = prisma; + const { params } = await parseFilters(websiteId, filters); if (event) { return rawQuery( ` select - we.event_name as event, - ed.event_key as field, - ed.data_type as type, - ed.string_value as value, + website_event.event_name as event, + event_data.event_key as field, + event_data.data_type as type, + event_data.string_value as value, count(*) as total - from event_data as ed - inner join website_event as we - on we.event_id = ed.website_event_id - where ed.website_id = {{websiteId::uuid}} - and ed.created_at between {{startDate}} and {{endDate}} - and we.event_name = {{event}} - group by we.event_name, ed.event_key, ed.data_type, ed.string_value + from event_data + inner join website_event + on website_event.event_id = event_data.website_event_id + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + and websit_event.event_name = {{event}} + group by website_event.event_name, event_data.event_key, event_data.data_type, event_data.string_value order by 1 asc, 2 asc, 3 asc, 4 desc `, - { websiteId, startDate: maxDate(startDate, website.resetAt), endDate, ...filters }, + params, ); } + return rawQuery( ` select - we.event_name as event, - ed.event_key as field, - ed.data_type as type, + website_event.event_name as event, + event_data.event_key as field, + event_data.data_type as type, count(*) as total - from event_data as ed - inner join website_event as we - on we.event_id = ed.website_event_id - where ed.website_id = {{websiteId::uuid}} - and ed.created_at between {{startDate}} and {{endDate}} - group by we.event_name, ed.event_key, ed.data_type + from event_data + inner join website_event + on website_event.event_id = event_data.website_event_id + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + group by website_event.event_name, event_data.event_key, event_data.data_type order by 1 asc, 2 asc limit 100 `, - { websiteId, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } async function clickhouseQuery( websiteId: string, - startDate: Date, - endDate: Date, - filters: { field?: string; event?: string }, + filters: QueryFilters & { field?: string; event?: string }, ) { - const { rawQuery } = clickhouse; - const website = await loadWebsite(websiteId); + const { rawQuery, parseFilters } = clickhouse; const { event } = filters; + const { params } = await parseFilters(websiteId, filters); if (event) { return rawQuery( @@ -97,7 +86,7 @@ async function clickhouseQuery( order by 1 asc, 2 asc, 3 asc, 4 desc limit 100 `, - { ...filters, websiteId, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } @@ -115,6 +104,6 @@ async function clickhouseQuery( order by 1 asc, 2 asc limit 100 `, - { websiteId, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } diff --git a/queries/analytics/eventData/getEventDataFields.ts b/queries/analytics/eventData/getEventDataFields.ts index 516c58d0..a27f2281 100644 --- a/queries/analytics/eventData/getEventDataFields.ts +++ b/queries/analytics/eventData/getEventDataFields.ts @@ -1,12 +1,10 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; -import { WebsiteEventDataFields } from 'lib/types'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters, WebsiteEventDataFields } from 'lib/types'; export async function getEventDataFields( - ...args: [websiteId: string, startDate: Date, endDate: Date, field?: string] + ...args: [websiteId: string, filters: QueryFilters & { field?: string }] ): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -14,9 +12,10 @@ export async function getEventDataFields( }); } -async function relationalQuery(websiteId: string, startDate: Date, endDate: Date, field: string) { - const { rawQuery } = prisma; - const website = await loadWebsite(websiteId); +async function relationalQuery(websiteId: string, filters: QueryFilters & { field?: string }) { + const { rawQuery, parseFilters } = prisma; + const { field } = filters; + const { params } = await parseFilters(websiteId, filters); if (field) { return rawQuery( @@ -33,7 +32,7 @@ async function relationalQuery(websiteId: string, startDate: Date, endDate: Date order by 3 desc, 2 desc, 1 asc limit 100 `, - { websiteId, field, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } @@ -50,13 +49,14 @@ async function relationalQuery(websiteId: string, startDate: Date, endDate: Date order by 3 desc, 2 asc, 1 asc limit 100 `, - { websiteId, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } -async function clickhouseQuery(websiteId: string, startDate: Date, endDate: Date, field: string) { - const { rawQuery } = clickhouse; - const website = await loadWebsite(websiteId); +async function clickhouseQuery(websiteId: string, filters: QueryFilters & { field?: string }) { + const { rawQuery, parseFilters } = clickhouse; + const { field } = filters; + const { params } = await parseFilters(websiteId, filters); if (field) { return rawQuery( @@ -73,7 +73,7 @@ async function clickhouseQuery(websiteId: string, startDate: Date, endDate: Date order by 3 desc, 2 desc, 1 asc limit 100 `, - { websiteId, field, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } @@ -90,6 +90,6 @@ async function clickhouseQuery(websiteId: string, startDate: Date, endDate: Date order by 3 desc, 2 asc, 1 asc limit 100 `, - { websiteId, startDate: maxDate(startDate, website.resetAt), endDate }, + params, ); } diff --git a/queries/analytics/events/getEventMetrics.ts b/queries/analytics/events/getEventMetrics.ts index 03b252b7..cf862f4a 100644 --- a/queries/analytics/events/getEventMetrics.ts +++ b/queries/analytics/events/getEventMetrics.ts @@ -1,24 +1,11 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; -import { WebsiteEventMetric } from 'lib/types'; +import { WebsiteEventMetric, QueryFilters } from 'lib/types'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; - -export interface GetEventMetricsCriteria { - startDate: Date; - endDate: Date; - timezone: string; - unit: string; - filters: { - url: string; - eventName: string; - }; -} export async function getEventMetrics( - ...args: [websiteId: string, criteria: GetEventMetricsCriteria] + ...args: [websiteId: string, criteria: QueryFilters] ): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -26,11 +13,13 @@ export async function getEventMetrics( }); } -async function relationalQuery(websiteId: string, criteria: GetEventMetricsCriteria) { - const { startDate, endDate, timezone = 'utc', unit = 'day', filters } = criteria; - const { rawQuery, getDateQuery, getFilterQuery } = prisma; - const website = await loadWebsite(websiteId); - const filterQuery = getFilterQuery(filters); +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; + const { rawQuery, getDateQuery, parseFilters } = prisma; + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.customEvent, + }); return rawQuery( ` @@ -46,22 +35,17 @@ async function relationalQuery(websiteId: string, criteria: GetEventMetricsCrite group by 1, 2 order by 2 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.customEvent, - domain: website.domain, - }, + params, ); } -async function clickhouseQuery(websiteId: string, criteria: GetEventMetricsCriteria) { - const { startDate, endDate, timezone = 'utc', unit = 'day', filters } = criteria; - const { rawQuery, getDateQuery, getFilterQuery } = clickhouse; - const website = await loadWebsite(websiteId); - const filterQuery = getFilterQuery(filters); +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; + const { rawQuery, getDateQuery, parseFilters } = clickhouse; + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.customEvent, + }); return rawQuery( ` @@ -77,13 +61,6 @@ async function clickhouseQuery(websiteId: string, criteria: GetEventMetricsCrite group by x, t order by t `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.customEvent, - domain: website.domain, - }, + params, ); } diff --git a/queries/analytics/pageviews/getPageviewMetrics.ts b/queries/analytics/pageviews/getPageviewMetrics.ts index 8e4460e6..bbbd7705 100644 --- a/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/queries/analytics/pageviews/getPageviewMetrics.ts @@ -2,19 +2,10 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters } from 'lib/types'; export async function getPageviewMetrics( - ...args: [ - websiteId: string, - criteria: { - startDate: Date; - endDate: Date; - column: string; - filters: object; - }, - ] + ...args: [websiteId: string, columns: string, filters: QueryFilters] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -22,20 +13,12 @@ export async function getPageviewMetrics( }); } -async function relationalQuery( - websiteId: string, - criteria: { - startDate: Date; - endDate: Date; - column: string; - filters: object; - }, -) { - const { startDate, endDate, filters = {}, column } = criteria; +async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const website = await loadWebsite(websiteId); - - const { filterQuery, joinSession } = parseFilters(filters); + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + ...filters, + eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -50,31 +33,16 @@ async function relationalQuery( order by 2 desc limit 100 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } -async function clickhouseQuery( - websiteId: string, - criteria: { - startDate: Date; - endDate: Date; - column: string; - filters: object; - }, -) { - const { startDate, endDate, filters = {}, column } = criteria; +async function clickhouseQuery(websiteId: string, column: string, filters: QueryFilters) { const { rawQuery, parseFilters } = clickhouse; - const website = await loadWebsite(websiteId); - - const { filterQuery } = parseFilters(filters); + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -88,13 +56,6 @@ async function clickhouseQuery( order by y desc limit 100 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } diff --git a/queries/analytics/pageviews/getPageviewStats.ts b/queries/analytics/pageviews/getPageviewStats.ts index cdbd6442..d6a980ef 100644 --- a/queries/analytics/pageviews/getPageviewStats.ts +++ b/queries/analytics/pageviews/getPageviewStats.ts @@ -2,43 +2,22 @@ import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import prisma from 'lib/prisma'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters } from 'lib/types'; -export interface PageviewStatsCriteria { - startDate: Date; - endDate: Date; - timezone?: string; - unit?: string; - filters: { - url?: string; - referrer?: string; - title?: string; - browser?: string; - os?: string; - device?: string; - screen?: string; - language?: string; - country?: string; - region?: string; - city?: string; - }; -} - -export async function getPageviewStats( - ...args: [websiteId: string, criteria: PageviewStatsCriteria] -) { +export async function getPageviewStats(...args: [websiteId: string, filters: QueryFilters]) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), }); } -async function relationalQuery(websiteId: string, criteria: PageviewStatsCriteria) { - const { startDate, endDate, timezone = 'utc', unit = 'day', filters = {} } = criteria; +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; const { getDateQuery, parseFilters, rawQuery } = prisma; - const website = await loadWebsite(websiteId); - const { filterQuery, joinSession } = parseFilters(filters); + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -53,22 +32,17 @@ async function relationalQuery(websiteId: string, criteria: PageviewStatsCriteri ${filterQuery} group by 1 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } -async function clickhouseQuery(websiteId: string, criteria: PageviewStatsCriteria) { - const { startDate, endDate, timezone = 'UTC', unit = 'day', filters = {} } = criteria; +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'UTC', unit = 'day' } = filters; const { parseFilters, rawQuery, getDateStringQuery, getDateQuery } = clickhouse; - const website = await loadWebsite(websiteId); - const { filterQuery } = parseFilters(filters); + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -88,13 +62,6 @@ async function clickhouseQuery(websiteId: string, criteria: PageviewStatsCriteri ) as g order by t `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index ff139931..dfe7c397 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -1,19 +1,10 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; -import { maxDate } from 'lib/date'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; +import { QueryFilters } from 'lib/types'; -export interface GetInsightsCriteria { - startDate: Date; - endDate: Date; - fields: { name: string; type: string; value: string }[]; - filters: string[]; - groups: string[]; -} - -export async function getInsights(...args: [websiteId: string, criteria: GetInsightsCriteria]) { +export async function getInsights(...args: [websiteId: string, filters: QueryFilters]) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), @@ -22,18 +13,18 @@ export async function getInsights(...args: [websiteId: string, criteria: GetInsi async function relationalQuery( websiteId: string, - criteria: GetInsightsCriteria, + filters: QueryFilters, ): Promise< { x: string; y: number; }[] > { - const { startDate, endDate, filters = [] } = criteria; const { parseFilters, rawQuery } = prisma; - const website = await loadWebsite(websiteId); - const params = {}; - const { filterQuery, joinSession } = parseFilters(params); + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -48,37 +39,30 @@ async function relationalQuery( ${filterQuery} group by 1 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - }, + params, ); } async function clickhouseQuery( websiteId: string, - criteria: GetInsightsCriteria, + filters: QueryFilters, ): Promise< { x: string; y: number; }[] > { - const { startDate, endDate, fields = [], filters = [], groups = [] } = criteria; const { parseFilters, rawQuery } = clickhouse; - const website = await loadWebsite(websiteId); - const params = {}; - const { filterQuery } = parseFilters(params); - - const fieldsQuery = parseFields(fields); + const { fields } = filters; + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` select - ${fieldsQuery} + ${parseFields(fields)} from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} @@ -88,13 +72,7 @@ async function clickhouseQuery( order by total desc limit 500 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - }, + params, ); } diff --git a/queries/analytics/sessions/getSessionMetrics.ts b/queries/analytics/sessions/getSessionMetrics.ts index a9b49ec8..910c9785 100644 --- a/queries/analytics/sessions/getSessionMetrics.ts +++ b/queries/analytics/sessions/getSessionMetrics.ts @@ -2,14 +2,10 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters } from 'lib/types'; export async function getSessionMetrics( - ...args: [ - websiteId: string, - criteria: { startDate: Date; endDate: Date; column: string; filters: object }, - ] + ...args: [websiteId: string, column: string, filters: QueryFilters] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -17,14 +13,12 @@ export async function getSessionMetrics( }); } -async function relationalQuery( - websiteId: string, - criteria: { startDate: Date; endDate: Date; column: string; filters: object }, -) { - const website = await loadWebsite(websiteId); - const { startDate, endDate, column, filters = {} } = criteria; +async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) { const { parseFilters, rawQuery } = prisma; - const { filterQuery, joinSession } = parseFilters(filters); + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( `select ${column} x, count(*) y @@ -32,28 +26,22 @@ async function relationalQuery( ${joinSession} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type = {{eventType}} ${filterQuery} ) as t group by 1 order by 2 desc limit 100`, - { - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - ...filters, - }, + params, ); } -async function clickhouseQuery( - websiteId: string, - data: { startDate: Date; endDate: Date; column: string; filters: object }, -) { - const { startDate, endDate, column, filters = {} } = data; +async function clickhouseQuery(websiteId: string, column: string, filters: QueryFilters) { const { parseFilters, rawQuery } = clickhouse; - const website = await loadWebsite(websiteId); - const { filterQuery } = parseFilters(filters); + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -68,12 +56,6 @@ async function clickhouseQuery( order by y desc limit 100 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - }, + params, ); } diff --git a/queries/analytics/sessions/getSessionStats.ts b/queries/analytics/sessions/getSessionStats.ts index 7633f242..9ed01a59 100644 --- a/queries/analytics/sessions/getSessionStats.ts +++ b/queries/analytics/sessions/getSessionStats.ts @@ -2,43 +2,22 @@ import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import prisma from 'lib/prisma'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters } from 'lib/types'; -export interface SessionStatsCriteria { - startDate: Date; - endDate: Date; - timezone?: string; - unit?: string; - filters: { - url?: string; - referrer?: string; - title?: string; - browser?: string; - os?: string; - device?: string; - screen?: string; - language?: string; - country?: string; - region?: string; - city?: string; - }; -} - -export async function getSessionStats( - ...args: [websiteId: string, criteria: SessionStatsCriteria] -) { +export async function getSessionStats(...args: [websiteId: string, filters: QueryFilters]) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), }); } -async function relationalQuery(websiteId: string, criteria: SessionStatsCriteria) { - const { startDate, endDate, timezone = 'utc', unit = 'day', filters = {} } = criteria; +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; const { getDateQuery, parseFilters, rawQuery } = prisma; - const website = await loadWebsite(websiteId); - const { filterQuery, joinSession } = parseFilters(filters); + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -53,22 +32,17 @@ async function relationalQuery(websiteId: string, criteria: SessionStatsCriteria ${filterQuery} group by 1 `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } -async function clickhouseQuery(websiteId: string, criteria: SessionStatsCriteria) { - const { startDate, endDate, timezone = 'UTC', unit = 'day', filters = {} } = criteria; +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'UTC', unit = 'day' } = filters; const { parseFilters, rawQuery, getDateStringQuery, getDateQuery } = clickhouse; - const website = await loadWebsite(websiteId); - const { filterQuery } = parseFilters(filters); + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -88,13 +62,6 @@ async function clickhouseQuery(websiteId: string, criteria: SessionStatsCriteria ) as g order by t `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } diff --git a/queries/analytics/stats/getWebsiteDateRange.ts b/queries/analytics/stats/getWebsiteDateRange.ts index 45885e45..4fb24733 100644 --- a/queries/analytics/stats/getWebsiteDateRange.ts +++ b/queries/analytics/stats/getWebsiteDateRange.ts @@ -1,9 +1,7 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; -import { loadWebsite } from 'lib/load'; import { DEFAULT_RESET_DATE } from 'lib/constants'; -import { maxDate } from 'lib/date'; export async function getWebsiteDateRange(...args: [websiteId: string]) { return runQuery({ @@ -13,8 +11,8 @@ export async function getWebsiteDateRange(...args: [websiteId: string]) { } async function relationalQuery(websiteId: string) { - const { rawQuery } = prisma; - const website = await loadWebsite(websiteId); + const { rawQuery, parseFilters } = prisma; + const { params } = await parseFilters(websiteId, { startDate: new Date(DEFAULT_RESET_DATE) }); const result = await rawQuery( ` @@ -25,15 +23,15 @@ async function relationalQuery(websiteId: string) { where website_id = {{websiteId::uuid}} and created_at >= {{startDate}} `, - { websiteId, startDate: maxDate(new Date(DEFAULT_RESET_DATE), new Date(website.resetAt)) }, + params, ); return result[0] ?? null; } async function clickhouseQuery(websiteId: string) { - const { rawQuery } = clickhouse; - const website = await loadWebsite(websiteId); + const { rawQuery, parseFilters } = clickhouse; + const { params } = await parseFilters(websiteId, { startDate: new Date(DEFAULT_RESET_DATE) }); const result = await rawQuery( ` @@ -44,7 +42,7 @@ async function clickhouseQuery(websiteId: string) { where website_id = {websiteId:UUID} and created_at >= {startDate:DateTime} `, - { websiteId, startDate: maxDate(new Date(DEFAULT_RESET_DATE), new Date(website.resetAt)) }, + params, ); return result[0] ?? null; diff --git a/queries/analytics/stats/getWebsiteStats.ts b/queries/analytics/stats/getWebsiteStats.ts index e048fc8f..16519511 100644 --- a/queries/analytics/stats/getWebsiteStats.ts +++ b/queries/analytics/stats/getWebsiteStats.ts @@ -2,29 +2,21 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; import { EVENT_TYPE } from 'lib/constants'; -import { loadWebsite } from 'lib/load'; -import { maxDate } from 'lib/date'; +import { QueryFilters } from 'lib/types'; -export async function getWebsiteStats( - ...args: [ - websiteId: string, - data: { startDate: Date; endDate: Date; type?: string; filters: object }, - ] -) { +export async function getWebsiteStats(...args: [websiteId: string, filters: QueryFilters]) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), }); } -async function relationalQuery( - websiteId: string, - criteria: { startDate: Date; endDate: Date; filters: object }, -) { - const { startDate, endDate, filters = {} } = criteria; +async function relationalQuery(websiteId: string, filters: QueryFilters) { const { getDateQuery, getTimestampIntervalQuery, parseFilters, rawQuery } = prisma; - const website = await loadWebsite(websiteId); - const { filterQuery, joinSession } = parseFilters(filters); + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -43,32 +35,23 @@ async function relationalQuery( join website on website_event.website_id = website.website_id ${joinSession} - where event_type = {{eventType}} - and website.website_id = {{websiteId::uuid}} + where website.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} + and event_type = {{eventType}} ${filterQuery} group by 1, 2 ) as t `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } -async function clickhouseQuery( - websiteId: string, - criteria: { startDate: Date; endDate: Date; filters: object }, -) { - const { startDate, endDate, filters = {} } = criteria; +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, getDateQuery, parseFilters } = clickhouse; - const website = await loadWebsite(websiteId); - const { filterQuery } = parseFilters(filters); + const { filterQuery, params } = await parseFilters(websiteId, { + ...filters, + eventType: EVENT_TYPE.pageView, + }); return rawQuery( ` @@ -92,13 +75,6 @@ async function clickhouseQuery( group by session_id, time_series ) as t; `, - { - ...filters, - websiteId, - startDate: maxDate(startDate, website.resetAt), - endDate, - eventType: EVENT_TYPE.pageView, - domain: website.domain, - }, + params, ); } From c0ef8dace4f622a4951b012e2a9a1422344f60e9 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 13:33:41 -0700 Subject: [PATCH 08/73] Fixed session queries. --- pages/api/websites/[id]/pageviews.ts | 3 +-- queries/analytics/sessions/getSessionMetrics.ts | 5 +++-- queries/analytics/sessions/getSessionStats.ts | 5 +++-- queries/index.js | 1 + 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pages/api/websites/[id]/pageviews.ts b/pages/api/websites/[id]/pageviews.ts index 87c60d58..c5532e76 100644 --- a/pages/api/websites/[id]/pageviews.ts +++ b/pages/api/websites/[id]/pageviews.ts @@ -4,9 +4,8 @@ import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types'; import { canViewWebsite } from 'lib/auth'; import { useAuth, useCors } from 'lib/middleware'; -import { getPageviewStats } from 'queries'; +import { getPageviewStats, getSessionStats } from 'queries'; import { parseDateRangeQuery } from 'lib/query'; -import { getSessionStats } from '../../../../queries/analytics/sessions/getSessionStats'; export interface WebsitePageviewRequestQuery { id: string; diff --git a/queries/analytics/sessions/getSessionMetrics.ts b/queries/analytics/sessions/getSessionMetrics.ts index 910c9785..5ef387ec 100644 --- a/queries/analytics/sessions/getSessionMetrics.ts +++ b/queries/analytics/sessions/getSessionMetrics.ts @@ -15,7 +15,7 @@ export async function getSessionMetrics( async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) { const { parseFilters, rawQuery } = prisma; - const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + const { filterQuery, params } = await parseFilters(websiteId, { ...filters, eventType: EVENT_TYPE.pageView, }); @@ -23,7 +23,8 @@ async function relationalQuery(websiteId: string, column: string, filters: Query return rawQuery( `select ${column} x, count(*) y from website_event - ${joinSession} + inner join session + on session.session_id = website_event.session_id where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type = {{eventType}} diff --git a/queries/analytics/sessions/getSessionStats.ts b/queries/analytics/sessions/getSessionStats.ts index 9ed01a59..b8884a44 100644 --- a/queries/analytics/sessions/getSessionStats.ts +++ b/queries/analytics/sessions/getSessionStats.ts @@ -14,7 +14,7 @@ export async function getSessionStats(...args: [websiteId: string, filters: Quer async function relationalQuery(websiteId: string, filters: QueryFilters) { const { timezone = 'utc', unit = 'day' } = filters; const { getDateQuery, parseFilters, rawQuery } = prisma; - const { filterQuery, joinSession, params } = await parseFilters(websiteId, { + const { filterQuery, params } = await parseFilters(websiteId, { ...filters, eventType: EVENT_TYPE.pageView, }); @@ -25,7 +25,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { ${getDateQuery('website_event.created_at', unit, timezone)} x, count(distinct website_event.session_id) y from website_event - ${joinSession} + inner join session + on session.session_id = website_event.session_id where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} diff --git a/queries/index.js b/queries/index.js index f509e039..474ab31e 100644 --- a/queries/index.js +++ b/queries/index.js @@ -19,6 +19,7 @@ export * from './analytics/sessions/createSession'; export * from './analytics/sessions/getSession'; export * from './analytics/sessions/getSessionMetrics'; export * from './analytics/sessions/getSessions'; +export * from './analytics/sessions/getSessionStats'; export * from './analytics/sessions/saveSessionData'; export * from './analytics/stats/getActiveVisitors'; export * from './analytics/stats/getRealtimeData'; From 2559263e91daeef2ab6f3b06675613da28191a07 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 14:00:37 -0700 Subject: [PATCH 09/73] Fixed session queries. --- .../analytics/sessions/getSessionMetrics.ts | 18 +++++++++--------- queries/analytics/sessions/getSessionStats.ts | 5 ++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/queries/analytics/sessions/getSessionMetrics.ts b/queries/analytics/sessions/getSessionMetrics.ts index 5ef387ec..f9bc2e94 100644 --- a/queries/analytics/sessions/getSessionMetrics.ts +++ b/queries/analytics/sessions/getSessionMetrics.ts @@ -21,15 +21,15 @@ async function relationalQuery(websiteId: string, column: string, filters: Query }); return rawQuery( - `select ${column} x, count(*) y - from website_event - inner join session - on session.session_id = website_event.session_id - where website_event.website_id = {{websiteId::uuid}} - and website_event.created_at between {{startDate}} and {{endDate}} - and website_event.event_type = {{eventType}} - ${filterQuery} - ) as t + ` + select ${column} x, count(*) y + from website_event + inner join session + on session.session_id = website_event.session_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type = {{eventType}} + ${filterQuery} group by 1 order by 2 desc limit 100`, diff --git a/queries/analytics/sessions/getSessionStats.ts b/queries/analytics/sessions/getSessionStats.ts index b8884a44..9ed01a59 100644 --- a/queries/analytics/sessions/getSessionStats.ts +++ b/queries/analytics/sessions/getSessionStats.ts @@ -14,7 +14,7 @@ export async function getSessionStats(...args: [websiteId: string, filters: Quer async function relationalQuery(websiteId: string, filters: QueryFilters) { const { timezone = 'utc', unit = 'day' } = filters; const { getDateQuery, parseFilters, rawQuery } = prisma; - const { filterQuery, params } = await parseFilters(websiteId, { + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { ...filters, eventType: EVENT_TYPE.pageView, }); @@ -25,8 +25,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { ${getDateQuery('website_event.created_at', unit, timezone)} x, count(distinct website_event.session_id) y from website_event - inner join session - on session.session_id = website_event.session_id + ${joinSession} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} From fab818e7c207028889bc5e8220bae30792074f2c Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 4 Aug 2023 14:09:45 -0700 Subject: [PATCH 10/73] add referrer query inside undefine / ignore check --- lib/prisma.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/prisma.ts b/lib/prisma.ts index d1b9d0e5..e1e209d2 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -72,14 +72,14 @@ function getFilterQuery(filters = {}): string { const filter = filters[key]; if (filter !== undefined && !IGNORED_FILTERS.includes(key)) { - const column = FILTER_COLUMNS[key] || key; - arr.push(`and ${column}={{${key}}}`); - } - - if (key === 'referrer') { - arr.push( - 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)', - ); + if (key === 'referrer') { + arr.push( + 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)', + ); + } else { + const column = FILTER_COLUMNS[key] || key; + arr.push(`and ${column}={{${key}}}`); + } } return arr; From 688705dbb3eb960bd8a45db69690374bed1d7083 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 14:23:03 -0700 Subject: [PATCH 11/73] Added query options. --- lib/prisma.ts | 15 ++++++++++----- lib/types.ts | 6 +++++- pages/api/websites/[id]/events.ts | 6 ++---- queries/analytics/events/getEventMetrics.ts | 5 +++-- .../analytics/pageviews/getPageviewMetrics.ts | 16 ++++++++++------ .../analytics/sessions/getSessionMetrics.ts | 19 ++++++++++++------- 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/prisma.ts b/lib/prisma.ts index d1b9d0e5..0125e7f3 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -4,7 +4,7 @@ import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; import { FILTER_COLUMNS, IGNORED_FILTERS, SESSION_COLUMNS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; -import { QueryFilters } from './types'; +import { QueryFilters, QueryOptions } from './types'; const MYSQL_DATE_FORMATS = { minute: '%Y-%m-%d %H:%i:00', @@ -88,13 +88,18 @@ function getFilterQuery(filters = {}): string { return query.join('\n'); } -async function parseFilters(websiteId, filters: QueryFilters & { [key: string]: any } = {}) { +async function parseFilters( + websiteId, + filters: QueryFilters & { [key: string]: any } = {}, + options: QueryOptions = {}, +) { const website = await loadWebsite(websiteId); return { - joinSession: Object.keys(filters).find(key => SESSION_COLUMNS[key]) - ? `inner join session on website_event.session_id = session.session_id` - : '', + joinSession: + options?.joinSession || Object.keys(filters).find(key => SESSION_COLUMNS.includes(key)) + ? `inner join session on website_event.session_id = session.session_id` + : '', filterQuery: getFilterQuery(filters), params: { ...filters, diff --git a/lib/types.ts b/lib/types.ts index 131740f8..3ce852ae 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -137,11 +137,11 @@ export interface QueryFilters { unit?: string; domain?: string; eventType?: number; + eventName?: string; url?: string; referrer?: string; title?: string; query?: string; - event?: string; os?: string; browser?: string; device?: string; @@ -150,3 +150,7 @@ export interface QueryFilters { city?: string; language?: string; } + +export interface QueryOptions { + joinSession?: boolean; +} diff --git a/pages/api/websites/[id]/events.ts b/pages/api/websites/[id]/events.ts index b9e3ac71..7d4f999f 100644 --- a/pages/api/websites/[id]/events.ts +++ b/pages/api/websites/[id]/events.ts @@ -43,10 +43,8 @@ export default async ( endDate, timezone, unit, - filters: { - url, - eventName, - }, + url, + eventName, }); return ok(res, events); diff --git a/queries/analytics/events/getEventMetrics.ts b/queries/analytics/events/getEventMetrics.ts index cf862f4a..09a85946 100644 --- a/queries/analytics/events/getEventMetrics.ts +++ b/queries/analytics/events/getEventMetrics.ts @@ -5,7 +5,7 @@ import { WebsiteEventMetric, QueryFilters } from 'lib/types'; import { EVENT_TYPE } from 'lib/constants'; export async function getEventMetrics( - ...args: [websiteId: string, criteria: QueryFilters] + ...args: [websiteId: string, filters: QueryFilters] ): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -16,7 +16,7 @@ export async function getEventMetrics( async function relationalQuery(websiteId: string, filters: QueryFilters) { const { timezone = 'utc', unit = 'day' } = filters; const { rawQuery, getDateQuery, parseFilters } = prisma; - const { filterQuery, params } = await parseFilters(websiteId, { + const { filterQuery, joinSession, params } = await parseFilters(websiteId, { ...filters, eventType: EVENT_TYPE.customEvent, }); @@ -28,6 +28,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { ${getDateQuery('created_at', unit, timezone)} t, count(*) y from website_event + ${joinSession} where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} diff --git a/queries/analytics/pageviews/getPageviewMetrics.ts b/queries/analytics/pageviews/getPageviewMetrics.ts index bbbd7705..b365d3f6 100644 --- a/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/queries/analytics/pageviews/getPageviewMetrics.ts @@ -1,7 +1,7 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; -import { EVENT_TYPE } from 'lib/constants'; +import { EVENT_TYPE, SESSION_COLUMNS } from 'lib/constants'; import { QueryFilters } from 'lib/types'; export async function getPageviewMetrics( @@ -15,16 +15,20 @@ export async function getPageviewMetrics( async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const { filterQuery, joinSession, params } = await parseFilters(websiteId, { - ...filters, - eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, - }); + const { filterQuery, joinSession, params } = await parseFilters( + websiteId, + { + ...filters, + eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, + }, + { joinSession: SESSION_COLUMNS.includes(column) }, + ); return rawQuery( ` select ${column} x, count(*) y from website_event - ${joinSession} + ${joinSession} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} diff --git a/queries/analytics/sessions/getSessionMetrics.ts b/queries/analytics/sessions/getSessionMetrics.ts index f9bc2e94..fb546a73 100644 --- a/queries/analytics/sessions/getSessionMetrics.ts +++ b/queries/analytics/sessions/getSessionMetrics.ts @@ -1,7 +1,7 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, CLICKHOUSE, PRISMA } from 'lib/db'; -import { EVENT_TYPE } from 'lib/constants'; +import { EVENT_TYPE, SESSION_COLUMNS } from 'lib/constants'; import { QueryFilters } from 'lib/types'; export async function getSessionMetrics( @@ -15,17 +15,22 @@ export async function getSessionMetrics( async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) { const { parseFilters, rawQuery } = prisma; - const { filterQuery, params } = await parseFilters(websiteId, { - ...filters, - eventType: EVENT_TYPE.pageView, - }); + const { filterQuery, joinSession, params } = await parseFilters( + websiteId, + { + ...filters, + eventType: EVENT_TYPE.pageView, + }, + { + joinSession: SESSION_COLUMNS.includes(column), + }, + ); return rawQuery( ` select ${column} x, count(*) y from website_event - inner join session - on session.session_id = website_event.session_id + ${joinSession} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type = {{eventType}} From dbdbc90ee7b8361c6597a444010c50691b4dce59 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 4 Aug 2023 14:30:10 -0700 Subject: [PATCH 12/73] fix if else in referrer filter --- lib/prisma.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/prisma.ts b/lib/prisma.ts index aa9efe74..50f26f75 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -72,13 +72,13 @@ function getFilterQuery(filters = {}): string { const filter = filters[key]; if (filter !== undefined && !IGNORED_FILTERS.includes(key)) { + const column = FILTER_COLUMNS[key] || key; + arr.push(`and ${column}={{${key}}}`); + if (key === 'referrer') { arr.push( 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)', ); - } else { - const column = FILTER_COLUMNS[key] || key; - arr.push(`and ${column}={{${key}}}`); } } From e11766ca1c6ee44f5c477c8d655b1daa66545347 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 15:21:14 -0700 Subject: [PATCH 13/73] Fixed event data queries. --- components/pages/event-data/EventDataTable.js | 13 +++--- lib/constants.ts | 6 +-- lib/types.ts | 1 + pages/api/event-data/events.ts | 12 +++-- pages/api/event-data/stats.ts | 5 ++- .../analytics/eventData/getEventDataEvents.ts | 45 +++++++++---------- 6 files changed, 44 insertions(+), 38 deletions(-) diff --git a/components/pages/event-data/EventDataTable.js b/components/pages/event-data/EventDataTable.js index 8260ac35..88de0109 100644 --- a/components/pages/event-data/EventDataTable.js +++ b/components/pages/event-data/EventDataTable.js @@ -13,15 +13,18 @@ export function EventDataTable({ data = [] }) { return ( - + {row => ( - - {row.event} + + {row.eventName} )} - - {row => row.field} + + {row => row.fieldName} + + + {row => row.dataType} {({ total }) => total.toLocaleString()} diff --git a/lib/constants.ts b/lib/constants.ts index 9362b456..dcb64143 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -48,12 +48,12 @@ export const FILTER_COLUMNS = { referrer: 'referrer_domain', title: 'page_title', query: 'url_query', - event: 'event_name', region: 'subdivision1', - type: 'event_type', + eventType: 'event_type', + eventName: 'event_name', }; -export const IGNORED_FILTERS = ['startDate', 'endDate', 'timezone', 'unit', 'eventType']; +export const IGNORED_FILTERS = ['startDate', 'endDate', 'timezone', 'unit']; export const COLLECTION_TYPE = { event: 'event', diff --git a/lib/types.ts b/lib/types.ts index 3ce852ae..7cc9a619 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -153,4 +153,5 @@ export interface QueryFilters { export interface QueryOptions { joinSession?: boolean; + ignoreFilters?: string[]; } diff --git a/pages/api/event-data/events.ts b/pages/api/event-data/events.ts index 1d74c3d2..e8693108 100644 --- a/pages/api/event-data/events.ts +++ b/pages/api/event-data/events.ts @@ -21,15 +21,19 @@ export default async ( await useAuth(req, res); if (req.method === 'GET') { - const { websiteId, startAt, endAt, field, event } = req.query; + const { websiteId, startAt, endAt, eventName } = req.query; if (!(await canViewWebsite(req.auth, websiteId))) { return unauthorized(res); } - const data = await getEventDataEvents(websiteId, new Date(+startAt), new Date(+endAt), { - field, - event, + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = await getEventDataEvents(websiteId, { + startDate, + endDate, + eventName, }); return ok(res, data); diff --git a/pages/api/event-data/stats.ts b/pages/api/event-data/stats.ts index 8fdf9438..969568e2 100644 --- a/pages/api/event-data/stats.ts +++ b/pages/api/event-data/stats.ts @@ -28,7 +28,10 @@ export default async ( return unauthorized(res); } - const results = await getEventDataFields(websiteId, new Date(+startAt), new Date(+endAt)); + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const results = await getEventDataFields(websiteId, { startDate, endDate }); const data = results.reduce( (obj, row) => { diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index fae46db1..d0d4ff46 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -4,7 +4,7 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import { QueryFilters, WebsiteEventDataFields } from 'lib/types'; export async function getEventDataEvents( - ...args: [websiteId: string, filters: QueryFilters & { field?: string; event?: string }] + ...args: [websiteId: string, filters: QueryFilters] ): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -12,20 +12,18 @@ export async function getEventDataEvents( }); } -async function relationalQuery( - websiteId: string, - filters: QueryFilters & { field?: string; event?: string }, -) { +async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; + const { eventName } = filters; const { params } = await parseFilters(websiteId, filters); - if (event) { + if (eventName) { return rawQuery( ` select - website_event.event_name as event, - event_data.event_key as field, - event_data.data_type as type, + website_event.event_name as eventName, + event_data.event_key as fieldName, + event_data.data_type as dataType, event_data.string_value as value, count(*) as total from event_data @@ -33,7 +31,7 @@ async function relationalQuery( on website_event.event_id = event_data.website_event_id where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} - and websit_event.event_name = {{event}} + and websit_event.event_name = {{eventName}} group by website_event.event_name, event_data.event_key, event_data.data_type, event_data.string_value order by 1 asc, 2 asc, 3 asc, 4 desc `, @@ -44,9 +42,9 @@ async function relationalQuery( return rawQuery( ` select - website_event.event_name as event, - event_data.event_key as field, - event_data.data_type as type, + website_event.event_name as eventName, + event_data.event_key as fieldName, + event_data.data_type as dataType, count(*) as total from event_data inner join website_event @@ -61,21 +59,18 @@ async function relationalQuery( ); } -async function clickhouseQuery( - websiteId: string, - filters: QueryFilters & { field?: string; event?: string }, -) { +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = clickhouse; - const { event } = filters; + const { eventName } = filters; const { params } = await parseFilters(websiteId, filters); - if (event) { + if (eventName) { return rawQuery( ` select - event_name as event, - event_key as field, - data_type as type, + event_name as eventName, + event_key as fieldName, + data_type as dataType, string_value as value, count(*) as total from event_data @@ -93,9 +88,9 @@ async function clickhouseQuery( return rawQuery( ` select - event_name as event, - event_key as field, - data_type as type, + event_name as eventName, + event_key as fieldName, + data_type as dataType, count(*) as total from event_data where website_id = {websiteId:UUID} From 91d2b596d6c6d1b317451dd0461f6b8a7a9702d5 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 4 Aug 2023 15:31:46 -0700 Subject: [PATCH 14/73] Fixed event data display. --- components/pages/event-data/EventDataTable.js | 5 +++-- .../pages/event-data/EventDataValueTable.js | 15 +++++++++------ components/pages/websites/WebsiteEventData.js | 14 +++++++------- queries/analytics/eventData/getEventDataEvents.ts | 2 +- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/components/pages/event-data/EventDataTable.js b/components/pages/event-data/EventDataTable.js index 88de0109..55fb0f59 100644 --- a/components/pages/event-data/EventDataTable.js +++ b/components/pages/event-data/EventDataTable.js @@ -2,6 +2,7 @@ import Link from 'next/link'; import { GridTable, GridColumn } from 'react-basics'; import { useMessages, usePageQuery } from 'hooks'; import Empty from 'components/common/Empty'; +import { DATA_TYPES } from 'lib/constants'; export function EventDataTable({ data = [] }) { const { formatMessage, labels } = useMessages(); @@ -15,7 +16,7 @@ export function EventDataTable({ data = [] }) { {row => ( - + {row.eventName} )} @@ -24,7 +25,7 @@ export function EventDataTable({ data = [] }) { {row => row.fieldName} - {row => row.dataType} + {row => DATA_TYPES[row.dataType]} {({ total }) => total.toLocaleString()} diff --git a/components/pages/event-data/EventDataValueTable.js b/components/pages/event-data/EventDataValueTable.js index 2637053e..b52c46d3 100644 --- a/components/pages/event-data/EventDataValueTable.js +++ b/components/pages/event-data/EventDataValueTable.js @@ -1,18 +1,19 @@ -import { GridTable, GridColumn, Button, Icon, Text, Flexbox } from 'react-basics'; +import { GridTable, GridColumn, Button, Icon, Text } from 'react-basics'; import { useMessages, usePageQuery } from 'hooks'; import Link from 'next/link'; import Icons from 'components/icons'; import PageHeader from 'components/layout/PageHeader'; import Empty from 'components/common/Empty'; +import { DATA_TYPES } from 'lib/constants'; -export function EventDataValueTable({ data = [], event }) { +export function EventDataValueTable({ data = [], eventName }) { const { formatMessage, labels } = useMessages(); const { resolveUrl } = usePageQuery(); const Title = () => { return ( <> - + - {event} + {eventName} ); }; @@ -31,8 +32,10 @@ export function EventDataValueTable({ data = [], event }) { {data.length <= 0 && } {data.length > 0 && ( - - + + + {row => DATA_TYPES[row.dataType]} + {({ total }) => total.toLocaleString()} diff --git a/components/pages/websites/WebsiteEventData.js b/components/pages/websites/WebsiteEventData.js index 7f9a6829..d6cb2639 100644 --- a/components/pages/websites/WebsiteEventData.js +++ b/components/pages/websites/WebsiteEventData.js @@ -5,18 +5,18 @@ import { EventDataMetricsBar } from 'components/pages/event-data/EventDataMetric import { useDateRange, useApi, usePageQuery } from 'hooks'; import styles from './WebsiteEventData.module.css'; -function useData(websiteId, event) { +function useData(websiteId, eventName) { const [dateRange] = useDateRange(websiteId); const { startDate, endDate } = dateRange; const { get, useQuery } = useApi(); const { data, error, isLoading } = useQuery( - ['event-data:events', { websiteId, startDate, endDate, event }], + ['event-data:events', { websiteId, startDate, endDate, eventName }], () => get('/event-data/events', { websiteId, startAt: +startDate, endAt: +endDate, - event, + eventName, }), { enabled: !!(websiteId && startDate && endDate) }, ); @@ -26,15 +26,15 @@ function useData(websiteId, event) { export default function WebsiteEventData({ websiteId }) { const { - query: { event }, + query: { eventName }, } = usePageQuery(); - const { data } = useData(websiteId, event); + const { data } = useData(websiteId, eventName); return ( - {!event && } - {event && } + {!eventName && } + {eventName && } ); } diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index d0d4ff46..dcb21283 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -76,7 +76,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { from event_data where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} - and event_name = {event:String} + and event_name = {eventName:String} group by event_key, data_type, string_value, event_name order by 1 asc, 2 asc, 3 asc, 4 desc limit 100 From 5e1111db5d6f486dad585297c70d37f90b4cb108 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sat, 5 Aug 2023 09:09:54 -0700 Subject: [PATCH 15/73] Update to insights query. --- components/pages/reports/FilterSelectForm.js | 13 +++++++ .../reports/insights/InsightsParameters.js | 24 +++++-------- lib/constants.ts | 11 +++++- pages/api/reports/insights.ts | 14 ++++---- queries/analytics/reports/getInsights.ts | 35 ++++++++----------- 5 files changed, 54 insertions(+), 43 deletions(-) create mode 100644 components/pages/reports/FilterSelectForm.js diff --git a/components/pages/reports/FilterSelectForm.js b/components/pages/reports/FilterSelectForm.js new file mode 100644 index 00000000..0dc107b0 --- /dev/null +++ b/components/pages/reports/FilterSelectForm.js @@ -0,0 +1,13 @@ +import { useState } from 'react'; +import FieldSelectForm from './FieldSelectForm'; +import FieldFilterForm from './FieldFilterForm'; + +export default function FilterSelectForm({ fields, onSelect }) { + const [field, setField] = useState(); + + if (!field) { + return ; + } + + return ; +} diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index 692c5ead..5b9b8f18 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -7,9 +7,9 @@ import Icons from 'components/icons'; import BaseParameters from '../BaseParameters'; import ParameterList from '../ParameterList'; import styles from './InsightsParameters.module.css'; -import FieldSelectForm from '../FieldSelectForm'; import PopupForm from '../PopupForm'; -import FieldFilterForm from '../FieldFilterForm'; +import FilterSelectForm from '../FilterSelectForm'; +import FieldSelectForm from '../FieldSelectForm'; const fieldOptions = [ { name: 'url', type: 'string' }, @@ -30,17 +30,15 @@ export function InsightsParameters() { const { formatMessage, labels } = useMessages(); const ref = useRef(null); const { parameters } = report || {}; - const { websiteId, dateRange, fields, filters, groups } = parameters || {}; - const queryEnabled = websiteId && dateRange && fields?.length; + const { websiteId, dateRange, filters, groups } = parameters || {}; + const queryEnabled = websiteId && dateRange && (filters?.length || groups?.length); const parameterGroups = [ - { label: formatMessage(labels.fields), group: REPORT_PARAMETERS.fields }, { label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters }, { label: formatMessage(labels.breakdown), group: REPORT_PARAMETERS.groups }, ]; const parameterData = { - fields, filters, groups, }; @@ -73,11 +71,11 @@ export function InsightsParameters() { {(close, element) => { return ( - {group === REPORT_PARAMETERS.fields && ( - - )} {group === REPORT_PARAMETERS.filters && ( - + + )} + {group === REPORT_PARAMETERS.groups && ( + )} ); @@ -100,12 +98,6 @@ export function InsightsParameters() { {({ name, value }) => { return (
- {group === REPORT_PARAMETERS.fields && ( - <> -
{name}
-
{value}
- - )} {group === REPORT_PARAMETERS.filters && ( <>
{name}
diff --git a/lib/constants.ts b/lib/constants.ts index dcb64143..cb520c27 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -53,7 +53,16 @@ export const FILTER_COLUMNS = { eventName: 'event_name', }; -export const IGNORED_FILTERS = ['startDate', 'endDate', 'timezone', 'unit']; +export const IGNORED_FILTERS = [ + 'startDate', + 'endDate', + 'timezone', + 'unit', + 'eventType', + 'fields', + 'filters', + 'groups', +]; export const COLLECTION_TYPE = { event: 'event', diff --git a/pages/api/reports/insights.ts b/pages/api/reports/insights.ts index a40c2124..f245153f 100644 --- a/pages/api/reports/insights.ts +++ b/pages/api/reports/insights.ts @@ -36,13 +36,15 @@ export default async ( return unauthorized(res); } - const data = await getInsights(websiteId, { - startDate: new Date(startDate), - endDate: new Date(endDate), - fields, - filters, + const data = await getInsights( + websiteId, + { + ...filters, + startDate: new Date(startDate), + endDate: new Date(endDate), + }, groups, - }); + ); return ok(res, data); } diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index dfe7c397..93569fe7 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -4,7 +4,9 @@ import clickhouse from 'lib/clickhouse'; import { EVENT_TYPE } from 'lib/constants'; import { QueryFilters } from 'lib/types'; -export async function getInsights(...args: [websiteId: string, filters: QueryFilters]) { +export async function getInsights( + ...args: [websiteId: string, filters: QueryFilters, groups: { name: string; type: string }[]] +) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), @@ -14,6 +16,7 @@ export async function getInsights(...args: [websiteId: string, filters: QueryFil async function relationalQuery( websiteId: string, filters: QueryFilters, + groups: { name: string; type: string }[], ): Promise< { x: string; @@ -46,6 +49,7 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, filters: QueryFilters, + groups: { name: string; type: string }[], ): Promise< { x: string; @@ -53,7 +57,6 @@ async function clickhouseQuery( }[] > { const { parseFilters, rawQuery } = clickhouse; - const { fields } = filters; const { filterQuery, params } = await parseFilters(websiteId, { ...filters, eventType: EVENT_TYPE.pageView, @@ -62,14 +65,14 @@ async function clickhouseQuery( return rawQuery( ` select - ${parseFields(fields)} + ${parseFields(groups)} from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} and event_type = {eventType:UInt32} ${filterQuery} - group by ${fields.map(({ name }) => name).join(',')} - order by total desc + group by ${groups.map(({ name }) => name).join(',')} + order by 1 desc limit 500 `, params, @@ -77,22 +80,14 @@ async function clickhouseQuery( } function parseFields(fields) { - let count = false; - let distinct = false; + const query = fields.reduce( + (arr, field) => { + const { name } = field; - const query = fields.reduce((arr, field) => { - const { name, value } = field; - - if (!count && value === 'total') { - count = true; - arr = arr.concat(`count(*) as views`); - } else if (!distinct && value === 'unique') { - distinct = true; - //arr = arr.concat(`count(distinct ${name})`); - } - - return arr.concat(name); - }, []); + return arr.concat(name); + }, + ['count(*) as views', 'count(distinct session_id) as visitors'], + ); return query.join(',\n'); } From f48720c9159d4d4ba7ea86e0098f50896e7b484c Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 6 Aug 2023 22:52:17 -0700 Subject: [PATCH 16/73] Breakdown feature for insights report. --- components/messages.js | 7 +++ components/pages/reports/ReportDetails.js | 2 + .../reports/insights/InsightsParameters.js | 54 +++++++++---------- .../pages/reports/insights/InsightsTable.js | 9 ++-- pages/api/reports/insights.ts | 19 +++---- queries/analytics/reports/getInsights.ts | 6 +-- 6 files changed, 51 insertions(+), 46 deletions(-) diff --git a/components/messages.js b/components/messages.js index a31e2875..286a5788 100644 --- a/components/messages.js +++ b/components/messages.js @@ -162,6 +162,13 @@ export const labels = defineMessages({ totalRecords: { id: 'labels.total-records', defaultMessage: 'Total records' }, insights: { id: 'label.insights', defaultMessage: 'Insights' }, dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' }, + referrer: { id: 'label.referrer', defaultMessage: 'Referrer' }, + country: { id: 'label.country', defaultMessage: 'Country' }, + region: { id: 'label.region', defaultMessage: 'Region' }, + city: { id: 'label.city', defaultMessage: 'City' }, + browser: { id: 'label.browser', defaultMessage: 'Browser' }, + device: { id: 'label.device', defaultMessage: 'Device' }, + pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, }); export const messages = defineMessages({ diff --git a/components/pages/reports/ReportDetails.js b/components/pages/reports/ReportDetails.js index c41d12f6..df130760 100644 --- a/components/pages/reports/ReportDetails.js +++ b/components/pages/reports/ReportDetails.js @@ -1,9 +1,11 @@ import FunnelReport from './funnel/FunnelReport'; import EventDataReport from './event-data/EventDataReport'; +import InsightsReport from './insights/InsightsReport'; const reports = { funnel: FunnelReport, 'event-data': EventDataReport, + insights: InsightsReport, }; export default function ReportDetails({ reportId, reportType }) { diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index 5b9b8f18..5d7e1fca 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -11,20 +11,6 @@ import PopupForm from '../PopupForm'; import FilterSelectForm from '../FilterSelectForm'; import FieldSelectForm from '../FieldSelectForm'; -const fieldOptions = [ - { name: 'url', type: 'string' }, - { name: 'title', type: 'string' }, - { name: 'referrer', type: 'string' }, - { name: 'query', type: 'string' }, - { name: 'browser', type: 'string' }, - { name: 'os', type: 'string' }, - { name: 'device', type: 'string' }, - { name: 'country', type: 'string' }, - { name: 'region', type: 'string' }, - { name: 'city', type: 'string' }, - { name: 'language', type: 'string' }, -]; - export function InsightsParameters() { const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); @@ -33,9 +19,23 @@ export function InsightsParameters() { const { websiteId, dateRange, filters, groups } = parameters || {}; const queryEnabled = websiteId && dateRange && (filters?.length || groups?.length); + const fieldOptions = [ + { name: 'url_path', type: 'string', label: formatMessage(labels.url) }, + { name: 'page_title', type: 'string', label: formatMessage(labels.pageTitle) }, + { name: 'referrer_domain', type: 'string', label: formatMessage(labels.referrer) }, + { name: 'url_query', type: 'string', label: formatMessage(labels.query) }, + { name: 'browser', type: 'string', label: formatMessage(labels.browser) }, + { name: 'os', type: 'string', label: formatMessage(labels.os) }, + { name: 'device', type: 'string', label: formatMessage(labels.device) }, + { name: 'country', type: 'string', label: formatMessage(labels.country) }, + { name: 'region', type: 'string', label: formatMessage(labels.region) }, + { name: 'city', type: 'string', label: formatMessage(labels.city) }, + { name: 'language', type: 'string', label: formatMessage(labels.language) }, + ]; + const parameterGroups = [ - { label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters }, { label: formatMessage(labels.breakdown), group: REPORT_PARAMETERS.groups }, + { label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters }, ]; const parameterData = { @@ -71,12 +71,12 @@ export function InsightsParameters() { {(close, element) => { return ( - {group === REPORT_PARAMETERS.filters && ( - - )} {group === REPORT_PARAMETERS.groups && ( )} + {group === REPORT_PARAMETERS.filters && ( + + )} ); }} @@ -95,19 +95,19 @@ export function InsightsParameters() { items={parameterData[group]} onRemove={index => handleRemove(group, index)} > - {({ name, value }) => { + {({ value, label }) => { return (
- {group === REPORT_PARAMETERS.filters && ( - <> -
{name}
-
{value[0]}
-
{value[1]}
- - )} {group === REPORT_PARAMETERS.groups && ( <> -
{name}
+
{label}
+ + )} + {group === REPORT_PARAMETERS.filters && ( + <> +
{label}
+
{value[0]}
+
{value[1]}
)}
diff --git a/components/pages/reports/insights/InsightsTable.js b/components/pages/reports/insights/InsightsTable.js index d751445b..7832a899 100644 --- a/components/pages/reports/insights/InsightsTable.js +++ b/components/pages/reports/insights/InsightsTable.js @@ -6,14 +6,15 @@ import { ReportContext } from '../Report'; export function InsightsTable() { const { report } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); - const { fields = [] } = report?.parameters || {}; + const { groups = [] } = report?.parameters || {}; return ( - {fields.map(({ name }) => { - return ; + {groups.map(({ name, label }) => { + return ; })} - + + ); } diff --git a/pages/api/reports/insights.ts b/pages/api/reports/insights.ts index f245153f..44f72063 100644 --- a/pages/api/reports/insights.ts +++ b/pages/api/reports/insights.ts @@ -13,7 +13,7 @@ export interface InsightsRequestBody { }; fields: { name: string; type: string; value: string }[]; filters: string[]; - groups: string[]; + groups: { name: string; type: string }[]; } export default async ( @@ -27,24 +27,19 @@ export default async ( const { websiteId, dateRange: { startDate, endDate }, - fields, - filters, groups, + filters, } = req.body; if (!(await canViewWebsite(req.auth, websiteId))) { return unauthorized(res); } - const data = await getInsights( - websiteId, - { - ...filters, - startDate: new Date(startDate), - endDate: new Date(endDate), - }, - groups, - ); + const data = await getInsights(websiteId, groups, { + ...filters, + startDate: new Date(startDate), + endDate: new Date(endDate), + }); return ok(res, data); } diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index 93569fe7..0f778555 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -5,7 +5,7 @@ import { EVENT_TYPE } from 'lib/constants'; import { QueryFilters } from 'lib/types'; export async function getInsights( - ...args: [websiteId: string, filters: QueryFilters, groups: { name: string; type: string }[]] + ...args: [websiteId: string, groups: { name: string; type: string }[], filters: QueryFilters] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -15,8 +15,8 @@ export async function getInsights( async function relationalQuery( websiteId: string, - filters: QueryFilters, groups: { name: string; type: string }[], + filters: QueryFilters, ): Promise< { x: string; @@ -48,8 +48,8 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, - filters: QueryFilters, groups: { name: string; type: string }[], + filters: QueryFilters, ): Promise< { x: string; From f57fbe6ba1543882ebf69bad929b73654f22519b Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 6 Aug 2023 22:52:58 -0700 Subject: [PATCH 17/73] Support Cloudflare headers for city and region. --- lib/detect.ts | 15 +++++++-------- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/detect.ts b/lib/detect.ts index 9c1e1fa4..43dac649 100644 --- a/lib/detect.ts +++ b/lib/detect.ts @@ -3,6 +3,7 @@ import { getClientIp } from 'request-ip'; import { browserName, detectOS } from 'detect-browser'; import isLocalhost from 'is-localhost-ip'; import maxmind from 'maxmind'; +import { safeDecodeURIComponent } from 'next-basics'; import { DESKTOP_OS, @@ -65,20 +66,18 @@ export async function getLocation(ip, req) { // Cloudflare headers if (req.headers['cf-ipcountry']) { return { - country: req.headers['cf-ipcountry'], + country: safeDecodeURIComponent(req.headers['cf-ipcountry']), + subdivision1: safeDecodeURIComponent(req.headers['cf-region-code']), + city: safeDecodeURIComponent(req.headers['cf-ipcity']), }; } // Vercel headers if (req.headers['x-vercel-ip-country']) { - const country = req.headers['x-vercel-ip-country']; - const region = req.headers['x-vercel-ip-country-region']; - const city = req.headers['x-vercel-ip-city']; - return { - country, - subdivision1: region, - city: city ? decodeURIComponent(city) : undefined, + country: safeDecodeURIComponent(req.headers['x-vercel-ip-country']), + subdivision1: safeDecodeURIComponent(req.headers['x-vercel-ip-country-region']), + city: safeDecodeURIComponent(req.headers['x-vercel-ip-city']), }; } diff --git a/package.json b/package.json index 868b3cdf..647cdf41 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "maxmind": "^4.3.6", "moment-timezone": "^0.5.35", "next": "13.3.1", - "next-basics": "^0.35.0", + "next-basics": "^0.36.0", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", "react": "^18.2.0", diff --git a/yarn.lock b/yarn.lock index 275bcd63..d9224c2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6371,10 +6371,10 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -next-basics@^0.35.0: - version "0.35.0" - resolved "https://registry.yarnpkg.com/next-basics/-/next-basics-0.35.0.tgz#aa68fd35a0e3fbabfdaf570cd092b6a7cf8df6f5" - integrity sha512-yqXZMLe109hSJ8sebI/f2m1XNnVuQowpELOhZSGOFOmLfvUyFBAEi0ULdqX1eb8xbttLgjcrumrZfMgmEwuCPw== +next-basics@^0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/next-basics/-/next-basics-0.36.0.tgz#b1675c3f2b98df2fec8df605095dab7d17f9dc7b" + integrity sha512-Nwou8pCjFuoD/ZxUw9iKC7hhZeWbo/ng0ze74yck3W89MNc/CepwCDziflAHY5XcmIVNmpXOCu9OfmzTdVRPWQ== dependencies: bcryptjs "^2.4.3" jsonwebtoken "^9.0.0" From 112005212e90ef678a3e8475f9a8c909cb49ee31 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 6 Aug 2023 23:04:19 -0700 Subject: [PATCH 18/73] Fixed events query. --- lib/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/constants.ts b/lib/constants.ts index cb520c27..67ed1c51 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -51,6 +51,7 @@ export const FILTER_COLUMNS = { region: 'subdivision1', eventType: 'event_type', eventName: 'event_name', + event: 'event_name', }; export const IGNORED_FILTERS = [ From 9d86385f5c6b48c515974dad56eee7c000b25300 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 7 Aug 2023 12:43:43 -0700 Subject: [PATCH 19/73] Updated filtering logic. --- components/pages/event-data/EventDataTable.js | 2 +- .../pages/event-data/EventDataValueTable.js | 6 +++--- components/pages/websites/WebsiteEventData.js | 8 ++++---- lib/clickhouse.ts | 6 +++--- lib/constants.ts | 19 ++++++------------- lib/prisma.ts | 6 +++--- lib/types.ts | 3 +-- pages/api/event-data/events.ts | 4 ++-- 8 files changed, 23 insertions(+), 31 deletions(-) diff --git a/components/pages/event-data/EventDataTable.js b/components/pages/event-data/EventDataTable.js index 55fb0f59..b0d11b9d 100644 --- a/components/pages/event-data/EventDataTable.js +++ b/components/pages/event-data/EventDataTable.js @@ -16,7 +16,7 @@ export function EventDataTable({ data = [] }) { {row => ( - + {row.eventName} )} diff --git a/components/pages/event-data/EventDataValueTable.js b/components/pages/event-data/EventDataValueTable.js index b52c46d3..3688ad09 100644 --- a/components/pages/event-data/EventDataValueTable.js +++ b/components/pages/event-data/EventDataValueTable.js @@ -6,14 +6,14 @@ import PageHeader from 'components/layout/PageHeader'; import Empty from 'components/common/Empty'; import { DATA_TYPES } from 'lib/constants'; -export function EventDataValueTable({ data = [], eventName }) { +export function EventDataValueTable({ data = [], event }) { const { formatMessage, labels } = useMessages(); const { resolveUrl } = usePageQuery(); const Title = () => { return ( <> - + - {eventName} + {event} ); }; diff --git a/components/pages/websites/WebsiteEventData.js b/components/pages/websites/WebsiteEventData.js index d6cb2639..5e208355 100644 --- a/components/pages/websites/WebsiteEventData.js +++ b/components/pages/websites/WebsiteEventData.js @@ -26,15 +26,15 @@ function useData(websiteId, eventName) { export default function WebsiteEventData({ websiteId }) { const { - query: { eventName }, + query: { event }, } = usePageQuery(); - const { data } = useData(websiteId, eventName); + const { data } = useData(websiteId, event); return ( - {!eventName && } - {eventName && } + {!event && } + {event && } ); } diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 6d5bcf42..a40567d3 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -3,7 +3,7 @@ import dateFormat from 'dateformat'; import debug from 'debug'; import { CLICKHOUSE } from 'lib/db'; import { QueryFilters } from './types'; -import { FILTER_COLUMNS, IGNORED_FILTERS } from './constants'; +import { FILTER_COLUMNS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; @@ -66,9 +66,9 @@ function getDateFormat(date) { function getFilterQuery(filters = {}) { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; + const column = FILTER_COLUMNS[key]; - if (filter !== undefined && !IGNORED_FILTERS.includes(key)) { - const column = FILTER_COLUMNS[key] || key; + if (filter !== undefined && column) { arr.push(`and ${column} = {${key}:String}`); } diff --git a/lib/constants.ts b/lib/constants.ts index 67ed1c51..887f90a9 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -48,23 +48,16 @@ export const FILTER_COLUMNS = { referrer: 'referrer_domain', title: 'page_title', query: 'url_query', + os: 'os', + browser: 'browser', + device: 'device', + country: 'country', region: 'subdivision1', - eventType: 'event_type', - eventName: 'event_name', + city: 'city', + language: 'language', event: 'event_name', }; -export const IGNORED_FILTERS = [ - 'startDate', - 'endDate', - 'timezone', - 'unit', - 'eventType', - 'fields', - 'filters', - 'groups', -]; - export const COLLECTION_TYPE = { event: 'event', identify: 'identify', diff --git a/lib/prisma.ts b/lib/prisma.ts index 50f26f75..a9ddf188 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -1,7 +1,7 @@ import prisma from '@umami/prisma-client'; import moment from 'moment-timezone'; import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; -import { FILTER_COLUMNS, IGNORED_FILTERS, SESSION_COLUMNS } from './constants'; +import { FILTER_COLUMNS, SESSION_COLUMNS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; import { QueryFilters, QueryOptions } from './types'; @@ -70,9 +70,9 @@ function getTimestampIntervalQuery(field: string): string { function getFilterQuery(filters = {}): string { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; + const column = FILTER_COLUMNS[key]; - if (filter !== undefined && !IGNORED_FILTERS.includes(key)) { - const column = FILTER_COLUMNS[key] || key; + if (filter !== undefined && column) { arr.push(`and ${column}={{${key}}}`); if (key === 'referrer') { diff --git a/lib/types.ts b/lib/types.ts index 7cc9a619..6057c42e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -135,9 +135,7 @@ export interface QueryFilters { endDate?: Date; timezone?: string; unit?: string; - domain?: string; eventType?: number; - eventName?: string; url?: string; referrer?: string; title?: string; @@ -149,6 +147,7 @@ export interface QueryFilters { region?: string; city?: string; language?: string; + event?: string; } export interface QueryOptions { diff --git a/pages/api/event-data/events.ts b/pages/api/event-data/events.ts index e8693108..e83e541b 100644 --- a/pages/api/event-data/events.ts +++ b/pages/api/event-data/events.ts @@ -21,7 +21,7 @@ export default async ( await useAuth(req, res); if (req.method === 'GET') { - const { websiteId, startAt, endAt, eventName } = req.query; + const { websiteId, startAt, endAt, event } = req.query; if (!(await canViewWebsite(req.auth, websiteId))) { return unauthorized(res); @@ -33,7 +33,7 @@ export default async ( const data = await getEventDataEvents(websiteId, { startDate, endDate, - eventName, + event, }); return ok(res, data); From 7da7f58cbe174120021b1889efa555a83845b70d Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 7 Aug 2023 13:28:32 -0700 Subject: [PATCH 20/73] Added "columns" to query options. Added events count to event data metrics. --- .../pages/event-data/EventDataMetricsBar.js | 5 ++ lib/clickhouse.ts | 9 +-- lib/prisma.ts | 6 +- lib/types.ts | 12 ++-- pages/api/event-data/fields.ts | 6 +- pages/api/event-data/stats.ts | 4 +- .../analytics/eventData/getEventDataFields.ts | 68 +++++-------------- 7 files changed, 45 insertions(+), 65 deletions(-) diff --git a/components/pages/event-data/EventDataMetricsBar.js b/components/pages/event-data/EventDataMetricsBar.js index 48843287..90f065d5 100644 --- a/components/pages/event-data/EventDataMetricsBar.js +++ b/components/pages/event-data/EventDataMetricsBar.js @@ -28,6 +28,11 @@ export function EventDataMetricsBar({ websiteId }) { {!error && isFetched && ( <> + { const filter = filters[key]; - const column = FILTER_COLUMNS[key]; + const column = FILTER_COLUMNS[key] ?? options?.columns?.[key]; if (filter !== undefined && column) { arr.push(`and ${column} = {${key}:String}`); @@ -85,11 +85,12 @@ function getFilterQuery(filters = {}) { async function parseFilters( websiteId: string, filters: QueryFilters & { [key: string]: any } = {}, + options?: QueryOptions, ) { const website = await loadWebsite(websiteId); return { - filterQuery: getFilterQuery(filters), + filterQuery: getFilterQuery(filters, options), params: { ...filters, websiteId, diff --git a/lib/prisma.ts b/lib/prisma.ts index a9ddf188..753f1ae4 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -67,10 +67,10 @@ function getTimestampIntervalQuery(field: string): string { } } -function getFilterQuery(filters = {}): string { +function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string { const query = Object.keys(filters).reduce((arr, key) => { const filter = filters[key]; - const column = FILTER_COLUMNS[key]; + const column = FILTER_COLUMNS[key] ?? options?.columns?.[key]; if (filter !== undefined && column) { arr.push(`and ${column}={{${key}}}`); @@ -100,7 +100,7 @@ async function parseFilters( options?.joinSession || Object.keys(filters).find(key => SESSION_COLUMNS.includes(key)) ? `inner join session on website_event.session_id = session.session_id` : '', - filterQuery: getFilterQuery(filters), + filterQuery: getFilterQuery(filters, options), params: { ...filters, websiteId, diff --git a/lib/types.ts b/lib/types.ts index 6057c42e..dc54fd47 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -80,15 +80,15 @@ export interface WebsiteEventMetric { } export interface WebsiteEventDataStats { - field: string; - type: number; + fieldName: string; + dataType: number; total: number; } export interface WebsiteEventDataFields { - field: string; - type: number; - value?: string; + fieldName: string; + dataType: number; + fieldValue?: string; total: number; } @@ -152,5 +152,5 @@ export interface QueryFilters { export interface QueryOptions { joinSession?: boolean; - ignoreFilters?: string[]; + columns?: { [key: string]: string }; } diff --git a/pages/api/event-data/fields.ts b/pages/api/event-data/fields.ts index 18b74bc3..f21bd570 100644 --- a/pages/api/event-data/fields.ts +++ b/pages/api/event-data/fields.ts @@ -11,6 +11,7 @@ export interface EventDataFieldsRequestBody { startDate: string; endDate: string; }; + field?: string; } export default async ( @@ -27,7 +28,10 @@ export default async ( return unauthorized(res); } - const data = await getEventDataFields(websiteId, new Date(+startAt), new Date(+endAt), field); + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = await getEventDataFields(websiteId, { startDate, endDate, field }); return ok(res, data); } diff --git a/pages/api/event-data/stats.ts b/pages/api/event-data/stats.ts index 969568e2..74f420c4 100644 --- a/pages/api/event-data/stats.ts +++ b/pages/api/event-data/stats.ts @@ -32,16 +32,18 @@ export default async ( const endDate = new Date(+endAt); const results = await getEventDataFields(websiteId, { startDate, endDate }); + const events = new Set(); const data = results.reduce( (obj, row) => { + events.add(row.fieldName); obj.records += Number(row.total); return obj; }, { fields: results.length, records: 0 }, ); - return ok(res, data); + return ok(res, { ...data, events: events.size }); } return methodNotAllowed(res); diff --git a/queries/analytics/eventData/getEventDataFields.ts b/queries/analytics/eventData/getEventDataFields.ts index a27f2281..c61de517 100644 --- a/queries/analytics/eventData/getEventDataFields.ts +++ b/queries/analytics/eventData/getEventDataFields.ts @@ -14,39 +14,23 @@ export async function getEventDataFields( async function relationalQuery(websiteId: string, filters: QueryFilters & { field?: string }) { const { rawQuery, parseFilters } = prisma; - const { field } = filters; - const { params } = await parseFilters(websiteId, filters); - - if (field) { - return rawQuery( - ` - select - event_key as field, - string_value as value, - count(*) as total - from event_data - where website_id = {{websiteId::uuid}} - and event_key = {{field}} - and created_at between {{startDate}} and {{endDate}} - group by event_key, string_value - order by 3 desc, 2 desc, 1 asc - limit 100 - `, - params, - ); - } + const { filterQuery, params } = await parseFilters(websiteId, filters, { + columns: { field: 'event_key' }, + }); return rawQuery( ` select - event_key as field, - data_type as type, + event_key as fieldName, + data_type as dataType, + string_value as fieldValue, count(*) as total from event_data where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} - group by event_key, data_type - order by 3 desc, 2 asc, 1 asc + ${filterQuery} + group by event_key, data_type, string_value + order by 3 desc, 2 desc, 1 asc limit 100 `, params, @@ -55,39 +39,23 @@ async function relationalQuery(websiteId: string, filters: QueryFilters & { fiel async function clickhouseQuery(websiteId: string, filters: QueryFilters & { field?: string }) { const { rawQuery, parseFilters } = clickhouse; - const { field } = filters; - const { params } = await parseFilters(websiteId, filters); - - if (field) { - return rawQuery( - ` - select - event_key as field, - string_value as value, - count(*) as total - from event_data - where website_id = {websiteId:UUID} - and event_key = {field:String} - and created_at between {startDate:DateTime} and {endDate:DateTime} - group by event_key, string_value - order by 3 desc, 2 desc, 1 asc - limit 100 - `, - params, - ); - } + const { filterQuery, params } = await parseFilters(websiteId, filters, { + columns: { field: 'event_key' }, + }); return rawQuery( ` select - event_key as field, - data_type as type, + event_key as fieldName, + data_type as dataType, + string_value as fieldValue, count(*) as total from event_data where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} - group by event_key, data_type - order by 3 desc, 2 asc, 1 asc + ${filterQuery} + group by event_key, data_type, string_value + order by 3 desc, 2 desc, 1 asc limit 100 `, params, From a71cf675ae1968b977a87b52f7c1f77165e54195 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 7 Aug 2023 13:43:44 -0700 Subject: [PATCH 21/73] Fixed event data query. --- queries/analytics/eventData/getEventDataEvents.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index dcb21283..ec0939b6 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -14,10 +14,10 @@ export async function getEventDataEvents( async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const { eventName } = filters; + const { event } = filters; const { params } = await parseFilters(websiteId, filters); - if (eventName) { + if (event) { return rawQuery( ` select @@ -31,7 +31,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { on website_event.event_id = event_data.website_event_id where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} - and websit_event.event_name = {{eventName}} + and website_event.event_name = {{event}} group by website_event.event_name, event_data.event_key, event_data.data_type, event_data.string_value order by 1 asc, 2 asc, 3 asc, 4 desc `, @@ -61,10 +61,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { async function clickhouseQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = clickhouse; - const { eventName } = filters; + const { event } = filters; const { params } = await parseFilters(websiteId, filters); - if (eventName) { + if (event) { return rawQuery( ` select @@ -76,7 +76,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { from event_data where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} - and event_name = {eventName:String} + and event_name = {event:String} group by event_key, data_type, string_value, event_name order by 1 asc, 2 asc, 3 asc, 4 desc limit 100 From 13530c9cdcf38289c80cc42563b05cb92dcf53ea Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 7 Aug 2023 14:01:53 -0700 Subject: [PATCH 22/73] add psql query for retention --- .../reports/retention/RetentionParameters.js | 14 +- .../reports/retention/RetentionReport.js | 6 +- .../pages/reports/retention/RetentionTable.js | 25 +- pages/api/reports/retention.ts | 15 +- queries/analytics/reports/getRetention.ts | 328 +++++++++--------- 5 files changed, 193 insertions(+), 195 deletions(-) diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js index 29c0eff2..bf40236d 100644 --- a/components/pages/reports/retention/RetentionParameters.js +++ b/components/pages/reports/retention/RetentionParameters.js @@ -4,6 +4,11 @@ import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from ' import { ReportContext } from 'components/pages/reports/Report'; import BaseParameters from '../BaseParameters'; +const fieldOptions = [ + { name: 'daily', type: 'string' }, + { name: 'weekly', type: 'string' }, +]; + export function RetentionParameters() { const { report, runReport, isRunning } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); @@ -24,14 +29,7 @@ export function RetentionParameters() { return (
- - - - - + {formatMessage(labels.runQuery)} diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js index 31d085f7..cab3c16c 100644 --- a/components/pages/reports/retention/RetentionReport.js +++ b/components/pages/reports/retention/RetentionReport.js @@ -8,8 +8,8 @@ import ReportBody from '../ReportBody'; import Funnel from 'assets/funnel.svg'; const defaultParameters = { - type: 'Retention', - parameters: { window: 60, urls: [] }, + type: 'retention', + parameters: {}, }; export default function RetentionReport({ reportId }) { @@ -20,7 +20,7 @@ export default function RetentionReport({ reportId }) { - + {/* */} diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js index 4ef87986..53db7841 100644 --- a/components/pages/reports/retention/RetentionTable.js +++ b/components/pages/reports/retention/RetentionTable.js @@ -1,18 +1,29 @@ import { useContext } from 'react'; -import DataTable from 'components/metrics/DataTable'; +import { GridTable, GridColumn } from 'react-basics'; import { useMessages } from 'hooks'; import { ReportContext } from '../Report'; export function RetentionTable() { const { report } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); + const { fields = [] } = report?.parameters || {}; + + // return ( + // + // {fields.map(({ name }) => { + // return ; + // })} + // + // + // ); return ( - + + {row => row.cohortDate} + {row => row.date_number} + + {row => row.date_number} + + ); } diff --git a/pages/api/reports/retention.ts b/pages/api/reports/retention.ts index 6b8aebcc..0e2c71b8 100644 --- a/pages/api/reports/retention.ts +++ b/pages/api/reports/retention.ts @@ -7,17 +7,12 @@ import { getRetention } from 'queries'; export interface RetentionRequestBody { websiteId: string; - urls: string[]; - window: number; - dateRange: { - startDate: string; - endDate: string; - }; + window: string; + dateRange: { window; startDate: string; endDate: string }; } export interface RetentionResponse { - urls: string[]; - window: number; + window: string; startAt: number; endAt: number; } @@ -32,7 +27,6 @@ export default async ( if (req.method === 'POST') { const { websiteId, - urls, window, dateRange: { startDate, endDate }, } = req.body; @@ -44,8 +38,7 @@ export default async ( const data = await getRetention(websiteId, { startDate: new Date(startDate), endDate: new Date(endDate), - urls, - windowMinutes: +window, + window: window, }); return ok(res, data); diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts index b2c47882..68d3b4b2 100644 --- a/queries/analytics/reports/getRetention.ts +++ b/queries/analytics/reports/getRetention.ts @@ -6,204 +6,200 @@ export async function getRetention( ...args: [ websiteId: string, criteria: { - windowMinutes: number; + window: string; startDate: Date; endDate: Date; - urls: string[]; }, ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), - [CLICKHOUSE]: () => clickhouseQuery(...args), + // [CLICKHOUSE]: () => clickhouseQuery(...args), }); } async function relationalQuery( websiteId: string, criteria: { - windowMinutes: number; + window: string; startDate: Date; endDate: Date; - urls: string[]; }, ): Promise< { - x: string; - y: number; - z: number; + date: Date; + visitors: number; + day: number; + percentage: number; }[] > { - const { windowMinutes, startDate, endDate, urls } = criteria; - const { rawQuery, getAddMinutesQuery } = prisma; - const { levelQuery, sumQuery } = getRetentionQuery(urls, windowMinutes); - - function getRetentionQuery( - urls: string[], - windowMinutes: number, - ): { - levelQuery: string; - sumQuery: string; - } { - return urls.reduce( - (pv, cv, i) => { - const levelNumber = i + 1; - const startSum = i > 0 ? 'union ' : ''; - - if (levelNumber >= 2) { - pv.levelQuery += ` - , level${levelNumber} AS ( - select distinct we.session_id, we.created_at - from level${i} l - join website_event we - on l.session_id = we.session_id - where we.created_at between l.created_at - and ${getAddMinutesQuery(`l.created_at `, windowMinutes)} - and we.referrer_path = {{${i - 1}}} - and we.url_path = {{${i}}} - and we.created_at <= {{endDate}} - and we.website_id = {{websiteId::uuid}} - )`; - } - - pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; - - return pv; - }, - { - levelQuery: '', - sumQuery: '', - }, - ); - } + const { window, startDate, endDate } = criteria; + const { rawQuery } = prisma; return rawQuery( ` - WITH level1 AS ( - select distinct session_id, created_at - from website_event + WITH cohort_items AS ( + select + date_trunc('week', created_at)::date as cohort_date, + session_id + from session where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} - and url_path = {{0}} - ) - ${levelQuery} - ${sumQuery} - ORDER BY level; - `, - { - websiteId, - startDate, - endDate, - ...urls, - }, - ).then(results => { - return urls.map((a, i) => ({ - x: a, - y: results[i]?.count || 0, - z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off - })); - }); -} - -async function clickhouseQuery( - websiteId: string, - criteria: { - windowMinutes: number; - startDate: Date; - endDate: Date; - urls: string[]; - }, -): Promise< - { - x: string; - y: number; - z: number; - }[] -> { - const { windowMinutes, startDate, endDate, urls } = criteria; - const { rawQuery } = clickhouse; - const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getRetentionQuery( - urls, - windowMinutes, - ); - - function getRetentionQuery( - urls: string[], - windowMinutes: number, - ): { - levelQuery: string; - sumQuery: string; - urlFilterQuery: string; - urlParams: { [key: string]: string }; - } { - return urls.reduce( - (pv, cv, i) => { - const levelNumber = i + 1; - const startSum = i > 0 ? 'union all ' : ''; - const startFilter = i > 0 ? ', ' : ''; - - if (levelNumber >= 2) { - pv.levelQuery += `\n - , level${levelNumber} AS ( - select distinct y.session_id as session_id, - y.url_path as url_path, - y.referrer_path as referrer_path, - y.created_at as created_at - from level${i} x - join level0 y - on x.session_id = y.session_id - where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute - and y.referrer_path = {url${i - 1}:String} - and y.url_path = {url${i}:String} - )`; - } - - pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; - pv.urlFilterQuery += `${startFilter}{url${i}:String} `; - pv.urlParams[`url${i}`] = cv; - - return pv; - }, - { - levelQuery: '', - sumQuery: '', - urlFilterQuery: '', - urlParams: {}, - }, - ); - } - - return rawQuery<{ level: number; count: number }[]>( - ` - WITH level0 AS ( - select distinct session_id, url_path, referrer_path, created_at - from umami.website_event - where url_path in (${urlFilterQuery}) - and website_id = {websiteId:UUID} - and created_at between {startDate:DateTime64} and {endDate:DateTime64} + order by 1, 2 ), - level1 AS ( - select * - from level0 - where url_path = {url0:String} + user_activities AS ( + select distinct + w.session_id, + (date_trunc('week', w.created_at)::date - c.cohort_date::date) / 7 as date_number + from website_event w + left join cohort_items c + on w.session_id = c.session_id + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + ), + cohort_size as ( + select cohort_date, + count(*) as visitors + from cohort_items + group by 1 + order by 1 + ), + cohort_date as ( + select + c.cohort_date, + a.date_number, + count(*) as visitors + from user_activities a + left join cohort_items c + on a.session_id = c.session_id + group by 1, 2 ) - ${levelQuery} - select * - from ( - ${sumQuery} - ) ORDER BY level; - `, + select + c.cohort_date, + c.date_number, + s.visitors, + c.visitors, + c.visitors::float * 100 / s.visitors as percentage + from cohort_date c + left join cohort_size s + on c.cohort_date = s.cohort_date + where c.cohort_date IS NOT NULL + order by 1, 2`, { websiteId, startDate, endDate, - ...urlParams, + window, }, ).then(results => { - return urls.map((a, i) => ({ - x: a, - y: results[i]?.count || 0, - z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off - })); + return results; + // return results.map((a, i) => ({ + // x: a, + // y: results[i]?.count || 0, + // z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off + // })); }); } + +// async function clickhouseQuery( +// websiteId: string, +// criteria: { +// windowMinutes: number; +// startDate: Date; +// endDate: Date; +// urls: string[]; +// }, +// ): Promise< +// { +// x: string; +// y: number; +// z: number; +// }[] +// > { +// const { windowMinutes, startDate, endDate, urls } = criteria; +// const { rawQuery } = clickhouse; +// const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getRetentionQuery( +// urls, +// windowMinutes, +// ); + +// function getRetentionQuery( +// urls: string[], +// windowMinutes: number, +// ): { +// levelQuery: string; +// sumQuery: string; +// urlFilterQuery: string; +// urlParams: { [key: string]: string }; +// } { +// return urls.reduce( +// (pv, cv, i) => { +// const levelNumber = i + 1; +// const startSum = i > 0 ? 'union all ' : ''; +// const startFilter = i > 0 ? ', ' : ''; + +// if (levelNumber >= 2) { +// pv.levelQuery += `\n +// , level${levelNumber} AS ( +// select distinct y.session_id as session_id, +// y.url_path as url_path, +// y.referrer_path as referrer_path, +// y.created_at as created_at +// from level${i} x +// join level0 y +// on x.session_id = y.session_id +// where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute +// and y.referrer_path = {url${i - 1}:String} +// and y.url_path = {url${i}:String} +// )`; +// } + +// pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; +// pv.urlFilterQuery += `${startFilter}{url${i}:String} `; +// pv.urlParams[`url${i}`] = cv; + +// return pv; +// }, +// { +// levelQuery: '', +// sumQuery: '', +// urlFilterQuery: '', +// urlParams: {}, +// }, +// ); +// } + +// return rawQuery<{ level: number; count: number }[]>( +// ` +// WITH level0 AS ( +// select distinct session_id, url_path, referrer_path, created_at +// from umami.website_event +// where url_path in (${urlFilterQuery}) +// and website_id = {websiteId:UUID} +// and created_at between {startDate:DateTime64} and {endDate:DateTime64} +// ), +// level1 AS ( +// select * +// from level0 +// where url_path = {url0:String} +// ) +// ${levelQuery} +// select * +// from ( +// ${sumQuery} +// ) ORDER BY level; +// `, +// { +// websiteId, +// startDate, +// endDate, +// ...urlParams, +// }, +// ).then(results => { +// return urls.map((a, i) => ({ +// x: a, +// y: results[i]?.count || 0, +// z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off +// })); +// }); +// } From 15575d7783be1b1a8b1a3d13ddc2199fd037dde6 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 7 Aug 2023 14:41:41 -0700 Subject: [PATCH 23/73] fix column data in relational event data query --- .../analytics/eventData/getEventDataEvents.ts | 16 ++++++++-------- .../analytics/eventData/getEventDataFields.ts | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index ec0939b6..a3a19bb1 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -21,10 +21,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { return rawQuery( ` select - website_event.event_name as eventName, - event_data.event_key as fieldName, - event_data.data_type as dataType, - event_data.string_value as value, + website_event.event_name as "eventName", + event_data.event_key as "fieldName", + event_data.data_type as "dataType", + event_data.string_value as "value", count(*) as total from event_data inner join website_event @@ -42,10 +42,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { return rawQuery( ` select - website_event.event_name as eventName, - event_data.event_key as fieldName, - event_data.data_type as dataType, - count(*) as total + website_event.event_name as "eventName", + event_data.event_key as "fieldName", + event_data.data_type as "dataType", + count(*) as "total" from event_data inner join website_event on website_event.event_id = event_data.website_event_id diff --git a/queries/analytics/eventData/getEventDataFields.ts b/queries/analytics/eventData/getEventDataFields.ts index c61de517..f5f426e0 100644 --- a/queries/analytics/eventData/getEventDataFields.ts +++ b/queries/analytics/eventData/getEventDataFields.ts @@ -21,10 +21,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters & { fiel return rawQuery( ` select - event_key as fieldName, - data_type as dataType, - string_value as fieldValue, - count(*) as total + event_key as "fieldName", + data_type as "dataType", + string_value as "fieldValue", + count(*) as "total" from event_data where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} From 2eee9c23c3dee26cdf967d23d55390e55ae666bc Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 7 Aug 2023 15:02:50 -0700 Subject: [PATCH 24/73] Added useFormat hook to format special values. --- components/metrics/BrowsersTable.js | 7 ++-- components/metrics/CountriesTable.js | 6 +-- components/metrics/DevicesTable.js | 8 ++-- .../pages/reports/insights/InsightsTable.js | 17 ++++++-- hooks/index.js | 1 + hooks/useFormat.js | 39 +++++++++++++++++++ hooks/useMessages.js | 4 +- lib/data.ts | 4 +- queries/analytics/reports/getInsights.ts | 1 + 9 files changed, 68 insertions(+), 19 deletions(-) create mode 100644 hooks/useFormat.js diff --git a/components/metrics/BrowsersTable.js b/components/metrics/BrowsersTable.js index 2920280f..bf4d0aaa 100644 --- a/components/metrics/BrowsersTable.js +++ b/components/metrics/BrowsersTable.js @@ -1,16 +1,17 @@ +import { useRouter } from 'next/router'; import FilterLink from 'components/common/FilterLink'; import MetricsTable from 'components/metrics/MetricsTable'; -import { BROWSERS } from 'lib/constants'; import useMessages from 'hooks/useMessages'; -import { useRouter } from 'next/router'; +import useFormat from 'hooks/useFormat'; export function BrowsersTable({ websiteId, ...props }) { const { formatMessage, labels } = useMessages(); const { basePath } = useRouter(); + const { formatBrowser } = useFormat(); function renderLink({ x: browser }) { return ( - + {browser} {code} diff --git a/components/metrics/DevicesTable.js b/components/metrics/DevicesTable.js index 0b8d5708..98690d0a 100644 --- a/components/metrics/DevicesTable.js +++ b/components/metrics/DevicesTable.js @@ -2,18 +2,16 @@ import MetricsTable from './MetricsTable'; import FilterLink from 'components/common/FilterLink'; import useMessages from 'hooks/useMessages'; import { useRouter } from 'next/router'; +import { useFormat } from 'hooks'; export function DevicesTable({ websiteId, ...props }) { const { formatMessage, labels } = useMessages(); const { basePath } = useRouter(); + const { formatDevice } = useFormat(); function renderLink({ x: device }) { return ( - + {device} {groups.map(({ name, label }) => { - return ; + return ( + + {row => formatValue(row[name], name)} + + ); })} - - + + {row => row.views.toLocaleString()} + + + {row => row.views.toLocaleString()} + ); } diff --git a/hooks/index.js b/hooks/index.js index 6a9b3b35..004260b0 100644 --- a/hooks/index.js +++ b/hooks/index.js @@ -6,6 +6,7 @@ export * from './useDocumentClick'; export * from './useEscapeKey'; export * from './useFilters'; export * from './useForceUpdate'; +export * from './useFormat'; export * from './useLanguageNames'; export * from './useLocale'; export * from './useMessages'; diff --git a/hooks/useFormat.js b/hooks/useFormat.js new file mode 100644 index 00000000..3fd10ec8 --- /dev/null +++ b/hooks/useFormat.js @@ -0,0 +1,39 @@ +import useMessages from './useMessages'; +import { BROWSERS } from 'lib/constants'; +import useLocale from './useLocale'; +import useCountryNames from './useCountryNames'; + +export function useFormat() { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const countryNames = useCountryNames(locale); + + const formatBrowser = value => { + return BROWSERS[value] || value; + }; + + const formatCountry = value => { + return countryNames[value] || value; + }; + + const formatDevice = value => { + return formatMessage(labels[value] || labels.unknown); + }; + + const formatValue = (value, type) => { + switch (type) { + case 'browser': + return formatBrowser(value); + case 'country': + return formatCountry(value); + case 'device': + return formatDevice(value); + default: + return value; + } + }; + + return { formatBrowser, formatCountry, formatDevice, formatValue }; +} + +export default useFormat; diff --git a/hooks/useMessages.js b/hooks/useMessages.js index 0719afd8..3c13fab0 100644 --- a/hooks/useMessages.js +++ b/hooks/useMessages.js @@ -4,11 +4,11 @@ import { messages, labels } from 'components/messages'; export function useMessages() { const { formatMessage } = useIntl(); - function getMessage(id) { + const getMessage = id => { const message = Object.values(messages).find(value => value.id === id); return message ? formatMessage(message) : id; - } + }; return { formatMessage, FormattedMessage, messages, labels, getMessage }; } diff --git a/lib/data.ts b/lib/data.ts index c2c53de3..47023bb4 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -25,7 +25,7 @@ export function flattenJSON( ).keyValues; } -export function getDynamicDataType(value: any): string { +export function getDataType(value: any): string { let type: string = typeof value; if ((type === 'string' && isValid(value)) || isValid(parseISO(value))) { @@ -36,7 +36,7 @@ export function getDynamicDataType(value: any): string { } function createKey(key, value, acc: { keyValues: any[]; parentKey: string }) { - const type = getDynamicDataType(value); + const type = getDataType(value); let dynamicDataType = null; diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index 0f778555..b7c8777d 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -41,6 +41,7 @@ async function relationalQuery( and website_event.event_type = {{eventType}} ${filterQuery} group by 1 + limit 500 `, params, ); From c4bbcf37b776071f257188dff8020cf6639133cf Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 7 Aug 2023 15:05:53 -0700 Subject: [PATCH 25/73] Wrong column. --- components/pages/reports/insights/InsightsTable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/pages/reports/insights/InsightsTable.js b/components/pages/reports/insights/InsightsTable.js index 51ad3684..24960254 100644 --- a/components/pages/reports/insights/InsightsTable.js +++ b/components/pages/reports/insights/InsightsTable.js @@ -22,7 +22,7 @@ export function InsightsTable() { {row => row.views.toLocaleString()} - {row => row.views.toLocaleString()} + {row => row.visitors.toLocaleString()} ); From 85c593416aa9c8d628d4876b32eb5512e4088e7b Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 7 Aug 2023 23:02:38 -0700 Subject: [PATCH 26/73] Updated insights report rendering. --- components/pages/reports/FieldSelectForm.js | 6 +- components/pages/reports/FilterSelectForm.js | 4 +- .../reports/insights/InsightsParameters.js | 68 +++++++++---------- .../pages/reports/insights/InsightsTable.js | 25 +++++-- pages/api/reports/insights.ts | 4 +- queries/analytics/reports/getInsights.ts | 12 ++-- 6 files changed, 64 insertions(+), 55 deletions(-) diff --git a/components/pages/reports/FieldSelectForm.js b/components/pages/reports/FieldSelectForm.js index 69f399bb..30fd193d 100644 --- a/components/pages/reports/FieldSelectForm.js +++ b/components/pages/reports/FieldSelectForm.js @@ -2,14 +2,14 @@ import { Menu, Item, Form, FormRow } from 'react-basics'; import { useMessages } from 'hooks'; import styles from './FieldSelectForm.module.css'; -export default function FieldSelectForm({ fields, onSelect }) { +export default function FieldSelectForm({ items, onSelect }) { const { formatMessage, labels } = useMessages(); return ( - onSelect(fields[key])}> - {fields.map(({ label, name, type }, index) => { + onSelect(items[key])}> + {items.map(({ name, label, type }, index) => { return (
{label || name}
diff --git a/components/pages/reports/FilterSelectForm.js b/components/pages/reports/FilterSelectForm.js index 0dc107b0..29493b08 100644 --- a/components/pages/reports/FilterSelectForm.js +++ b/components/pages/reports/FilterSelectForm.js @@ -2,11 +2,11 @@ import { useState } from 'react'; import FieldSelectForm from './FieldSelectForm'; import FieldFilterForm from './FieldFilterForm'; -export default function FilterSelectForm({ fields, onSelect }) { +export default function FilterSelectForm({ items, onSelect }) { const [field, setField] = useState(); if (!field) { - return ; + return ; } return ; diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index 5d7e1fca..4ec60a9a 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -2,7 +2,6 @@ import { useContext, useRef } from 'react'; import { useMessages } from 'hooks'; import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics'; import { ReportContext } from 'components/pages/reports/Report'; -import { REPORT_PARAMETERS } from 'lib/constants'; import Icons from 'components/icons'; import BaseParameters from '../BaseParameters'; import ParameterList from '../ParameterList'; @@ -16,52 +15,52 @@ export function InsightsParameters() { const { formatMessage, labels } = useMessages(); const ref = useRef(null); const { parameters } = report || {}; - const { websiteId, dateRange, filters, groups } = parameters || {}; - const queryEnabled = websiteId && dateRange && (filters?.length || groups?.length); + const { websiteId, dateRange, fields, filters } = parameters || {}; + const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length); const fieldOptions = [ - { name: 'url_path', type: 'string', label: formatMessage(labels.url) }, - { name: 'page_title', type: 'string', label: formatMessage(labels.pageTitle) }, - { name: 'referrer_domain', type: 'string', label: formatMessage(labels.referrer) }, - { name: 'url_query', type: 'string', label: formatMessage(labels.query) }, - { name: 'browser', type: 'string', label: formatMessage(labels.browser) }, - { name: 'os', type: 'string', label: formatMessage(labels.os) }, - { name: 'device', type: 'string', label: formatMessage(labels.device) }, - { name: 'country', type: 'string', label: formatMessage(labels.country) }, - { name: 'region', type: 'string', label: formatMessage(labels.region) }, - { name: 'city', type: 'string', label: formatMessage(labels.city) }, - { name: 'language', type: 'string', label: formatMessage(labels.language) }, + { name: 'url_path', label: formatMessage(labels.url) }, + { name: 'page_title', label: formatMessage(labels.pageTitle) }, + { name: 'referrer_domain', label: formatMessage(labels.referrer) }, + { name: 'url_query', label: formatMessage(labels.query) }, + { name: 'browser', label: formatMessage(labels.browser) }, + { name: 'os', label: formatMessage(labels.os) }, + { name: 'device', label: formatMessage(labels.device) }, + { name: 'country', label: formatMessage(labels.country) }, + { name: 'region', label: formatMessage(labels.region) }, + { name: 'city', label: formatMessage(labels.city) }, + { name: 'language', label: formatMessage(labels.language) }, ]; const parameterGroups = [ - { label: formatMessage(labels.breakdown), group: REPORT_PARAMETERS.groups }, - { label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters }, + { id: 'fields', label: formatMessage(labels.fields) }, + { id: 'filters', label: formatMessage(labels.filters) }, ]; const parameterData = { + fields, filters, - groups, }; const handleSubmit = values => { runReport(values); }; - const handleAdd = (group, value) => { - const data = parameterData[group]; + const handleAdd = (id, value) => { + const data = parameterData[id]; if (!data.find(({ name }) => name === value.name)) { - updateReport({ parameters: { [group]: data.concat(value) } }); + updateReport({ parameters: { [id]: data.concat(value) } }); } }; - const handleRemove = (group, index) => { - const data = [...parameterData[group]]; + const handleRemove = (id, index) => { + const data = [...parameterData[id]]; data.splice(index, 1); - updateReport({ parameters: { [group]: data } }); + updateReport({ parameters: { [id]: data } }); }; - const AddButton = ({ group }) => { + const AddButton = ({ id }) => { return ( @@ -71,11 +70,11 @@ export function InsightsParameters() { {(close, element) => { return ( - {group === REPORT_PARAMETERS.groups && ( - + {id === 'fields' && ( + )} - {group === REPORT_PARAMETERS.filters && ( - + {id === 'filters' && ( + )} ); @@ -88,22 +87,19 @@ export function InsightsParameters() { return ( - {parameterGroups.map(({ label, group }) => { + {parameterGroups.map(({ id, label }) => { return ( - }> - handleRemove(group, index)} - > + }> + handleRemove(id, index)}> {({ value, label }) => { return (
- {group === REPORT_PARAMETERS.groups && ( + {id === 'fields' && ( <>
{label}
)} - {group === REPORT_PARAMETERS.filters && ( + {id === 'filters' && ( <>
{label}
{value[0]}
diff --git a/components/pages/reports/insights/InsightsTable.js b/components/pages/reports/insights/InsightsTable.js index 24960254..0d5298e4 100644 --- a/components/pages/reports/insights/InsightsTable.js +++ b/components/pages/reports/insights/InsightsTable.js @@ -1,29 +1,42 @@ -import { useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { GridTable, GridColumn } from 'react-basics'; import { useFormat, useMessages } from 'hooks'; import { ReportContext } from '../Report'; +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; export function InsightsTable() { + const [fields, setFields] = useState(); const { report } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); - const { groups = [] } = report?.parameters || {}; const { formatValue } = useFormat(); + useEffect( + () => { + setFields(report?.parameters?.fields); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [report?.data], + ); + + if (!fields) { + return ; + } + return ( - {groups.map(({ name, label }) => { + {fields.map(({ name, label }) => { return ( {row => formatValue(row[name], name)} ); })} - - {row => row.views.toLocaleString()} - {row => row.visitors.toLocaleString()} + + {row => row.views.toLocaleString()} + ); } diff --git a/pages/api/reports/insights.ts b/pages/api/reports/insights.ts index 44f72063..decb1f81 100644 --- a/pages/api/reports/insights.ts +++ b/pages/api/reports/insights.ts @@ -27,7 +27,7 @@ export default async ( const { websiteId, dateRange: { startDate, endDate }, - groups, + fields, filters, } = req.body; @@ -35,7 +35,7 @@ export default async ( return unauthorized(res); } - const data = await getInsights(websiteId, groups, { + const data = await getInsights(websiteId, fields, { ...filters, startDate: new Date(startDate), endDate: new Date(endDate), diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index b7c8777d..4c47052b 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -5,7 +5,7 @@ import { EVENT_TYPE } from 'lib/constants'; import { QueryFilters } from 'lib/types'; export async function getInsights( - ...args: [websiteId: string, groups: { name: string; type: string }[], filters: QueryFilters] + ...args: [websiteId: string, fields: { name: string; type?: string }[], filters: QueryFilters] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -15,7 +15,7 @@ export async function getInsights( async function relationalQuery( websiteId: string, - groups: { name: string; type: string }[], + fields: { name: string; type?: string }[], filters: QueryFilters, ): Promise< { @@ -49,7 +49,7 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, - groups: { name: string; type: string }[], + fields: { name: string; type?: string }[], filters: QueryFilters, ): Promise< { @@ -66,14 +66,14 @@ async function clickhouseQuery( return rawQuery( ` select - ${parseFields(groups)} + ${parseFields(fields)} from website_event where website_id = {websiteId:UUID} and created_at between {startDate:DateTime} and {endDate:DateTime} and event_type = {eventType:UInt32} ${filterQuery} - group by ${groups.map(({ name }) => name).join(',')} - order by 1 desc + group by ${fields.map(({ name }) => name).join(',')} + order by 1 desc, 2 desc limit 500 `, params, From 77d170ea51428f78e827e4201207a5c00bc3758d Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Tue, 8 Aug 2023 10:06:41 -0700 Subject: [PATCH 27/73] Fixed event data display. --- .../analytics/eventData/getEventDataEvents.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index ec0939b6..25084111 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -21,11 +21,11 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { return rawQuery( ` select - website_event.event_name as eventName, - event_data.event_key as fieldName, - event_data.data_type as dataType, - event_data.string_value as value, - count(*) as total + website_event.event_name as "eventName", + event_data.event_key as "fieldName", + event_data.data_type as "dataType", + event_data.string_value as "value", + count(*) as "total" from event_data inner join website_event on website_event.event_id = event_data.website_event_id @@ -42,10 +42,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { return rawQuery( ` select - website_event.event_name as eventName, - event_data.event_key as fieldName, - event_data.data_type as dataType, - count(*) as total + website_event.event_name as "eventName", + event_data.event_key as "fieldName", + event_data.data_type as "dataType", + count(*) as "total" from event_data inner join website_event on website_event.event_id = event_data.website_event_id From bf507037c73e1f23dc56c7a2aa04be017ae4277d Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 8 Aug 2023 11:57:58 -0700 Subject: [PATCH 28/73] finish CH query and clean up objects --- .../reports/retention/RetentionParameters.js | 1 - .../pages/reports/retention/RetentionTable.js | 25 +- pages/api/reports/retention.ts | 4 - queries/analytics/reports/getRetention.ts | 231 ++++++++---------- 4 files changed, 109 insertions(+), 152 deletions(-) diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js index bf40236d..f6bde0b1 100644 --- a/components/pages/reports/retention/RetentionParameters.js +++ b/components/pages/reports/retention/RetentionParameters.js @@ -29,7 +29,6 @@ export function RetentionParameters() { return ( - {formatMessage(labels.runQuery)} diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js index 53db7841..35d55a64 100644 --- a/components/pages/reports/retention/RetentionTable.js +++ b/components/pages/reports/retention/RetentionTable.js @@ -6,22 +6,23 @@ import { ReportContext } from '../Report'; export function RetentionTable() { const { report } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); - const { fields = [] } = report?.parameters || {}; - // return ( - // - // {fields.map(({ name }) => { - // return ; - // })} - // - // - // ); return ( - {row => row.cohortDate} - {row => row.date_number} + + {row => row.date} + + + {row => row.day} + - {row => row.date_number} + {row => row.visitors} + + + {row => row.returnVisitors} + + + {row => row.percentage} ); diff --git a/pages/api/reports/retention.ts b/pages/api/reports/retention.ts index 0e2c71b8..83ed0b57 100644 --- a/pages/api/reports/retention.ts +++ b/pages/api/reports/retention.ts @@ -7,12 +7,10 @@ import { getRetention } from 'queries'; export interface RetentionRequestBody { websiteId: string; - window: string; dateRange: { window; startDate: string; endDate: string }; } export interface RetentionResponse { - window: string; startAt: number; endAt: number; } @@ -27,7 +25,6 @@ export default async ( if (req.method === 'POST') { const { websiteId, - window, dateRange: { startDate, endDate }, } = req.body; @@ -38,7 +35,6 @@ export default async ( const data = await getRetention(websiteId, { startDate: new Date(startDate), endDate: new Date(endDate), - window: window, }); return ok(res, data); diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts index 68d3b4b2..c34ba068 100644 --- a/queries/analytics/reports/getRetention.ts +++ b/queries/analytics/reports/getRetention.ts @@ -5,8 +5,7 @@ import prisma from 'lib/prisma'; export async function getRetention( ...args: [ websiteId: string, - criteria: { - window: string; + dateRange: { startDate: Date; endDate: Date; }, @@ -14,48 +13,121 @@ export async function getRetention( ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), - // [CLICKHOUSE]: () => clickhouseQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), }); } async function relationalQuery( websiteId: string, - criteria: { - window: string; + dateRange: { startDate: Date; endDate: Date; }, ): Promise< { date: Date; - visitors: number; day: number; + visitors: number; + returnVisitors: number; percentage: number; }[] > { - const { window, startDate, endDate } = criteria; + const { startDate, endDate } = dateRange; const { rawQuery } = prisma; return rawQuery( ` WITH cohort_items AS ( - select - date_trunc('week', created_at)::date as cohort_date, - session_id + select session_id, + date_trunc('day', created_at)::date as cohort_date from session where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} - order by 1, 2 ), user_activities AS ( select distinct w.session_id, - (date_trunc('week', w.created_at)::date - c.cohort_date::date) / 7 as date_number + (date_trunc('day', w.created_at)::date - c.cohort_date::date) as day_number from website_event w - left join cohort_items c + join cohort_items c on w.session_id = c.session_id where website_id = {{websiteId::uuid}} - and created_at between {{startDate}} and {{endDate}} + and created_at between {{startDate}} and {{endDate}} + ), + cohort_size as ( + select cohort_date, + count(*) as visitors + from cohort_items + group by 1 + order by 1 + ), + cohort_date as ( + select + c.cohort_date, + a.day_number, + count(*) as visitors + from user_activities a + join cohort_items c + on a.session_id = c.session_id + where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30) + group by 1, 2 + ) + select + c.cohort_date as date, + c.day_number as day, + s.visitors, + c.visitors as "returnVisitors", + c.visitors::float * 100 / s.visitors as percentage + from cohort_date c + join cohort_size s + on c.cohort_date = s.cohort_date + order by 1, 2`, + { + websiteId, + startDate, + endDate, + }, + ); +} + +async function clickhouseQuery( + websiteId: string, + dateRange: { + startDate: Date; + endDate: Date; + }, +): Promise< + { + date: Date; + day: number; + visitors: number; + returnVisitors: number; + percentage: number; + }[] +> { + const { startDate, endDate } = dateRange; + const { rawQuery } = clickhouse; + + return rawQuery( + ` + WITH cohort_items AS ( + select + min(date_trunc('day', created_at)) as cohort_date, + session_id + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by session_id + ), + user_activities AS ( + select distinct + w.session_id, + (date_trunc('day', w.created_at) - c.cohort_date) / 86400 as day_number + from website_event w + join cohort_items c + on w.session_id = c.session_id + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} ), cohort_size as ( select cohort_date, @@ -67,139 +139,28 @@ async function relationalQuery( cohort_date as ( select c.cohort_date, - a.date_number, + a.day_number, count(*) as visitors from user_activities a - left join cohort_items c + join cohort_items c on a.session_id = c.session_id + where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30) group by 1, 2 ) select - c.cohort_date, - c.date_number, - s.visitors, - c.visitors, - c.visitors::float * 100 / s.visitors as percentage + c.cohort_date as date, + c.day_number as day, + s.visitors as visitors, + c.visitors returnVisitors, + c.visitors * 100 / s.visitors as percentage from cohort_date c - left join cohort_size s + join cohort_size s on c.cohort_date = s.cohort_date - where c.cohort_date IS NOT NULL order by 1, 2`, { websiteId, startDate, endDate, - window, }, - ).then(results => { - return results; - // return results.map((a, i) => ({ - // x: a, - // y: results[i]?.count || 0, - // z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off - // })); - }); + ); } - -// async function clickhouseQuery( -// websiteId: string, -// criteria: { -// windowMinutes: number; -// startDate: Date; -// endDate: Date; -// urls: string[]; -// }, -// ): Promise< -// { -// x: string; -// y: number; -// z: number; -// }[] -// > { -// const { windowMinutes, startDate, endDate, urls } = criteria; -// const { rawQuery } = clickhouse; -// const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getRetentionQuery( -// urls, -// windowMinutes, -// ); - -// function getRetentionQuery( -// urls: string[], -// windowMinutes: number, -// ): { -// levelQuery: string; -// sumQuery: string; -// urlFilterQuery: string; -// urlParams: { [key: string]: string }; -// } { -// return urls.reduce( -// (pv, cv, i) => { -// const levelNumber = i + 1; -// const startSum = i > 0 ? 'union all ' : ''; -// const startFilter = i > 0 ? ', ' : ''; - -// if (levelNumber >= 2) { -// pv.levelQuery += `\n -// , level${levelNumber} AS ( -// select distinct y.session_id as session_id, -// y.url_path as url_path, -// y.referrer_path as referrer_path, -// y.created_at as created_at -// from level${i} x -// join level0 y -// on x.session_id = y.session_id -// where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute -// and y.referrer_path = {url${i - 1}:String} -// and y.url_path = {url${i}:String} -// )`; -// } - -// pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; -// pv.urlFilterQuery += `${startFilter}{url${i}:String} `; -// pv.urlParams[`url${i}`] = cv; - -// return pv; -// }, -// { -// levelQuery: '', -// sumQuery: '', -// urlFilterQuery: '', -// urlParams: {}, -// }, -// ); -// } - -// return rawQuery<{ level: number; count: number }[]>( -// ` -// WITH level0 AS ( -// select distinct session_id, url_path, referrer_path, created_at -// from umami.website_event -// where url_path in (${urlFilterQuery}) -// and website_id = {websiteId:UUID} -// and created_at between {startDate:DateTime64} and {endDate:DateTime64} -// ), -// level1 AS ( -// select * -// from level0 -// where url_path = {url0:String} -// ) -// ${levelQuery} -// select * -// from ( -// ${sumQuery} -// ) ORDER BY level; -// `, -// { -// websiteId, -// startDate, -// endDate, -// ...urlParams, -// }, -// ).then(results => { -// return urls.map((a, i) => ({ -// x: a, -// y: results[i]?.count || 0, -// z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off -// })); -// }); -// } From 577294191d6991e2f5a7de06e00f438940a56d38 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 8 Aug 2023 12:03:03 -0700 Subject: [PATCH 29/73] remove retention chart --- .../pages/reports/retention/RetentionChart.js | 74 ------------------- .../retention/RetentionChart.module.css | 3 - .../reports/retention/RetentionReport.js | 2 - 3 files changed, 79 deletions(-) delete mode 100644 components/pages/reports/retention/RetentionChart.js delete mode 100644 components/pages/reports/retention/RetentionChart.module.css diff --git a/components/pages/reports/retention/RetentionChart.js b/components/pages/reports/retention/RetentionChart.js deleted file mode 100644 index 5f7361fd..00000000 --- a/components/pages/reports/retention/RetentionChart.js +++ /dev/null @@ -1,74 +0,0 @@ -import { useCallback, useContext, useMemo } from 'react'; -import { Loading, StatusLight } from 'react-basics'; -import useMessages from 'hooks/useMessages'; -import useTheme from 'hooks/useTheme'; -import BarChart from 'components/metrics/BarChart'; -import { formatLongNumber } from 'lib/format'; -import styles from './RetentionChart.module.css'; -import { ReportContext } from '../Report'; - -export function RetentionChart({ className, loading }) { - const { report } = useContext(ReportContext); - const { formatMessage, labels } = useMessages(); - const { colors } = useTheme(); - - const { parameters, data } = report || {}; - - const renderXLabel = useCallback( - (label, index) => { - return parameters.urls[index]; - }, - [parameters], - ); - - const renderTooltipPopup = useCallback((setTooltipPopup, model) => { - const { opacity, labelColors, dataPoints } = model.tooltip; - - if (!dataPoints?.length || !opacity) { - setTooltipPopup(null); - return; - } - - setTooltipPopup( - <> -
- {formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)} -
-
- - {formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)} - -
- , - ); - }, []); - - const datasets = useMemo(() => { - return [ - { - label: formatMessage(labels.uniqueVisitors), - data: data, - borderWidth: 1, - ...colors.chart.visitors, - }, - ]; - }, [data]); - - if (loading) { - return ; - } - - return ( - - ); -} - -export default RetentionChart; diff --git a/components/pages/reports/retention/RetentionChart.module.css b/components/pages/reports/retention/RetentionChart.module.css deleted file mode 100644 index 9e1690b3..00000000 --- a/components/pages/reports/retention/RetentionChart.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.loading { - height: 300px; -} diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js index cab3c16c..333496d8 100644 --- a/components/pages/reports/retention/RetentionReport.js +++ b/components/pages/reports/retention/RetentionReport.js @@ -1,4 +1,3 @@ -import RetentionChart from './RetentionChart'; import RetentionTable from './RetentionTable'; import RetentionParameters from './RetentionParameters'; import Report from '../Report'; @@ -20,7 +19,6 @@ export default function RetentionReport({ reportId }) { - {/* */} From 618c643a0a16f69d8b7c1d6b856a69619b9a3bb3 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Tue, 8 Aug 2023 15:29:59 -0700 Subject: [PATCH 30/73] Insights report filtering. --- components/pages/reports/FieldFilterForm.js | 45 +++++++------------ components/pages/reports/FilterSelectForm.js | 25 ++++++++++- .../reports/insights/InsightsParameters.js | 6 ++- hooks/useFilters.js | 4 +- pages/api/websites/[id]/values.ts | 43 ++++++++++++++++++ .../{stats => }/getActiveVisitors.ts | 6 +-- .../analytics/{stats => }/getRealtimeData.ts | 2 +- queries/analytics/getValues.ts | 38 ++++++++++++++++ .../{stats => }/getWebsiteDateRange.ts | 0 .../analytics/{stats => }/getWebsiteStats.ts | 0 queries/analytics/reports/getInsights.ts | 26 ++++++----- queries/index.js | 9 ++-- 12 files changed, 152 insertions(+), 52 deletions(-) create mode 100644 pages/api/websites/[id]/values.ts rename queries/analytics/{stats => }/getActiveVisitors.ts (84%) rename queries/analytics/{stats => }/getRealtimeData.ts (93%) create mode 100644 queries/analytics/getValues.ts rename queries/analytics/{stats => }/getWebsiteDateRange.ts (100%) rename queries/analytics/{stats => }/getWebsiteStats.ts (100%) diff --git a/components/pages/reports/FieldFilterForm.js b/components/pages/reports/FieldFilterForm.js index 021ea97e..a2b68968 100644 --- a/components/pages/reports/FieldFilterForm.js +++ b/components/pages/reports/FieldFilterForm.js @@ -1,48 +1,37 @@ import { useState } from 'react'; -import { Form, FormRow, Menu, Item, Flexbox, Dropdown, TextField, Button } from 'react-basics'; +import { Form, FormRow, Item, Flexbox, Dropdown, Button } from 'react-basics'; import { useFilters } from 'hooks'; import styles from './FieldFilterForm.module.css'; -export default function FieldFilterForm({ name, type, onSelect }) { - const [filter, setFilter] = useState(''); - const [value, setValue] = useState(''); - const { filters, types } = useFilters(); - const items = types[type]; +export default function FieldFilterForm({ label, type, values, onSelect }) { + const [filter, setFilter] = useState('eq'); + const [value, setValue] = useState(); + const filters = useFilters(type); - const renderValue = value => { - return filters[value]; + const renderFilterValue = value => { + return filters.find(f => f.value === value)?.label; }; - if (type === 'boolean') { - return ( - - - onSelect({ name, type, value: ['eq', value] })}> - {items.map(value => { - return {filters[value]}; - })} - - - - ); - } - return (
- + - {value => { - return {filters[value]}; + {({ value, label }) => { + return {label}; + }} + + + {value => { + return {value}; }} - setValue(e.target.value)} autoFocus={true} /> diff --git a/components/pages/reports/FilterSelectForm.js b/components/pages/reports/FilterSelectForm.js index 49238e1c..844c2a1d 100644 --- a/components/pages/reports/FilterSelectForm.js +++ b/components/pages/reports/FilterSelectForm.js @@ -27,8 +27,16 @@ export default function FilterSelectForm({ websiteId, items, onSelect }) { } if (isLoading) { - return ; + return ; } - return ; + return ( + + ); } diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index c6140d68..d4c0b95b 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -1,6 +1,15 @@ import { useContext, useRef } from 'react'; -import { useMessages } from 'hooks'; -import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics'; +import { useFormat, useMessages, useFilters } from 'hooks'; +import { + Form, + FormRow, + FormButtons, + SubmitButton, + PopupTrigger, + Icon, + Popup, + TooltipPopup, +} from 'react-basics'; import { ReportContext } from 'components/pages/reports/Report'; import Icons from 'components/icons'; import BaseParameters from '../BaseParameters'; @@ -13,23 +22,26 @@ import FieldSelectForm from '../FieldSelectForm'; export function InsightsParameters() { const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { filterLabels } = useFilters(); const ref = useRef(null); const { parameters } = report || {}; const { websiteId, dateRange, fields, filters } = parameters || {}; + const { startDate, endDate } = dateRange || {}; + const parametersSelected = websiteId && startDate && endDate; const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length); const fieldOptions = [ - { name: 'url_path', label: formatMessage(labels.url) }, - { name: 'page_title', label: formatMessage(labels.pageTitle) }, - { name: 'referrer_domain', label: formatMessage(labels.referrer) }, - { name: 'url_query', label: formatMessage(labels.query) }, + { name: 'url', label: formatMessage(labels.url) }, + { name: 'title', label: formatMessage(labels.pageTitle) }, + { name: 'referrer', label: formatMessage(labels.referrer) }, + { name: 'query', label: formatMessage(labels.query) }, { name: 'browser', label: formatMessage(labels.browser) }, { name: 'os', label: formatMessage(labels.os) }, { name: 'device', label: formatMessage(labels.device) }, { name: 'country', label: formatMessage(labels.country) }, { name: 'region', label: formatMessage(labels.region) }, { name: 'city', label: formatMessage(labels.city) }, - { name: 'language', label: formatMessage(labels.language) }, ]; const parameterGroups = [ @@ -63,9 +75,11 @@ export function InsightsParameters() { const AddButton = ({ id }) => { return ( - - - + + + + + {(close, element) => { return ( @@ -91,32 +105,33 @@ export function InsightsParameters() { return (
- {parameterGroups.map(({ id, label }) => { - return ( - }> - handleRemove(id, index)}> - {({ value, label }) => { - return ( -
- {id === 'fields' && ( - <> -
{label}
- - )} - {id === 'filters' && ( - <> -
{label}
-
{value[0]}
-
{value[1]}
- - )} -
- ); - }} -
-
- ); - })} + {parametersSelected && + parameterGroups.map(({ id, label }) => { + return ( + }> + handleRemove(id, index)}> + {({ name, filter, value, label }) => { + return ( +
+ {id === 'fields' && ( + <> +
{label}
+ + )} + {id === 'filters' && ( + <> +
{fieldOptions.find(f => f.name === name)?.label}
+
{filterLabels[filter]}
+
{formatValue(value, name)}
+ + )} +
+ ); + }} +
+
+ ); + })} {formatMessage(labels.runQuery)} diff --git a/hooks/useFilters.js b/hooks/useFilters.js index 0175e72a..5143fe5b 100644 --- a/hooks/useFilters.js +++ b/hooks/useFilters.js @@ -1,11 +1,13 @@ import { useMessages } from 'hooks'; -export function useFilters(type) { +export function useFilters() { const { formatMessage, labels } = useMessages(); - const filters = { - eq: formatMessage(labels.equals), - neq: formatMessage(labels.doesNotEqual), + const filterLabels = { + eq: formatMessage(labels.is), + neq: formatMessage(labels.isNot), + s: formatMessage(labels.isSet), + ns: formatMessage(labels.isNotSet), c: formatMessage(labels.contains), dnc: formatMessage(labels.doesNotContain), t: formatMessage(labels.true), @@ -18,7 +20,7 @@ export function useFilters(type) { af: formatMessage(labels.after), }; - const types = { + const typeFilters = { string: ['eq', 'neq'], array: ['c', 'dnc'], boolean: ['t', 'f'], @@ -27,7 +29,11 @@ export function useFilters(type) { uuid: ['eq'], }; - return types[type]?.map(key => ({ type, value: key, label: filters[key] })) ?? []; + const getFilters = type => { + return typeFilters[type]?.map(key => ({ type, value: key, label: filterLabels[key] })) ?? []; + }; + + return { getFilters, filterLabels, typeFilters }; } export default useFilters; diff --git a/pages/api/websites/[id]/values.ts b/pages/api/websites/[id]/values.ts index b40fc262..ad8625bd 100644 --- a/pages/api/websites/[id]/values.ts +++ b/pages/api/websites/[id]/values.ts @@ -3,7 +3,7 @@ import { canViewWebsite } from 'lib/auth'; import { useAuth, useCors } from 'lib/middleware'; import { NextApiResponse } from 'next'; import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { EVENT_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; +import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; import { getValues } from 'queries'; export interface WebsiteResetRequestQuery { @@ -28,7 +28,7 @@ export default async ( return unauthorized(res); } - const values = await getValues(websiteId, type as string); + const values = await getValues(websiteId, FILTER_COLUMNS[type as string]); return ok( res, diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index 2167aa2c..9793f258 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -45,7 +45,7 @@ async function relationalQuery( and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type = {{eventType}} ${filterQuery} - group by ${fields.map(({ name }) => name).join(',')} + ${parseGroupBy(fields)} order by 1 desc, 2 desc limit 500 `, @@ -78,7 +78,7 @@ async function clickhouseQuery( and created_at between {startDate:DateTime} and {endDate:DateTime} and event_type = {eventType:UInt32} ${filterQuery} - group by ${fields.map(({ name }) => name).join(',')} + ${parseGroupBy(fields)} order by 1 desc, 2 desc limit 500 `, @@ -98,3 +98,10 @@ function parseFields(fields) { return query.join(',\n'); } + +function parseGroupBy(fields) { + if (!fields.length) { + return ''; + } + return `group by ${fields.map(({ name }) => name).join(',')}`; +} From 50b3ad81e2df79bc9f10b24e1210b1f7a49ba2d8 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 9 Aug 2023 15:33:06 -0700 Subject: [PATCH 34/73] increase operation-per-run for stale issues --- .github/workflows/stale-issues.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index bf2505b1..52c0d432 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -19,4 +19,5 @@ jobs: close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.' days-before-pr-stale: -1 days-before-pr-close: -1 + operations-per-run: 500 repo-token: ${{ secrets.GITHUB_TOKEN }} From 39fb4fdaf873f0a78bafe153a1500f6455c2653d Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 10 Aug 2023 09:31:25 -0700 Subject: [PATCH 35/73] Added field types. --- components/pages/reports/FieldFilterForm.js | 1 + .../pages/reports/FieldFilterForm.module.css | 5 ++++ components/pages/reports/FieldSelectForm.js | 4 +-- components/pages/reports/FilterSelectForm.js | 4 +-- .../reports/insights/InsightsParameters.js | 26 +++++++++++-------- .../pages/reports/insights/InsightsTable.js | 2 +- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/components/pages/reports/FieldFilterForm.js b/components/pages/reports/FieldFilterForm.js index 381bb7e3..3df8f745 100644 --- a/components/pages/reports/FieldFilterForm.js +++ b/components/pages/reports/FieldFilterForm.js @@ -40,6 +40,7 @@ export default function FieldFilterForm({ name, label, type, values, onSelect })
{label || name}
- {type &&
{type}
} + {showType && type &&
{type}
} ); })} diff --git a/components/pages/reports/FilterSelectForm.js b/components/pages/reports/FilterSelectForm.js index 844c2a1d..38094bca 100644 --- a/components/pages/reports/FilterSelectForm.js +++ b/components/pages/reports/FilterSelectForm.js @@ -23,7 +23,7 @@ export default function FilterSelectForm({ websiteId, items, onSelect }) { const { data, isLoading } = useValues(websiteId, field?.name); if (!field) { - return ; + return ; } if (isLoading) { @@ -34,7 +34,7 @@ export default function FilterSelectForm({ websiteId, items, onSelect }) { diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index d4c0b95b..18eeffc3 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -32,16 +32,16 @@ export function InsightsParameters() { const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length); const fieldOptions = [ - { name: 'url', label: formatMessage(labels.url) }, - { name: 'title', label: formatMessage(labels.pageTitle) }, - { name: 'referrer', label: formatMessage(labels.referrer) }, - { name: 'query', label: formatMessage(labels.query) }, - { name: 'browser', label: formatMessage(labels.browser) }, - { name: 'os', label: formatMessage(labels.os) }, - { name: 'device', label: formatMessage(labels.device) }, - { name: 'country', label: formatMessage(labels.country) }, - { name: 'region', label: formatMessage(labels.region) }, - { name: 'city', label: formatMessage(labels.city) }, + { name: 'url', type: 'string', label: formatMessage(labels.url) }, + { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, + { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, + { name: 'query', type: 'string', label: formatMessage(labels.query) }, + { name: 'browser', type: 'string', label: formatMessage(labels.browser) }, + { name: 'os', type: 'string', label: formatMessage(labels.os) }, + { name: 'device', type: 'string', label: formatMessage(labels.device) }, + { name: 'country', type: 'string', label: formatMessage(labels.country) }, + { name: 'region', type: 'string', label: formatMessage(labels.region) }, + { name: 'city', type: 'string', label: formatMessage(labels.city) }, ]; const parameterGroups = [ @@ -85,7 +85,11 @@ export function InsightsParameters() { return ( {id === 'fields' && ( - + )} {id === 'filters' && ( ; } From dcf8b2edaa5c24ce5b0075e51c8e8aaf7ba2a482 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 10 Aug 2023 13:26:33 -0700 Subject: [PATCH 36/73] Add Search Api/Components. --- .eslintrc.json | 3 +- components/common/Pager.js | 37 ++++ components/common/Pager.module.css | 7 + components/common/SettingsTable.js | 121 ++++++++--- components/input/WebsiteSelect.js | 4 +- components/pages/dashboard/Dashboard.js | 13 +- components/pages/reports/ReportsTable.js | 20 +- .../settings/teams/TeamAddWebsiteForm.js | 32 +-- .../pages/settings/teams/TeamMembers.js | 25 ++- .../pages/settings/teams/TeamMembersTable.js | 26 ++- .../pages/settings/teams/TeamWebsites.js | 25 ++- .../pages/settings/teams/TeamWebsitesTable.js | 26 ++- components/pages/settings/teams/TeamsList.js | 50 +++-- components/pages/settings/teams/TeamsTable.js | 31 ++- components/pages/settings/users/UsersList.js | 32 ++- components/pages/settings/users/UsersTable.js | 21 +- .../pages/settings/websites/WebsitesList.js | 22 +- .../pages/settings/websites/WebsitesTable.js | 19 +- .../pages/websites/WebsiteReportsPage.js | 20 +- hooks/useApiFilter.ts | 28 +++ hooks/useReports.js | 21 +- lib/constants.ts | 16 ++ lib/prisma.ts | 34 ++- lib/types.ts | 55 ++++- package.json | 2 +- pages/api/reports/index.ts | 20 +- pages/api/teams/[id]/users/index.ts | 14 +- pages/api/teams/[id]/websites/index.ts | 15 +- pages/api/teams/index.ts | 15 +- pages/api/users/[id]/websites.ts | 18 +- pages/api/users/index.ts | 9 +- pages/api/websites/index.ts | 7 +- queries/admin/report.ts | 118 +++++++++-- queries/admin/team.ts | 88 +++++++- queries/admin/user.ts | 156 ++++---------- queries/admin/website.ts | 198 +++++++++++++++++- yarn.lock | 8 +- 37 files changed, 1069 insertions(+), 287 deletions(-) create mode 100644 components/common/Pager.js create mode 100644 components/common/Pager.module.css create mode 100644 hooks/useApiFilter.ts diff --git a/.eslintrc.json b/.eslintrc.json index 7a824ff6..25e83d5a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -50,7 +50,8 @@ "@next/next/no-img-element": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-var-requires": "off" + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-empty-interface": "off" }, "globals": { "React": "writable" diff --git a/components/common/Pager.js b/components/common/Pager.js new file mode 100644 index 00000000..584e0669 --- /dev/null +++ b/components/common/Pager.js @@ -0,0 +1,37 @@ +import styles from './Pager.module.css'; +import { Button, Flexbox, Icon, Icons } from 'react-basics'; + +export function Pager({ page, pageSize, count, onPageChange, onPageSizeChange }) { + const maxPage = Math.ceil(count / pageSize); + const lastPage = page === maxPage; + const firstPage = page === 1; + + if (count === 0) { + return null; + } + + const handlePageChange = value => { + const nextPage = page + value; + if (nextPage > 0 && nextPage <= maxPage) { + onPageChange(nextPage); + } + }; + + return ( + + + {`Page ${page} of ${maxPage}`} + + + ); +} + +export default Pager; diff --git a/components/common/Pager.module.css b/components/common/Pager.module.css new file mode 100644 index 00000000..b4ee9f0e --- /dev/null +++ b/components/common/Pager.module.css @@ -0,0 +1,7 @@ +.container { + margin-top: 20px; +} + +.text { + margin: 0 10px; +} diff --git a/components/common/SettingsTable.js b/components/common/SettingsTable.js index 8f039858..9fb4c2a9 100644 --- a/components/common/SettingsTable.js +++ b/components/common/SettingsTable.js @@ -1,37 +1,98 @@ -import { Table, TableHeader, TableBody, TableRow, TableCell, TableColumn } from 'react-basics'; +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; +import useMessages from 'hooks/useMessages'; +import { useState } from 'react'; +import { + SearchField, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from 'react-basics'; import styles from './SettingsTable.module.css'; +import Pager from 'components/common/Pager'; + +export function SettingsTable({ + columns = [], + data, + children, + cellRender, + showSearch, + showPaging, + onFilterChange, + onPageChange, + onPageSizeChange, + filterValue, +}) { + const { formatMessage, messages } = useMessages(); + const [filter, setFilter] = useState(filterValue); + const { data: value, page, count, pageSize } = data; + + const handleFilterChange = value => { + setFilter(value); + onFilterChange(value); + }; -export function SettingsTable({ columns = [], data = [], children, cellRender }) { return ( - - - {(column, index) => { - return ( - - {column.label} - - ); - }} - - - {(row, keys, rowIndex) => { - row.action = children(row, keys, rowIndex); + <> + {showSearch && ( + + )} + {value.length === 0 && filterValue && ( + + )} + {value.length > 0 && ( +
+ + {(column, index) => { + return ( + + {column.label} + + ); + }} + + + {(row, keys, rowIndex) => { + row.action = children(row, keys, rowIndex); - return ( - - {(data, key, colIndex) => { - return ( - - - {cellRender ? cellRender(row, data, key, colIndex) : data[key]} - - ); - }} - - ); - }} - -
+ return ( + + {(data, key, colIndex) => { + return ( + + + {cellRender ? cellRender(row, data, key, colIndex) : data[key]} + + ); + }} + + ); + }} + + {showPaging && ( + + )} + + )} + ); } diff --git a/components/input/WebsiteSelect.js b/components/input/WebsiteSelect.js index b77ae57c..ae3ceb46 100644 --- a/components/input/WebsiteSelect.js +++ b/components/input/WebsiteSelect.js @@ -8,12 +8,12 @@ export function WebsiteSelect({ websiteId, onSelect }) { const { data } = useQuery(['websites:me'], () => get('/me/websites')); const renderValue = value => { - return data?.find(({ id }) => id === value)?.name; + return data?.data?.find(({ id }) => id === value)?.name; }; return ( - get('/websites', { userId, includeTeams: 1 }), + get('/websites', { includeTeams: 1 }), ); - const hasData = data && data.length !== 0; + const hasData = data && data?.data.length !== 0; + const { dir } = useLocale(); function handleMore() { @@ -47,8 +48,10 @@ export function Dashboard({ userId }) { )} {hasData && ( <> - {editing && } - {!editing && } + {editing && } + {!editing && ( + + )} {max < data.length && ( -
- + {hasData && ( +
+ + + {({ id, name }) => {name}} + + + + + + {formatMessage(labels.addWebsite)} + + + + + )} ); } diff --git a/components/pages/settings/teams/TeamMembers.js b/components/pages/settings/teams/TeamMembers.js index 3ea8232c..9762ef29 100644 --- a/components/pages/settings/teams/TeamMembers.js +++ b/components/pages/settings/teams/TeamMembers.js @@ -2,13 +2,22 @@ import { Loading, useToasts } from 'react-basics'; import TeamMembersTable from 'components/pages/settings/teams/TeamMembersTable'; import useApi from 'hooks/useApi'; import useMessages from 'hooks/useMessages'; +import useApiFilter from 'hooks/useApiFilter'; export function TeamMembers({ teamId, readOnly }) { const { showToast } = useToasts(); - const { get, useQuery } = useApi(); const { formatMessage, messages } = useMessages(); - const { data, isLoading, refetch } = useQuery(['teams:users', teamId], () => - get(`/teams/${teamId}/users`), + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { get, useQuery } = useApi(); + const { data, isLoading, refetch } = useQuery( + ['teams:users', teamId, filter, page, pageSize], + () => + get(`/teams/${teamId}/users`, { + filter, + page, + pageSize, + }), ); if (isLoading) { @@ -22,7 +31,15 @@ export function TeamMembers({ teamId, readOnly }) { return ( <> - + ); } diff --git a/components/pages/settings/teams/TeamMembersTable.js b/components/pages/settings/teams/TeamMembersTable.js index 8e6fad82..daa4acc6 100644 --- a/components/pages/settings/teams/TeamMembersTable.js +++ b/components/pages/settings/teams/TeamMembersTable.js @@ -4,7 +4,15 @@ import { ROLES } from 'lib/constants'; import TeamMemberRemoveButton from './TeamMemberRemoveButton'; import SettingsTable from 'components/common/SettingsTable'; -export function TeamMembersTable({ data = [], onSave, readOnly }) { +export function TeamMembersTable({ + data = [], + onSave, + readOnly, + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { user } = useUser(); @@ -16,7 +24,7 @@ export function TeamMembersTable({ data = [], onSave, readOnly }) { const cellRender = (row, data, key) => { if (key === 'username') { - return row?.user?.username; + return row?.username; } if (key === 'role') { return formatMessage( @@ -27,13 +35,23 @@ export function TeamMembersTable({ data = [], onSave, readOnly }) { }; return ( - + {row => { return ( !readOnly && ( diff --git a/components/pages/settings/teams/TeamWebsites.js b/components/pages/settings/teams/TeamWebsites.js index 9a5761e5..2ae344f5 100644 --- a/components/pages/settings/teams/TeamWebsites.js +++ b/components/pages/settings/teams/TeamWebsites.js @@ -13,13 +13,22 @@ import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable import TeamAddWebsiteForm from 'components/pages/settings/teams/TeamAddWebsiteForm'; import useApi from 'hooks/useApi'; import useMessages from 'hooks/useMessages'; +import useApiFilter from 'hooks/useApiFilter'; export function TeamWebsites({ teamId }) { const { showToast } = useToasts(); const { formatMessage, labels, messages } = useMessages(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); const { get, useQuery } = useApi(); - const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () => - get(`/teams/${teamId}/websites`), + const { data, isLoading, refetch } = useQuery( + ['teams:websites', teamId, filter, page, pageSize], + () => + get(`/teams/${teamId}/websites`, { + filter, + page, + pageSize, + }), ); const hasData = data && data.length !== 0; @@ -49,7 +58,17 @@ export function TeamWebsites({ teamId }) { return (
{addButton} - {hasData && } + {hasData && ( + + )}
); } diff --git a/components/pages/settings/teams/TeamWebsitesTable.js b/components/pages/settings/teams/TeamWebsitesTable.js index 4873c6c7..564c8a78 100644 --- a/components/pages/settings/teams/TeamWebsitesTable.js +++ b/components/pages/settings/teams/TeamWebsitesTable.js @@ -6,9 +6,17 @@ import TeamWebsiteRemoveButton from './TeamWebsiteRemoveButton'; import SettingsTable from 'components/common/SettingsTable'; import useConfig from 'hooks/useConfig'; -export function TeamWebsitesTable({ data = [], onSave }) { +export function TeamWebsitesTable({ + data = [], + onSave, + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { openExternal } = useConfig(); + const { user } = useUser(); const columns = [ { name: 'name', label: formatMessage(labels.name) }, @@ -17,11 +25,19 @@ export function TeamWebsitesTable({ data = [], onSave }) { ]; return ( - + {row => { - const { teamId } = row; - const { id: websiteId, name, domain, userId } = row.website; - const { teamUser } = row.team; + const { id: teamId, teamUser } = row.teamWebsite[0].team; + const { id: websiteId, name, domain, userId } = row; const owner = teamUser[0]; const canRemove = user.id === userId || user.id === owner.userId; diff --git a/components/pages/settings/teams/TeamsList.js b/components/pages/settings/teams/TeamsList.js index 0c82639b..061100f6 100644 --- a/components/pages/settings/teams/TeamsList.js +++ b/components/pages/settings/teams/TeamsList.js @@ -1,24 +1,37 @@ -import { useState } from 'react'; -import { Button, Icon, Modal, ModalTrigger, useToasts, Text, Flexbox } from 'react-basics'; import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; -import TeamAddForm from 'components/pages/settings/teams/TeamAddForm'; -import PageHeader from 'components/layout/PageHeader'; -import TeamsTable from 'components/pages/settings/teams/TeamsTable'; -import Page from 'components/layout/Page'; import Icons from 'components/icons'; -import TeamJoinForm from './TeamJoinForm'; +import Page from 'components/layout/Page'; +import PageHeader from 'components/layout/PageHeader'; +import TeamAddForm from 'components/pages/settings/teams/TeamAddForm'; +import TeamsTable from 'components/pages/settings/teams/TeamsTable'; import useApi from 'hooks/useApi'; import useMessages from 'hooks/useMessages'; -import { ROLES } from 'lib/constants'; import useUser from 'hooks/useUser'; +import { ROLES } from 'lib/constants'; +import { useState } from 'react'; +import { Button, Flexbox, Icon, Modal, ModalTrigger, Text, useToasts } from 'react-basics'; +import TeamJoinForm from './TeamJoinForm'; +import useApiFilter from 'hooks/useApiFilter'; export default function TeamsList() { const { user } = useUser(); const { formatMessage, labels, messages } = useMessages(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); const [update, setUpdate] = useState(0); + const { get, useQuery } = useApi(); - const { data, isLoading, error } = useQuery(['teams', update], () => get(`/teams`)); - const hasData = data && data.length !== 0; + const { data, isLoading, error } = useQuery(['teams', update, filter, page, pageSize], () => { + return get(`/teams`, { + filter, + page, + pageSize, + }); + }); + + const hasData = data && data?.data.length !== 0; + const isFiltered = filter; + const { showToast } = useToasts(); const handleSave = () => { @@ -71,15 +84,26 @@ export default function TeamsList() { return ( - {hasData && ( + {(hasData || isFiltered) && ( {joinButton} {createButton} )} - {hasData && } - {!hasData && ( + + {(hasData || isFiltered) && ( + + )} + + {!hasData && !isFiltered && ( {joinButton} diff --git a/components/pages/settings/teams/TeamsTable.js b/components/pages/settings/teams/TeamsTable.js index a344fefc..e35fb839 100644 --- a/components/pages/settings/teams/TeamsTable.js +++ b/components/pages/settings/teams/TeamsTable.js @@ -1,14 +1,21 @@ +import SettingsTable from 'components/common/SettingsTable'; +import useLocale from 'hooks/useLocale'; +import useMessages from 'hooks/useMessages'; +import useUser from 'hooks/useUser'; +import { ROLES } from 'lib/constants'; import Link from 'next/link'; import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics'; import TeamDeleteForm from './TeamDeleteForm'; import TeamLeaveForm from './TeamLeaveForm'; -import useMessages from 'hooks/useMessages'; -import useUser from 'hooks/useUser'; -import { ROLES } from 'lib/constants'; -import SettingsTable from 'components/common/SettingsTable'; -import useLocale from 'hooks/useLocale'; -export function TeamsTable({ data = [], onDelete }) { +export function TeamsTable({ + data = { data: [] }, + onDelete, + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { user } = useUser(); const { dir } = useLocale(); @@ -27,7 +34,17 @@ export function TeamsTable({ data = [], onDelete }) { }; return ( - + {row => { const { id, teamUser } = row; const owner = teamUser.find(({ role }) => role === ROLES.teamOwner); diff --git a/components/pages/settings/users/UsersList.js b/components/pages/settings/users/UsersList.js index 8886203b..614aabef 100644 --- a/components/pages/settings/users/UsersList.js +++ b/components/pages/settings/users/UsersList.js @@ -7,14 +7,27 @@ import UserAddButton from './UserAddButton'; import useApi from 'hooks/useApi'; import useUser from 'hooks/useUser'; import useMessages from 'hooks/useMessages'; +import useApiFilter from 'hooks/useApiFilter'; export function UsersList() { const { formatMessage, labels, messages } = useMessages(); const { user } = useUser(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { get, useQuery } = useApi(); - const { data, isLoading, error, refetch } = useQuery(['user'], () => get(`/users`), { - enabled: !!user, - }); + const { data, isLoading, error, refetch } = useQuery( + ['user', filter, page, pageSize], + () => + get(`/users`, { + filter, + page, + pageSize, + }), + { + enabled: !!user, + }, + ); const { showToast } = useToasts(); const hasData = data && data.length !== 0; @@ -33,8 +46,17 @@ export function UsersList() { - {hasData && } - {!hasData && ( + {(hasData || filter) && ( + + )} + {!hasData && !filter && ( diff --git a/components/pages/settings/users/UsersTable.js b/components/pages/settings/users/UsersTable.js index 2023efc5..f4c9dd77 100644 --- a/components/pages/settings/users/UsersTable.js +++ b/components/pages/settings/users/UsersTable.js @@ -8,7 +8,14 @@ import useMessages from 'hooks/useMessages'; import SettingsTable from 'components/common/SettingsTable'; import useLocale from 'hooks/useLocale'; -export function UsersTable({ data = [], onDelete }) { +export function UsersTable({ + data = { data: [] }, + onDelete, + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { user } = useUser(); const { dateLocale } = useLocale(); @@ -36,7 +43,17 @@ export function UsersTable({ data = [], onDelete }) { }; return ( - + {(row, keys, rowIndex) => { return ( <> diff --git a/components/pages/settings/websites/WebsitesList.js b/components/pages/settings/websites/WebsitesList.js index de423d0b..310b481f 100644 --- a/components/pages/settings/websites/WebsitesList.js +++ b/components/pages/settings/websites/WebsitesList.js @@ -8,14 +8,22 @@ import useApi from 'hooks/useApi'; import useUser from 'hooks/useUser'; import useMessages from 'hooks/useMessages'; import { ROLES } from 'lib/constants'; +import useApiFilter from 'hooks/useApiFilter'; export function WebsitesList() { const { formatMessage, labels, messages } = useMessages(); const { user } = useUser(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); const { get, useQuery } = useApi(); const { data, isLoading, error, refetch } = useQuery( - ['websites', user?.id], - () => get(`/users/${user?.id}/websites`), + ['websites', user?.id, filter, page, pageSize], + () => + get(`/users/${user?.id}/websites`, { + filter, + page, + pageSize, + }), { enabled: !!user }, ); const { showToast } = useToasts(); @@ -47,7 +55,15 @@ export function WebsitesList() { return ( {addButton} - {hasData && } + {hasData && ( + + )} {!hasData && ( {addButton} diff --git a/components/pages/settings/websites/WebsitesTable.js b/components/pages/settings/websites/WebsitesTable.js index 902393e6..aa8cbe8a 100644 --- a/components/pages/settings/websites/WebsitesTable.js +++ b/components/pages/settings/websites/WebsitesTable.js @@ -4,7 +4,13 @@ import SettingsTable from 'components/common/SettingsTable'; import useMessages from 'hooks/useMessages'; import useConfig from 'hooks/useConfig'; -export function WebsitesTable({ data = [] }) { +export function WebsitesTable({ + data = [], + filterValue, + onFilterChange, + onPageChange, + onPageSizeChange, +}) { const { formatMessage, labels } = useMessages(); const { openExternal } = useConfig(); @@ -15,7 +21,16 @@ export function WebsitesTable({ data = [] }) { ]; return ( - + {row => { const { id } = row; diff --git a/components/pages/websites/WebsiteReportsPage.js b/components/pages/websites/WebsiteReportsPage.js index 56927028..a1d49d10 100644 --- a/components/pages/websites/WebsiteReportsPage.js +++ b/components/pages/websites/WebsiteReportsPage.js @@ -7,7 +7,16 @@ import WebsiteHeader from './WebsiteHeader'; export function WebsiteReportsPage({ websiteId }) { const { formatMessage, labels } = useMessages(); - const { reports, error, isLoading, deleteReport } = useReports(websiteId); + const { + reports, + error, + isLoading, + deleteReport, + filter, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + } = useReports(websiteId); const handleDelete = async id => { await deleteReport(id); @@ -26,7 +35,14 @@ export function WebsiteReportsPage({ websiteId }) { - + ); } diff --git a/hooks/useApiFilter.ts b/hooks/useApiFilter.ts new file mode 100644 index 00000000..d411fd43 --- /dev/null +++ b/hooks/useApiFilter.ts @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +export function useApiFilter() { + const [filter, setFilter] = useState(); + const [filterType, setFilterType] = useState('All'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const handleFilterChange = value => setFilter(value); + const handlePageChange = value => setPage(value); + const handlePageSizeChange = value => setPageSize(value); + + return { + filter, + setFilter, + filterType, + setFilterType, + page, + setPage, + pageSize, + setPageSize, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + }; +} + +export default useApiFilter; diff --git a/hooks/useReports.js b/hooks/useReports.js index f4369eec..57d76492 100644 --- a/hooks/useReports.js +++ b/hooks/useReports.js @@ -1,12 +1,16 @@ import { useState } from 'react'; import useApi from './useApi'; +import useApiFilter from 'hooks/useApiFilter'; export function useReports(websiteId) { const [modified, setModified] = useState(Date.now()); const { get, useQuery, del, useMutation } = useApi(); const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); - const { data, error, isLoading } = useQuery(['reports:website', { websiteId, modified }], () => - get(`/reports`, { websiteId }), + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { data, error, isLoading } = useQuery( + ['reports:website', { websiteId, modified, filter, page, pageSize }], + () => get(`/reports`, { websiteId, filter, page, pageSize }), ); const deleteReport = id => { @@ -17,7 +21,18 @@ export function useReports(websiteId) { }); }; - return { reports: data, error, isLoading, deleteReport }; + return { + reports: data, + error, + isLoading, + deleteReport, + filter, + page, + pageSize, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + }; } export default useReports; diff --git a/lib/constants.ts b/lib/constants.ts index 887f90a9..9257298c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -30,6 +30,22 @@ export const FILTER_RANGE = 'filter-range'; export const FILTER_REFERRERS = 'filter-referrers'; export const FILTER_PAGES = 'filter-pages'; +export const USER_FILTER_TYPES = { + all: 'All', + username: 'Username', +} as const; +export const WEBSITE_FILTER_TYPES = { all: 'All', name: 'Name', domain: 'Domain' } as const; +export const TEAM_FILTER_TYPES = { all: 'All', name: 'Name', 'user:username': 'Owner' } as const; +export const REPORT_FILTER_TYPES = { + all: 'All', + name: 'Name', + description: 'Description', + type: 'Type', + 'user:username': 'Username', + 'website:name': 'Website Name', + 'website:domain': 'Website Domain', +} as const; + export const EVENT_COLUMNS = ['url', 'referrer', 'title', 'query', 'event']; export const SESSION_COLUMNS = [ diff --git a/lib/prisma.ts b/lib/prisma.ts index 753f1ae4..c67ce4bc 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -4,7 +4,7 @@ import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; import { FILTER_COLUMNS, SESSION_COLUMNS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; -import { QueryFilters, QueryOptions } from './types'; +import { QueryFilters, QueryOptions, SearchFilter } from './types'; const MYSQL_DATE_FORMATS = { minute: '%Y-%m-%d %H:%i:00', @@ -128,6 +128,37 @@ async function rawQuery(sql: string, data: object): Promise { return prisma.rawQuery(query, params); } +function getPageFilters(filters: SearchFilter): [ + { + orderBy: { + [x: string]: string; + }[]; + take: number; + skip: number; + }, + { + pageSize: number; + page: number; + orderBy: string; + }, +] { + const { pageSize = 10, page = 1, orderBy } = filters; + + return [ + { + ...(pageSize > 0 && { take: pageSize, skip: pageSize * (page - 1) }), + ...(orderBy && { + orderBy: [ + { + [orderBy]: 'asc', + }, + ], + }), + }, + { pageSize, page: +page, orderBy }, + ]; +} + export default { ...prisma, getAddMinutesQuery, @@ -135,5 +166,6 @@ export default { getTimestampIntervalQuery, getFilterQuery, parseFilters, + getPageFilters, rawQuery, }; diff --git a/lib/types.ts b/lib/types.ts index dc54fd47..5a25169a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,17 +1,62 @@ import { NextApiRequest } from 'next'; -import { COLLECTION_TYPE, DATA_TYPE, EVENT_TYPE, KAFKA_TOPIC, ROLES } from './constants'; +import { + COLLECTION_TYPE, + DATA_TYPE, + EVENT_TYPE, + KAFKA_TOPIC, + REPORT_FILTER_TYPES, + ROLES, + TEAM_FILTER_TYPES, + USER_FILTER_TYPES, + WEBSITE_FILTER_TYPES, +} from './constants'; type ObjectValues = T[keyof T]; export type CollectionType = ObjectValues; - export type Role = ObjectValues; - export type EventType = ObjectValues; - export type DynamicDataType = ObjectValues; - export type KafkaTopic = ObjectValues; +export type ReportSearchFilterType = ObjectValues; +export type UserSearchFilterType = ObjectValues; +export type WebsiteSearchFilterType = ObjectValues; +export type TeamSearchFilterType = ObjectValues; + +export interface WebsiteSearchFilter extends SearchFilter { + userId?: string; + teamId?: string; + includeTeams?: boolean; +} + +export interface UserSearchFilter extends SearchFilter { + teamId?: string; +} + +export interface TeamSearchFilter extends SearchFilter { + userId?: string; +} + +export interface ReportSearchFilter extends SearchFilter { + userId?: string; + websiteId?: string; +} + +export interface SearchFilter { + filter?: string; + filterType?: T; + pageSize?: number; + page?: number; + orderBy?: string; +} + +export interface FilterResult { + data: T; + count: number; + pageSize: number; + page: number; + orderBy?: string; +} export interface DynamicData { [key: string]: number | string | DynamicData | number[] | string[] | DynamicData[]; diff --git a/package.json b/package.json index 647cdf41..89dc5e97 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", "react": "^18.2.0", - "react-basics": "^0.91.0", + "react-basics": "^0.92.0", "react-beautiful-dnd": "^13.1.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.4", diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts index c856b565..8c6825f1 100644 --- a/pages/api/reports/index.ts +++ b/pages/api/reports/index.ts @@ -1,10 +1,12 @@ -import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createReport, getWebsiteReports } from 'queries'; import { canViewWebsite } from 'lib/auth'; import { uuid } from 'lib/crypto'; +import { useAuth, useCors } from 'lib/middleware'; +import { NextApiRequestQueryBody, ReportSearchFilterType, SearchFilter } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { createReport, getReportsByWebsiteId } from 'queries'; + +export interface ReportsRequestQuery extends SearchFilter {} export interface ReportRequestBody { websiteId: string; @@ -35,7 +37,13 @@ export default async ( return unauthorized(res); } - const data = await getWebsiteReports(websiteId); + const { page, filter, pageSize } = req.query; + + const data = await getReportsByWebsiteId(websiteId, { + page, + filter, + pageSize: +pageSize || null, + }); return ok(res, data); } diff --git a/pages/api/teams/[id]/users/index.ts b/pages/api/teams/[id]/users/index.ts index c73da683..6f8b077e 100644 --- a/pages/api/teams/[id]/users/index.ts +++ b/pages/api/teams/[id]/users/index.ts @@ -1,11 +1,11 @@ import { canUpdateTeam, canViewTeam } from 'lib/auth'; import { useAuth } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createTeamUser, getTeamUsers, getUserByUsername } from 'queries'; +import { createTeamUser, getUserByUsername, getUsersByTeamId } from 'queries'; -export interface TeamUserRequestQuery { +export interface TeamUserRequestQuery extends SearchFilter { id: string; } @@ -27,7 +27,13 @@ export default async ( return unauthorized(res); } - const users = await getTeamUsers(teamId); + const { page, filter, pageSize } = req.query; + + const users = await getUsersByTeamId(teamId, { + page, + filter, + pageSize: +pageSize || null, + }); return ok(res, users); } diff --git a/pages/api/teams/[id]/websites/index.ts b/pages/api/teams/[id]/websites/index.ts index 63be478b..dcd08939 100644 --- a/pages/api/teams/[id]/websites/index.ts +++ b/pages/api/teams/[id]/websites/index.ts @@ -1,11 +1,12 @@ import { canViewTeam } from 'lib/auth'; import { useAuth } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createTeamWebsites, getTeamWebsites } from 'queries/admin/teamWebsite'; +import { getWebsites, getWebsitesByTeamId } from 'queries'; +import { createTeamWebsites } from 'queries/admin/teamWebsite'; -export interface TeamWebsiteRequestQuery { +export interface TeamWebsiteRequestQuery extends SearchFilter { id: string; } @@ -26,7 +27,13 @@ export default async ( return unauthorized(res); } - const websites = await getTeamWebsites(teamId); + const { page, filter, pageSize } = req.query; + + const websites = await getWebsitesByTeamId(teamId, { + page, + filter, + pageSize: +pageSize || null, + }); return ok(res, websites); } diff --git a/pages/api/teams/index.ts b/pages/api/teams/index.ts index 453f1ef3..997ed885 100644 --- a/pages/api/teams/index.ts +++ b/pages/api/teams/index.ts @@ -1,18 +1,19 @@ import { Team } from '@prisma/client'; -import { NextApiRequestQueryBody } from 'lib/types'; import { canCreateTeam } from 'lib/auth'; import { uuid } from 'lib/crypto'; import { useAuth } from 'lib/middleware'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { getRandomChars, methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createTeam, getUserTeams } from 'queries'; +import { createTeam, getTeamsByUserId } from 'queries'; -export interface TeamsRequestBody { +export interface TeamsRequestQuery extends SearchFilter {} +export interface TeamsRequestBody extends SearchFilter { name: string; } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useAuth(req, res); @@ -22,9 +23,11 @@ export default async ( } = req.auth; if (req.method === 'GET') { - const teams = await getUserTeams(userId); + const { page, filter, pageSize } = req.query; - return ok(res, teams); + const results = await getTeamsByUserId(userId, { page, filter, pageSize: +pageSize || null }); + + return ok(res, results); } if (req.method === 'POST') { diff --git a/pages/api/users/[id]/websites.ts b/pages/api/users/[id]/websites.ts index e94094a4..e1761291 100644 --- a/pages/api/users/[id]/websites.ts +++ b/pages/api/users/[id]/websites.ts @@ -1,9 +1,12 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUserWebsites } from 'queries'; +import { getWebsitesByUserId } from 'queries'; +export interface UserWebsitesRequestQuery extends SearchFilter { + id: string; +} export interface UserWebsitesRequestBody { name: string; domain: string; @@ -17,16 +20,19 @@ export default async ( await useCors(req, res); await useAuth(req, res); const { user } = req.auth; - const { id: userId } = req.query; + const { id: userId, page, filter, pageSize, includeTeams } = req.query; if (req.method === 'GET') { if (!user.isAdmin && user.id !== userId) { return unauthorized(res); } - const { includeTeams } = req.query; - - const websites = await getUserWebsites(userId, { includeTeams }); + const websites = await getWebsitesByUserId(userId, { + page, + filter, + pageSize: +pageSize || null, + includeTeams, + }); return ok(res, websites); } diff --git a/pages/api/users/index.ts b/pages/api/users/index.ts index 6f6c205f..5e913c02 100644 --- a/pages/api/users/index.ts +++ b/pages/api/users/index.ts @@ -2,11 +2,12 @@ import { canCreateUser, canViewUsers } from 'lib/auth'; import { ROLES } from 'lib/constants'; import { uuid } from 'lib/crypto'; import { useAuth } from 'lib/middleware'; -import { NextApiRequestQueryBody, Role, User } from 'lib/types'; +import { NextApiRequestQueryBody, Role, SearchFilter, User, UserSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { badRequest, hashPassword, methodNotAllowed, ok, unauthorized } from 'next-basics'; import { createUser, getUserByUsername, getUsers } from 'queries'; +export interface UsersRequestQuery extends SearchFilter {} export interface UsersRequestBody { username: string; password: string; @@ -15,7 +16,7 @@ export interface UsersRequestBody { } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useAuth(req, res); @@ -25,7 +26,9 @@ export default async ( return unauthorized(res); } - const users = await getUsers(); + const { page, filter, pageSize } = req.query; + + const users = await getUsers({ page, filter, pageSize: +pageSize || null }); return ok(res, users); } diff --git a/pages/api/websites/index.ts b/pages/api/websites/index.ts index c8b5aba2..f94fa037 100644 --- a/pages/api/websites/index.ts +++ b/pages/api/websites/index.ts @@ -1,12 +1,14 @@ import { canCreateWebsite } from 'lib/auth'; import { uuid } from 'lib/crypto'; import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { createWebsite } from 'queries'; import userWebsites from 'pages/api/users/[id]/websites'; +export interface WebsitesRequestQuery extends SearchFilter {} + export interface WebsitesRequestBody { name: string; domain: string; @@ -14,7 +16,7 @@ export interface WebsitesRequestBody { } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); @@ -26,6 +28,7 @@ export default async ( if (req.method === 'GET') { req.query.id = userId; + req.query.pageSize = 100; return userWebsites(req, res); } diff --git a/queries/admin/report.ts b/queries/admin/report.ts index ee7a0592..d2523f82 100644 --- a/queries/admin/report.ts +++ b/queries/admin/report.ts @@ -1,5 +1,7 @@ import { Prisma, Report } from '@prisma/client'; +import { REPORT_FILTER_TYPES } from 'lib/constants'; import prisma from 'lib/prisma'; +import { FilterResult, ReportSearchFilter, ReportSearchFilterType, SearchFilter } from 'lib/types'; export async function createReport(data: Prisma.ReportUncheckedCreateInput): Promise { return prisma.client.report.create({ data }); @@ -13,22 +15,6 @@ export async function getReportById(reportId: string): Promise { }); } -export async function getUserReports(userId: string): Promise { - return prisma.client.report.findMany({ - where: { - userId, - }, - }); -} - -export async function getWebsiteReports(websiteId: string): Promise { - return prisma.client.report.findMany({ - where: { - websiteId, - }, - }); -} - export async function updateReport( reportId: string, data: Prisma.ReportUpdateInput, @@ -39,3 +25,103 @@ export async function updateReport( export async function deleteReport(reportId: string): Promise { return prisma.client.report.delete({ where: { id: reportId } }); } + +export async function getReports( + ReportSearchFilter: ReportSearchFilter, +): Promise> { + const { userId, websiteId, filter, filterType = REPORT_FILTER_TYPES.all } = ReportSearchFilter; + const where: Prisma.ReportWhereInput = { + ...(userId && { userId: userId }), + ...(websiteId && { websiteId: websiteId }), + ...(filter && { + AND: { + OR: [ + { + ...((filterType === REPORT_FILTER_TYPES.all || + filterType === REPORT_FILTER_TYPES.name) && { + name: { + startsWith: filter, + }, + }), + }, + { + ...((filterType === REPORT_FILTER_TYPES.all || + filterType === REPORT_FILTER_TYPES.description) && { + description: { + startsWith: filter, + }, + }), + }, + { + ...((filterType === REPORT_FILTER_TYPES.all || + filterType === REPORT_FILTER_TYPES.type) && { + type: { + startsWith: filter, + }, + }), + }, + { + ...((filterType === REPORT_FILTER_TYPES.all || + filterType === REPORT_FILTER_TYPES['user:username']) && { + user: { + username: { + startsWith: filter, + }, + }, + }), + }, + { + ...((filterType === REPORT_FILTER_TYPES.all || + filterType === REPORT_FILTER_TYPES['website:name']) && { + website: { + name: { + startsWith: filter, + }, + }, + }), + }, + { + ...((filterType === REPORT_FILTER_TYPES.all || + filterType === REPORT_FILTER_TYPES['website:domain']) && { + website: { + domain: { + startsWith: filter, + }, + }, + }), + }, + ], + }, + }), + }; + + const [pageFilters, getParameters] = prisma.getPageFilters(ReportSearchFilter); + + const reports = await prisma.client.report.findMany({ + where, + ...pageFilters, + }); + const count = await prisma.client.report.count({ + where, + }); + + return { + data: reports, + count, + ...getParameters, + }; +} + +export async function getReportsByUserId( + userId: string, + filter: SearchFilter, +): Promise> { + return getReports({ userId, ...filter }); +} + +export async function getReportsByWebsiteId( + websiteId: string, + filter: SearchFilter, +): Promise> { + return getReports({ websiteId, ...filter }); +} diff --git a/queries/admin/team.ts b/queries/admin/team.ts index a8b3385c..97838227 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -1,7 +1,8 @@ import { Prisma, Team } from '@prisma/client'; import prisma from 'lib/prisma'; -import { ROLES } from 'lib/constants'; +import { ROLES, TEAM_FILTER_TYPES } from 'lib/constants'; import { uuid } from 'lib/crypto'; +import { FilterResult, TeamSearchFilter, TeamSearchFilterType, SearchFilter } from 'lib/types'; export interface GetTeamOptions { includeTeamUser?: boolean; @@ -26,12 +27,6 @@ export function getTeamByAccessCode(accessCode: string, options: GetTeamOptions return getTeam({ accessCode }, options); } -export async function getTeams(where: Prisma.TeamWhereInput): Promise { - return prisma.client.team.findMany({ - where, - }); -} - export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise { const { id } = data; @@ -85,3 +80,82 @@ export async function deleteTeam( }), ]); } + +export async function getTeams( + TeamSearchFilter: TeamSearchFilter, + options?: { include?: Prisma.TeamInclude }, +): Promise> { + const { userId, filter, filterType = TEAM_FILTER_TYPES.all } = TeamSearchFilter; + const where: Prisma.TeamWhereInput = { + ...(userId && { + teamUser: { + some: { userId }, + }, + }), + ...(filter && { + AND: { + OR: [ + { + ...((filterType === TEAM_FILTER_TYPES.all || filterType === TEAM_FILTER_TYPES.name) && { + name: { startsWith: filter }, + }), + }, + { + ...((filterType === TEAM_FILTER_TYPES.all || + filterType === TEAM_FILTER_TYPES['user:username']) && { + teamUser: { + every: { + role: ROLES.teamOwner, + user: { + username: { + startsWith: filter, + }, + }, + }, + }, + }), + }, + ], + }, + }), + }; + + const [pageFilters, getParameters] = prisma.getPageFilters({ + orderBy: 'name', + ...TeamSearchFilter, + }); + + const teams = await prisma.client.team.findMany({ + where: { + ...where, + }, + ...pageFilters, + ...(options?.include && { include: options?.include }), + }); + const count = await prisma.client.team.count({ where }); + + return { data: teams, count, ...getParameters }; +} + +export async function getTeamsByUserId( + userId: string, + filter?: SearchFilter, +): Promise> { + return getTeams( + { userId, ...filter }, + { + include: { + teamUser: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + }, + }, + ); +} diff --git a/queries/admin/user.ts b/queries/admin/user.ts index f60c4801..f4be4751 100644 --- a/queries/admin/user.ts +++ b/queries/admin/user.ts @@ -1,9 +1,9 @@ -import { Prisma, Team, TeamUser } from '@prisma/client'; -import { getRandomChars } from 'next-basics'; +import { Prisma } from '@prisma/client'; import cache from 'lib/cache'; -import { ROLES } from 'lib/constants'; +import { ROLES, USER_FILTER_TYPES } from 'lib/constants'; import prisma from 'lib/prisma'; -import { Website, User, Role } from 'lib/types'; +import { FilterResult, Role, User, UserSearchFilter } from 'lib/types'; +import { getRandomChars } from 'next-basics'; export interface GetUserOptions { includePassword?: boolean; @@ -36,125 +36,59 @@ export async function getUserByUsername(username: string, options: GetUserOption return getUser({ username }, options); } -export async function getUsers(): Promise { - return prisma.client.user.findMany({ - take: 100, - where: { - deletedAt: null, - }, - orderBy: [ - { - username: 'asc', - }, - ], - select: { - id: true, - username: true, - role: true, - createdAt: true, - }, - }); -} - -export async function getUserTeams(userId: string): Promise< - (Team & { - teamUser: (TeamUser & { - user: { id: string; username: string }; - })[]; - })[] -> { - return prisma.client.team.findMany({ - where: { +export async function getUsers( + UserSearchFilter: UserSearchFilter = {}, + options?: { include?: Prisma.UserInclude }, +): Promise> { + const { teamId, filter, filterType = USER_FILTER_TYPES.all } = UserSearchFilter; + const where: Prisma.UserWhereInput = { + ...(teamId && { teamUser: { some: { - userId, + teamId, }, }, - }, - include: { - teamUser: { - include: { - user: { - select: { - id: true, - username: true, - }, + }), + ...(filter && { + AND: { + OR: [ + { + ...((filterType === USER_FILTER_TYPES.all || + filterType === USER_FILTER_TYPES.username) && { + username: { + startsWith: filter, + }, + }), }, - }, + ], }, - }, + }), + }; + const [pageFilters, getParameters] = prisma.getPageFilters({ + orderBy: 'username', + ...UserSearchFilter, }); -} -export async function getUserWebsites( - userId: string, - options?: { includeTeams: boolean }, -): Promise { - const { rawQuery } = prisma; - - if (options?.includeTeams) { - const websites = await rawQuery( - ` - select - website_id as "id", - name, - domain, - share_id as "shareId", - reset_at as "resetAt", - user_id as "userId", - created_at as "createdAt", - updated_at as "updatedAt", - deleted_at as "deletedAt", - null as "teamId", - null as "teamName" - from website - where user_id = {{userId::uuid}} - and deleted_at is null - union - select - w.website_id as "id", - w.name, - w.domain, - w.share_id as "shareId", - w.reset_at as "resetAt", - w.user_id as "userId", - w.created_at as "createdAt", - w.updated_at as "updatedAt", - w.deleted_at as "deletedAt", - t.team_id as "teamId", - t.name as "teamName" - from website w - inner join team_website tw - on tw.website_id = w.website_id - inner join team t - on t.team_id = tw.team_id - inner join team_user tu - on tu.team_id = tw.team_id - where tu.user_id = {{userId::uuid}} - and w.deleted_at is null - `, - { userId }, - ); - - return websites.reduce((arr, item) => { - if (!arr.find(({ id }) => id === item.id)) { - return arr.concat(item); - } - return arr; - }, []); - } - - return prisma.client.website.findMany({ + const users = await prisma.client.user.findMany({ where: { - userId, + ...where, deletedAt: null, }, - orderBy: [ - { - name: 'asc', - }, - ], + ...pageFilters, + ...(options?.include && { include: options.include }), }); + const count = await prisma.client.user.count({ + where: { + ...where, + deletedAt: null, + }, + }); + + return { data: users as any, count, ...getParameters }; +} + +export async function getUsersByTeamId(teamId: string, filter?: UserSearchFilter) { + return getUsers({ teamId, ...filter }); } export async function createUser(data: { diff --git a/queries/admin/website.ts b/queries/admin/website.ts index 35f32bac..68f634a6 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -1,6 +1,8 @@ import { Prisma, Website } from '@prisma/client'; import cache from 'lib/cache'; +import { ROLES, WEBSITE_FILTER_TYPES } from 'lib/constants'; import prisma from 'lib/prisma'; +import { FilterResult, WebsiteSearchFilter } from 'lib/types'; async function getWebsite(where: Prisma.WebsiteWhereUniqueInput): Promise { return prisma.client.website.findUnique({ @@ -16,11 +18,199 @@ export async function getWebsiteByShareId(shareId: string) { return getWebsite({ shareId }); } -export async function getWebsites(): Promise { - return prisma.client.website.findMany({ - orderBy: { - name: 'asc', +export async function getWebsites( + WebsiteSearchFilter: WebsiteSearchFilter, + options?: { include?: Prisma.WebsiteInclude }, +): Promise> { + const { + userId, + teamId, + includeTeams, + filter, + filterType = WEBSITE_FILTER_TYPES.all, + } = WebsiteSearchFilter; + + const filterQuery = { + AND: { + OR: [ + { + ...((filterType === WEBSITE_FILTER_TYPES.all || + filterType === WEBSITE_FILTER_TYPES.name) && { + name: { startsWith: filter }, + }), + }, + { + ...((filterType === WEBSITE_FILTER_TYPES.all || + filterType === WEBSITE_FILTER_TYPES.domain) && { + domain: { startsWith: filter }, + }), + }, + ], }, + }; + + const where: Prisma.WebsiteWhereInput = { + ...(teamId && { + teamWebsite: { + some: { + teamId, + }, + }, + }), + AND: { + OR: [ + { + ...(userId && { + userId, + }), + }, + { + ...(includeTeams && { + teamWebsite: { + some: { + team: { + teamUser: { + some: { + userId, + }, + }, + }, + }, + }, + }), + }, + ], + }, + ...(filter && filterQuery), + }; + + const [pageFilters, getParameters] = prisma.getPageFilters({ + orderBy: 'name', + ...WebsiteSearchFilter, + }); + + const websites = await prisma.client.website.findMany({ + where: { + ...where, + deletedAt: null, + }, + ...pageFilters, + ...(options?.include && { include: options.include }), + }); + const count = await prisma.client.website.count({ where }); + + return { data: websites, count, ...getParameters }; +} + +export async function getWebsitesByUserId( + userId: string, + filter?: WebsiteSearchFilter, +): Promise> { + return getWebsites({ userId, ...filter }); +} + +export async function getWebsitesByTeamId( + teamId: string, + filter?: WebsiteSearchFilter, +): Promise> { + return getWebsites( + { + teamId, + ...filter, + includeTeams: true, + }, + { + include: { + teamWebsite: { + include: { + team: { + include: { + teamUser: { + where: { role: ROLES.teamOwner }, + }, + }, + }, + }, + }, + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + ); +} + +export async function getUserWebsites( + userId: string, + options?: { includeTeams: boolean }, +): Promise { + const { rawQuery } = prisma; + + if (options?.includeTeams) { + const websites = await rawQuery( + ` + select + website_id as "id", + name, + domain, + share_id as "shareId", + reset_at as "resetAt", + user_id as "userId", + created_at as "createdAt", + updated_at as "updatedAt", + deleted_at as "deletedAt", + null as "teamId", + null as "teamName" + from website + where user_id = {{userId::uuid}} + and deleted_at is null + union + select + w.website_id as "id", + w.name, + w.domain, + w.share_id as "shareId", + w.reset_at as "resetAt", + w.user_id as "userId", + w.created_at as "createdAt", + w.updated_at as "updatedAt", + w.deleted_at as "deletedAt", + t.team_id as "teamId", + t.name as "teamName" + from website w + inner join team_website tw + on tw.website_id = w.website_id + inner join team t + on t.team_id = tw.team_id + inner join team_user tu + on tu.team_id = tw.team_id + where tu.user_id = {{userId::uuid}} + and w.deleted_at is null + `, + { userId }, + ); + + return websites.reduce((arr, item) => { + if (!arr.find(({ id }) => id === item.id)) { + return arr.concat(item); + } + return arr; + }, []); + } + + return prisma.client.website.findMany({ + where: { + userId, + deletedAt: null, + }, + orderBy: [ + { + name: 'asc', + }, + ], }); } diff --git a/yarn.lock b/yarn.lock index d9224c2a..115e3cc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7557,10 +7557,10 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-basics@^0.91.0: - version "0.91.0" - resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.91.0.tgz#2970529a22a455ec73a1be884eb93a109c9dafc0" - integrity sha512-vP8LYWiFwA+eguMEuHvHct4Jl5R/2GUjWc1tMujDG0CsAAUGhx68tAJr0K3gBrWjmpJrTPVfX8SdBNKSDAjQsw== +react-basics@^0.92.0: + version "0.92.0" + resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.92.0.tgz#02bc6e88bdaf189c30cc6cbd8bbb1c9d12cd089b" + integrity sha512-BVUWg5a7R88konA9NedYMBx1hl50d6h/MD7qlKOEO/Cnm8cOC7AYTRKAKhO6kHMWjY4ZpUuvlg0UcF+SJP/uXA== dependencies: classnames "^2.3.1" date-fns "^2.29.3" From cbe1a21e671db9ebf343600055219e2345957542 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 10 Aug 2023 22:50:41 -0700 Subject: [PATCH 37/73] Add query types. --- components/common/SettingsTable.js | 2 +- pages/api/me/teams.ts | 9 +++++++-- pages/api/me/websites.ts | 9 +++++++-- pages/api/users/[id]/teams.ts | 20 +++++++++++++++----- pages/api/users/[id]/websites.ts | 1 + 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/components/common/SettingsTable.js b/components/common/SettingsTable.js index 9fb4c2a9..a57919f1 100644 --- a/components/common/SettingsTable.js +++ b/components/common/SettingsTable.js @@ -42,7 +42,7 @@ export function SettingsTable({ delay={1000} value={filter} placeholder="Search" - style={{ maxWidth: '300px', 'margin-bottom': '10px' }} + style={{ maxWidth: '300px', marginBottom: '10px' }} /> )} {value.length === 0 && filterValue && ( diff --git a/pages/api/me/teams.ts b/pages/api/me/teams.ts index 36699016..d323043b 100644 --- a/pages/api/me/teams.ts +++ b/pages/api/me/teams.ts @@ -1,10 +1,15 @@ import { useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed } from 'next-basics'; import userTeams from 'pages/api/users/[id]/teams'; -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { +export interface MyTeamsRequestQuery extends SearchFilter {} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { await useCors(req, res); if (req.method === 'GET') { diff --git a/pages/api/me/websites.ts b/pages/api/me/websites.ts index 29f1e431..f9ccbcab 100644 --- a/pages/api/me/websites.ts +++ b/pages/api/me/websites.ts @@ -1,11 +1,16 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed } from 'next-basics'; import userWebsites from 'pages/api/users/[id]/websites'; -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { +export interface MyWebsitesRequestQuery extends SearchFilter {} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { await useCors(req, res); await useAuth(req, res); diff --git a/pages/api/users/[id]/teams.ts b/pages/api/users/[id]/teams.ts index c31b98ca..831a992d 100644 --- a/pages/api/users/[id]/teams.ts +++ b/pages/api/users/[id]/teams.ts @@ -1,17 +1,21 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, TeamSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUserTeams } from 'queries'; +import { getTeamsByUserId } from 'queries'; -export interface UserWebsitesRequestBody { +export interface UserTeamsRequestQuery extends SearchFilter { + id: string; +} + +export interface UserTeamsRequestBody { name: string; domain: string; shareId: string; } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); @@ -25,7 +29,13 @@ export default async ( return unauthorized(res); } - const teams = await getUserTeams(userId); + const { page, filter, pageSize } = req.query; + + const teams = await getTeamsByUserId(userId, { + page, + filter, + pageSize: +pageSize || null, + }); return ok(res, teams); } diff --git a/pages/api/users/[id]/websites.ts b/pages/api/users/[id]/websites.ts index e1761291..72d793d1 100644 --- a/pages/api/users/[id]/websites.ts +++ b/pages/api/users/[id]/websites.ts @@ -19,6 +19,7 @@ export default async ( ) => { await useCors(req, res); await useAuth(req, res); + const { user } = req.auth; const { id: userId, page, filter, pageSize, includeTeams } = req.query; From 9436efabc054dc84924f79deb4266edf71a52029 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 11 Aug 2023 09:05:56 -0700 Subject: [PATCH 38/73] Insights report filtering. --- .../pages/reports/funnel/FunnelReport.js | 3 +- .../reports/insights/InsightsParameters.js | 2 +- .../pages/reports/insights/InsightsReport.js | 5 +- .../pages/reports/insights/InsightsTable.js | 9 +++- hooks/useFilters.js | 48 +++++++++++-------- lib/clickhouse.ts | 36 ++++++++------ lib/constants.ts | 23 +++++++++ lib/prisma.ts | 44 ++++++++++++----- package.json | 2 +- pages/api/reports/insights.ts | 10 +++- queries/analytics/reports/getInsights.ts | 6 +-- yarn.lock | 8 ++-- 12 files changed, 134 insertions(+), 62 deletions(-) diff --git a/components/pages/reports/funnel/FunnelReport.js b/components/pages/reports/funnel/FunnelReport.js index 7b4d8ece..d2971fa3 100644 --- a/components/pages/reports/funnel/FunnelReport.js +++ b/components/pages/reports/funnel/FunnelReport.js @@ -6,9 +6,10 @@ import ReportHeader from '../ReportHeader'; import ReportMenu from '../ReportMenu'; import ReportBody from '../ReportBody'; import Funnel from 'assets/funnel.svg'; +import { REPORT_TYPES } from 'lib/constants'; const defaultParameters = { - type: 'funnel', + type: REPORT_TYPES.funnel, parameters: { window: 60, urls: [] }, }; diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index 18eeffc3..6de4b838 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -119,7 +119,7 @@ export function InsightsParameters() {
{id === 'fields' && ( <> -
{label}
+
{fieldOptions.find(f => f.name === name)?.label}
)} {id === 'filters' && ( diff --git a/components/pages/reports/insights/InsightsReport.js b/components/pages/reports/insights/InsightsReport.js index 88f12304..3d855d9e 100644 --- a/components/pages/reports/insights/InsightsReport.js +++ b/components/pages/reports/insights/InsightsReport.js @@ -5,10 +5,11 @@ import ReportBody from '../ReportBody'; import InsightsParameters from './InsightsParameters'; import InsightsTable from './InsightsTable'; import Lightbulb from 'assets/lightbulb.svg'; +import { REPORT_TYPES } from 'lib/constants'; const defaultParameters = { - type: 'insights', - parameters: { fields: [], filters: [], groups: [] }, + type: REPORT_TYPES.insights, + parameters: { fields: [], filters: [] }, }; export default function InsightsReport({ reportId }) { diff --git a/components/pages/reports/insights/InsightsTable.js b/components/pages/reports/insights/InsightsTable.js index f4549001..d5422c9e 100644 --- a/components/pages/reports/insights/InsightsTable.js +++ b/components/pages/reports/insights/InsightsTable.js @@ -31,10 +31,15 @@ export function InsightsTable() { ); })} - + {row => row.visitors.toLocaleString()} - + {row => row.views.toLocaleString()} diff --git a/hooks/useFilters.js b/hooks/useFilters.js index 5143fe5b..089f2ee8 100644 --- a/hooks/useFilters.js +++ b/hooks/useFilters.js @@ -1,32 +1,40 @@ import { useMessages } from 'hooks'; +import { OPERATORS } from 'lib/constants'; export function useFilters() { const { formatMessage, labels } = useMessages(); const filterLabels = { - eq: formatMessage(labels.is), - neq: formatMessage(labels.isNot), - s: formatMessage(labels.isSet), - ns: formatMessage(labels.isNotSet), - c: formatMessage(labels.contains), - dnc: formatMessage(labels.doesNotContain), - t: formatMessage(labels.true), - f: formatMessage(labels.false), - gt: formatMessage(labels.greaterThan), - lt: formatMessage(labels.lessThan), - gte: formatMessage(labels.greaterThanEquals), - lte: formatMessage(labels.lessThanEquals), - be: formatMessage(labels.before), - af: formatMessage(labels.after), + [OPERATORS.equals]: formatMessage(labels.is), + [OPERATORS.notEquals]: formatMessage(labels.isNot), + [OPERATORS.set]: formatMessage(labels.isSet), + [OPERATORS.notSet]: formatMessage(labels.isNotSet), + [OPERATORS.contains]: formatMessage(labels.contains), + [OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain), + [OPERATORS.true]: formatMessage(labels.true), + [OPERATORS.false]: formatMessage(labels.false), + [OPERATORS.greaterThan]: formatMessage(labels.greaterThan), + [OPERATORS.lessThan]: formatMessage(labels.lessThan), + [OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals), + [OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals), + [OPERATORS.before]: formatMessage(labels.before), + [OPERATORS.after]: formatMessage(labels.after), }; const typeFilters = { - string: ['eq', 'neq'], - array: ['c', 'dnc'], - boolean: ['t', 'f'], - number: ['eq', 'neq', 'gt', 'lt', 'gte', 'lte'], - date: ['be', 'af'], - uuid: ['eq'], + string: [OPERATORS.equals, OPERATORS.notEquals], + array: [OPERATORS.contains, OPERATORS.doesNotContain], + boolean: [OPERATORS.true, OPERATORS.false], + number: [ + OPERATORS.equals, + OPERATORS.notEquals, + OPERATORS.greaterThan, + OPERATORS.lessThan, + OPERATORS.greaterThanEquals, + OPERATORS.lessThanEquals, + ], + date: [OPERATORS.before, OPERATORS.after], + uuid: [OPERATORS.equals], }; const getFilters = type => { diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index f7abd94f..75786850 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -3,7 +3,7 @@ import dateFormat from 'dateformat'; import debug from 'debug'; import { CLICKHOUSE } from 'lib/db'; import { QueryFilters, QueryOptions } from './types'; -import { FILTER_COLUMNS } from './constants'; +import { FILTER_COLUMNS, OPERATORS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; @@ -63,17 +63,29 @@ function getDateFormat(date) { return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`; } +function mapFilter(column, operator, name) { + switch (operator) { + case OPERATORS.equals: + return `${column} = {${name}:String}`; + case OPERATORS.notEquals: + return `${column} != {${name}:String}`; + default: + return ''; + } +} + function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) { - const query = Object.keys(filters).reduce((arr, key) => { - const filter = filters[key]; - const column = FILTER_COLUMNS[key] ?? options?.columns?.[key]; + const query = Object.keys(filters).reduce((arr, name) => { + const value = filters[name]; + const operator = value?.filter ?? OPERATORS.equals; + const column = FILTER_COLUMNS[name] ?? options?.columns?.[name]; - if (filter !== undefined && column) { - arr.push(`and ${column} = {${key}:String}`); - } + if (value !== undefined && column) { + arr.push(`and ${mapFilter(column, operator, name)}`); - if (key === 'referrer') { - arr.push('and referrer_domain != {websiteDomain:String}'); + if (name === 'referrer') { + arr.push('and referrer_domain != {websiteDomain:String}'); + } } return arr; @@ -82,11 +94,7 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) return query.join('\n'); } -async function parseFilters( - websiteId: string, - filters: QueryFilters & { [key: string]: any } = {}, - options?: QueryOptions, -) { +async function parseFilters(websiteId: string, filters: QueryFilters = {}, options?: QueryOptions) { const website = await loadWebsite(websiteId); return { diff --git a/lib/constants.ts b/lib/constants.ts index 887f90a9..8972f81f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -76,6 +76,23 @@ export const DATA_TYPE = { array: 5, } as const; +export const OPERATORS = { + equals: 'eq', + notEquals: 'neq', + set: 's', + notSet: 'ns', + contains: 'c', + doesNotContain: 'dnc', + true: 't', + false: 'f', + greaterThan: 'gt', + lessThan: 'lt', + greaterThanEquals: 'gte', + lessThanEquals: 'lte', + before: 'bf', + after: 'af', +} as const; + export const DATA_TYPES = { [DATA_TYPE.string]: 'string', [DATA_TYPE.number]: 'number', @@ -84,6 +101,12 @@ export const DATA_TYPES = { [DATA_TYPE.array]: 'array', }; +export const REPORT_TYPES = { + funnel: 'funnel', + insights: 'insights', + retention: 'retention', +} as const; + export const REPORT_PARAMETERS = { fields: 'fields', filters: 'filters', diff --git a/lib/prisma.ts b/lib/prisma.ts index 753f1ae4..a4993286 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -1,7 +1,7 @@ import prisma from '@umami/prisma-client'; import moment from 'moment-timezone'; import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db'; -import { FILTER_COLUMNS, SESSION_COLUMNS } from './constants'; +import { FILTER_COLUMNS, SESSION_COLUMNS, OPERATORS } from './constants'; import { loadWebsite } from './load'; import { maxDate } from './date'; import { QueryFilters, QueryOptions } from './types'; @@ -67,15 +67,27 @@ function getTimestampIntervalQuery(field: string): string { } } +function mapFilter(column, operator, name) { + switch (operator) { + case OPERATORS.equals: + return `${column} = {{${name}}}`; + case OPERATORS.notEquals: + return `${column} != {{${name}}}`; + default: + return ''; + } +} + function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): string { - const query = Object.keys(filters).reduce((arr, key) => { - const filter = filters[key]; - const column = FILTER_COLUMNS[key] ?? options?.columns?.[key]; + const query = Object.keys(filters).reduce((arr, name) => { + const value = filters[name]; + const operator = value?.filter ?? OPERATORS.equals; + const column = FILTER_COLUMNS[name] ?? options?.columns?.[name]; - if (filter !== undefined && column) { - arr.push(`and ${column}={{${key}}}`); + if (value !== undefined && column) { + arr.push(`and ${mapFilter(column, operator, name)}`); - if (key === 'referrer') { + if (name === 'referrer') { arr.push( 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)', ); @@ -88,11 +100,17 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}): return query.join('\n'); } -async function parseFilters( - websiteId, - filters: QueryFilters & { [key: string]: any } = {}, - options: QueryOptions = {}, -) { +function normalizeFilters(filters = {}) { + return Object.keys(filters).reduce((obj, key) => { + const value = filters[key]; + + obj[key] = value?.value ?? value; + + return obj; + }, {}); +} + +async function parseFilters(websiteId, filters: QueryFilters = {}, options: QueryOptions = {}) { const website = await loadWebsite(websiteId); return { @@ -102,7 +120,7 @@ async function parseFilters( : '', filterQuery: getFilterQuery(filters, options), params: { - ...filters, + ...normalizeFilters(filters), websiteId, startDate: maxDate(filters.startDate, website.resetAt), websiteDomain: website.domain, diff --git a/package.json b/package.json index 647cdf41..89dc5e97 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", "react": "^18.2.0", - "react-basics": "^0.91.0", + "react-basics": "^0.92.0", "react-beautiful-dnd": "^13.1.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.4", diff --git a/pages/api/reports/insights.ts b/pages/api/reports/insights.ts index decb1f81..09a07d2f 100644 --- a/pages/api/reports/insights.ts +++ b/pages/api/reports/insights.ts @@ -16,6 +16,14 @@ export interface InsightsRequestBody { groups: { name: string; type: string }[]; } +function convertFilters(filters) { + return filters.reduce((obj, { name, ...value }) => { + obj[name] = value; + + return obj; + }, {}); +} + export default async ( req: NextApiRequestQueryBody, res: NextApiResponse, @@ -36,7 +44,7 @@ export default async ( } const data = await getInsights(websiteId, fields, { - ...filters, + ...convertFilters(filters), startDate: new Date(startDate), endDate: new Date(endDate), }); diff --git a/queries/analytics/reports/getInsights.ts b/queries/analytics/reports/getInsights.ts index 9793f258..fa54488b 100644 --- a/queries/analytics/reports/getInsights.ts +++ b/queries/analytics/reports/getInsights.ts @@ -1,7 +1,7 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; -import { EVENT_TYPE, SESSION_COLUMNS } from 'lib/constants'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; import { QueryFilters } from 'lib/types'; export async function getInsights( @@ -91,7 +91,7 @@ function parseFields(fields) { (arr, field) => { const { name } = field; - return arr.concat(name); + return arr.concat(`${FILTER_COLUMNS[name]} as "${name}"`); }, ['count(*) as views', 'count(distinct website_event.session_id) as visitors'], ); @@ -103,5 +103,5 @@ function parseGroupBy(fields) { if (!fields.length) { return ''; } - return `group by ${fields.map(({ name }) => name).join(',')}`; + return `group by ${fields.map(({ name }) => FILTER_COLUMNS[name]).join(',')}`; } diff --git a/yarn.lock b/yarn.lock index d9224c2a..115e3cc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7557,10 +7557,10 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-basics@^0.91.0: - version "0.91.0" - resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.91.0.tgz#2970529a22a455ec73a1be884eb93a109c9dafc0" - integrity sha512-vP8LYWiFwA+eguMEuHvHct4Jl5R/2GUjWc1tMujDG0CsAAUGhx68tAJr0K3gBrWjmpJrTPVfX8SdBNKSDAjQsw== +react-basics@^0.92.0: + version "0.92.0" + resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.92.0.tgz#02bc6e88bdaf189c30cc6cbd8bbb1c9d12cd089b" + integrity sha512-BVUWg5a7R88konA9NedYMBx1hl50d6h/MD7qlKOEO/Cnm8cOC7AYTRKAKhO6kHMWjY4ZpUuvlg0UcF+SJP/uXA== dependencies: classnames "^2.3.1" date-fns "^2.29.3" From cbed961d0126590501ee8e1ebc0e1729a9e98247 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Fri, 11 Aug 2023 11:37:01 -0700 Subject: [PATCH 39/73] Fix search results. --- pages/api/me/websites.ts | 2 +- queries/admin/team.ts | 2 +- queries/admin/website.ts | 44 +++++++++++++++++++++------------------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/pages/api/me/websites.ts b/pages/api/me/websites.ts index f9ccbcab..238d1b6e 100644 --- a/pages/api/me/websites.ts +++ b/pages/api/me/websites.ts @@ -1,5 +1,5 @@ import { useAuth, useCors } from 'lib/middleware'; -import { NextApiRequestQueryBody, WebsiteSearchFilterType } from 'lib/types'; +import { NextApiRequestQueryBody, SearchFilter, WebsiteSearchFilterType } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed } from 'next-basics'; diff --git a/queries/admin/team.ts b/queries/admin/team.ts index 97838227..3294c029 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -104,7 +104,7 @@ export async function getTeams( ...((filterType === TEAM_FILTER_TYPES.all || filterType === TEAM_FILTER_TYPES['user:username']) && { teamUser: { - every: { + some: { role: ROLES.teamOwner, user: { username: { diff --git a/queries/admin/website.ts b/queries/admin/website.ts index 68f634a6..721b0662 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -57,31 +57,33 @@ export async function getWebsites( }, }, }), - AND: { - OR: [ - { - ...(userId && { - userId, - }), - }, - { - ...(includeTeams && { - teamWebsite: { - some: { - team: { - teamUser: { - some: { - userId, + AND: [ + { + OR: [ + { + ...(userId && { + userId, + }), + }, + { + ...(includeTeams && { + teamWebsite: { + some: { + team: { + teamUser: { + some: { + userId, + }, }, }, }, }, - }, - }), - }, - ], - }, - ...(filter && filterQuery), + }), + }, + ], + }, + { ...(filter && filterQuery) }, + ], }; const [pageFilters, getParameters] = prisma.getPageFilters({ From 5e64eac396f29b17873c362255487289c2805351 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Fri, 11 Aug 2023 13:52:10 -0700 Subject: [PATCH 40/73] Add no reports message. --- components/messages.js | 4 +++ .../pages/websites/WebsiteReportsPage.js | 34 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/components/messages.js b/components/messages.js index 85a46ff5..f47513e8 100644 --- a/components/messages.js +++ b/components/messages.js @@ -246,6 +246,10 @@ export const messages = defineMessages({ id: 'message.no-websites-configured', defaultMessage: 'You do not have any websites configured.', }, + noReportsConfigured: { + id: 'message.no-reports-configured', + defaultMessage: 'You do not have any reports configured.', + }, noTeamWebsites: { id: 'message.no-team-websites', defaultMessage: 'This team does not have any websites.', diff --git a/components/pages/websites/WebsiteReportsPage.js b/components/pages/websites/WebsiteReportsPage.js index a1d49d10..beb9bc4f 100644 --- a/components/pages/websites/WebsiteReportsPage.js +++ b/components/pages/websites/WebsiteReportsPage.js @@ -1,12 +1,13 @@ +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import Page from 'components/layout/Page'; -import Link from 'next/link'; -import { Button, Icon, Icons, Text, Flexbox } from 'react-basics'; -import { useMessages, useReports } from 'hooks'; import ReportsTable from 'components/pages/reports/ReportsTable'; +import { useMessages, useReports } from 'hooks'; +import Link from 'next/link'; +import { Button, Flexbox, Icon, Icons, Text } from 'react-basics'; import WebsiteHeader from './WebsiteHeader'; export function WebsiteReportsPage({ websiteId }) { - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, messages } = useMessages(); const { reports, error, @@ -18,6 +19,8 @@ export function WebsiteReportsPage({ websiteId }) { handlePageSizeChange, } = useReports(websiteId); + const hasData = reports && reports.data.length !== 0; + const handleDelete = async id => { await deleteReport(id); }; @@ -35,14 +38,21 @@ export function WebsiteReportsPage({ websiteId }) { - + {hasData && ( + + )} + {!hasData && ( + + {/* {addButton} */} + + )} ); } From b37a1fce634c261fd634183434ab9f3ef4a90dda Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 11 Aug 2023 16:15:11 -0700 Subject: [PATCH 41/73] fix day filter --- queries/analytics/reports/getRetention.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts index c34ba068..9b18df49 100644 --- a/queries/analytics/reports/getRetention.ts +++ b/queries/analytics/reports/getRetention.ts @@ -69,7 +69,6 @@ async function relationalQuery( from user_activities a join cohort_items c on a.session_id = c.session_id - where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30) group by 1, 2 ) select @@ -81,6 +80,7 @@ async function relationalQuery( from cohort_date c join cohort_size s on c.cohort_date = s.cohort_date + where c.day_number IN (0,1,2,3,4,5,6,7,14,21,30) order by 1, 2`, { websiteId, @@ -144,7 +144,6 @@ async function clickhouseQuery( from user_activities a join cohort_items c on a.session_id = c.session_id - where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30) group by 1, 2 ) select @@ -156,6 +155,7 @@ async function clickhouseQuery( from cohort_date c join cohort_size s on c.cohort_date = s.cohort_date + where c.day_number IN (0,1,2,3,4,5,6,7,14,21,30) order by 1, 2`, { websiteId, From 820ad69d608729a2f17684214014b0261e18bd36 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sat, 12 Aug 2023 20:13:11 -0700 Subject: [PATCH 42/73] Retention report updates. --- assets/magnet.svg | 1 + components/icons.ts | 2 + components/pages/reports/ReportTemplates.js | 5 +- .../pages/reports/ReportTemplates.module.css | 1 - .../reports/retention/RetentionReport.js | 7 +-- .../pages/reports/retention/RetentionTable.js | 52 +++++++++++++++++-- .../retention/RetentionTable.module.css | 28 ++++++++++ 7 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 assets/magnet.svg create mode 100644 components/pages/reports/retention/RetentionTable.module.css diff --git a/assets/magnet.svg b/assets/magnet.svg new file mode 100644 index 00000000..3c64c3ee --- /dev/null +++ b/assets/magnet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/icons.ts b/components/icons.ts index e42b15fe..01d7caf5 100644 --- a/components/icons.ts +++ b/components/icons.ts @@ -11,6 +11,7 @@ import Gear from 'assets/gear.svg'; import Globe from 'assets/globe.svg'; import Lock from 'assets/lock.svg'; import Logo from 'assets/logo.svg'; +import Magnet from 'assets/magnet.svg'; import Moon from 'assets/moon.svg'; import Nodes from 'assets/nodes.svg'; import Overview from 'assets/overview.svg'; @@ -35,6 +36,7 @@ const icons = { Globe, Lock, Logo, + Magnet, Moon, Nodes, Overview, diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js index 1de7de9c..0f5e710d 100644 --- a/components/pages/reports/ReportTemplates.js +++ b/components/pages/reports/ReportTemplates.js @@ -4,6 +4,7 @@ import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; import Funnel from 'assets/funnel.svg'; import Lightbulb from 'assets/lightbulb.svg'; +import Magnet from 'assets/magnet.svg'; import styles from './ReportTemplates.module.css'; import { useMessages } from 'hooks'; @@ -47,9 +48,9 @@ export function ReportTemplates() { }, { title: formatMessage(labels.retention), - description: 'Track your websites user retention', + description: 'Measure you website stickiness by tracking how often users return.', url: '/reports/retention', - icon: , + icon: , }, ]; diff --git a/components/pages/reports/ReportTemplates.module.css b/components/pages/reports/ReportTemplates.module.css index 33183505..0cdcb835 100644 --- a/components/pages/reports/ReportTemplates.module.css +++ b/components/pages/reports/ReportTemplates.module.css @@ -2,7 +2,6 @@ display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 20px; - width: 360px; } .report { diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js index 333496d8..63eea44c 100644 --- a/components/pages/reports/retention/RetentionReport.js +++ b/components/pages/reports/retention/RetentionReport.js @@ -4,17 +4,18 @@ import Report from '../Report'; import ReportHeader from '../ReportHeader'; import ReportMenu from '../ReportMenu'; import ReportBody from '../ReportBody'; -import Funnel from 'assets/funnel.svg'; +import Magnet from 'assets/magnet.svg'; +import { REPORT_TYPES } from 'lib/constants'; const defaultParameters = { - type: 'retention', + type: REPORT_TYPES.retention, parameters: {}, }; export default function RetentionReport({ reportId }) { return ( - } /> + } /> diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js index 35d55a64..7d0f2522 100644 --- a/components/pages/reports/retention/RetentionTable.js +++ b/components/pages/reports/retention/RetentionTable.js @@ -1,21 +1,65 @@ import { useContext } from 'react'; import { GridTable, GridColumn } from 'react-basics'; -import { useMessages } from 'hooks'; import { ReportContext } from '../Report'; +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; +import styles from './RetentionTable.module.css'; export function RetentionTable() { const { report } = useContext(ReportContext); - const { formatMessage, labels } = useMessages(); + const { data } = report || {}; + + if (!data) { + return ; + } + + const dates = data.reduce((arr, { date }) => { + if (!arr.includes(date)) { + return arr.concat(date); + } + return arr; + }, []); + + const days = Array(14).fill(null); return ( - + <> +
+
+ {days.map((n, i) => ( +
+ Day {i} +
+ ))} +
+ {dates.map((date, i) => { + return ( +
+ {days.map((n, day) => { + return ( +
+ {data.find(row => row.date === date && row.day === day)?.percentage.toFixed(2)} +
+ ); + })} +
+ ); + })} +
+ + + ); +} + +function DataTable({ data }) { + return ( + {row => row.date} {row => row.day} - + {row => row.visitors} diff --git a/components/pages/reports/retention/RetentionTable.module.css b/components/pages/reports/retention/RetentionTable.module.css new file mode 100644 index 00000000..785582a0 --- /dev/null +++ b/components/pages/reports/retention/RetentionTable.module.css @@ -0,0 +1,28 @@ +.table { + display: flex; + flex-direction: column; +} + +.header { + width: 60px; + height: 40px; + text-align: center; + font-size: var(--font-size-sm); +} + +.row { + display: flex; + flex-direction: row; + gap: 1px; + margin-bottom: 1px; +} + +.cell { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + background: var(--blue100); + border-radius: var(--border-radius); +} From 6d43cb23dd3203a9d39db8172bc432e1f8ea1359 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Sun, 13 Aug 2023 16:42:11 -0700 Subject: [PATCH 43/73] Remove case sensitivity on search. --- queries/admin/report.ts | 6 ++++++ queries/admin/team.ts | 3 ++- queries/admin/user.ts | 1 + queries/admin/website.ts | 4 ++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/queries/admin/report.ts b/queries/admin/report.ts index d2523f82..7ca2f2b2 100644 --- a/queries/admin/report.ts +++ b/queries/admin/report.ts @@ -41,6 +41,7 @@ export async function getReports( filterType === REPORT_FILTER_TYPES.name) && { name: { startsWith: filter, + mode: 'insensitive', }, }), }, @@ -49,6 +50,7 @@ export async function getReports( filterType === REPORT_FILTER_TYPES.description) && { description: { startsWith: filter, + mode: 'insensitive', }, }), }, @@ -57,6 +59,7 @@ export async function getReports( filterType === REPORT_FILTER_TYPES.type) && { type: { startsWith: filter, + mode: 'insensitive', }, }), }, @@ -66,6 +69,7 @@ export async function getReports( user: { username: { startsWith: filter, + mode: 'insensitive', }, }, }), @@ -76,6 +80,7 @@ export async function getReports( website: { name: { startsWith: filter, + mode: 'insensitive', }, }, }), @@ -86,6 +91,7 @@ export async function getReports( website: { domain: { startsWith: filter, + mode: 'insensitive', }, }, }), diff --git a/queries/admin/team.ts b/queries/admin/team.ts index 3294c029..71ea634a 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -97,7 +97,7 @@ export async function getTeams( OR: [ { ...((filterType === TEAM_FILTER_TYPES.all || filterType === TEAM_FILTER_TYPES.name) && { - name: { startsWith: filter }, + name: { startsWith: filter, mode: 'insensitive' }, }), }, { @@ -109,6 +109,7 @@ export async function getTeams( user: { username: { startsWith: filter, + mode: 'insensitive', }, }, }, diff --git a/queries/admin/user.ts b/queries/admin/user.ts index f4be4751..3aece6d1 100644 --- a/queries/admin/user.ts +++ b/queries/admin/user.ts @@ -57,6 +57,7 @@ export async function getUsers( filterType === USER_FILTER_TYPES.username) && { username: { startsWith: filter, + mode: 'insensitive', }, }), }, diff --git a/queries/admin/website.ts b/queries/admin/website.ts index 721b0662..a55db814 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -36,13 +36,13 @@ export async function getWebsites( { ...((filterType === WEBSITE_FILTER_TYPES.all || filterType === WEBSITE_FILTER_TYPES.name) && { - name: { startsWith: filter }, + name: { startsWith: filter, mode: 'insensitive' }, }), }, { ...((filterType === WEBSITE_FILTER_TYPES.all || filterType === WEBSITE_FILTER_TYPES.domain) && { - domain: { startsWith: filter }, + domain: { startsWith: filter, mode: 'insensitive' }, }), }, ], From f7eeaa622b39c7a75f18465cd12bf5fbb0d8cd62 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Sun, 13 Aug 2023 22:21:49 -0700 Subject: [PATCH 44/73] Add website/reports to top nav. --- components/layout/NavBar.js | 2 + components/messages.js | 3 + components/pages/reports/ReportsPage.js | 36 +++++++- components/pages/reports/ReportsTable.js | 12 +++ .../pages/settings/websites/WebsitesList.js | 9 +- .../pages/settings/websites/WebsitesTable.js | 17 +++- .../pages/websites/WebsiteReportsPage.js | 12 +-- components/pages/websites/WebsitesPage.js | 67 ++++++++++++++ hooks/index.js | 1 + hooks/useReports.js | 6 +- hooks/useWebsiteReports.js | 38 ++++++++ lib/types.ts | 2 + pages/api/reports/index.ts | 10 +- pages/api/users/[id]/websites.ts | 3 +- pages/api/websites/[id]/reports.ts | 38 ++++++++ pages/reports/index.js | 13 +++ pages/websites/index.js | 13 +++ queries/admin/report.ts | 54 ++++++++++- queries/admin/website.ts | 91 ++++++++++++------- 19 files changed, 361 insertions(+), 66 deletions(-) create mode 100644 components/pages/websites/WebsitesPage.js create mode 100644 hooks/useWebsiteReports.js create mode 100644 pages/api/websites/[id]/reports.ts create mode 100644 pages/reports/index.js create mode 100644 pages/websites/index.js diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js index 97eaa46c..e896b404 100644 --- a/components/layout/NavBar.js +++ b/components/layout/NavBar.js @@ -18,6 +18,8 @@ export function NavBar() { const links = [ { label: formatMessage(labels.dashboard), url: '/dashboard' }, + { label: formatMessage(labels.websites), url: '/websites' }, + { label: formatMessage(labels.reports), url: '/reports' }, !cloudMode && { label: formatMessage(labels.settings), url: '/settings' }, ].filter(n => n); diff --git a/components/messages.js b/components/messages.js index f47513e8..c0024810 100644 --- a/components/messages.js +++ b/components/messages.js @@ -21,6 +21,8 @@ export const labels = defineMessages({ details: { id: 'label.details', defaultMessage: 'Details' }, website: { id: 'label.website', defaultMessage: 'Website' }, websites: { id: 'label.websites', defaultMessage: 'Websites' }, + myWebsites: { id: 'label.my-websites', defaultMessage: 'My Websites' }, + teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team Websites' }, created: { id: 'label.created', defaultMessage: 'Created' }, edit: { id: 'label.edit', defaultMessage: 'Edit' }, name: { id: 'label.name', defaultMessage: 'Name' }, @@ -28,6 +30,7 @@ export const labels = defineMessages({ accessCode: { id: 'label.access-code', defaultMessage: 'Access code' }, teamId: { id: 'label.team-id', defaultMessage: 'Team ID' }, team: { id: 'label.team', defaultMessage: 'Team' }, + teamName: { id: 'label.team-name', defaultMessage: 'Team Name' }, regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' }, remove: { id: 'label.remove', defaultMessage: 'Remove' }, join: { id: 'label.join', defaultMessage: 'Join' }, diff --git a/components/pages/reports/ReportsPage.js b/components/pages/reports/ReportsPage.js index 470e1b08..d63fc77f 100644 --- a/components/pages/reports/ReportsPage.js +++ b/components/pages/reports/ReportsPage.js @@ -1,13 +1,24 @@ +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; -import Link from 'next/link'; -import { Button, Icon, Icons, Text } from 'react-basics'; import { useMessages, useReports } from 'hooks'; +import Link from 'next/link'; +import { Button, Flexbox, Icon, Icons, Text } from 'react-basics'; import ReportsTable from './ReportsTable'; export function ReportsPage() { - const { formatMessage, labels } = useMessages(); - const { reports, error, isLoading } = useReports(); + const { formatMessage, labels, messages } = useMessages(); + const { + reports, + error, + isLoading, + filter, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + } = useReports(); + + const hasData = (reports && reports?.data.length !== 0) || filter; return ( @@ -21,7 +32,22 @@ export function ReportsPage() { - + + {hasData && ( + + )} + {!hasData && ( + + )} ); } diff --git a/components/pages/reports/ReportsTable.js b/components/pages/reports/ReportsTable.js index 529f5359..e59e4069 100644 --- a/components/pages/reports/ReportsTable.js +++ b/components/pages/reports/ReportsTable.js @@ -12,14 +12,23 @@ export function ReportsTable({ onFilterChange, onPageChange, onPageSizeChange, + showDomain, }) { const [report, setReport] = useState(null); const { formatMessage, labels } = useMessages(); + const domainColumn = [ + { + name: 'domain', + label: formatMessage(labels.domain), + }, + ]; + const columns = [ { name: 'name', label: formatMessage(labels.name) }, { name: 'description', label: formatMessage(labels.description) }, { name: 'type', label: formatMessage(labels.type) }, + ...(showDomain ? domainColumn : []), { name: 'action', label: ' ' }, ]; @@ -41,6 +50,9 @@ export function ReportsTable({ > {row => { const { id } = row; + if (showDomain) { + row.domain = row.website.domain; + } return ( diff --git a/components/pages/settings/websites/WebsitesList.js b/components/pages/settings/websites/WebsitesList.js index 310b481f..f99b2d6e 100644 --- a/components/pages/settings/websites/WebsitesList.js +++ b/components/pages/settings/websites/WebsitesList.js @@ -10,19 +10,21 @@ import useMessages from 'hooks/useMessages'; import { ROLES } from 'lib/constants'; import useApiFilter from 'hooks/useApiFilter'; -export function WebsitesList() { +export function WebsitesList({ showTeam, showHeader = true, includeTeams, onlyTeams, fetch }) { const { formatMessage, labels, messages } = useMessages(); const { user } = useUser(); const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = useApiFilter(); const { get, useQuery } = useApi(); const { data, isLoading, error, refetch } = useQuery( - ['websites', user?.id, filter, page, pageSize], + ['websites', fetch, user?.id, filter, page, pageSize, includeTeams, onlyTeams], () => get(`/users/${user?.id}/websites`, { filter, page, pageSize, + includeTeams, + onlyTeams, }), { enabled: !!user }, ); @@ -54,10 +56,11 @@ export function WebsitesList() { return ( - {addButton} + {showHeader && {addButton}} {hasData && ( {row => { - const { id } = row; + const { + id, + teamWebsite, + user: { username }, + } = row; + if (showTeam) { + row.teamName = teamWebsite[0]?.team.name; + row.owner = username; + } return ( <> diff --git a/components/pages/websites/WebsiteReportsPage.js b/components/pages/websites/WebsiteReportsPage.js index beb9bc4f..85a002e6 100644 --- a/components/pages/websites/WebsiteReportsPage.js +++ b/components/pages/websites/WebsiteReportsPage.js @@ -1,7 +1,7 @@ import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import Page from 'components/layout/Page'; import ReportsTable from 'components/pages/reports/ReportsTable'; -import { useMessages, useReports } from 'hooks'; +import { useMessages, useWebsiteReports } from 'hooks'; import Link from 'next/link'; import { Button, Flexbox, Icon, Icons, Text } from 'react-basics'; import WebsiteHeader from './WebsiteHeader'; @@ -17,9 +17,9 @@ export function WebsiteReportsPage({ websiteId }) { handleFilterChange, handlePageChange, handlePageSizeChange, - } = useReports(websiteId); + } = useWebsiteReports(websiteId); - const hasData = reports && reports.data.length !== 0; + const hasData = (reports && reports.data.length !== 0) || filter; const handleDelete = async id => { await deleteReport(id); @@ -48,11 +48,7 @@ export function WebsiteReportsPage({ websiteId }) { filterValue={filter} /> )} - {!hasData && ( - - {/* {addButton} */} - - )} + {!hasData && } ); } diff --git a/components/pages/websites/WebsitesPage.js b/components/pages/websites/WebsitesPage.js new file mode 100644 index 00000000..4fdd025d --- /dev/null +++ b/components/pages/websites/WebsitesPage.js @@ -0,0 +1,67 @@ +import Page from 'components/layout/Page'; +import PageHeader from 'components/layout/PageHeader'; +import WebsiteAddForm from 'components/pages/settings/websites/WebsiteAddForm'; +import WebsiteList from 'components/pages/settings/websites/WebsitesList'; +import { useMessages } from 'hooks'; +import useUser from 'hooks/useUser'; +import { ROLES } from 'lib/constants'; +import { useState } from 'react'; +import { + Button, + Icon, + Icons, + Item, + Modal, + ModalTrigger, + Tabs, + Text, + useToasts, +} from 'react-basics'; + +export function WebsitesPage() { + const { formatMessage, labels, messages } = useMessages(); + const [tab, setTab] = useState('my-websites'); + const [fetch, setFetch] = useState(1); + const { user } = useUser(); + const { showToast } = useToasts(); + + const handleSave = async () => { + setFetch(fetch + 1); + showToast({ message: formatMessage(messages.saved), variant: 'success' }); + }; + + const addButton = ( + <> + {user.role !== ROLES.viewOnly && ( + + + + {close => } + + + )} + + ); + + return ( + + {addButton} + + {formatMessage(labels.myWebsites)} + {formatMessage(labels.teamWebsites)} + + + {tab === 'my-websites' && } + {tab === 'team-webaites' && ( + + )} + + ); +} + +export default WebsitesPage; diff --git a/hooks/index.js b/hooks/index.js index 004260b0..2596ba57 100644 --- a/hooks/index.js +++ b/hooks/index.js @@ -20,3 +20,4 @@ export * from './useTheme'; export * from './useTimezone'; export * from './useUser'; export * from './useWebsite'; +export * from './useWebsiteReports'; diff --git a/hooks/useReports.js b/hooks/useReports.js index 57d76492..932fa6dc 100644 --- a/hooks/useReports.js +++ b/hooks/useReports.js @@ -2,15 +2,15 @@ import { useState } from 'react'; import useApi from './useApi'; import useApiFilter from 'hooks/useApiFilter'; -export function useReports(websiteId) { +export function useReports() { const [modified, setModified] = useState(Date.now()); const { get, useQuery, del, useMutation } = useApi(); const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = useApiFilter(); const { data, error, isLoading } = useQuery( - ['reports:website', { websiteId, modified, filter, page, pageSize }], - () => get(`/reports`, { websiteId, filter, page, pageSize }), + ['reports', { modified, filter, page, pageSize }], + () => get(`/reports`, { filter, page, pageSize }), ); const deleteReport = id => { diff --git a/hooks/useWebsiteReports.js b/hooks/useWebsiteReports.js new file mode 100644 index 00000000..3b7ec415 --- /dev/null +++ b/hooks/useWebsiteReports.js @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import useApi from './useApi'; +import useApiFilter from 'hooks/useApiFilter'; + +export function useWebsiteReports(websiteId) { + const [modified, setModified] = useState(Date.now()); + const { get, useQuery, del, useMutation } = useApi(); + const { mutate } = useMutation(reportId => del(`/reports/${reportId}`)); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = + useApiFilter(); + const { data, error, isLoading } = useQuery( + ['reports:website', { websiteId, modified, filter, page, pageSize }], + () => get(`/websites/${websiteId}/reports`, { websiteId, filter, page, pageSize }), + ); + + const deleteReport = id => { + mutate(id, { + onSuccess: () => { + setModified(Date.now()); + }, + }); + }; + + return { + reports: data, + error, + isLoading, + deleteReport, + filter, + page, + pageSize, + handleFilterChange, + handlePageChange, + handlePageSizeChange, + }; +} + +export default useWebsiteReports; diff --git a/lib/types.ts b/lib/types.ts index 5a25169a..65bef8fb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -27,6 +27,7 @@ export interface WebsiteSearchFilter extends SearchFilter { @@ -40,6 +41,7 @@ export interface TeamSearchFilter extends SearchFilter { export interface ReportSearchFilter extends SearchFilter { userId?: string; websiteId?: string; + includeTeams?: boolean; } export interface SearchFilter { diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts index 8c6825f1..db83e6ed 100644 --- a/pages/api/reports/index.ts +++ b/pages/api/reports/index.ts @@ -4,7 +4,7 @@ import { useAuth, useCors } from 'lib/middleware'; import { NextApiRequestQueryBody, ReportSearchFilterType, SearchFilter } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { createReport, getReportsByWebsiteId } from 'queries'; +import { createReport, getReportsByUserId, getReportsByWebsiteId } from 'queries'; export interface ReportsRequestQuery extends SearchFilter {} @@ -26,20 +26,14 @@ export default async ( await useCors(req, res); await useAuth(req, res); - const { websiteId } = req.query; - const { user: { id: userId }, } = req.auth; if (req.method === 'GET') { - if (!(websiteId && (await canViewWebsite(req.auth, websiteId)))) { - return unauthorized(res); - } - const { page, filter, pageSize } = req.query; - const data = await getReportsByWebsiteId(websiteId, { + const data = await getReportsByUserId(userId, { page, filter, pageSize: +pageSize || null, diff --git a/pages/api/users/[id]/websites.ts b/pages/api/users/[id]/websites.ts index 72d793d1..0e9231f7 100644 --- a/pages/api/users/[id]/websites.ts +++ b/pages/api/users/[id]/websites.ts @@ -21,7 +21,7 @@ export default async ( await useAuth(req, res); const { user } = req.auth; - const { id: userId, page, filter, pageSize, includeTeams } = req.query; + const { id: userId, page, filter, pageSize, includeTeams, onlyTeams } = req.query; if (req.method === 'GET') { if (!user.isAdmin && user.id !== userId) { @@ -33,6 +33,7 @@ export default async ( filter, pageSize: +pageSize || null, includeTeams, + onlyTeams, }); return ok(res, websites); diff --git a/pages/api/websites/[id]/reports.ts b/pages/api/websites/[id]/reports.ts new file mode 100644 index 00000000..60c6f714 --- /dev/null +++ b/pages/api/websites/[id]/reports.ts @@ -0,0 +1,38 @@ +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors } from 'lib/middleware'; +import { NextApiRequestQueryBody, ReportSearchFilterType, SearchFilter } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getReportsByWebsiteId } from 'queries'; + +export interface ReportsRequestQuery extends SearchFilter { + id: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + const { id: websiteId } = req.query; + + if (req.method === 'GET') { + if (!(websiteId && (await canViewWebsite(req.auth, websiteId)))) { + return unauthorized(res); + } + + const { page, filter, pageSize } = req.query; + + const data = await getReportsByWebsiteId(websiteId, { + page, + filter, + pageSize: +pageSize || null, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/pages/reports/index.js b/pages/reports/index.js new file mode 100644 index 00000000..ff3b4e86 --- /dev/null +++ b/pages/reports/index.js @@ -0,0 +1,13 @@ +import AppLayout from 'components/layout/AppLayout'; +import ReportsPage from 'components/pages/reports/ReportsPage'; +import { useMessages } from 'hooks'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + + + + ); +} diff --git a/pages/websites/index.js b/pages/websites/index.js new file mode 100644 index 00000000..42a327bc --- /dev/null +++ b/pages/websites/index.js @@ -0,0 +1,13 @@ +import AppLayout from 'components/layout/AppLayout'; +import WebsitesPage from 'components/pages/websites/WebsitesPage'; +import useMessages from 'hooks/useMessages'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + + + + ); +} diff --git a/queries/admin/report.ts b/queries/admin/report.ts index 7ca2f2b2..3c50c2cb 100644 --- a/queries/admin/report.ts +++ b/queries/admin/report.ts @@ -28,13 +28,45 @@ export async function deleteReport(reportId: string): Promise { export async function getReports( ReportSearchFilter: ReportSearchFilter, + options?: { include?: Prisma.ReportInclude }, ): Promise> { - const { userId, websiteId, filter, filterType = REPORT_FILTER_TYPES.all } = ReportSearchFilter; + const { + userId, + websiteId, + includeTeams, + filter, + filterType = REPORT_FILTER_TYPES.all, + } = ReportSearchFilter; + const where: Prisma.ReportWhereInput = { ...(userId && { userId: userId }), ...(websiteId && { websiteId: websiteId }), - ...(filter && { - AND: { + AND: [ + { + OR: [ + { + ...(userId && { userId: userId }), + }, + { + ...(includeTeams && { + website: { + teamWebsite: { + some: { + team: { + teamUser: { + some: { + userId, + }, + }, + }, + }, + }, + }, + }), + }, + ], + }, + { OR: [ { ...((filterType === REPORT_FILTER_TYPES.all || @@ -98,7 +130,7 @@ export async function getReports( }, ], }, - }), + ], }; const [pageFilters, getParameters] = prisma.getPageFilters(ReportSearchFilter); @@ -106,6 +138,7 @@ export async function getReports( const reports = await prisma.client.report.findMany({ where, ...pageFilters, + ...(options?.include && { include: options.include }), }); const count = await prisma.client.report.count({ where, @@ -122,7 +155,18 @@ export async function getReportsByUserId( userId: string, filter: SearchFilter, ): Promise> { - return getReports({ userId, ...filter }); + return getReports( + { userId, ...filter }, + { + include: { + website: { + select: { + domain: true, + }, + }, + }, + }, + ); } export async function getReportsByWebsiteId( diff --git a/queries/admin/website.ts b/queries/admin/website.ts index a55db814..d7b98b45 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -26,29 +26,11 @@ export async function getWebsites( userId, teamId, includeTeams, + onlyTeams, filter, filterType = WEBSITE_FILTER_TYPES.all, } = WebsiteSearchFilter; - const filterQuery = { - AND: { - OR: [ - { - ...((filterType === WEBSITE_FILTER_TYPES.all || - filterType === WEBSITE_FILTER_TYPES.name) && { - name: { startsWith: filter, mode: 'insensitive' }, - }), - }, - { - ...((filterType === WEBSITE_FILTER_TYPES.all || - filterType === WEBSITE_FILTER_TYPES.domain) && { - domain: { startsWith: filter, mode: 'insensitive' }, - }), - }, - ], - }, - }; - const where: Prisma.WebsiteWhereInput = { ...(teamId && { teamWebsite: { @@ -61,28 +43,53 @@ export async function getWebsites( { OR: [ { - ...(userId && { - userId, - }), + ...(userId && + !onlyTeams && { + userId, + }), }, { - ...(includeTeams && { - teamWebsite: { - some: { - team: { - teamUser: { - some: { - userId, + ...((includeTeams || onlyTeams) && { + AND: [ + { + teamWebsite: { + some: { + team: { + teamUser: { + some: { + userId, + }, + }, }, }, }, }, - }, + { + userId: { + not: userId, + }, + }, + ], + }), + }, + ], + }, + { + OR: [ + { + ...((filterType === WEBSITE_FILTER_TYPES.all || + filterType === WEBSITE_FILTER_TYPES.name) && { + name: { startsWith: filter, mode: 'insensitive' }, + }), + }, + { + ...((filterType === WEBSITE_FILTER_TYPES.all || + filterType === WEBSITE_FILTER_TYPES.domain) && { + domain: { startsWith: filter, mode: 'insensitive' }, }), }, ], }, - { ...(filter && filterQuery) }, ], }; @@ -108,7 +115,27 @@ export async function getWebsitesByUserId( userId: string, filter?: WebsiteSearchFilter, ): Promise> { - return getWebsites({ userId, ...filter }); + return getWebsites( + { userId, ...filter }, + { + include: { + teamWebsite: { + include: { + team: { + select: { + name: true, + }, + }, + }, + }, + user: { + select: { + username: true, + }, + }, + }, + }, + ); } export async function getWebsitesByTeamId( From 96d74783e06ed5bedb79588f404421409392c5d2 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Sun, 13 Aug 2023 22:32:25 -0700 Subject: [PATCH 45/73] Edit button states. --- components/pages/reports/ReportsTable.js | 17 ++++++++++++----- queries/admin/report.ts | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/components/pages/reports/ReportsTable.js b/components/pages/reports/ReportsTable.js index e59e4069..39a35c96 100644 --- a/components/pages/reports/ReportsTable.js +++ b/components/pages/reports/ReportsTable.js @@ -1,9 +1,10 @@ -import { useState } from 'react'; -import { Flexbox, Icon, Icons, Text, Button, Modal } from 'react-basics'; +import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm'; import LinkButton from 'components/common/LinkButton'; import SettingsTable from 'components/common/SettingsTable'; -import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm'; import { useMessages } from 'hooks'; +import useUser from 'hooks/useUser'; +import { useState } from 'react'; +import { Button, Flexbox, Icon, Icons, Modal, Text } from 'react-basics'; export function ReportsTable({ data = [], @@ -16,6 +17,7 @@ export function ReportsTable({ }) { const [report, setReport] = useState(null); const { formatMessage, labels } = useMessages(); + const { user } = useUser(); const domainColumn = [ { @@ -49,14 +51,19 @@ export function ReportsTable({ filterValue={filterValue} > {row => { - const { id } = row; + const { + id, + userId: reportOwnerId, + website: { domain, userId: websiteOwnerId }, + } = row; if (showDomain) { - row.domain = row.website.domain; + row.domain = domain; } return ( {formatMessage(labels.view)} + {!showDomain || user.id === reportOwnerId || user.id === websiteOwnerId} - + {(!showTeam || ownerId === user.id) && ( + + + + )} - - )} - - - - - ); - }} - + return ( + <> + {(!showTeam || ownerId === user.id) && ( + + + + )} + + + + + ); + }} + + )} + {!showTable && } + ); } From 38445fce7a22edbce476985d4beb3ab163bc7a81 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Tue, 15 Aug 2023 13:08:18 -0700 Subject: [PATCH 56/73] Fix test console. --- components/pages/console/TestConsole.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/pages/console/TestConsole.js b/components/pages/console/TestConsole.js index 4f167b9a..060314fd 100644 --- a/components/pages/console/TestConsole.js +++ b/components/pages/console/TestConsole.js @@ -72,7 +72,7 @@ export function TestConsole() { } const [websiteId] = id || []; - const website = data.find(({ id }) => websiteId === id); + const website = data?.data.find(({ id }) => websiteId === id); return ( From c3e261fc50a78f0d50119aff9d583c8cea766516 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 16 Aug 2023 08:49:22 -0700 Subject: [PATCH 57/73] Resolve issues in event data --- components/pages/event-data/EventDataValueTable.js | 1 + components/pages/websites/WebsiteEventData.js | 6 +++--- lib/types.ts | 9 ++------- pages/api/event-data/events.ts | 5 +++-- pages/api/event-data/fields.ts | 4 ++-- pages/api/event-data/stats.ts | 12 ++++++------ queries/analytics/eventData/getEventDataEvents.ts | 8 ++++---- queries/analytics/eventData/getEventDataFields.ts | 4 ++-- 8 files changed, 23 insertions(+), 26 deletions(-) diff --git a/components/pages/event-data/EventDataValueTable.js b/components/pages/event-data/EventDataValueTable.js index 3688ad09..69ed10a7 100644 --- a/components/pages/event-data/EventDataValueTable.js +++ b/components/pages/event-data/EventDataValueTable.js @@ -36,6 +36,7 @@ export function EventDataValueTable({ data = [], event }) { {row => DATA_TYPES[row.dataType]} + {({ total }) => total.toLocaleString()} diff --git a/components/pages/websites/WebsiteEventData.js b/components/pages/websites/WebsiteEventData.js index 5e208355..7f9a6829 100644 --- a/components/pages/websites/WebsiteEventData.js +++ b/components/pages/websites/WebsiteEventData.js @@ -5,18 +5,18 @@ import { EventDataMetricsBar } from 'components/pages/event-data/EventDataMetric import { useDateRange, useApi, usePageQuery } from 'hooks'; import styles from './WebsiteEventData.module.css'; -function useData(websiteId, eventName) { +function useData(websiteId, event) { const [dateRange] = useDateRange(websiteId); const { startDate, endDate } = dateRange; const { get, useQuery } = useApi(); const { data, error, isLoading } = useQuery( - ['event-data:events', { websiteId, startDate, endDate, eventName }], + ['event-data:events', { websiteId, startDate, endDate, event }], () => get('/event-data/events', { websiteId, startAt: +startDate, endAt: +endDate, - eventName, + event, }), { enabled: !!(websiteId && startDate && endDate) }, ); diff --git a/lib/types.ts b/lib/types.ts index 65bef8fb..3f3ac533 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -126,13 +126,8 @@ export interface WebsiteEventMetric { y: number; } -export interface WebsiteEventDataStats { - fieldName: string; - dataType: number; - total: number; -} - -export interface WebsiteEventDataFields { +export interface WebsiteEventData { + eventName?: string; fieldName: string; dataType: number; fieldValue?: string; diff --git a/pages/api/event-data/events.ts b/pages/api/event-data/events.ts index e83e541b..9f8f964b 100644 --- a/pages/api/event-data/events.ts +++ b/pages/api/event-data/events.ts @@ -5,16 +5,17 @@ import { NextApiResponse } from 'next'; import { ok, methodNotAllowed, unauthorized } from 'next-basics'; import { getEventDataEvents } from 'queries'; -export interface EventDataFieldsRequestBody { +export interface EventDataEventsRequestQuery { websiteId: string; dateRange: { startDate: string; endDate: string; }; + event?: string; } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); diff --git a/pages/api/event-data/fields.ts b/pages/api/event-data/fields.ts index f21bd570..b6a73133 100644 --- a/pages/api/event-data/fields.ts +++ b/pages/api/event-data/fields.ts @@ -5,7 +5,7 @@ import { NextApiResponse } from 'next'; import { ok, methodNotAllowed, unauthorized } from 'next-basics'; import { getEventDataFields } from 'queries'; -export interface EventDataFieldsRequestBody { +export interface EventDataFieldsRequestQuery { websiteId: string; dateRange: { startDate: string; @@ -15,7 +15,7 @@ export interface EventDataFieldsRequestBody { } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); diff --git a/pages/api/event-data/stats.ts b/pages/api/event-data/stats.ts index 74f420c4..d1ee396b 100644 --- a/pages/api/event-data/stats.ts +++ b/pages/api/event-data/stats.ts @@ -5,7 +5,7 @@ import { NextApiResponse } from 'next'; import { ok, methodNotAllowed, unauthorized } from 'next-basics'; import { getEventDataFields } from 'queries'; -export interface EventDataRequestBody { +export interface EventDataStatsRequestQuery { websiteId: string; dateRange: { startDate: string; @@ -15,7 +15,7 @@ export interface EventDataRequestBody { } export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); @@ -32,18 +32,18 @@ export default async ( const endDate = new Date(+endAt); const results = await getEventDataFields(websiteId, { startDate, endDate }); - const events = new Set(); + const fields = new Set(); const data = results.reduce( (obj, row) => { - events.add(row.fieldName); + fields.add(row.fieldName); obj.records += Number(row.total); return obj; }, - { fields: results.length, records: 0 }, + { events: results.length, records: 0 }, ); - return ok(res, { ...data, events: events.size }); + return ok(res, { ...data, fields: fields.size }); } return methodNotAllowed(res); diff --git a/queries/analytics/eventData/getEventDataEvents.ts b/queries/analytics/eventData/getEventDataEvents.ts index 25084111..2c8cb0e0 100644 --- a/queries/analytics/eventData/getEventDataEvents.ts +++ b/queries/analytics/eventData/getEventDataEvents.ts @@ -1,11 +1,11 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; -import { QueryFilters, WebsiteEventDataFields } from 'lib/types'; +import { QueryFilters, WebsiteEventData } from 'lib/types'; export async function getEventDataEvents( ...args: [websiteId: string, filters: QueryFilters] -): Promise { +): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), @@ -24,7 +24,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { website_event.event_name as "eventName", event_data.event_key as "fieldName", event_data.data_type as "dataType", - event_data.string_value as "value", + event_data.string_value as "fieldValue", count(*) as "total" from event_data inner join website_event @@ -71,7 +71,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { event_name as eventName, event_key as fieldName, data_type as dataType, - string_value as value, + string_value as fieldValue, count(*) as total from event_data where website_id = {websiteId:UUID} diff --git a/queries/analytics/eventData/getEventDataFields.ts b/queries/analytics/eventData/getEventDataFields.ts index f5f426e0..ac32b188 100644 --- a/queries/analytics/eventData/getEventDataFields.ts +++ b/queries/analytics/eventData/getEventDataFields.ts @@ -1,11 +1,11 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; -import { QueryFilters, WebsiteEventDataFields } from 'lib/types'; +import { QueryFilters, WebsiteEventData } from 'lib/types'; export async function getEventDataFields( ...args: [websiteId: string, filters: QueryFilters & { field?: string }] -): Promise { +): Promise { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), From c548267d91409db46d42894953dd57b4cfbecd48 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 16 Aug 2023 10:50:28 -0700 Subject: [PATCH 58/73] Added month select component. --- components/input/DateFilter.js | 6 +-- components/input/MonthSelect.js | 51 +++++++++++++++++++ components/input/MonthSelect.module.css | 12 +++++ components/layout/SettingsLayout.module.css | 1 + components/pages/realtime/RealtimeLog.js | 4 +- components/pages/reports/BaseParameters.js | 37 +++++++++----- .../reports/retention/RetentionParameters.js | 21 +++++--- .../pages/reports/retention/RetentionTable.js | 29 +++++------ .../retention/RetentionTable.module.css | 26 ++++++++-- lib/charts.js | 14 ++--- lib/clickhouse.ts | 6 +-- lib/date.js | 2 +- lib/prisma.ts | 6 +-- package.json | 2 +- queries/analytics/reports/getRetention.ts | 15 +++--- yarn.lock | 8 +-- 16 files changed, 169 insertions(+), 71 deletions(-) create mode 100644 components/input/MonthSelect.js create mode 100644 components/input/MonthSelect.module.css diff --git a/components/input/DateFilter.js b/components/input/DateFilter.js index 7fc4319d..af4b69dd 100644 --- a/components/input/DateFilter.js +++ b/components/input/DateFilter.js @@ -3,7 +3,7 @@ import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics'; import { endOfYear, isSameDay } from 'date-fns'; import DatePickerForm from 'components/metrics/DatePickerForm'; import useLocale from 'hooks/useLocale'; -import { dateFormat } from 'lib/date'; +import { formatDate } from 'lib/date'; import Icons from 'components/icons'; import useMessages from 'hooks/useMessages'; @@ -135,8 +135,8 @@ const CustomRange = ({ startDate, endDate, onClick }) => { - {dateFormat(startDate, 'd LLL y', locale)} - {!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`} + {formatDate(startDate, 'd LLL y', locale)} + {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'd LLL y', locale)}`} ); diff --git a/components/input/MonthSelect.js b/components/input/MonthSelect.js new file mode 100644 index 00000000..bb054446 --- /dev/null +++ b/components/input/MonthSelect.js @@ -0,0 +1,51 @@ +import { useRef, useState } from 'react'; +import { Text, Icon, CalendarMonthSelect, CalendarYearSelect, Button } from 'react-basics'; +import { startOfMonth, endOfMonth } from 'date-fns'; +import Icons from 'components/icons'; +import { useLocale } from 'hooks'; +import { formatDate } from 'lib/date'; +import { getDateLocale } from 'lib/lang'; +import styles from './MonthSelect.module.css'; + +const MONTH = 'month'; +const YEAR = 'year'; + +export function MonthSelect({ date = new Date(), onChange }) { + const { locale } = useLocale(); + const [select, setSelect] = useState(null); + const month = formatDate(date, 'MMMM', locale); + const year = date.getFullYear(); + const ref = useRef(); + + const handleSelect = value => { + setSelect(state => (state !== value ? value : null)); + }; + + const handleChange = date => { + onChange(`range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`); + setSelect(null); + }; + + return ( + <> +
+ + +
+ {select === MONTH && ( + + )} + {select === YEAR && ( + + )} + + ); +} + +export default MonthSelect; diff --git a/components/input/MonthSelect.module.css b/components/input/MonthSelect.module.css new file mode 100644 index 00000000..04cf575c --- /dev/null +++ b/components/input/MonthSelect.module.css @@ -0,0 +1,12 @@ +.container { + display: flex; + align-items: center; + justify-content: center; +} + +.input { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} diff --git a/components/layout/SettingsLayout.module.css b/components/layout/SettingsLayout.module.css index 569b903b..36d029f0 100644 --- a/components/layout/SettingsLayout.module.css +++ b/components/layout/SettingsLayout.module.css @@ -2,6 +2,7 @@ display: flex; flex-direction: column; padding-top: 40px; + padding-right: 20px; } .content { diff --git a/components/pages/realtime/RealtimeLog.js b/components/pages/realtime/RealtimeLog.js index 744bff00..6486f707 100644 --- a/components/pages/realtime/RealtimeLog.js +++ b/components/pages/realtime/RealtimeLog.js @@ -8,7 +8,7 @@ import useLocale from 'hooks/useLocale'; import useCountryNames from 'hooks/useCountryNames'; import { BROWSERS } from 'lib/constants'; import { stringToColor } from 'lib/format'; -import { dateFormat } from 'lib/date'; +import { formatDate } from 'lib/date'; import { safeDecodeURI } from 'next-basics'; import Icons from 'components/icons'; import styles from './RealtimeLog.module.css'; @@ -50,7 +50,7 @@ export function RealtimeLog({ data, websiteDomain }) { }, ]; - const getTime = ({ createdAt }) => dateFormat(new Date(createdAt), 'pp', locale); + const getTime = ({ createdAt }) => formatDate(new Date(createdAt), 'pp', locale); const getColor = ({ id, sessionId }) => stringToColor(sessionId || id); diff --git a/components/pages/reports/BaseParameters.js b/components/pages/reports/BaseParameters.js index 394432cf..76c35a58 100644 --- a/components/pages/reports/BaseParameters.js +++ b/components/pages/reports/BaseParameters.js @@ -6,7 +6,12 @@ import { useContext } from 'react'; import { ReportContext } from './Report'; import { useMessages } from 'hooks'; -export function BaseParameters() { +export function BaseParameters({ + showWebsiteSelect = true, + allowWebsiteSelect = true, + showDateSelect = true, + allowDateSelect = true, +}) { const { report, updateReport } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); @@ -24,17 +29,25 @@ export function BaseParameters() { return ( <> - - - - - - + {showWebsiteSelect && ( + + {allowWebsiteSelect && ( + + )} + + )} + {showDateSelect && ( + + {allowDateSelect && ( + + )} + + )} ); } diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js index f6bde0b1..1eee6bf2 100644 --- a/components/pages/reports/retention/RetentionParameters.js +++ b/components/pages/reports/retention/RetentionParameters.js @@ -1,21 +1,19 @@ import { useContext, useRef } from 'react'; import { useMessages } from 'hooks'; -import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics'; +import { Form, FormButtons, FormRow, SubmitButton } from 'react-basics'; import { ReportContext } from 'components/pages/reports/Report'; +import { MonthSelect } from 'components/input/MonthSelect'; import BaseParameters from '../BaseParameters'; - -const fieldOptions = [ - { name: 'daily', type: 'string' }, - { name: 'weekly', type: 'string' }, -]; +import { parseDateRange } from 'lib/date'; export function RetentionParameters() { - const { report, runReport, isRunning } = useContext(ReportContext); + const { report, runReport, isRunning, updateReport } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); const ref = useRef(null); const { parameters } = report || {}; const { websiteId, dateRange } = parameters || {}; + const { startDate } = dateRange || {}; const queryDisabled = !websiteId || !dateRange; const handleSubmit = (data, e) => { @@ -26,9 +24,16 @@ export function RetentionParameters() { } }; + const handleDateChange = value => { + updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } }); + }; + return (
- + + + + {formatMessage(labels.runQuery)} diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js index 01a84a01..f7d8c4bb 100644 --- a/components/pages/reports/retention/RetentionTable.js +++ b/components/pages/reports/retention/RetentionTable.js @@ -1,9 +1,10 @@ import { useContext } from 'react'; import { GridTable, GridColumn } from 'react-basics'; +import classNames from 'classnames'; import { ReportContext } from '../Report'; import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import { useMessages } from 'hooks'; -import { dateFormat } from 'lib/date'; +import { formatDate } from 'lib/date'; import styles from './RetentionTable.module.css'; export function RetentionTable() { @@ -15,34 +16,32 @@ export function RetentionTable() { return ; } - const dates = data.reduce((arr, { date }) => { - if (!arr.includes(date)) { - return arr.concat(date); + const rows = data.reduce((arr, { date, visitors }) => { + if (!arr.find(a => a.date === date)) { + return arr.concat({ date, visitors }); } return arr; }, []); - const days = Array(32).fill(null); + const days = [1, 2, 3, 4, 5, 6, 7, 14, 21, 30]; return ( <>
-
+
{formatMessage(labels.date)}
- {days.map((n, i) => ( -
- {formatMessage(labels.day)} {i} +
{formatMessage(labels.visitors)}
+ {days.map(n => ( +
+ {formatMessage(labels.day)} {n}
))}
- {dates.map((date, i) => { + {rows.map(({ date, visitors }, i) => { return (
-
- {dateFormat(date, 'P')} -
- {date} -
+
{formatDate(`${date} 00:00:00`, 'PP')}
+
{visitors}
{days.map((n, day) => { return (
diff --git a/components/pages/reports/retention/RetentionTable.module.css b/components/pages/reports/retention/RetentionTable.module.css index 0943ffc0..79cbbc5f 100644 --- a/components/pages/reports/retention/RetentionTable.module.css +++ b/components/pages/reports/retention/RetentionTable.module.css @@ -4,10 +4,7 @@ } .header { - width: 60px; - height: 40px; - text-align: center; - font-size: var(--font-size-sm); + font-weight: 700; } .row { @@ -28,5 +25,24 @@ } .date { - min-width: 200px; + display: flex; + align-items: center; + min-width: 160px; +} + +.visitors { + display: flex; + align-items: center; + min-width: 80px; +} + +.day { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + text-align: center; + font-size: var(--font-size-sm); + font-weight: 400; } diff --git a/lib/charts.js b/lib/charts.js index 0571a9a9..ff746cb5 100644 --- a/lib/charts.js +++ b/lib/charts.js @@ -1,5 +1,5 @@ import { StatusLight } from 'react-basics'; -import { dateFormat } from 'lib/date'; +import { formatDate } from 'lib/date'; import { formatLongNumber } from 'lib/format'; export function renderNumberLabels(label) { @@ -12,15 +12,15 @@ export function renderDateLabels(unit, locale) { switch (unit) { case 'minute': - return dateFormat(d, 'h:mm', locale); + return formatDate(d, 'h:mm', locale); case 'hour': - return dateFormat(d, 'p', locale); + return formatDate(d, 'p', locale); case 'day': - return dateFormat(d, 'MMM d', locale); + return formatDate(d, 'MMM d', locale); case 'month': - return dateFormat(d, 'MMM', locale); + return formatDate(d, 'MMM', locale); case 'year': - return dateFormat(d, 'YYY', locale); + return formatDate(d, 'YYY', locale); default: return label; } @@ -50,7 +50,7 @@ export function renderStatusTooltipPopup(unit, locale) { setTooltipPopup( <> -
{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}
+
{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label} diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 75786850..aa2f21ed 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -63,12 +63,12 @@ function getDateFormat(date) { return `'${dateFormat(date, 'UTC:yyyy-mm-dd HH:MM:ss')}'`; } -function mapFilter(column, operator, name) { +function mapFilter(column, operator, name, type = 'String') { switch (operator) { case OPERATORS.equals: - return `${column} = {${name}:String}`; + return `${column} = {${name}:${type}`; case OPERATORS.notEquals: - return `${column} != {${name}:String}`; + return `${column} != {${name}:${type}}`; default: return ''; } diff --git a/lib/date.js b/lib/date.js index 8a023822..49bff897 100644 --- a/lib/date.js +++ b/lib/date.js @@ -249,7 +249,7 @@ export const customFormats = { }, }; -export function dateFormat(date, str, locale = 'en-US') { +export function formatDate(date, str, locale = 'en-US') { return format( typeof date === 'string' ? new Date(date) : date, customFormats?.[locale]?.[str] || str, diff --git a/lib/prisma.ts b/lib/prisma.ts index efce3f4e..6a6c1790 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -67,12 +67,12 @@ function getTimestampIntervalQuery(field: string): string { } } -function mapFilter(column, operator, name) { +function mapFilter(column, operator, name, type = 'String') { switch (operator) { case OPERATORS.equals: - return `${column} = {{${name}}}`; + return `${column} = {${name}:${type}`; case OPERATORS.notEquals: - return `${column} != {{${name}}}`; + return `${column} != {${name}:${type}}`; default: return ''; } diff --git a/package.json b/package.json index 89dc5e97..46ad4d2d 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", "react": "^18.2.0", - "react-basics": "^0.92.0", + "react-basics": "^0.94.0", "react-beautiful-dnd": "^13.1.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.4", diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts index ee7e4619..7473e042 100644 --- a/queries/analytics/reports/getRetention.ts +++ b/queries/analytics/reports/getRetention.ts @@ -5,9 +5,10 @@ import prisma from 'lib/prisma'; export async function getRetention( ...args: [ websiteId: string, - dateRange: { + filters: { startDate: Date; endDate: Date; + timezone: string; }, ] ) { @@ -19,9 +20,10 @@ export async function getRetention( async function relationalQuery( websiteId: string, - dateRange: { + filters: { startDate: Date; endDate: Date; + timezone: string; }, ): Promise< { @@ -32,9 +34,8 @@ async function relationalQuery( percentage: number; }[] > { - const { startDate, endDate } = dateRange; + const { startDate, endDate, timezone = 'UTC' } = filters; const { getDateQuery, rawQuery } = prisma; - const timezone = 'utc'; const unit = 'day'; return rawQuery( @@ -94,9 +95,10 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, - dateRange: { + filters: { startDate: Date; endDate: Date; + timezone: string; }, ): Promise< { @@ -107,9 +109,8 @@ async function clickhouseQuery( percentage: number; }[] > { - const { startDate, endDate } = dateRange; + const { startDate, endDate, timezone = 'UTC' } = filters; const { getDateQuery, getDateStringQuery, rawQuery } = clickhouse; - const timezone = 'UTC'; const unit = 'day'; return rawQuery( diff --git a/yarn.lock b/yarn.lock index 115e3cc9..e67cc413 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7557,10 +7557,10 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-basics@^0.92.0: - version "0.92.0" - resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.92.0.tgz#02bc6e88bdaf189c30cc6cbd8bbb1c9d12cd089b" - integrity sha512-BVUWg5a7R88konA9NedYMBx1hl50d6h/MD7qlKOEO/Cnm8cOC7AYTRKAKhO6kHMWjY4ZpUuvlg0UcF+SJP/uXA== +react-basics@^0.94.0: + version "0.94.0" + resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.94.0.tgz#c15698148b959f40c6b451088f36f5735eb82815" + integrity sha512-OlUHWrGRctRGEm+yL9iWSC9HRnxZhlm3enP2iCKytVmt7LvaPtsK4RtZ27qp4irNvuzg79aqF+h5IFnG+Vi7WA== dependencies: classnames "^2.3.1" date-fns "^2.29.3" From b69a6bb6136d9d3b011662d020c75f6102274688 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 16 Aug 2023 10:52:01 -0700 Subject: [PATCH 59/73] stale-issues add ordering to look at old issues first --- .github/workflows/stale-issues.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index 52c0d432..24711fba 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -19,5 +19,6 @@ jobs: close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.' days-before-pr-stale: -1 days-before-pr-close: -1 - operations-per-run: 500 + operations-per-run: 200 + ascending: true repo-token: ${{ secrets.GITHUB_TOKEN }} From 3601cb63a550b90b5ec377dcea9dacf321951672 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 16 Aug 2023 10:58:07 -0700 Subject: [PATCH 60/73] Change day range for retention. --- queries/analytics/reports/getRetention.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts index 6ab49470..3c384b6e 100644 --- a/queries/analytics/reports/getRetention.ts +++ b/queries/analytics/reports/getRetention.ts @@ -86,7 +86,7 @@ async function relationalQuery( from cohort_date c join cohort_size s on c.cohort_date = s.cohort_date - where c.day_number IN (0,1,2,3,4,5,6,7,14,21,30) + where c.day_number <= 31 order by 1, 2`, { websiteId, @@ -165,7 +165,7 @@ async function clickhouseQuery( from cohort_date c join cohort_size s on c.cohort_date = s.cohort_date - where c.day_number IN (0,1,2,3,4,5,6,7,14,21,30) + where c.day_number <= 31 order by 1, 2`, { websiteId, From 7df142b02b31afee1576ce55ae887ea6aa9b8695 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 16 Aug 2023 12:12:45 -0700 Subject: [PATCH 61/73] reorder getfunnel where to match index --- queries/analytics/reports/getFunnel.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/queries/analytics/reports/getFunnel.ts b/queries/analytics/reports/getFunnel.ts index 3c5c65e0..1bbbc878 100644 --- a/queries/analytics/reports/getFunnel.ts +++ b/queries/analytics/reports/getFunnel.ts @@ -57,12 +57,14 @@ async function relationalQuery( from level${i} l join website_event we on l.session_id = we.session_id - where we.created_at between l.created_at - and ${getAddMinutesQuery(`l.created_at `, windowMinutes)} + where we.website_id = {{websiteId::uuid}} + and we.created_at between l.created_at and ${getAddMinutesQuery( + `l.created_at `, + windowMinutes, + )} and we.referrer_path = {{${i - 1}}} and we.url_path = {{${i}}} and we.created_at <= {{endDate}} - and we.website_id = {{websiteId::uuid}} )`; } From d6c8f3aa18bdcfe6a46c2397819ddb45424cb03c Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 16 Aug 2023 13:47:43 -0700 Subject: [PATCH 62/73] fix mapfilter / rawquery for relational --- lib/clickhouse.ts | 2 +- lib/prisma.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index aa2f21ed..8ce7bc98 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -66,7 +66,7 @@ function getDateFormat(date) { function mapFilter(column, operator, name, type = 'String') { switch (operator) { case OPERATORS.equals: - return `${column} = {${name}:${type}`; + return `${column} = {${name}:${type}}`; case OPERATORS.notEquals: return `${column} != {${name}:${type}}`; default: diff --git a/lib/prisma.ts b/lib/prisma.ts index cebd7193..12bafa51 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -92,12 +92,12 @@ function getTimestampIntervalQuery(field: string): string { } } -function mapFilter(column, operator, name, type = 'String') { +function mapFilter(column, operator, name, type = 'varchar') { switch (operator) { case OPERATORS.equals: - return `${column} = {${name}:${type}`; + return `${column} = {{${name}::${type}}}`; case OPERATORS.notEquals: - return `${column} != {${name}:${type}}`; + return `${column} != {{${name}::${type}}}`; default: return ''; } @@ -161,7 +161,7 @@ async function rawQuery(sql: string, data: object): Promise { return Promise.reject(new Error('Unknown database.')); } - const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => { + const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*\}\}/g, (...args) => { const [, name, type] = args; params.push(data[name]); From 0dfa6c120cade8669c87637f5975426a003c809b Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Wed, 16 Aug 2023 13:56:12 -0700 Subject: [PATCH 63/73] Remove add buttons for cloud mode. --- components/pages/reports/ReportsPage.js | 24 +++++++++---------- .../pages/settings/websites/WebsitesList.js | 10 ++++---- components/pages/websites/WebsitesPage.js | 4 +++- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/components/pages/reports/ReportsPage.js b/components/pages/reports/ReportsPage.js index 8fc56917..29c77975 100644 --- a/components/pages/reports/ReportsPage.js +++ b/components/pages/reports/ReportsPage.js @@ -3,11 +3,13 @@ import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; import { useMessages, useReports } from 'hooks'; import Link from 'next/link'; +import useConfig from 'hooks/useConfig'; import { Button, Icon, Icons, Text } from 'react-basics'; import ReportsTable from './ReportsTable'; export function ReportsPage() { const { formatMessage, labels, messages } = useMessages(); + const { cloudMode } = useConfig(); const { reports, error, @@ -21,21 +23,19 @@ export function ReportsPage() { const hasData = (reports && reports?.data.length !== 0) || filter; - const handleDelete = async id => { - await deleteReport(id); - }; - return ( - - - + {!cloudMode && ( + + + + )} {hasData && ( diff --git a/components/pages/settings/websites/WebsitesList.js b/components/pages/settings/websites/WebsitesList.js index 799b032b..538fc61a 100644 --- a/components/pages/settings/websites/WebsitesList.js +++ b/components/pages/settings/websites/WebsitesList.js @@ -1,18 +1,18 @@ -import { Button, Icon, Text, Modal, ModalTrigger, useToasts, Icons } from 'react-basics'; import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; -import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import WebsiteAddForm from 'components/pages/settings/websites/WebsiteAddForm'; import WebsitesTable from 'components/pages/settings/websites/WebsitesTable'; import useApi from 'hooks/useApi'; -import useUser from 'hooks/useUser'; -import useMessages from 'hooks/useMessages'; -import { ROLES } from 'lib/constants'; import useApiFilter from 'hooks/useApiFilter'; +import useMessages from 'hooks/useMessages'; +import useUser from 'hooks/useUser'; +import { ROLES } from 'lib/constants'; +import { Button, Icon, Icons, Modal, ModalTrigger, Text, useToasts } from 'react-basics'; export function WebsitesList({ showTeam, showHeader = true, includeTeams, onlyTeams, fetch }) { const { formatMessage, labels, messages } = useMessages(); const { user } = useUser(); + const { filter, page, pageSize, handleFilterChange, handlePageChange, handlePageSizeChange } = useApiFilter(); const { get, useQuery } = useApi(); diff --git a/components/pages/websites/WebsitesPage.js b/components/pages/websites/WebsitesPage.js index 4fdd025d..4a2207da 100644 --- a/components/pages/websites/WebsitesPage.js +++ b/components/pages/websites/WebsitesPage.js @@ -4,6 +4,7 @@ import WebsiteAddForm from 'components/pages/settings/websites/WebsiteAddForm'; import WebsiteList from 'components/pages/settings/websites/WebsitesList'; import { useMessages } from 'hooks'; import useUser from 'hooks/useUser'; +import useConfig from 'hooks/useConfig'; import { ROLES } from 'lib/constants'; import { useState } from 'react'; import { @@ -23,6 +24,7 @@ export function WebsitesPage() { const [tab, setTab] = useState('my-websites'); const [fetch, setFetch] = useState(1); const { user } = useUser(); + const { cloudMode } = useConfig(); const { showToast } = useToasts(); const handleSave = async () => { @@ -50,7 +52,7 @@ export function WebsitesPage() { return ( - {addButton} + {!cloudMode && addButton} {formatMessage(labels.myWebsites)} {formatMessage(labels.teamWebsites)} From 146650586b3cbd4d484e321c15d45c7c4baa0109 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 16 Aug 2023 13:56:41 -0700 Subject: [PATCH 64/73] add normalize filters to clickhouse --- lib/clickhouse.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 8ce7bc98..bc10a6d4 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -94,13 +94,23 @@ function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) return query.join('\n'); } +function normalizeFilters(filters = {}) { + return Object.keys(filters).reduce((obj, key) => { + const value = filters[key]; + + obj[key] = value?.value ?? value; + + return obj; + }, {}); +} + async function parseFilters(websiteId: string, filters: QueryFilters = {}, options?: QueryOptions) { const website = await loadWebsite(websiteId); return { filterQuery: getFilterQuery(filters, options), params: { - ...filters, + ...normalizeFilters(filters), websiteId, startDate: maxDate(filters.startDate, website.resetAt), websiteDomain: website.domain, From 6dba68c823df80302fc261b3a5d854a660d7cade Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 16 Aug 2023 14:33:55 -0700 Subject: [PATCH 65/73] add metric performance indexes --- .../03_metric_performance_index/migration.sql | 50 +++++++++++++++++++ db/mysql/schema.prisma | 17 +++++++ .../03_metric_performance_index/migration.sql | 50 +++++++++++++++++++ db/postgresql/schema.prisma | 17 +++++++ 4 files changed, 134 insertions(+) create mode 100644 db/mysql/migrations/03_metric_performance_index/migration.sql create mode 100644 db/postgresql/migrations/03_metric_performance_index/migration.sql diff --git a/db/mysql/migrations/03_metric_performance_index/migration.sql b/db/mysql/migrations/03_metric_performance_index/migration.sql new file mode 100644 index 00000000..64681364 --- /dev/null +++ b/db/mysql/migrations/03_metric_performance_index/migration.sql @@ -0,0 +1,50 @@ +-- CreateIndex +CREATE INDEX `event_data_website_id_created_at_idx` ON `event_data`(`website_id`, `created_at`); + +-- CreateIndex +CREATE INDEX `event_data_website_id_created_at_event_key_idx` ON `event_data`(`website_id`, `created_at`, `event_key`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_idx` ON `session`(`website_id`, `created_at`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_hostname_idx` ON `session`(`website_id`, `created_at`, `hostname`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_browser_idx` ON `session`(`website_id`, `created_at`, `browser`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_os_idx` ON `session`(`website_id`, `created_at`, `os`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_device_idx` ON `session`(`website_id`, `created_at`, `device`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_screen_idx` ON `session`(`website_id`, `created_at`, `screen`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_language_idx` ON `session`(`website_id`, `created_at`, `language`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_country_idx` ON `session`(`website_id`, `created_at`, `country`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_subdivision1_idx` ON `session`(`website_id`, `created_at`, `subdivision1`); + +-- CreateIndex +CREATE INDEX `session_website_id_created_at_city_idx` ON `session`(`website_id`, `created_at`, `city`); + +-- CreateIndex +CREATE INDEX `website_event_website_id_created_at_url_path_idx` ON `website_event`(`website_id`, `created_at`, `url_path`); + +-- CreateIndex +CREATE INDEX `website_event_website_id_created_at_url_query_idx` ON `website_event`(`website_id`, `created_at`, `url_query`); + +-- CreateIndex +CREATE INDEX `website_event_website_id_created_at_referrer_domain_idx` ON `website_event`(`website_id`, `created_at`, `referrer_domain`); + +-- CreateIndex +CREATE INDEX `website_event_website_id_created_at_page_title_idx` ON `website_event`(`website_id`, `created_at`, `page_title`); + +-- CreateIndex +CREATE INDEX `website_event_website_id_created_at_event_name_idx` ON `website_event`(`website_id`, `created_at`, `event_name`); diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index a25405df..38bb91f4 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -44,6 +44,16 @@ model Session { @@index([createdAt]) @@index([websiteId]) + @@index([websiteId, createdAt]) + @@index([websiteId, createdAt, hostname]) + @@index([websiteId, createdAt, browser]) + @@index([websiteId, createdAt, os]) + @@index([websiteId, createdAt, device]) + @@index([websiteId, createdAt, screen]) + @@index([websiteId, createdAt, language]) + @@index([websiteId, createdAt, country]) + @@index([websiteId, createdAt, subdivision1]) + @@index([websiteId, createdAt, city]) @@map("session") } @@ -91,6 +101,11 @@ model WebsiteEvent { @@index([sessionId]) @@index([websiteId]) @@index([websiteId, createdAt]) + @@index([websiteId, createdAt, urlPath]) + @@index([websiteId, createdAt, urlQuery]) + @@index([websiteId, createdAt, referrerDomain]) + @@index([websiteId, createdAt, pageTitle]) + @@index([websiteId, createdAt, eventName]) @@index([websiteId, sessionId, createdAt]) @@map("website_event") } @@ -113,6 +128,8 @@ model EventData { @@index([websiteId]) @@index([websiteEventId]) @@index([websiteId, websiteEventId, createdAt]) + @@index([websiteId, createdAt]) + @@index([websiteId, createdAt, eventKey]) @@map("event_data") } diff --git a/db/postgresql/migrations/03_metric_performance_index/migration.sql b/db/postgresql/migrations/03_metric_performance_index/migration.sql new file mode 100644 index 00000000..5db7aa50 --- /dev/null +++ b/db/postgresql/migrations/03_metric_performance_index/migration.sql @@ -0,0 +1,50 @@ +-- CreateIndex +CREATE INDEX "event_data_website_id_created_at_idx" ON "event_data"("website_id", "created_at"); + +-- CreateIndex +CREATE INDEX "event_data_website_id_created_at_event_key_idx" ON "event_data"("website_id", "created_at", "event_key"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_idx" ON "session"("website_id", "created_at"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_hostname_idx" ON "session"("website_id", "created_at", "hostname"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_browser_idx" ON "session"("website_id", "created_at", "browser"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_os_idx" ON "session"("website_id", "created_at", "os"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_device_idx" ON "session"("website_id", "created_at", "device"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_screen_idx" ON "session"("website_id", "created_at", "screen"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_language_idx" ON "session"("website_id", "created_at", "language"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_country_idx" ON "session"("website_id", "created_at", "country"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_subdivision1_idx" ON "session"("website_id", "created_at", "subdivision1"); + +-- CreateIndex +CREATE INDEX "session_website_id_created_at_city_idx" ON "session"("website_id", "created_at", "city"); + +-- CreateIndex +CREATE INDEX "website_event_website_id_created_at_url_path_idx" ON "website_event"("website_id", "created_at", "url_path"); + +-- CreateIndex +CREATE INDEX "website_event_website_id_created_at_url_query_idx" ON "website_event"("website_id", "created_at", "url_query"); + +-- CreateIndex +CREATE INDEX "website_event_website_id_created_at_referrer_domain_idx" ON "website_event"("website_id", "created_at", "referrer_domain"); + +-- CreateIndex +CREATE INDEX "website_event_website_id_created_at_page_title_idx" ON "website_event"("website_id", "created_at", "page_title"); + +-- CreateIndex +CREATE INDEX "website_event_website_id_created_at_event_name_idx" ON "website_event"("website_id", "created_at", "event_name"); diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 5753c6ef..d7a70ab0 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -44,6 +44,16 @@ model Session { @@index([createdAt]) @@index([websiteId]) + @@index([websiteId, createdAt]) + @@index([websiteId, createdAt, hostname]) + @@index([websiteId, createdAt, browser]) + @@index([websiteId, createdAt, os]) + @@index([websiteId, createdAt, device]) + @@index([websiteId, createdAt, screen]) + @@index([websiteId, createdAt, language]) + @@index([websiteId, createdAt, country]) + @@index([websiteId, createdAt, subdivision1]) + @@index([websiteId, createdAt, city]) @@map("session") } @@ -91,6 +101,11 @@ model WebsiteEvent { @@index([sessionId]) @@index([websiteId]) @@index([websiteId, createdAt]) + @@index([websiteId, createdAt, urlPath]) + @@index([websiteId, createdAt, urlQuery]) + @@index([websiteId, createdAt, referrerDomain]) + @@index([websiteId, createdAt, pageTitle]) + @@index([websiteId, createdAt, eventName]) @@index([websiteId, sessionId, createdAt]) @@map("website_event") } @@ -112,6 +127,8 @@ model EventData { @@index([createdAt]) @@index([websiteId]) @@index([websiteEventId]) + @@index([websiteId, createdAt]) + @@index([websiteId, createdAt, eventKey]) @@map("event_data") } From 9b8fa08d8235729cb23c901d1cf0f4a301f1d75d Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 17 Aug 2023 01:33:10 -0700 Subject: [PATCH 66/73] Fixed ugly navbar. --- components/layout/AppLayout.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/components/layout/AppLayout.module.css b/components/layout/AppLayout.module.css index 0afd11f9..be51f83c 100644 --- a/components/layout/AppLayout.module.css +++ b/components/layout/AppLayout.module.css @@ -10,6 +10,7 @@ width: 100vw; grid-column: 1; grid-row: 1 / 2; + z-index: 1; } .body { From 2c8996b68f8c7ea42e4c5d006249337957e15427 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 17 Aug 2023 03:21:20 -0700 Subject: [PATCH 67/73] Retention report UI updates. --- components/input/MonthSelect.js | 65 ++++++++++++------- components/input/MonthSelect.module.css | 10 +++ components/pages/reports/PopupForm.js | 26 ++------ components/pages/reports/PopupForm.module.css | 2 +- .../reports/insights/InsightsParameters.js | 8 +-- .../insights/InsightsParameters.module.css | 5 ++ .../reports/retention/RetentionParameters.js | 2 +- .../reports/retention/RetentionReport.js | 8 ++- .../pages/reports/retention/RetentionTable.js | 61 ++++++++--------- .../retention/RetentionTable.module.css | 6 +- package.json | 2 +- yarn.lock | 8 +-- 12 files changed, 110 insertions(+), 93 deletions(-) diff --git a/components/input/MonthSelect.js b/components/input/MonthSelect.js index bb054446..a20890ad 100644 --- a/components/input/MonthSelect.js +++ b/components/input/MonthSelect.js @@ -1,5 +1,13 @@ -import { useRef, useState } from 'react'; -import { Text, Icon, CalendarMonthSelect, CalendarYearSelect, Button } from 'react-basics'; +import { useRef } from 'react'; +import { + Text, + Icon, + CalendarMonthSelect, + CalendarYearSelect, + Button, + PopupTrigger, + Popup, +} from 'react-basics'; import { startOfMonth, endOfMonth } from 'date-fns'; import Icons from 'components/icons'; import { useLocale } from 'hooks'; @@ -7,43 +15,50 @@ import { formatDate } from 'lib/date'; import { getDateLocale } from 'lib/lang'; import styles from './MonthSelect.module.css'; -const MONTH = 'month'; -const YEAR = 'year'; - export function MonthSelect({ date = new Date(), onChange }) { const { locale } = useLocale(); - const [select, setSelect] = useState(null); const month = formatDate(date, 'MMMM', locale); const year = date.getFullYear(); const ref = useRef(); - const handleSelect = value => { - setSelect(state => (state !== value ? value : null)); - }; - const handleChange = date => { onChange(`range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`); - setSelect(null); }; return ( <>
- - + + + + + + + + + + + +
- {select === MONTH && ( - - )} - {select === YEAR && ( - - )} ); } diff --git a/components/input/MonthSelect.module.css b/components/input/MonthSelect.module.css index 04cf575c..3b13bcc1 100644 --- a/components/input/MonthSelect.module.css +++ b/components/input/MonthSelect.module.css @@ -2,6 +2,8 @@ display: flex; align-items: center; justify-content: center; + border: 1px solid var(--base400); + border-radius: var(--border-radius); } .input { @@ -10,3 +12,11 @@ gap: 10px; cursor: pointer; } + +.popup { + border: 1px solid var(--base400); + background: var(--base50); + border-radius: var(--border-radius); + padding: 20px; + margin-top: 5px; +} diff --git a/components/pages/reports/PopupForm.js b/components/pages/reports/PopupForm.js index 0f0ead36..0e825a26 100644 --- a/components/pages/reports/PopupForm.js +++ b/components/pages/reports/PopupForm.js @@ -1,29 +1,11 @@ -import { createPortal } from 'react-dom'; -import { useDocumentClick, useKeyDown } from 'react-basics'; import classNames from 'classnames'; import styles from './PopupForm.module.css'; -export function PopupForm({ element, className, children, onClose }) { - const { right, top } = element.getBoundingClientRect(); - const style = { position: 'absolute', left: right, top }; - - useKeyDown('Escape', onClose); - - useDocumentClick(e => { - if (e.target !== element && !element?.parentElement?.contains(e.target)) { - onClose(); - } - }); - - const handleClick = e => { - e.stopPropagation(); - }; - - return createPortal( -
+export function PopupForm({ className, style, children }) { + return ( +
{children} -
, - document.body, +
); } diff --git a/components/pages/reports/PopupForm.module.css b/components/pages/reports/PopupForm.module.css index 4daf199a..94d98b38 100644 --- a/components/pages/reports/PopupForm.module.css +++ b/components/pages/reports/PopupForm.module.css @@ -3,8 +3,8 @@ background: var(--base50); min-width: 300px; padding: 20px; - margin-left: 30px; border: 1px solid var(--base400); border-radius: var(--border-radius); box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1); + z-index: 1000; } diff --git a/components/pages/reports/insights/InsightsParameters.js b/components/pages/reports/insights/InsightsParameters.js index 6de4b838..9f3571e6 100644 --- a/components/pages/reports/insights/InsightsParameters.js +++ b/components/pages/reports/insights/InsightsParameters.js @@ -80,10 +80,10 @@ export function InsightsParameters() { - - {(close, element) => { + + {close => { return ( - + {id === 'fields' && ( }> handleRemove(id, index)}> - {({ name, filter, value, label }) => { + {({ name, filter, value }) => { return (
{id === 'fields' && ( diff --git a/components/pages/reports/insights/InsightsParameters.module.css b/components/pages/reports/insights/InsightsParameters.module.css index 06b62414..c84f8a9e 100644 --- a/components/pages/reports/insights/InsightsParameters.module.css +++ b/components/pages/reports/insights/InsightsParameters.module.css @@ -10,3 +10,8 @@ .op { font-weight: bold; } + +.popup { + margin-top: -10px; + margin-left: 30px; +} diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js index 1eee6bf2..d98608ae 100644 --- a/components/pages/reports/retention/RetentionParameters.js +++ b/components/pages/reports/retention/RetentionParameters.js @@ -31,7 +31,7 @@ export function RetentionParameters() { return ( - + diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js index 63eea44c..a9aaeb3e 100644 --- a/components/pages/reports/retention/RetentionReport.js +++ b/components/pages/reports/retention/RetentionReport.js @@ -6,10 +6,16 @@ import ReportMenu from '../ReportMenu'; import ReportBody from '../ReportBody'; import Magnet from 'assets/magnet.svg'; import { REPORT_TYPES } from 'lib/constants'; +import { parseDateRange } from 'lib/date'; +import { endOfMonth, startOfMonth } from 'date-fns'; const defaultParameters = { type: REPORT_TYPES.retention, - parameters: {}, + parameters: { + dateRange: parseDateRange( + `range:${startOfMonth(new Date()).getTime()}:${endOfMonth(new Date()).getTime()}`, + ), + }, }; export default function RetentionReport({ reportId }) { diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js index f7d8c4bb..df0b0f99 100644 --- a/components/pages/reports/retention/RetentionTable.js +++ b/components/pages/reports/retention/RetentionTable.js @@ -1,5 +1,4 @@ import { useContext } from 'react'; -import { GridTable, GridColumn } from 'react-basics'; import classNames from 'classnames'; import { ReportContext } from '../Report'; import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; @@ -16,14 +15,26 @@ export function RetentionTable() { return ; } - const rows = data.reduce((arr, { date, visitors }) => { - if (!arr.find(a => a.date === date)) { - return arr.concat({ date, visitors }); + const days = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28]; + + const rows = data.reduce((arr, row) => { + const { date, visitors, day } = row; + if (day === 0) { + return arr.concat({ + date, + visitors, + records: days + .reduce((arr, day) => { + arr[day] = data.find(x => x.date === date && x.day === day); + return arr; + }, []) + .filter(n => n), + }); } return arr; }, []); - const days = [1, 2, 3, 4, 5, 6, 7, 14, 21, 30]; + const totalDays = rows.length; return ( <> @@ -37,15 +48,22 @@ export function RetentionTable() {
))}
- {rows.map(({ date, visitors }, i) => { + {rows.map(({ date, visitors, records }, rowIndex) => { return ( -
+
{formatDate(`${date} 00:00:00`, 'PP')}
{visitors}
- {days.map((n, day) => { + {days.map(day => { + if (totalDays - rowIndex < day) { + return null; + } + const percentage = records[day]?.percentage; return ( -
- {data.find(row => row.date === date && row.day === day)?.percentage.toFixed(2)} +
+ {percentage ? `${percentage.toFixed(2)}%` : ''}
); })} @@ -53,31 +71,8 @@ export function RetentionTable() { ); })}
- ); } -function DataTable({ data }) { - return ( - - - {row => row.date} - - - {row => row.day} - - - {row => row.visitors} - - - {row => row.returnVisitors} - - - {row => row.percentage} - - - ); -} - export default RetentionTable; diff --git a/components/pages/reports/retention/RetentionTable.module.css b/components/pages/reports/retention/RetentionTable.module.css index 79cbbc5f..bfe3ac1c 100644 --- a/components/pages/reports/retention/RetentionTable.module.css +++ b/components/pages/reports/retention/RetentionTable.module.css @@ -20,7 +20,7 @@ justify-content: center; width: 60px; height: 60px; - background: var(--blue100); + background: var(--blue200); border-radius: var(--border-radius); } @@ -46,3 +46,7 @@ font-size: var(--font-size-sm); font-weight: 400; } + +.empty { + background: var(--blue100); +} diff --git a/package.json b/package.json index 46ad4d2d..9d2de093 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", "react": "^18.2.0", - "react-basics": "^0.94.0", + "react-basics": "^0.96.0", "react-beautiful-dnd": "^13.1.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index e67cc413..350e483f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7557,10 +7557,10 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-basics@^0.94.0: - version "0.94.0" - resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.94.0.tgz#c15698148b959f40c6b451088f36f5735eb82815" - integrity sha512-OlUHWrGRctRGEm+yL9iWSC9HRnxZhlm3enP2iCKytVmt7LvaPtsK4RtZ27qp4irNvuzg79aqF+h5IFnG+Vi7WA== +react-basics@^0.96.0: + version "0.96.0" + resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.96.0.tgz#e5e72201abdccdda94b952ef605163ca11772d8f" + integrity sha512-WNAxP+0xBtUNgEXrL8aW6UQMmD6WoX9My0VW6uq+Q262DOPTU3zPtWl+9vvES4pF3tPJCFvmFAlK/Alw9+XKVQ== dependencies: classnames "^2.3.1" date-fns "^2.29.3" From 58527c3c51aa10050f469604fb72ca3f2b1073bc Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 17 Aug 2023 11:57:29 -0700 Subject: [PATCH 68/73] Set onfocus. --- components/common/SettingsTable.js | 1 + 1 file changed, 1 insertion(+) diff --git a/components/common/SettingsTable.js b/components/common/SettingsTable.js index a57919f1..e9491331 100644 --- a/components/common/SettingsTable.js +++ b/components/common/SettingsTable.js @@ -41,6 +41,7 @@ export function SettingsTable({ onChange={handleFilterChange} delay={1000} value={filter} + autoFocus={true} placeholder="Search" style={{ maxWidth: '300px', marginBottom: '10px' }} /> From f35a9f0950f540f1edfabd3c261e05813d7ca425 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 17 Aug 2023 12:39:58 -0700 Subject: [PATCH 69/73] Add page of intl. --- components/common/Pager.js | 8 ++++++-- lib/constants.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/components/common/Pager.js b/components/common/Pager.js index 584e0669..4a00590d 100644 --- a/components/common/Pager.js +++ b/components/common/Pager.js @@ -1,7 +1,9 @@ import styles from './Pager.module.css'; import { Button, Flexbox, Icon, Icons } from 'react-basics'; +import useMessages from 'hooks/useMessages'; -export function Pager({ page, pageSize, count, onPageChange, onPageSizeChange }) { +export function Pager({ page, pageSize, count, onPageChange }) { + const { formatMessage, labels } = useMessages(); const maxPage = Math.ceil(count / pageSize); const lastPage = page === maxPage; const firstPage = page === 1; @@ -24,7 +26,9 @@ export function Pager({ page, pageSize, count, onPageChange, onPageSizeChange }) - {`Page ${page} of ${maxPage}`} + + {formatMessage(labels.pageOf, { x: page, y: maxPage })} + - {formatMessage(labels.pageOf, { x: page, y: maxPage })} + {formatMessage(labels.pageOf, { current: page, total: maxPage })} diff --git a/components/common/Pager.module.css b/components/common/Pager.module.css index b4ee9f0e..99eb70ce 100644 --- a/components/common/Pager.module.css +++ b/components/common/Pager.module.css @@ -3,5 +3,5 @@ } .text { - margin: 0 10px; + margin: 0 16px; } diff --git a/components/input/MonthSelect.js b/components/input/MonthSelect.js index a20890ad..88373fdd 100644 --- a/components/input/MonthSelect.js +++ b/components/input/MonthSelect.js @@ -21,8 +21,9 @@ export function MonthSelect({ date = new Date(), onChange }) { const year = date.getFullYear(); const ref = useRef(); - const handleChange = date => { + const handleChange = (close, date) => { onChange(`range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`); + close(); }; return ( @@ -36,11 +37,13 @@ export function MonthSelect({ date = new Date(), onChange }) { - + {close => ( + + )} @@ -51,11 +54,13 @@ export function MonthSelect({ date = new Date(), onChange }) { - + {close => ( + + )}
diff --git a/components/messages.js b/components/messages.js index afd8d848..ff619945 100644 --- a/components/messages.js +++ b/components/messages.js @@ -21,8 +21,8 @@ export const labels = defineMessages({ details: { id: 'label.details', defaultMessage: 'Details' }, website: { id: 'label.website', defaultMessage: 'Website' }, websites: { id: 'label.websites', defaultMessage: 'Websites' }, - myWebsites: { id: 'label.my-websites', defaultMessage: 'My Websites' }, - teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team Websites' }, + myWebsites: { id: 'label.my-websites', defaultMessage: 'My websites' }, + teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team websites' }, created: { id: 'label.created', defaultMessage: 'Created' }, edit: { id: 'label.edit', defaultMessage: 'Edit' }, name: { id: 'label.name', defaultMessage: 'Name' }, @@ -30,7 +30,7 @@ export const labels = defineMessages({ accessCode: { id: 'label.access-code', defaultMessage: 'Access code' }, teamId: { id: 'label.team-id', defaultMessage: 'Team ID' }, team: { id: 'label.team', defaultMessage: 'Team' }, - teamName: { id: 'label.team-name', defaultMessage: 'Team Name' }, + teamName: { id: 'label.team-name', defaultMessage: 'Team name' }, regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' }, remove: { id: 'label.remove', defaultMessage: 'Remove' }, join: { id: 'label.join', defaultMessage: 'Join' }, @@ -177,6 +177,7 @@ export const labels = defineMessages({ pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, day: { id: 'label.day', defaultMessage: 'Day' }, date: { id: 'label.date', defaultMessage: 'Date' }, + pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, }); export const messages = defineMessages({ diff --git a/hooks/useMessages.js b/hooks/useMessages.js index 3c13fab0..e3a6c20b 100644 --- a/hooks/useMessages.js +++ b/hooks/useMessages.js @@ -2,7 +2,7 @@ import { useIntl, FormattedMessage } from 'react-intl'; import { messages, labels } from 'components/messages'; export function useMessages() { - const { formatMessage } = useIntl(); + const intl = useIntl(); const getMessage = id => { const message = Object.values(messages).find(value => value.id === id); @@ -10,6 +10,10 @@ export function useMessages() { return message ? formatMessage(message) : id; }; + const formatMessage = (descriptor, values, opts) => { + return descriptor ? intl.formatMessage(descriptor, values, opts) : null; + }; + return { formatMessage, FormattedMessage, messages, labels, getMessage }; } diff --git a/lang/am-ET.json b/lang/am-ET.json index f91e5eda..7bed1423 100644 --- a/lang/am-ET.json +++ b/lang/am-ET.json @@ -37,7 +37,9 @@ "label.custom-range": "Custom range", "label.dashboard": "Dashboard", "label.data": "Data", + "label.date": "Date", "label.date-range": "Date range", + "label.day": "Day", "label.default-date-range": "Default date range", "label.delete": "Delete", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobile", "label.more": "More", + "label.my-websites": "My websites", "label.name": "Name", "label.new-password": "New password", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Page views", "label.pageTitle": "Page title", "label.pages": "Pages", @@ -117,6 +121,7 @@ "label.required": "Required", "label.reset": "Reset", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Save", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "This month", diff --git a/lang/ar-SA.json b/lang/ar-SA.json index f10ba91d..0efdfee7 100644 --- a/lang/ar-SA.json +++ b/lang/ar-SA.json @@ -37,7 +37,9 @@ "label.custom-range": "فترة مخصصة", "label.dashboard": "الشاشة الرئيسية", "label.data": "البيانات", + "label.date": "Date", "label.date-range": "فترة مخصصة", + "label.day": "Day", "label.default-date-range": "الفترة المخصصة الافتراضية", "label.delete": "حذف", "label.delete-team": "حذف مجموعة", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "جوال", "label.more": "المزيد", + "label.my-websites": "My websites", "label.name": "الإسم", "label.new-password": "كلمة مرور جديدة", "label.none": "غير معرف", "label.os": "OS", "label.overview": "Overview", "label.owner": "المالك", + "label.page-of": "Page {current} of {total}", "label.page-views": "مشاهدات الصفحة", "label.pageTitle": "Page title", "label.pages": "الصفحات", @@ -117,6 +121,7 @@ "label.required": "اجباري", "label.reset": "اعادة تعيين", "label.reset-website": "اعادة تعيين الإحصائيات", + "label.retention": "Retention", "label.role": "الصلاحية", "label.run-query": "Run query", "label.save": "حفظ", @@ -133,7 +138,9 @@ "label.team-guest": "زائر للمجموعة", "label.team-id": "معرف المجموعة", "label.team-member": "عضو المجموعة", + "label.team-name": "Team name", "label.team-owner": "مدير المجموعة", + "label.team-websites": "Team websites", "label.teams": "المجموعات", "label.theme": "المظهر", "label.this-month": "الشهر الحالي", diff --git a/lang/be-BY.json b/lang/be-BY.json index 72e96b40..32693ebd 100644 --- a/lang/be-BY.json +++ b/lang/be-BY.json @@ -37,7 +37,9 @@ "label.custom-range": "Карыстацкі дыяпазон", "label.dashboard": "Інфармацыйная панэль", "label.data": "Data", + "label.date": "Date", "label.date-range": "Дыяпазон дат", + "label.day": "Day", "label.default-date-range": "Дыяпазон дат па змаўчанню", "label.delete": "Выдаліць", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Мабільны", "label.more": "Болей", + "label.my-websites": "My websites", "label.name": "Імя", "label.new-password": "Новы пароль", "label.none": "Няма", "label.os": "OS", "label.overview": "Overview", "label.owner": "Уласнік", + "label.page-of": "Page {current} of {total}", "label.page-views": "Прагляды старонкі", "label.pageTitle": "Page title", "label.pages": "Старонкі", @@ -117,6 +121,7 @@ "label.required": "Абавязкова", "label.reset": "Скінуць", "label.reset-website": "Скінуць статыстыку", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Захаваць", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Тэма", "label.this-month": "Гэты месяц", diff --git a/lang/bn-BD.json b/lang/bn-BD.json index ba2bcbb5..483d1008 100644 --- a/lang/bn-BD.json +++ b/lang/bn-BD.json @@ -37,7 +37,9 @@ "label.custom-range": "কাস্টম রেঞ্জ", "label.dashboard": "ড্যাশবোর্ড", "label.data": "Data", + "label.date": "Date", "label.date-range": "তারিখের পরিসীমা", + "label.day": "Day", "label.default-date-range": "ডিফল্ট তারিখের পরিসীমা", "label.delete": "মুছে ফেলুন", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "মুঠোফোন", "label.more": "আরও", + "label.my-websites": "My websites", "label.name": "নাম", "label.new-password": "নতুন পাসওয়ার্ড", "label.none": "কিছুই না", "label.os": "OS", "label.overview": "Overview", "label.owner": "মালিক", + "label.page-of": "Page {current} of {total}", "label.page-views": "পৃষ্ঠা পরিদর্শন গুলো", "label.pageTitle": "Page title", "label.pages": "পৃষ্ঠাগুলি", @@ -117,6 +121,7 @@ "label.required": "প্রয়োজনীয়", "label.reset": "রিসেট", "label.reset-website": "ওয়েবসাইট রিসেট করুন", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "সংরক্ষণ", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "থিম", "label.this-month": "এই মাস", diff --git a/lang/ca-ES.json b/lang/ca-ES.json index ba3e7bd6..51aee79d 100644 --- a/lang/ca-ES.json +++ b/lang/ca-ES.json @@ -37,7 +37,9 @@ "label.custom-range": "Rang personalitzat", "label.dashboard": "Panell", "label.data": "Data", + "label.date": "Date", "label.date-range": "Interval de dates", + "label.day": "Day", "label.default-date-range": "Interval de dates per defecte", "label.delete": "Esborra", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mòbil", "label.more": "Més", + "label.my-websites": "My websites", "label.name": "Nom", "label.new-password": "Contrasenya nova", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Propietari", + "label.page-of": "Page {current} of {total}", "label.page-views": "Pàgines vistes", "label.pageTitle": "Page title", "label.pages": "Pàgines", @@ -117,6 +121,7 @@ "label.required": "Obligatori", "label.reset": "Restableix", "label.reset-website": "Restableix estadístiques", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Desa", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Aquest mes", diff --git a/lang/cs-CZ.json b/lang/cs-CZ.json index 0017d418..548ee817 100644 --- a/lang/cs-CZ.json +++ b/lang/cs-CZ.json @@ -37,7 +37,9 @@ "label.custom-range": "Vlastní rozsah", "label.dashboard": "Přehled", "label.data": "Data", + "label.date": "Date", "label.date-range": "Období", + "label.day": "Day", "label.default-date-range": "Výchozí období", "label.delete": "Smazat", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobilní telefon", "label.more": "Více", + "label.my-websites": "My websites", "label.name": "Jméno", "label.new-password": "Nové heslo", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Zobrazení stránek", "label.pageTitle": "Page title", "label.pages": "Stránky", @@ -117,6 +121,7 @@ "label.required": "Vyžadováno", "label.reset": "Reset", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Uložit", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Tento měsíc", diff --git a/lang/da-DK.json b/lang/da-DK.json index 7c221beb..9d4fe50e 100644 --- a/lang/da-DK.json +++ b/lang/da-DK.json @@ -37,7 +37,9 @@ "label.custom-range": "Tilpasset interval", "label.dashboard": "Betjeningspanel", "label.data": "Data", + "label.date": "Date", "label.date-range": "Datointerval", + "label.day": "Day", "label.default-date-range": "Standard datointerval", "label.delete": "Slet", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobil", "label.more": "Mere", + "label.my-websites": "My websites", "label.name": "Navn", "label.new-password": "Ny adgangskode", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Ejer", + "label.page-of": "Page {current} of {total}", "label.page-views": "Sidevisninger", "label.pageTitle": "Page title", "label.pages": "Sider", @@ -117,6 +121,7 @@ "label.required": "Påkrævet", "label.reset": "Nulstil", "label.reset-website": "Nulstil statistikker", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Gem", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Tema", "label.this-month": "Denne måned", diff --git a/lang/de-CH.json b/lang/de-CH.json index 9064d63f..5c6c45d1 100644 --- a/lang/de-CH.json +++ b/lang/de-CH.json @@ -37,7 +37,9 @@ "label.custom-range": "Benutzerdefinierte Bereich", "label.dashboard": "Übersicht", "label.data": "Datä", + "label.date": "Date", "label.date-range": "Datumsbereich", + "label.day": "Day", "label.default-date-range": "Vorigstellte Datumsbereich", "label.delete": "Lösche", "label.delete-team": "Team lösche", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Handy", "label.more": "Meh", + "label.my-websites": "My websites", "label.name": "Name", "label.new-password": "Neus Passwort", "label.none": "Keis", "label.os": "OS", "label.overview": "Overview", "label.owner": "Bsitzer", + "label.page-of": "Page {current} of {total}", "label.page-views": "Siitenufrüef", "label.pageTitle": "Page title", "label.pages": "Siite", @@ -117,6 +121,7 @@ "label.required": "Erforderlich", "label.reset": "Zruggsetze", "label.reset-website": "Statistik zruggsetze", + "label.retention": "Retention", "label.role": "Rollä", "label.run-query": "Run query", "label.save": "Speichere", @@ -133,7 +138,9 @@ "label.team-guest": "Team Gast", "label.team-id": "Team ID", "label.team-member": "Team Mitglied", + "label.team-name": "Team name", "label.team-owner": "Team Bsitzer", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Thema", "label.this-month": "De Monet", diff --git a/lang/de-DE.json b/lang/de-DE.json index 6f6934ec..3307dfa4 100644 --- a/lang/de-DE.json +++ b/lang/de-DE.json @@ -37,7 +37,9 @@ "label.custom-range": "Benutzerdefinierter Bereich", "label.dashboard": "Übersicht", "label.data": "Daten", + "label.date": "Date", "label.date-range": "Datumsbereich", + "label.day": "Day", "label.default-date-range": "Voreingestellter Datumsbereich", "label.delete": "Löschen", "label.delete-team": "Team löschen", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Handy", "label.more": "Mehr", + "label.my-websites": "My websites", "label.name": "Name", "label.new-password": "Neues Passwort", "label.none": "Keine", "label.os": "OS", "label.overview": "Übersicht", "label.owner": "Besitzer", + "label.page-of": "Page {current} of {total}", "label.page-views": "Seitenaufrufe", "label.pageTitle": "Page title", "label.pages": "Seiten", @@ -117,6 +121,7 @@ "label.required": "Erforderlich", "label.reset": "Zurücksetzen", "label.reset-website": "Statistik zurücksetzen", + "label.retention": "Retention", "label.role": "Rolle", "label.run-query": "Abfrage starten", "label.save": "Speichern", @@ -133,7 +138,9 @@ "label.team-guest": "Team Gast", "label.team-id": "Team ID", "label.team-member": "Team Mitglied", + "label.team-name": "Team name", "label.team-owner": "Team Eigentümer", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Thema", "label.this-month": "Diesen Monat", diff --git a/lang/el-GR.json b/lang/el-GR.json index 2280f545..dd95c777 100644 --- a/lang/el-GR.json +++ b/lang/el-GR.json @@ -37,7 +37,9 @@ "label.custom-range": "Προσαρμοσμένο εύρος", "label.dashboard": "Πίνακας", "label.data": "Data", + "label.date": "Date", "label.date-range": "Εύρος ημερομηνιών", + "label.day": "Day", "label.default-date-range": "Προεπιλεγμένο εύρος ημερομηνιών", "label.delete": "Διαγραφή", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Κινητό", "label.more": "Περισσότερα", + "label.my-websites": "My websites", "label.name": "Όνομα", "label.new-password": "Νέος κωδικός", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Προβολές σελίδας", "label.pageTitle": "Page title", "label.pages": "Σελίδες", @@ -117,6 +121,7 @@ "label.required": "Απαιτείται", "label.reset": "Επαναφορά", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Αποθήκευση", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Αυτο το μήνα", diff --git a/lang/en-GB.json b/lang/en-GB.json index fa67bbb8..4efaec5d 100644 --- a/lang/en-GB.json +++ b/lang/en-GB.json @@ -37,7 +37,9 @@ "label.custom-range": "Custom range", "label.dashboard": "Dashboard", "label.data": "Data", + "label.date": "Date", "label.date-range": "Date range", + "label.day": "Day", "label.default-date-range": "Default date range", "label.delete": "Delete", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobile", "label.more": "More", + "label.my-websites": "My websites", "label.name": "Name", "label.new-password": "New password", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Page views", "label.pageTitle": "Page title", "label.pages": "Pages", @@ -117,6 +121,7 @@ "label.required": "Required", "label.reset": "Reset", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Save", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "This month", diff --git a/lang/en-US.json b/lang/en-US.json index d83cffb5..b7c77a69 100644 --- a/lang/en-US.json +++ b/lang/en-US.json @@ -37,7 +37,9 @@ "label.custom-range": "Custom range", "label.dashboard": "Dashboard", "label.data": "Data", + "label.date": "Date", "label.date-range": "Date range", + "label.day": "Day", "label.default-date-range": "Default date range", "label.delete": "Delete", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobile", "label.more": "More", + "label.my-websites": "My websites", "label.name": "Name", "label.new-password": "New password", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Page views", "label.pageTitle": "Page title", "label.pages": "Pages", @@ -117,6 +121,7 @@ "label.required": "Required", "label.reset": "Reset", "label.reset-website": "Reset website", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Save", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "This month", @@ -185,7 +192,7 @@ "message.no-data-available": "No data available.", "message.no-event-data": "No event data is available.", "message.no-match-password": "Passwords do not match.", - "message.no-results-found": "No results were found.", + "message.no-results-found": "No results found.", "message.no-team-websites": "This team does not have any websites.", "message.no-teams": "You have not created any teams.", "message.no-users": "There are no users.", diff --git a/lang/es-ES.json b/lang/es-ES.json index cdaf194c..7a401e51 100644 --- a/lang/es-ES.json +++ b/lang/es-ES.json @@ -37,7 +37,9 @@ "label.custom-range": "Intervalo personalizado", "label.dashboard": "Panel de control", "label.data": "Datos", + "label.date": "Date", "label.date-range": "Intervalo de fechas", + "label.day": "Day", "label.default-date-range": "Intervalo por defecto", "label.delete": "Eliminar", "label.delete-team": "Eliminar equipo", @@ -90,12 +92,14 @@ "label.min": "Mín", "label.mobile": "Móvil", "label.more": "Más", + "label.my-websites": "My websites", "label.name": "Nombre", "label.new-password": "Nueva contraseña", "label.none": "Ninguno", "label.os": "OS", "label.overview": "Resumen", "label.owner": "Propietario", + "label.page-of": "Page {current} of {total}", "label.page-views": "Vistas", "label.pageTitle": "Page title", "label.pages": "Páginas", @@ -117,6 +121,7 @@ "label.required": "Obligatorio", "label.reset": "Reiniciar", "label.reset-website": "Reiniciar estadísticas", + "label.retention": "Retention", "label.role": "Rol", "label.run-query": "Ejecutar consulta", "label.save": "Guardar", @@ -133,7 +138,9 @@ "label.team-guest": "Invitado al equipo", "label.team-id": "ID de equipo", "label.team-member": "Miembro del equipo", + "label.team-name": "Team name", "label.team-owner": "Admin. del equipo", + "label.team-websites": "Team websites", "label.teams": "Equipos", "label.theme": "Tema", "label.this-month": "Este mes", diff --git a/lang/es-MX.json b/lang/es-MX.json index f42c9c55..499b2533 100644 --- a/lang/es-MX.json +++ b/lang/es-MX.json @@ -37,7 +37,9 @@ "label.custom-range": "Intervalo personalizado", "label.dashboard": "Panel de control", "label.data": "Datos", + "label.date": "Date", "label.date-range": "Intervalo de fechas", + "label.day": "Day", "label.default-date-range": "Intervalo por defecto", "label.delete": "Eliminar", "label.delete-team": "Eliminar team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Móvil", "label.more": "Más", + "label.my-websites": "My websites", "label.name": "Nombre", "label.new-password": "Nueva contraseña", "label.none": "Ninguno", "label.os": "OS", "label.overview": "Overview", "label.owner": "Propietario", + "label.page-of": "Page {current} of {total}", "label.page-views": "Vistas", "label.pageTitle": "Page title", "label.pages": "Páginas", @@ -117,6 +121,7 @@ "label.required": "Obligatorio", "label.reset": "Reiniciar", "label.reset-website": "Reiniciar estadísticas", + "label.retention": "Retention", "label.role": "Rol", "label.run-query": "Run query", "label.save": "Guardar", @@ -133,7 +138,9 @@ "label.team-guest": "Invitado de equipo", "label.team-id": "ID de equipo", "label.team-member": "Miembro de equipo", + "label.team-name": "Team name", "label.team-owner": "Admin. del equipo", + "label.team-websites": "Team websites", "label.teams": "Equipos", "label.theme": "Tema", "label.this-month": "Este mes", diff --git a/lang/fa-IR.json b/lang/fa-IR.json index 1839cb61..b263a7d1 100644 --- a/lang/fa-IR.json +++ b/lang/fa-IR.json @@ -37,7 +37,9 @@ "label.custom-range": "محدوده‌ی دلخواه", "label.dashboard": "داشبورد", "label.data": "Data", + "label.date": "Date", "label.date-range": "محدوده‌ی تاریخ", + "label.day": "Day", "label.default-date-range": "محدوده‌ی پیشفرض تاریخ", "label.delete": "حذف", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "موبایل", "label.more": "بیشتر", + "label.my-websites": "My websites", "label.name": "نام", "label.new-password": "رمز جدید", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "ایجاد شده توسط", + "label.page-of": "Page {current} of {total}", "label.page-views": "بازدید صفحه", "label.pageTitle": "Page title", "label.pages": "صفحه‌ها", @@ -117,6 +121,7 @@ "label.required": "ضروری", "label.reset": "بازنشانی", "label.reset-website": "بازنشانی آمار", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "ذخیره", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "تم", "label.this-month": "این ماه", diff --git a/lang/fi-FI.json b/lang/fi-FI.json index 31b2f1ee..9e9c1de0 100644 --- a/lang/fi-FI.json +++ b/lang/fi-FI.json @@ -37,7 +37,9 @@ "label.custom-range": "Mukautettu ajanjakso", "label.dashboard": "Ohjauspaneeli", "label.data": "Data", + "label.date": "Date", "label.date-range": "Ajanjakso", + "label.day": "Day", "label.default-date-range": "Oletusajanjakso", "label.delete": "Poista", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Puhelin", "label.more": "Lisää", + "label.my-websites": "My websites", "label.name": "Nimi", "label.new-password": "Uusi salasana", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Omistaja", + "label.page-of": "Page {current} of {total}", "label.page-views": "Sivun näyttökerrat", "label.pageTitle": "Page title", "label.pages": "Sivut", @@ -117,6 +121,7 @@ "label.required": "Vaaditaan", "label.reset": "Nollaa", "label.reset-website": "Nollaa tilastot", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Tallenna", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Teema", "label.this-month": "Tämä kuukausi", diff --git a/lang/fo-FO.json b/lang/fo-FO.json index 030b815f..6259a555 100644 --- a/lang/fo-FO.json +++ b/lang/fo-FO.json @@ -37,7 +37,9 @@ "label.custom-range": "Tillaga spenni", "label.dashboard": "Yvirlitsskíggi", "label.data": "Data", + "label.date": "Date", "label.date-range": "Vel dato", + "label.day": "Day", "label.default-date-range": "Forsett dato", "label.delete": "Sletta", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Telefon", "label.more": "Meira", + "label.my-websites": "My websites", "label.name": "Navn", "label.new-password": "Nýtt loyniorð", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Opnaðar síðir", "label.pageTitle": "Page title", "label.pages": "Síðir", @@ -117,6 +121,7 @@ "label.required": "Kravt", "label.reset": "Nulstilla", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Goym", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Hendan mánan", diff --git a/lang/fr-FR.json b/lang/fr-FR.json index 29ac8728..558c1cd2 100644 --- a/lang/fr-FR.json +++ b/lang/fr-FR.json @@ -37,7 +37,9 @@ "label.custom-range": "Période personnalisée", "label.dashboard": "Tableau de bord", "label.data": "Données", + "label.date": "Date", "label.date-range": "Période", + "label.day": "Day", "label.default-date-range": "Période par défaut", "label.delete": "Supprimer", "label.delete-team": "Supprimer l'équipe", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Téléphone", "label.more": "Plus", + "label.my-websites": "My websites", "label.name": "Nom", "label.new-password": "Nouveau mot de passe", "label.none": "Aucun·e", "label.os": "OS", "label.overview": "Vue d'ensemble", "label.owner": "Propriétaire", + "label.page-of": "Page {current} of {total}", "label.page-views": "Pages vues", "label.pageTitle": "Page title", "label.pages": "Pages", @@ -117,6 +121,7 @@ "label.required": "Requis", "label.reset": "Réinitialiser", "label.reset-website": "Réinitialiser les statistiques", + "label.retention": "Retention", "label.role": "Rôle", "label.run-query": "Éxécuter la requête", "label.save": "Enregistrer", @@ -133,7 +138,9 @@ "label.team-guest": "Invité dans l'équipe", "label.team-id": "ID d'équipe", "label.team-member": "Membre de l'équipe", + "label.team-name": "Team name", "label.team-owner": "Propriétaire de l'équipe", + "label.team-websites": "Team websites", "label.teams": "Équipes", "label.theme": "Thème", "label.this-month": "Ce mois", diff --git a/lang/ga-ES.json b/lang/ga-ES.json index 402f7681..e6ceda8a 100644 --- a/lang/ga-ES.json +++ b/lang/ga-ES.json @@ -37,7 +37,9 @@ "label.custom-range": "Rango personalizado", "label.dashboard": "Taboleiro", "label.data": "Data", + "label.date": "Date", "label.date-range": "Rango temporal", + "label.day": "Day", "label.default-date-range": "Rango temporal por defecto", "label.delete": "Eliminar", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Móbil", "label.more": "Máis", + "label.my-websites": "My websites", "label.name": "Nome", "label.new-password": "Novo contrasinal", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Dona", + "label.page-of": "Page {current} of {total}", "label.page-views": "Vistas de páxinas", "label.pageTitle": "Page title", "label.pages": "Páxinas", @@ -117,6 +121,7 @@ "label.required": "Requerido", "label.reset": "Restablecer", "label.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Gardar", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Decorado", "label.this-month": "Este mes", diff --git a/lang/he-IL.json b/lang/he-IL.json index 5ba91136..fd3e0b8b 100644 --- a/lang/he-IL.json +++ b/lang/he-IL.json @@ -37,7 +37,9 @@ "label.custom-range": "טווח מותאם", "label.dashboard": "דשבורד", "label.data": "Data", + "label.date": "Date", "label.date-range": "טווח תאריכים", + "label.day": "Day", "label.default-date-range": "טווח תאריכים בברירת מחדל", "label.delete": "הסרה", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "מובייל", "label.more": "עוד", + "label.my-websites": "My websites", "label.name": "שם", "label.new-password": "סיסמה חדשה", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "צפיות בדפים", "label.pageTitle": "Page title", "label.pages": "דפים", @@ -117,6 +121,7 @@ "label.required": "נדרש", "label.reset": "איפוס", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "שמירה", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "החודש", diff --git a/lang/hi-IN.json b/lang/hi-IN.json index e57b6865..6e268aa6 100644 --- a/lang/hi-IN.json +++ b/lang/hi-IN.json @@ -37,7 +37,9 @@ "label.custom-range": "कस्टम रेंज", "label.dashboard": "नियंत्रण-पट्ट", "label.data": "Data", + "label.date": "Date", "label.date-range": "तिथि सीमा", + "label.day": "Day", "label.default-date-range": "डिफ़ॉल्ट तिथि सीमा", "label.delete": "खाता हटाएं", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "मोबाइल फोन", "label.more": "और", + "label.my-websites": "My websites", "label.name": "नाम", "label.new-password": "नया पासवर्ड", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "पृष्ठ दृश्य", "label.pageTitle": "Page title", "label.pages": "पृष्ठों", @@ -117,6 +121,7 @@ "label.required": "अपेक्षित", "label.reset": "रीसेट", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "सहेजें", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "इस महीने", diff --git a/lang/hr-HR.json b/lang/hr-HR.json index 805d820e..ecde7100 100644 --- a/lang/hr-HR.json +++ b/lang/hr-HR.json @@ -37,7 +37,9 @@ "label.custom-range": "Prilagođeni raspon", "label.dashboard": "Nadzorna ploča", "label.data": "Data", + "label.date": "Date", "label.date-range": "Raspon datuma", + "label.day": "Day", "label.default-date-range": "Zadani datumski raspon", "label.delete": "Obriši", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobile", "label.more": "Više", + "label.my-websites": "My websites", "label.name": "Ime", "label.new-password": "Nova lozinka", "label.none": "Ništa", "label.os": "OS", "label.overview": "Overview", "label.owner": "Vlasnik", + "label.page-of": "Page {current} of {total}", "label.page-views": "Page views", "label.pageTitle": "Page title", "label.pages": "Pages", @@ -117,6 +121,7 @@ "label.required": "Potrebna", "label.reset": "Resetirati", "label.reset-website": "Resetirati web stranicu", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Spremi", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Tema", "label.this-month": "Ovaj mjesec", diff --git a/lang/hu-HU.json b/lang/hu-HU.json index 96512e15..0401afff 100644 --- a/lang/hu-HU.json +++ b/lang/hu-HU.json @@ -37,7 +37,9 @@ "label.custom-range": "Egyedi tartomány", "label.dashboard": "Áttekintés", "label.data": "Data", + "label.date": "Date", "label.date-range": "Időintervallum", + "label.day": "Day", "label.default-date-range": "Alapértelmezett időintervallum", "label.delete": "Eltávolítás", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Telefon", "label.more": "Bővebben", + "label.my-websites": "My websites", "label.name": "Név", "label.new-password": "Új jelszó", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Oldalmegtekintések", "label.pageTitle": "Page title", "label.pages": "Oldalak", @@ -117,6 +121,7 @@ "label.required": "Kötelező", "label.reset": "Visszaállítás", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Mentés", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Ezen hónap", diff --git a/lang/id-ID.json b/lang/id-ID.json index 0d81b55c..d0b8a064 100644 --- a/lang/id-ID.json +++ b/lang/id-ID.json @@ -37,7 +37,9 @@ "label.custom-range": "Rentang khusus", "label.dashboard": "Dasbor", "label.data": "Data", + "label.date": "Date", "label.date-range": "Rentang tanggal", + "label.day": "Day", "label.default-date-range": "Rentang tanggal bawaan", "label.delete": "Hapus", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Ponsel", "label.more": "Lebih banyak", + "label.my-websites": "My websites", "label.name": "Nama", "label.new-password": "Kata sandi baru", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Pemilik", + "label.page-of": "Page {current} of {total}", "label.page-views": "Tampilan halaman", "label.pageTitle": "Page title", "label.pages": "Halaman", @@ -117,6 +121,7 @@ "label.required": "Wajib", "label.reset": "Atur ulang", "label.reset-website": "Atur ulang statistik", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Simpan", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Tema", "label.this-month": "Bulan ini", diff --git a/lang/it-IT.json b/lang/it-IT.json index 0d664767..57d6d5ba 100644 --- a/lang/it-IT.json +++ b/lang/it-IT.json @@ -37,7 +37,9 @@ "label.custom-range": "Personalizzato", "label.dashboard": "Pannello di Controllo", "label.data": "Data", + "label.date": "Date", "label.date-range": "Periodo", + "label.day": "Day", "label.default-date-range": "Periodo standard", "label.delete": "Elimina", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Cellulare", "label.more": "Dettagli", + "label.my-websites": "My websites", "label.name": "Nome", "label.new-password": "Nuova password", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Proprietario", + "label.page-of": "Page {current} of {total}", "label.page-views": "Visualizzazioni di pagina", "label.pageTitle": "Page title", "label.pages": "Pagine", @@ -117,6 +121,7 @@ "label.required": "Obbligatorio", "label.reset": "Reset", "label.reset-website": "Resetta le statistiche", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Salva", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Tema", "label.this-month": "Questo mese", diff --git a/lang/ja-JP.json b/lang/ja-JP.json index 1e1dd7f3..0f4d5450 100644 --- a/lang/ja-JP.json +++ b/lang/ja-JP.json @@ -37,7 +37,9 @@ "label.custom-range": "期間を指定する", "label.dashboard": "ダッシュボード", "label.data": "Data", + "label.date": "Date", "label.date-range": "範囲指定", + "label.day": "Day", "label.default-date-range": "最初に表示する期間", "label.delete": "削除", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "携帯電話", "label.more": "さらに表示", + "label.my-websites": "My websites", "label.name": "名前", "label.new-password": "新しいパスワード", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "閲覧数", "label.pageTitle": "Page title", "label.pages": "ページ", @@ -117,6 +121,7 @@ "label.required": "必須", "label.reset": "リセット", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "保存", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "今月", diff --git a/lang/km-KH.json b/lang/km-KH.json index d5c8af6b..58f7926f 100644 --- a/lang/km-KH.json +++ b/lang/km-KH.json @@ -37,7 +37,9 @@ "label.custom-range": "កំណត់ដោយខ្លួនឯង", "label.dashboard": "ផ្ទាំងគ្រប់គ្រង", "label.data": "Data", + "label.date": "Date", "label.date-range": "ចន្លោះកាលបរិច្ឆេទ", + "label.day": "Day", "label.default-date-range": "ចន្លោះកាលបរិច្ឆេទស្រាប់", "label.delete": "លុប", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "ទូរស័ព្ទចល័ត", "label.more": "បន្ថែម", + "label.my-websites": "My websites", "label.name": "ឈ្មោះ", "label.new-password": "ពាក្យសម្ងាត់​ថ្មី", "label.none": "មិនមាន", "label.os": "OS", "label.overview": "Overview", "label.owner": "ម្ចាស់", + "label.page-of": "Page {current} of {total}", "label.page-views": "អ្នកមើលទំព័រ", "label.pageTitle": "Page title", "label.pages": "ទំព័រ", @@ -117,6 +121,7 @@ "label.required": "ទាមទារ", "label.reset": "កំណត់ឡើងវិញ", "label.reset-website": "កំណត់ស្ថិតិឡើងវិញ", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "រក្សាទុក", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "រូបរាង", "label.this-month": "ខែនេះ", diff --git a/lang/ko-KR.json b/lang/ko-KR.json index 66e03632..767e8e22 100644 --- a/lang/ko-KR.json +++ b/lang/ko-KR.json @@ -37,7 +37,9 @@ "label.custom-range": "범위 지정", "label.dashboard": "대시보드", "label.data": "Data", + "label.date": "Date", "label.date-range": "날짜 범위", + "label.day": "Day", "label.default-date-range": "기본 날짜 범위", "label.delete": "삭제", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "모바일", "label.more": "더 보기", + "label.my-websites": "My websites", "label.name": "이름", "label.new-password": "새 비밀번호", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "페이지 뷰(PV)", "label.pageTitle": "Page title", "label.pages": "페이지", @@ -117,6 +121,7 @@ "label.required": "필수", "label.reset": "리셋", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "저장", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "이번 달", diff --git a/lang/lt-LT.json b/lang/lt-LT.json index a90dbb68..c8161f1d 100644 --- a/lang/lt-LT.json +++ b/lang/lt-LT.json @@ -37,7 +37,9 @@ "label.custom-range": "Pasirinktinis intervalas", "label.dashboard": "Švieslentė", "label.data": "Data", + "label.date": "Date", "label.date-range": "Laikotarpis", + "label.day": "Day", "label.default-date-range": "Numatytasis laikotarpis", "label.delete": "Ištrinti", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobilusis", "label.more": "Daugiau", + "label.my-websites": "My websites", "label.name": "Pavadinimas", "label.new-password": "Naujas slaptažodis", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Savininkas", + "label.page-of": "Page {current} of {total}", "label.page-views": "Puslapių peržiūros", "label.pageTitle": "Page title", "label.pages": "Puslapiai", @@ -117,6 +121,7 @@ "label.required": "Reikalinga", "label.reset": "Atstatyti", "label.reset-website": "Atstatyti statistikos duomenis", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Išsaugoti", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Šis mėnuo", diff --git a/lang/mn-MN.json b/lang/mn-MN.json index 99efcd3e..1478c079 100644 --- a/lang/mn-MN.json +++ b/lang/mn-MN.json @@ -37,7 +37,9 @@ "label.custom-range": "Дурын хугацаа", "label.dashboard": "Хянах самбар", "label.data": "Өгөгдөл", + "label.date": "Date", "label.date-range": "Хугацааны муж", + "label.day": "Day", "label.default-date-range": "Өгөгдмөл хугацааны муж", "label.delete": "Устгах", "label.delete-team": "Баг устгах", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Утас", "label.more": "Цааш", + "label.my-websites": "My websites", "label.name": "Нэр", "label.new-password": "Шинэ нууц үг", "label.none": "Байхгүй", "label.os": "OS", "label.overview": "Overview", "label.owner": "Эзэмшигч", + "label.page-of": "Page {current} of {total}", "label.page-views": "Хуудас үзсэн", "label.pageTitle": "Page title", "label.pages": "Хуудас", @@ -117,6 +121,7 @@ "label.required": "Шаардлагатай", "label.reset": "Дахин эхлүүлэх", "label.reset-website": "Тоон үзүүлэлтийг дахин эхлүүлэх", + "label.retention": "Retention", "label.role": "Эрх", "label.run-query": "Run query", "label.save": "Хадгалах", @@ -133,7 +138,9 @@ "label.team-guest": "Багийн зочин", "label.team-id": "Багийн ID", "label.team-member": "Багийн гишүүн", + "label.team-name": "Team name", "label.team-owner": "Багийн эзэмшигч", + "label.team-websites": "Team websites", "label.teams": "Багууд", "label.theme": "Загвар", "label.this-month": "Энэ сар", diff --git a/lang/ms-MY.json b/lang/ms-MY.json index 53de5477..5b8769c5 100644 --- a/lang/ms-MY.json +++ b/lang/ms-MY.json @@ -37,7 +37,9 @@ "label.custom-range": "Julat khas", "label.dashboard": "Papan pemuka", "label.data": "Data", + "label.date": "Date", "label.date-range": "Julat tarikh", + "label.day": "Day", "label.default-date-range": "Julat tarikh lalai", "label.delete": "Padam", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Telefon bimbit", "label.more": "Lebih banyak lagi", + "label.my-websites": "My websites", "label.name": "Nama", "label.new-password": "Kata laluan baru", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Paparan halaman", "label.pageTitle": "Page title", "label.pages": "Halaman", @@ -117,6 +121,7 @@ "label.required": "Diperlukan", "label.reset": "Tetapkan semula", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Simpan", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Bulan ini", diff --git a/lang/nb-NO.json b/lang/nb-NO.json index 59210425..654c3c79 100644 --- a/lang/nb-NO.json +++ b/lang/nb-NO.json @@ -37,7 +37,9 @@ "label.custom-range": "Egendefinert utvalg", "label.dashboard": "Dashbord", "label.data": "Data", + "label.date": "Date", "label.date-range": "Datointervall", + "label.day": "Day", "label.default-date-range": "Standard datoperiode", "label.delete": "Slett", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobiltelefon", "label.more": "Mer", + "label.my-websites": "My websites", "label.name": "Navn", "label.new-password": "Nytt passord", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Eier", + "label.page-of": "Page {current} of {total}", "label.page-views": "Sidevisninger", "label.pageTitle": "Page title", "label.pages": "Sider", @@ -117,6 +121,7 @@ "label.required": "Påkrevd", "label.reset": "Nullstill", "label.reset-website": "Nullstill statistikk", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Lagre", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Denne måneden", diff --git a/lang/nl-NL.json b/lang/nl-NL.json index eb01cebf..ad30cf36 100644 --- a/lang/nl-NL.json +++ b/lang/nl-NL.json @@ -37,7 +37,9 @@ "label.custom-range": "Aangepast bereik", "label.dashboard": "Overzicht", "label.data": "Gegevens", + "label.date": "Date", "label.date-range": "Datumbereik", + "label.day": "Day", "label.default-date-range": "Standaard bereik", "label.delete": "Verwijderen", "label.delete-team": "Team verwijderen", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobiel", "label.more": "Toon meer", + "label.my-websites": "My websites", "label.name": "Naam", "label.new-password": "Nieuw wachtwoord", "label.none": "Geen", "label.os": "OS", "label.overview": "Overview", "label.owner": "Eigenaar", + "label.page-of": "Page {current} of {total}", "label.page-views": "Paginaweergaven", "label.pageTitle": "Page title", "label.pages": "Pagina's", @@ -117,6 +121,7 @@ "label.required": "Verplicht", "label.reset": "Opnieuw instellen", "label.reset-website": "Statistieken opnieuw instellen", + "label.retention": "Retention", "label.role": "Gebruikersrol", "label.run-query": "Run query", "label.save": "Opslaan", @@ -133,7 +138,9 @@ "label.team-guest": "Team gast", "label.team-id": "Team ID", "label.team-member": "Teamlid", + "label.team-name": "Team name", "label.team-owner": "Teameigenaar", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Thema", "label.this-month": "Deze maand", diff --git a/lang/pl-PL.json b/lang/pl-PL.json index c56cccc4..eb940613 100644 --- a/lang/pl-PL.json +++ b/lang/pl-PL.json @@ -37,7 +37,9 @@ "label.custom-range": "Zakres niestandardowy", "label.dashboard": "Panel", "label.data": "Data", + "label.date": "Date", "label.date-range": "Zakres dat", + "label.day": "Day", "label.default-date-range": "Domyślny zakres dat", "label.delete": "Usuń", "label.delete-team": "Usuń zespół", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Smartfon", "label.more": "Więcej", + "label.my-websites": "My websites", "label.name": "Nazwa", "label.new-password": "Nowe hasło", "label.none": "Brak", "label.os": "OS", "label.overview": "Przegląd", "label.owner": "Właściciel", + "label.page-of": "Page {current} of {total}", "label.page-views": "Wyświetlenia strony", "label.pageTitle": "Page title", "label.pages": "Strony", @@ -117,6 +121,7 @@ "label.required": "Wymagany", "label.reset": "Zresetuj", "label.reset-website": "Zresetuj statystyki", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Uruchom zapytanie", "label.save": "Zapisz", @@ -133,7 +138,9 @@ "label.team-guest": "Gość zespołu", "label.team-id": "ID zespołu", "label.team-member": "Członek zespołu", + "label.team-name": "Team name", "label.team-owner": "Właściciel zespołu", + "label.team-websites": "Team websites", "label.teams": "Zespoły", "label.theme": "Motyw", "label.this-month": "W tym miesiącu", diff --git a/lang/pt-BR.json b/lang/pt-BR.json index dbaa97b1..b68d9615 100644 --- a/lang/pt-BR.json +++ b/lang/pt-BR.json @@ -37,7 +37,9 @@ "label.custom-range": "Intervalo personalizado", "label.dashboard": "Painel", "label.data": "Data", + "label.date": "Date", "label.date-range": "Intervalo de datas", + "label.day": "Day", "label.default-date-range": "Intervalo de datas predefinido", "label.delete": "Remover", "label.delete-team": "Remover time", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Celular", "label.more": "Mais", + "label.my-websites": "My websites", "label.name": "Nome", "label.new-password": "Nova senha", "label.none": "Nenhum", "label.os": "OS", "label.overview": "Overview", "label.owner": "Proprietário", + "label.page-of": "Page {current} of {total}", "label.page-views": "Visualizações de página", "label.pageTitle": "Page title", "label.pages": "Páginas", @@ -117,6 +121,7 @@ "label.required": "Obrigatório", "label.reset": "Redefinir", "label.reset-website": "Redefinir estatísticas", + "label.retention": "Retention", "label.role": "Papel", "label.run-query": "Executar query", "label.save": "Salvar", @@ -133,7 +138,9 @@ "label.team-guest": "Convidado", "label.team-id": "ID do Time", "label.team-member": "Membro", + "label.team-name": "Team name", "label.team-owner": "Proprietário", + "label.team-websites": "Team websites", "label.teams": "Times", "label.theme": "Tema", "label.this-month": "Este mês", diff --git a/lang/pt-PT.json b/lang/pt-PT.json index faf9185c..fcf7ff03 100644 --- a/lang/pt-PT.json +++ b/lang/pt-PT.json @@ -37,7 +37,9 @@ "label.custom-range": "Intervalo personalizado", "label.dashboard": "Painel", "label.data": "Data", + "label.date": "Date", "label.date-range": "Intervalo de datas", + "label.day": "Day", "label.default-date-range": "Intervalo de datas predefinido", "label.delete": "Eliminar", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Telemóvel", "label.more": "Mais", + "label.my-websites": "My websites", "label.name": "Nome", "label.new-password": "Nova senha", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Proprietário", + "label.page-of": "Page {current} of {total}", "label.page-views": "Visualizações da página", "label.pageTitle": "Page title", "label.pages": "Páginas", @@ -117,6 +121,7 @@ "label.required": "Obrigatório", "label.reset": "Repor", "label.reset-website": "Repor estatísticas", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Guardar", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Tema", "label.this-month": "Este mês", diff --git a/lang/ro-RO.json b/lang/ro-RO.json index 45fc73d7..43a78ecd 100644 --- a/lang/ro-RO.json +++ b/lang/ro-RO.json @@ -37,7 +37,9 @@ "label.custom-range": "Interval personalizat", "label.dashboard": "Tablou de bord", "label.data": "Data", + "label.date": "Date", "label.date-range": "Interval de date", + "label.day": "Day", "label.default-date-range": "Interval de date implicit", "label.delete": "Șterge", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobil", "label.more": "Mai mult", + "label.my-websites": "My websites", "label.name": "Nume", "label.new-password": "Parola nouă", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Vizualizări de pagină", "label.pageTitle": "Page title", "label.pages": "Pagini", @@ -117,6 +121,7 @@ "label.required": "Obligatoriu", "label.reset": "Resetează", "label.reset-website": "Resetează statisticile pentru site", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Salvează", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Această lună", diff --git a/lang/ru-RU.json b/lang/ru-RU.json index 80cf0da8..b9129beb 100644 --- a/lang/ru-RU.json +++ b/lang/ru-RU.json @@ -37,7 +37,9 @@ "label.custom-range": "Другой период", "label.dashboard": "Информационная панель", "label.data": "Данные", + "label.date": "Date", "label.date-range": "Диапазон дат", + "label.day": "Day", "label.default-date-range": "Диапазон дат по-умолчанию", "label.delete": "Удалить", "label.delete-team": "Удалить команду", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Смартфон", "label.more": "Больше", + "label.my-websites": "My websites", "label.name": "Имя", "label.new-password": "Новый пароль", "label.none": "Не указано", "label.os": "OS", "label.overview": "Overview", "label.owner": "Владелец", + "label.page-of": "Page {current} of {total}", "label.page-views": "Просмотры страниц", "label.pageTitle": "Page title", "label.pages": "Страницы", @@ -117,6 +121,7 @@ "label.required": "Обязательное", "label.reset": "Сбросить", "label.reset-website": "Сбросить статистику", + "label.retention": "Retention", "label.role": "Роль", "label.run-query": "Run query", "label.save": "Сохранить", @@ -133,7 +138,9 @@ "label.team-guest": "Гость команды", "label.team-id": "ID команды", "label.team-member": "Член команды", + "label.team-name": "Team name", "label.team-owner": "Владелец команды", + "label.team-websites": "Team websites", "label.teams": "Команды", "label.theme": "Тема", "label.this-month": "Этот месяц", diff --git a/lang/si-LK.json b/lang/si-LK.json index f656cc00..6f6dda6d 100644 --- a/lang/si-LK.json +++ b/lang/si-LK.json @@ -37,7 +37,9 @@ "label.custom-range": "අභිරුචි පරාසය", "label.dashboard": "උපකරණ පුවරුව", "label.data": "Data", + "label.date": "Date", "label.date-range": "දින පරාසය", + "label.day": "Day", "label.default-date-range": "පෙරනිමි දින පරාසය", "label.delete": "මකන්න", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobile", "label.more": "තවත්", + "label.my-websites": "My websites", "label.name": "නම", "label.new-password": "අලුත් මුරපදය", "label.none": "කිසිවක් නැත", "label.os": "OS", "label.overview": "Overview", "label.owner": "හිමිකරු", + "label.page-of": "Page {current} of {total}", "label.page-views": "Page views", "label.pageTitle": "Page title", "label.pages": "Pages", @@ -117,6 +121,7 @@ "label.required": "අවශ්‍යයි", "label.reset": "යළි පිහිටුවන්න", "label.reset-website": "සංඛ්යා ලේඛන නැවත සකසන්න", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "සුරකින්න", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "තේමාව", "label.this-month": "මෙ මාසය", diff --git a/lang/sk-SK.json b/lang/sk-SK.json index 49301b79..3f033923 100644 --- a/lang/sk-SK.json +++ b/lang/sk-SK.json @@ -37,7 +37,9 @@ "label.custom-range": "Vlastný rozsah", "label.dashboard": "Prehlad", "label.data": "Data", + "label.date": "Date", "label.date-range": "Obdobie", + "label.day": "Day", "label.default-date-range": "Predvolené obdobie", "label.delete": "Zmazať", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobilný telefon", "label.more": "Viac", + "label.my-websites": "My websites", "label.name": "Meno", "label.new-password": "Nové heslo", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Zobrazenie stánok", "label.pageTitle": "Page title", "label.pages": "Stránky", @@ -117,6 +121,7 @@ "label.required": "Povinné", "label.reset": "Reset", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Uložiť", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Tento mesiac", diff --git a/lang/sl-SI.json b/lang/sl-SI.json index faffc4c1..aae7888d 100644 --- a/lang/sl-SI.json +++ b/lang/sl-SI.json @@ -37,7 +37,9 @@ "label.custom-range": "Razpon po meri", "label.dashboard": "Nadzorna plošča", "label.data": "Data", + "label.date": "Date", "label.date-range": "Časovni razpon", + "label.day": "Day", "label.default-date-range": "Privzeti časovni razpon", "label.delete": "Izbriši", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobilni telefon", "label.more": "Več", + "label.my-websites": "My websites", "label.name": "Ime", "label.new-password": "Novo geslo", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Ogledi strani", "label.pageTitle": "Page title", "label.pages": "Strani", @@ -117,6 +121,7 @@ "label.required": "Zahtevano", "label.reset": "Ponastavi", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Shrani", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Ta mesec", diff --git a/lang/sv-SE.json b/lang/sv-SE.json index b07bffc6..e6abb5bf 100644 --- a/lang/sv-SE.json +++ b/lang/sv-SE.json @@ -37,7 +37,9 @@ "label.custom-range": "Anpassat urval", "label.dashboard": "Översikt", "label.data": "Data", + "label.date": "Date", "label.date-range": "Datumomfång", + "label.day": "Day", "label.default-date-range": "Standard datum-urval", "label.delete": "Radera", "label.delete-team": "Radera team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobil", "label.more": "Mer", + "label.my-websites": "My websites", "label.name": "Namn", "label.new-password": "Nytt lösenord", "label.none": "Inga", "label.os": "OS", "label.overview": "Overview", "label.owner": "Ägare", + "label.page-of": "Page {current} of {total}", "label.page-views": "Sidvisningar", "label.pageTitle": "Page title", "label.pages": "Sidor", @@ -117,6 +121,7 @@ "label.required": "Krävs", "label.reset": "Återställ", "label.reset-website": "Återställ statistik", + "label.retention": "Retention", "label.role": "Roll", "label.run-query": "Run query", "label.save": "Spara", @@ -133,7 +138,9 @@ "label.team-guest": "Team-gäst", "label.team-id": "Team ID", "label.team-member": "Team-medlem", + "label.team-name": "Team name", "label.team-owner": "Team-ägare", + "label.team-websites": "Team websites", "label.teams": "Team", "label.theme": "Tema", "label.this-month": "Denna månad", diff --git a/lang/ta-IN.json b/lang/ta-IN.json index 5a2d9458..be3d5e81 100644 --- a/lang/ta-IN.json +++ b/lang/ta-IN.json @@ -37,7 +37,9 @@ "label.custom-range": "தனிப்பயன் வேறுபாட்டெல்லை", "label.dashboard": "முகப்பு", "label.data": "Data", + "label.date": "Date", "label.date-range": "தேதி வரம்பு", + "label.day": "Day", "label.default-date-range": "இயல்புநிலை தேதி வரம்பு", "label.delete": "அழி", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "கைபேசி", "label.more": "மேலும்", + "label.my-websites": "My websites", "label.name": "பெயர்", "label.new-password": "புதிய கடவுச்சொல்", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "பக்க காட்சிகள்", "label.pageTitle": "Page title", "label.pages": "பக்கங்கள்", @@ -117,6 +121,7 @@ "label.required": "தேவையானவை", "label.reset": "மீட்டமை", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "சேமி", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "இந்த மாதம்", diff --git a/lang/th-TH.json b/lang/th-TH.json index 949b79d3..43f2f758 100644 --- a/lang/th-TH.json +++ b/lang/th-TH.json @@ -37,7 +37,9 @@ "label.custom-range": "กำหนดช่วงเวลา", "label.dashboard": "แดชบอร์ด", "label.data": "Data", + "label.date": "Date", "label.date-range": "ตั้งแต่วันที่", + "label.day": "Day", "label.default-date-range": "ช่วงเวลา", "label.delete": "ลบ", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "โทรศัพท์มือถือ", "label.more": "เพิ่มเติม", + "label.my-websites": "My websites", "label.name": "ชื่อ", "label.new-password": "รหัสผ่านใหม่", "label.none": "ไม่ได้กำหนด", "label.os": "OS", "label.overview": "Overview", "label.owner": "เจ้าของ", + "label.page-of": "Page {current} of {total}", "label.page-views": "การเข้าชม", "label.pageTitle": "Page title", "label.pages": "หน้าเพจ", @@ -117,6 +121,7 @@ "label.required": "ต้องการ", "label.reset": "รีเซต", "label.reset-website": "รีเซตข้อมูลสถิติ", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "บันทึก", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "ธีม", "label.this-month": "เดือนปัจจุบัน", diff --git a/lang/tr-TR.json b/lang/tr-TR.json index 3ec44722..0ec10e0b 100644 --- a/lang/tr-TR.json +++ b/lang/tr-TR.json @@ -37,7 +37,9 @@ "label.custom-range": "Özelleştirilmiş aralık", "label.dashboard": "Kontrol Paneli", "label.data": "Data", + "label.date": "Date", "label.date-range": "Tarih aralığı", + "label.day": "Day", "label.default-date-range": "Varsayılan tarih aralığı", "label.delete": "Sil", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Mobil Cihaz", "label.more": "Detaylı göster", + "label.my-websites": "My websites", "label.name": "İsim", "label.new-password": "Yeni parola", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Owner", + "label.page-of": "Page {current} of {total}", "label.page-views": "Sayfa görünümü", "label.pageTitle": "Page title", "label.pages": "Sayfalar", @@ -117,6 +121,7 @@ "label.required": "Zorunlu alan", "label.reset": "Sıfırla", "label.reset-website": "Reset statistics", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Kaydet", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Bu ay", diff --git a/lang/uk-UA.json b/lang/uk-UA.json index 29a89c0a..89079eff 100644 --- a/lang/uk-UA.json +++ b/lang/uk-UA.json @@ -37,7 +37,9 @@ "label.custom-range": "Довільний період", "label.dashboard": "Інформаційна панель", "label.data": "Data", + "label.date": "Date", "label.date-range": "Діапазон дат", + "label.day": "Day", "label.default-date-range": "Діапазон дат за замовчуванням", "label.delete": "Видалити", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Мобільний", "label.more": "Більше", + "label.my-websites": "My websites", "label.name": "Ім'я", "label.new-password": "Новий пароль", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Власник", + "label.page-of": "Page {current} of {total}", "label.page-views": "Перегляди сторінок", "label.pageTitle": "Page title", "label.pages": "Сторінки", @@ -117,6 +121,7 @@ "label.required": "Обов'язкове", "label.reset": "Скинути", "label.reset-website": "Скинути статистику сайту", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Зберегти", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "Цього місяця", diff --git a/lang/ur-PK.json b/lang/ur-PK.json index 582ba248..4d585dcb 100644 --- a/lang/ur-PK.json +++ b/lang/ur-PK.json @@ -37,7 +37,9 @@ "label.custom-range": "اپنی مرضی کی حد", "label.dashboard": "ڈیش بورڈ", "label.data": "Data", + "label.date": "Date", "label.date-range": "تاریخ کی حد", + "label.day": "Day", "label.default-date-range": "پہلے سے طے شدہ تاریخ کی حد", "label.delete": "حذف کریں", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "موبائل", "label.more": "مزید", + "label.my-websites": "My websites", "label.name": "نام", "label.new-password": "نیا پاس ورڈ", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "مالک", + "label.page-of": "Page {current} of {total}", "label.page-views": "صفحہ کے نظارے", "label.pageTitle": "Page title", "label.pages": "صفحات", @@ -117,6 +121,7 @@ "label.required": "درکار ہے", "label.reset": "دوبارہ ترتیب دیں", "label.reset-website": "اعدادوشمار کو دوبارہ ترتیب دیں", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "محفوظ کریں", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Theme", "label.this-month": "اس مہینے", diff --git a/lang/vi-VN.json b/lang/vi-VN.json index 46791971..e9bce2d3 100644 --- a/lang/vi-VN.json +++ b/lang/vi-VN.json @@ -37,7 +37,9 @@ "label.custom-range": "Phạm vi ngày tuỳ chọn", "label.dashboard": "Bảng điều khiển", "label.data": "Data", + "label.date": "Date", "label.date-range": "Phạm vi ngày", + "label.day": "Day", "label.default-date-range": "Khoảng thời gian mặc định", "label.delete": "Xoá", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "Di động", "label.more": "Thêm", + "label.my-websites": "My websites", "label.name": "Tên", "label.new-password": "Mật khẩu mới", "label.none": "None", "label.os": "OS", "label.overview": "Overview", "label.owner": "Chủ sở hữu", + "label.page-of": "Page {current} of {total}", "label.page-views": "Lượt xem", "label.pageTitle": "Page title", "label.pages": "Trang", @@ -117,6 +121,7 @@ "label.required": "Yêu cầu", "label.reset": "Tái thiết lập", "label.reset-website": "Tái thiết lập thống kê", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "Lưu", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "Giao diện", "label.this-month": "Tháng này", diff --git a/lang/zh-CN.json b/lang/zh-CN.json index 274e1f69..f26b3051 100644 --- a/lang/zh-CN.json +++ b/lang/zh-CN.json @@ -37,7 +37,9 @@ "label.custom-range": "自定义时间段", "label.dashboard": "仪表板", "label.data": "统计数据", + "label.date": "Date", "label.date-range": "时间段", + "label.day": "Day", "label.default-date-range": "默认时间段", "label.delete": "删除", "label.delete-team": "删除团队", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "手机", "label.more": "更多", + "label.my-websites": "My websites", "label.name": "名字", "label.new-password": "新密码", "label.none": "无", "label.os": "OS", "label.overview": "Overview", "label.owner": "所有者", + "label.page-of": "Page {current} of {total}", "label.page-views": "页面浏览量", "label.pageTitle": "Page title", "label.pages": "网页", @@ -117,6 +121,7 @@ "label.required": "必填", "label.reset": "重置", "label.reset-website": "重置统计数据", + "label.retention": "Retention", "label.role": "角色", "label.run-query": "Run query", "label.save": "保存", @@ -133,7 +138,9 @@ "label.team-guest": "团队访客", "label.team-id": "团队 ID", "label.team-member": "团队成员", + "label.team-name": "Team name", "label.team-owner": "团队所有者", + "label.team-websites": "Team websites", "label.teams": "团队", "label.theme": "主题", "label.this-month": "本月", diff --git a/lang/zh-TW.json b/lang/zh-TW.json index 03fcd00b..c5761150 100644 --- a/lang/zh-TW.json +++ b/lang/zh-TW.json @@ -37,7 +37,9 @@ "label.custom-range": "自定義時段", "label.dashboard": "管理面板", "label.data": "Data", + "label.date": "Date", "label.date-range": "多日", + "label.day": "Day", "label.default-date-range": "默認日期範圍", "label.delete": "刪除", "label.delete-team": "Delete team", @@ -90,12 +92,14 @@ "label.min": "Min", "label.mobile": "手機", "label.more": "更多", + "label.my-websites": "My websites", "label.name": "名字", "label.new-password": "新密碼", "label.none": "無", "label.os": "OS", "label.overview": "Overview", "label.owner": "擁有者", + "label.page-of": "Page {current} of {total}", "label.page-views": "網頁流量", "label.pageTitle": "Page title", "label.pages": "網頁", @@ -117,6 +121,7 @@ "label.required": "必填", "label.reset": "重置", "label.reset-website": "重置統計數據", + "label.retention": "Retention", "label.role": "Role", "label.run-query": "Run query", "label.save": "保存", @@ -133,7 +138,9 @@ "label.team-guest": "Team guest", "label.team-id": "Team ID", "label.team-member": "Team member", + "label.team-name": "Team name", "label.team-owner": "Team owner", + "label.team-websites": "Team websites", "label.teams": "Teams", "label.theme": "主題", "label.this-month": "本月", diff --git a/public/intl/messages/am-ET.json b/public/intl/messages/am-ET.json index eec8866b..f48fe83c 100644 --- a/public/intl/messages/am-ET.json +++ b/public/intl/messages/am-ET.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Date range" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "More" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ar-SA.json b/public/intl/messages/ar-SA.json index 9b511914..a9a12404 100644 --- a/public/intl/messages/ar-SA.json +++ b/public/intl/messages/ar-SA.json @@ -227,12 +227,24 @@ "value": "البيانات" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "فترة مخصصة" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "المزيد" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "المالك" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "اعادة تعيين الإحصائيات" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "عضو المجموعة" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "مدير المجموعة" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/be-BY.json b/public/intl/messages/be-BY.json index a267a722..4978aa45 100644 --- a/public/intl/messages/be-BY.json +++ b/public/intl/messages/be-BY.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Дыяпазон дат" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Болей" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Уласнік" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Скінуць статыстыку" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/bn-BD.json b/public/intl/messages/bn-BD.json index c2b9dca8..938f6f98 100644 --- a/public/intl/messages/bn-BD.json +++ b/public/intl/messages/bn-BD.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "তারিখের পরিসীমা" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "আরও" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "মালিক" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "ওয়েবসাইট রিসেট করুন" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ca-ES.json b/public/intl/messages/ca-ES.json index 91996324..694b49c2 100644 --- a/public/intl/messages/ca-ES.json +++ b/public/intl/messages/ca-ES.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Interval de dates" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Més" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Propietari" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Restableix estadístiques" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/cs-CZ.json b/public/intl/messages/cs-CZ.json index a8469a04..3fd34c31 100644 --- a/public/intl/messages/cs-CZ.json +++ b/public/intl/messages/cs-CZ.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Období" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Více" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/da-DK.json b/public/intl/messages/da-DK.json index 325d1a65..d8da1c3e 100644 --- a/public/intl/messages/da-DK.json +++ b/public/intl/messages/da-DK.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Datointerval" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mere" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Ejer" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Nulstil statistikker" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/de-CH.json b/public/intl/messages/de-CH.json index fc6fad59..aa0b2d94 100644 --- a/public/intl/messages/de-CH.json +++ b/public/intl/messages/de-CH.json @@ -227,12 +227,24 @@ "value": "Datä" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Datumsbereich" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Meh" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Bsitzer" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Statistik zruggsetze" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team Mitglied" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team Bsitzer" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/de-DE.json b/public/intl/messages/de-DE.json index 035f61d1..136cd31d 100644 --- a/public/intl/messages/de-DE.json +++ b/public/intl/messages/de-DE.json @@ -227,12 +227,24 @@ "value": "Daten" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Datumsbereich" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mehr" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Besitzer" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Statistik zurücksetzen" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team Mitglied" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team Eigentümer" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/el-GR.json b/public/intl/messages/el-GR.json index b1caa817..d3ff5e42 100644 --- a/public/intl/messages/el-GR.json +++ b/public/intl/messages/el-GR.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Εύρος ημερομηνιών" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Περισσότερα" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/en-GB.json b/public/intl/messages/en-GB.json index 0894c6ec..0e6ac614 100644 --- a/public/intl/messages/en-GB.json +++ b/public/intl/messages/en-GB.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Date range" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "More" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index ca2a683f..64a99ae1 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Date range" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "More" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset website" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, @@ -1230,7 +1284,7 @@ "message.no-results-found": [ { "type": 0, - "value": "No results were found." + "value": "No results found." } ], "message.no-team-websites": [ diff --git a/public/intl/messages/es-ES.json b/public/intl/messages/es-ES.json index b01782de..43e10170 100644 --- a/public/intl/messages/es-ES.json +++ b/public/intl/messages/es-ES.json @@ -227,12 +227,24 @@ "value": "Datos" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Intervalo de fechas" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Más" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Propietario" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reiniciar estadísticas" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Miembro del equipo" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Admin. del equipo" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/es-MX.json b/public/intl/messages/es-MX.json index 0b42ba04..c238951f 100644 --- a/public/intl/messages/es-MX.json +++ b/public/intl/messages/es-MX.json @@ -227,12 +227,24 @@ "value": "Datos" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Intervalo de fechas" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Más" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Propietario" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reiniciar estadísticas" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Miembro de equipo" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Admin. del equipo" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/fa-IR.json b/public/intl/messages/fa-IR.json index 4c52ec60..757b5ae8 100644 --- a/public/intl/messages/fa-IR.json +++ b/public/intl/messages/fa-IR.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "محدوده‌ی تاریخ" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "بیشتر" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "ایجاد شده توسط" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "بازنشانی آمار" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/fi-FI.json b/public/intl/messages/fi-FI.json index 6cb32963..5fdf5b19 100644 --- a/public/intl/messages/fi-FI.json +++ b/public/intl/messages/fi-FI.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Ajanjakso" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Lisää" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Omistaja" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Nollaa tilastot" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/fo-FO.json b/public/intl/messages/fo-FO.json index 4473b999..3eb3f452 100644 --- a/public/intl/messages/fo-FO.json +++ b/public/intl/messages/fo-FO.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Vel dato" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Meira" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json index a72e35ed..326c99a4 100644 --- a/public/intl/messages/fr-FR.json +++ b/public/intl/messages/fr-FR.json @@ -227,12 +227,24 @@ "value": "Données" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Période" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "Plus" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "Propriétaire" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "Réinitialiser les statistiques" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Membre de l'équipe" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Propriétaire de l'équipe" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ga-ES.json b/public/intl/messages/ga-ES.json index 26dcf380..d086b57f 100644 --- a/public/intl/messages/ga-ES.json +++ b/public/intl/messages/ga-ES.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Rango temporal" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Máis" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Dona" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -735,6 +771,12 @@ "value": " in the box below to confirm." } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -831,12 +873,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/he-IL.json b/public/intl/messages/he-IL.json index e9d8425c..dc206268 100644 --- a/public/intl/messages/he-IL.json +++ b/public/intl/messages/he-IL.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "טווח תאריכים" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "עוד" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/hi-IN.json b/public/intl/messages/hi-IN.json index afb246e3..91f1f026 100644 --- a/public/intl/messages/hi-IN.json +++ b/public/intl/messages/hi-IN.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "तिथि सीमा" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "और" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/hr-HR.json b/public/intl/messages/hr-HR.json index 3e29f21e..cd8d4d38 100644 --- a/public/intl/messages/hr-HR.json +++ b/public/intl/messages/hr-HR.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Raspon datuma" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Više" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Vlasnik" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Resetirati web stranicu" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/hu-HU.json b/public/intl/messages/hu-HU.json index 2e6f7209..e39182b1 100644 --- a/public/intl/messages/hu-HU.json +++ b/public/intl/messages/hu-HU.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Időintervallum" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Bővebben" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/id-ID.json b/public/intl/messages/id-ID.json index aa76cb8b..97526840 100644 --- a/public/intl/messages/id-ID.json +++ b/public/intl/messages/id-ID.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Rentang tanggal" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "Lebih banyak" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "Pemilik" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "Atur ulang statistik" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/it-IT.json b/public/intl/messages/it-IT.json index 6d67165e..a93715d3 100644 --- a/public/intl/messages/it-IT.json +++ b/public/intl/messages/it-IT.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Periodo" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Dettagli" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Proprietario" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Resetta le statistiche" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ja-JP.json b/public/intl/messages/ja-JP.json index dccd27f2..bde2f3a9 100644 --- a/public/intl/messages/ja-JP.json +++ b/public/intl/messages/ja-JP.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "範囲指定" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "さらに表示" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -731,6 +767,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -827,12 +869,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/km-KH.json b/public/intl/messages/km-KH.json index a7a57c0a..1f7b82ca 100644 --- a/public/intl/messages/km-KH.json +++ b/public/intl/messages/km-KH.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "ចន្លោះកាលបរិច្ឆេទ" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "បន្ថែម" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "ម្ចាស់" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "កំណត់ស្ថិតិឡើងវិញ" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ko-KR.json b/public/intl/messages/ko-KR.json index 29d170c7..26413708 100644 --- a/public/intl/messages/ko-KR.json +++ b/public/intl/messages/ko-KR.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "날짜 범위" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "더 보기" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -731,6 +767,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -827,12 +869,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/lt-LT.json b/public/intl/messages/lt-LT.json index af7aa81f..21610b7b 100644 --- a/public/intl/messages/lt-LT.json +++ b/public/intl/messages/lt-LT.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Laikotarpis" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -666,6 +678,12 @@ "value": "Daugiau" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -702,6 +720,24 @@ "value": "Savininkas" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -832,6 +868,12 @@ "value": "Atstatyti statistikos duomenis" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -928,12 +970,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/mn-MN.json b/public/intl/messages/mn-MN.json index 1ffc8ad1..013e5c88 100644 --- a/public/intl/messages/mn-MN.json +++ b/public/intl/messages/mn-MN.json @@ -227,12 +227,24 @@ "value": "Өгөгдөл" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Хугацааны муж" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Цааш" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Эзэмшигч" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Тоон үзүүлэлтийг дахин эхлүүлэх" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Багийн гишүүн" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Багийн эзэмшигч" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ms-MY.json b/public/intl/messages/ms-MY.json index 4fb07f17..e022e122 100644 --- a/public/intl/messages/ms-MY.json +++ b/public/intl/messages/ms-MY.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Julat tarikh" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "Lebih banyak lagi" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/nb-NO.json b/public/intl/messages/nb-NO.json index ed27041a..82576ff8 100644 --- a/public/intl/messages/nb-NO.json +++ b/public/intl/messages/nb-NO.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Datointervall" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mer" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Eier" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Nullstill statistikk" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/nl-NL.json b/public/intl/messages/nl-NL.json index cdee1071..5ee25206 100644 --- a/public/intl/messages/nl-NL.json +++ b/public/intl/messages/nl-NL.json @@ -227,12 +227,24 @@ "value": "Gegevens" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Datumbereik" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Toon meer" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Eigenaar" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Statistieken opnieuw instellen" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Teamlid" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Teameigenaar" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/pl-PL.json b/public/intl/messages/pl-PL.json index 8772081f..6da1ff7a 100644 --- a/public/intl/messages/pl-PL.json +++ b/public/intl/messages/pl-PL.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Zakres dat" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Więcej" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Właściciel" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Zresetuj statystyki" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Członek zespołu" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Właściciel zespołu" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/pt-BR.json b/public/intl/messages/pt-BR.json index 80f014cd..ba508a50 100644 --- a/public/intl/messages/pt-BR.json +++ b/public/intl/messages/pt-BR.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Intervalo de datas" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mais" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Proprietário" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Redefinir estatísticas" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Membro" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Proprietário" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/pt-PT.json b/public/intl/messages/pt-PT.json index 80a26929..a6431fb3 100644 --- a/public/intl/messages/pt-PT.json +++ b/public/intl/messages/pt-PT.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Intervalo de datas" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mais" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Proprietário" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Repor estatísticas" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ro-RO.json b/public/intl/messages/ro-RO.json index d3055f7d..1438ab41 100644 --- a/public/intl/messages/ro-RO.json +++ b/public/intl/messages/ro-RO.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Interval de date" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mai mult" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Resetează statisticile pentru site" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ru-RU.json b/public/intl/messages/ru-RU.json index fdaeef7f..b3213e67 100644 --- a/public/intl/messages/ru-RU.json +++ b/public/intl/messages/ru-RU.json @@ -227,12 +227,24 @@ "value": "Данные" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Диапазон дат" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Больше" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Владелец" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Сбросить статистику" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Член команды" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Владелец команды" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/si-LK.json b/public/intl/messages/si-LK.json index c43f8bf3..f4e5bca2 100644 --- a/public/intl/messages/si-LK.json +++ b/public/intl/messages/si-LK.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "දින පරාසය" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "තවත්" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "හිමිකරු" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "සංඛ්යා ලේඛන නැවත සකසන්න" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/sk-SK.json b/public/intl/messages/sk-SK.json index d838c058..b7e2914a 100644 --- a/public/intl/messages/sk-SK.json +++ b/public/intl/messages/sk-SK.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Obdobie" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Viac" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/sl-SI.json b/public/intl/messages/sl-SI.json index 17a84881..a3af95cb 100644 --- a/public/intl/messages/sl-SI.json +++ b/public/intl/messages/sl-SI.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Časovni razpon" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Več" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/sv-SE.json b/public/intl/messages/sv-SE.json index 8013bc70..4a7f4130 100644 --- a/public/intl/messages/sv-SE.json +++ b/public/intl/messages/sv-SE.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Datumomfång" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Mer" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Ägare" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Återställ statistik" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team-medlem" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team-ägare" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ta-IN.json b/public/intl/messages/ta-IN.json index feaa5b16..90fb9ebf 100644 --- a/public/intl/messages/ta-IN.json +++ b/public/intl/messages/ta-IN.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "தேதி வரம்பு" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "மேலும்" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/th-TH.json b/public/intl/messages/th-TH.json index 407ddfd3..c30a9d61 100644 --- a/public/intl/messages/th-TH.json +++ b/public/intl/messages/th-TH.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "ตั้งแต่วันที่" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "เพิ่มเติม" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "เจ้าของ" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "รีเซตข้อมูลสถิติ" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/tr-TR.json b/public/intl/messages/tr-TR.json index 52f68030..138681ad 100644 --- a/public/intl/messages/tr-TR.json +++ b/public/intl/messages/tr-TR.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Tarih aralığı" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Detaylı göster" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Owner" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Reset statistics" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/uk-UA.json b/public/intl/messages/uk-UA.json index 6698e61c..bdc2d345 100644 --- a/public/intl/messages/uk-UA.json +++ b/public/intl/messages/uk-UA.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Діапазон дат" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "Більше" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "Власник" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "Скинути статистику сайту" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/ur-PK.json b/public/intl/messages/ur-PK.json index 317c7dc1..2005bc71 100644 --- a/public/intl/messages/ur-PK.json +++ b/public/intl/messages/ur-PK.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "تاریخ کی حد" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "مزید" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "مالک" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "اعدادوشمار کو دوبارہ ترتیب دیں" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/vi-VN.json b/public/intl/messages/vi-VN.json index 868f214a..9fe0dd4e 100644 --- a/public/intl/messages/vi-VN.json +++ b/public/intl/messages/vi-VN.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "Phạm vi ngày" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -553,6 +565,12 @@ "value": "Thêm" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -589,6 +607,24 @@ "value": "Chủ sở hữu" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -719,6 +755,12 @@ "value": "Tái thiết lập thống kê" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -815,12 +857,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json index 43f24574..666918f5 100644 --- a/public/intl/messages/zh-CN.json +++ b/public/intl/messages/zh-CN.json @@ -227,12 +227,24 @@ "value": "统计数据" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "时间段" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "更多" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "所有者" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -731,6 +767,12 @@ "value": "重置统计数据" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -827,12 +869,24 @@ "value": "团队成员" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "团队所有者" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, diff --git a/public/intl/messages/zh-TW.json b/public/intl/messages/zh-TW.json index e1b60217..43a1996d 100644 --- a/public/intl/messages/zh-TW.json +++ b/public/intl/messages/zh-TW.json @@ -227,12 +227,24 @@ "value": "Data" } ], + "label.date": [ + { + "type": 0, + "value": "Date" + } + ], "label.date-range": [ { "type": 0, "value": "多日" } ], + "label.day": [ + { + "type": 0, + "value": "Day" + } + ], "label.default-date-range": [ { "type": 0, @@ -561,6 +573,12 @@ "value": "更多" } ], + "label.my-websites": [ + { + "type": 0, + "value": "My websites" + } + ], "label.name": [ { "type": 0, @@ -597,6 +615,24 @@ "value": "擁有者" } ], + "label.page-of": [ + { + "type": 0, + "value": "Page " + }, + { + "type": 1, + "value": "current" + }, + { + "type": 0, + "value": " of " + }, + { + "type": 1, + "value": "total" + } + ], "label.page-views": [ { "type": 0, @@ -727,6 +763,12 @@ "value": "重置統計數據" } ], + "label.retention": [ + { + "type": 0, + "value": "Retention" + } + ], "label.role": [ { "type": 0, @@ -823,12 +865,24 @@ "value": "Team member" } ], + "label.team-name": [ + { + "type": 0, + "value": "Team name" + } + ], "label.team-owner": [ { "type": 0, "value": "Team owner" } ], + "label.team-websites": [ + { + "type": 0, + "value": "Team websites" + } + ], "label.teams": [ { "type": 0, From 69389ebcd5e73d12ca2f395be4d2d30fc8c42fba Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 17 Aug 2023 16:39:59 -0700 Subject: [PATCH 73/73] Add team reports. --- pages/api/reports/index.ts | 1 + queries/admin/report.ts | 6 +++--- queries/admin/team.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts index db83e6ed..762f297c 100644 --- a/pages/api/reports/index.ts +++ b/pages/api/reports/index.ts @@ -37,6 +37,7 @@ export default async ( page, filter, pageSize: +pageSize || null, + includeTeams: true, }); return ok(res, data); diff --git a/queries/admin/report.ts b/queries/admin/report.ts index 22f3c62b..a053ba92 100644 --- a/queries/admin/report.ts +++ b/queries/admin/report.ts @@ -1,7 +1,7 @@ import { Prisma, Report } from '@prisma/client'; import { REPORT_FILTER_TYPES } from 'lib/constants'; import prisma from 'lib/prisma'; -import { FilterResult, ReportSearchFilter, ReportSearchFilterType, SearchFilter } from 'lib/types'; +import { FilterResult, ReportSearchFilter } from 'lib/types'; export async function createReport(data: Prisma.ReportUncheckedCreateInput): Promise { return prisma.client.report.create({ data }); @@ -155,7 +155,7 @@ export async function getReports( export async function getReportsByUserId( userId: string, - filter: SearchFilter, + filter: ReportSearchFilter, ): Promise> { return getReports( { userId, ...filter }, @@ -174,7 +174,7 @@ export async function getReportsByUserId( export async function getReportsByWebsiteId( websiteId: string, - filter: SearchFilter, + filter: ReportSearchFilter, ): Promise> { return getReports({ websiteId, ...filter }); } diff --git a/queries/admin/team.ts b/queries/admin/team.ts index 79735fc7..284b218e 100644 --- a/queries/admin/team.ts +++ b/queries/admin/team.ts @@ -1,8 +1,8 @@ import { Prisma, Team } from '@prisma/client'; -import prisma from 'lib/prisma'; import { ROLES, TEAM_FILTER_TYPES } from 'lib/constants'; import { uuid } from 'lib/crypto'; -import { FilterResult, TeamSearchFilter, TeamSearchFilterType, SearchFilter } from 'lib/types'; +import prisma from 'lib/prisma'; +import { FilterResult, TeamSearchFilter } from 'lib/types'; export interface GetTeamOptions { includeTeamUser?: boolean; @@ -142,7 +142,7 @@ export async function getTeams( export async function getTeamsByUserId( userId: string, - filter?: SearchFilter, + filter?: TeamSearchFilter, ): Promise> { return getTeams( { userId, ...filter },